recipes/routed-site/main.ts
One file of recipes/routed-site, at commit 9a5bb24.
// The routed site, proved three ways: written out as pages with no browser, taken over in place,
// and then driven in a real one.
//
// Run: node --import @aweftjs/build/loader recipes/routed-site/main.ts
// Serve it instead, to click around: npx vite recipes/routed-site
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import { chromium } from 'playwright';
import { createDocument, parseHtml } from '@aweftjs/dom';
import { createRouter } from '@aweftjs/dom/router';
import type { LightDocument } from '@aweftjs/dom';
import { context, h, hydrate, render } from '@aweftjs/ui';
import { Site, urls } from './site.tsx';
const here = fileURLToPath(new URL('.', import.meta.url));
const dist = join(here, 'dist');
// --- one process, every page, all at once -------------------------------------------------------
console.log(`rendering ${urls.length} pages at once, one context each`);
const pages = await Promise.all(urls.map(async (wanted) => {
const own = context();
// No window here, so the router runs from the URL it is given and every browser effect is off.
const router = createRouter({ url: wanted.url });
const body = await render(h(Site, { router }), { context: own });
return { ...wanted, body, head: own.head.markup(), css: own.theme.markup() };
}));
for (const page of pages) {
assert.ok(page.body.includes(`id="${page.id}"`), `${page.url} rendered the act it names`);
assert.match(page.head, new RegExp(`<title[^>]*>${page.title}</title>`), `${page.url} has its own title`);
// The layout writes a default title outside every Head and each act overrides it from inside
// one. Seeing the default means the override did not reach the list.
assert.ok(!page.head.includes('>Routed site<'), `${page.url} overrode the layout's title`);
}
// Nothing one page declared shows up in another. Each page had its own render object, so this is
// the check that two renders in one process cannot see each other (aweft design 109).
for (const page of pages) {
for (const other of pages) {
if (other === page) continue;
assert.ok(!page.head.includes(`>${other.title}<`), `${page.url} carries no head tag of ${other.url}`);
assert.ok(!page.body.includes(`id="${other.id}"`), `${page.url} carries no act id of ${other.url}`);
}
}
// The classes the theme generated. Names are minted per render from a counter that starts again on
// every page, so the six pages share names rather than having disjoint ones. What has to hold is
// that a shared name means the same thing: every class a page uses is defined in that page's own
// stylesheet, and a name two pages both use carries the same rules on both.
const rulesOf = (css: string, name: string): string =>
css.split('\n').filter((line) => new RegExp(`\\.${name}\\b`).test(line)).join('\n');
const meanings = new Map<string, { url: string; rules: string }>();
for (const page of pages) {
let used = 0;
for (const found of page.body.matchAll(/class="([^"]*)"/g)) {
for (const name of found[1]!.split(' ').filter((one) => one !== '')) {
used += 1;
assert.ok(page.css.includes(`.${name}`), `${page.url} defines the class ${name} it uses`);
const rules = rulesOf(page.css, name);
const standing = meanings.get(name);
if (standing === undefined) meanings.set(name, { url: page.url, rules });
else assert.equal(rules, standing.rules, `${name} means on ${page.url} what it means on ${standing.url}`);
}
}
// Without this the loop above passes on a page that carries no class at all, which is what it
// used to do: the site declared no theme and every page had nothing to check.
assert.ok(used > 0, `${page.url} carries generated classes, so the check above checked something`);
}
assert.ok(meanings.size > 2, 'and the act with a theme of its own brought a class the layout did not');
// The nested stage is what makes these two differ: the same act above, a different act below.
const docs = pages.find((page) => page.url === '/docs')!;
const install = pages.find((page) => page.url === '/docs/install')!;
assert.ok(docs.body.includes('id="docs"') && install.body.includes('id="docs"'), 'both are under the docs act');
assert.notEqual(docs.body, install.body, 'and the child stage put a different act inside it');
assert.ok(install.body.includes('>install<'), 'the parameter reached the nested act');
console.log(' every page differs where it should and shares nothing with the others');
// --- taking over the markup in place --------------------------------------------------------------
/** Every element under a node, in order, by identity. */
const elementsIn = (from: { firstChild: unknown; nextSibling: unknown; nodeType: number } | null): unknown[] => {
const found: unknown[] = [];
for (let node = from; node !== null; node = node.nextSibling as typeof node) {
if (node.nodeType === 1) found.push(node);
found.push(...elementsIn(node.firstChild as never));
}
return found;
};
/**
* Hydrate one rendered page and answer what it took over.
*
* The act at `/about` is a module name, so it arrives after `hydrate` has returned. The
* hydration waits for it (aweft design 243), which is what makes the counts below match.
*/
const takeOver = async (page: typeof install, text: string): Promise<{ kept: number; of: number }> => {
const light: LightDocument = createDocument();
for (const node of parseHtml(page.body, light)) light.body.appendChild(node);
// The page a server writes carries both: the theme's stylesheet and the head tags.
for (const node of parseHtml(`<style data-aweft>${page.css}</style>${page.head}`, light)) {
light.head.appendChild(node);
}
const before = elementsIn(light.body.firstChild as never);
const headBefore = elementsIn(light.head.firstChild as never);
assert.ok(before.length > 0 && headBefore.length > 0, `${page.url}: the server wrote a page and a head`);
const made: string[] = [];
const factory = light.createElement.bind(light);
(light as unknown as Record<string, unknown>)['createElement'] = (tag: string) => {
made.push(tag);
return factory(tag);
};
const stop = hydrate(light.body as never, h(Site, { router: createRouter({ url: page.url }) }));
await stop.ready;
// Every element the server wrote is the same object it was: nothing was replaced, in the page
// or in the head. The head is the stricter half: a stamped tag is taken over in place, so no
// `title`, `meta` or `link` is made at all. (The page's own elements are a different story:
// `dom` builds the client tree and pairs it with the server's, so the element it made is
// discarded rather than inserted. What matters is which one stays.)
const after = elementsIn(light.body.firstChild as never);
assert.deepEqual(after, before, `${page.url}: every page element was adopted`);
assert.deepEqual(elementsIn(light.head.firstChild as never), headBefore, `${page.url}: every head tag was adopted`);
assert.deepEqual(made.filter((tag) => ['title', 'meta', 'link', 'script', 'style'].includes(tag)), [],
`${page.url}: no head tag was made, the stamped ones were taken over`);
assert.ok(light.body.textContent?.includes(text), `${page.url}: and the page is still the page`);
stop();
return { kept: after.filter((node) => before.includes(node)).length, of: before.length };
};
// One act is a component and one is a module name. The second is the one that used to throw a
// hydration mismatch, because the pairing walk closed before the act arrived.
const plain = await takeOver(install, 'install');
const named = await takeOver(pages.find((page) => page.url === '/about')!, 'Loaded on demand');
console.log(` /docs/install adopted ${String(plain.kept)} of ${String(plain.of)} elements, `
+ `/about ${String(named.kept)} of ${String(named.of)}, none replaced`);
// --- the same site, in a real browser ------------------------------------------------------------
const TYPES: Record<string, string> = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
/** Serve the built page, answering every path with it so a deep link loads. */
const serve = async (): Promise<{ url: string; close(): Promise<void> }> => {
const server = createServer((request, response) => {
const path = (request.url ?? '/').split('?')[0]!;
const file = join(dist, normalize(path === '/' ? '/index.html' : path));
const wanted = file.startsWith(dist) && extname(file) !== '' ? file : join(dist, 'index.html');
try {
const body = readFileSync(wanted);
response.writeHead(200, { 'content-type': TYPES[extname(wanted)] ?? 'text/html' });
response.end(body);
} catch {
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()); }),
};
};
console.log('building the site through aweft()');
await build({ configFile: join(here, 'vite.config.ts'), logLevel: 'warn' });
const site = 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)));
const live = (): Promise<string> => view.textContent('[aria-live]').then((text) => text ?? '');
try {
// A deep link opens the nested act, from a cold load rather than a navigation.
await view.goto(`${site.url}/docs/install`);
await view.waitForSelector('#docs-page');
assert.equal(await view.textContent('#docs-page-name'), 'install');
assert.equal(await view.title(), 'Docs: install', 'the head tags reached the real document');
// A click on an anchor is a navigation the router took over: no page load, new act, new title.
await view.click('#to-post');
await view.waitForSelector('#post');
assert.equal(new URL(view.url()).pathname, '/posts/hello');
assert.equal(await view.title(), 'Post hello');
assert.equal(await live(), 'Post hello', 'the live region announced the new title');
// Focus is on the act's root, which is what a keyboard user needs after everything changed.
const focused = await view.evaluate(() =>
(globalThis as unknown as { document: { activeElement: { id: string } | null } }).document.activeElement?.id ?? '');
assert.equal(focused, 'page', 'focus is on the root of what the template rendered');
// The query cell round trips through the URL, on the entry that was already there.
assert.equal(await view.textContent('#query'), '{}');
const before = view.url();
await view.click('#sort');
await view.waitForFunction(() => (globalThis as unknown as { location: { search: string } }).location.search === '?sort=new');
assert.equal(await view.textContent('#query'), '{"sort":"new"}');
await view.goBack();
await view.waitForFunction(() => (globalThis as unknown as { location: { pathname: string } }).location.pathname === '/docs/install');
assert.notEqual(view.url(), before, 'a query write left one entry, not two, so back went a whole page');
await view.goForward();
await view.waitForSelector('#post');
// A dialog on a history entry of its own: the URL does not move, and back dismisses it.
const url = view.url();
await view.click('#open-dialog');
await view.waitForSelector('#dialog');
assert.equal(view.url(), url, 'the address bar did not move');
assert.equal(await view.textContent('#dialog-from'), 'the post', 'the props reached the act');
assert.equal(await view.title(), 'The dialog');
await view.goBack();
await view.waitForSelector('#post');
assert.equal(view.url(), url);
// Scroll on a long page, leave it, come back: the position is restored before paint.
await view.click('#to-home');
await view.waitForSelector('#home');
await view.evaluate(() => (globalThis as unknown as { scrollTo(x: number, y: number): void }).scrollTo(0, 900));
await view.waitForFunction(() => (globalThis as unknown as { scrollY: number }).scrollY === 900);
// Clicked through the DOM: Playwright scrolls an element into view before clicking it, which
// would throw away the position under test.
await view.evaluate(() => {
(globalThis as unknown as { document: { getElementById(id: string): { click(): void } | null } })
.document.getElementById('to-about')!.click();
});
await view.waitForSelector('#about');
assert.equal(await view.evaluate(() => (globalThis as unknown as { scrollY: number }).scrollY), 0,
'a new act starts at the top');
assert.equal(await view.title(), 'About', 'the lazy act arrived and brought its title');
await view.goBack();
await view.waitForSelector('#home');
await view.waitForFunction(() => (globalThis as unknown as { scrollY: number }).scrollY === 900);
// Nothing matched is the fallback, and it is a real page rather than a blank one.
await view.click('#to-nowhere');
await view.waitForSelector('#not-found');
assert.equal(await view.title(), 'Not found');
assert.deepEqual(problems, [], 'the page threw nothing');
console.log('recipes/routed-site: ok');
} finally {
await browser.close();
await site.close();
}