diff --git a/packages/md/src/compile.ts b/packages/md/src/compile.ts index 49029a26d..6200f31e6 100644 --- a/packages/md/src/compile.ts +++ b/packages/md/src/compile.ts @@ -6,7 +6,6 @@ import { transformerNotationFocus, transformerNotationErrorLevel, } from '@shikijs/transformers'; -import matter from 'gray-matter'; import MarkdownExit from 'markdown-exit'; import type { RuleCore, Token } from 'markdown-exit'; import anchor from 'markdown-it-anchor'; @@ -14,7 +13,7 @@ import attrs from 'markdown-it-attrs'; import { full as emoji } from 'markdown-it-emoji'; import footnote from 'markdown-it-footnote'; import { createHighlighter, bundledLanguages, isSpecialLang, type BundledLanguage } from 'shiki'; -import { extractHeadings } from './metadata.ts'; +import { extractHeadings, parseFrontmatter } from './metadata.ts'; import { containerPlugin } from './plugins/containers.ts'; import { gitHubAlertsPlugin } from './plugins/github-alerts.ts'; import { imagePlugin } from './plugins/image.ts'; @@ -139,7 +138,7 @@ async function createCompiler(options?: CompileOptions): Promise { } export async function compile(source: string, options?: CompileOptions): Promise { - const { data: frontmatter, content } = matter(source); + const { frontmatter, content } = parseFrontmatter(source); const compiler = await getCompiler(options); const env: { codeCopy?: boolean } = {}; diff --git a/packages/md/src/metadata.ts b/packages/md/src/metadata.ts index 8b006b43f..59a6a18bd 100644 --- a/packages/md/src/metadata.ts +++ b/packages/md/src/metadata.ts @@ -51,8 +51,23 @@ export type MetadataResult = Readonly<{ title: string; }>; +export function parseFrontmatter( + source: string, +): Readonly<{ frontmatter: Record; content: string }> { + try { + // Passing options skips gray-matter's cache. It stores a source before parsing it, + // so without them a second read of invalid frontmatter returns empty data and + // leaves the fences in the content instead of throwing again. + const { data: frontmatter, content } = matter(source, {}); + return { frontmatter, content }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid frontmatter: ${message}`, { cause: error }); + } +} + export function extractMetadata(source: string): MetadataResult { - const { data: frontmatter, content } = matter(source); + const { frontmatter, content } = parseFrontmatter(source); const compiler = getMetadataCompiler(); const env: Record = {}; diff --git a/packages/md/src/plugin.ts b/packages/md/src/plugin.ts index 30699d528..4cf797b1d 100644 --- a/packages/md/src/plugin.ts +++ b/packages/md/src/plugin.ts @@ -2,7 +2,13 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'pathslash'; import { glob } from 'tinyglobby'; -import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; +import { + normalizePath, + type Logger, + type Plugin, + type ResolvedConfig, + type ViteDevServer, +} from 'vite'; import { pageFileEnvironment, resolvePagesDir } from 'void/pages'; import type { CompileOptions } from './compile.ts'; import { emitReactComponent } from './emit-react.ts'; @@ -82,11 +88,14 @@ export function voidMarkdown(options?: MarkdownOptions): Array { let mdPages: ReadonlyArray = []; let root = ''; let pagesDir = ''; + let logger: Logger | undefined; // 'dev' on the dev server, 'prod' for builds and preview. let mdEnvironment: 'dev' | 'prod' = 'prod'; const svelteModules = new Map(); const solidModules = new Map(); const clientScripts = new Map(); + // The last error logged per page, so that rescans don't repeat it. + const loggedPageErrors = new Map(); function createVirtualModuleId( prefix: string, @@ -99,6 +108,13 @@ export function voidMarkdown(options?: MarkdownOptions): Array { return `${prefix}${encoded}.${hash}.${extension}`; } + // The name has to be in the message: Vite's error metadata does not cross the + // worker boundary, so the dev error page and the worker log would not show it. + function createPageError(file: string, error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(`void-md: ${relative(root, file)}: ${message}`, { cause: error }); + } + async function scanMdPages(): Promise> { if (!existsSync(pagesDir)) { return []; @@ -106,27 +122,51 @@ export function voidMarkdown(options?: MarkdownOptions): Array { const liveFiles = await listMdFiles(pagesDir, mdEnvironment); const pages = await Promise.all( - liveFiles.map(async (file) => { - const content = readFileSync(join(pagesDir, file), 'utf-8'); - const { body, clientCode } = extractScript(content); - const result = extractMetadata(body); - - // componentId keeps the suffix so it matches the page id from scanPages. - const componentId = stripPageSuffix(file) || 'index'; + liveFiles.map(async (file): Promise => { + const path = '/' + mdRouteName(file, pageFileEnvironment(file)); + const filePath = join(pagesDir, file); + try { + const content = readFileSync(filePath, 'utf-8'); + const { body, clientCode } = extractScript(content); + const result = extractMetadata(body); + + // componentId keeps the suffix so it matches the page id from scanPages. + const componentId = stripPageSuffix(file) || 'index'; + + if (clientCode) { + clientScripts.set(componentId, clientCode); + } + loggedPageErrors.delete(file); - if (clientCode) { - clientScripts.set(componentId, clientCode); + return { + path, + title: result.title, + frontmatter: result.frontmatter, + headings: result.headings, + }; + } catch (error) { + const pageError = createPageError(filePath, error); + // A build must not ship without the page. + if (mdEnvironment === 'prod') { + throw pageError; + } + // Removed since the glob, as when switching branches. Not an error to report. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + if (loggedPageErrors.get(file) !== pageError.message) { + loggedPageErrors.set(file, pageError.message); + logger?.error(pageError.message, { error: pageError, timestamp: true }); + } + // A page being edited is often invalid for a moment. Keep its last good + // entry so that lists built from the pages don't change on every save. + return mdPages.find((page) => page.path === path); } - - return { - path: '/' + mdRouteName(file, pageFileEnvironment(file)), - title: result.title, - frontmatter: result.frontmatter, - headings: result.headings, - }; }), ); - return pages.sort((leftPage, rightPage) => leftPage.path.localeCompare(rightPage.path)); + return pages + .filter((page) => page !== undefined) + .sort((leftPage, rightPage) => leftPage.path.localeCompare(rightPage.path)); } return [ @@ -168,6 +208,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array { configResolved(config: ResolvedConfig) { root = config.root; + logger = config.logger; pagesDir = resolvePagesDir(root); for (const plugin of config.plugins) { const match = ADAPTER_PLUGIN_NAMES[plugin.name]; @@ -198,7 +239,16 @@ export function voidMarkdown(options?: MarkdownOptions): Array { if (!file.endsWith('.md') || !isInPagesDir(normalizePath(file))) { return; } - mdPages = await scanMdPages(); + try { + mdPages = await scanMdPages(); + } catch (error) { + // Keep the last good scan; the next change rescans. + server.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return; + } const mod = server.moduleGraph.getModuleById('\0void-md-pages'); if (mod) { server.moduleGraph.invalidateModule(mod); @@ -335,7 +385,9 @@ export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; }); const { compile } = await import('./compile.ts'); - const result = await compile(body, { shiki: options?.shiki }); + const result = await compile(body, { shiki: options?.shiki }).catch((error: unknown) => { + throw createPageError(_id, error); + }); if (adapter === 'vue') { return { code: emitVueComponent(result, islands), map: null }; diff --git a/packages/md/test/integration/invalid-frontmatter.test.ts b/packages/md/test/integration/invalid-frontmatter.test.ts new file mode 100644 index 000000000..175760137 --- /dev/null +++ b/packages/md/test/integration/invalid-frontmatter.test.ts @@ -0,0 +1,86 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import spawn from 'cross-spawn'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, +} from '../../../../test/integration-helpers.ts'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const INVALID = '---\ntitle\ndescription: Missing colon above\n---\n\n# Invalid\n'; +// The page scan reads the layout on its own, so these have to get past that too. +const INVALID_LAYOUTS: Record = { + 'mixed-test': '---\ntitle: Invalid layout\nlayout: [unclosed\n---\n\n# Invalid\n', + 'client-script-test': '---\ntitle: Invalid layout\nlayout: !unknown missing\n---\n\n# Invalid\n', +}; + +describe('invalid frontmatter on the dev server', () => { + // A copy, since other suites build this playground while a page here is invalid. + let playground: IsolatedPlayground; + let child: ReturnType | undefined; + let base: string; + let validAtStart: string; + + const pageFile = (name: string) => join(playground.root, 'pages/posts', `${name}.md`); + const pageStatus = async (name: string) => (await fetch(`${base}/posts/${name}`)).status; + const waitForPageStatus = (name: string, status: number) => + expect.poll(() => pageStatus(name), { timeout: 15_000, interval: 200 }).toBe(status); + + beforeAll(async () => { + playground = createIsolatedPlayground(ROOT, 'playground/markdown-blog'); + validAtStart = readFileSync(pageFile('getting-started'), 'utf8'); + writeFileSync(pageFile('getting-started'), INVALID); + for (const [name, source] of Object.entries(INVALID_LAYOUTS)) { + writeFileSync(pageFile(name), source); + } + + const server = await startServer((port) => { + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }); + return child; + }, 30_000); + base = server.base; + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + playground?.cleanup(); + }); + + it('starts with an invalid page, names it on that page and serves the others', async () => { + for (const name of ['getting-started', ...Object.keys(INVALID_LAYOUTS)]) { + const response = await fetch(`${base}/posts/${name}`); + expect(response.status).toBe(500); + const html = await response.text(); + expect(html).toContain(`pages/posts/${name}.md`); + expect(html).toContain('Invalid frontmatter'); + } + + expect(await pageStatus('code-examples')).toBe(200); + + writeFileSync(pageFile('getting-started'), validAtStart); + await waitForPageStatus('getting-started', 200); + }); + + // Not the page above: the file watcher can miss a second save right after the first. + it('survives saving a page with invalid frontmatter and recovers once it is fixed', async () => { + const valid = readFileSync(pageFile('code-examples'), 'utf8'); + expect(await pageStatus('code-examples')).toBe(200); + + writeFileSync(pageFile('code-examples'), INVALID); + await waitForPageStatus('code-examples', 500); + // Repeated requests keep failing rather than rendering the fences as content. + expect(await pageStatus('code-examples')).toBe(500); + + writeFileSync(pageFile('code-examples'), valid); + await waitForPageStatus('code-examples', 200); + }); +}); diff --git a/packages/md/test/unit/metadata.test.ts b/packages/md/test/unit/metadata.test.ts index f3e272d01..0935d7898 100644 --- a/packages/md/test/unit/metadata.test.ts +++ b/packages/md/test/unit/metadata.test.ts @@ -13,6 +13,16 @@ describe('extractMetadata', () => { ]); }); + it('keeps rejecting invalid frontmatter after the first read', async () => { + const source = '---\ntitle\ndescription: Missing colon above\n---\n\n# Heading\n'; + + // Dev reads a page for navigation metadata before rendering it. Every + // read must report the error instead of treating the YAML fences as content. + expect(() => extractMetadata(source)).toThrow('Invalid frontmatter'); + expect(() => extractMetadata(source)).toThrow('Invalid frontmatter'); + await expect(compile(source)).rejects.toThrow('Invalid frontmatter'); + }); + it('strips footnote refs from heading text/slug to match rendered anchor ids', async () => { const source = '## API[^1]\n\nBody[^1].\n\n[^1]: note'; diff --git a/packages/md/test/unit/plugin-invalid-pages.test.ts b/packages/md/test/unit/plugin-invalid-pages.test.ts new file mode 100644 index 000000000..98029d18f --- /dev/null +++ b/packages/md/test/unit/plugin-invalid-pages.test.ts @@ -0,0 +1,264 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as tinyglobby from 'tinyglobby'; +import type { ResolvedConfig, ViteDevServer } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { scanPages } from 'void/pages'; +import { extractMetadata } from '../../src/metadata.ts'; +import { voidMarkdown } from '../../src/plugin.ts'; + +vi.mock('tinyglobby', { spy: true }); + +const VALID = '---\ntitle: Good title\n---\n\n# Hello\n'; +const INVALID = '---\ntitle\ndescription: Missing colon above\n---\n\n# Hello\n'; +const OTHER_INVALID = '---\ntitle: [unclosed\n---\n\n# Hello\n'; + +type WatcherListener = (file: string) => Promise; +type MdPageEntry = { path: string; title: string }; + +function getHookHandler(hook: T | { handler: T } | undefined): T { + if (typeof hook === 'function') { + return hook; + } + if (hook && typeof hook === 'object' && 'handler' in hook) { + return hook.handler; + } + throw new Error('Expected Vite hook handler.'); +} + +const tempDirs: Array = []; + +function createApp(pages: Record) { + const root = mkdtempSync(join(tmpdir(), 'void-md-invalid-pages-')); + tempDirs.push(root); + const pagesDir = join(root, 'pages'); + mkdirSync(pagesDir); + for (const [file, content] of Object.entries(pages)) { + writeFileSync(join(pagesDir, file), content); + } + return { root, pagesDir }; +} + +async function startPlugin(root: string, command: 'serve' | 'build') { + const [plugin] = voidMarkdown(); + const logError = vi.fn<(message: string) => void>(); + const logger = { error: logError } as unknown as ResolvedConfig['logger']; + + const config = getHookHandler(plugin.config) as ( + config: { root: string }, + env: { command: 'serve' | 'build' }, + ) => unknown; + const configResolved = getHookHandler(plugin.configResolved) as (config: ResolvedConfig) => void; + const buildStart = getHookHandler(plugin.buildStart) as () => Promise; + const load = getHookHandler(plugin.load) as (id: string) => string | undefined; + const configureServer = getHookHandler(plugin.configureServer) as (server: ViteDevServer) => void; + const transform = getHookHandler(plugin.transform) as ( + code: string, + id: string, + ) => Promise<{ code: string }>; + + await config({ root }, { command }); + configResolved({ + root, + plugins: [{ name: 'void-react:pages' }], + logger, + } as unknown as ResolvedConfig); + + return { + logError, + scan: () => buildStart.call({} as never), + transform: (code: string, id: string) => transform.call({} as never, code, id), + listPages(): Array { + const source = load('\0void-md-pages')!; + return JSON.parse(source.slice('export default '.length, -1)) as Array; + }, + /** Attach to a stub dev server and return the plugin's own watcher listener. */ + watchPages(): WatcherListener { + let listener: WatcherListener | undefined; + configureServer({ + config: { logger }, + moduleGraph: { getModuleById: () => undefined }, + watcher: { + on(event: string, callback: WatcherListener) { + if (event === 'change') { + listener = callback; + } + }, + }, + } as unknown as ViteDevServer); + return listener!; + }, + }; +} + +afterEach(() => { + vi.mocked(tinyglobby.glob).mockReset(); + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe('voidMarkdown invalid pages on the dev server', () => { + it('starts with an invalid page, leaving out only that page', async () => { + const { root } = createApp({ 'broken.md': INVALID, 'other.md': VALID }); + const plugin = await startPlugin(root, 'serve'); + + await plugin.scan(); + + expect(plugin.listPages().map((page) => page.path)).toEqual(['/other']); + expect(plugin.logError).toHaveBeenCalledOnce(); + expect(plugin.logError.mock.calls[0]?.[0]).toContain('pages/broken.md'); + expect(plugin.logError.mock.calls[0]?.[0]).toContain('Invalid frontmatter'); + }); + + it('survives a page saved with invalid frontmatter and keeps its last good entry', async () => { + const { root, pagesDir } = createApp({ 'index.md': VALID }); + const plugin = await startPlugin(root, 'serve'); + await plugin.scan(); + const onChange = plugin.watchPages(); + + writeFileSync(join(pagesDir, 'index.md'), INVALID); + // A rejection here is an unhandled rejection in Vite's watcher, which exits the process. + await expect(onChange(join(pagesDir, 'index.md'))).resolves.toBeUndefined(); + + expect(plugin.listPages()).toMatchObject([{ path: '/', title: 'Good title' }]); + expect(plugin.logError.mock.calls[0]?.[0]).toContain('pages/index.md'); + }); + + it('picks the page up again once its frontmatter is fixed', async () => { + const { root, pagesDir } = createApp({ 'index.md': INVALID }); + const plugin = await startPlugin(root, 'serve'); + await plugin.scan(); + expect(plugin.listPages()).toEqual([]); + const onChange = plugin.watchPages(); + + writeFileSync(join(pagesDir, 'index.md'), VALID); + await onChange(join(pagesDir, 'index.md')); + + expect(plugin.listPages()).toMatchObject([{ path: '/', title: 'Good title' }]); + }); + + it('logs a page error once per failure across rescans', async () => { + const { root, pagesDir } = createApp({ 'index.md': VALID, 'other.md': VALID }); + const plugin = await startPlugin(root, 'serve'); + await plugin.scan(); + const onChange = plugin.watchPages(); + const index = join(pagesDir, 'index.md'); + const other = join(pagesDir, 'other.md'); + + writeFileSync(index, INVALID); + await onChange(index); + // Saving an unrelated page rescans the invalid one too. + await onChange(other); + expect(plugin.logError).toHaveBeenCalledTimes(1); + + // A different mistake in the same page is news. + writeFileSync(index, OTHER_INVALID); + await onChange(index); + expect(plugin.logError).toHaveBeenCalledTimes(2); + + // So is the same mistake coming back after a fix. + writeFileSync(index, VALID); + await onChange(index); + writeFileSync(index, OTHER_INVALID); + await onChange(index); + expect(plugin.logError).toHaveBeenCalledTimes(3); + }); + + it('skips a page removed during the scan without reporting it', async () => { + const { root, pagesDir } = createApp({ 'index.md': VALID }); + const plugin = await startPlugin(root, 'serve'); + await plugin.scan(); + const onChange = plugin.watchPages(); + + // The glob result is stale by the time the file is read, as when switching branches. + vi.mocked(tinyglobby.glob).mockResolvedValueOnce(['index.md', 'removed.md']); + await expect(onChange(join(pagesDir, 'index.md'))).resolves.toBeUndefined(); + + expect(plugin.listPages().map((page) => page.path)).toEqual(['/']); + expect(plugin.logError).not.toHaveBeenCalled(); + }); + + it('keeps the last good pages when the scan itself fails', async () => { + const { root, pagesDir } = createApp({ 'index.md': VALID }); + const plugin = await startPlugin(root, 'serve'); + await plugin.scan(); + const onChange = plugin.watchPages(); + + vi.mocked(tinyglobby.glob).mockRejectedValueOnce(new Error('scan failed')); + await expect(onChange(join(pagesDir, 'index.md'))).resolves.toBeUndefined(); + + expect(plugin.listPages().map((page) => page.path)).toEqual(['/']); + expect(plugin.logError.mock.calls[0]?.[0]).toContain('scan failed'); + }); + + it('fails the invalid page itself, even after the scan has read the same source', async () => { + const { root, pagesDir } = createApp({ 'index.md': INVALID }); + const plugin = await startPlugin(root, 'serve'); + await plugin.scan(); + + const request = () => + plugin.transform(INVALID, join(pagesDir, 'index.md').replaceAll('\\', '/')); + + await expect(request()).rejects.toThrow(/pages\/index\.md.*Invalid frontmatter/); + await expect(request()).rejects.toThrow(/pages\/index\.md.*Invalid frontmatter/); + }); +}); + +describe('voidMarkdown invalid pages in a build', () => { + it('fails the build and names the page', async () => { + const { root } = createApp({ 'broken.md': INVALID, 'other.md': VALID }); + const plugin = await startPlugin(root, 'build'); + + await expect(plugin.scan()).rejects.toThrow(/pages\/broken\.md.*Invalid frontmatter/); + expect(plugin.logError).not.toHaveBeenCalled(); + }); + + it('fails the build when a page cannot be read', async () => { + const { root } = createApp({ 'index.md': VALID }); + const plugin = await startPlugin(root, 'build'); + + vi.mocked(tinyglobby.glob).mockResolvedValueOnce(['removed.md']); + + await expect(plugin.scan()).rejects.toThrow(/pages\/removed\.md.*ENOENT/); + }); +}); + +// The page scan applies the layout and fails on an unknown one, and this package renders the page. +// If they disagree, a page this package rejects can stop the dev server from starting, or a page it +// accepts can get the wrong layout. +describe('the page scan and voidMarkdown', () => { + it.each([ + ['a plain layout', 'layout: post'], + ['a layout from a merge key', 'base: &base {layout: post}\n<<: *base'], + ['tab indentation', 'seo:\n\ttitle: Tab indent\nlayout: post'], + ['a quoted value across lines', 'title: "Across\nlines"\nlayout: post'], + ['an empty item', 'tags: [,]\nlayout: post'], + ['an unclosed sequence', 'layout: [missing'], + ['an unclosed quote', 'layout: "missing'], + ['an invalid line', 'title\nlayout: missing'], + ['an unknown tag on the value', 'layout: !unknown missing'], + ['an unknown tag on another key', 'other: !unknown value\nlayout: missing'], + ['a core tag that does not fit the value', 'layout: !!int missing'], + ['a merge tag', 'other: !!merge value\nlayout: missing'], + ['a merge key that is not a mapping', '<<: value\nlayout: missing'], + ['an empty key', ': value\nlayout: missing'], + ['a control character', 'layout: mis\x01sing'], + ])('read the same layout from frontmatter with %s', async (_name, frontmatter) => { + const source = `---\n${frontmatter}\n---\n\n# Hello\n`; + const { root, pagesDir } = createApp({ 'page.md': source }); + mkdirSync(join(pagesDir, '_layouts')); + writeFileSync(join(pagesDir, '_layouts', 'post.vue'), ''); + + let renderedLayout: unknown; + try { + renderedLayout = extractMetadata(source).frontmatter.layout; + } catch { + // Rejected, so it has no layout to apply. + } + + const { pages } = await scanPages(root, { pagesDir }); + expect(pages.find((page) => page.componentId === 'page')?.layout).toBe(renderedLayout); + }); +}); diff --git a/packages/void/package.json b/packages/void/package.json index e64f41c72..45bc3fa8a 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -372,6 +372,7 @@ "drizzle-valibot": "^0.4.2", "drizzle-zod": "^0.8.3", "email-validator": "^2.0.4", + "gray-matter": "^4.0.3", "hono": "catalog:", "ignore": "catalog:", "jiti": "^2.7.0", diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index 53a8c6ab4..db80be4c8 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -375,9 +375,11 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis let layout: string | false | undefined; if (ext === '.md') { + // Imported here because most apps have no Markdown pages. + const { default: matter } = await import('gray-matter'); ({ metadata, layout } = deriveFromSource(join(pagesDir, file), 'page-markdown', (source) => ({ metadata: extractFrontmatterMeta(source), - layout: extractFrontmatterLayout(source), + layout: extractFrontmatterLayout(source, matter), }))); } else { layout = deriveFromSource(join(pagesDir, file), 'page-layout', (source) => @@ -582,19 +584,31 @@ function extractFrontmatterMeta( } /** Extract layout field from YAML frontmatter in markdown source. */ -function extractFrontmatterLayout(source: string): string | false | undefined { +function extractFrontmatterLayout( + source: string, + matter: typeof import('gray-matter'), +): string | false | undefined { const yaml = markdownFrontmatter(source); if (yaml === undefined) { return undefined; } - const value = frontmatterField(yaml, 'layout'); - if (!value) { + let frontmatter: { layout?: unknown }; + try { + // The parser @void/md uses, so that both agree on what a page's frontmatter says. + // Passing options skips gray-matter's cache of every source it has seen. + frontmatter = matter(`---\n${yaml}\n---`, {}).data; + } catch { + // It names no layout. @void/md reports the error on the page or fails the build. + return undefined; + } + const value = frontmatter.layout; + if (value === undefined || value === null || value === '') { return undefined; } - if (value === 'false') { + if (value === false || value === 'false') { return false; } - return value; + return typeof value === 'string' ? value : JSON.stringify(value); } export { diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 4278f5a9a..e4f1a1d66 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -848,6 +848,62 @@ const example = 'export const layout = "home"'; expect(page!.layout).toBe('post'); }); + it.each([ + ['layout: post # comment', 'post'], + ['layout: "post"', 'post'], + ["layout: '!post'", '!post'], + ['layout: false # comment', false], + ['layout: "false"', false], + ['layout:', undefined], + ['layout: ""', undefined], + ['layout:\ntitle: Hi', undefined], + ['title: Hi', undefined], + ['a scalar, not a mapping', undefined], + ['seo:\n\ttitle: Tab indent\nlayout: post', 'post'], + ['title: "Across\nlines"\nlayout: post', 'post'], + ['base: &base {layout: post}\n<<: *base', 'post'], + ])('reads markdown frontmatter %s as YAML', async (frontmatter, layout) => { + writeFile('_layouts/post.vue', ''); + writeFile('post.md', `---\n${frontmatter}\n---\n# Hi`); + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'post'); + expect(page!.layout).toBe(layout); + }); + + // @void/md reports invalid frontmatter on the page, so the scan must not stop at it. + it.each([ + ['an unclosed sequence', 'layout: [unclosed'], + ['an unclosed mapping', 'layout: {unclosed'], + ['an unclosed quote', 'layout: "unclosed'], + ['an invalid line before the layout', 'title\nlayout: missing'], + ['an unknown tag without a value', 'layout: !unknown'], + ['an unknown tag on the value', 'layout: !unknown missing'], + ['an unknown tag on another key', 'other: !unknown value\nlayout: missing'], + ['a core tag that does not fit the value', 'layout: !!int missing'], + ['a merge key that is not a mapping', '<<: value\nlayout: missing'], + ['an empty key', ': value\nlayout: missing'], + ['a control character', 'layout: mis\x01sing'], + ])('reads no layout from frontmatter with %s', async (_name, frontmatter) => { + writeFile('post.md', `---\n${frontmatter}\n---\n# Hi`); + const result = await scanPages(TMP); + expect(result.pages.find((p) => p.componentId === 'post')!.layout).toBeUndefined(); + }); + + it.each([ + ['layout: missing', '_layouts/missing.vue'], + ['layout: "!missing"', '_layouts/missing.vue'], + ['layout: [a, b]', 'specifies layout \'["a","b"]\''], + ['layout: {a: 1}', 'specifies layout \'{"a":1}\''], + ['layout: 123', "specifies layout '123'"], + ['layout: true', "specifies layout 'true'"], + ])( + 'throws when valid markdown frontmatter %s names no layout', + async (frontmatter, message) => { + writeFile('post.md', `---\n${frontmatter}\n---\n# Hi`); + await expect(scanPages(TMP)).rejects.toThrow(message); + }, + ); + it('throws when named layout reference is not found', async () => { writeFile( 'index.vue', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f3a8ccaf..bbd595886 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: email-validator: specifier: ^2.0.4 version: 2.0.4 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 hono: specifier: 'catalog:' version: 4.13.16