@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.tsBoth 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:
| written | means |
|---|---|
hidden | the attribute set to true |
id=plain | the 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
childrenat run timean element given
childrenas a propertyan element whose tag is not a literal name, a component included
every element in a file that binds the name
hanywhere 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.
| reason | refused | write instead |
|---|---|---|
image-needs-alt | <img src="a.png" /> | alt="what it shows", or alt="" for decoration |
control-needs-label | <input /> on its own | an 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-positive | tabindex="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 touchedThree 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 TransformErrorWhat 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 usesat: 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">) => PluginThe 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 nohgets one from;text, which finds the text the page shows and writestext/source.jsonunder 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: MangleHow 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) => TransformResultCompile 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 textoptions:filename, whose extension picks the dialect and which names the map;release, which removes assert calls;defaultH, the package a file with nohgets 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 URLcontext: what Node hands the hook, passed on unchangednextLoad: 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 writtencontext: what Node hands the hook;parentURLis the file that wrote the importnextResolve: 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.
| reason | fix |
|---|---|
attribute-needs-value | Give the attribute a value, or drop the = to make it true. |
bad-attribute-name | Start the attribute with a name, or close the tag. |
button-needs-name | Put text inside the button, or give it an aria-label. |
click-needs-role | Make it a <button>, or give it a role and a tabindex so the keyboard reaches it. |
control-needs-label | Give it an id and a <label for> naming it, wrap it in a <label>, or give it an aria-label. |
empty-expression | Put an expression in the braces, or drop them to make the attribute true. |
frame-needs-title | Give the frame a title that says what it holds. |
heading-needs-text | Put the heading's text inside it, or drop the heading. |
image-needs-alt | Give the image an alt that says what it shows, or alt="" for a decorative one. |
invalid-escape | Use an escape the template accepts, or write the character itself. |
link-needs-href | Give the link an href, or make it a <button> when it does something on the page. |
mismatched-closing-tag | Name the element being closed, or write </> for the innermost one. |
namespaced-attribute | Write the attribute name without a colon in it. |
namespaced-tag | Write the tag as a plain name or a member expression. |
nothing-to-close | Remove the closing tag, or open the element it closes. |
spread-needs-hole | Put the object in a hole, written as =${object}. |
tabindex-positive | Use 0 to join the tab order where the element sits, or -1 to reach it from code only. |
tag-needs-name | Put a tag name after the <, or write the tag as a hole. |
unclosed-element | Close the element, or write it as <tag /> if it has no children. |
unknown-default-h | Set 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-setting | Set it to 1 to find the text a page shows, or leave it unset. |
unsupported-child | Write the child as text, an element, or an expression in braces. |
unterminated-attribute | Close the value with the same quote it opened with. |
unterminated-closing-tag | End the closing tag with >. |
unterminated-comment | Close the comment with --> before the next hole. |
unterminated-tag | Close 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 originrecipes/ui: A page with themes, contexts, control flow, a popup and a suspend, built by vite and driven in a real browserrecipes/icons: Icons named three ways, and what each way puts in the bundlerecipes/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 pagerecipes/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/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 fetchrecipes/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,auditandwalkreading what only the rendered page showsrecipes/build: the package's own recipe