Skip to content

fix(md): keep the dev server running on invalid frontmatter - #690

Open
morinokami wants to merge 1 commit into
voidzero-dev:mainfrom
morinokami:fix/md-invalid-frontmatter
Open

morinokami wants to merge 1 commit into
voidzero-dev:mainfrom
morinokami:fix/md-invalid-frontmatter

Conversation

@morinokami

Copy link
Copy Markdown
Contributor

A Markdown page with invalid frontmatter stops vp dev. For example, saving this page exits the process with code 1 and a YAMLException:

---
title
description: Minimal Void Markdown page
---

The dev server could stop in four ways:

  • Saving an invalid page. The @void/md watcher listener rejected, and the unhandled rejection exited the process.
  • Starting with an invalid page. The plugin's buildStart scan threw.
  • A page removed during a scan. readFileSync threw ENOENT in the same listener. Adding and removing many pages in a burst reproduces this, which approximates switching branches.
  • An invalid layout: line. The shared page scan in void/pages read layout: with a regex, so layout: [unclosed named a layout called [unclosed, and layout validation stopped startup. Error handling in @void/md cannot catch this.

Builds also failed on invalid frontmatter, as they should, but the error did not name the file.

Fix

On the dev server, an invalid page now fails on its own. It answers 500 with the file name and Invalid frontmatter, and other pages keep working. In packages/md/src/plugin.ts:

  • scanMdPages handles errors per page. It logs the error once per failure and keeps the page's last good entry, so lists built from the pages don't change on every save. A page with no good entry yet is left out. A page removed since the glob is skipped without an error.
  • The watcher listener keeps the last good scan if a scan fails, and the next change rescans, as the void/pages plugin does.
  • Errors from compile() name the page. Vite's error metadata does not cross the worker boundary, so the file name has to be in the message.
  • Builds still fail, and now name the page.

parseFrontmatter in packages/md/src/metadata.ts is now the one place in @void/md that parses frontmatter. gray-matter stores a source in its cache before parsing it, so the next read of invalid frontmatter returned empty data with the fences left in the content. Once the server survived, the page answered 200 with its frontmatter rendered as text. Passing options skips the cache.

scanPages in packages/void/src/pages/scan.ts now reads layout with gray-matter, the parser @void/md uses, so the two agree on what a page's frontmatter says. If the frontmatter does not parse, the page has no layout, and @void/md reports the error.

This also fixes valid forms the regex misread. layout: post # comment named a layout called post # comment, and layout: followed by title: Hi named a layout called title: Hi.

This is a bug fix with no new API, so the docs do not change.

New dependency

void gains gray-matter in dependencies, with the same range as @void/md. Apps without Markdown pages install it but do not load it: scanPages imports it when it reaches a .md page. Importing void takes the same time as on main (median 257ms, against 260ms on main), and the parser's strings do not appear in the build output of playground/markdown-blog.

void already depends on yaml, but yaml and gray-matter disagreed on 44 of 167 frontmatter inputs:

  • yaml only warns on an unknown tag (layout: !missing value) and accepts forms gray-matter rejects (<<: value), so a page @void/md rejects could still stop startup.
  • yaml rejects forms gray-matter accepts, such as tab indentation, so a valid page would lose its layout.

Not changed here

  • layout: nonexistent is valid frontmatter, so it still stops startup. Saved on a running server, it logs the error and the server keeps running.
  • The scan still parses only frontmatter whose first line is exactly ---. @void/md also reads ---yaml, --- and ---js, so those pages get no layout from the scan. This keeps the scan away from gray-matter's eval of ---js blocks.
  • title and description in the scan still use the regex, so title: Foo # comment reads as Foo # comment.
  • Line numbers in the error don't count a leading <script> block.
  • A block with only a scalar (title alone) is valid YAML, so it is not an error, and the frontmatter is a string.

Tests

  • packages/md/test/unit/plugin-invalid-pages.test.ts (new): runs the plugin's hooks against a temporary app. It covers starting with an invalid page, saving one, fixing it, repeated logging, a page removed during a scan, a failed scan and builds. It also compares what scanPages and extractMetadata read on 15 frontmatter forms, so a future difference between the two fails a test.
  • packages/md/test/integration/invalid-frontmatter.test.ts (new): runs vp dev on a copy of playground/markdown-blog. Three invalid pages, two of them with an invalid layout: line, are present at startup, and another page is saved invalid and then fixed.
  • packages/md/test/unit/metadata.test.ts: every read of invalid frontmatter throws.
  • packages/void/test/unit/pages-scan.test.ts: which forms name a layout, which name none, and which throw.

Without this fix, 38 of these unit cases fail, and the integration test fails because the server stops during startup. I checked this by running the tests against the source on main.

Verification

  • vp test run: 772 files pass, 1 skipped (15,766 tests pass, 29 skipped).
  • vp run check: 33/33 pass.
  • scanPages and extractMetadata on all 167 inputs: @void/md rejects 64, and none of them stops the scan. It accepts 103, and both read the same layout.
  • By hand with vp dev on React (playground/named-layouts) and Vue (playground/markdown-blog):
    • Starting with the frontmatter above, layout: !missing value, layout: [unclosed or four other invalid forms: the server starts, the page answers 500 and names the file, and other pages answer 200.
    • Saving the frontmatter above or layout: [unclosed on a running server: the server keeps running, the page answers 500, other pages answer 200, and fixing the page brings it back.
  • By hand with vp dev on React: a plain layout, tab indentation, a quoted value across lines, a merge key and a trailing comment all apply the layout.
  • By hand with vp build: invalid frontmatter exits 1 and names the page on React and Vue. On Vue, this includes a page with a client script, and a build with valid pages exits 0.
  • Svelte and Solid were not run by hand. The changed code runs before the adapter-specific code.

A Markdown page with invalid frontmatter stopped `vp dev`. Saving one
rejected the `@void/md` watcher listener, and the unhandled rejection
exited the process. Starting with one failed the plugin's `buildStart`
scan. A page removed during a scan, as when switching branches, stopped
the server the same way.

On the dev server, an invalid page now fails on its own. It answers 500
with the file name and `Invalid frontmatter`, and other pages keep
working. The error is logged once per failure, and lists built from the
pages keep the page's last good entry. Builds still fail, and now name
the page.

gray-matter stores a source in its cache before parsing it, so a second
read of invalid frontmatter returned empty data. With the server still
running, the page would render its fences as content instead of failing,
so `parseFrontmatter` passes options, which skips the cache.

The shared page scan also stopped startup. It read `layout:` with a
regex, so `layout: [unclosed` named a layout called `[unclosed`, which
then failed layout validation. It now uses gray-matter, the parser
`@void/md` uses, so the two agree on what a page's frontmatter says.

`void` already depends on `yaml`, but it only warns on an unknown tag
(`layout: !missing value`) and accepts forms gray-matter rejects, such
as `<<: value`. So `void` gains gray-matter as a dependency, imported
only when an app has Markdown pages. Forms the regex misread now work,
such as `layout: post # comment`. A test compares what the two packages
read.

The scan still parses only frontmatter whose first line is exactly
`---`, which keeps it away from gray-matter's `eval` of `---js` blocks.
A layout that parses but does not exist still stops startup, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8F8mc1JSJJ8bD9Fkqnoxm
@morinokami
morinokami requested a review from a team as a code owner November 3, 2026 16:52

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant