npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aweftjs/build

v0.1.1

Published

One transform, two modes: markup and JSX to h calls, static subtrees to templates, asserts out of a release build.

Readme

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

Loaded from source, it has the bundler serve source. In the repo, and in an application that carries it as a submodule, .npmrc asks Node for each @aweftjs/* package's src/ with the aweft-source condition (design 256). The plugin reads which file it was loaded from, and when that is plugin.ts it adds the same condition to every environment the bundler builds, so the page gets the files the process runs and not a dist/ of whatever age (design 297). Loaded from an npm install, it adds nothing.

That needs the config file loaded by Node, not bundled. vite's default loader bundles the config and resolves its imports itself, without the condition, so the plugin it hands back is the one in dist/. Start vite with --configLoader native, or pass configLoader: 'native' beside configFile to createServer and build, as every recipe here does.

// 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:

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

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