# recipes/ssg/main.ts

One file of [`recipes/ssg`](/docs/recipes/ssg), at commit `9a5bb24`.

```ts
// A routed site written out as files, served, and taken over in a real browser.
//
// The job: a site with real URLs is built once and served by anything that can serve a directory.
// Every page is a file, the pages nobody could enumerate are still reachable, and the page a
// reader lands on is live the moment its bundle runs, without being rebuilt underneath them.
//
// Run: AWEFT_DEFAULT_H=@aweftjs/ui node --import @aweftjs/build/loader recipes/ssg/main.ts
// Serve it instead, to click around: npx vite recipes/ssg

import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';

import { build } from 'vite';
import { chromium } from 'playwright';

import { createSite } from '@aweftjs/ssg';
import { h } from '@aweftjs/ui';

import { Page } from './page.tsx';

const here = fileURLToPath(new URL('.', import.meta.url));
const dist = join(here, 'dist');

// --- the client bundle, and the shell it leaves behind -------------------------------------------

console.log('building the client bundle through aweft({ defaultH })');
await build({ configFile: join(here, 'vite.config.ts'), logLevel: 'warn' });

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

// --- every page, written out ---------------------------------------------------------------------

const written = await site.write();

const wanted = [
	'index.html',
	'docs/index.html',
	'docs/install/index.html',
	'docs/concepts/index.html',
	'posts/hello/index.html',
	'posts/second/index.html',
	'posts/third/index.html',
	'about/index.html',
	'dialog/index.html',
	'404.html',
	'shell.html',
	'sitemap.xml',
];
for (const name of wanted) assert.ok(existsSync(join(dist, name)), `${name} was written`);
assert.deepEqual([...written.files].sort(), [...wanted].sort(), 'and nothing else was');

// `tags/:tag` declares no `entries()`, so the walk cannot say which tags exist. It reports the act
// and writes nothing for it; the live shell is what answers those URLs.
assert.deepEqual(written.unenumerated, [{ prefix: '', name: 'tags/:tag' }],
	'the one act nothing could enumerate is reported');
assert.ok(!existsSync(join(dist, 'tags')), 'and no page was invented for it');

// An act reached by `open` rather than by a URL is still a declared act, so it is still a page.
assert.ok(existsSync(join(dist, 'dialog/index.html')), 'a declared act is a page even when nothing links to it');

// The stage's `fallback` is the one act that is not. It is written once, as 404.html.
assert.ok(!existsSync(join(dist, 'missing')), 'the fallback act is not a page of its own');

console.log(`  ${written.urls.length} pages, ${written.files.length} files, 1 act reported as unenumerated`);

// --- the sitemap ------------------------------------------------------------------------------------

const sitemap = readFileSync(join(dist, 'sitemap.xml'), 'utf8');
assert.ok(sitemap.includes('<loc>https://example.com/</loc>'), 'the sitemap has the site root');
assert.ok(sitemap.includes('<loc>https://example.com/posts/hello</loc>'), 'and a page from entries()');
assert.ok(!sitemap.includes('/dialog'), 'and not the page whose head says robots noindex');
assert.ok(!sitemap.includes('404'), 'and not the fallback, which is not a URL of the site');
assert.ok(!sitemap.includes('/missing'), 'and not the fallback act under its own name either');
assert.ok(!sitemap.includes('/tags/'), 'and not an act nothing enumerated');

// --- the 404 and the live shell ---------------------------------------------------------------------

const notFound = readFileSync(join(dist, '404.html'), 'utf8');
assert.match(notFound, /<title[^>]*>Not found<\/title>/, '404.html is the fallback act, with its own title');
assert.ok(notFound.includes('data-aweft-ssg'), 'and it is a generated page, so a host that serves it hydrates');
assert.ok(!notFound.includes('_aweft-404'), 'the URL it was rendered at is nowhere in it');

assert.equal(readFileSync(join(dist, 'shell.html'), 'utf8'), shell, 'shell.html is the shell, unchanged');
assert.ok(!shell.includes('data-aweft-ssg'), 'with no stamp on it, so attach mounts it live');

// --- one page at a time, which is what a running application does ------------------------------------

const scratch = mkdtempSync(join(tmpdir(), 'aweft-ssg-one-'));
try {
	const one = createSite({ page: (router) => h(Page, { router }), shell, out: scratch });
	const partial = await one.write(['/posts/second']);
	assert.deepEqual(partial.files, ['posts/second/index.html'], 'a list writes those pages');
	assert.equal(partial.sitemap, null, 'and no sitemap, because a sitemap is about every page');
	assert.deepEqual(partial.unenumerated, [], 'and it does not walk, so it reports nothing');
	assert.ok(!existsSync(join(scratch, 'index.html')), 'and nothing else is touched');

	// A URL the site's routing does not match is refused by name rather than written out as the
	// fallback page, which would publish a "not found" page at a URL the site claims to have.
	await assert.rejects(() => one.write(['/no/such/route']),
		(error: Error & { reason?: string }) => {
			assert.equal(error.reason, 'not-a-page');
			return true;
		});
	assert.ok(!existsSync(join(scratch, 'no')), 'and nothing was written for it');

	// `posts/:id` matches any id, so a slug that names no post is a page as far as the routing is
	// concerned. What that page shows is the act's business, and no build tool can see it.
	const typo = await one.write(['/posts/a-slug-with-a-typo']);
	assert.deepEqual(typo.files, ['posts/a-slug-with-a-typo/index.html'],
		'a matched route with an unknown parameter is written, because only the act knows better');
} finally {
	rmSync(scratch, { recursive: true, force: true });
}

// --- one .tsx, two compilers -------------------------------------------------------------------------

// `banner.tsx` binds no `h` at all. The bundler was told `defaultH` in `vite.config.ts` and this
// process was told the same thing in `AWEFT_DEFAULT_H`, so both give it `ui`'s. Compiled against
// `dom`'s instead, `theme` would be written out as a literal attribute and the class would be
// missing, and the hydration below would find the two documents disagreeing.
const install = readFileSync(join(dist, 'docs/install/index.html'), 'utf8');
const banner = /<p id="banner"[^>]*>/.exec(install);
assert.ok(banner !== null, 'the banner is on the generated page');
assert.match(banner[0], /class="[^"]+"/, 'the server compiled it with ui\'s h, so theme became a class');
assert.ok(!banner[0].includes('theme='), 'and theme is not sitting on the element as an attribute');

console.log('  one .tsx with no h of its own, compiled the same way on both sides');

// --- serving the directory -------------------------------------------------------------------------

const TYPES: Record<string, string> = {
	'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.xml': 'application/xml',
};

/**
 * A host, as simply as a host can be: the exact file, then the directory's `index.html`, then the
 * live shell for a URL only the client can render, then the 404. `server`'s routes are exact, so
 * a directory would be a route per file there.
 */
const serve = async (): Promise<{ url: string; close(): Promise<void> }> => {
	const server = createServer((request, response) => {
		const path = decodeURIComponent((request.url ?? '/').split('?')[0]!);
		const asked = join(dist, normalize(path));
		const tries = asked === dist || asked.startsWith(`${dist}/`)
			? [asked, join(asked, 'index.html'), join(dist, 'shell.html'), join(dist, '404.html')]
			: [];
		for (const file of tries) {
			if (!existsSync(file) || !statSync(file).isFile()) continue;
			response.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
			response.end(readFileSync(file));
			return;
		}
		response.writeHead(404).end();
	});
	await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
	const port = (server.address() as { port: number }).port;
	return {
		url: `http://127.0.0.1:${port}`,
		close: () => new Promise<void>((resolve) => { server.close(() => resolve()); }),
	};
};

// --- the same site, in a real browser ------------------------------------------------------------

interface Probe {
	readonly removed: readonly string[];
	readonly added: readonly string[];
	readonly makes: number;
	readonly ms: number;
}

const host = await serve();
const browser = await chromium.launch();
const view = await browser.newPage({ viewport: { width: 900, height: 600 } });
const problems: string[] = [];
view.on('pageerror', (error) => problems.push(String(error)));

// Installed before the page's own scripts, so it is watching before the bundle runs.
await view.addInitScript(() => {
	const makes: number[] = [];
	const removed: string[] = [];
	const added: string[] = [];

	const real = document.createElement;
	document.createElement = (tag: string) => {
		makes.push(performance.now());
		return real.call(document, tag);
	};

	new MutationObserver((records) => {
		// While the parser is writing the page, every node it puts in is a mutation. The hydration
		// runs after that, when the document is interactive, and that is the only part this watches.
		if (document.readyState === 'loading') return;
		const body = document.body;
		if (body === null) return;
		for (const record of records) {
			if (!body.contains(record.target)) continue;
			for (const node of record.removedNodes) if (node.nodeType === 1) removed.push(node.nodeName.toLowerCase());
			for (const node of record.addedNodes) if (node.nodeType === 1) added.push(node.nodeName.toLowerCase());
		}
	}).observe(document, { childList: true, subtree: true });

	(globalThis as never as { probe: unknown }).probe = { makes, removed, added };
});

const probeOf = (): Promise<Probe> => view.evaluate(() => {
	const held = (globalThis as never as {
		probe: { makes: number[]; removed: string[]; added: string[] };
	}).probe;
	const from = performance.getEntriesByName('attach-start')[0]!.startTime;
	const to = performance.getEntriesByName('attach-end')[0]!.startTime;
	return {
		removed: held.removed,
		added: held.added,
		makes: held.makes.filter((at) => at >= from && at <= to).length,
		ms: performance.getEntriesByName('attach')[0]!.duration,
	};
});

try {
	// A deep link to a generated page, from a cold load rather than a navigation.
	await view.goto(`${host.url}/posts/hello`);
	await view.waitForSelector('#post');
	assert.equal(await view.title(), 'Post hello', 'the head tags the file carries are the document\'s');

	const probe = await probeOf();
	// Nothing the server wrote was thrown away and rebuilt. This is the whole promise of a
	// hydration: no flash, no lost scroll position, no focus taken off an element mid-read.
	assert.deepEqual(probe.removed, [], 'the hydration removed no element the server wrote');
	assert.deepEqual(probe.added, [], 'and inserted none of its own: this page has no popup sink, '
		+ 'and the live region is part of the markup the server wrote');
	console.log(`  hydrating /posts/hello: ${probe.makes} createElement calls in ${probe.ms.toFixed(1)} ms`);

	assert.match(String(await view.getAttribute('#banner', 'class')), /\S/,
		'the bundle compiled banner.tsx with ui\'s h as well');
	assert.equal(await view.getAttribute('#banner', 'theme'), null);

	// A plain onClick on the page the server wrote answers a real click.
	assert.equal(await view.textContent('#query'), '{}');
	await view.click('#sort');
	await view.waitForFunction(() =>
		(globalThis as never as { location: { search: string } }).location.search === '?sort=new');
	assert.equal(await view.textContent('#query'), '{"sort":"new"}', 'a click on the hydrated page is answered');

	// A link is a navigation the router took over: a new act and a new title, no page load.
	await view.click('#to-install');
	await view.waitForSelector('#docs-page');
	assert.equal(await view.textContent('#docs-page-name'), 'install');
	assert.equal(await view.title(), 'Docs: install', 'the act change moved the title');

	// The nested act again, this time as its own generated file.
	await view.goto(`${host.url}/docs/install`);
	await view.waitForSelector('#docs-page');
	const nested = await probeOf();
	assert.deepEqual(nested.removed, [], 'the page under a nested stage hydrates in place too');
	assert.equal(await view.textContent('#docs-page-name'), 'install');
	console.log(`  hydrating /docs/install: ${nested.makes} createElement calls in ${nested.ms.toFixed(1)} ms`);

	// The 404, as a host that has one serves it.
	await view.goto(`${host.url}/404.html`);
	await view.waitForSelector('#not-found');
	assert.equal(await view.title(), 'Not found');

	// A URL nothing enumerated: the live shell, mounted rather than hydrated, and a real page.
	const answer = await view.goto(`${host.url}/tags/rust`);
	assert.ok(!(await answer!.text()).includes('data-aweft-ssg'), 'the shell was served, with no stamp on it');
	await view.waitForSelector('#tag');
	assert.equal(await view.textContent('#tag-name'), 'rust', 'and the client rendered the act from the URL');
	assert.equal(await view.title(), 'Tag rust');

	assert.deepEqual(problems, [], 'the pages threw nothing');
	console.log('recipes/ssg: ok');
} finally {
	await browser.close();
	await host.close();
}
```
