Spec: Written ahead of the package. Not in 7.0.0-alpha.0: site7.0.0-alpha.0

Ship a Site

Turn a folder of records into one live Site with its 404 page and a contact flow, from a Node script, Vite, React Router or a Cloudflare Worker.

Pick where the Site ships. Every target below runs in this site's checks, on published exports only.

Prerequisites

  • Node.js 22.18 or later, and @mdxui/site installed as in the Quickstart.
  • Records in pages/: home.json, a 404.json, and one Theme across all of them. The 10 records of Harbor tides are a whole Site to start from.
Target

Read every record

Each file in pages/ is parsed and given its address: contact/sent.json is /contact/sent. A record that does not parse stops the run at its path.

ship.ts
const origin = 'https://harbor-tides.example'; // the Site's origin, for sitemap.xml

// Every record in pages/: home.json is /, contact/sent.json is /contact/sent.
const pages: { path: string; page: Page }[] = [];
for (const file of (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json'))) {
  const page = PageSchema.parse(JSON.parse(await readFile(`pages/${file}`, 'utf8')));
  pages.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page });
}
pages.sort((a, b) => a.path.localeCompare(b.path));

Build whole or not at all

Before it writes a file, ship.ts stops on a missing 404 page or a second Theme. Without pages/404.json, then with one record under directory:

Error: pages/404.json is missing: a Site has a 404 page
Error: the records name 2 Themes, quaypoint and directory: a Site has one
ship.ts · whole
// A Site renders whole or not at all: a 404 page, and one Theme for every Page.
if (!pages.some((p) => p.path === '/404')) throw new Error('pages/404.json is missing: a Site has a 404 page');
const themes = new Set(pages.map((p) => p.page.theme));
if (themes.size > 1) throw new Error(`the records name ${themes.size} Themes, ${[...themes].join(' and ')}: a Site has one`);

Render every page

node ship.ts writes each page, then sitemap.xml and robots.txt. Run on the Harbor tides records:

dist/index.html
dist/404.html
dist/blog.html
dist/blog/tide-windows.html
dist/contact.html
dist/contact/sent.html
dist/integrations.html
dist/integrations/chartplot.html
dist/pricing.html
dist/terms.html
dist/sitemap.xml
dist/robots.txt

10 pages, then sitemap.xml, which lists 8 of them:

dist/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://harbor-tides.example/</loc></url>
  <url><loc>https://harbor-tides.example/blog</loc></url>
  <url><loc>https://harbor-tides.example/blog/tide-windows</loc></url>
  <url><loc>https://harbor-tides.example/contact</loc></url>
  <url><loc>https://harbor-tides.example/integrations</loc></url>
  <url><loc>https://harbor-tides.example/integrations/chartplot</loc></url>
  <url><loc>https://harbor-tides.example/pricing</loc></url>
  <url><loc>https://harbor-tides.example/terms</loc></url>
</urlset>

To render under your own Theme, swap renderPage for render from site.ts.

ship.ts · write
const write = async (file: string, text: string) => {
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, text);
  console.log(file);
};
for (const { path, page } of pages) await write(`dist${path === '/' ? '/index' : path}.html`, renderPage(page));

// Every page a search engine may list: not the 404, and no record marked noindex.
const listed = pages.filter((p) => p.path !== '/404' && !p.page.meta?.noindex);
await write(
  'dist/sitemap.xml',
  `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${listed.map((p) => `  <url><loc>${origin}${p.path}</loc></url>`).join('\n')}\n</urlset>\n`,
);
await write('dist/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n`);

Serve it, 404 included

Upload the output to a static host whose not-found page is 404.html. On Cloudflare, wrangler.jsonc sets not_found_handling, so any unknown address answers 404 with that page, and runs the Worker only for /contact/send. Deploy with pnpm wrangler deploy.

ship.ts · write
const write = async (file: string, text: string) => {
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, text);
  console.log(file);
};
for (const { path, page } of pages) await write(`dist${path === '/' ? '/index' : path}.html`, renderPage(page));

// Every page a search engine may list: not the 404, and no record marked noindex.
const listed = pages.filter((p) => p.path !== '/404' && !p.page.meta?.noindex);
await write(
  'dist/sitemap.xml',
  `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${listed.map((p) => `  <url><loc>${origin}${p.path}</loc></url>`).join('\n')}\n</urlset>\n`,
);
await write('dist/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n`);

Receive the contact form

cta-form posts, and a static host answers only GET. worker.ts takes the post, forwards the fields to CONTACT and redirects to the sent page:

POST /contact/send
  forwards POST https://forms.harbor-tides.example/inbox: name, email, topic, message
  answers 303 See Other, location: /contact/sent

The worked Sites on these docs are served as static files, so their forms do not send.

worker.ts
// Runs only for POST /contact/send (wrangler.jsonc, assets.run_worker_first); every other path is a file in dist/.
export default {
  async fetch(request: Request, env: { CONTACT: string }): Promise<Response> {
    if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
    const form = await request.formData();
    // Forward the form, field for field, to the Site's contact address.
    const sent = await fetch(env.CONTACT, { method: 'POST', body: form });
    if (!sent.ok) return new Response('The message was not sent. Try again.', { status: 502 });
    return Response.redirect(new URL('/contact/sent', request.url), 303);
  },
};

Result

Every record is a page at its address, and any other address answers 404 with your 404 page. The Harbor tides records ship the same way, at /sites/harbor/.

Result
Harbor tides › 404, served for any unknown address · quaypointOpen the render
const origin = 'https://harbor-tides.example'; // the Site's origin, for sitemap.xml

// Every record in pages/: home.json is /, contact/sent.json is /contact/sent.
const pages: { path: string; page: Page }[] = [];
for (const file of (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json'))) {
  const page = PageSchema.parse(JSON.parse(await readFile(`pages/${file}`, 'utf8')));
  pages.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page });
}
pages.sort((a, b) => a.path.localeCompare(b.path));

Next steps

On this page