diff --git a/tests/templates.test.ts b/tests/templates.test.ts new file mode 100644 index 0000000..85bfb72 --- /dev/null +++ b/tests/templates.test.ts @@ -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'); + }); +}); diff --git a/worker/lib/templates.ts b/worker/lib/templates.ts new file mode 100644 index 0000000..981af87 --- /dev/null +++ b/worker/lib/templates.ts @@ -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 { + 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}"`); +}