mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
feat: resolve parameterized shortlink URLs
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveTemplateUrl, TemplateResolutionError } from '../worker/lib/templates';
|
||||
|
||||
function params(query = ''): URLSearchParams {
|
||||
return new URLSearchParams(query);
|
||||
}
|
||||
|
||||
describe('resolveTemplateUrl', () => {
|
||||
it('returns a URL without template placeholders unchanged', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/static/path?x=1#hash',
|
||||
query: params(),
|
||||
}),
|
||||
).toBe('https://example.com/static/path?x=1#hash');
|
||||
});
|
||||
|
||||
it('uses a default value when no override is provided', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://google.com/search?q={query,default=hello}',
|
||||
query: params(),
|
||||
}),
|
||||
).toBe('https://google.com/search?q=hello');
|
||||
});
|
||||
|
||||
it('uses pathParam for the first template variable', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://en.wikipedia.org/wiki/{topic,default=Python}',
|
||||
pathParam: 'Django',
|
||||
query: params(),
|
||||
}),
|
||||
).toBe('https://en.wikipedia.org/wiki/Django');
|
||||
});
|
||||
|
||||
it('lets matching query params override pathParam and defaults', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/search?q={query,default=hello}',
|
||||
pathParam: 'from-path',
|
||||
query: params('query=from query'),
|
||||
}),
|
||||
).toBe('https://example.com/search?q=from%20query');
|
||||
});
|
||||
|
||||
it('resolves multiple params from query/default values', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/{locale,default=en}/search?q={query}&page={page,default=1}',
|
||||
query: params('query=Django tips&page=2'),
|
||||
}),
|
||||
).toBe('https://example.com/en/search?q=Django%20tips&page=2');
|
||||
});
|
||||
|
||||
it('throws a typed error for a missing required param', () => {
|
||||
expect(() =>
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/search?q={query}',
|
||||
query: params(),
|
||||
}),
|
||||
).toThrow(TemplateResolutionError);
|
||||
expect(() =>
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/search?q={query}',
|
||||
query: params(),
|
||||
}),
|
||||
).toThrow('No value provided for template parameter "query"');
|
||||
});
|
||||
|
||||
it('throws a typed error for invalid parameter names', () => {
|
||||
expect(() =>
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/search?q={bad-name,default=hello}',
|
||||
query: params(),
|
||||
}),
|
||||
).toThrow(TemplateResolutionError);
|
||||
expect(() =>
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://example.com/search?q={bad-name,default=hello}',
|
||||
query: params(),
|
||||
}),
|
||||
).toThrow('Invalid template parameter name "bad-name"');
|
||||
});
|
||||
|
||||
it('preserves static Chinese text around params and encodes only substituted values', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://chatgpt.com/?q=翻译: {query,default=hello} 保证真实性',
|
||||
query: params('query=cool beans'),
|
||||
}),
|
||||
).toBe('https://chatgpt.com/?q=翻译: cool%20beans 保证真实性');
|
||||
});
|
||||
|
||||
it('splits defaults only on the first comma', () => {
|
||||
expect(
|
||||
resolveTemplateUrl({
|
||||
targetUrl: 'https://google.com/search?q={query,default=hello,world}',
|
||||
query: params(),
|
||||
}),
|
||||
).toBe('https://google.com/search?q=hello%2Cworld');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
export type TemplateInput = {
|
||||
targetUrl: string;
|
||||
pathParam?: string;
|
||||
query: URLSearchParams;
|
||||
};
|
||||
|
||||
export type TemplateParameter = {
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
raw: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export class TemplateResolutionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TemplateResolutionError';
|
||||
}
|
||||
}
|
||||
|
||||
const TEMPLATE_PATTERN = /\{([^{}]*)\}/g;
|
||||
const PARAMETER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const DEFAULT_PREFIX = 'default=';
|
||||
|
||||
export function parseTemplateParameters(targetUrl: string): TemplateParameter[] {
|
||||
return Array.from(targetUrl.matchAll(TEMPLATE_PATTERN), ([raw, content], index) => {
|
||||
const { name, defaultValue } = parseParameterContent(content, raw);
|
||||
|
||||
return { name, defaultValue, raw, index };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTemplateUrl(input: TemplateInput): string {
|
||||
const parameters = parseTemplateParameters(input.targetUrl);
|
||||
|
||||
if (parameters.length === 0) {
|
||||
return input.targetUrl;
|
||||
}
|
||||
|
||||
let parameterIndex = 0;
|
||||
|
||||
return input.targetUrl.replace(TEMPLATE_PATTERN, () => {
|
||||
const parameter = parameters[parameterIndex];
|
||||
parameterIndex += 1;
|
||||
|
||||
const value = resolveParameterValue(parameter, input);
|
||||
return encodeURIComponent(value);
|
||||
});
|
||||
}
|
||||
|
||||
function parseParameterContent(content: string, raw: string): Pick<TemplateParameter, 'name' | 'defaultValue'> {
|
||||
const commaIndex = content.indexOf(',');
|
||||
const rawName = commaIndex === -1 ? content : content.slice(0, commaIndex);
|
||||
const name = rawName.trim();
|
||||
|
||||
if (!PARAMETER_NAME_PATTERN.test(name)) {
|
||||
throw new TemplateResolutionError(`Invalid template parameter name "${name}" in placeholder ${raw}`);
|
||||
}
|
||||
|
||||
if (commaIndex === -1) {
|
||||
return { name };
|
||||
}
|
||||
|
||||
const defaultPart = content.slice(commaIndex + 1).trim();
|
||||
if (!defaultPart.startsWith(DEFAULT_PREFIX)) {
|
||||
throw new TemplateResolutionError(`Invalid default for template parameter "${name}" in placeholder ${raw}`);
|
||||
}
|
||||
|
||||
return { name, defaultValue: unquoteDefault(defaultPart.slice(DEFAULT_PREFIX.length).trim()) };
|
||||
}
|
||||
|
||||
function unquoteDefault(value: string): string {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveParameterValue(parameter: TemplateParameter, input: TemplateInput): string {
|
||||
const queryValue = input.query.get(parameter.name);
|
||||
if (queryValue !== null) {
|
||||
return queryValue;
|
||||
}
|
||||
|
||||
if (parameter.index === 0 && input.pathParam !== undefined) {
|
||||
return input.pathParam;
|
||||
}
|
||||
|
||||
if (parameter.defaultValue !== undefined) {
|
||||
return parameter.defaultValue;
|
||||
}
|
||||
|
||||
throw new TemplateResolutionError(`No value provided for template parameter "${parameter.name}"`);
|
||||
}
|
||||
Reference in New Issue
Block a user