diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index acd74892..2a899efc 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -215,6 +215,12 @@ export default defineConfig({ search: { provider: 'local', options: { + miniSearch: { + options: { + tokenize: (text) => + text.split(/[\n\r\p{Z}\p{Terminal_Punctuation}]+/u) + } + }, async _render(src, env, md) { const html = await md.renderAsync(src, env) if (env.frontmatter?.search === false) return '' diff --git a/__tests__/e2e/local-search/index.md b/__tests__/e2e/local-search/index.md index 08d8b31a..04129895 100644 --- a/__tests__/e2e/local-search/index.md +++ b/__tests__/e2e/local-search/index.md @@ -1 +1,3 @@ -# Local search included \ No newline at end of file +# Local search included + +The custom tokenizer keeps #hash-probe and hyphen-linked-words whole. diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index 07ec1926..fe507e5b 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -83,6 +83,33 @@ describe('local search', () => { ).toBe(0) }) + test('custom tokenize function reaches the client', async () => { + await page.locator('.VPNavBarSearchButton').click() + + const input = await page.waitForSelector('input#localsearch-input') + + // '#hash-probe' survives as one token only under the custom tokenizer — + // MiniSearch's default one would degrade the query to 'hash'/'probe' + // and miss the index built with the custom tokenizer + await input.type('#hash-probe') + + await page.waitForFunction(() => { + const options = [ + ...document.querySelectorAll('#localsearch-list li[role=option]') + ] + + return ( + options.length === 1 && + options[0].textContent?.includes('Local search included') + ) + }) + + // a fragment of a kept-whole token must not match anything + await input.fill('linked-words') + + await page.waitForSelector('.no-results') + }) + test('uses the same desktop breakpoint as the nav bar', async () => { try { for (const { width, isDesktop } of [ diff --git a/__tests__/unit/node/utils/fnSerialize.test.ts b/__tests__/unit/node/utils/fnSerialize.test.ts new file mode 100644 index 00000000..04db1bca --- /dev/null +++ b/__tests__/unit/node/utils/fnSerialize.test.ts @@ -0,0 +1,98 @@ +import { + deserializeFunctions, + serializeFunctions +} from 'node/utils/fnSerialize' + +// runs the exact code shape that plugin.ts / build.ts emit into the site-data +// module and the metadata script — the revived value must come back without +// the deserializer ever compiling a string (new Function is used here only to +// stand in for the browser executing the emitted file) +function emitAndRevive(data: any): any { + const fns: string[] = [] + const serialized = serializeFunctions(data, fns) + const script = `${deserializeFunctions};return deserializeFunctions(JSON.parse(${JSON.stringify( + JSON.stringify(serialized) + )}),[${fns.join(',')}])` + return new Function(script)() +} + +describe('node/utils/fnSerialize', () => { + test('emitted deserializer does not rely on unsafe-eval', () => { + expect(deserializeFunctions).not.toContain('new Function') + expect(deserializeFunctions).not.toContain('eval') + }) + + test('serializes functions as indexed markers', () => { + const fns: string[] = [] + const serialized = serializeFunctions( + { a: (x: number) => x, b: { c: (x: number) => x * 2 } }, + fns + ) + expect(serialized).toEqual({ a: '_vp-fn_0', b: { c: '_vp-fn_1' } }) + expect(fns).toHaveLength(2) + }) + + test('revives functions nested in objects and arrays', () => { + const data = { + search: { + options: { + miniSearch: { + options: { + tokenize: (text: string) => text.split(/\s+/) + }, + searchOptions: { + boostDocument: (id: string) => (id === 'index.md' ? 2 : 1) + } + } + } + }, + list: [(n: number) => n + 1, 'plain', 42] + } + + const revived = emitAndRevive(data) + + expect(revived.search.options.miniSearch.options.tokenize('a b')).toEqual([ + 'a', + 'b' + ]) + expect( + revived.search.options.miniSearch.searchOptions.boostDocument('index.md') + ).toBe(2) + expect(revived.list[0](1)).toBe(2) + expect(revived.list[1]).toBe('plain') + expect(revived.list[2]).toBe(42) + }) + + test('revives method shorthand and async functions', () => { + const data = { + tokenize(text: string) { + return text.toUpperCase() + }, + async extractField(doc: { id: string }) { + return doc.id + } + } + + const revived = emitAndRevive(data) + + expect(revived.tokenize('abc')).toBe('ABC') + return expect(revived.extractField({ id: 'x' })).resolves.toBe('x') + }) + + test('drops underscore-prefixed keys', () => { + const revived = emitAndRevive({ _render: () => '', keep: 1 }) + expect(revived).toEqual({ keep: 1 }) + }) + + test('leaves data strings resembling markers untouched', () => { + const data = { + fn: (x: number) => x, + note: '_vp-fn_alert(1)' + } + + const revived = emitAndRevive(data) + + expect(revived.fn(1)).toBe(1) + expect(revived.note).toBe('_vp-fn_alert(1)') + }) +}) diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 12fa1abb..5bd5f5a1 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -267,13 +267,14 @@ async function generateMetadataScript( // It's also embedded as a string and JSON.parsed from the client because // it's faster than embedding as JS object literal. const hashMapString = JSON.stringify(JSON.stringify(pageToHashMap)) + const fns: string[] = [] const siteDataString = JSON.stringify( - JSON.stringify(serializeFunctions({ ...config.site, head: [] })) + JSON.stringify(serializeFunctions({ ...config.site, head: [] }, fns)) ) const metadataContent = `window.__VP_HASH_MAP__=JSON.parse(${hashMapString});${ - siteDataString.includes('_vp-fn_') - ? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}));` + fns.length + ? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}),[${fns.join(',')}]);` : `window.__VP_SITE_DATA__=JSON.parse(${siteDataString});` }` diff --git a/src/node/plugin.ts b/src/node/plugin.ts index ec2ffd9d..42f1f9d4 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -206,8 +206,13 @@ export async function createVitePressPlugin( return `export default window.__VP_SITE_DATA__` } } - data = serializeFunctions(data) - return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))` + const fns: string[] = [] + const dataStr = JSON.stringify( + JSON.stringify(serializeFunctions(data, fns)) + ) + return fns.length + ? `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${dataStr}),[${fns.join(',')}])` + : `export default JSON.parse(${dataStr})` } }, diff --git a/src/node/utils/fnSerialize.ts b/src/node/utils/fnSerialize.ts index da4f3680..74a1d5f3 100644 --- a/src/node/utils/fnSerialize.ts +++ b/src/node/utils/fnSerialize.ts @@ -1,30 +1,37 @@ /* -export function deserializeFunctions(value: any): any { +export function deserializeFunctions(value: any, fns: any[]): any { if (Array.isArray(value)) { - return value.map(deserializeFunctions) + return value.map((v) => deserializeFunctions(v, fns)) } else if (typeof value === 'object' && value !== null) { return Object.keys(value).reduce((acc, key) => { - acc[key] = deserializeFunctions(value[key]) + acc[key] = deserializeFunctions(value[key], fns) return acc }, {} as any) } else if (typeof value === 'string' && value.startsWith('_vp-fn_')) { - return new Function(`return ${value.slice(7)}`)() + return fns[+value.slice(7)] ?? value } else { return value } } */ +// functions are emitted as plain code next to this and only looked up here by +// index, so no `new Function` is needed and strict CSP (no `unsafe-eval`) +// stays intact (#3685) export const deserializeFunctions = - 'function deserializeFunctions(r){return Array.isArray(r)?r.map(deserializeFunctions):typeof r=="object"&&r!==null?Object.keys(r).reduce((t,n)=>(t[n]=deserializeFunctions(r[n]),t),{}):typeof r=="string"&&r.startsWith("_vp-fn_")?new Function(`return ${r.slice(7)}`)():r}' + 'function deserializeFunctions(r,e){return Array.isArray(r)?r.map(t=>deserializeFunctions(t,e)):typeof r=="object"&&r!==null?Object.keys(r).reduce((t,n)=>(t[n]=deserializeFunctions(r[n],e),t),{}):typeof r=="string"&&r.startsWith("_vp-fn_")?e[+r.slice(7)]??r:r}' -export function serializeFunctions(value: any, key?: string): any { +export function serializeFunctions( + value: any, + fns: string[], + key?: string +): any { if (Array.isArray(value)) { - return value.map((v) => serializeFunctions(v)) + return value.map((v) => serializeFunctions(v, fns)) } else if (typeof value === 'object' && value !== null) { return Object.keys(value).reduce((acc, key) => { if (key[0] === '_') return acc - acc[key] = serializeFunctions(value[key], key) + acc[key] = serializeFunctions(value[key], fns, key) return acc }, {} as any) } else if (typeof value === 'function') { @@ -35,7 +42,7 @@ export function serializeFunctions(value: any, key?: string): any { ) { serialized = serialized.replace(key, 'function') } - return `_vp-fn_${serialized}` + return `_vp-fn_${fns.push(`(${serialized})`) - 1}` } else { return value }