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 }); + } +});