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..463dd87a 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -83,6 +83,28 @@ describe('local search', () => { ).toBe(0) }) + test('resolves $frontmatter expressions in search results', async () => { + await page.locator('.VPNavBarSearchButton').click() + + const input = await page.waitForSelector('input#localsearch-input') + await input.type('Frontmatter Title Resolved') + + const searchResults = page.locator('#localsearch-list') + await page.waitForFunction(() => { + return document.querySelectorAll('#localsearch-list li[role=option]') + .length + }) + + 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 () => { await page.locator('.VPNavBarSearchButton').click() diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts index 5db12abd..76063dac 100644 --- a/__tests__/unit/node/markdown/markdown.test.ts +++ b/__tests__/unit/node/markdown/markdown.test.ts @@ -42,6 +42,14 @@ describe('node/markdown/markdown', () => { expect(await render(':tada:', { emoji: false })).toContain(':tada:') }) + test('frontmatterExpressions', async () => { + const src = '---\ntitle: Hello\n---\n\n{{ $frontmatter.title }}' + expect(await render(src)).toContain('
Hello
') + + const disabled = await render(src, { frontmatterExpressions: 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: `