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
12 changes: 8 additions & 4 deletions packages/md/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? {}; }
`;
}
Expand All @@ -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) ?? {}; }
`;
}
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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)
Expand All @@ -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 || {} },
Expand All @@ -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)
)
)
);
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/runtime/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export const ParamsContext: Context<Record<string, string> | null> = createConte
export const RouterContext: Context<VoidRouter | null> = createContext<VoidRouter | null>(null);
export const NavigationContext: Context<NavigationState> =
createContext<NavigationState>(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<Record<string, unknown>> = createContext<
Record<string, unknown>
>({});
1 change: 1 addition & 0 deletions packages/react/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
ParamsContext,
RouterContext,
NavigationContext,
FrontmatterContext,
} from './context.ts';
export type { InferProps } from 'void';
export type Deferred<T> = Promise<T>;
56 changes: 56 additions & 0 deletions packages/react/test/integration/md-layout-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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.
/// <reference path="../../../md/src/env.d.ts" />
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<string, unknown> },
assetTags: { css: string; preloads: string; body: string },
): Promise<string>;
};

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 <div><p id="fm">{useFrontmatter().title}</p>{children}</div>;
}`,
);
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('<p id="fm">Hello from frontmatter</p>');
expect(html).toContain('Body</h1>');
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
});
30 changes: 19 additions & 11 deletions packages/solid/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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)
Expand All @@ -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;
}
});
}
});
}
Expand Down
5 changes: 5 additions & 0 deletions packages/solid/src/runtime/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export const RouterContext: Context<VoidRouter | null> = createContext<VoidRoute
export const NavigationContext: Context<(() => 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<Record<string, unknown>> = createContext<
Record<string, unknown>
>({});
1 change: 1 addition & 0 deletions packages/solid/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ export {
ParamsContext,
RouterContext,
NavigationContext,
FrontmatterContext,
} from './context.ts';
export type { DeferredState as Deferred, InferProps } from 'void';
62 changes: 62 additions & 0 deletions packages/solid/test/integration/md-layout-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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.
/// <reference path="../../../md/src/env.d.ts" />
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<string, unknown> },
assetTags: { css: string; preloads: string; body: string },
): Promise<string>;
};

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 <div><p id="fm">{useFrontmatter().title}</p>{props.children}</div>;
}`,
);
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('<p id="fm">Hello from frontmatter</p>');
expect(html).toContain('Body</h1>');
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
});
5 changes: 4 additions & 1 deletion packages/svelte/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
},
Expand Down
5 changes: 5 additions & 0 deletions packages/svelte/src/runtime/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions packages/svelte/test/integration/md-layout-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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.
/// <reference path="../../../md/src/env.d.ts" />
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<string, unknown> },
assetTags: { css: string; preloads: string; body: string },
): Promise<string>;
};

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'),
`<script>
import { useFrontmatter } from "@void/md";
let { children } = $props();
const frontmatter = useFrontmatter();
</script>
<div><p id="fm">{frontmatter.title}</p>{@render children()}</div>
`,
);
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('<p id="fm">Hello from frontmatter</p>');
expect(html).toContain('Body</h1>');
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
});
Loading