@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 listFour 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 key | what the walk does |
|---|---|
plain, no entries | one 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 entries | none. 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:
| file | what it is |
|---|---|
index.html, <url>/index.html | one per page. Every static host serves this layout with no configuration |
404.html | the 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.html | the shell as it stands, with no stamp, so attach mounts it live |
sitemap.xml | every 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 timeThe 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 childthe shell's own
<title>is removed when the page declares one, so the document carries exactly onethe markup goes inside
<body>, and the body tag gainsdata-aweft-ssgnothing 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) => SiteMake 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 anywhereshell: the page shell the bundler built, as textout: the directory to write intobase: the site's absolute URL. Without it there is no sitemap and no alternates, and the write result says solocale: 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) => RemoveTake over a generated page, or mount a live one, whichever this document is.
Params
target: the element the page lives in,document.bodyfor a pagessgwroteitem: 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 wayrender: theuisystems to use. Omitted, the document's shared render is used, asmountandhydratedo
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; }) => PageLanguageThe language a generated page is in, and the base its router needs.
Params
document: the page's document, or anything with adocumentElementcarryinglangand alocationwith apathname; the browser'sdocumentandwindoware 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.
| reason | fix |
|---|---|
bad-entries | Answer an array of objects from entries(), one per page, an empty array for none, or null to say you cannot list them. |
bad-entry-value | Answer a value with no empty, . or .. segment in it; those name a place rather than a page. |
locale-invalid | Write a BCP 47 tag such as fr or fr-CA. |
locale-needed | Name the language the pages are written in as locale; its pages stand unprefixed and every other language under its tag. |
locale-twice | Leave the source language out of locales: its pages stand unprefixed and need no catalog. |
missing-parameter | Answer every :name and *name in the act key from entries(), as a string. |
not-a-page | Ask for a URL the site has; walk() answers the list, and the fallback is written once as 404.html. |
not-a-path | Write each page at the path its URL names; a . or a .. in it names a place rather than a page. |
outside-out | Keep every page URL under the site root; a parameter that answers .. is not a page. |
shell-body | Give the page shell a body element after its head; the page's markup goes inside it. |
shell-body-content | Move it into the head or into a component; the generated body holds the page's markup and a hydration refuses anything else. |
shell-head | Give 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 itrecipes/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 placerecipes/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 standsrecipes/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