diff --git a/__tests__/e2e/local-search/frontmatter-title.md b/__tests__/e2e/local-search/frontmatter-title.md new file mode 100644 index 00000000..0000a15b --- /dev/null +++ b/__tests__/e2e/local-search/frontmatter-title.md @@ -0,0 +1,7 @@ +--- +title: Frontmatter Title Resolved +--- + +# {{ $frontmatter.title }} + +This page uses a frontmatter title expression. diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index fe507e5b..8248b46a 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -16,7 +16,7 @@ describe('local search', () => { }) try { - await page.locator('.VPNavBarSearchButton').click() + await openSearch() const loading = page.locator('.search-loading') const results = page.locator('.results') @@ -49,22 +49,10 @@ describe('local search', () => { ) test('exclude content from search results', async () => { - await page.locator('.VPNavBarSearchButton').click() - - const input = await page.waitForSelector('input#localsearch-input') - await input.type('local') + await searchFor('local') + await waitForSearchResults({ text: 'Local search included', count: 1 }) const searchResults = page.locator('#localsearch-list') - await page.waitForFunction(() => { - const options = [ - ...document.querySelectorAll('#localsearch-list li[role=option]') - ] - - return ( - options.length === 1 && - options[0].textContent?.includes('Local search included') - ) - }) expect(await searchResults.locator('li[role=option]').count()).toBe(1) @@ -83,26 +71,28 @@ describe('local search', () => { ).toBe(0) }) - test('custom tokenize function reaches the client', async () => { - await page.locator('.VPNavBarSearchButton').click() + test('resolves $frontmatter expressions in search results', async () => { + await searchFor('Frontmatter Title Resolved') + await waitForSearchResults({ text: 'Frontmatter Title Resolved' }) - const input = await page.waitForSelector('input#localsearch-input') + const searchResults = page.locator('#localsearch-list') + + expect( + await searchResults + .filter({ hasText: 'Frontmatter Title Resolved' }) + .count() + ).toBe(1) + expect( + await searchResults.filter({ hasText: '$frontmatter.title' }).count() + ).toBe(0) + }) + test('custom tokenize function reaches the client', async () => { // '#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') - ) - }) + const input = await searchFor('#hash-probe') + await waitForSearchResults({ text: 'Local search included', count: 1 }) // a fragment of a kept-whole token must not match anything await input.fill('linked-words') @@ -118,8 +108,7 @@ describe('local search', () => { ]) { await page.setViewportSize({ width, height: 600 }) await goto('/') - await page.locator('.VPNavBarSearchButton').click() - await page.waitForSelector('input#localsearch-input') + await openSearch() expect(await page.locator('.VPNavBarHamburger').isVisible()).toBe( !isDesktop @@ -135,17 +124,9 @@ describe('local search', () => { test('navigate results with macOS Ctrl shortcuts', async () => { await page.evaluate(() => document.documentElement.classList.add('mac')) - await page.locator('.VPNavBarSearchButton').click() - const input = await page.waitForSelector('input#localsearch-input') - await input.type('lorem') - - await page.waitForFunction(() => { - return ( - document.querySelectorAll('#localsearch-list li[role=option]').length > - 1 - ) - }) + const input = await searchFor('lorem') + await waitForSearchResults({ minCount: 2 }) expect(await input.getAttribute('aria-activedescendant')).toBe( 'localsearch-item-0' @@ -163,6 +144,43 @@ describe('local search', () => { }) }) +async function openSearch() { + await page.locator('.VPNavBarSearchButton').click() + return page.waitForSelector('input#localsearch-input') +} + +// fills the query in one step, so exactly one search runs and the result +// list settles into the state for this query and nothing else +async function searchFor(query: string) { + const input = await openSearch() + await input.fill(query) + return input +} + +// waits until the result list matches, so assertions never run against the +// results of an earlier query +function waitForSearchResults(condition: { + /** some result must contain this text */ + text?: string + /** exactly this many results */ + count?: number + /** at least this many results */ + minCount?: number +}) { + return page.waitForFunction(({ text, count, minCount }) => { + const options = [ + ...document.querySelectorAll('#localsearch-list li[role=option]') + ] + + return ( + (count === undefined || options.length === count) && + (minCount === undefined || options.length >= minCount) && + (text === undefined || + options.some((option) => option.textContent?.includes(text))) + ) + }, condition) +} + function pressMacCtrl(key: string) { return page.evaluate((key) => { window.dispatchEvent( diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts index 5db12abd..af5d2fef 100644 --- a/__tests__/unit/node/markdown/markdown.test.ts +++ b/__tests__/unit/node/markdown/markdown.test.ts @@ -42,6 +42,16 @@ describe('node/markdown/markdown', () => { expect(await render(':tada:', { emoji: false })).toContain(':tada:') }) + test('eagerFrontmatterInterpolation', async () => { + const src = '---\ntitle: Hello\n---\n\n{{ $frontmatter.title }}' + expect(await render(src)).toContain('
Hello
') + + const disabled = await render(src, { + eagerFrontmatterInterpolation: false + }) + expect(disabled).toContain('{{ $frontmatter.title }}
') + }) + test('tasklist', async () => { const src = '- [ ] todo' expect(await render(src)).toContain(' code, ...options }) +} + +async function render(src: string, env: RecordA <b>& B / 2 / false
') + }) + + test('resolves bracket paths and dates', async () => { + expect(await renderBody("{{ $frontmatter['k-y'] }}")).toBe('dashed
') + expect(await renderBody('{{ $frontmatter["k-y"] }}')).toBe('dashed
') + expect(await renderBody('{{ $frontmatter.list[1] }}')).toBe('b
') + expect(await renderBody('{{ $frontmatter.list.length }}')).toBe('2
') + // dates are normalized the same way the `__pageData` JSON round-trip + // normalizes them for the runtime + expect(await renderBody('{{ $frontmatter.date }}')).toBe( + '2024-01-18T00:00:00.000Z
' + ) + }) + + test('escapes values so they render as this exact text', async () => { + expect(await renderBody('{{ $frontmatter.html }}')).toBe( + '<b>& {{ hi }}</b>
' + ) + // a value containing mustaches must not be interpolated again by Vue + expect(await renderBody('{{ $frontmatter.mustache }}')).toBe( + '{{ x }}
' + ) + expect( + await renderBody( + '© {{ $frontmatter.title }} / {{ $frontmatter.no }}' + ) + ).toBe('© Hello World / {{ $frontmatter.no }}
') + }) + + test('leaves everything else to Vue', async () => { + const expressions = [ + '{{ $frontmatter.missing }}', // key not in frontmatter + '{{ $frontmatter.title.length }}', // path through a non-object + '{{ $frontmatter.nothing.x }}', + '{{ $frontmatter.nothing }}', // renders '' but may be transformed later + '{{ $frontmatter }}', + '{{ $frontmatter.nested }}', // objects are for Vue's display formatting + '{{ $frontmatter.list }}', + '{{ $frontmatter.spaced }}', // double space would be condensed + '{{ $frontmatter.multiline }}', + '{{ $frontmatter.list[01] }}', + '{{ $frontmatter.title.toUpperCase() }}', + '{{ $frontmatter[title] }}', + '{{ $frontmatterX }}', + '{{ $params.id }}', + '{{ frontmatter.title }}' + ] + const html = await renderBody(expressions.join('\n\n')) + for (const expression of expressions) { + expect(html).toContain(`${expression}
`) + } + }) + + test('skips code and v-pre', async () => { + const html = await render(`\ +--- +title: Hi +--- + +\`{{ $frontmatter.title }}\` + +\`\`\`js +{{ $frontmatter.title }} +\`\`\` + +::: v-pre +{{ $frontmatter.title }} +::: + +{{ $frontmatter.title }} {{ $frontmatter.title }} +`) + expect(html.match(/\{\{ \$frontmatter\.title \}\}/g)).toHaveLength(4) + expect(html).toContain(' Hi') + }) + + test('skips v-pre scopes from attrs', async () => { + const html = await renderBody( + '**{{ $frontmatter.title }}**{v-pre} {{ $frontmatter.title }}' + ) + expect(html).toContain( + '{{ $frontmatter.title }} Hello World' + ) + }) + + test('stops at v-pre html blocks', async () => { + const html = await renderBody( + '{{ $frontmatter.title }}\n\nHello World
') + expect(html).toContain('{{ $frontmatter.title }}
') + }) + + test('feeds the resolved text to anchors and the page title', async () => { + const env: Record{{ $frontmatter.title }}
' + ) + }) + + describe('equivalence with runtime interpolation', () => { + async function ssr(html: string, $frontmatter: unknown) { + const app = createSSRApp({ template: `