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
4 changes: 3 additions & 1 deletion docs/guide/pages-routing/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Welcome to Void. Here's an interactive demo:
```

- **`<script>` block:** extracted before markdown compilation. Island imports use `with { island: "..." }` syntax (see [islands](./islands)). Any other code in the block becomes a [client script](#client-scripts) that runs in the browser.
- **Frontmatter:** YAML metadata between `---` fences. Layouts can read it through [`useFrontmatter()`](#frontmatter-access).
- **Frontmatter:** YAML metadata between `---` fences. Layouts and islands can read it through [`useFrontmatter()`](#frontmatter-access).
- **Body:** standard markdown plus GFM. Uppercase tags such as `<Counter />` reference imported components and render as islands.

## Client Scripts
Expand Down Expand Up @@ -230,6 +230,8 @@ export default function DocsLayout(props) {

:::

Island components on a Markdown page can call `useFrontmatter()` too. A page with islands includes its frontmatter in the HTML for them to read in the browser. Keep private values out of the frontmatter of those pages.

## Pages Virtual Module

Import `@void/md/pages` to get metadata for all markdown pages at build time. Use it to build sidebars, navigation, or search indexes:
Expand Down
17 changes: 14 additions & 3 deletions packages/react/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ import React from "react";
import { renderToString } from "react-dom/server";
import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/react";
import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head";
import { islandFrontmatterScript } from "void/pages-server";

${generateComponentManifest(scan, pagesDir)}
${mdScriptIdsCode}
Expand Down Expand Up @@ -293,6 +294,11 @@ export async function renderIslandPage(pageObj, assetTags) {
);

const html = renderToString(element);
// Islands hydrate as separate roots. Send the page frontmatter so their
// useFrontmatter() returns the same value in the browser.
const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=")
? islandFrontmatterScript(__pageModule.frontmatter)
: "";
const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : "";
const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : "";
const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : "";
Expand All @@ -303,7 +309,7 @@ export async function renderIslandPage(pageObj, assetTags) {
<head>\${headHtml}\${PREAMBLE_SCRIPT}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}</head>
<body\${bodyAttrs}>
<div id="app"\${__mdScriptAttr}>\${html}</div>
\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
\${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
</body>
</html>\`;
}
Expand Down Expand Up @@ -345,15 +351,20 @@ function generateIslandClientEntry(
lines.push('};');

lines.push(`
async function hydrateIsland(el, mod, props) {
async function hydrateIsland(el, mod, props, frontmatter) {
const { hydrateRoot } = await import("react-dom/client");
const React = await import("react");
const Component = mod.default || mod;
let island = React.createElement(Component, props);
if (Object.keys(frontmatter).length > 0) {
const { FrontmatterContext } = await import("@void/react");
island = React.createElement(FrontmatterContext.Provider, { value: frontmatter }, island);
}
function HydratedIsland() {
React.useLayoutEffect(() => {
el.setAttribute("data-hydrated", "true");
}, []);
return React.createElement(Component, props);
return island;
}
hydrateRoot(el, React.createElement(HydratedIsland));
}
Expand Down
53 changes: 53 additions & 0 deletions packages/react/test/integration/md-layout-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ 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 { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts';
import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts';
import { voidMarkdown } from '../../../md/src/plugin.ts';
import { islandsPlugin } from '../../src/plugin-islands.ts';

const ISLAND_FRONTMATTER_SCRIPT =
'<script type="application/json" id="__void_frontmatter">{"title":"Hello from frontmatter"}</script>';

type IslandRenderer = {
renderIslandPage(
page: { component: string; props: Record<string, unknown> },
Expand Down Expand Up @@ -49,8 +54,56 @@ export default function Layout({ children }) {
);
expect(html).toContain('<p id="fm">Hello from frontmatter</p>');
expect(html).toContain('Body</h1>');
// No island on the page, so the frontmatter stays on the server.
expect(html).not.toContain('__void_frontmatter');
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
});

it('an island reads the same frontmatter in the browser as on the server', async () => {
const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_'));
mkdirSync(join(root, 'pages'));
mkdirSync(join(root, 'components'));
writeFileSync(
join(root, 'pages', 'layout.island.tsx'),
`import Title from "../components/Title" with { island: "load" };
export default function Layout({ children }) {
return <div><Title />{children}</div>;
}`,
);
writeFileSync(
join(root, 'components', 'Title.tsx'),
`import { createElement } from "react";
import { useFrontmatter } from "@void/md";
export default function Title() {
return createElement("p", { id: "island-fm" }, String(useFrontmatter().title));
}`,
);
writeFileSync(
join(root, 'pages', 'index.md'),
'---\ntitle: Hello from frontmatter\n---\n\n# Body\n',
);

let visit: IslandPageVisit | undefined;
try {
visit = await openIslandPage(root, {
alias: { '@void/react': join(import.meta.dirname, '../../src/runtime/index.ts') },
plugins: [
...voidMarkdown(),
{ name: 'void-react:pages' },
...islandsPlugin(join(root, 'pages')),
],
});
expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT);
await waitForIslandsHydrated(visit.page);
expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter');
// A server/browser mismatch makes React log a hydration error and re-render the island.
expect(visit.diagnostics().toLowerCase()).not.toContain('hydrat');
expect(visit.diagnostics()).not.toContain('pageerror');
} finally {
await visit?.close();
rmSync(root, { recursive: true, force: true });
}
});
25 changes: 22 additions & 3 deletions packages/solid/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ import { renderToStringAsync, generateHydrationScript } from "solid-js/web";
import { createComponent } from "solid-js";
import { SharedContext, ErrorsContext, ParamsContext, FrontmatterContext } from "@void/solid";
import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head";
import { islandFrontmatterScript } from "void/pages-server";

${generateComponentManifest(scan, pagesDir)}
${mdScriptIdsCode}
Expand Down Expand Up @@ -311,6 +312,11 @@ export async function renderIslandPage(pageObj, assetTags) {

const html = await renderToStringAsync(() => createComponent(ServerApp, {}));

// Islands hydrate as separate roots. Send the page frontmatter so their
// useFrontmatter() returns the same value in the browser.
const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=")
? islandFrontmatterScript(__pageModule.frontmatter)
: "";
const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : "";
const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : "";
const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : "";
Expand All @@ -321,7 +327,7 @@ export async function renderIslandPage(pageObj, assetTags) {
<head>\${headHtml}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}\${__pagesNeedingClientJS.has(pageObj.component) ? generateHydrationScript() : ""}</head>
<body\${bodyAttrs}>
<div id="app"\${__mdScriptAttr}>\${html}</div>
\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
\${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
</body>
</html>\`;
}
Expand Down Expand Up @@ -367,12 +373,25 @@ function generateIslandClientEntry(
// providers and layouts). render() replaces the server HTML with a fresh
// client render — the brief re-render is acceptable for interactive islands.
lines.push(`
async function mountIsland(el, mod, props) {
async function mountIsland(el, mod, props, frontmatter) {
const { render } = await import("solid-js/web");
const { createComponent } = await import("solid-js");
const Component = mod.default || mod;
const { FrontmatterContext } =
Object.keys(frontmatter).length > 0 ? await import("@void/solid") : {};
el.textContent = "";
render(() => createComponent(Component, props), el);
render(
() =>
FrontmatterContext
? createComponent(FrontmatterContext.Provider, {
value: frontmatter,
get children() {
return createComponent(Component, props);
},
})
: createComponent(Component, props),
el,
);
el.setAttribute("data-hydrated", "true");
}

Expand Down
51 changes: 51 additions & 0 deletions packages/solid/test/integration/md-layout-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { join } from 'node:path';
import { createServer } from 'vite';
import solid from 'vite-plugin-solid';
import { expect, it } from 'vite-plus/test';
import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts';
import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts';
import { voidMarkdown } from '../../../md/src/plugin.ts';
import { islandsPlugin } from '../../src/plugin-islands.ts';

const ISLAND_FRONTMATTER_SCRIPT =
'<script type="application/json" id="__void_frontmatter">{"title":"Hello from frontmatter"}</script>';

type IslandRenderer = {
renderIslandPage(
page: { component: string; props: Record<string, unknown> },
Expand Down Expand Up @@ -55,8 +60,54 @@ export default function Layout(props) {
);
expect(html).toContain('<p id="fm">Hello from frontmatter</p>');
expect(html).toContain('Body</h1>');
// No island on the page, so the frontmatter stays on the server.
expect(html).not.toContain('__void_frontmatter');
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
});

it('an island reads the same frontmatter in the browser as on the server', async () => {
const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_'));
mkdirSync(join(root, 'pages'));
mkdirSync(join(root, 'components'));
writeFileSync(
join(root, 'pages/layout.island.tsx'),
`import Title from "../components/Title" with { island: "load" };
export default function Layout(props) {
return <div><Title />{props.children}</div>;
}`,
);
writeFileSync(
join(root, 'components/Title.tsx'),
`import { useFrontmatter } from "@void/md";
export default function Title() {
return <p id="island-fm">{String(useFrontmatter().title)}</p>;
}`,
);
writeFileSync(
join(root, 'pages', 'index.md'),
'---\ntitle: Hello from frontmatter\n---\n\n# Body\n',
);

let visit: IslandPageVisit | undefined;
try {
visit = await openIslandPage(root, {
alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') },
plugins: [
...voidMarkdown(),
solid({ ssr: true }),
{ name: 'void-solid:pages' },
...islandsPlugin(join(root, 'pages')),
],
});
expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT);
await waitForIslandsHydrated(visit.page);
expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter');
expect(visit.diagnostics()).not.toContain('pageerror');
} finally {
await visit?.close();
rmSync(root, { recursive: true, force: true });
}
});
13 changes: 10 additions & 3 deletions packages/svelte/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ function generateIslandRenderer(
import { render } from "svelte/server";
import App from "@void/svelte/App.svelte";
import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head";
import { islandFrontmatterScript } from "void/pages-server";

${generateComponentManifest(scan, pagesDir)}
${mdScriptIdsCode}
Expand Down Expand Up @@ -372,6 +373,11 @@ export async function renderIslandPage(pageObj, assetTags) {
},
});

// Islands hydrate as separate roots. Send the page frontmatter so their
// useFrontmatter() returns the same value in the browser.
const __frontmatterScript = __pagesNeedingClientJS.has(pageObj.component) && html.includes("data-island=")
? islandFrontmatterScript(__pageModule.frontmatter)
: "";
const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : "";
const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : "";
const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : "";
Expand All @@ -382,7 +388,7 @@ export async function renderIslandPage(pageObj, assetTags) {
<head>\${headHtml}\${head}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}</head>
<body\${bodyAttrs}>
<div id="app"\${__mdScriptAttr}>\${html}</div>
\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
\${__frontmatterScript}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""}
</body>
</html>\`;
}
Expand Down Expand Up @@ -430,10 +436,11 @@ function generateIslandClientEntry(
lines.push('};');

lines.push(`
async function hydrateIsland(el, mod, props) {
async function hydrateIsland(el, mod, props, frontmatter) {
const { hydrate } = await import("svelte");
const Component = mod.default || mod;
hydrate(Component, { target: el, props });
const context = new Map([["__void_frontmatter", frontmatter]]);
hydrate(Component, { target: el, props, context });
el.setAttribute("data-hydrated", "true");
}

Expand Down
59 changes: 59 additions & 0 deletions packages/svelte/test/integration/md-layout-frontmatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { join } from 'node:path';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { createServer } from 'vite';
import { expect, it } from 'vite-plus/test';
import { waitForIslandsHydrated } from '../../../../test/browser-helpers.ts';
import { openIslandPage, type IslandPageVisit } from '../../../../test/island-page-browser.ts';
import { voidMarkdown } from '../../../md/src/plugin.ts';
import { islandsPlugin } from '../../src/plugin-islands.ts';

const ISLAND_FRONTMATTER_SCRIPT =
'<script type="application/json" id="__void_frontmatter">{"title":"Hello from frontmatter"}</script>';

type IslandRenderer = {
renderIslandPage(
page: { component: string; props: Record<string, unknown> },
Expand Down Expand Up @@ -57,8 +62,62 @@ it('a layout reads the frontmatter of the Markdown page it wraps', async () => {
);
expect(html).toContain('<p id="fm">Hello from frontmatter</p>');
expect(html).toContain('Body</h1>');
// No island on the page, so the frontmatter stays on the server.
expect(html).not.toContain('__void_frontmatter');
} finally {
await server.close();
rmSync(root, { recursive: true, force: true });
}
});

it('an island reads the same frontmatter in the browser as on the server', async () => {
const root = mkdtempSync(join(import.meta.dirname, '__tmp_md_island_frontmatter_'));
mkdirSync(join(root, 'pages'));
mkdirSync(join(root, 'components'));
writeFileSync(
join(root, 'pages/layout.island.svelte'),
`<script>
import Title from "../components/Title.svelte" with { island: "load" };
let { children } = $props();
</script>
<div><Title />{@render children()}</div>
`,
);
writeFileSync(
join(root, 'components/Title.svelte'),
`<script>
import { useFrontmatter } from "@void/md";
const frontmatter = useFrontmatter();
</script>
<p id="island-fm">{String(frontmatter.title)}</p>
`,
);
writeFileSync(
join(root, 'pages', 'index.md'),
'---\ntitle: Hello from frontmatter\n---\n\n# Body\n',
);

let visit: IslandPageVisit | undefined;
try {
visit = await openIslandPage(root, {
alias: {
'@void/svelte/App.svelte': join(import.meta.dirname, '../../src/runtime/App.svelte'),
},
plugins: [
...voidMarkdown(),
svelte(),
{ name: 'void-svelte:pages' },
...islandsPlugin(join(root, 'pages')),
],
});
expect(visit.html).toContain(ISLAND_FRONTMATTER_SCRIPT);
await waitForIslandsHydrated(visit.page);
expect(await visit.page.textContent('#island-fm')).toBe('Hello from frontmatter');
// A server/browser mismatch makes Svelte warn about hydration and re-render the island.
expect(visit.diagnostics().toLowerCase()).not.toContain('hydrat');
expect(visit.diagnostics()).not.toContain('pageerror');
} finally {
await visit?.close();
rmSync(root, { recursive: true, force: true });
}
});
Loading
Loading