aweft

@aweftjs/ssg

Pages from a routed site, at build time and at run time. It renders every page a site declares, writes each one as a file a static host can serve with no configuration, once per language when the site has more than one, and hands the browser one function that takes the page over in place.

It decides nothing else: not where page data comes from, not which host serves the files, not what that host does with a URL it has no file for, and not whether a page it could not enumerate should fail your build.

Quickstart

import { readFileSync } from 'node:fs';
import { createSite } from '@aweftjs/ssg';
import { h } from '@aweftjs/ui';

import { Site } from './site.tsx';

const site = createSite({
	page: (router) => h(Site, { router }),
	shell: readFileSync('dist/index.html', 'utf8'),
	out: 'dist',
	base: 'https://example.com',
});

const written = await site.write();
console.log(written.files);          // every page, 404.html, shell.html, sitemap.xml
console.log(written.unenumerated);   // the acts nothing could list

Four things go in. page builds the whole page from a router, and it is the same function your browser entry mounts, which is what makes the markup here the markup the client renders. shell is the index.html your bundler built, as text. out is the directory. base is the site's absolute URL, needed for the sitemap and the language alternates. Two more for a site in more than one language, below.

The browser half is one import:

import { createRouter } from '@aweftjs/dom/router';
import { attach } from '@aweftjs/ssg/client';

const router = createRouter();
attach(document.body, <Site router={router} />);
router.links(document.body);

attach hydrates a page this package wrote and mounts anything else, so the development server and the generated site share one entry file. It imports mount and hydrate from @aweftjs/ui and nothing else, so a page bundle carries none of the rest of this package. languageOf(document), beside it, reads the language back off a page for the entry of a site in several languages.

A site in several languages

const site = createSite({
	page: (router) => h(Site, { router }),
	shell, out, base: 'https://example.com',
	locale: 'en',
	locales: { fr: JSON.parse(readFileSync('text/fr.json', 'utf8')), uk: JSON.parse(readFileSync('text/uk.json', 'utf8')) },
});

locale is the language the pages are written in, a BCP 47 tag. locales is every other language, each tag to its catalog, the plain object @aweftjs/ui's text tokens look up (its README, The text a page shows). With locales and no locale the site is refused (locale-needed), because the layout below needs to know which language stands unprefixed.

The layout. The source language is written where the site was written before, <url>/index.html; every other language under its tag, /fr/<url>/index.html, with /fr/404.html and /fr/shell.html beside it. A site that adds a language keeps every URL it had, and a host serves every language with the same two rules it served one with. write() renders each URL the walk found once per language, with the language's catalog on the render and a router whose base is the language's prefix, so a link the page writes with router.base in front stays in its language. page('/fr/about') is about in French: the URL carries the language. write(urls) with a list writes a prefixed URL in that language and an unprefixed one in every language.

The document. <html> gains lang with the page's tag, replacing one the shell wrote, and dir="rtl" for a script that runs right to left, which Intl.Locale says and nothing here lists. With a base, every page's head gains a <link rel="alternate" hreflang> per language and an x-default naming the source language's URL, and the sitemap lists every language's URL with the same alternates as xhtml:link. A site with locale alone writes lang and nothing else changes.

The report. The write result gains text: per language, missing, the keys the pages looked up that its catalog has no entry for (those show the source), and unused, the entries no page looked up. It reads what the renders resolved, so it says what the site would show; the build's own report (packages/build/README.md) reads the files, and the two differ on a page compiled at run time. It fails nothing. An application that wants a build to stop on a missing translation reads it and stops.

The entry. languageOf(document) answers { locale, base }: the tag on <html lang>, and /<tag> when the address is under that prefix, '' for the source language and for a site with one language alike. A page with no lang answers '' for both, which finds no catalog and which context() reads as no language, so the entry below is the entry of a site with one language too.

const catalogs = { fr: () => import('./text/fr.json'), uk: () => import('./text/uk.json') };
const { locale, base } = languageOf(document);
const catalog = (await catalogs[locale]?.())?.default;
attach(document.body, <Site router={createRouter({ base })} />, context({ locale, catalog }));

The catalog is the application's own import, one request before attach, and a hydration waits for it. How the visitor's language is chosen is not here: the pages are files, one per language, and a host or a first page sends the reader to the right tree.

The three things a site does

site.walk()

Renders /, reads the stage list ui's render holds, and turns every act the site declares into a URL. It renders each URL it has not rendered yet and stops when a render adds none, which is what finds the pages under a nested stage: a stage inside an act does not exist until that act has been rendered once.

const { urls, unenumerated } = await site.walk();

The rules, per act:

the act keywhat the walk does
plain, no entriesone URL
plain, with entries()one URL, unless entries() answers []
has :name or *rest, with entries()one URL per answered object
has :name or *rest, no entriesnone. The act is reported in unenumerated

An act's URL sits under its stage's prefix, and the prefix is what that stage's parent actually matched: an act declared :page inside a stage that matched posts/3 is /posts/3/:page.

Every declared act is a page. Nothing tells an act you route to from an act you only ever reach with stage.open, so a dialog declared in acts is written out as a page of its own. Say so in its head if you do not want it found:

<Head><Title>The dialog</Title><Meta name="robots" content="noindex" /></Head>

A URL the site has no page for is refused. page(url) and write(urls) throw not-a-page when a URL leaves a stage showing its fallback or showing nothing, because that page is the site's 404.html and writing it at another path publishes a "not found" page on a URL the site claims to have. The limit is what routing can see: an act declared posts/:id matches any id, so a slug that names no row is a page as far as this is concerned, and only your act knows better.

An act with a parameter and no entries() is reported, not refused. A site with a page per database row is normal and a build tool that stops because it cannot see the database is not. Those URLs are answered by shell.html, which mounts live. An application that wants the build to fail reads unenumerated and fails on it.

site.page(url)

One finished document, for a caller that serves or stores a page rather than writing it.

const { html, title, noindex } = await site.page('/posts/hello');

noindex is read off the page's head list, not out of the HTML, so a robots tag that lost its group and was never emitted does not count.

A URL with a query or a hash on it is the page without them: /docs?page=2#top, /docs/ and /docs are one page and one file.

A page whose pending never settles never finishes. A render waits for everything a component declared pending and there is no timeout anywhere in this package, so a fetch that hangs hangs the build. The wait, and the timeout on it, are the application's.

site.write(urls?)

With no list it walks the site and writes everything:

filewhat it is
index.html, <url>/index.htmlone per page. Every static host serves this layout with no configuration
404.htmlthe site rendered at /_aweft-404, a URL a site is not expected to declare, so what it shows is your fallback act. A site that declares a root *rest act matches that URL too, so 404.html is that act's page instead, and the walk reports the *rest act as unenumerated
shell.htmlthe shell as it stands, with no stamp, so attach mounts it live
sitemap.xmlevery indexable page, when the site has a base. With none there is no sitemap and the result says sitemap: null

With a list it writes those pages and touches nothing else: no walk, no 404, no shell, no sitemap. That is the call a running application makes when it publishes one thing, and it is why a scheduled full write is how the sitemap stays current.

await site.write([`/posts/${id}`]);   // inside a request
await site.write();                    // nightly, or at build time

The document

The shell is read as text, not parsed, and four things happen to it:

  • the theme's <style data-aweft> and the page's head tags go at the front of <head>, behind a <meta charset> your shell wrote as the head's first child

  • the shell's own <title> is removed when the page declares one, so the document carries exactly one

  • the markup goes inside <body>, and the body tag gains data-aweft-ssg

  • nothing else is touched

Keep your shell's <body> empty. The page's markup is what goes there, and a hydration refuses anything else it finds. A shell with markup in its body is refused by name, with the fix. A bundler puts its module script in the head, which is where it has to stay.

A shell with no <head> or no <body> is refused too.

What it never decides

Where page data comes from. entries() and your components read whatever they read. This package never opens a store.

How the data reaches the client. A component that waited on the server waits again on the client unless you hand it the value. Write it out beside the pages and read it in your entry before you call attach; recipes/posts-to-pages shows the whole pattern, and dom's README has the reason.

Which host serves the files, or what it does with a URL it has no file for. The layout is chosen so that "the exact file, then <path>/index.html" is enough for any static host. Answering an unenumerated URL with shell.html and everything else with 404.html is the host's rule, not this package's.

Whether a missing entries() fails a build. It is in the report. You decide.

Rendering at request time. This is a Node API. Your build script, your job or your module calls write(). There is no bundler plugin hook.

Which language a visitor gets. A header, a cookie, a setting: the application's, and the pages are one file per language for it to send a reader to. Whether a translation is right, or whether a missing one fails a build. It is in the report.

Known limits

A language tag that is also a top-level segment of the site is the language's. placeOf reads the prefix first, so a site with an act at /de and German under de writes the German home page over the act's page. Name the act something else; a two-letter segment is a language on any site in more than one of them.

The source language is refused in locales (locale-twice), and a tag Intl cannot read is refused (locale-invalid); a tag it can read but has no plural rules for falls back to the host's own rules, which is what Intl does.

Proven by

recipes/ssg builds the routed site, writes it out, serves it and drives it in Chromium: a deep link hydrates with no element the server wrote removed or replaced, a click on the hydrated page is answered, a link changes the act and the title, and a URL nothing enumerated is served the live shell. recipes/posts-to-pages publishes a post over a socket, writes that one page, hydrates it in a browser, and refreshes the sitemap from a scheduled full write. recipes/translated-site writes a site in three languages, reads the report, and hydrates the Ukrainian page in Chromium with nothing the server wrote removed.

The suite in tests/ is the same guarantees stated one at a time, over the light tree @aweftjs/dom ships, plus a Chromium run for attach.

Boundaries

An integrator: it may import anything, and nothing imports it. It is the only package here that writes files, and @aweftjs/ssg/client is the half that does not, so a page bundle never reaches node:fs through it.

API

Every export of @aweftjs/ssg, its signature as the compiler resolves it, and its block comment.

@aweftjs/ssg

PageResult

interface PageResult { readonly html: string; readonly title: string | null; readonly noindex: boolean; }

One finished page.

Site

interface Site { walk(): Promise<WalkResult>; page(url: string): Promise<PageResult>; write(urls?: readonly string[]): Promise<WriteResult>; }

A site, ready to be walked, rendered or written.

SiteOptions

interface SiteOptions { page(router: Router): unknown; readonly shell: string; readonly out: string; readonly base?: string; readonly locale?: string; readonly locales?: Readonly<Record<string, Catalog>>; }

What createSite takes.

TextReport

interface TextReport { readonly missing: readonly string[]; readonly unused: readonly string[]; }

What a language's catalog lacks and holds beyond what the pages looked up.

Unenumerated

interface Unenumerated { readonly prefix: string; readonly name: string; }

An act with a parameter in its key and no entries(), so a walk cannot say what its URLs are.

WalkResult

interface WalkResult { readonly urls: readonly string[]; readonly unenumerated: readonly Unenumerated[]; }

What a walk found.

WriteResult

interface WriteResult { readonly files: readonly string[]; readonly urls: readonly string[]; readonly unenumerated: readonly Unenumerated[]; readonly sitemap: string | null; readonly text: Readonly<Record<string, TextReport>>; }

What a write did.

createSite

createSite: (options: SiteOptions) => Site

Make a site.

Params

  • page: builds the page from a router. The browser entry calls it with a live router; this calls it with one made from each URL, with no window anywhere

  • shell: the page shell the bundler built, as text

  • out: the directory to write into

  • base: the site's absolute URL. Without it there is no sitemap and no alternates, and the write result says so

  • locale: the language the pages are written in; locales: the other languages and their catalogs (design 279)

Returns the site.

Throws locale-needed for locales with no locale, because the layout needs to know which language stands unprefixed. Otherwise nothing here: walk, page and write throw what a render throws; page refuses a shell it cannot read (shell-head, shell-body, shell-body-content) and a URL the site has no page for (not-a-page).

Example

const site = createSite({
  page: (router) => h(Site, { router }),
  shell: readFileSync('dist/index.html', 'utf8'),
  out: 'dist',
  base: 'https://example.com',
});
const written = await site.write();

@aweftjs/ssg/client

PageLanguage

interface PageLanguage { readonly locale: string; readonly base: string; }

Where a page stands: its language, and the router base its URLs carry.

attach

attach: (target: ParentLike, item: unknown, render?: Render | undefined) => Remove

Take over a generated page, or mount a live one, whichever this document is.

Params

  • target: the element the page lives in, document.body for a page ssg wrote

  • item: the same item the site was rendered from. A component call, h(Site, props), or a function that makes it, which is mounted as a component with no props either way

  • render: the ui systems to use. Omitted, the document's shared render is used, as mount and hydrate do

Returns the removal, exactly as mount and hydrate answer it. A page ssg wrote carries data-aweft-ssg on its body and is hydrated: the server's elements are adopted in place and nothing flashes. Anything else is mounted. The choice cannot be left to the application, because both mistakes are silent in a different way: mounting over server markup renders the page twice, and hydrating an empty body says the markup ran out.

Example

const router = createRouter();
attach(document.body, h(Site, { router }));
router.links(document.body);

languageOf

languageOf: (document: { readonly documentElement?: { getAttribute?(name: string): string | null; } | null | undefined; readonly location?: { readonly pathname?: string | undefined; } | null | undefined; }) => PageLanguage

The language a generated page is in, and the base its router needs.

Params

  • document: the page's document, or anything with a documentElement carrying lang and a location with a pathname; the browser's document and window are read when the argument has no location of its own

Returns the tag on <html lang> and the prefix the address carries when its first segment is that tag. A site with one language answers '' for both, so an entry written this way works before the site has a second language.

Example

const { locale, base } = languageOf(document);
const catalog = locale === 'en' ? undefined : (await import(`./text/${locale}.json`)).default;
attach(document.body, h(Site, { router: createRouter({ base }) }), context({ locale, catalog }));

Refusals

Every error this package raises carries a reason to switch on and a fix that says what to do. These are its reasons, from errors.txt.

reasonfix
bad-entriesAnswer an array of objects from entries(), one per page, an empty array for none, or null to say you cannot list them.
bad-entry-valueAnswer a value with no empty, . or .. segment in it; those name a place rather than a page.
locale-invalidWrite a BCP 47 tag such as fr or fr-CA.
locale-neededName the language the pages are written in as locale; its pages stand unprefixed and every other language under its tag.
locale-twiceLeave the source language out of locales: its pages stand unprefixed and need no catalog.
missing-parameterAnswer every :name and *name in the act key from entries(), as a string.
not-a-pageAsk for a URL the site has; walk() answers the list, and the fallback is written once as 404.html.
not-a-pathWrite each page at the path its URL names; a . or a .. in it names a place rather than a page.
outside-outKeep every page URL under the site root; a parameter that answers .. is not a page.
shell-bodyGive the page shell a body element after its head; the page's markup goes inside it.
shell-body-contentMove it into the head or into a component; the generated body holds the page's markup and a hydration refuses anything else.
shell-headGive the page shell a head element; the stylesheet and the page's head tags go at the front of it.

Recipes

The programs in the stack's gate that use this package, each a job someone would have.

  • recipes/ssg: A routed site written out as files, served by anything, and taken over in place when the browser gets to it

  • recipes/translated-site: A site written in one language and launched in three: the build finds every string and writes the catalog an agent fills, each language is a tree of pages with the plural rules of its own, a stored act is compiled where it runs, and the Ukrainian page hydrates in place

  • recipes/static: A generated site served by the stack's own server: one process is the whole deployment, and the page still comes alive where it stands

  • recipes/posts-to-pages: Pages written while the application runs: a post published over a socket becomes a page, and a scheduled full write refreshes the sitemap