Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/md/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ 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';
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';
Expand Down Expand Up @@ -139,7 +138,7 @@ async function createCompiler(options?: CompileOptions): Promise<MarkdownExit> {
}

export async function compile(source: string, options?: CompileOptions): Promise<CompileResult> {
const { data: frontmatter, content } = matter(source);
const { frontmatter, content } = parseFrontmatter(source);
const compiler = await getCompiler(options);

const env: { codeCopy?: boolean } = {};
Expand Down
17 changes: 16 additions & 1 deletion packages/md/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,23 @@ export type MetadataResult = Readonly<{
title: string;
}>;

export function parseFrontmatter(
source: string,
): Readonly<{ frontmatter: Record<string, unknown>; 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<string, unknown> = {};
Expand Down
92 changes: 72 additions & 20 deletions packages/md/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -82,11 +88,14 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
let mdPages: ReadonlyArray<MdPage> = [];
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<string, string>();
const solidModules = new Map<string, string>();
const clientScripts = new Map<string, string>();
// The last error logged per page, so that rescans don't repeat it.
const loggedPageErrors = new Map<string, string>();

function createVirtualModuleId(
prefix: string,
Expand All @@ -99,34 +108,65 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
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<ReadonlyArray<MdPage>> {
if (!existsSync(pagesDir)) {
return [];
}

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<MdPage | undefined> => {
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 [
Expand Down Expand Up @@ -168,6 +208,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {

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];
Expand Down Expand Up @@ -198,7 +239,16 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
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);
Expand Down Expand Up @@ -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 };
Expand Down
86 changes: 86 additions & 0 deletions packages/md/test/integration/invalid-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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<typeof spawn> | 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);
});
});
10 changes: 10 additions & 0 deletions packages/md/test/unit/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading