aweft

@aweftjs/build

The transforms. It compiles markup and JSX to h calls, replaces a static subtree with a template made once and cloned per use, removes assert calls from a release build, and, when asked, finds every string a page shows so it can be looked up in the reader's language. It decides nothing else: not which bundler you use, not whether you write JSX, markup or h by hand, not what a custom h does, and not when source that arrives at run time is compiled.

Nothing here is required. A page that writes h and markup runs with no build step at all; this makes it faster and smaller.

Quickstart

One implementation, two ways in.

// A bundler, rollup's shape, vite among them.
import { aweft } from '@aweftjs/build';

export default { plugins: [aweft({ release: process.env.NODE_ENV === 'production' })] };

The plugin also sets the bundler's own JSX handling to preserve, so vite's dependency scan and its own transform leave a .tsx page to this plugin rather than reading its JSX as another library's.

// Anywhere, a browser included, for source that did not exist at build time.
import { transform } from '@aweftjs/build';

const { code, map } = transform(source, { filename: 'page.tsx', release: true, defaultH: '@aweftjs/ui' });
# A Node process, which has no bundler config to put an option in.
AWEFT_DEFAULT_H=@aweftjs/ui AWEFT_TEXT=1 node --import @aweftjs/build/loader build-site.ts

Both produce the same bytes for the same input, and there is a fixture suite that says so (tests/modes.test.ts). That equality is not a nicety: source validated by one transform and executed by another can validate, be stored, and break when it renders.

What it does to a file

Markup in a template literal

A html`...` tag imported from @aweftjs/dom becomes h calls, so the page carries no parser.

import { html } from '@aweftjs/dom';
const Note = (tone, text) => html`<p class="note ${tone}">${text}</p>`;

becomes calls to h, with the mixed attribute joined by joined from @aweftjs/dom, which is the same function the runtime parser uses for it.

The dialect is @aweftjs/dom's, unchanged. A tag may be an expression (<${Component}>), </> closes the innermost open element, <!-- ... --> is a comment and goes, and ${value} between tags is a child. Inside a tag, these are all the forms there are:

writtenmeans
hiddenthe attribute set to true
id=plainthe unquoted text up to the next space or >
class="row ${tone}"the quoted parts as one value, joined by joined
title=${t}, $onclick=${fn}the expression itself
=${props} or ${props}a spread: every key of the object becomes a prop

A spread is the one form with two spellings. =${props} is the explicit one; a hole on its own, with no name in front of it, means the same thing. Both compile to { ...props }.

What compiling changes is when a mistake is reported: every fault the parser would have thrown at render time is thrown here instead, as a TransformError carrying at, the offset in the file, and the reason and fix every refusal in the stack carries. errors.txt lists every reason. One check does not survive compilation, and it is named rather than hidden. The parser asserts that a spread is an object; a compiled template writes { ...expr }, which spreads whatever it is handed. A string spreads as one attribute per character, so =${'not a tag'} renders <div 0="n" 1="o" 2="t" ...> where the parser would have refused, and a number, null or undefined spreads as nothing at all.

JSX

JSX compiles to a plain h(...) call resolved by ordinary lexical scope.

const Card = ({ title }) => <section class="card"><h1>{title}</h1></section>;

A lowercase tag with no dot in it is an element name; anything else is an expression, so <Item/> and <ns.Part/> call whatever the surrounding code calls Item and ns. A fragment, <>...</>, becomes an array of items, which is what mount takes.

h is imported from @aweftjs/dom only when the file has no h of its own. A file that declares its own h, or imports one from elsewhere, keeps it, and its JSX compiles to that one. That is what lets a component library ship its own h, its own theming and a wholly separate definition without this package knowing anything about it.

Which package supplies that import is defaultH. A bundler takes it as a plugin option, aweft({ defaultH: '@aweftjs/ui' }). A Node process running the loader has no config to put one in, so it says the same thing in the environment: AWEFT_DEFAULT_H=@aweftjs/ui, read once when the loader starts, and refused by name if it is set to anything but the two package names. --import is resolved by Node from the working directory, so run it from the application root, where node_modules is; from anywhere else the failure reads as a missing package. Set them to the same value when one .tsx is rendered on a server and bundled for a browser, or the two sides compile it differently and the page will not hydrate (design 147).

Static hoisting

The shape a source fixes becomes a template made once per document and instanced per use. Element names, attributes whose value is a literal, and text go in the template; everything else is applied to the instance.

const Row = (label, click) => h('tr', { class: 'row' },
	h('td', { class: 'a' }, label),
	h('td', { class: 'b' }, h('a', { $onclick: click }, 'go')));

becomes one template(...) at the top of the file and one call to it per row. Measured by bench/hoist.ts in Chromium, 10,000 rows of that shape, best of five invocations of best of seven: inside a mount, which is where a list builds its rows, 28.5 ms through the eight h calls against 13.4 ms as one template instance. Outside any mount, which is where a page builds the item it then hands to mount or hydrate, the same two are 43.1 ms and 29.8 ms, because every node made there is marked as the binding's own so hydrate can adopt the server's markup. For a clone that marking is a walk, and it is about 16 ms per 10,000 instances. A row built during a mount that is not hydrating pays none of it. The loop for re-running the script is bench/README.md.

Hoisting only happens where h is provably @aweftjs/dom's in that file. JSX still compiles to any h; only this substitution is restricted, because it assumes dom's h's semantics. Four things are left as plain h calls:

  • an element with a spread in its properties, which may carry children at run time

  • an element given children as a property

  • an element whose tag is not a literal name, a component included

  • every element in a file that binds the name h anywhere except the one import from @aweftjs/dom

In the first three the subtree around the element still hoists, with the call as one of its varying parts. The fourth is not one element but the whole file: a file that binds h itself hoists nothing anywhere in it, including the elements the shadow never reaches. That is deliberate, because following a shadow properly needs real scope analysis, and the blunt rule can only be wrong in the direction of hoisting less.

The access rules

Every element the transform reads, in whichever notation wrote it, goes through eight rules before anything is hoisted, and a fault is a TransformError at the element like any other (design 265). Each is a case a screen reader or a keyboard cannot recover from, and each is decidable from the source alone.

reasonrefusedwrite instead
image-needs-alt<img src="a.png" />alt="what it shows", or alt="" for decoration
control-needs-label<input /> on its ownan id with a <label for>, a <label> around it, or an aria-label
click-needs-role<div $onclick={go}>a <button>, or a role and a tabindex
tabindex-positivetabindex="1"0 to join the tab order where it sits, -1 to reach it from code
link-needs-href<a>docs</a>an href, or a <button> when it acts on the page
button-needs-name<button />text inside it, or an aria-label
heading-needs-text<h2 />the heading's text, or no heading
frame-needs-title<iframe src="/map" />a title saying what it holds

An input whose literal type is hidden, submit, button, reset or image needs no label. Natively interactive elements (a, button, input, select, textarea, summary, details, option, label, audio, video) may take a click as they are.

The rules read what the source says and stop there. An attribute given as an expression is present: alt={caption} passes whatever caption holds. A spread makes the element unknowable and it passes. A component is not read itself, and the elements written inside it are. The Node loader compiles .tsx only, so an h call in a .ts file meets the rules in a bundle and not under node --import @aweftjs/build/loader. A label counts only when it is around the control in the same JSX, template or h call; a label in another expression pairs through an id. What the source cannot settle, a rendered page can: audit from @aweftjs/testing/browser runs axe over the page a test drives.

Assert stripping

With release: true, a statement that is nothing but a call to a name imported by name from a neighbouring assert module is removed, and the import goes with it when nothing else in the file still names it. So does any other import the file named only inside those calls: a helper an assert fed on has no use left. An import the file never names is left alone.

import { assert } from './assert.ts';   // removed with its last call
import assert from 'node:assert/strict'; // never touched

Three shapes stay: a default import (node:assert and friends), assert used as a value rather than called, and a call whose value something reads, so const ok = assert(x, 'm') keeps its initializer. To get your own asserts stripped, import them by name from a module called assert next to the file.

Icon names

A string literal shaped set:name, on the name prop of the Icon a file bound from @aweftjs/ui, becomes an import of that one icon and the plugin and loader answer that import. @aweftjs/icons's README says exactly which names move and which are left for run time.

The text a page shows

aweft({ text: true }), transform(source, { text: true }), or AWEFT_TEXT=1 for the loader. Off by default. On, in a file whose h is @aweftjs/ui's, every literal a person reads becomes a text() call from @aweftjs/ui, which looks the string up in the render's catalog where the page mounts (packages/ui/README.md, The text a page shows):

<input placeholder="Search" name="q" />
<p>Save changes</p>

compiles to

import { text as _text } from '@aweftjs/ui';
<input placeholder={_text("Search")} name="q" />
<p>{_text("Save changes")}</p>

What moves: every literal text child of an element, in JSX, markup and a hand-written h call alike, and every string literal on a text prop. The text props are TEXT_PROPS on this package: label, title, description, placeholder, alt, error, caption, aria-label, aria-description, aria-placeholder, aria-valuetext and aria-roledescription. A class, an href, a name, a type or an id is a word for the machine and never moves.

What is left alone: text with no letter in it (punctuation, a number, a separator); an element with a literal translate="no" and everything under it; a prop given as an expression; the props of an element carrying a spread, whose children still move; a file compiled against dom's h, which comes out byte for byte as it went in; and a file under node_modules, because a package ships compiled files a server render reads as they are. A string built at run time is never a literal: write it as a message, text('{n, plural, one {# item} other {# items}}', { n }), which is also the only way it translates. A sentence with an element inside it is one message with a tag, text('Read <link>the docs</link>', { link }), not two literals.

What the transform answers. transform gains text on its result: every key the file's tokens look up, each once, the wrapped literals and the text() calls the page wrote itself, a literal context folded in as source|context. A page that compiles a stored module at run time keeps that list beside the source, since the build below never saw it.

What the plugin writes. When the bundle closes, text/source.json under the bundler's root: every key, sorted, with the files it came from relative to the root, and the keys every installed @aweftjs package ships in its text.json folded in under that name. It then reads each text/<tag>.json beside it and warns about the keys that catalog lacks and the entries it holds that no file uses. It never fails a build for either: what to do about a missing translation is yours. The loader writes nothing, because a process that renders pages is not a build.

Both sides must agree. A page rendered on a server with the option off and bundled with it on has different trees on the two sides, one text node against one component per string, and does not hydrate. AWEFT_TEXT is the loader's word for the plugin's text, as AWEFT_DEFAULT_H is for defaultH, and the scaffold in recipes/full-stack sets both. Set to anything but a yes or a no it is refused when the loader starts.

What it costs. A literal that hoisted into the template is a hole filled by a component call, about 0.7 µs per text in Chromium and a bracket pair in the static markup, measured in design 277.

The release mangle

mangle is the configuration for renaming this stack's internal properties, in the pattern and in the two shapes a minifier takes.

import { mangle } from '@aweftjs/build';

await minify(code, mangle.terser);   // or: build({ ...mangle.esbuild })

A property whose name ends in exactly one underscore is internal surface and may be renamed. A property whose name begins with an underscore is runtime-private and must keep its name, because a wildcard observer decides what it delivers by looking at the name. The two are never the same rule. Nothing in the stack uses the trailing convention yet, so today this renames nothing; it is here so that the rename, when it happens, is a rename and nothing else.

What it never decides

Which bundler you use. Whether you write JSX, markup or h by hand. What a custom h does. When source that arrives at run time is compiled, or by whom. Which language a page shows, and whether a missing translation fails a build.

Proven by

recipes/build/main.ts builds a real page through the transforms, runs it in all three modes, and checks that a release build of the binding's own source has no asserts left in it. recipes/translated-site builds a site with the text option on, reads the source catalog back, and hydrates the pages in three languages. The package's own suite is the equivalence suite: every fixture runs twice, once as written and once transformed, mounted, rendered and hydrated, over a document whose nodes clone and one whose nodes do not.

The design notes

A design NNN above is the note of that number in docs/design/, which says what was decided, why, what it costs, and what would reverse it.

API

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

@aweftjs/build

Mangle

interface Mangle { readonly pattern: RegExp; readonly terser: { readonly mangle: { readonly properties: { readonly regex: RegExp; }; }; }; readonly esbuild: { readonly mangleProps: RegExp; }; }

No block comment on this export.

Plugin

interface Plugin { readonly name: string; readonly enforce: 'pre'; config(): { oxc: { jsx: 'preserve'; }; }; configResolved(config: { root?: string; }): void; transform(code: string, id: string): { code: string; map: string; } | null; closeBundle(): Promise<void>; resolveId(source: string, importer?: string): Promise<string | null>; load(id: string): Promise<string | null>; }

What a bundler asks of a plugin, and all this one answers.

SourceMap

interface SourceMap { readonly version: number; readonly sources: readonly string[]; readonly names: readonly string[]; readonly mappings: string; toString(): string; toUrl(): string; }

A source map in the shape every bundler and every browser reads.

TEXT_PROPS

TEXT_PROPS: ReadonlySet<string>

The props whose string literal is text a person reads: HTML's own text-carrying attributes, and the four names ui's components take a label, a description, an error and a caption under. A class, an href, a name or a type is a word for the machine and is never here.

TransformError

TransformError: typeof TransformError

What transform throws when the source says something a compiled template cannot mean.

Every fault the runtime markup parser raises is raised here instead where the transform can see it in the source: an unterminated tag, a closing tag with nothing open, a spread written without =, a namespaced JSX tag or attribute, an attribute given no value. The build stops rather than the page.

Params

  • message: what is wrong, in the words the runtime parser uses

  • at: the offset in the source it is at

Returns the error. at is an offset into the source transform was given, not a line and column, so a caller that wants a position works it out from the source it passed in.

Example

try { transform(source, { filename: 'page.ts' }); } catch (error) {
  if (error instanceof TransformError) report(source.slice(0, error.at).split('\n').length);
}

TransformOptions

interface TransformOptions { readonly filename?: string; readonly release?: boolean; readonly defaultH?: '@aweftjs/dom' | '@aweftjs/ui'; readonly text?: boolean; }

What a caller may say about the source it hands transform.

Both are optional and both default to off: with no filename the source is read as JSX and the map names no source, and with no release the asserts stay in.

Example

transform(source, { filename: 'page.tsx', release: true });

TransformResult

interface TransformResult { readonly code: string; readonly map: SourceMap; readonly text: readonly string[]; }

What transform answers: the compiled source and the map from it back to the original.

code is the whole file. What the transform did not touch comes out byte for byte as it went in, so a diff of the two shows only the elements that compiled.

Example

const { code, map } = transform(source, { filename: 'page.ts' });
write(code + `\n//# sourceMappingURL=` + map.toUrl());

aweft

aweft: (options?: Omit<TransformOptions, "filename">) => Plugin

The plugin for a bundler that takes rollup's shape, vite among them.

Params

  • options: release, which removes assert calls; defaultH, the package a file with no h gets one from; text, which finds the text the page shows and writes text/source.json under the bundler's root when the bundle closes. The filename comes from the bundler.

Returns the plugin. It handles .js, .jsx, .ts and .tsx and answers null for anything else, which leaves the file to the rest of the pipeline. It also answers the icon imports the transform writes, and tells the bundler to leave JSX to it, so one plugin is still the whole registration. With text on it finds the text in the application's own files and never in one under node_modules, folds the keys every installed @aweftjs package ships in its text.json into the source catalog, and warns, when the bundle closes, about the keys each text/<tag>.json beside it lacks and the entries it holds that no file uses; it never fails a build for either.

Throws out of load, whatever @aweftjs/icons/node refuses with: set-not-installed for a set the application has not installed, naming the install command, and icon-not-in-set for a name the set does not have.

Example

export default { plugins: [aweft({ release: true, text: true })] };

mangle

mangle: Mangle

How a release build renames this stack's internal properties.

A property whose name ends in exactly one underscore is internal surface and may be renamed. A property whose name begins with an underscore is runtime-private and must keep its name, because a wildcard observer decides what it delivers by looking at the name.

Example

import { mangle } from '@aweftjs/build';
await minify(code, mangle.terser);

transform

transform: (source: string, options?: TransformOptions) => TransformResult

Compile markup, JSX and static subtrees, and take the asserts out of a release build.

The same function is the whole of the bundler plugin and is callable in a browser, so source compiled at build time and source compiled at run time cannot disagree.

Params

  • source: the file's text

  • options: filename, whose extension picks the dialect and which names the map; release, which removes assert calls; defaultH, the package a file with no h gets one from; text, which finds the text the page shows

Returns the transformed code, its source map, and the text keys it found.

Throws a TransformError for a fault in the markup or the JSX, carrying at, the offset in the source. Its reason names the rule broken: unterminated-tag, unclosed-element, mismatched-closing-tag, nothing-to-close, unterminated-closing-tag, tag-needs-name, bad-attribute-name, attribute-needs-value, unterminated-attribute, spread-needs-hole, unterminated-comment, invalid-escape, namespaced-tag, namespaced-attribute, empty-expression or unsupported-child; or one of the access rules (design 265): image-needs-alt, control-needs-label, click-needs-role, tabindex-positive, link-needs-href, button-needs-name, heading-needs-text or frame-needs-title. Source the parser cannot read throws the parser's own error instead.

Example

const { code, map } = transform(source, { filename: 'app.tsx', release: true });

@aweftjs/build/loader

load

load: (url: string, context: unknown, nextLoad: NextLoad) => Promise<LoadResult>

Node's load hook: compile a .tsx file, leave everything else alone.

Params

  • url: the module's URL

  • context: what Node hands the hook, passed on unchanged

  • nextLoad: the rest of the chain

Returns for a .tsx file, its source with the JSX compiled, as module-typescript so Node strips the types afterwards. For a URL resolve above claimed, the generated icon module. For anything else, whatever the rest of the chain says. A file that binds no h of its own gets one from the package AWEFT_DEFAULT_H names, and from @aweftjs/dom when nothing names one.

Throws an Error naming the file and the line, with the fault as its cause. The fault is a TransformError for a rule a compiled template cannot meet, and the parser's own SyntaxError for source it cannot read. Neither names the file on its own, and a stack into a parser is not where the reader has to look. AWEFT_DEFAULT_H set to anything but the two package names is refused when this module loads, before any file is read and whether or not one ever is, and so is AWEFT_TEXT set to anything but a yes or a no. With AWEFT_TEXT=1 the text pass runs on every file, as it does under aweft({ text: true }).

resolve

resolve: (specifier: string, context: unknown, nextResolve: NextResolve) => Promise<ResolveResult>

Node's resolve hook: claim an icon import, leave everything else to the chain.

Params

  • specifier: the import as it was written

  • context: what Node hands the hook; parentURL is the file that wrote the import

  • nextResolve: the rest of the chain

Returns for @aweftjs/icons/<set>, @aweftjs/icons/<set>/<name> and @aweftjs/icons/<set>/+standard, a URL under this file's own scheme carrying the request and the directory to resolve the set from. For anything else, whatever the rest of the chain says, which is what the package's two real entries get, and what a specifier the generator does not recognise as a request gets.

Example

import check from '@aweftjs/icons/lucide/check';

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
attribute-needs-valueGive the attribute a value, or drop the = to make it true.
bad-attribute-nameStart the attribute with a name, or close the tag.
button-needs-namePut text inside the button, or give it an aria-label.
click-needs-roleMake it a <button>, or give it a role and a tabindex so the keyboard reaches it.
control-needs-labelGive it an id and a <label for> naming it, wrap it in a <label>, or give it an aria-label.
empty-expressionPut an expression in the braces, or drop them to make the attribute true.
frame-needs-titleGive the frame a title that says what it holds.
heading-needs-textPut the heading's text inside it, or drop the heading.
image-needs-altGive the image an alt that says what it shows, or alt="" for a decorative one.
invalid-escapeUse an escape the template accepts, or write the character itself.
link-needs-hrefGive the link an href, or make it a <button> when it does something on the page.
mismatched-closing-tagName the element being closed, or write </> for the innermost one.
namespaced-attributeWrite the attribute name without a colon in it.
namespaced-tagWrite the tag as a plain name or a member expression.
nothing-to-closeRemove the closing tag, or open the element it closes.
spread-needs-holePut the object in a hole, written as =${object}.
tabindex-positiveUse 0 to join the tab order where the element sits, or -1 to reach it from code only.
tag-needs-namePut a tag name after the <, or write the tag as a hole.
unclosed-elementClose the element, or write it as <tag /> if it has no children.
unknown-default-hSet it to @aweftjs/dom or @aweftjs/ui, or leave it unset to compile a file with no h of its own against dom.
unknown-text-settingSet it to 1 to find the text a page shows, or leave it unset.
unsupported-childWrite the child as text, an element, or an expression in braces.
unterminated-attributeClose the value with the same quote it opened with.
unterminated-closing-tagEnd the closing tag with >.
unterminated-commentClose the comment with --> before the next hole.
unterminated-tagClose the opening tag with > or />.

Recipes

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

  • recipes/full-stack: A page and a server in one directory: the page reaching the server in development through the dev server's proxy, and a sign-in that sets the cookie on that one origin

  • recipes/ui: A page with themes, contexts, control flow, a popup and a suspend, built by vite and driven in a real browser

  • recipes/icons: Icons named three ways, and what each way puts in the bundle

  • recipes/routed-site: A site with real URLs: nested pages, a page with a parameter, a page that arrives later, a dialog the back button dismisses, and a title per page

  • 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/room: An act module stored on the server, run in a frame on the page: the board it shares reaches the server, its ask carries the page's identity, its own stage navigates on the page's URL under the host act, an error inside reaches the page's logs, and the frame paints but cannot fetch

  • recipes/accessible-page: A page everyone can use, driven by keyboard and audited in both modes, and each guardrail catching one page written wrong: the build refusing an element no one can read, the mount throwing on a nameless button and a page with no language, audit and walk reading what only the rendered page shows

  • recipes/build: the package's own recipe