# @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

```ts
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:

```tsx
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

```ts
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`](/docs/packages/build)) 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.

```tsx
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.

```ts
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:

```tsx
<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.

```ts
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.

```ts
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`](/docs/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`](/docs/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`](/docs/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`](/docs/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`

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

One finished page.

#### `Site`

```ts
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`

```ts
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`

```ts
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`

```ts
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`

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

What a walk found.

#### `WriteResult`

```ts
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`

```ts
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**

```ts
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`

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

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

#### `attach`

```ts
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**

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

#### `languageOf`

```ts
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**

```ts
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`](/docs/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`](/docs/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`](/docs/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`](/docs/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
