Skip to content

Commit 8723e9c

Browse files
Brooooooklynclaude
andcommitted
fix(md): let layouts read the page frontmatter with React, Solid and Svelte
`useFrontmatter()` in a layout that wraps a `.md` page returned `{}` with React, Solid and Svelte. The frontmatter was provided only inside the page component, below its layouts. The island renderer now provides the page's frontmatter above the layout chain, as Vue already does. React and Solid match contexts by identity, so the adapter owns `FrontmatterContext` and the `@void/md` runtime re-exports it. Svelte sets the context in `App.svelte`. Fixes #451 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5bf1163 commit 8723e9c

12 files changed

Lines changed: 237 additions & 20 deletions

File tree

‎packages/md/src/plugin.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,13 @@ import { inject } from "vue";
278278
export function useFrontmatter() { return inject("__void_frontmatter", {}); }
279279
`;
280280
}
281+
// React and Solid match contexts by identity. The adapter owns the
282+
// context so its island renderer can provide it above the layouts.
281283
if (adapter === 'react') {
282284
return `
283-
import { createContext, useContext } from "react";
284-
export const FrontmatterContext = createContext({});
285+
import { useContext } from "react";
286+
import { FrontmatterContext } from "@void/react";
287+
export { FrontmatterContext };
285288
export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; }
286289
`;
287290
}
@@ -295,8 +298,9 @@ export function useFrontmatter() { return getContext(FRONTMATTER_KEY) ?? {}; }
295298
}
296299
if (adapter === 'solid') {
297300
return `
298-
import { createContext, useContext } from "solid-js";
299-
export const FrontmatterContext = createContext({});
301+
import { useContext } from "solid-js";
302+
import { FrontmatterContext } from "@void/solid";
303+
export { FrontmatterContext };
300304
export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; }
301305
`;
302306
}

‎packages/react/src/plugin-islands.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ function generateIslandRenderer(
249249
return `
250250
import React from "react";
251251
import { renderToString } from "react-dom/server";
252-
import { SharedContext, ErrorsContext, ParamsContext } from "@void/react";
252+
import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/react";
253253
import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head";
254254
255255
${generateComponentManifest(scan, pagesDir)}
@@ -263,7 +263,9 @@ function escapeAttr(s) {
263263
}
264264
265265
export async function renderIslandPage(pageObj, assetTags) {
266-
const PageComponent = (await components[pageObj.component]()).default;
266+
const __pageModule = await components[pageObj.component]();
267+
const PageComponent = __pageModule.default;
268+
const __frontmatter = __pageModule.frontmatter ?? {};
267269
const layoutIds = layoutTree[pageObj.component] || [];
268270
const layoutComponents = await Promise.all(
269271
layoutIds.map(async (id) => (await components[id]()).default)
@@ -274,7 +276,8 @@ export async function renderIslandPage(pageObj, assetTags) {
274276
element = React.createElement(layoutComponents[i], null, element);
275277
}
276278
277-
// No RouterContext — islands have no Void Router
279+
// No RouterContext — islands have no Void Router.
280+
// Frontmatter wraps the layouts so they can read the Markdown page's values.
278281
element = React.createElement(
279282
SharedContext.Provider,
280283
{ value: pageObj.shared || {} },
@@ -284,7 +287,7 @@ export async function renderIslandPage(pageObj, assetTags) {
284287
React.createElement(
285288
ErrorsContext.Provider,
286289
{ value: pageObj.errors || {} },
287-
element
290+
React.createElement(FrontmatterContext.Provider, { value: __frontmatter }, element)
288291
)
289292
)
290293
);

‎packages/react/src/runtime/context.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ export const ParamsContext: Context<Record<string, string> | null> = createConte
1616
export const RouterContext: Context<VoidRouter | null> = createContext<VoidRouter | null>(null);
1717
export const NavigationContext: Context<NavigationState> =
1818
createContext<NavigationState>(idleNavigationState());
19+
// Island renderers provide the page's Markdown frontmatter above its layouts.
20+
// The `@void/md` runtime re-exports this context for `useFrontmatter()`.
21+
export const FrontmatterContext: Context<Record<string, unknown>> = createContext<
22+
Record<string, unknown>
23+
>({});

‎packages/react/src/runtime/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
ParamsContext,
1414
RouterContext,
1515
NavigationContext,
16+
FrontmatterContext,
1617
} from './context.ts';
1718
export type { InferProps } from 'void';
1819
export type Deferred<T> = Promise<T>;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// The Markdown plugin source needs its ambient module declarations in this package's typecheck.
2+
// eslint-disable-next-line typescript/triple-slash-reference -- env.d.ts has no exports to import.
3+
/// <reference path="../../../md/src/env.d.ts" />
4+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5+
import { join } from 'node:path';
6+
import { createServer } from 'vite';
7+
import { expect, it } from 'vite-plus/test';
8+
import { voidMarkdown } from '../../../md/src/plugin.ts';
9+
import { islandsPlugin } from '../../src/plugin-islands.ts';
10+
11+
type IslandRenderer = {
12+
renderIslandPage(
13+
page: { component: string; props: Record<string, unknown> },
14+
assetTags: { css: string; preloads: string; body: string },
15+
): Promise<string>;
16+
};
17+
18+
it('a layout reads the frontmatter of the Markdown page it wraps', async () => {
19+
const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_frontmatter_'));
20+
const pages = join(root, 'pages');
21+
mkdirSync(pages);
22+
writeFileSync(
23+
join(pages, 'layout.tsx'),
24+
`import { useFrontmatter } from "@void/md";
25+
export default function Layout({ children }) {
26+
return <div><p id="fm">{useFrontmatter().title}</p>{children}</div>;
27+
}`,
28+
);
29+
writeFileSync(join(pages, 'index.md'), '---\ntitle: Hello from frontmatter\n---\n\n# Body\n');
30+
31+
const server = await createServer({
32+
root,
33+
configFile: false,
34+
cacheDir: join(root, '.vite'),
35+
logLevel: 'silent',
36+
appType: 'custom',
37+
server: { middlewareMode: true, ws: false },
38+
resolve: { alias: { '@void/react': join(import.meta.dirname, '../../src/runtime/index.ts') } },
39+
// The Markdown plugin picks its output from the adapter's pages plugin name.
40+
plugins: [...voidMarkdown(), { name: 'void-react:pages' }, ...islandsPlugin(pages)],
41+
});
42+
try {
43+
const renderer = (await server.ssrLoadModule(
44+
'virtual:void-islands-renderer',
45+
)) as IslandRenderer;
46+
const html = await renderer.renderIslandPage(
47+
{ component: 'index', props: {} },
48+
{ css: '', preloads: '', body: '' },
49+
);
50+
expect(html).toContain('<p id="fm">Hello from frontmatter</p>');
51+
expect(html).toContain('Body</h1>');
52+
} finally {
53+
await server.close();
54+
rmSync(root, { recursive: true, force: true });
55+
}
56+
});

‎packages/solid/src/plugin-islands.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ function generateIslandRenderer(
256256
return `
257257
import { renderToStringAsync, generateHydrationScript } from "solid-js/web";
258258
import { createComponent } from "solid-js";
259-
import { SharedContext, ErrorsContext, ParamsContext } from "@void/solid";
259+
import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/solid";
260260
import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head";
261261
262262
${generateComponentManifest(scan, pagesDir)}
@@ -268,7 +268,9 @@ function escapeAttr(s) {
268268
}
269269
270270
export async function renderIslandPage(pageObj, assetTags) {
271-
const PageComponent = (await components[pageObj.component]()).default;
271+
const __pageModule = await components[pageObj.component]();
272+
const PageComponent = __pageModule.default;
273+
const __frontmatter = __pageModule.frontmatter ?? {};
272274
const layoutIds = layoutTree[pageObj.component] || [];
273275
const layoutComponents = await Promise.all(
274276
layoutIds.map(async (id) => (await components[id]()).default)
@@ -284,15 +286,21 @@ export async function renderIslandPage(pageObj, assetTags) {
284286
return createComponent(ParamsContext.Provider, {
285287
value: pageObj.params || {},
286288
get children() {
287-
// Build layout chain inside providers so contexts are available.
288-
// No RouterContext — islands have no Void Router.
289-
let element = createComponent(PageComponent, pageObj.props);
290-
for (let i = layoutComponents.length - 1; i >= 0; i--) {
291-
const Layout = layoutComponents[i];
292-
const child = element;
293-
element = createComponent(Layout, { get children() { return child; } });
294-
}
295-
return element;
289+
// Frontmatter wraps the layouts so they can read the Markdown page's values.
290+
return createComponent(FrontmatterContext.Provider, {
291+
value: __frontmatter,
292+
get children() {
293+
// Build layout chain inside providers so contexts are available.
294+
// No RouterContext — islands have no Void Router.
295+
let element = createComponent(PageComponent, pageObj.props);
296+
for (let i = layoutComponents.length - 1; i >= 0; i--) {
297+
const Layout = layoutComponents[i];
298+
const child = element;
299+
element = createComponent(Layout, { get children() { return child; } });
300+
}
301+
return element;
302+
}
303+
});
296304
}
297305
});
298306
}

‎packages/solid/src/runtime/context.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ export const RouterContext: Context<VoidRouter | null> = createContext<VoidRoute
1616
export const NavigationContext: Context<(() => NavigationState) | null> = createContext<
1717
(() => NavigationState) | null
1818
>(null);
19+
// Island renderers provide the page's Markdown frontmatter above its layouts.
20+
// The `@void/md` runtime re-exports this context for `useFrontmatter()`.
21+
export const FrontmatterContext: Context<Record<string, unknown>> = createContext<
22+
Record<string, unknown>
23+
>({});

‎packages/solid/src/runtime/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ export {
1313
ParamsContext,
1414
RouterContext,
1515
NavigationContext,
16+
FrontmatterContext,
1617
} from './context.ts';
1718
export type { DeferredState as Deferred, InferProps } from 'void';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// The Markdown plugin source needs its ambient module declarations in this package's typecheck.
2+
// eslint-disable-next-line typescript/triple-slash-reference -- env.d.ts has no exports to import.
3+
/// <reference path="../../../md/src/env.d.ts" />
4+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5+
import { join } from 'node:path';
6+
import { createServer } from 'vite';
7+
import solid from 'vite-plugin-solid';
8+
import { expect, it } from 'vite-plus/test';
9+
import { voidMarkdown } from '../../../md/src/plugin.ts';
10+
import { islandsPlugin } from '../../src/plugin-islands.ts';
11+
12+
type IslandRenderer = {
13+
renderIslandPage(
14+
page: { component: string; props: Record<string, unknown> },
15+
assetTags: { css: string; preloads: string; body: string },
16+
): Promise<string>;
17+
};
18+
19+
it('a layout reads the frontmatter of the Markdown page it wraps', async () => {
20+
const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_frontmatter_'));
21+
const pages = join(root, 'pages');
22+
mkdirSync(pages);
23+
writeFileSync(
24+
join(pages, 'layout.tsx'),
25+
`import { useFrontmatter } from "@void/md";
26+
export default function Layout(props) {
27+
return <div><p id="fm">{useFrontmatter().title}</p>{props.children}</div>;
28+
}`,
29+
);
30+
writeFileSync(join(pages, 'index.md'), '---\ntitle: Hello from frontmatter\n---\n\n# Body\n');
31+
32+
const server = await createServer({
33+
root,
34+
configFile: false,
35+
cacheDir: join(root, '.vite'),
36+
logLevel: 'silent',
37+
appType: 'custom',
38+
server: { middlewareMode: true, ws: false },
39+
resolve: { alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') } },
40+
// The Markdown plugin picks its output from the adapter's pages plugin name.
41+
plugins: [
42+
...voidMarkdown(),
43+
solid({ ssr: true }),
44+
{ name: 'void-solid:pages' },
45+
...islandsPlugin(pages),
46+
],
47+
});
48+
try {
49+
const renderer = (await server.ssrLoadModule(
50+
'virtual:void-islands-renderer',
51+
)) as IslandRenderer;
52+
const html = await renderer.renderIslandPage(
53+
{ component: 'index', props: {} },
54+
{ css: '', preloads: '', body: '' },
55+
);
56+
expect(html).toContain('<p id="fm">Hello from frontmatter</p>');
57+
expect(html).toContain('Body</h1>');
58+
} finally {
59+
await server.close();
60+
rmSync(root, { recursive: true, force: true });
61+
}
62+
});

‎packages/svelte/src/plugin-islands.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,8 @@ function escapeAttr(s) {
351351
}
352352
353353
export async function renderIslandPage(pageObj, assetTags) {
354-
const PageComponent = (await components[pageObj.component]()).default;
354+
const __pageModule = await components[pageObj.component]();
355+
const PageComponent = __pageModule.default;
355356
const layoutIds = layoutTree[pageObj.component] || [];
356357
const layoutComponents = await Promise.all(
357358
layoutIds.map(async (id) => (await components[id]()).default)
@@ -364,6 +365,8 @@ export async function renderIslandPage(pageObj, assetTags) {
364365
pageProps: pageObj.props,
365366
shared: pageObj.shared || {},
366367
params: pageObj.params || {},
368+
// Set above the layouts so they can read the Markdown page's values.
369+
frontmatter: __pageModule.frontmatter,
367370
router: null,
368371
stores: null,
369372
},

0 commit comments

Comments
 (0)