From 8723e9c254508a0b976dceed8aac658dee6b0bb9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 18 Oct 2026 16:37:51 +0800 Subject: [PATCH] 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) --- packages/md/src/plugin.ts | 12 ++-- packages/react/src/plugin-islands.ts | 11 ++-- packages/react/src/runtime/context.ts | 5 ++ packages/react/src/runtime/index.ts | 1 + .../integration/md-layout-frontmatter.test.ts | 56 ++++++++++++++++ packages/solid/src/plugin-islands.ts | 30 +++++---- packages/solid/src/runtime/context.ts | 5 ++ packages/solid/src/runtime/index.ts | 1 + .../integration/md-layout-frontmatter.test.ts | 62 ++++++++++++++++++ packages/svelte/src/plugin-islands.ts | 5 +- packages/svelte/src/runtime/App.svelte | 5 ++ .../integration/md-layout-frontmatter.test.ts | 64 +++++++++++++++++++ 12 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 packages/react/test/integration/md-layout-frontmatter.test.ts create mode 100644 packages/solid/test/integration/md-layout-frontmatter.test.ts create mode 100644 packages/svelte/test/integration/md-layout-frontmatter.test.ts diff --git a/packages/md/src/plugin.ts b/packages/md/src/plugin.ts index 2c7c6fc04..08b104baf 100644 --- a/packages/md/src/plugin.ts +++ b/packages/md/src/plugin.ts @@ -278,10 +278,13 @@ import { inject } from "vue"; export function useFrontmatter() { return inject("__void_frontmatter", {}); } `; } + // React and Solid match contexts by identity. The adapter owns the + // context so its island renderer can provide it above the layouts. if (adapter === 'react') { return ` -import { createContext, useContext } from "react"; -export const FrontmatterContext = createContext({}); +import { useContext } from "react"; +import { FrontmatterContext } from "@void/react"; +export { FrontmatterContext }; export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; } `; } @@ -295,8 +298,9 @@ export function useFrontmatter() { return getContext(FRONTMATTER_KEY) ?? {}; } } if (adapter === 'solid') { return ` -import { createContext, useContext } from "solid-js"; -export const FrontmatterContext = createContext({}); +import { useContext } from "solid-js"; +import { FrontmatterContext } from "@void/solid"; +export { FrontmatterContext }; export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; } `; } diff --git a/packages/react/src/plugin-islands.ts b/packages/react/src/plugin-islands.ts index e062522bf..2e621ad10 100644 --- a/packages/react/src/plugin-islands.ts +++ b/packages/react/src/plugin-islands.ts @@ -249,7 +249,7 @@ function generateIslandRenderer( return ` import React from "react"; import { renderToString } from "react-dom/server"; -import { SharedContext, ErrorsContext, ParamsContext } from "@void/react"; +import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/react"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; ${generateComponentManifest(scan, pagesDir)} @@ -263,7 +263,9 @@ function escapeAttr(s) { } export async function renderIslandPage(pageObj, assetTags) { - const PageComponent = (await components[pageObj.component]()).default; + const __pageModule = await components[pageObj.component](); + const PageComponent = __pageModule.default; + const __frontmatter = __pageModule.frontmatter ?? {}; const layoutIds = layoutTree[pageObj.component] || []; const layoutComponents = await Promise.all( layoutIds.map(async (id) => (await components[id]()).default) @@ -274,7 +276,8 @@ export async function renderIslandPage(pageObj, assetTags) { element = React.createElement(layoutComponents[i], null, element); } - // No RouterContext — islands have no Void Router + // No RouterContext — islands have no Void Router. + // Frontmatter wraps the layouts so they can read the Markdown page's values. element = React.createElement( SharedContext.Provider, { value: pageObj.shared || {} }, @@ -284,7 +287,7 @@ export async function renderIslandPage(pageObj, assetTags) { React.createElement( ErrorsContext.Provider, { value: pageObj.errors || {} }, - element + React.createElement(FrontmatterContext.Provider, { value: __frontmatter }, element) ) ) ); diff --git a/packages/react/src/runtime/context.ts b/packages/react/src/runtime/context.ts index 994531a05..fd969f041 100644 --- a/packages/react/src/runtime/context.ts +++ b/packages/react/src/runtime/context.ts @@ -16,3 +16,8 @@ export const ParamsContext: Context | null> = createConte export const RouterContext: Context = createContext(null); export const NavigationContext: Context = createContext(idleNavigationState()); +// Island renderers provide the page's Markdown frontmatter above its layouts. +// The `@void/md` runtime re-exports this context for `useFrontmatter()`. +export const FrontmatterContext: Context> = createContext< + Record +>({}); diff --git a/packages/react/src/runtime/index.ts b/packages/react/src/runtime/index.ts index 9e114623e..d3ad9687d 100644 --- a/packages/react/src/runtime/index.ts +++ b/packages/react/src/runtime/index.ts @@ -13,6 +13,7 @@ export { ParamsContext, RouterContext, NavigationContext, + FrontmatterContext, } from './context.ts'; export type { InferProps } from 'void'; export type Deferred = Promise; diff --git a/packages/react/test/integration/md-layout-frontmatter.test.ts b/packages/react/test/integration/md-layout-frontmatter.test.ts new file mode 100644 index 000000000..09afe218c --- /dev/null +++ b/packages/react/test/integration/md-layout-frontmatter.test.ts @@ -0,0 +1,56 @@ +// The Markdown plugin source needs its ambient module declarations in this package's typecheck. +// eslint-disable-next-line typescript/triple-slash-reference -- env.d.ts has no exports to import. +/// +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createServer } from 'vite'; +import { expect, it } from 'vite-plus/test'; +import { voidMarkdown } from '../../../md/src/plugin.ts'; +import { islandsPlugin } from '../../src/plugin-islands.ts'; + +type IslandRenderer = { + renderIslandPage( + page: { component: string; props: Record }, + assetTags: { css: string; preloads: string; body: string }, + ): Promise; +}; + +it('a layout reads the frontmatter of the Markdown page it wraps', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_frontmatter_')); + const pages = join(root, 'pages'); + mkdirSync(pages); + writeFileSync( + join(pages, 'layout.tsx'), + `import { useFrontmatter } from "@void/md"; +export default function Layout({ children }) { + return

{useFrontmatter().title}

{children}
; +}`, + ); + writeFileSync(join(pages, 'index.md'), '---\ntitle: Hello from frontmatter\n---\n\n# Body\n'); + + const server = await createServer({ + root, + configFile: false, + cacheDir: join(root, '.vite'), + logLevel: 'silent', + appType: 'custom', + server: { middlewareMode: true, ws: false }, + resolve: { alias: { '@void/react': join(import.meta.dirname, '../../src/runtime/index.ts') } }, + // The Markdown plugin picks its output from the adapter's pages plugin name. + plugins: [...voidMarkdown(), { name: 'void-react:pages' }, ...islandsPlugin(pages)], + }); + try { + const renderer = (await server.ssrLoadModule( + 'virtual:void-islands-renderer', + )) as IslandRenderer; + const html = await renderer.renderIslandPage( + { component: 'index', props: {} }, + { css: '', preloads: '', body: '' }, + ); + expect(html).toContain('

Hello from frontmatter

'); + expect(html).toContain('Body'); + } finally { + await server.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts index 06e5c42d7..3b6e97ce8 100644 --- a/packages/solid/src/plugin-islands.ts +++ b/packages/solid/src/plugin-islands.ts @@ -256,7 +256,7 @@ function generateIslandRenderer( return ` import { renderToStringAsync, generateHydrationScript } from "solid-js/web"; import { createComponent } from "solid-js"; -import { SharedContext, ErrorsContext, ParamsContext } from "@void/solid"; +import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/solid"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; ${generateComponentManifest(scan, pagesDir)} @@ -268,7 +268,9 @@ function escapeAttr(s) { } export async function renderIslandPage(pageObj, assetTags) { - const PageComponent = (await components[pageObj.component]()).default; + const __pageModule = await components[pageObj.component](); + const PageComponent = __pageModule.default; + const __frontmatter = __pageModule.frontmatter ?? {}; const layoutIds = layoutTree[pageObj.component] || []; const layoutComponents = await Promise.all( layoutIds.map(async (id) => (await components[id]()).default) @@ -284,15 +286,21 @@ export async function renderIslandPage(pageObj, assetTags) { return createComponent(ParamsContext.Provider, { value: pageObj.params || {}, get children() { - // Build layout chain inside providers so contexts are available. - // No RouterContext — islands have no Void Router. - let element = createComponent(PageComponent, pageObj.props); - for (let i = layoutComponents.length - 1; i >= 0; i--) { - const Layout = layoutComponents[i]; - const child = element; - element = createComponent(Layout, { get children() { return child; } }); - } - return element; + // Frontmatter wraps the layouts so they can read the Markdown page's values. + return createComponent(FrontmatterContext.Provider, { + value: __frontmatter, + get children() { + // Build layout chain inside providers so contexts are available. + // No RouterContext — islands have no Void Router. + let element = createComponent(PageComponent, pageObj.props); + for (let i = layoutComponents.length - 1; i >= 0; i--) { + const Layout = layoutComponents[i]; + const child = element; + element = createComponent(Layout, { get children() { return child; } }); + } + return element; + } + }); } }); } diff --git a/packages/solid/src/runtime/context.ts b/packages/solid/src/runtime/context.ts index f168918aa..a6fea9155 100644 --- a/packages/solid/src/runtime/context.ts +++ b/packages/solid/src/runtime/context.ts @@ -16,3 +16,8 @@ export const RouterContext: Context = createContext NavigationState) | null> = createContext< (() => NavigationState) | null >(null); +// Island renderers provide the page's Markdown frontmatter above its layouts. +// The `@void/md` runtime re-exports this context for `useFrontmatter()`. +export const FrontmatterContext: Context> = createContext< + Record +>({}); diff --git a/packages/solid/src/runtime/index.ts b/packages/solid/src/runtime/index.ts index cbe927720..36727e276 100644 --- a/packages/solid/src/runtime/index.ts +++ b/packages/solid/src/runtime/index.ts @@ -13,5 +13,6 @@ export { ParamsContext, RouterContext, NavigationContext, + FrontmatterContext, } from './context.ts'; export type { DeferredState as Deferred, InferProps } from 'void'; diff --git a/packages/solid/test/integration/md-layout-frontmatter.test.ts b/packages/solid/test/integration/md-layout-frontmatter.test.ts new file mode 100644 index 000000000..3e8c80a23 --- /dev/null +++ b/packages/solid/test/integration/md-layout-frontmatter.test.ts @@ -0,0 +1,62 @@ +// The Markdown plugin source needs its ambient module declarations in this package's typecheck. +// eslint-disable-next-line typescript/triple-slash-reference -- env.d.ts has no exports to import. +/// +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createServer } from 'vite'; +import solid from 'vite-plugin-solid'; +import { expect, it } from 'vite-plus/test'; +import { voidMarkdown } from '../../../md/src/plugin.ts'; +import { islandsPlugin } from '../../src/plugin-islands.ts'; + +type IslandRenderer = { + renderIslandPage( + page: { component: string; props: Record }, + assetTags: { css: string; preloads: string; body: string }, + ): Promise; +}; + +it('a layout reads the frontmatter of the Markdown page it wraps', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_frontmatter_')); + const pages = join(root, 'pages'); + mkdirSync(pages); + writeFileSync( + join(pages, 'layout.tsx'), + `import { useFrontmatter } from "@void/md"; +export default function Layout(props) { + return

{useFrontmatter().title}

{props.children}
; +}`, + ); + writeFileSync(join(pages, 'index.md'), '---\ntitle: Hello from frontmatter\n---\n\n# Body\n'); + + const server = await createServer({ + root, + configFile: false, + cacheDir: join(root, '.vite'), + logLevel: 'silent', + appType: 'custom', + server: { middlewareMode: true, ws: false }, + resolve: { alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') } }, + // The Markdown plugin picks its output from the adapter's pages plugin name. + plugins: [ + ...voidMarkdown(), + solid({ ssr: true }), + { name: 'void-solid:pages' }, + ...islandsPlugin(pages), + ], + }); + try { + const renderer = (await server.ssrLoadModule( + 'virtual:void-islands-renderer', + )) as IslandRenderer; + const html = await renderer.renderIslandPage( + { component: 'index', props: {} }, + { css: '', preloads: '', body: '' }, + ); + expect(html).toContain('

Hello from frontmatter

'); + expect(html).toContain('Body'); + } finally { + await server.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index 7f29062f1..a2da2d683 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -351,7 +351,8 @@ function escapeAttr(s) { } export async function renderIslandPage(pageObj, assetTags) { - const PageComponent = (await components[pageObj.component]()).default; + const __pageModule = await components[pageObj.component](); + const PageComponent = __pageModule.default; const layoutIds = layoutTree[pageObj.component] || []; const layoutComponents = await Promise.all( layoutIds.map(async (id) => (await components[id]()).default) @@ -364,6 +365,8 @@ export async function renderIslandPage(pageObj, assetTags) { pageProps: pageObj.props, shared: pageObj.shared || {}, params: pageObj.params || {}, + // Set above the layouts so they can read the Markdown page's values. + frontmatter: __pageModule.frontmatter, router: null, stores: null, }, diff --git a/packages/svelte/src/runtime/App.svelte b/packages/svelte/src/runtime/App.svelte index b4a330941..062e4b7d5 100644 --- a/packages/svelte/src/runtime/App.svelte +++ b/packages/svelte/src/runtime/App.svelte @@ -13,6 +13,7 @@ pageProps: props.pageProps, shared: props.shared, params: props.params, + frontmatter: props.frontmatter, router: props.router, routeUrl: props.routeUrl, navigation: props.navigation, @@ -51,6 +52,10 @@ setContext("__void_navigation", { get value() { return navigation; }, }); + // Island pages pass Markdown frontmatter so layouts can read it (useFrontmatter). + if (init.frontmatter) { + setContext("__void_frontmatter", init.frontmatter); + } // Client mode: subscribe to stores for live updates after hydration. // onMount only runs on the client, not during SSR. diff --git a/packages/svelte/test/integration/md-layout-frontmatter.test.ts b/packages/svelte/test/integration/md-layout-frontmatter.test.ts new file mode 100644 index 000000000..14e16ee05 --- /dev/null +++ b/packages/svelte/test/integration/md-layout-frontmatter.test.ts @@ -0,0 +1,64 @@ +// The Markdown plugin source needs its ambient module declarations in this package's typecheck. +// eslint-disable-next-line typescript/triple-slash-reference -- env.d.ts has no exports to import. +/// +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { createServer } from 'vite'; +import { expect, it } from 'vite-plus/test'; +import { voidMarkdown } from '../../../md/src/plugin.ts'; +import { islandsPlugin } from '../../src/plugin-islands.ts'; + +type IslandRenderer = { + renderIslandPage( + page: { component: string; props: Record }, + assetTags: { css: string; preloads: string; body: string }, + ): Promise; +}; + +it('a layout reads the frontmatter of the Markdown page it wraps', async () => { + const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_frontmatter_')); + const pages = join(root, 'pages'); + mkdirSync(pages); + writeFileSync( + join(pages, 'layout.svelte'), + ` +

{frontmatter.title}

{@render children()}
+`, + ); + writeFileSync(join(pages, 'index.md'), '---\ntitle: Hello from frontmatter\n---\n\n# Body\n'); + + const server = await createServer({ + root, + configFile: false, + cacheDir: join(root, '.vite'), + logLevel: 'silent', + appType: 'custom', + server: { middlewareMode: true, ws: false }, + resolve: { + alias: { + '@void/svelte/App.svelte': join(import.meta.dirname, '../../src/runtime/App.svelte'), + }, + }, + // The Markdown plugin picks its output from the adapter's pages plugin name. + plugins: [...voidMarkdown(), svelte(), { name: 'void-svelte:pages' }, ...islandsPlugin(pages)], + }); + try { + const renderer = (await server.ssrLoadModule( + 'virtual:void-islands-renderer', + )) as IslandRenderer; + const html = await renderer.renderIslandPage( + { component: 'index', props: {} }, + { css: '', preloads: '', body: '' }, + ); + expect(html).toContain('

Hello from frontmatter

'); + expect(html).toContain('Body'); + } finally { + await server.close(); + rmSync(root, { recursive: true, force: true }); + } +});