Compare commits
@@ -0,0 +1,32 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, v8]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
- run: npm run size
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npx playwright install --with-deps chromium-headless-shell
|
||||
- run: npm run test:e2e
|
||||
@@ -0,0 +1,44 @@
|
||||
name: release
|
||||
|
||||
# Tag a version (git tag v8.0.0 && git push --tags) and this publishes
|
||||
# every workspace package to the uhhm npm registry on this Gitea, plus
|
||||
# the single-file CDN bundle as a generic package.
|
||||
#
|
||||
# Repo secrets needed (Settings → Actions → Secrets):
|
||||
# PACKAGES_TOKEN — a Gitea access token with package:write scope
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
|
||||
- name: point npm at the uhhm registry
|
||||
run: |
|
||||
echo "@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/" >> "$HOME/.npmrc"
|
||||
echo "//project.uhhm.no/api/packages/uhhm/npm/:_authToken=${{ secrets.PACKAGES_TOKEN }}" >> "$HOME/.npmrc"
|
||||
|
||||
- name: publish workspaces
|
||||
run: npm publish --workspaces
|
||||
|
||||
- name: build + upload the CDN bundle (generic package)
|
||||
run: |
|
||||
npm run bundle
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
curl --fail -X PUT \
|
||||
-H "Authorization: token ${{ secrets.PACKAGES_TOKEN }}" \
|
||||
--upload-file dist-cdn/buuh.js \
|
||||
"https://project.uhhm.no/api/packages/uhhm/generic/buuh-cdn/${VERSION}/buuh.js"
|
||||
curl --fail -X PUT \
|
||||
-H "Authorization: token ${{ secrets.PACKAGES_TOKEN }}" \
|
||||
--upload-file dist-cdn/buuh.js.map \
|
||||
"https://project.uhhm.no/api/packages/uhhm/generic/buuh-cdn/${VERSION}/buuh.js.map"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, v8]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
- run: npm run size
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npx playwright install --with-deps chromium-headless-shell
|
||||
- run: npm run test:e2e
|
||||
@@ -12,3 +12,4 @@ coverage.json
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
.idea
|
||||
dist-cdn/
|
||||
|
||||
@@ -1,701 +1,163 @@
|
||||
<h1 align="center">Choo</h1>
|
||||
<h1 align="center">buuh</h1>
|
||||
|
||||
<div align="center">
|
||||
:steam_locomotive::train::train::train::train::train:
|
||||
<strong>🚂🚋🚋🚋🚋👻</strong>
|
||||
</div>
|
||||
<div align="center">
|
||||
<strong>Fun functional programming</strong>
|
||||
</div>
|
||||
<div align="center">
|
||||
A <code>4kb</code> framework for creating sturdy frontend applications
|
||||
The sturdy little frontend framework — a friendly fork of choo,
|
||||
rebuilt for the modern platform.
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
<!-- Stability -->
|
||||
<a href="https://nodejs.org/api/documentation.html#documentation_stability_index">
|
||||
<img src="https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square"
|
||||
alt="API stability" />
|
||||
</a>
|
||||
<!-- NPM version -->
|
||||
<a href="https://npmjs.org/package/choo">
|
||||
<img src="https://img.shields.io/npm/v/choo.svg?style=flat-square"
|
||||
alt="NPM version" />
|
||||
</a>
|
||||
<!-- Build Status -->
|
||||
<a href="https://travis-ci.org/choojs/choo">
|
||||
<img src="https://img.shields.io/travis/choojs/choo/master.svg?style=flat-square"
|
||||
alt="Build Status" />
|
||||
</a>
|
||||
<!-- Test Coverage -->
|
||||
<a href="https://codecov.io/github/choojs/choo">
|
||||
<img src="https://img.shields.io/codecov/c/github/choojs/choo/master.svg?style=flat-square"
|
||||
alt="Test Coverage" />
|
||||
</a>
|
||||
<!-- Downloads -->
|
||||
<a href="https://npmjs.org/package/choo">
|
||||
<img src="https://img.shields.io/npm/dt/choo.svg?style=flat-square"
|
||||
alt="Download" />
|
||||
</a>
|
||||
<!-- Standard -->
|
||||
<a href="https://standardjs.com">
|
||||
<img src="https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square"
|
||||
alt="Standard" />
|
||||
</a>
|
||||
</div>
|
||||
> **buuh is a fork of [choo](https://github.com/choojs/choo)** — same
|
||||
> API, same philosophy, modern engine. It exists because we love choo
|
||||
> and wanted it alive on today's platform; all credit for the design
|
||||
> belongs upstream (see [Credits](#credits)). Everything is MIT, and if
|
||||
> upstream ever wants this work home, the door is open
|
||||
> ([`docs/upstream-rfc-draft.md`](docs/upstream-rfc-draft.md)).
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="https://choo.io">
|
||||
Website
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/choojs/choo-handbook">
|
||||
Handbook
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/YerkoPalma/awesome-choo">
|
||||
Ecosystem
|
||||
</a>
|
||||
<span> | </span>
|
||||
<!-- <a href="https://github.com/trainyard/choo-cli"> -->
|
||||
<!-- CLI -->
|
||||
<!-- </a> -->
|
||||
<!-- <span> | </span> -->
|
||||
<a href="https://github.com/choojs/choo/blob/master/.github/CONTRIBUTING.md">
|
||||
Contributing
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://www.reddit.com/r/choojs/">
|
||||
Reddit
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://webchat.freenode.net/?channels=choo">
|
||||
Chat
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
## What is this
|
||||
|
||||
<div align="center">
|
||||
<sub>The little framework that could. Built with ❤︎ by
|
||||
<a href="https://twitter.com/yoshuawuyts">Yoshua Wuyts</a> and
|
||||
<a href="https://github.com/choojs/choo/graphs/contributors">
|
||||
contributors
|
||||
</a>
|
||||
</div>
|
||||
buuh is a small framework for building frontend applications with plain
|
||||
JavaScript and HTML. State lives in stores, changes flow through an event
|
||||
emitter, views are tagged template literals, and re-renders morph the real
|
||||
DOM. The whole framework — core, html engine, morphing, hydration, router,
|
||||
event bus — is **7.97 kB min+gzip** (7.15 kB brotli), enforced by CI.
|
||||
|
||||
## Table of Contents
|
||||
- [Features](#features)
|
||||
- [Example](#example)
|
||||
- [Philosophy](#philosophy)
|
||||
- [Events](#events)
|
||||
- [State](#state)
|
||||
- [Routing](#routing)
|
||||
- [Server Rendering](#server-rendering)
|
||||
- [Components](#components)
|
||||
- [Optimizations](#optimizations)
|
||||
- [FAQ](#faq)
|
||||
- [API](#api)
|
||||
- [Installation](#installation)
|
||||
- [See Also](#see-also)
|
||||
- [Support](#support)
|
||||
|
||||
## Features
|
||||
- __minimal size:__ weighing `4kb`, Choo is a tiny little framework
|
||||
- __event based:__ our performant event system makes writing apps easy
|
||||
- __small api:__ with only 6 methods there's not much to learn
|
||||
- __minimal tooling:__ built for the cutting edge `browserify` compiler
|
||||
- __isomorphic:__ renders seamlessly in both Node and browsers
|
||||
- __very cute:__ choo choo!
|
||||
|
||||
## Example
|
||||
```js
|
||||
var html = require('choo/html')
|
||||
var devtools = require('choo-devtools')
|
||||
var choo = require('choo')
|
||||
import choo from '@uhhm/buuh'
|
||||
import html from '@uhhm/buuh-html'
|
||||
|
||||
var app = choo()
|
||||
app.use(devtools())
|
||||
app.use(countStore)
|
||||
app.route('/', mainView)
|
||||
app.mount('body')
|
||||
const app = choo()
|
||||
|
||||
function mainView (state, emit) {
|
||||
return html`
|
||||
app.use((state, emitter) => {
|
||||
state.count = 0
|
||||
emitter.on('increment', (n) => {
|
||||
state.count += n
|
||||
emitter.emit('render')
|
||||
})
|
||||
})
|
||||
|
||||
app.route('/', (state, emit) => html`
|
||||
<body>
|
||||
<h1>count is ${state.count}</h1>
|
||||
<button onclick=${onclick}>Increment</button>
|
||||
<button onclick=${() => emit('increment', 1)}>Increment</button>
|
||||
</body>
|
||||
`
|
||||
`)
|
||||
|
||||
function onclick () {
|
||||
emit('increment', 1)
|
||||
}
|
||||
}
|
||||
|
||||
function countStore (state, emitter) {
|
||||
state.count = 0
|
||||
emitter.on('increment', function (count) {
|
||||
state.count += count
|
||||
emitter.emit('render')
|
||||
})
|
||||
}
|
||||
```
|
||||
Want to see more examples? Check out the [Choo handbook][handbook].
|
||||
|
||||
## Philosophy
|
||||
We believe programming should be fun and light, not stern and stressful. It's
|
||||
cool to be cute; using serious words without explaining them doesn't make for
|
||||
better results - if anything it scares people off. We don't want to be scary,
|
||||
we want to be nice and fun, and then _casually_ be the best choice around.
|
||||
_Real casually._
|
||||
|
||||
We believe frameworks should be disposable, and components recyclable. We don't
|
||||
want a web where walled gardens jealously compete with one another. By making
|
||||
the DOM the lowest common denominator, switching from one framework to another
|
||||
becomes frictionless. Choo is modest in its design; we don't believe it will
|
||||
be top of the class forever, so we've made it as easy to toss out as it is to
|
||||
pick up.
|
||||
|
||||
We don't believe that bigger is better. Big APIs, large complexities, long
|
||||
files - we see them as omens of impending userland complexity. We want everyone
|
||||
on a team, no matter the size, to fully understand how an application is laid
|
||||
out. And once an application is built, we want it to be small, performant and
|
||||
easy to reason about. All of which makes for easy to debug code, better results
|
||||
and super smiley faces.
|
||||
|
||||
## Events
|
||||
At the core of Choo is an event emitter, which is used for both application
|
||||
logic but also to interface with the framework itself. The package we use for
|
||||
this is [nanobus](https://github.com/choojs/nanobus).
|
||||
|
||||
You can access the emitter through `app.use(state, emitter, app)`, `app.route(route,
|
||||
view(state, emit))` or `app.emitter`. Routes only have access to the
|
||||
`emitter.emit` method to encourage people to separate business logic from
|
||||
render logic.
|
||||
|
||||
The purpose of the emitter is two-fold: it allows wiring up application code
|
||||
together, and splitting it off nicely - but it also allows communicating with
|
||||
the Choo framework itself. All events can be read as constants from
|
||||
`state.events`. Choo ships with the following events built in:
|
||||
|
||||
### `'DOMContentLoaded'`|`state.events.DOMCONTENTLOADED`
|
||||
Choo emits this when the DOM is ready. Similar to the DOM's
|
||||
`'DOMContentLoaded'` event, except it will be emitted even if the listener is
|
||||
added _after_ the DOM became ready. Uses
|
||||
[document-ready](https://github.com/bendrucker/document-ready) under the hood.
|
||||
|
||||
### `'render'`|`state.events.RENDER`
|
||||
This event should be emitted to re-render the DOM. A common pattern is to
|
||||
update the `state` object, and then emit the `'render'` event straight after.
|
||||
Note that `'render'` will only have an effect once the `DOMContentLoaded` event
|
||||
has been fired.
|
||||
|
||||
### `'navigate'`|`state.events.NAVIGATE`
|
||||
Choo emits this event whenever routes change. This is triggered by either
|
||||
`'pushState'`, `'replaceState'` or `'popState'`.
|
||||
|
||||
### `'pushState'`|`state.events.PUSHSTATE`
|
||||
This event should be emitted to navigate to a new route. The new route is added
|
||||
to the browser's history stack, and will emit `'navigate'` and `'render'`.
|
||||
Similar to
|
||||
[history.pushState](http://devdocs.io/dom/history_api).
|
||||
|
||||
### `'replaceState'`|`state.events.REPLACESTATE`
|
||||
This event should be emitted to navigate to a new route. The new route replaces
|
||||
the current entry in the browser's history stack, and will emit `'navigate'`
|
||||
and `'render'`. Similar to
|
||||
[history.replaceState](http://devdocs.io/dom/history#history-replacestate).
|
||||
|
||||
### `'popState'`|`state.events.POPSTATE`
|
||||
This event is emitted when the user hits the 'back' button in their browser.
|
||||
The new route will be a previous entry in the browser's history stack, and
|
||||
immediately afterward the`'navigate'` and `'render'`events will be emitted.
|
||||
Similar to [history.popState](http://devdocs.io/dom_events/popstate). (Note
|
||||
that `emit('popState')` will _not_ cause a popState action - use
|
||||
`history.go(-1)` for that - this is different from the behaviour of `pushState`
|
||||
and `replaceState`!)
|
||||
|
||||
### `'DOMTitleChange'`|`state.events.DOMTITLECHANGE`
|
||||
This event should be emitted whenever the `document.title` needs to be updated.
|
||||
It will set both `document.title` and `state.title`. This value can be used
|
||||
when server rendering to accurately include a `<title>` tag in the header.
|
||||
This is derived from the
|
||||
[DOMTitleChanged event](https://developer.mozilla.org/en-US/docs/Web/Events/DOMTitleChanged).
|
||||
|
||||
## State
|
||||
Choo comes with a shared state object. This object can be mutated freely, and
|
||||
is passed into the view functions whenever `'render'` is emitted. The state
|
||||
object comes with a few properties set.
|
||||
|
||||
When initializing the application, `window.initialState` is used to provision
|
||||
the initial state. This is especially useful when combined with server
|
||||
rendering. See [server rendering](#server-rendering) for more details.
|
||||
|
||||
### `state.events`
|
||||
A mapping of Choo's built in events. It's recommended to extend this object
|
||||
with your application's events. By defining your event names once and setting
|
||||
them on `state.events`, it reduces the chance of typos, generally autocompletes
|
||||
better, makes refactoring easier and compresses better.
|
||||
|
||||
### `state.params`
|
||||
The current params taken from the route. E.g. `/foo/:bar` becomes available as
|
||||
`state.params.bar` If a wildcard route is used (`/foo/*`) it's available as
|
||||
`state.params.wildcard`.
|
||||
|
||||
### `state.query`
|
||||
An object containing the current queryString. `/foo?bin=baz` becomes `{ bin:
|
||||
'baz' }`.
|
||||
|
||||
### `state.href`
|
||||
An object containing the current href. `/foo?bin=baz` becomes `/foo`.
|
||||
|
||||
### `state.route`
|
||||
The current name of the route used in the router (e.g. `/foo/:bar`).
|
||||
|
||||
### `state.title`
|
||||
The current page title. Can be set using the `DOMTitleChange` event.
|
||||
|
||||
### `state.components`
|
||||
An object _recommended_ to use for local component state.
|
||||
|
||||
### `state.cache(Component, id, [...args])`
|
||||
Generic class cache. Will lookup Component instance by id and create one if not
|
||||
found. Useful for working with stateful [components](#components).
|
||||
|
||||
## Routing
|
||||
Choo is an application level framework. This means that it takes care of
|
||||
everything related to routing and pathnames for you.
|
||||
|
||||
### Params
|
||||
Params can be registered by prepending the route name with `:routename`, e.g.
|
||||
`/foo/:bar/:baz`. The value of the param will be saved on `state.params` (e.g.
|
||||
`state.params.bar`). Wildcard routes can be registered with `*`, e.g. `/foo/*`.
|
||||
The value of the wildcard will be saved under `state.params.wildcard`.
|
||||
|
||||
### Default routes
|
||||
Sometimes a route doesn't match, and you want to display a page to handle it.
|
||||
You can do this by declaring `app.route('*', handler)` to handle all routes
|
||||
that didn't match anything else.
|
||||
|
||||
### Querystrings
|
||||
Querystrings (e.g. `?foo=bar`) are ignored when matching routes. An object
|
||||
containing the key-value mappings exists as `state.query`.
|
||||
|
||||
### Hash routing
|
||||
By default, hashes are ignored when routing. When enabling hash routing
|
||||
(`choo({ hash: true })`) hashes will be treated as part of the url, converting
|
||||
`/foo#bar` to `/foo/bar`. This is useful if the application is not mounted at
|
||||
the website root. Unless hash routing is enabled, if a hash is found we check if
|
||||
there's an anchor on the same page, and will scroll the element into view. Using
|
||||
both hashes in URLs and anchor links on the page is generally not recommended.
|
||||
|
||||
### Following links
|
||||
By default all clicks on `<a>` tags are handled by the router through the
|
||||
[nanohref](https://github.com/choojs/nanohref) module. This can be
|
||||
disabled application-wide by passing `{ href: false }` to the application
|
||||
constructor. The event is not handled under the following conditions:
|
||||
- the click event had `.preventDefault()` called on it
|
||||
- the link has a `target="_blank"` attribute with `rel="noopener noreferrer"`
|
||||
- a modifier key is enabled (e.g. `ctrl`, `alt`, `shift` or `meta`)
|
||||
- the link's href starts with protocol handler such as `mailto:` or `dat:`
|
||||
- the link points to a different host
|
||||
- the link has a `download` attribute
|
||||
|
||||
:warn: Note that we only handle `target=_blank` if they also have
|
||||
`rel="noopener noreferrer"` on them. This is needed to [properly sandbox web
|
||||
pages](https://mathiasbynens.github.io/rel-noopener/).
|
||||
|
||||
### Navigating programmatically
|
||||
To navigate routes you can emit `'pushState'`, `'popState'` or
|
||||
`'replaceState'`. See [#events](#events) for more details about these events.
|
||||
|
||||
## Server Rendering
|
||||
Choo was built with Node in mind. To render on the server call
|
||||
`.toString(route, [state])` on your `choo` instance.
|
||||
|
||||
```js
|
||||
var html = require('choo/html')
|
||||
var choo = require('choo')
|
||||
|
||||
var app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
return html`<div>Hello ${state.name}</div>`
|
||||
})
|
||||
|
||||
var state = { name: 'Node' }
|
||||
var string = app.toString('/', state)
|
||||
|
||||
console.log(string)
|
||||
// => '<div>Hello Node</div>'
|
||||
app.mount('body')
|
||||
```
|
||||
|
||||
When starting an application in the browser, it's recommended to provide the
|
||||
same `state` object available as `window.initialState`. When the application is
|
||||
started, it'll be used to initialize the application state. The process of
|
||||
server rendering, and providing an initial state on the client to create the
|
||||
exact same document is also known as "rehydration".
|
||||
## Getting it
|
||||
|
||||
For security purposes, after `window.initialState` is used it is deleted from
|
||||
the `window` object.
|
||||
From the uhhm registry (one `.npmrc` line routes the scope; everything
|
||||
else still resolves from npmjs):
|
||||
|
||||
```console
|
||||
$ echo "@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/" >> .npmrc
|
||||
$ npm i @uhhm/buuh @uhhm/buuh-html
|
||||
$ npm i -D @uhhm/bankai
|
||||
```
|
||||
|
||||
Or with no package manager at all — one file, one import map:
|
||||
|
||||
```html
|
||||
<html>
|
||||
<head>
|
||||
<script>window.initialState = { initial: 'state' }</script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
<script type="importmap">
|
||||
{ "imports": { "buuh": "https://cdn.uhhm.no/buuh@8.0.0.js" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import { choo, html } from 'buuh'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Components
|
||||
From time to time there will arise a need to have an element in an application
|
||||
hold a self-contained state or to not rerender when the application does. This
|
||||
is common when using 3rd party libraries to e.g. display an interactive map or a
|
||||
graph and you rely on this 3rd party library to handle modifications to the DOM.
|
||||
Components come baked in to Choo for these kinds of situations. See
|
||||
[nanocomponent][nanocomponent] for documentation on the component class.
|
||||
See [`docs/publishing.md`](docs/publishing.md) for how the registry and
|
||||
the CDN bundle fit together.
|
||||
|
||||
```javascript
|
||||
// map.js
|
||||
var html = require('choo/html')
|
||||
var mapboxgl = require('mapbox-gl')
|
||||
var Component = require('choo/component')
|
||||
## Why this fork
|
||||
|
||||
module.exports = class Map extends Component {
|
||||
constructor (id, state, emit) {
|
||||
super(id)
|
||||
this.local = state.components[id] = {}
|
||||
}
|
||||
- **No build step, anywhere.** Templates parse at runtime (cached per
|
||||
call site in a WeakMap, clone-based instantiation — as fast as choo's
|
||||
old compile-time transform, without the toolchain). An import map and
|
||||
a `<script type="module">` is a complete development setup:
|
||||
see [`examples/counter/index.html`](examples/counter/index.html).
|
||||
- **Isomorphic by contract.** One app module. The browser mounts it, the
|
||||
server imports the same file and calls `toString(route)` or streams it
|
||||
with `toStream(route)` — a web-standard `ReadableStream`, so it plugs
|
||||
into Node, Deno, Bun, and edge runtimes alike.
|
||||
- **Streaming SSR.** Async values in child position flush the shell
|
||||
first and stream the rest in document order. Store data via
|
||||
`state.prefetch` promises. Hydration *adopts* the server DOM (same
|
||||
element references, form state kept) and warns precisely when server
|
||||
and client markup disagree.
|
||||
- **Async routes.** `app.route('/big', lazy(() => import('./big.js')))`
|
||||
— loading view or held tree while in flight, awaited on the server
|
||||
(the answer to [choojs/choo#653](https://github.com/choojs/choo/pull/653)).
|
||||
- **Modern platform, no shims.** ESM only, Node ≥ 24, Baseline
|
||||
Widely Available browsers. The router speaks WHATWG URL and survives
|
||||
emoji, literal `%`, and Unicode normalization differences.
|
||||
|
||||
load (element) {
|
||||
this.map = new mapboxgl.Map({
|
||||
container: element,
|
||||
center: this.local.center
|
||||
})
|
||||
}
|
||||
## bankai
|
||||
|
||||
update (center) {
|
||||
if (center.join() !== this.local.center.join()) {
|
||||
this.map.setCenter(center)
|
||||
}
|
||||
return false
|
||||
}
|
||||
The isomorphic compiler & server, rebuilt on Vite 8 / Rolldown. Only the
|
||||
client ever bundles — server code is plain ESM Node runs as-authored.
|
||||
|
||||
createElement (center) {
|
||||
this.local.center = center
|
||||
return html`<div></div>`
|
||||
}
|
||||
}
|
||||
```console
|
||||
$ bankai start app.js # dev: HMR + streaming SSR on every request
|
||||
$ bankai build app.js # client bundle, manifest, service worker,
|
||||
# brotli/gzip precompression, --prerender
|
||||
$ bankai serve # prod: 103 Early Hints, immutable assets,
|
||||
# streaming SSR, --h2
|
||||
$ bankai inspect # raw/gzip/brotli size report
|
||||
```
|
||||
|
||||
```javascript
|
||||
// index.js
|
||||
var choo = require('choo')
|
||||
var html = require('choo/html')
|
||||
var Map = require('./map.js')
|
||||
Conventions over config: `style.css` next to your entry is bundled for
|
||||
the client (server code never sees it), `sw.js` becomes a service worker
|
||||
with the precache manifest injected, `<title>` follows your
|
||||
`DOMTitleChange` emits — on the server too.
|
||||
|
||||
var app = choo()
|
||||
app.route('/', mainView)
|
||||
app.mount('body')
|
||||
## Packages
|
||||
|
||||
function mainView (state, emit) {
|
||||
return html`
|
||||
<body>
|
||||
<button onclick=${onclick}>Where am i?</button>
|
||||
${state.cache(Map, 'my-map').render(state.center)}
|
||||
</body>
|
||||
`
|
||||
| package | what it is |
|
||||
|---|---|
|
||||
| `@uhhm/buuh` | the app class: stores, router, emitter, `toString`/`toStream`, `lazy()` |
|
||||
| `@uhhm/buuh-html` | tagged templates: DOM in the browser, strings/streams on the server, `morph`, `hydrate`, `raw` |
|
||||
| `@uhhm/buuh-component` | stateful components with lifecycle hooks; the hydration boundary |
|
||||
| `@uhhm/buuh-devtools` | `window.choo` console tooling |
|
||||
| `@uhhm/buuh-migrate` | `buuh-migrate` — the choo v7 → buuh codemod |
|
||||
| `@uhhm/bankai` | the compiler & server above (bin: `bankai`) |
|
||||
|
||||
function onclick () {
|
||||
emit('locate')
|
||||
}
|
||||
}
|
||||
## Migrating from choo v7
|
||||
|
||||
app.use(function (state, emitter) {
|
||||
state.center = [18.0704503, 59.3244897]
|
||||
emitter.on('locate', function () {
|
||||
window.navigator.geolocation.getCurrentPosition(function (position) {
|
||||
state.center = [position.coords.longitude, position.coords.latitude]
|
||||
emitter.emit('render')
|
||||
})
|
||||
})
|
||||
})
|
||||
```console
|
||||
$ npx @uhhm/buuh-migrate .
|
||||
```
|
||||
|
||||
### Caching components
|
||||
When working with stateful components, one will need to keep track of component
|
||||
instances – `state.cache` does just that. The component cache is a function
|
||||
which takes a component class and a unique id (`string`) as its first two
|
||||
arguments. Any following arguments will be forwarded to the component constructor
|
||||
together with `state` and `emit`.
|
||||
converts simple CJS, remaps every specifier, and tells you honestly what
|
||||
it didn't dare touch. The API surface is unchanged — `choo()` still works
|
||||
without `new`. See [`docs/migrating-v7-to-v8.md`](docs/migrating-v7-to-v8.md).
|
||||
|
||||
The default class cache is an LRU cache (using [nanolru][nanolru]), meaning it
|
||||
will only hold on to a fixed amount of class instances (`100` by default) before
|
||||
starting to evict the least-recently-used instances. This behavior can be
|
||||
overridden with [options](#app--chooopts).
|
||||
## Development
|
||||
|
||||
## Optimizations
|
||||
Choo is reasonably fast out of the box. But sometimes you might hit a scenario
|
||||
where a particular part of the UI slows down the application, and you want to
|
||||
speed it up. Here are some optimizations that are possible.
|
||||
|
||||
### Caching DOM elements
|
||||
Sometimes we want to tell the algorithm to not evaluate certain nodes (and its
|
||||
children). This can be because we're sure they haven't changed, or perhaps
|
||||
because another piece of code is managing that part of the DOM tree. To achieve
|
||||
this `nanomorph` evaluates the `.isSameNode()` method on nodes to determine if
|
||||
they should be updated or not.
|
||||
|
||||
```js
|
||||
var el = html`<div>node</div>`
|
||||
|
||||
// tell nanomorph to not compare the DOM tree if they're both divs
|
||||
el.isSameNode = function (target) {
|
||||
return (target && target.nodeName && target.nodeName === 'DIV')
|
||||
}
|
||||
```console
|
||||
$ npm install
|
||||
$ npm test # 109 tests on node:test, zero framework deps
|
||||
$ npm run test:e2e # real Chromium: hydration, streaming, bankai
|
||||
$ npm run size # the wire-size budget
|
||||
$ npm run bench # vs nanohtml v1 and µhtml
|
||||
$ npm run bundle # the single-file CDN build
|
||||
```
|
||||
|
||||
### Reordering lists
|
||||
It's common to work with lists of elements on the DOM. Adding, removing or
|
||||
reordering elements in a list can be rather expensive. To optimize this you can
|
||||
add an `id` attribute to a DOM node. When reordering nodes it will compare
|
||||
nodes with the same ID against each other, resulting in far fewer re-renders.
|
||||
This is especially potent when coupled with DOM node caching.
|
||||
Releases: bump versions, `git tag vX.Y.Z`, push the tag — Gitea Actions
|
||||
tests, publishes every package to the uhhm registry, and uploads the CDN
|
||||
bundle ([`.gitea/workflows/release.yml`](.gitea/workflows/release.yml)).
|
||||
|
||||
```js
|
||||
var el = html`
|
||||
<section>
|
||||
<div id="first">hello</div>
|
||||
<div id="second">world</div>
|
||||
</section>
|
||||
`
|
||||
```
|
||||
## Credits
|
||||
|
||||
### Pruning dependencies
|
||||
We use the `require('assert')` module from Node core to provide helpful error
|
||||
messages in development. In production you probably want to strip this using
|
||||
[unassertify][unassertify].
|
||||
|
||||
To convert inlined HTML to valid DOM nodes we use `require('nanohtml')`. This has
|
||||
overhead during runtime, so for production environments we should unwrap this
|
||||
using the [nanohtml transform][nanohtml].
|
||||
|
||||
Setting up browserify transforms can sometimes be a bit of hassle; to make this
|
||||
more convenient we recommend using [bankai build][bankai] to build your assets for production.
|
||||
|
||||
## FAQ
|
||||
### Why is it called Choo?
|
||||
Because I thought it sounded cute. All these programs talk about being
|
||||
_"performant"_, _"rigid"_, _"robust"_ - I like programming to be light, fun and
|
||||
non-scary. Choo embraces that.
|
||||
|
||||
Also imagine telling some business people you chose to rewrite something
|
||||
critical for serious bizcorp using a train themed framework.
|
||||
:steam_locomotive::train::train::train:
|
||||
|
||||
### Is it called Choo, Choo.js or...?
|
||||
It's called "Choo", though we're fine if you call it "Choo-choo" or
|
||||
"Chugga-chugga-choo-choo" too. The only time "choo.js" is tolerated is if /
|
||||
when you shimmy like you're a locomotive.
|
||||
|
||||
### Does Choo use a virtual-dom?
|
||||
Choo uses [nanomorph][nanomorph], which diffs real DOM nodes instead of
|
||||
virtual nodes. It turns out that [browsers are actually ridiculously good at
|
||||
dealing with DOM nodes][morphdom-bench], and it has the added benefit of
|
||||
working with _any_ library that produces valid DOM nodes. So to put a long
|
||||
answer short: we're using something even better.
|
||||
|
||||
### How can I support older browsers?
|
||||
Template strings aren't supported in all browsers, and parsing them creates
|
||||
significant overhead. To optimize we recommend running `browserify` with
|
||||
[nanohtml][nanohtml] as a global transform or using [bankai][bankai] directly.
|
||||
```sh
|
||||
$ browserify -g nanohtml
|
||||
```
|
||||
|
||||
### Is choo production ready?
|
||||
Sure.
|
||||
|
||||
## API
|
||||
This section provides documentation on how each function in Choo works. It's
|
||||
intended to be a technical reference. If you're interested in learning choo for
|
||||
the first time, consider reading through the [handbook][handbook] first
|
||||
:sparkles:
|
||||
|
||||
### `app = choo([opts])`
|
||||
Initialize a new `choo` instance. `opts` can also contain the following values:
|
||||
- __opts.history:__ default: `true`. Listen for url changes through the
|
||||
history API.
|
||||
- __opts.href:__ default: `true`. Handle all relative `<a
|
||||
href="<location>"></a>` clicks and call `emit('render')`
|
||||
- __opts.cache:__ default: `undefined`. Override default class cache used by
|
||||
`state.cache`. Can be a a `number` (maximum number of instances in cache,
|
||||
default `100`) or an `object` with a [nanolru][nanolru]-compatible API.
|
||||
- __opts.hash:__ default: `false`. Treat hashes in URLs as part of the pathname,
|
||||
transforming `/foo#bar` to `/foo/bar`. This is useful if the application is
|
||||
not mounted at the website root.
|
||||
|
||||
### `app.use(callback(state, emitter, app))`
|
||||
Call a function and pass it a `state`, `emitter` and `app`. `emitter` is an instance
|
||||
of [nanobus](https://github.com/choojs/nanobus/). You can listen to
|
||||
messages by calling `emitter.on()` and emit messages by calling
|
||||
`emitter.emit()`. `app` is the same Choo instance. Callbacks passed to `app.use()` are commonly referred to as
|
||||
`'stores'`.
|
||||
|
||||
If the callback has a `.storeName` property on it, it will be used to identify
|
||||
the callback during tracing.
|
||||
|
||||
See [#events](#events) for an overview of all events.
|
||||
|
||||
### `app.route(routeName, handler(state, emit))`
|
||||
Register a route on the router. The handler function is passed `app.state`
|
||||
and `app.emitter.emit` as arguments. Uses [nanorouter][nanorouter] under the
|
||||
hood.
|
||||
|
||||
See [#routing](#routing) for an overview of how to use routing efficiently.
|
||||
|
||||
### `app.mount(selector)`
|
||||
Start the application and mount it on the given `querySelector`,
|
||||
the given selector can be a String or a DOM element.
|
||||
|
||||
In the browser, this will _replace_ the selector provided with the tree returned from `app.start()`.
|
||||
If you want to add the app as a child to an element, use `app.start()` to obtain the tree and manually append it.
|
||||
|
||||
On the server, this will save the `selector` on the app instance.
|
||||
When doing server side rendering, you can then check the `app.selector` property to see where the render result should be inserted.
|
||||
|
||||
Returns `this`, so you can easily export the application for server side rendering:
|
||||
|
||||
```js
|
||||
module.exports = app.mount('body')
|
||||
```
|
||||
|
||||
### `tree = app.start()`
|
||||
Start the application. Returns a tree of DOM nodes that can be mounted using
|
||||
`document.body.appendChild()`.
|
||||
|
||||
### `app.toString(location, [state])`
|
||||
Render the application to a string. Useful for rendering on the server.
|
||||
|
||||
### `choo/html`
|
||||
Create DOM nodes from template string literals. Exposes
|
||||
[nanohtml](https://github.com/choojs/nanohtml). Can be optimized using
|
||||
[nanohtml][nanohtml].
|
||||
|
||||
### `choo/html/raw`
|
||||
Exposes [nanohtml/raw](https://github.com/shama/nanohtml#unescaping) helper for rendering raw HTML content.
|
||||
|
||||
## Installation
|
||||
```sh
|
||||
$ npm install choo
|
||||
```
|
||||
|
||||
## See Also
|
||||
- [bankai](https://github.com/choojs/bankai) - streaming asset compiler
|
||||
- [stack.gl](http://stack.gl/) - open software ecosystem for WebGL
|
||||
- [yo-yo](https://github.com/maxogden/yo-yo) - tiny library for modular UI
|
||||
- [tachyons](https://github.com/tachyons-css/tachyons) - functional CSS for
|
||||
humans
|
||||
- [sheetify](https://github.com/stackcss/sheetify) - modular CSS bundler for
|
||||
`browserify`
|
||||
|
||||
## Support
|
||||
Creating a quality framework takes a lot of time. Unlike others frameworks,
|
||||
Choo is completely independently funded. We fight for our users. This does mean
|
||||
however that we also have to spend time working contracts to pay the bills.
|
||||
This is where you can help: by chipping in you can ensure more time is spent
|
||||
improving Choo rather than dealing with distractions.
|
||||
|
||||
### Sponsors
|
||||
Become a sponsor and help ensure the development of independent quality
|
||||
software. You can help us keep the lights on, bellies full and work days sharp
|
||||
and focused on improving the state of the web. [Become a
|
||||
sponsor](https://opencollective.com/choo#sponsor)
|
||||
|
||||
<a href="https://opencollective.com/choo/sponsor/0/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/1/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/2/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/3/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/4/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/5/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/6/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/7/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/8/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/9/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/9/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/10/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/10/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/11/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/11/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/12/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/12/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/13/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/13/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/14/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/14/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/15/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/15/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/16/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/16/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/17/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/17/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/18/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/18/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/19/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/19/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/20/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/20/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/21/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/21/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/22/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/22/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/23/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/23/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/24/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/24/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/25/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/25/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/26/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/26/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/27/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/27/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/28/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/28/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/sponsor/29/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/29/avatar.svg"></a>
|
||||
|
||||
### Backers
|
||||
Become a backer, and buy us a coffee (or perhaps lunch?) every month or so.
|
||||
[Become a backer](https://opencollective.com/choo#backer)
|
||||
|
||||
<a href="https://opencollective.com/choo/backer/0/website" target="_blank"><img src="https://opencollective.com/choo/backer/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/1/website" target="_blank"><img src="https://opencollective.com/choo/backer/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/2/website" target="_blank"><img src="https://opencollective.com/choo/backer/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/3/website" target="_blank"><img src="https://opencollective.com/choo/backer/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/4/website" target="_blank"><img src="https://opencollective.com/choo/backer/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/5/website" target="_blank"><img src="https://opencollective.com/choo/backer/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/6/website" target="_blank"><img src="https://opencollective.com/choo/backer/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/7/website" target="_blank"><img src="https://opencollective.com/choo/backer/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/8/website" target="_blank"><img src="https://opencollective.com/choo/backer/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/9/website" target="_blank"><img src="https://opencollective.com/choo/backer/9/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/10/website" target="_blank"><img src="https://opencollective.com/choo/backer/10/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/11/website" target="_blank"><img src="https://opencollective.com/choo/backer/11/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/12/website" target="_blank"><img src="https://opencollective.com/choo/backer/12/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/13/website" target="_blank"><img src="https://opencollective.com/choo/backer/13/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/14/website" target="_blank"><img src="https://opencollective.com/choo/backer/14/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/15/website" target="_blank"><img src="https://opencollective.com/choo/backer/15/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/16/website" target="_blank"><img src="https://opencollective.com/choo/backer/16/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/17/website" target="_blank"><img src="https://opencollective.com/choo/backer/17/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/18/website" target="_blank"><img src="https://opencollective.com/choo/backer/18/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/19/website" target="_blank"><img src="https://opencollective.com/choo/backer/19/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/20/website" target="_blank"><img src="https://opencollective.com/choo/backer/20/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/21/website" target="_blank"><img src="https://opencollective.com/choo/backer/21/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/22/website" target="_blank"><img src="https://opencollective.com/choo/backer/22/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/23/website" target="_blank"><img src="https://opencollective.com/choo/backer/23/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/24/website" target="_blank"><img src="https://opencollective.com/choo/backer/24/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/25/website" target="_blank"><img src="https://opencollective.com/choo/backer/25/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/26/website" target="_blank"><img src="https://opencollective.com/choo/backer/26/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/27/website" target="_blank"><img src="https://opencollective.com/choo/backer/27/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/28/website" target="_blank"><img src="https://opencollective.com/choo/backer/28/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/choo/backer/29/website" target="_blank"><img src="https://opencollective.com/choo/backer/29/avatar.svg"></a>
|
||||
|
||||
## License
|
||||
[MIT](https://tldrlegal.com/license/mit-license)
|
||||
|
||||
[nanocomponent]: https://github.com/choojs/nanocomponent
|
||||
[nanolru]: https://github.com/s3ththompson/nanolru
|
||||
[bankai]: https://github.com/choojs/bankai
|
||||
[nanohtml]: https://github.com/choojs/nanohtml
|
||||
[browserify]: https://github.com/substack/node-browserify
|
||||
[budo]: https://github.com/mattdesl/budo
|
||||
[es2020]: https://github.com/yoshuawuyts/es2020
|
||||
[handbook]: https://github.com/yoshuawuyts/choo-handbook
|
||||
[hyperx]: https://github.com/substack/hyperx
|
||||
[morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks
|
||||
[nanomorph]: https://github.com/choojs/nanomorph
|
||||
[nanorouter]: https://github.com/choojs/nanorouter
|
||||
[yo-yo]: https://github.com/maxogden/yo-yo
|
||||
[unassertify]: https://github.com/unassert-js/unassertify
|
||||
[window-performance]: https://developer.mozilla.org/en-US/docs/Web/API/Performance
|
||||
buuh is choo. The design, the API, and most of the ideas are
|
||||
[Yoshua Wuyts](https://github.com/yoshuawuyts)'s and the
|
||||
[choojs contributors](https://github.com/choojs)'; the ESM groundwork
|
||||
came from the [@pirxpilot](https://github.com/pirxpilot) fork line; this
|
||||
fork modernized the engine and the toolchain around them. Per-file
|
||||
attribution headers name the module and version each port came from.
|
||||
MIT, as always.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Real-browser benchmark (Playwright Chromium): @uhhm/buuh-html vs µhtml v5,
|
||||
// same create/update scenarios as bench/render.js but with native DOM.
|
||||
// nanohtml v1 is CJS-only and can't be import-mapped, so it only appears
|
||||
// in the happy-dom bench.
|
||||
//
|
||||
// node bench/real-browser.js
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
import { startServer } from '../test/e2e/serve.js'
|
||||
|
||||
const srv = await startServer()
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<script type="importmap">${JSON.stringify({
|
||||
imports: {
|
||||
'@uhhm/buuh-html': '/packages/html/browser.js',
|
||||
'@uhhm/buuh-html/morph': '/packages/html/morph.js',
|
||||
uhtml: '/node_modules/uhtml/dist/prod/dom.js'
|
||||
}
|
||||
})}</script>
|
||||
</head><body></body></html>`
|
||||
|
||||
await page.route('**/bench-page', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }))
|
||||
await page.goto(srv.origin + '/bench-page')
|
||||
|
||||
const results = await page.evaluate(async () => {
|
||||
const { default: html } = await import('@uhhm/buuh-html')
|
||||
const { default: morph } = await import('@uhhm/buuh-html/morph')
|
||||
const { html: uhtml, render: urender } = await import('uhtml')
|
||||
|
||||
const ROWS = 100
|
||||
const data = (tick) => Array.from({ length: ROWS }, (_, i) => ({
|
||||
id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS
|
||||
}))
|
||||
|
||||
const bench = (fn) => {
|
||||
let t = 0
|
||||
const warmupEnd = performance.now() + 200
|
||||
while (performance.now() < warmupEnd) fn(t++)
|
||||
let ops = 0
|
||||
const start = performance.now()
|
||||
const end = start + 1000
|
||||
while (performance.now() < end) { fn(t++); ops++ }
|
||||
return Math.round((ops / (performance.now() - start)) * 1000)
|
||||
}
|
||||
|
||||
const ourTable = (rows) => html`<table><tbody>${rows.map((r) => html`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
|
||||
const uTable = (rows) => uhtml`<table><tbody>${rows.map((r) => uhtml`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
|
||||
|
||||
const out = {}
|
||||
out['@uhhm/buuh-html create'] = bench((t) => ourTable(data(t)))
|
||||
out['uhtml v5 create (fresh container)'] = bench((t) => {
|
||||
const c = document.createElement('div')
|
||||
urender(c, uTable(data(t)))
|
||||
})
|
||||
|
||||
const live = ourTable(data(0))
|
||||
document.body.appendChild(live)
|
||||
out['@uhhm/buuh-html + nanomorph update'] = bench((t) => morph(live, ourTable(data(t))))
|
||||
live.remove()
|
||||
|
||||
const c = document.createElement('div')
|
||||
document.body.appendChild(c)
|
||||
out['uhtml v5 update (in place)'] = bench((t) => urender(c, uTable(data(t))))
|
||||
c.remove()
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
console.log('\nreal Chromium — 100-row table, ops/s:')
|
||||
for (const [name, ops] of Object.entries(results)) {
|
||||
console.log(' ' + name.padEnd(38) + String(ops).padStart(8) + ' ops/s')
|
||||
}
|
||||
console.log()
|
||||
|
||||
await browser.close()
|
||||
await srv.close()
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Rendering benchmarks: @uhhm/buuh-html vs nanohtml v1 vs µhtml.
|
||||
//
|
||||
// npm run bench
|
||||
//
|
||||
// Two environments:
|
||||
// - server: pure string rendering in Node (no DOM at all)
|
||||
// - browser-path: DOM construction in happy-dom. Caveat: happy-dom is a
|
||||
// JS DOM, so absolute numbers are not real-browser numbers — but all
|
||||
// contenders pay the same DOM tax, so the relative ordering is
|
||||
// indicative of template-engine overhead.
|
||||
//
|
||||
// Scenarios: "create" builds a fresh 100-row table per iteration;
|
||||
// "update" re-renders the same live tree with new data each iteration
|
||||
// (choo-style engines build fresh + morph, µhtml updates holes in place).
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const ROWS = 100
|
||||
const DURATION_MS = 500
|
||||
const WARMUP_MS = 100
|
||||
|
||||
function data (tick) {
|
||||
const rows = new Array(ROWS)
|
||||
for (let i = 0; i < ROWS; i++) {
|
||||
rows[i] = { id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS }
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function bench (name, fn) {
|
||||
let t = performance.now()
|
||||
const warmupEnd = t + WARMUP_MS
|
||||
let tick = 0
|
||||
while (performance.now() < warmupEnd) fn(tick++)
|
||||
|
||||
let ops = 0
|
||||
const start = performance.now()
|
||||
const end = start + DURATION_MS
|
||||
while (performance.now() < end) {
|
||||
fn(tick++)
|
||||
ops++
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
const opsSec = Math.round((ops / elapsed) * 1000)
|
||||
console.log(' ' + name.padEnd(34) + String(opsSec).padStart(8) + ' ops/s')
|
||||
return opsSec
|
||||
}
|
||||
|
||||
// ---------- server (string) rendering ----------
|
||||
|
||||
console.log('\nserver rendering — %d-row table to string:', ROWS)
|
||||
{
|
||||
const { default: ourHtml } = await import('@uhhm/buuh-html/server')
|
||||
const nano = require('nanohtml') // main entry is the server renderer
|
||||
|
||||
const table = (html, rows) => html`<table><tbody>${rows.map((r) => html`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
|
||||
|
||||
bench('@uhhm/buuh-html (server)', (t) => String(table(ourHtml, data(t))))
|
||||
bench('nanohtml v1 (server)', (t) => String(table(nano, data(t))))
|
||||
}
|
||||
|
||||
// ---------- browser-path rendering (happy-dom) ----------
|
||||
|
||||
const { Window } = await import('happy-dom')
|
||||
const win = new Window()
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
// µhtml touches DOM constructors as ambient globals at import time
|
||||
for (const name of [
|
||||
'DocumentFragment', 'HTMLElement', 'SVGElement', 'Element', 'Node',
|
||||
'Text', 'Comment', 'Range', 'MutationObserver', 'customElements',
|
||||
'requestAnimationFrame', 'cancelAnimationFrame'
|
||||
]) {
|
||||
if (!(name in globalThis) && name in win) globalThis[name] = win[name]
|
||||
}
|
||||
|
||||
const { default: ourBrowserHtml } = await import('@uhhm/buuh-html/browser')
|
||||
const { default: morph } = await import('@uhhm/buuh-html/morph')
|
||||
const nanoBrowser = require('nanohtml/lib/browser.js')
|
||||
const { html: uhtml, render: urender } = await import('uhtml')
|
||||
|
||||
const table = (html, rows) => html`<table><tbody>${rows.map((r) => html`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
|
||||
|
||||
console.log('\nbrowser-path create — fresh %d-row table per iteration (happy-dom):', ROWS)
|
||||
bench('@uhhm/buuh-html (cached templates)', (t) => table(ourBrowserHtml, data(t)))
|
||||
bench('nanohtml v1 (hyperx runtime)', (t) => table(nanoBrowser, data(t)))
|
||||
bench('uhtml v5 (fresh container)', (t) => {
|
||||
const c = document.createElement('div')
|
||||
urender(c, uhtml`<table><tbody>${data(t).map((r) => uhtml`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`)
|
||||
})
|
||||
|
||||
console.log('\nbrowser-path update — re-render same live tree (happy-dom):')
|
||||
{
|
||||
const live = table(ourBrowserHtml, data(0))
|
||||
document.body.appendChild(live)
|
||||
bench('@uhhm/buuh-html + nanomorph', (t) => morph(live, table(ourBrowserHtml, data(t))))
|
||||
document.body.removeChild(live)
|
||||
}
|
||||
{
|
||||
const live = table(nanoBrowser, data(0))
|
||||
document.body.appendChild(live)
|
||||
bench('nanohtml v1 + nanomorph', (t) => morph(live, table(nanoBrowser, data(t))))
|
||||
document.body.removeChild(live)
|
||||
}
|
||||
{
|
||||
const c = document.createElement('div')
|
||||
document.body.appendChild(c)
|
||||
const view = (t) => uhtml`<table><tbody>${data(t).map((r) => uhtml`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
|
||||
bench('uhtml v5 (keyed holes, in place)', (t) => urender(c, view(t)))
|
||||
document.body.removeChild(c)
|
||||
}
|
||||
|
||||
console.log('\ncaveat: happy-dom numbers are indicative (same DOM tax for all), not real-browser numbers.\n')
|
||||
@@ -0,0 +1,83 @@
|
||||
# Deploying a choo v8 + bankai v10 app
|
||||
|
||||
The server side of a v8 app is plain ESM — there is no server bundle.
|
||||
Deploy is: ship the source + `dist/`, run a server.
|
||||
|
||||
## Plain Node (the blessed path)
|
||||
|
||||
```console
|
||||
$ bankai build app.js --title "my app" --prerender /
|
||||
$ bankai serve --h2 --port 443
|
||||
```
|
||||
|
||||
`bankai serve` does static assets (immutable + precompressed), 103 Early
|
||||
Hints, streaming SSR, and `window.initialState`. `--h2` matters:
|
||||
browsers only act on Early Hints over HTTP/2 or HTTP/3. For a public
|
||||
deployment use a real certificate (the built-in one is a generated
|
||||
localhost cert) or front it with a TLS-terminating proxy.
|
||||
|
||||
## Docker
|
||||
|
||||
```dockerfile
|
||||
FROM node:24-slim
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
RUN npx bankai build app.js
|
||||
EXPOSE 8080
|
||||
CMD ["npx", "bankai", "serve"]
|
||||
```
|
||||
|
||||
## Behind a proxy / CDN (and the HTTP/3 answer)
|
||||
|
||||
Node has no production HTTP/3 server yet (QUIC support in core is still
|
||||
experimental and not exposed as an h3 listener), and that's fine: h3 is
|
||||
infrastructure's job. Run `bankai serve` on h1 or h2 behind:
|
||||
|
||||
- **Caddy** — serves h2 + h3 by default, forwards to bankai:
|
||||
```
|
||||
example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
- **nginx ≥ 1.29** — `listen 443 quic;` for h3 plus
|
||||
`early_hints on;`-era support for forwarding the 103.
|
||||
- **Cloudflare / Fastly** — h3 at the edge automatically; both
|
||||
understand and propagate Early Hints (Cloudflare has since 2021).
|
||||
|
||||
bankai's contract with these layers is just headers: it emits the 103
|
||||
interim response and repeats the `Link` header on the final response, so
|
||||
any hints-aware edge can act on them — including over h3/QUIC, where
|
||||
Early Hints work exactly as on h2. What you should *not* expect is
|
||||
HTTP/2-style server push: it's removed from Chrome and Firefox and was
|
||||
never in HTTP/3. Hints + preload is the whole story now.
|
||||
|
||||
## Web-standard runtimes (Deno, Bun, edge)
|
||||
|
||||
`toStream()` returns a WHATWG `ReadableStream`, so the render half needs
|
||||
no adapter at all:
|
||||
|
||||
```js
|
||||
import createApp from './app.js'
|
||||
import { documentHead, documentTail, resolveApp } from 'bankai'
|
||||
|
||||
export default {
|
||||
async fetch (req) {
|
||||
const app = createApp()
|
||||
const state = {}
|
||||
const url = new URL(req.url)
|
||||
const body = app.toStream(url.pathname + url.search, state)
|
||||
// stream the body through a TransformStream that wraps head + tail,
|
||||
// or buffer if the route is small — your call, it's a web stream
|
||||
return new Response(body, {
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Serve `dist/` via the platform's static hosting and reuse
|
||||
`dist/bankai.json` for the asset links. A first-class edge adapter
|
||||
(head/tail wrapping + Early Hints via the platform APIs) is on the v8
|
||||
roadmap; the primitives are deliberately already web-standard.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Migrating a choo v7 app to v8
|
||||
|
||||
The API you know is intact: `choo()`, `app.use`, `app.route`,
|
||||
`app.mount`, `app.toString`, `emit`, stores, `state.events`. What changed
|
||||
is the plumbing: ESM everywhere, new package names, no compile step, and
|
||||
a platform baseline of Node ≥ 24 + Baseline Widely Available browsers.
|
||||
|
||||
## The fast path
|
||||
|
||||
```console
|
||||
$ npx @uhhm/buuh-migrate . # rewrite in place
|
||||
$ npx @uhhm/buuh-migrate --dry . # or report only
|
||||
```
|
||||
|
||||
The codemod converts simple top-level CJS to ESM, remaps specifiers, and
|
||||
prints a note for everything it won't guess at. Then:
|
||||
|
||||
1. Add `"type": "module"` to package.json.
|
||||
2. Swap dependencies for `@uhhm/buuh`, `@uhhm/buuh-html`, and friends.
|
||||
3. Run your app. Read the console: hydration now *tells you* when server
|
||||
and client markup disagree.
|
||||
|
||||
## Specifier map
|
||||
|
||||
| v7 | v8 |
|
||||
|---|---|
|
||||
| `choo` | `@uhhm/buuh` |
|
||||
| `choo/html`, `nanohtml` | `@uhhm/buuh-html` |
|
||||
| `nanohtml/raw` | `@uhhm/buuh-html/raw` |
|
||||
| `nanomorph` | `@uhhm/buuh-html/morph` |
|
||||
| `nanocomponent`, `choo/component` | `@uhhm/buuh-component` |
|
||||
| `choo-devtools` | `@uhhm/buuh-devtools` |
|
||||
| `choo-lazy-route` | `lazy()` from `@uhhm/buuh` |
|
||||
| `nanobus`, `nanorouter`, `nanohref`, `nanotiming` | built into `@uhhm/buuh` |
|
||||
| `nanoquery` | built in (`state.query`); use `URLSearchParams` elsewhere |
|
||||
| `nanoraf`, `nanoassert`, `nanolru` | retired — platform/built-in |
|
||||
|
||||
## Behavior changes to know about
|
||||
|
||||
- **URLs decode once, and never crash.** A literal `%` in a path routed
|
||||
v7 into a `URIError`; v8 keeps the raw segment. Params are no longer
|
||||
double-decoded (`%2540` → `%40`, not `@`). Routes and locations are
|
||||
NFC-normalized, so `café` matches however the é was composed.
|
||||
`state.href` is decoded for reading.
|
||||
- **Event handlers never serialize.** v7's server renderer emitted
|
||||
`onclick=""`; v8 emits nothing — handlers are behavior, not markup.
|
||||
- **Whitespace is preserved as authored** in browser renders (v7's
|
||||
browser transform collapsed it). Server and browser output are now
|
||||
byte-identical, which is what makes hydration adoption work.
|
||||
- **`mount()` hydrates.** First render adopts server DOM in place and
|
||||
warns on real mismatches; `<script>` tags and whitespace are ignored.
|
||||
- **`toString()` got stricter, `toStream()` got capable.** Sync renders
|
||||
behave exactly as v7. Async content — `state.prefetch` promises, lazy
|
||||
routes, promise-valued template holes — requires `toStream()`, and
|
||||
`toString()` says so instead of misrendering.
|
||||
- **Views can't return arrays** (unchanged from v7) and document-level
|
||||
roots (`html\`<body>…\``) work in the browser renderer too.
|
||||
|
||||
## Things that moved to bankai
|
||||
|
||||
sheetify → put a `style.css` next to your entry (client-only by
|
||||
construction). `split-require` → native `import()` + `lazy()`.
|
||||
`bankai start/build/serve/inspect` replace the v9 pipeline; HTTP/2 push
|
||||
never shipped and is dead in browsers — bankai v10 sends 103 Early Hints
|
||||
instead.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Publishing buuh on project.uhhm.no
|
||||
|
||||
Two different distribution problems, two different mechanisms. Gitea
|
||||
handles one of them natively; the other needs a static host.
|
||||
|
||||
## 1. `npm install` — Gitea Packages (yes, it does what you hope)
|
||||
|
||||
Gitea ships a real npm registry per user/org. For the `uhhm` org the
|
||||
endpoint is:
|
||||
|
||||
```
|
||||
https://project.uhhm.no/api/packages/uhhm/npm/
|
||||
```
|
||||
|
||||
**Publishing** happens from the release workflow
|
||||
(`.gitea/workflows/release.yml`): tag `v8.0.0`, push the tag, and every
|
||||
workspace package is published with a `package:write` token stored as
|
||||
the `PACKAGES_TOKEN` repo secret. Bump the workspace versions before
|
||||
tagging.
|
||||
|
||||
**Consuming** needs one line of `.npmrc` in a project (or `~/.npmrc`):
|
||||
|
||||
```ini
|
||||
@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/
|
||||
```
|
||||
|
||||
Then plain npm works, and this is why the packages are scoped: npm
|
||||
routes *by scope*, so `@uhhm/*` resolves against your Gitea while
|
||||
everything else still comes from npmjs.org. No token is needed to
|
||||
install if the packages are public (they inherit visibility from the
|
||||
owner). Starting a project is:
|
||||
|
||||
```console
|
||||
$ echo "@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/" >> .npmrc
|
||||
$ npm i @uhhm/buuh @uhhm/buuh-html
|
||||
$ npm i -D @uhhm/bankai
|
||||
$ npx bankai start app.js
|
||||
```
|
||||
|
||||
## 2. "CDN require" — the part Gitea does NOT do
|
||||
|
||||
This is the misunderstanding worth clearing up: **Gitea's npm registry
|
||||
serves tarballs to package managers, not individual JavaScript files to
|
||||
browsers.** There is no unpkg-style `https://…/@uhhm/buuh/index.js`
|
||||
endpoint, and the generic-package download URLs serve
|
||||
`application/octet-stream` — browsers refuse that for ES modules (strict
|
||||
MIME checking). So an import map cannot point at Gitea directly.
|
||||
|
||||
What works instead, in order of effort:
|
||||
|
||||
**a. The single-file bundle + any static host (recommended).**
|
||||
`npm run bundle` produces `dist-cdn/buuh.js` — the whole framework as
|
||||
one minified ES module with named exports. The release workflow uploads
|
||||
it to Gitea's generic package store as the archive of record; to make it
|
||||
importable, serve a copy from any host that sends
|
||||
`text/javascript` + CORS. Since you run uhhm.no, that's a few lines of
|
||||
Caddy:
|
||||
|
||||
```
|
||||
cdn.uhhm.no {
|
||||
root * /srv/cdn
|
||||
file_server
|
||||
header Access-Control-Allow-Origin *
|
||||
header /buuh@* Cache-Control "public, max-age=31536000, immutable"
|
||||
}
|
||||
```
|
||||
|
||||
Drop each release in as `buuh@8.0.0.js` (a `curl` from the generic
|
||||
package URL, or an extra `scp` step in the workflow), and every project
|
||||
on earth can do:
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{ "imports": { "buuh": "https://cdn.uhhm.no/buuh@8.0.0.js" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import { choo, html } from 'buuh'
|
||||
</script>
|
||||
```
|
||||
|
||||
That is the zero-build story with your own domain on it.
|
||||
|
||||
**b. Self-hosted esm.sh.** esm.sh is open source and can be pointed at
|
||||
a custom npm registry — run it against the Gitea endpoint and you get
|
||||
real CDN semantics (per-package URLs, versioning, bundling) for
|
||||
everything you publish. More moving parts; worth it only if you want
|
||||
per-package URLs rather than the one-file bundle.
|
||||
|
||||
**c. Raw Gitea file URLs — don't.** Gitea serves raw `.js` as
|
||||
`text/plain` for safety, which module loading rejects. Fronting raw
|
||||
URLs with a MIME-rewriting proxy works but is a hack with none of the
|
||||
caching benefits of (a).
|
||||
|
||||
## Version hygiene
|
||||
|
||||
- Workspace versions are currently `8.0.0-dev`; set real versions
|
||||
before the first tag (`npm version 8.0.0 --workspaces --no-git-tag-version`).
|
||||
- The tag drives the generic-package version in the workflow, so keep
|
||||
tags and package.json versions in step.
|
||||
- `bankai` the bin name survives; the package is `@uhhm/bankai` so scope
|
||||
routing works.
|
||||
@@ -0,0 +1,84 @@
|
||||
# RFC: choo v8 — same soul, modern engine
|
||||
|
||||
> Draft, to be posted as an issue on choojs/choo. Placeholders in
|
||||
> [brackets]. Tone check before posting: continuation, not correction.
|
||||
|
||||
---
|
||||
|
||||
Hi everyone — long-time choo user, org member, still shipping things
|
||||
with it. I'd like to propose (and have prototyped) a v8.
|
||||
|
||||
**The short version:** choo's design aged beautifully. The toolchain
|
||||
around it didn't — browserify, the nanohtml transform, Babel 6, Travis.
|
||||
v8 keeps the API and the philosophy and replaces every dead dependency
|
||||
with either the platform or ~300 lines we own. I've built the whole
|
||||
thing on a branch to make this a concrete conversation rather than a
|
||||
wishlist: **[link to v8 branch]**.
|
||||
|
||||
## What stays exactly the same
|
||||
|
||||
The 7-line counter is still the 7-line counter. `choo()`, stores,
|
||||
`emit`, tagged template views, morphing re-renders, `toString()` on the
|
||||
server. `choo()` still works without `new`. A codemod
|
||||
(`npx @choojs/migrate`) moves v7 apps across — validated against this
|
||||
repo's own example app.
|
||||
|
||||
## What changes
|
||||
|
||||
- **ESM only, Node ≥ 24, Baseline browsers. Zero compile steps.** The
|
||||
nanohtml browserify transform is replaced by a runtime template cache
|
||||
(parse once per call site, clone per render — the µhtml technique,
|
||||
adapted to our morph-based model). Consequence: **you can develop a
|
||||
choo app with an import map and view-source**, no tooling at all.
|
||||
- **Twelve packages become six.** nanobus/nanorouter/nanohref/nanotiming
|
||||
fold into `@choojs/core` as attributed ports; nanoquery/nanoraf/
|
||||
nanoassert retire to the platform. Smaller maintenance surface is the
|
||||
point — single-maintainer fatigue is what stalled v7, and I don't want
|
||||
to rebuild that failure mode.
|
||||
- **SSR grows up: `toStream()`.** Web-standard `ReadableStream`,
|
||||
progressive flushing through async template holes, `state.prefetch`
|
||||
for store data, and hydration that *adopts* server DOM and warns on
|
||||
real mismatches. Answers the async-route question too — #653 finally
|
||||
gets both halves: `lazy(() => import('./view.js'))` in the browser,
|
||||
awaited by `toStream` on the server.
|
||||
- **bankai v10** — same one-command soul, rebuilt as a thin shell over
|
||||
Vite 8/Rolldown. Only the client bundles (v8 server code runs
|
||||
as-authored — no server build to rot). 103 Early Hints replace the
|
||||
HTTP/2 push story, `style.css`/`sw.js` conventions replace sheetify
|
||||
and the env-var service-worker dance, `--prerender` and `--h2`
|
||||
included.
|
||||
- **The size claim, honestly restated.** v7 said 4kb for choo alone
|
||||
(templates compiled away by the transform). v8's *entire* framework —
|
||||
core + html engine + morph + hydration — is 7.97 kB min+gzip
|
||||
/ 7.15 kB brotli, enforced by CI. Apples-to-apples it's smaller;
|
||||
the README now says the true number.
|
||||
|
||||
Numbers, tests, and the full decision log live on the branch:
|
||||
109 unit tests (the v7 suite among them, behavior preserved) + 7
|
||||
Playwright tests in real Chromium covering hydration, streaming, and
|
||||
bankai dev/prod. Also fixed along the way: the non-ASCII/`%` URL
|
||||
crashes (routing now parses with WHATWG URL, decodes once, and
|
||||
NFC-normalizes).
|
||||
|
||||
## What I'm asking
|
||||
|
||||
1. **@yoshuawuyts** — a blessing costs one emoji and would mean a lot.
|
||||
Zero obligation beyond that; the credits already say what this
|
||||
builds on.
|
||||
2. **@pirxpilot** — your ESM fork line is the only living continuation
|
||||
of this code and v8's core started from that groundwork. I'd love to
|
||||
co-maintain rather than fork-in-parallel; either way, thank you.
|
||||
3. **npm publish rights** for `choo`, `bankai`, and the nano*
|
||||
packages, or a nod to ship under `@choojs/*` with the old names as
|
||||
deprecation pointers after 8.0.0 exists (nothing gets deprecated
|
||||
before then; master and v7 stay untouched).
|
||||
4. **API feedback**, especially: `lazy()` wrapper vs thenable route
|
||||
handlers; the `state.prefetch` contract; anything in the migration
|
||||
doc that reads as a betrayal rather than an upgrade.
|
||||
|
||||
**Comment window: three weeks from posting.** After that I'll take
|
||||
silence as consent, keep working on the v8 branch in the open, and cut
|
||||
pre-releases under a `next` tag. If this lands wrong for anyone, say so
|
||||
— the plan bends.
|
||||
|
||||
🚂🚋🚋🚋🚋🚋
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# buuh (choo v8) — branch notes
|
||||
|
||||
> Naming note: this work now ships publicly as **buuh**, a friendly fork
|
||||
> under the `uhhm` org on project.uhhm.no, with packages scoped
|
||||
> `@uhhm/*` (scoped so npm's per-scope registry routing works — and so
|
||||
> we never squat upstream's names on any registry). The docs below use
|
||||
> both names; "v8" refers to this modernization effort either way. The
|
||||
> upstream RFC stays drafted in `docs/upstream-rfc-draft.md` if this
|
||||
> ever goes home.
|
||||
|
||||
This branch is the working tree for the v8 modernization effort. The v7 code
|
||||
at the repo root is untouched and stays authoritative until 8.0.0 ships;
|
||||
everything new lives under `packages/`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `packages/core` — `@uhhm/buuh`: the Choo class, same API as choo v7
|
||||
(`use`/`route`/`start`/`mount`/`toString`/`emit`), ported to ESM. The
|
||||
nano* internals are consolidated into `lib/` as attributed ports:
|
||||
- `lib/bus.js` ← nanobus 4.5.0
|
||||
- `lib/router.js` ← nanorouter 4.0.0 + wayfarer 7.0.1 (+ trie)
|
||||
- `lib/cache.js` ← choo component/cache.js + nanolru 1.0.0
|
||||
- `lib/raf.js` ← nanoraf 3.1.0, `lib/href.js` ← nanohref 3.1.0
|
||||
- `lib/timing.js` ← nanotiming 7.3.1 (unified on global `performance`)
|
||||
- `lib/dom.js` ← document-ready 2.0.1 + scroll-to-anchor 1.0.0
|
||||
- `lib/query.js` — nanoquery replaced by `URLSearchParams`
|
||||
- `packages/html` — `@uhhm/buuh-html`: the rendering package.
|
||||
- `server.js` — server-side tagged template (← nanohtml 1.10.0 server,
|
||||
transform branches removed; pure runtime)
|
||||
- `browser.js` — runtime-only cached template tag: each template literal
|
||||
is parsed once (WeakMap keyed on its strings array) into a <template>
|
||||
plus hole instructions; renders clone and fill. Values never pass
|
||||
through innerHTML. Document-level roots (<body> etc.) parse via
|
||||
DOMParser since <template> drops them. Known limits: no dynamic tag
|
||||
names, no holes in raw-text elements, SVG fragments need their <svg>
|
||||
root, and (unlike v7) whitespace is preserved as authored — which is
|
||||
what makes server and browser output byte-identical.
|
||||
- `morph.js` — ← nanomorph 5.4.3, consolidated to one module
|
||||
- `raw.js` — mark pre-encoded strings (works with both renderers)
|
||||
- `packages/component` — `@uhhm/buuh-component`: ← nanocomponent 6.6.0 +
|
||||
on-load 3.4.1 as ES classes; the future island/hydration boundary.
|
||||
- `packages/devtools` — `@uhhm/buuh-devtools`: window.choo with live state,
|
||||
event log, timings via PerformanceObserver, copy(); no-op on the server.
|
||||
- `packages/migrate` — `@uhhm/buuh-migrate`: the `choo-migrate` codemod.
|
||||
Regex-based on purpose: converts simple top-level CJS to ESM, remaps
|
||||
specifiers (choo → @uhhm/buuh, nanohtml → @uhhm/buuh-html, …), points
|
||||
retired packages at their replacements, and reports everything it
|
||||
refuses to guess at.
|
||||
- `examples/counter` — the isomorphic proof: one app module, mounted
|
||||
zero-build in the browser via import map (`index.html`), string-rendered
|
||||
in Node (`render.js`).
|
||||
- `examples/streaming` — streaming SSR demo server
|
||||
(`node examples/streaming/server.js`).
|
||||
|
||||
## Status vs the modernization plan
|
||||
|
||||
- [x] Phase 1: monorepo scaffold, ESM ports, choo v7 node test suite green
|
||||
on `node --test` (Node ≥ 24), CI on GitHub Actions
|
||||
- [x] URL normalization fix: WHATWG URL parsing, single decode with raw
|
||||
fallback (no more URIError on '%'), NFC matching, per-segment
|
||||
wildcard decode, decoded state.href
|
||||
- [x] Phase 2 (core): browser renderer rewrite, `@uhhm/buuh-component`,
|
||||
zero-build counter example, full-app integration test in happy-dom
|
||||
- [x] Phase 2 (tail): adoption-style hydration with mismatch warnings
|
||||
(`@uhhm/buuh-html/hydrate`, wired into `mount()`), real-browser
|
||||
Playwright e2e (zero-build page, SSR-then-hydrate page, adoption
|
||||
proof; CI job included), benchmarks vs nanohtml v1 / µhtml
|
||||
|
||||
## Benchmarks (2026-09, 100-row table, `npm run bench` / `bench:browser`)
|
||||
|
||||
Real Chromium: @uhhm/buuh-html creates fresh trees ~12% faster than µhtml v5
|
||||
(3.5k vs 3.1k ops/s) — the parse-once/clone design pays off in native DOM.
|
||||
µhtml updates in place ~5x faster than our fresh-tree + nanomorph loop
|
||||
(3.0k vs 0.6k ops/s): that is choo's architectural cost, mitigated in real
|
||||
apps by @uhhm/buuh-component caching (proxy nodes skip unchanged subtrees),
|
||||
and the number to beat if Phase 3 explores keyed-hole optimizations.
|
||||
Server string rendering is on par with nanohtml v1 (~13k ops/s, within
|
||||
6%). happy-dom numbers in bench/render.js are indicative only.
|
||||
- [x] Phase 3: v8 wiring, all shipped together:
|
||||
- `toStream(location, state)` — web-standard ReadableStream of
|
||||
UTF-8; `new Response(stream)` on web servers,
|
||||
`Readable.fromWeb(stream).pipe(res)` on Node. Progressive: the
|
||||
server tag now builds a parts list, so promises/async iterables
|
||||
in child position flush the shell first and stream the rest in
|
||||
document order (proven byte-level and in real Chromium).
|
||||
- `state.prefetch` — stores push promises during init; toStream
|
||||
awaits them before rendering; toString refuses them loudly.
|
||||
- `lazy(loader, loadingView?)` — the answer to choojs/choo#653,
|
||||
both halves at once: browser renders the loading view (or holds
|
||||
the current tree) until the dynamic import lands, server awaits
|
||||
it in toStream; toString fails with guidance. Views cache after
|
||||
first load.
|
||||
- `@uhhm/buuh-devtools` and the `choo-migrate` codemod (validated by
|
||||
migrating choo's own v7 example and running the result on v8).
|
||||
- [ ] Phase 3 follow-ups for the RFC: API shape feedback on lazy()
|
||||
(thenable handlers vs wrapper), serializing streamed state for
|
||||
hydration of async holes (bankai v10 territory).
|
||||
- [x] Phase 4: bankai v10 — `packages/bankai`, a thin shell over Vite 8
|
||||
(Rolldown). The server side needs no build at all: v8 apps are
|
||||
plain ESM that Node runs as-authored, so only the client bundles.
|
||||
- `bankai start <entry>` — dev: Vite middleware mode + HMR, with
|
||||
per-request streaming SSR through vite.ssrLoadModule and a
|
||||
virtual client entry (the user writes one isomorphic module;
|
||||
bankai generates the two lines of browser glue).
|
||||
- `bankai build <entry>` — client bundle + Vite manifest +
|
||||
dist/bankai.json route/asset metadata + service worker (if
|
||||
`<entry dir>/sw.js` exists, built with the precache list
|
||||
injected) + brotli/gzip precompression of every text asset.
|
||||
- `bankai serve` — production: static assets with immutable
|
||||
caching and precompressed-variant negotiation, 103 Early Hints
|
||||
(`res.writeEarlyHints`) carrying the route's assets before every
|
||||
SSR page, streaming render, and a `window.initialState` tail
|
||||
(script-safe serialization, choo internals filtered; hydration
|
||||
ignores server-only scripts).
|
||||
- `bankai inspect` — raw/gzip/brotli size table from the manifest.
|
||||
All proven end-to-end in real Chromium: both dev and prod pages
|
||||
hydrate with zero console errors and zero mismatch warnings, and
|
||||
the 103 interim response is asserted at the HTTP level.
|
||||
- [x] Phase 4 follow-ups: `bankai serve --h2` (local certs via openssl,
|
||||
103 verified with a real h2 client), `bankai build --prerender`,
|
||||
SSR `<title>` from state (the server reads the first stream chunk
|
||||
before writing the head), the `style.css` convention (client-only
|
||||
CSS — the sheetify answer), and the CI wire-size budget
|
||||
(`npm run size`: 7.97 kB min+gzip for the whole framework,
|
||||
budget 8.5 kB).
|
||||
- [x] Phase 5: README rewritten for v8 (honest size claim, credits),
|
||||
`docs/migrating-v7-to-v8.md`, `docs/deploy.md` (Node, Docker,
|
||||
proxy/CDN with the HTTP/3 story — h3 is infrastructure's job,
|
||||
bankai's contract is the 103 + Link headers any hints-aware edge
|
||||
propagates — and web-standard runtimes), and `docs/rfc.md`: the
|
||||
draft announcement to post on choojs/choo, with the pings, the
|
||||
asks, and the three-week comment window.
|
||||
- [ ] Post-RFC: publish pre-releases under a next tag, refresh
|
||||
choo.io/handbook (separate repos), deprecation pointers on retired
|
||||
packages only after 8.0.0, first-class edge adapter for bankai.
|
||||
- [ ] Phase 4: bankai v10 (Vite 8/Rolldown shell, SSR middleware,
|
||||
103 Early Hints, service worker, precompression)
|
||||
- [ ] Phase 5: docs, examples, launch
|
||||
|
||||
## Deliberate changes from v7
|
||||
|
||||
- `choo()` still works without `new`; `Choo` is also a named export.
|
||||
- nanoquery → `URLSearchParams` (same output shape, repeated keys → arrays).
|
||||
- Assertions live in `lib/assert.js` so production builds can strip them.
|
||||
- Everything targets Node ≥ 24 and Baseline Widely Available browsers; there
|
||||
is no compile step and no transpilation anywhere.
|
||||
|
||||
## Attribution
|
||||
|
||||
All ported modules are MIT-licensed work by the original choojs/nano*
|
||||
authors and contributors; per-file headers note the source package and
|
||||
version. This branch exists to carry that work forward, not to replace it.
|
||||
@@ -0,0 +1,40 @@
|
||||
// The classic choo counter. This one module is the whole app, and both
|
||||
// sides consume it: the browser mounts it (import map resolves
|
||||
// @uhhm/buuh-html to the DOM renderer), Node stringifies it (same specifier
|
||||
// resolves to the string renderer). That's the isomorphic contract.
|
||||
|
||||
import choo from '@uhhm/buuh'
|
||||
import html from '@uhhm/buuh-html'
|
||||
|
||||
export default function createApp () {
|
||||
const app = choo()
|
||||
app.use(countStore)
|
||||
app.route('/', mainView)
|
||||
// the demo gets served from arbitrary subpaths (npx serve ., test
|
||||
// servers); a wildcard fallback makes it mount anywhere
|
||||
app.route('*', mainView)
|
||||
return app
|
||||
}
|
||||
|
||||
function mainView (state, emit) {
|
||||
emit(state.events.DOMTITLECHANGE, `count is ${state.count}`)
|
||||
|
||||
return html`
|
||||
<body>
|
||||
<h1>count is ${state.count}</h1>
|
||||
<button onclick=${onclick}>Increment</button>
|
||||
</body>
|
||||
`
|
||||
|
||||
function onclick () {
|
||||
emit('increment', 1)
|
||||
}
|
||||
}
|
||||
|
||||
function countStore (state, emitter) {
|
||||
state.count = state.count || 0
|
||||
emitter.on('increment', function (count) {
|
||||
state.count += count
|
||||
emitter.emit('render')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>choo v8 counter — zero build</title>
|
||||
<!--
|
||||
No bundler, no compiler, no node_modules in the browser: an import map
|
||||
and native ES modules. Serve this directory from the repo root, e.g.
|
||||
npx serve .
|
||||
then open /examples/counter/
|
||||
-->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@uhhm/buuh": "../../packages/core/index.js",
|
||||
"@uhhm/buuh/timing": "../../packages/core/lib/timing.js",
|
||||
"@uhhm/buuh-html": "../../packages/html/browser.js",
|
||||
"@uhhm/buuh-html/raw": "../../packages/html/raw.js",
|
||||
"@uhhm/buuh-html/morph": "../../packages/html/morph.js",
|
||||
"@uhhm/buuh-html/hydrate": "../../packages/html/hydrate.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
import createApp from './app.js'
|
||||
createApp().mount('body')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Server-side render of the exact same app module the browser mounts:
|
||||
// node examples/counter/render.js
|
||||
import createApp from './app.js'
|
||||
|
||||
process.stdout.write(createApp().toString('/') + '\n')
|
||||
@@ -0,0 +1,29 @@
|
||||
/* The style.css convention: bankai's client entry imports this file
|
||||
automatically (CSS is a client concern — server code never sees it),
|
||||
Vite extracts and hashes it, and the server preloads it via Early
|
||||
Hints. The v8 answer to sheetify. */
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 36rem;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #ffc0cb55;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* global __BANKAI_ASSETS__ */
|
||||
// Service worker built by `bankai build`: the precache list is injected
|
||||
// at build time from the asset manifest (the choo-service-worker
|
||||
// convention, manifest edition). Register it from your app with
|
||||
// navigator.serviceWorker.register('/sw.js').
|
||||
|
||||
const CACHE = 'counter-v1'
|
||||
const ASSETS = __BANKAI_ASSETS__
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((hit) => hit || fetch(event.request))
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// Streaming SSR demo: the shell flushes immediately, the slow section
|
||||
// streams in when its promise resolves. Run it:
|
||||
// node examples/streaming/server.js
|
||||
//
|
||||
// This page is server-rendered only (no hydration script): async holes
|
||||
// stream on the server, while client-side views must be synchronous.
|
||||
// Serializing streamed state for hydration is bankai v10 territory.
|
||||
|
||||
import choo from '@uhhm/buuh'
|
||||
import html from '@uhhm/buuh-html'
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
export default function createApp ({ delay = 500 } = {}) {
|
||||
const app = choo()
|
||||
app.use((state) => { state.delay = delay })
|
||||
app.route('/', mainView)
|
||||
app.route('*', mainView)
|
||||
return app
|
||||
}
|
||||
|
||||
function mainView (state, emit) {
|
||||
return html`
|
||||
<body>
|
||||
<h1>choo streams</h1>
|
||||
<p>This shell was flushed before the slow part finished.</p>
|
||||
${slowSection(state)}
|
||||
</body>
|
||||
`
|
||||
}
|
||||
|
||||
async function slowSection (state) {
|
||||
await wait(state.delay)
|
||||
return html`<section id="slow">…and this arrived ${state.delay}ms later, same response.</section>`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Minimal streaming SSR server on plain Node http.
|
||||
// node examples/streaming/server.js
|
||||
// The same ReadableStream plugs into web-standard servers as
|
||||
// `new Response(app.toStream('/'))` — Readable.fromWeb is just the
|
||||
// Node http bridge.
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import createApp from './app.js'
|
||||
|
||||
const PORT = process.env.PORT || 8080
|
||||
|
||||
createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.write('<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>choo streams</title></head>\n')
|
||||
|
||||
const body = Readable.fromWeb(createApp().toStream('/'))
|
||||
body.pipe(res, { end: false })
|
||||
body.on('end', () => res.end('\n</html>'))
|
||||
body.on('error', (err) => {
|
||||
console.error(err)
|
||||
res.destroy()
|
||||
})
|
||||
}).listen(PORT, () => {
|
||||
console.log(`streaming on http://localhost:${PORT} — watch the slow section arrive`)
|
||||
})
|
||||
+20
-69
@@ -1,78 +1,29 @@
|
||||
{
|
||||
"name": "choo",
|
||||
"version": "7.1.0",
|
||||
"description": "A 4kb framework for creating sturdy frontend applications",
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"html/index.js",
|
||||
"html/raw.js",
|
||||
"html/index.d.ts",
|
||||
"component/cache.js",
|
||||
"component/index.js",
|
||||
"dist",
|
||||
"example"
|
||||
"name": "@uhhm/buuh-monorepo",
|
||||
"private": true,
|
||||
"version": "8.0.0",
|
||||
"description": "Monorepo for buuh \u2014 a friendly fork of choo, rebuilt for the modern platform",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"browser": {
|
||||
"assert": "nanoassert"
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "mkdir -p dist/ && browserify index -s Choo -p bundle-collapser/plugin > dist/bundle.js && browserify index -s Choo -p tinyify > dist/bundle.min.js && cat dist/bundle.min.js | gzip --best --stdout | wc -c | pretty-bytes",
|
||||
"deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js --entry ./component/index.js -i nanoassert",
|
||||
"inspect": "browserify --full-paths index -p tinyify | discify --open",
|
||||
"prepublishOnly": "npm run build",
|
||||
"start": "bankai start example",
|
||||
"test": "standard && npm run deps && npm run test:types && npm run test:node && npm run test:browser",
|
||||
"test:types": "tsd",
|
||||
"test:node": "node test/node.js | tap-format-spec",
|
||||
"test:browser": "browserify test/browser.js | tape-run | tap-format-spec"
|
||||
"test": "node --test packages/*/test/*.test.js",
|
||||
"test:e2e": "node --test test/e2e/*.test.js",
|
||||
"bench": "node bench/render.js",
|
||||
"bench:browser": "node bench/real-browser.js",
|
||||
"size": "node scripts/size.js",
|
||||
"bundle": "node scripts/bundle.js"
|
||||
},
|
||||
"repository": "choojs/choo",
|
||||
"keywords": [
|
||||
"client",
|
||||
"frontend",
|
||||
"framework",
|
||||
"minimal",
|
||||
"composable",
|
||||
"tiny"
|
||||
],
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"document-ready": "^2.0.1",
|
||||
"nanoassert": "^1.1.0",
|
||||
"nanobus": "^4.4.0",
|
||||
"nanocomponent": "^6.5.0",
|
||||
"nanohref": "^3.0.0",
|
||||
"nanohtml": "^1.1.0",
|
||||
"nanolru": "^1.0.0",
|
||||
"nanomorph": "^5.1.2",
|
||||
"nanoquery": "^1.1.0",
|
||||
"nanoraf": "^3.0.0",
|
||||
"nanorouter": "^4.0.0",
|
||||
"nanotiming": "^7.0.0",
|
||||
"scroll-to-anchor": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tap-format/spec": "^0.2.0",
|
||||
"browserify": "^16.2.2",
|
||||
"bundle-collapser": "^1.2.1",
|
||||
"dependency-check": "^3.1.0",
|
||||
"disc": "^1.3.3",
|
||||
"hyperscript": "^2.0.2",
|
||||
"pretty-bytes-cli": "^2.0.0",
|
||||
"spok": "^0.9.1",
|
||||
"standard": "^11.0.1",
|
||||
"tape": "^4.6.3",
|
||||
"tape-run": "^6.0.0",
|
||||
"tinyify": "^2.2.0",
|
||||
"tsd": "^0.11.0"
|
||||
},
|
||||
"tsd": {
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM"
|
||||
]
|
||||
}
|
||||
"happy-dom": "^20.14.0",
|
||||
"nanohtml": "^1.10.0",
|
||||
"playwright": "^1.63.0",
|
||||
"uhtml": "^5.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
// bankai <command> [entry] [options]
|
||||
//
|
||||
// bankai start <entry> dev server: Vite + HMR + streaming SSR
|
||||
// bankai build <entry> production build (client bundle, manifest,
|
||||
// service worker, precompression)
|
||||
// bankai serve production server for a built app: static
|
||||
// assets + 103 Early Hints + streaming SSR
|
||||
// bankai inspect size report for the built output
|
||||
//
|
||||
// options: --port <n> --out <dir> --title <text>
|
||||
// --prerender </,/about> (build: comma-separated routes)
|
||||
// --h2 (serve: HTTP/2 + local cert, so
|
||||
// browsers act on the Early Hints)
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
port: { type: 'string' },
|
||||
out: { type: 'string', default: 'dist' },
|
||||
title: { type: 'string', default: 'choo' },
|
||||
prerender: { type: 'string' },
|
||||
h2: { type: 'boolean', default: false }
|
||||
}
|
||||
})
|
||||
|
||||
const [command, entry] = positionals
|
||||
const port = values.port ? Number(values.port) : 8080
|
||||
|
||||
try {
|
||||
if (command === 'start') {
|
||||
requireEntry(entry)
|
||||
const { default: dev } = await import('./lib/dev.js')
|
||||
const { origin } = await dev({ entry, port, title: values.title })
|
||||
console.log(`bankai: dev server streaming on ${origin}`)
|
||||
} else if (command === 'build') {
|
||||
requireEntry(entry)
|
||||
const { default: build } = await import('./lib/build.js')
|
||||
const prerender = values.prerender ? values.prerender.split(',').map((r) => r.trim()).filter(Boolean) : []
|
||||
const { outDir, compressed, prerendered } = await build({ entry, outDir: values.out, title: values.title, prerender })
|
||||
const extra = prerendered.length ? `, ${prerendered.length} route(s) prerendered` : ''
|
||||
console.log(`bankai: built to ${outDir} (${compressed} asset(s) precompressed${extra}) — bankai serve to run it`)
|
||||
} else if (command === 'serve') {
|
||||
const { default: serve } = await import('./lib/serve.js')
|
||||
const { origin } = await serve({ outDir: values.out, port, h2: values.h2 })
|
||||
console.log(`bankai: production server on ${origin}`)
|
||||
} else if (command === 'inspect') {
|
||||
const { default: inspect } = await import('./lib/inspect.js')
|
||||
await inspect({ outDir: values.out })
|
||||
} else {
|
||||
console.log('usage: bankai <start|build|serve|inspect> [entry] [--port n] [--out dir] [--title text]')
|
||||
process.exit(command ? 1 : 0)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('bankai:', err.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function requireEntry (value) {
|
||||
if (!value) {
|
||||
console.error('bankai: an entry module is required, e.g. bankai start app.js')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as dev } from './lib/dev.js'
|
||||
export { default as build } from './lib/build.js'
|
||||
export { default as serve } from './lib/serve.js'
|
||||
export { default as inspect } from './lib/inspect.js'
|
||||
export { documentHead, documentTail, serializeState, assetLinks } from './lib/document.js'
|
||||
export { resolveApp } from './lib/app.js'
|
||||
@@ -0,0 +1,23 @@
|
||||
// The isomorphic entry contract (plan decision D5): the entry module's
|
||||
// default export is either a choo app instance (typically
|
||||
// `export default app.mount('body')` — mount() on the server records the
|
||||
// selector and returns the app) or a factory returning one (preferred:
|
||||
// fresh state per request). One module, three consumers: browser mount,
|
||||
// server render, build metadata.
|
||||
|
||||
export function resolveApp (mod, entry) {
|
||||
const value = mod && mod.default
|
||||
if (!value) {
|
||||
throw new Error(`bankai: ${entry} has no default export — export your choo app (or a function returning one)`)
|
||||
}
|
||||
// a factory is a plain function; an app instance has .mount
|
||||
const app = typeof value === 'function' && !value.mount ? value() : value
|
||||
if (typeof app.toStream !== 'function') {
|
||||
throw new Error(`bankai: the default export of ${entry} is not a choo v8 app (missing toStream)`)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
export function selectorOf (app) {
|
||||
return app.selector || 'body'
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// bankai build — the production build. Only the CLIENT is bundled: the
|
||||
// server side of a choo v8 app is plain ESM that Node runs as-authored,
|
||||
// so there is no server build to rot. Outputs:
|
||||
// dist/assets/* hashed client chunks + css
|
||||
// dist/.vite/manifest.json Vite's build manifest
|
||||
// dist/bankai.json bankai's route/asset metadata for serving
|
||||
// dist/**/index.html prerendered routes (--prerender)
|
||||
// dist/sw.js service worker (if <entry dir>/sw.js exists),
|
||||
// with the precached asset list defined in
|
||||
// *.br / *.gz precompressed siblings for every text asset
|
||||
|
||||
import { readFile, writeFile, readdir, stat, access, mkdir } from 'node:fs/promises'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { brotliCompress, gzip, constants } from 'node:zlib'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import bankaiPlugin, { findCss, CLIENT_ID } from './plugin.js'
|
||||
import { resolveApp } from './app.js'
|
||||
import { documentHead, documentTail } from './document.js'
|
||||
|
||||
const brotli = promisify(brotliCompress)
|
||||
const gzipP = promisify(gzip)
|
||||
|
||||
export default async function buildApp ({ entry, outDir = 'dist', title = 'choo', prerender = [] }) {
|
||||
const { build } = await import('vite')
|
||||
entry = resolve(entry)
|
||||
outDir = resolve(outDir)
|
||||
const css = await findCss(entry)
|
||||
|
||||
await build({
|
||||
appType: 'custom',
|
||||
plugins: [bankaiPlugin(entry, { css })],
|
||||
logLevel: 'warn',
|
||||
// real-world stylesheets (tachyons et al) carry IE-era hacks like
|
||||
// *zoom; strip them instead of refusing to build
|
||||
css: { lightningcss: { errorRecovery: true } },
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
rollupOptions: { input: { main: CLIENT_ID } }
|
||||
}
|
||||
})
|
||||
|
||||
// derive the route-independent asset set from Vite's manifest
|
||||
const manifest = JSON.parse(await readFile(join(outDir, '.vite', 'manifest.json'), 'utf8'))
|
||||
const entryChunk = Object.values(manifest).find((m) => m.isEntry)
|
||||
const modulepreload = (entryChunk.imports || []).map((key) => '/' + manifest[key].file)
|
||||
const assets = {
|
||||
scripts: ['/' + entryChunk.file],
|
||||
modulepreload,
|
||||
css: collectCss(manifest, entryChunk)
|
||||
}
|
||||
|
||||
const meta = { title, entry, assets, builtAt: new Date().toISOString() }
|
||||
await writeFile(join(outDir, 'bankai.json'), JSON.stringify(meta, null, 2))
|
||||
|
||||
// static prerender: routes rendered through the same toStream path the
|
||||
// server uses, written as <route>/index.html
|
||||
const prerendered = []
|
||||
if (prerender.length) {
|
||||
const mod = await import(pathToFileURL(entry))
|
||||
for (const route of prerender) {
|
||||
const app = resolveApp(mod, entry)
|
||||
const state = {}
|
||||
let body = ''
|
||||
const decoder = new TextDecoder()
|
||||
for await (const chunk of app.toStream(route, state)) {
|
||||
body += decoder.decode(chunk, { stream: true })
|
||||
}
|
||||
const page = documentHead({ title: state.title || title, ...assets }) + body + documentTail(state)
|
||||
const rel = route === '/' ? 'index.html' : join(route.replace(/^\//, '').replace(/\/$/, ''), 'index.html')
|
||||
const target = join(outDir, rel)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, page)
|
||||
prerendered.push(route)
|
||||
}
|
||||
}
|
||||
|
||||
// service worker: <entry dir>/sw.js, built standalone with the asset
|
||||
// list injected — the choo-service-worker convention, manifest edition
|
||||
const swSource = join(dirname(entry), 'sw.js')
|
||||
if (await exists(swSource)) {
|
||||
const precache = [
|
||||
...(prerendered.length ? prerendered : ['/']),
|
||||
...assets.scripts, ...assets.modulepreload, ...assets.css
|
||||
]
|
||||
await build({
|
||||
appType: 'custom',
|
||||
logLevel: 'warn',
|
||||
define: { __BANKAI_ASSETS__: JSON.stringify(precache) },
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: { sw: swSource },
|
||||
output: { entryFileNames: 'sw.js' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// precompress text assets so the server never compresses at request time
|
||||
const compressed = await precompress(outDir)
|
||||
|
||||
return { outDir, meta, compressed, prerendered }
|
||||
}
|
||||
|
||||
function collectCss (manifest, entryChunk) {
|
||||
const css = new Set(entryChunk.css || [])
|
||||
for (const key of entryChunk.imports || []) {
|
||||
for (const file of manifest[key].css || []) css.add(file)
|
||||
}
|
||||
return [...css].map((file) => '/' + file)
|
||||
}
|
||||
|
||||
const COMPRESSIBLE = /\.(js|mjs|css|html|json|svg|txt|map)$/
|
||||
|
||||
async function precompress (dir) {
|
||||
let count = 0
|
||||
for (const name of await readdir(dir)) {
|
||||
const path = join(dir, name)
|
||||
const info = await stat(path)
|
||||
if (info.isDirectory()) {
|
||||
count += await precompress(path)
|
||||
continue
|
||||
}
|
||||
if (!COMPRESSIBLE.test(name) || info.size < 1024) continue
|
||||
const data = await readFile(path)
|
||||
await writeFile(path + '.br', await brotli(data, {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY }
|
||||
}))
|
||||
await writeFile(path + '.gz', await gzipP(data, { level: constants.Z_BEST_COMPRESSION }))
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
async function exists (path) {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// bankai start — the dev server: Vite middleware mode for the module
|
||||
// graph and HMR, per-request streaming SSR of the same entry through
|
||||
// vite.ssrLoadModule so server code shares Vite's transforms and cache.
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import bankaiPlugin, { findCss, CLIENT_URL } from './plugin.js'
|
||||
import { resolveApp } from './app.js'
|
||||
import { documentHead, documentTail } from './document.js'
|
||||
|
||||
export default async function dev ({ entry, port = 8080, title = 'choo' }) {
|
||||
const { createServer: createViteServer } = await import('vite')
|
||||
const css = await findCss(entry) // style.css convention; restart to add it
|
||||
|
||||
const vite = await createViteServer({
|
||||
appType: 'custom',
|
||||
server: { middlewareMode: true },
|
||||
plugins: [bankaiPlugin(entry, { css })],
|
||||
css: { lightningcss: { errorRecovery: true } },
|
||||
logLevel: 'warn'
|
||||
})
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
vite.middlewares(req, res, async () => {
|
||||
// not an asset Vite knows: this is a page navigation — SSR it
|
||||
try {
|
||||
const mod = await vite.ssrLoadModule(entry)
|
||||
const app = resolveApp(mod, entry)
|
||||
const state = {}
|
||||
|
||||
// Read the first chunk before writing the head: by then stores
|
||||
// have run and the view has rendered up to its first async hole,
|
||||
// so DOMTitleChange emits have landed in state.title.
|
||||
const reader = app.toStream(req.url, state).getReader()
|
||||
const first = await reader.read()
|
||||
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.write(documentHead({
|
||||
title: state.title || title,
|
||||
scripts: ['/@vite/client', CLIENT_URL]
|
||||
}))
|
||||
if (!first.done) res.write(first.value)
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
res.write(value)
|
||||
}
|
||||
res.end(documentTail(state))
|
||||
} catch (err) {
|
||||
vite.ssrFixStacktrace(err)
|
||||
console.error(err)
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' })
|
||||
res.end('bankai dev error:\n\n' + (err.stack || err.message))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise((resolve) => server.listen(port, resolve))
|
||||
return {
|
||||
server,
|
||||
vite,
|
||||
port: server.address().port,
|
||||
origin: `http://localhost:${server.address().port}`,
|
||||
close: async () => {
|
||||
await vite.close()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// The HTML document around the app's streamed body. bankai owns the
|
||||
// <head> (charset, viewport, title, asset links) and the tail
|
||||
// (window.initialState + </html>); the app's view owns <body>.
|
||||
// Page scripts live in <head> (type=module defers itself); the one
|
||||
// exception is the initialState script, which must be inline and is
|
||||
// emitted after the body — inline scripts execute during parse, module
|
||||
// scripts only after it, so the order still holds.
|
||||
|
||||
export function documentHead ({ title, css = [], modulepreload = [], scripts = [] }) {
|
||||
let head = '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
if (title) head += `<title>${escapeHtml(title)}</title>\n`
|
||||
for (const href of css) {
|
||||
head += `<link rel="stylesheet" href="${escapeHtml(href)}">\n`
|
||||
}
|
||||
for (const href of modulepreload) {
|
||||
head += `<link rel="modulepreload" href="${escapeHtml(href)}">\n`
|
||||
}
|
||||
for (const src of scripts) {
|
||||
head += `<script type="module" src="${escapeHtml(src)}"></script>\n`
|
||||
}
|
||||
head += '</head>\n'
|
||||
return head
|
||||
}
|
||||
|
||||
export function documentTail (state) {
|
||||
return `\n<script>window.initialState=${serializeState(state)}</script>\n</html>\n`
|
||||
}
|
||||
|
||||
// choo internals that every boot recomputes \u2014 no point shipping them
|
||||
const RECOMPUTED = new Set(['events', 'cache', 'prefetch'])
|
||||
|
||||
// JSON that is safe to embed in an inline <script>: no '</script>' or
|
||||
// '<!--' breakouts, no raw line separators
|
||||
export function serializeState (state) {
|
||||
return JSON.stringify(state, function (key, value) {
|
||||
if (this === state && RECOMPUTED.has(key)) return undefined
|
||||
return value
|
||||
})
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
}
|
||||
|
||||
// Early Hints / Link header values for a route's assets
|
||||
export function assetLinks ({ css = [], modulepreload = [], scripts = [] }) {
|
||||
const links = []
|
||||
for (const href of scripts) links.push(`<${href}>; rel=modulepreload`)
|
||||
for (const href of modulepreload) links.push(`<${href}>; rel=modulepreload`)
|
||||
for (const href of css) links.push(`<${href}>; rel=preload; as=style`)
|
||||
return links
|
||||
}
|
||||
|
||||
function escapeHtml (str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// bankai inspect — what did the build produce, and how big is it on the
|
||||
// wire? Reads the manifest + dist and prints raw/gzip/brotli sizes.
|
||||
|
||||
import { readFile, readdir, stat } from 'node:fs/promises'
|
||||
import { join, resolve, relative } from 'node:path'
|
||||
|
||||
export default async function inspect ({ outDir = 'dist' }) {
|
||||
outDir = resolve(outDir)
|
||||
const meta = JSON.parse(await readFile(join(outDir, 'bankai.json'), 'utf8'))
|
||||
|
||||
const rows = []
|
||||
await walk(outDir, rows, outDir)
|
||||
rows.sort((a, b) => b.raw - a.raw)
|
||||
|
||||
console.log(`\nbankai build — ${meta.title} (${meta.builtAt})\n`)
|
||||
console.log(' ' + 'file'.padEnd(44) + 'raw'.padStart(10) + 'gzip'.padStart(10) + 'brotli'.padStart(10))
|
||||
let totals = { raw: 0, gz: 0, br: 0 }
|
||||
for (const row of rows) {
|
||||
totals.raw += row.raw
|
||||
totals.gz += row.gz || 0
|
||||
totals.br += row.br || 0
|
||||
console.log(' ' + row.file.padEnd(44) + kb(row.raw) + kb(row.gz) + kb(row.br))
|
||||
}
|
||||
console.log(' ' + 'total'.padEnd(44) + kb(totals.raw) + kb(totals.gz) + kb(totals.br) + '\n')
|
||||
return rows
|
||||
}
|
||||
|
||||
async function walk (dir, rows, root) {
|
||||
for (const name of await readdir(dir)) {
|
||||
if (name === '.vite') continue
|
||||
const path = join(dir, name)
|
||||
const info = await stat(path)
|
||||
if (info.isDirectory()) {
|
||||
await walk(path, rows, root)
|
||||
continue
|
||||
}
|
||||
if (name.endsWith('.br') || name.endsWith('.gz')) continue
|
||||
const row = { file: relative(root, path), raw: info.size, gz: null, br: null }
|
||||
row.gz = await sizeOf(path + '.gz')
|
||||
row.br = await sizeOf(path + '.br')
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
async function sizeOf (path) {
|
||||
try {
|
||||
return (await stat(path)).size
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function kb (bytes) {
|
||||
if (bytes === null) return '—'.padStart(10)
|
||||
return (bytes < 1024 ? bytes + ' B' : (bytes / 1024).toFixed(1) + ' kB').padStart(10)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Vite plugin: a virtual client entry that mounts the user's app. The
|
||||
// user writes one isomorphic module; bankai generates the two lines of
|
||||
// browser glue instead of asking for a second file.
|
||||
|
||||
export const CLIENT_ID = 'bankai:client'
|
||||
const RESOLVED_CLIENT_ID = '\0' + CLIENT_ID
|
||||
|
||||
// the URL a browser uses to import the virtual module through Vite's dev
|
||||
// middleware ('\0' encodes as '__x00__')
|
||||
export const CLIENT_URL = '/@id/__x00__' + CLIENT_ID
|
||||
|
||||
export default function bankaiPlugin (entry, { css } = {}) {
|
||||
return {
|
||||
name: 'bankai',
|
||||
resolveId (id) {
|
||||
if (id === CLIENT_ID) return RESOLVED_CLIENT_ID
|
||||
},
|
||||
load (id) {
|
||||
if (id === RESOLVED_CLIENT_ID) {
|
||||
return [
|
||||
// the style.css convention: CSS is a client concern, so the
|
||||
// client glue imports it and server code never sees it — the
|
||||
// v8 answer to what sheetify transforms did in v9
|
||||
...(css ? [`import ${JSON.stringify(css)}`] : []),
|
||||
`import create from ${JSON.stringify(entry)}`,
|
||||
'const app = typeof create === "function" && !create.mount ? create() : create',
|
||||
'app.mount(app.selector || "body")'
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <entry dir>/style.css, when it exists
|
||||
export async function findCss (entry) {
|
||||
const { access } = await import('node:fs/promises')
|
||||
const { join, dirname } = await import('node:path')
|
||||
const candidate = join(dirname(entry), 'style.css')
|
||||
try {
|
||||
await access(candidate)
|
||||
return candidate
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// bankai serve — the production server. Static assets from dist
|
||||
// (precompressed variants negotiated, hashed assets cached forever);
|
||||
// prerendered routes served as static HTML; everything else is a page:
|
||||
// 103 Early Hints from the build manifest, then streaming SSR of the
|
||||
// entry module — imported natively, because the server side never
|
||||
// needed a build. `h2: true` serves HTTP/2 with a generated local cert
|
||||
// (allowHTTP1 for tools) — browsers only act on Early Hints over h2/h3,
|
||||
// so production deployments want this or a fronting h2 proxy.
|
||||
|
||||
import http from 'node:http'
|
||||
import http2 from 'node:http2'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile, stat, mkdir } from 'node:fs/promises'
|
||||
import { join, normalize, extname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import { resolveApp } from './app.js'
|
||||
import { documentHead, documentTail, assetLinks } from './document.js'
|
||||
|
||||
const execFileP = promisify(execFile)
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.map': 'application/json',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ico': 'image/x-icon'
|
||||
}
|
||||
|
||||
export default async function serve ({ outDir = 'dist', port = 8080, h2 = false }) {
|
||||
outDir = resolve(outDir)
|
||||
const meta = JSON.parse(await readFile(join(outDir, 'bankai.json'), 'utf8'))
|
||||
const mod = await import(pathToFileURL(meta.entry))
|
||||
const links = assetLinks(meta.assets)
|
||||
|
||||
async function handler (req, res) {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
|
||||
// static asset?
|
||||
const file = normalize(join(outDir, pathname))
|
||||
if (file.startsWith(outDir) && extname(pathname)) {
|
||||
if (await isFile(file)) return sendFile(req, res, file, pathname)
|
||||
if (MIME[extname(pathname)]) {
|
||||
// a missing asset is a miss, not a page
|
||||
res.writeHead(404, { 'content-type': 'text/plain' })
|
||||
return res.end('not found: ' + pathname)
|
||||
}
|
||||
}
|
||||
|
||||
// prerendered page?
|
||||
const prerendered = normalize(join(outDir, pathname, 'index.html'))
|
||||
if (prerendered.startsWith(outDir) && await isFile(prerendered)) {
|
||||
if (links.length && res.writeEarlyHints) res.writeEarlyHints({ link: links })
|
||||
return sendFile(req, res, prerendered, pathname, { link: links.join(', ') })
|
||||
}
|
||||
|
||||
// live page: hints first, then stream
|
||||
try {
|
||||
const app = resolveApp(mod, meta.entry)
|
||||
const state = {}
|
||||
|
||||
if (links.length && res.writeEarlyHints) {
|
||||
res.writeEarlyHints({ link: links })
|
||||
}
|
||||
|
||||
// first chunk before the head: stores + the first render slice have
|
||||
// run by then, so state.title is populated
|
||||
const reader = app.toStream(req.url, state).getReader()
|
||||
const first = await reader.read()
|
||||
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
link: links.join(', ')
|
||||
})
|
||||
res.write(documentHead({ title: state.title || meta.title, ...meta.assets }))
|
||||
if (!first.done) res.write(first.value)
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
res.write(value)
|
||||
}
|
||||
res.end(documentTail(state))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' })
|
||||
res.end('bankai: render error')
|
||||
}
|
||||
}
|
||||
|
||||
let server
|
||||
if (h2) {
|
||||
const { key, cert } = await localCert(outDir)
|
||||
server = http2.createSecureServer({ key, cert, allowHTTP1: true }, handler)
|
||||
} else {
|
||||
server = http.createServer(handler)
|
||||
}
|
||||
|
||||
await new Promise((resolveListen) => server.listen(port, resolveListen))
|
||||
return {
|
||||
server,
|
||||
port: server.address().port,
|
||||
origin: `${h2 ? 'https' : 'http'}://localhost:${server.address().port}`,
|
||||
close: () => new Promise((resolveClose) => server.close(resolveClose))
|
||||
}
|
||||
}
|
||||
|
||||
// self-signed localhost cert, generated once via openssl and cached
|
||||
// alongside the build (never commit dist)
|
||||
async function localCert (outDir) {
|
||||
const dir = join(outDir, '.bankai-cert')
|
||||
const keyPath = join(dir, 'key.pem')
|
||||
const certPath = join(dir, 'cert.pem')
|
||||
if (!(await isFile(keyPath)) || !(await isFile(certPath))) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await execFileP('openssl', [
|
||||
'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '365',
|
||||
'-subj', '/CN=localhost',
|
||||
'-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1',
|
||||
'-keyout', keyPath, '-out', certPath
|
||||
])
|
||||
}
|
||||
return {
|
||||
key: await readFile(keyPath),
|
||||
cert: await readFile(certPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function isFile (path) {
|
||||
try {
|
||||
return (await stat(path)).isFile()
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFile (req, res, file, pathname, extraHeaders = {}) {
|
||||
const headers = {
|
||||
...extraHeaders,
|
||||
'content-type': MIME[extname(file)] || 'application/octet-stream',
|
||||
// hashed build assets are immutable; everything else revalidates
|
||||
'cache-control': pathname.startsWith('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
vary: 'accept-encoding'
|
||||
}
|
||||
|
||||
// negotiate precompressed siblings written at build time
|
||||
const accepted = String(req.headers['accept-encoding'] || '')
|
||||
for (const [encoding, ext] of [['br', '.br'], ['gzip', '.gz']]) {
|
||||
if (accepted.includes(encoding) && await isFile(file + ext)) {
|
||||
headers['content-encoding'] = encoding
|
||||
res.writeHead(200, headers)
|
||||
createReadStream(file + ext).pipe(res)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, headers)
|
||||
createReadStream(file).pipe(res)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@uhhm/bankai",
|
||||
"version": "8.0.0",
|
||||
"description": "The isomorphic choo compiler & server: one command, streaming SSR, zero config",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"bankai": "./cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
},
|
||||
"files": [
|
||||
"cli.js",
|
||||
"index.js",
|
||||
"lib"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"keywords": [
|
||||
"choo",
|
||||
"bankai",
|
||||
"ssr",
|
||||
"streaming",
|
||||
"compiler"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// The full production pipeline against the real counter example:
|
||||
// build → artifacts on disk → serve → 103 Early Hints, streamed SSR,
|
||||
// filtered initialState, precompressed asset negotiation.
|
||||
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import http from 'node:http'
|
||||
import { access, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import build from '../lib/build.js'
|
||||
import serve from '../lib/serve.js'
|
||||
|
||||
const repo = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const entry = join(repo, 'examples', 'counter', 'app.js')
|
||||
|
||||
let outDir, meta, prerendered, srv
|
||||
|
||||
before(async () => {
|
||||
outDir = await mkdtemp(join(tmpdir(), 'bankai-test-'))
|
||||
;({ meta, prerendered } = await build({ entry, outDir, title: 'counter', prerender: ['/'] }))
|
||||
srv = await serve({ outDir, port: 0 })
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await srv?.close()
|
||||
if (outDir) await rm(outDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function get (path, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: 'localhost', port: srv.port, path, headers }, (res) => {
|
||||
const chunks = []
|
||||
res.on('data', (d) => chunks.push(d))
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
interim: req.interim
|
||||
}))
|
||||
})
|
||||
req.interim = []
|
||||
req.on('information', (info) => req.interim.push(info))
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
test('build produced manifest, metadata and precompressed assets', async () => {
|
||||
await access(join(outDir, '.vite', 'manifest.json'))
|
||||
await access(join(outDir, 'bankai.json'))
|
||||
assert.strictEqual(meta.assets.scripts.length, 1, 'one entry chunk')
|
||||
const chunk = meta.assets.scripts[0]
|
||||
await access(join(outDir, chunk.slice(1)))
|
||||
await access(join(outDir, chunk.slice(1) + '.br'))
|
||||
await access(join(outDir, chunk.slice(1) + '.gz'))
|
||||
})
|
||||
|
||||
test('the style.css convention bundles css the server never imports', async () => {
|
||||
assert.strictEqual(meta.assets.css.length, 1, 'extracted css asset')
|
||||
const res = await get('/some/page')
|
||||
const text = res.body.toString()
|
||||
assert.match(text, /<link rel="stylesheet" href="\/assets\/.*\.css">/)
|
||||
assert.match(String(res.headers.link), /as=style/, 'stylesheet in the Link header')
|
||||
})
|
||||
|
||||
test('prerendered routes are written and served statically with hints', async () => {
|
||||
assert.deepStrictEqual(prerendered, ['/'])
|
||||
await access(join(outDir, 'index.html'))
|
||||
const res = await get('/')
|
||||
assert.strictEqual(res.status, 200)
|
||||
assert.match(res.body.toString(), /count is 0/)
|
||||
assert.strictEqual(res.interim[0]?.statusCode, 103, 'prerendered pages still send Early Hints')
|
||||
})
|
||||
|
||||
test('the SSR <title> comes from state (DOMTitleChange during render)', async () => {
|
||||
const res = await get('/some/page')
|
||||
assert.match(res.body.toString(), /<title>count is 0<\/title>/)
|
||||
})
|
||||
|
||||
test('live pages get 103 Early Hints carrying the route assets', async () => {
|
||||
const res = await get('/some/page')
|
||||
assert.strictEqual(res.interim.length, 1, 'one interim response')
|
||||
assert.strictEqual(res.interim[0].statusCode, 103)
|
||||
assert.match(String(res.interim[0].headers.link), /rel=modulepreload/)
|
||||
assert.match(String(res.headers.link), /rel=modulepreload/, 'Link header repeated on the final response')
|
||||
})
|
||||
|
||||
test('live pages stream SSR html with a filtered initialState tail', async () => {
|
||||
const res = await get('/some/page')
|
||||
const text = res.body.toString()
|
||||
assert.strictEqual(res.status, 200)
|
||||
assert.match(text, /<h1>count is 0<\/h1>/, 'server-rendered view')
|
||||
assert.match(text, /<script type="module" src="\/assets\//, 'client entry wired')
|
||||
assert.match(text, /window\.initialState=/, 'state serialized')
|
||||
assert.ok(!/initialState=.*"events"/.test(text), 'internals filtered from state')
|
||||
})
|
||||
|
||||
test('h2: HTTP/2 server sends Early Hints and streams the page', async (t) => {
|
||||
const { default: http2 } = await import('node:http2')
|
||||
let h2srv
|
||||
try {
|
||||
h2srv = await serve({ outDir, port: 0, h2: true })
|
||||
} catch (err) {
|
||||
t.skip('openssl unavailable: ' + err.message)
|
||||
return
|
||||
}
|
||||
|
||||
const session = http2.connect(`https://localhost:${h2srv.port}`, { rejectUnauthorized: false })
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const req = session.request({ ':path': '/some/page' })
|
||||
let interim = null
|
||||
let status = null
|
||||
let body = ''
|
||||
req.on('headers', (headers) => { interim = headers })
|
||||
req.on('response', (headers) => { status = headers[':status'] })
|
||||
req.on('data', (d) => { body += d })
|
||||
req.on('end', () => resolve({ interim, status, body }))
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
session.close()
|
||||
await h2srv.close()
|
||||
|
||||
assert.strictEqual(result.status, 200)
|
||||
assert.strictEqual(result.interim?.[':status'], 103, '103 interim over h2')
|
||||
assert.match(String(result.interim?.link), /rel=modulepreload/)
|
||||
assert.match(result.body, /count is 0/)
|
||||
})
|
||||
|
||||
test('hashed assets are immutable and served precompressed on request', async () => {
|
||||
const chunk = meta.assets.scripts[0]
|
||||
|
||||
const plain = await get(chunk)
|
||||
assert.strictEqual(plain.status, 200)
|
||||
assert.match(plain.headers['content-type'], /javascript/)
|
||||
assert.match(plain.headers['cache-control'], /immutable/)
|
||||
assert.strictEqual(plain.headers['content-encoding'], undefined)
|
||||
|
||||
const br = await get(chunk, { 'accept-encoding': 'br, gzip' })
|
||||
assert.strictEqual(br.headers['content-encoding'], 'br')
|
||||
assert.ok(br.body.length < plain.body.length, 'brotli variant is smaller')
|
||||
|
||||
const gz = await get(chunk, { 'accept-encoding': 'gzip' })
|
||||
assert.strictEqual(gz.headers['content-encoding'], 'gzip')
|
||||
})
|
||||
|
||||
test('the service worker builds with the precache list injected', async () => {
|
||||
const res = await get('/sw.js')
|
||||
assert.strictEqual(res.status, 200)
|
||||
const sw = res.body.toString()
|
||||
assert.ok(!sw.includes('__BANKAI_ASSETS__'), 'define was applied')
|
||||
assert.ok(sw.includes(meta.assets.scripts[0]), 'hashed entry chunk in the precache list')
|
||||
assert.match(sw, /[`"']\/[`"']\s*,/, 'root page precached (any quote style the minifier picks)')
|
||||
})
|
||||
|
||||
test('missing assets 404; extensionless paths fall through to SSR', async () => {
|
||||
const miss = await get('/assets/nope-not-real.js')
|
||||
assert.strictEqual(miss.status, 404)
|
||||
|
||||
const page = await get('/some/page')
|
||||
assert.strictEqual(page.status, 200, 'wildcard route rendered')
|
||||
assert.match(page.body.toString(), /count is 0/)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { documentHead, documentTail, serializeState, assetLinks } from '../lib/document.js'
|
||||
|
||||
test('documentHead renders title, css, preloads and module scripts', () => {
|
||||
const head = documentHead({
|
||||
title: 'a < b',
|
||||
css: ['/assets/x.css'],
|
||||
modulepreload: ['/assets/dep.js'],
|
||||
scripts: ['/assets/main.js']
|
||||
})
|
||||
assert.match(head, /<title>a < b<\/title>/)
|
||||
assert.match(head, /<link rel="stylesheet" href="\/assets\/x.css">/)
|
||||
assert.match(head, /<link rel="modulepreload" href="\/assets\/dep.js">/)
|
||||
assert.match(head, /<script type="module" src="\/assets\/main.js"><\/script>/)
|
||||
assert.match(head, /^<!doctype html>/)
|
||||
})
|
||||
|
||||
test('serializeState blocks script breakouts and drops recomputed internals', () => {
|
||||
const json = serializeState({
|
||||
events: { RENDER: 'render' },
|
||||
cache: null,
|
||||
prefetch: [],
|
||||
count: 3,
|
||||
evil: '</script><script>alert(1)</script>',
|
||||
nested: { events: 'keep me' }
|
||||
})
|
||||
assert.ok(!json.includes('</script'), 'no closing-script breakout')
|
||||
const parsed = JSON.parse(json.replace(/\\u003c/g, '<'))
|
||||
assert.strictEqual(parsed.count, 3)
|
||||
assert.strictEqual(parsed.events, undefined, 'top-level events dropped')
|
||||
assert.strictEqual(parsed.cache, undefined)
|
||||
assert.strictEqual(parsed.prefetch, undefined)
|
||||
assert.strictEqual(parsed.nested.events, 'keep me', 'only top-level internals dropped')
|
||||
})
|
||||
|
||||
test('documentTail is an inline script plus the html close', () => {
|
||||
const tail = documentTail({ count: 1 })
|
||||
assert.match(tail, /<script>window.initialState=\{"count":1\}<\/script>/)
|
||||
assert.match(tail, /<\/html>/)
|
||||
})
|
||||
|
||||
test('assetLinks builds Early Hints link values', () => {
|
||||
const links = assetLinks({
|
||||
scripts: ['/assets/main.js'],
|
||||
modulepreload: ['/assets/dep.js'],
|
||||
css: ['/assets/x.css']
|
||||
})
|
||||
assert.deepStrictEqual(links, [
|
||||
'</assets/main.js>; rel=modulepreload',
|
||||
'</assets/dep.js>; rel=modulepreload',
|
||||
'</assets/x.css>; rel=preload; as=style'
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
// Ported from nanocomponent 6.6.0 (MIT) — https://github.com/choojs/nanocomponent
|
||||
// Native DOM components: render once, morph on update, lifecycle hooks via
|
||||
// onload. In v8 this class is also the intended island/hydration boundary.
|
||||
|
||||
import morph from '@uhhm/buuh-html/morph'
|
||||
import nanotiming from '@uhhm/buuh/timing'
|
||||
import onload, { KEY_ATTR } from './onload.js'
|
||||
|
||||
function makeID () {
|
||||
return 'ncid-' + Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
|
||||
}
|
||||
|
||||
export default class Component {
|
||||
static makeID = makeID
|
||||
|
||||
constructor (name) {
|
||||
this._hasWindow = typeof window !== 'undefined'
|
||||
this._id = null // represents the id of the root node
|
||||
this._ncID = null // internal component id
|
||||
this._olID = null
|
||||
this._proxy = null
|
||||
this._loaded = false // Used to debounce on-load when child-reordering
|
||||
this._rootNodeName = null
|
||||
this._name = name || 'component'
|
||||
this._rerender = false
|
||||
|
||||
this._handleLoad = this._handleLoad.bind(this)
|
||||
this._handleUnload = this._handleUnload.bind(this)
|
||||
|
||||
this._arguments = []
|
||||
}
|
||||
|
||||
get element () {
|
||||
if (!this._hasWindow) return undefined
|
||||
const el = document.getElementById(this._id)
|
||||
if (el) return el.dataset.nanocomponent === this._ncID ? el : undefined
|
||||
}
|
||||
|
||||
render (...args) {
|
||||
const renderTiming = nanotiming(this._name + '.render')
|
||||
let el
|
||||
|
||||
if (!this._hasWindow) {
|
||||
const createTiming = nanotiming(this._name + '.create')
|
||||
el = this.createElement(...args)
|
||||
createTiming()
|
||||
renderTiming()
|
||||
return el
|
||||
} else if (this.element) {
|
||||
el = this.element // retain reference, as the ID might change on render
|
||||
const updateTiming = nanotiming(this._name + '.update')
|
||||
const shouldUpdate = this._rerender || this.update(...args)
|
||||
updateTiming()
|
||||
if (this._rerender) this._rerender = false
|
||||
if (shouldUpdate) {
|
||||
const desiredHtml = this._handleRender(args)
|
||||
const morphTiming = nanotiming(this._name + '.morph')
|
||||
morph(el, desiredHtml)
|
||||
morphTiming()
|
||||
if (this.afterupdate) this.afterupdate(el)
|
||||
}
|
||||
if (!this._proxy) { this._proxy = this._createProxy() }
|
||||
renderTiming()
|
||||
return this._proxy
|
||||
} else {
|
||||
this._reset()
|
||||
el = this._handleRender(args)
|
||||
if (this.beforerender) this.beforerender(el)
|
||||
if (this.load || this.unload || this.afterreorder) {
|
||||
onload(el, this._handleLoad, this._handleUnload, this._ncID)
|
||||
this._olID = el.getAttribute(KEY_ATTR)
|
||||
}
|
||||
renderTiming()
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
rerender () {
|
||||
if (!this.element) throw new Error('component: cant rerender on an unmounted dom node')
|
||||
this._rerender = true
|
||||
this.render(...this._arguments)
|
||||
}
|
||||
|
||||
_handleRender (args) {
|
||||
const createElementTiming = nanotiming(this._name + '.createElement')
|
||||
const el = this.createElement(...args)
|
||||
createElementTiming()
|
||||
if (!this._rootNodeName) this._rootNodeName = el.nodeName
|
||||
if (!(el instanceof window.Element)) {
|
||||
throw new Error('component: createElement should return a single DOM node')
|
||||
}
|
||||
if (this._rootNodeName !== el.nodeName) {
|
||||
throw new Error('component: root node types cannot differ between re-renders')
|
||||
}
|
||||
this._arguments = args
|
||||
return this._brandNode(this._ensureID(el))
|
||||
}
|
||||
|
||||
_createProxy () {
|
||||
const proxy = document.createElement(this._rootNodeName)
|
||||
const self = this
|
||||
this._brandNode(proxy)
|
||||
proxy.id = this._id
|
||||
proxy.setAttribute('data-proxy', '')
|
||||
proxy.isSameNode = function (el) {
|
||||
return (el && el.dataset.nanocomponent === self._ncID)
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
_reset () {
|
||||
this._ncID = makeID()
|
||||
this._olID = null
|
||||
this._id = null
|
||||
this._proxy = null
|
||||
this._rootNodeName = null
|
||||
}
|
||||
|
||||
_brandNode (node) {
|
||||
node.setAttribute('data-nanocomponent', this._ncID)
|
||||
if (this._olID) node.setAttribute(KEY_ATTR, this._olID)
|
||||
return node
|
||||
}
|
||||
|
||||
_ensureID (node) {
|
||||
if (node.id) this._id = node.id
|
||||
else node.id = this._id = this._ncID
|
||||
// Update proxy node ID if it changed
|
||||
if (this._proxy && this._proxy.id !== this._id) this._proxy.id = this._id
|
||||
return node
|
||||
}
|
||||
|
||||
_handleLoad (el) {
|
||||
if (this._loaded) {
|
||||
if (this.afterreorder) this.afterreorder(el)
|
||||
return // Debounce child-reorders
|
||||
}
|
||||
this._loaded = true
|
||||
if (this.load) this.load(el)
|
||||
}
|
||||
|
||||
_handleUnload (el) {
|
||||
if (this.element) return // Debounce child-reorders
|
||||
this._loaded = false
|
||||
if (this.unload) this.unload(el)
|
||||
}
|
||||
|
||||
createElement () {
|
||||
throw new Error('component: createElement should be implemented!')
|
||||
}
|
||||
|
||||
update () {
|
||||
throw new Error('component: update should be implemented!')
|
||||
}
|
||||
}
|
||||
|
||||
export { Component, makeID }
|
||||
@@ -0,0 +1,106 @@
|
||||
// Ported from on-load 3.4.1 (MIT) — https://github.com/shama/on-load
|
||||
// Fire load/unload callbacks when watched nodes enter or leave the DOM,
|
||||
// driven by a single document-wide MutationObserver.
|
||||
|
||||
const watch = Object.create(null)
|
||||
const KEY_ID = 'onloadid' + Math.random().toString(36).slice(2, 8)
|
||||
const KEY_ATTR = 'data-' + KEY_ID
|
||||
let INDEX = 0
|
||||
|
||||
if (typeof window !== 'undefined' && window.MutationObserver) {
|
||||
const observer = new window.MutationObserver(function (mutations) {
|
||||
if (Object.keys(watch).length < 1) return
|
||||
for (let i = 0; i < mutations.length; i++) {
|
||||
if (mutations[i].attributeName === KEY_ATTR) {
|
||||
eachAttr(mutations[i], turnon, turnoff)
|
||||
continue
|
||||
}
|
||||
eachMutation(mutations[i].removedNodes, turnoff)
|
||||
eachMutation(mutations[i].addedNodes, turnon)
|
||||
}
|
||||
})
|
||||
if (document.body) {
|
||||
beginObserve(observer)
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
beginObserve(observer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function beginObserve (observer) {
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeOldValue: true,
|
||||
attributeFilter: [KEY_ATTR]
|
||||
})
|
||||
}
|
||||
|
||||
export default function onload (el, on, off, caller) {
|
||||
if (!document.body) throw new Error('onload: will not work prior to DOMContentLoaded')
|
||||
on = on || function () {}
|
||||
off = off || function () {}
|
||||
el.setAttribute(KEY_ATTR, 'o' + INDEX)
|
||||
watch['o' + INDEX] = [on, off, 0, caller]
|
||||
INDEX += 1
|
||||
return el
|
||||
}
|
||||
|
||||
onload.KEY_ATTR = KEY_ATTR
|
||||
onload.KEY_ID = KEY_ID
|
||||
|
||||
export { KEY_ATTR, KEY_ID }
|
||||
|
||||
function turnon (index, el) {
|
||||
if (watch[index][0] && watch[index][2] === 0) {
|
||||
watch[index][0](el)
|
||||
watch[index][2] = 1
|
||||
}
|
||||
}
|
||||
|
||||
function turnoff (index, el) {
|
||||
if (watch[index][1] && watch[index][2] === 1) {
|
||||
watch[index][1](el)
|
||||
watch[index][2] = 0
|
||||
}
|
||||
}
|
||||
|
||||
function eachAttr (mutation, on, off) {
|
||||
const newValue = mutation.target.getAttribute(KEY_ATTR)
|
||||
if (sameOrigin(mutation.oldValue, newValue)) {
|
||||
watch[newValue] = watch[mutation.oldValue]
|
||||
return
|
||||
}
|
||||
if (watch[mutation.oldValue]) {
|
||||
off(mutation.oldValue, mutation.target)
|
||||
}
|
||||
if (watch[newValue]) {
|
||||
on(newValue, mutation.target)
|
||||
}
|
||||
}
|
||||
|
||||
function sameOrigin (oldValue, newValue) {
|
||||
if (!oldValue || !newValue) return false
|
||||
if (!watch[oldValue] || !watch[newValue]) return false
|
||||
return watch[oldValue][3] === watch[newValue][3]
|
||||
}
|
||||
|
||||
function eachMutation (nodes, fn) {
|
||||
const keys = Object.keys(watch)
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
if (node && node.getAttribute && node.getAttribute(KEY_ATTR)) {
|
||||
const onloadid = node.getAttribute(KEY_ATTR)
|
||||
keys.forEach(function (k) {
|
||||
if (onloadid === k) {
|
||||
fn(k, node)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (node.childNodes && node.childNodes.length > 0) {
|
||||
eachMutation(node.childNodes, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@uhhm/buuh-component",
|
||||
"version": "8.0.0",
|
||||
"description": "Native DOM components with lifecycle hooks \u2014 the choo island boundary",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./onload": "./onload.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"onload.js"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uhhm/buuh": "^8.0.0",
|
||||
"@uhhm/buuh-html": "^8.0.0"
|
||||
},
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"keywords": [
|
||||
"component",
|
||||
"dom",
|
||||
"lifecycle",
|
||||
"choo"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Component behavior in both worlds: plain string rendering without a DOM,
|
||||
// and mount/update/morph/proxy semantics inside happy-dom.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
import serverHtml from '@uhhm/buuh-html/server'
|
||||
|
||||
test('renders via createElement on the server (no window)', async () => {
|
||||
const { default: Component } = await import('../index.js')
|
||||
|
||||
class Title extends Component {
|
||||
createElement (text) {
|
||||
return serverHtml`<h1>${text}</h1>`
|
||||
}
|
||||
|
||||
update () { return false }
|
||||
}
|
||||
|
||||
const res = new Title().render('hello')
|
||||
assert.strictEqual(res.toString(), '<h1>hello</h1>')
|
||||
})
|
||||
|
||||
test('browser: render, mount, update morphs in place, proxy stands in', async () => {
|
||||
const win = new Window()
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
// module registry caches per specifier; component/index.js re-evaluates
|
||||
// _hasWindow per instance, so importing after globals are set is enough
|
||||
const { default: Component } = await import('../index.js?browser')
|
||||
const { default: html } = await import('@uhhm/buuh-html/browser')
|
||||
const { default: morph } = await import('@uhhm/buuh-html/morph')
|
||||
|
||||
class Counter extends Component {
|
||||
createElement (n) {
|
||||
this.n = n
|
||||
return html`<div>count ${n}</div>`
|
||||
}
|
||||
|
||||
update (n) {
|
||||
return n !== this.n
|
||||
}
|
||||
}
|
||||
|
||||
const counter = new Counter('counter')
|
||||
|
||||
const el = counter.render(0)
|
||||
assert.ok(el.getAttribute('data-nanocomponent'), 'root node branded')
|
||||
document.body.appendChild(el)
|
||||
assert.strictEqual(counter.element, el, 'element getter finds mounted node')
|
||||
|
||||
// unchanged args: update() false, no morph, proxy returned
|
||||
const proxy = counter.render(0)
|
||||
assert.ok(proxy.hasAttribute('data-proxy'), 'returns proxy while mounted')
|
||||
assert.ok(proxy.isSameNode(el), 'proxy claims identity of the mounted node')
|
||||
|
||||
// changed args: update() true, mounted node morphs in place
|
||||
counter.render(1)
|
||||
assert.strictEqual(el.textContent, 'count 1', 'mounted node updated in place')
|
||||
|
||||
// and the proxy plays correctly with nanomorph in a parent view
|
||||
const parent = (n) => {
|
||||
const inner = counter.render(n)
|
||||
return html`<main>${inner}</main>`
|
||||
}
|
||||
const treeA = parent(1)
|
||||
// simulate first full-page render: mounted component moves into the tree
|
||||
assert.ok(treeA.querySelector('[data-proxy]') || treeA.contains(el))
|
||||
|
||||
document.body.removeChild(el)
|
||||
})
|
||||
@@ -0,0 +1,391 @@
|
||||
// Ported from choo 7.1.0 index.js (MIT) — https://github.com/choojs/choo
|
||||
// Same API, same event flow; ESM, consolidated nano* internals in ./lib.
|
||||
|
||||
import morph from '@uhhm/buuh-html/morph'
|
||||
import hydrate from '@uhhm/buuh-html/hydrate'
|
||||
import nanotiming from './lib/timing.js'
|
||||
import Nanorouter from './lib/router.js'
|
||||
import Nanobus from './lib/bus.js'
|
||||
import nanoquery from './lib/query.js'
|
||||
import nanoraf from './lib/raf.js'
|
||||
import nanohref from './lib/href.js'
|
||||
import ComponentCache from './lib/cache.js'
|
||||
import lazy, { PENDING } from './lib/lazy.js'
|
||||
import { documentReady, scrollToAnchor } from './lib/dom.js'
|
||||
import { ok, equal, notEqual } from './lib/assert.js'
|
||||
|
||||
export { lazy }
|
||||
|
||||
const HISTORY_OBJECT = {}
|
||||
|
||||
// state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'),
|
||||
// but never throw on malformed input — keep the raw string instead.
|
||||
function safeDecode (location) {
|
||||
try {
|
||||
return decodeURI(location).normalize('NFC')
|
||||
} catch (e) {
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
export class Choo {
|
||||
constructor (opts) {
|
||||
const timing = nanotiming('choo.constructor')
|
||||
opts = opts || {}
|
||||
|
||||
equal(typeof opts, 'object', 'choo: opts should be type object')
|
||||
|
||||
const self = this
|
||||
|
||||
// define events used by choo
|
||||
this._events = {
|
||||
DOMCONTENTLOADED: 'DOMContentLoaded',
|
||||
DOMTITLECHANGE: 'DOMTitleChange',
|
||||
REPLACESTATE: 'replaceState',
|
||||
PUSHSTATE: 'pushState',
|
||||
NAVIGATE: 'navigate',
|
||||
POPSTATE: 'popState',
|
||||
RENDER: 'render'
|
||||
}
|
||||
|
||||
// properties for internal use only
|
||||
this._historyEnabled = opts.history === undefined ? true : opts.history
|
||||
this._hrefEnabled = opts.href === undefined ? true : opts.href
|
||||
this._hashEnabled = opts.hash === undefined ? false : opts.hash
|
||||
this._hasWindow = typeof window !== 'undefined'
|
||||
this._cache = opts.cache
|
||||
this._loaded = false
|
||||
this._stores = [ondomtitlechange]
|
||||
this._tree = null
|
||||
this._pendingTree = false
|
||||
|
||||
// state
|
||||
const _state = {
|
||||
events: this._events,
|
||||
components: {}
|
||||
}
|
||||
if (this._hasWindow) {
|
||||
this.state = window.initialState
|
||||
? Object.assign({}, window.initialState, _state)
|
||||
: _state
|
||||
delete window.initialState
|
||||
} else {
|
||||
this.state = _state
|
||||
}
|
||||
|
||||
// properties that are part of the API
|
||||
this.router = new Nanorouter()
|
||||
this.emitter = new Nanobus('choo.emit')
|
||||
this.emit = this.emitter.emit.bind(this.emitter)
|
||||
|
||||
// listen for title changes; available even when calling .toString()
|
||||
if (this._hasWindow) this.state.title = document.title
|
||||
function ondomtitlechange (state) {
|
||||
self.emitter.prependListener(self._events.DOMTITLECHANGE, function (title) {
|
||||
equal(typeof title, 'string', 'events.DOMTitleChange: title should be type string')
|
||||
state.title = title
|
||||
if (self._hasWindow) document.title = title
|
||||
})
|
||||
}
|
||||
timing()
|
||||
}
|
||||
|
||||
route (route, handler) {
|
||||
const routeTiming = nanotiming("choo.route('" + route + "')")
|
||||
equal(typeof route, 'string', 'choo.route: route should be type string')
|
||||
equal(typeof handler, 'function', 'choo.handler: route should be type function')
|
||||
this.router.on(route, handler)
|
||||
routeTiming()
|
||||
}
|
||||
|
||||
use (cb) {
|
||||
equal(typeof cb, 'function', 'choo.use: cb should be type function')
|
||||
const self = this
|
||||
this._stores.push(function (state) {
|
||||
let msg = 'choo.use'
|
||||
msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg
|
||||
const endTiming = nanotiming(msg)
|
||||
cb(state, self.emitter, self)
|
||||
endTiming()
|
||||
})
|
||||
}
|
||||
|
||||
start () {
|
||||
equal(typeof window, 'object', 'choo.start: window was not found. .start() must be called in a browser, use .toString() if running in Node')
|
||||
const startTiming = nanotiming('choo.start')
|
||||
|
||||
const self = this
|
||||
if (this._historyEnabled) {
|
||||
this.emitter.prependListener(this._events.NAVIGATE, function () {
|
||||
self._matchRoute(self.state)
|
||||
if (self._loaded) {
|
||||
self.emitter.emit(self._events.RENDER)
|
||||
setTimeout(scrollToAnchor.bind(null, window.location.hash), 0)
|
||||
}
|
||||
})
|
||||
|
||||
this.emitter.prependListener(this._events.POPSTATE, function () {
|
||||
self.emitter.emit(self._events.NAVIGATE)
|
||||
})
|
||||
|
||||
this.emitter.prependListener(this._events.PUSHSTATE, function (href) {
|
||||
equal(typeof href, 'string', 'events.pushState: href should be type string')
|
||||
window.history.pushState(HISTORY_OBJECT, null, href)
|
||||
self.emitter.emit(self._events.NAVIGATE)
|
||||
})
|
||||
|
||||
this.emitter.prependListener(this._events.REPLACESTATE, function (href) {
|
||||
equal(typeof href, 'string', 'events.replaceState: href should be type string')
|
||||
window.history.replaceState(HISTORY_OBJECT, null, href)
|
||||
self.emitter.emit(self._events.NAVIGATE)
|
||||
})
|
||||
|
||||
window.onpopstate = function () {
|
||||
self.emitter.emit(self._events.POPSTATE)
|
||||
}
|
||||
|
||||
if (self._hrefEnabled) {
|
||||
nanohref(function (location) {
|
||||
const href = location.href
|
||||
const hash = location.hash
|
||||
if (href === window.location.href) {
|
||||
if (!self._hashEnabled && hash) scrollToAnchor(hash)
|
||||
return
|
||||
}
|
||||
self.emitter.emit(self._events.PUSHSTATE, href)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this._setCache(this.state)
|
||||
this._matchRoute(this.state)
|
||||
this._stores.forEach(function (initStore) {
|
||||
initStore(self.state)
|
||||
})
|
||||
|
||||
let tree = this._prerender(this.state)
|
||||
ok(tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
|
||||
if (tree === PENDING) {
|
||||
// lazy route still loading and no loading view: hold the spot with
|
||||
// a placeholder; the first real render replaces it wholesale
|
||||
tree = document.createElement('div')
|
||||
tree.setAttribute('data-choo-pending', '')
|
||||
this._pendingTree = true
|
||||
}
|
||||
this._tree = tree
|
||||
|
||||
this.emitter.prependListener(self._events.RENDER, nanoraf(function () {
|
||||
const renderTiming = nanotiming('choo.render')
|
||||
const newTree = self._prerender(self.state)
|
||||
ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href)
|
||||
|
||||
if (newTree === PENDING) {
|
||||
// lazy route still loading: keep whatever is on screen
|
||||
renderTiming()
|
||||
return
|
||||
}
|
||||
|
||||
if (self._pendingTree) {
|
||||
// placeholder → first real view: replace, don't morph
|
||||
const old = self._tree
|
||||
self._tree = newTree
|
||||
if (old.parentNode) old.parentNode.replaceChild(newTree, old)
|
||||
self._pendingTree = false
|
||||
renderTiming()
|
||||
return
|
||||
}
|
||||
|
||||
equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' +
|
||||
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
|
||||
newTree.nodeName.toLowerCase() + '>.')
|
||||
|
||||
const morphTiming = nanotiming('choo.morph')
|
||||
morph(self._tree, newTree)
|
||||
morphTiming()
|
||||
|
||||
renderTiming()
|
||||
}))
|
||||
|
||||
documentReady(function () {
|
||||
self.emitter.emit(self._events.DOMCONTENTLOADED)
|
||||
self._loaded = true
|
||||
})
|
||||
|
||||
startTiming()
|
||||
return this._tree
|
||||
}
|
||||
|
||||
mount (selector) {
|
||||
const mountTiming = nanotiming("choo.mount('" + selector + "')")
|
||||
if (typeof window !== 'object') {
|
||||
ok(typeof selector === 'string', 'choo.mount: selector should be type String')
|
||||
this.selector = selector
|
||||
mountTiming()
|
||||
return this
|
||||
}
|
||||
|
||||
ok(typeof selector === 'string' || typeof selector === 'object', 'choo.mount: selector should be type String or HTMLElement')
|
||||
|
||||
const self = this
|
||||
|
||||
documentReady(function () {
|
||||
const renderTiming = nanotiming('choo.render')
|
||||
const newTree = self.start()
|
||||
if (typeof selector === 'string') {
|
||||
self._tree = document.querySelector(selector)
|
||||
} else {
|
||||
self._tree = selector
|
||||
}
|
||||
|
||||
ok(self._tree, 'choo.mount: could not query selector: ' + selector)
|
||||
equal(self._tree.nodeName, newTree.nodeName, 'choo.mount: The target node <' +
|
||||
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
|
||||
newTree.nodeName.toLowerCase() + '>.')
|
||||
|
||||
// First render adopts the existing (usually server-rendered) DOM:
|
||||
// matching nodes are left in place, and any server/client markup
|
||||
// disagreement is reported before the client render wins.
|
||||
const morphTiming = nanotiming('choo.morph')
|
||||
hydrate(self._tree, newTree, {
|
||||
onMismatch: function (diff) {
|
||||
console.warn(
|
||||
'choo.mount: server and client markup differ at ' + diff.path +
|
||||
' (' + diff.reason + '): server rendered ' + diff.server +
|
||||
', client rendered ' + diff.client +
|
||||
'. The client version wins; fix the view so both sides agree.'
|
||||
)
|
||||
}
|
||||
})
|
||||
morphTiming()
|
||||
|
||||
renderTiming()
|
||||
})
|
||||
mountTiming()
|
||||
}
|
||||
|
||||
toString (location, state) {
|
||||
state = state || {}
|
||||
state.components = state.components || {}
|
||||
state.events = Object.assign({}, state.events, this._events)
|
||||
|
||||
notEqual(typeof window, 'object', 'choo.mount: window was found. .toString() must be called in Node, use .start() or .mount() if running in the browser')
|
||||
equal(typeof location, 'string', 'choo.toString: location should be type string')
|
||||
equal(typeof state, 'object', 'choo.toString: state should be type object')
|
||||
|
||||
this._setCache(state)
|
||||
this._matchRoute(state, location)
|
||||
this.emitter.removeAllListeners()
|
||||
state.prefetch = []
|
||||
this._stores.forEach(function (initStore) {
|
||||
initStore(state)
|
||||
})
|
||||
ok(state.prefetch.length === 0, 'choo.toString: stores requested prefetch data — render this route with toStream() instead')
|
||||
|
||||
const html = this._prerender(state)
|
||||
ok(html, 'choo.toString: no valid value returned for the route ' + location)
|
||||
ok(html !== PENDING, 'choo.toString: the route ' + location + ' loads its view lazily — render it with toStream() instead')
|
||||
ok(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location)
|
||||
return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
|
||||
}
|
||||
|
||||
// Streaming server render on web-standard streams: returns a
|
||||
// ReadableStream of UTF-8 bytes. Compared to toString() it can wait —
|
||||
// for store prefetch promises (pushed into state.prefetch during store
|
||||
// init) and for lazy route views — and it flushes template output
|
||||
// progressively: everything before an async hole is sent immediately,
|
||||
// the rest follows as each promise resolves, in document order.
|
||||
// Runtime-portable by construction: pass it to `new Response(stream)`
|
||||
// on web-standard servers, or `Readable.fromWeb(stream).pipe(res)` on
|
||||
// Node's http.
|
||||
toStream (location, state) {
|
||||
state = state || {}
|
||||
state.components = state.components || {}
|
||||
state.events = Object.assign({}, state.events, this._events)
|
||||
|
||||
notEqual(typeof window, 'object', 'choo.toStream: window was found. .toStream() must be called in Node, use .start() or .mount() if running in the browser')
|
||||
equal(typeof location, 'string', 'choo.toStream: location should be type string')
|
||||
|
||||
const self = this
|
||||
this._setCache(state)
|
||||
this._matchRoute(state, location)
|
||||
this.emitter.removeAllListeners()
|
||||
state.prefetch = []
|
||||
this._stores.forEach(function (initStore) {
|
||||
initStore(state)
|
||||
})
|
||||
const prefetch = state.prefetch
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({
|
||||
async start (controller) {
|
||||
try {
|
||||
if (prefetch.length) await Promise.all(prefetch)
|
||||
if (self._handler.__lazy) await self._handler.load()
|
||||
|
||||
const html = self._prerender(state)
|
||||
ok(html, 'choo.toStream: no valid value returned for the route ' + location)
|
||||
ok(!Array.isArray(html), 'choo.toStream: return value was an array for the route ' + location)
|
||||
|
||||
if (typeof html[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of html) {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
}
|
||||
} else {
|
||||
const str = typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
|
||||
controller.enqueue(encoder.encode(str))
|
||||
}
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
controller.error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_matchRoute (state, locationOverride) {
|
||||
let location, queryString
|
||||
if (locationOverride) {
|
||||
location = locationOverride.replace(/\?.+$/, '').replace(/\/$/, '')
|
||||
if (!this._hashEnabled) location = location.replace(/#.+$/, '')
|
||||
queryString = locationOverride
|
||||
} else {
|
||||
location = window.location.pathname.replace(/\/$/, '')
|
||||
if (this._hashEnabled) location += window.location.hash.replace(/^#/, '/')
|
||||
queryString = window.location.search
|
||||
}
|
||||
const matched = this.router.match(location)
|
||||
this._handler = matched.cb
|
||||
state.href = safeDecode(location)
|
||||
state.query = nanoquery(queryString)
|
||||
state.route = matched.route
|
||||
state.params = matched.params
|
||||
}
|
||||
|
||||
_prerender (state) {
|
||||
const routeTiming = nanotiming("choo.prerender('" + state.route + "')")
|
||||
const res = this._handler(state, this.emit)
|
||||
routeTiming()
|
||||
return res
|
||||
}
|
||||
|
||||
_setCache (state) {
|
||||
const cache = new ComponentCache(state, this.emitter.emit.bind(this.emitter), this._cache)
|
||||
state.cache = renderComponent
|
||||
|
||||
function renderComponent (Component, id, ...args) {
|
||||
equal(typeof Component, 'function', 'choo.state.cache: Component should be type function')
|
||||
return cache.render(Component, id, ...args)
|
||||
}
|
||||
|
||||
// When the state gets stringified, make sure `state.cache` isn't
|
||||
// stringified too.
|
||||
renderComponent.toJSON = function () {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Callable with or without `new`, matching choo v7's `choo()` usage.
|
||||
export default function choo (opts) {
|
||||
return new Choo(opts)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Minimal assertions in the spirit of nanoassert (MIT).
|
||||
// These guard the public API in development; a production bundler can strip
|
||||
// them by replacing this module with no-ops.
|
||||
|
||||
export function ok (value, message) {
|
||||
if (!value) throw new Error(message || 'assertion failed')
|
||||
}
|
||||
|
||||
/* eslint-disable eqeqeq */
|
||||
export function equal (a, b, message) {
|
||||
if (a != b) throw new Error(message || `${a} != ${b}`)
|
||||
}
|
||||
|
||||
export function notEqual (a, b, message) {
|
||||
if (a == b) throw new Error(message || `${a} == ${b}`)
|
||||
}
|
||||
/* eslint-enable eqeqeq */
|
||||
|
||||
export default ok
|
||||
@@ -0,0 +1,161 @@
|
||||
// Ported from nanobus 4.5.0 (MIT) — https://github.com/choojs/nanobus
|
||||
// Event emitter with a '*' wildcard channel and nanotiming instrumentation.
|
||||
|
||||
import nanotiming from './timing.js'
|
||||
import { ok, equal } from './assert.js'
|
||||
|
||||
function assertEventName (eventName, method) {
|
||||
ok(
|
||||
typeof eventName === 'string' || typeof eventName === 'symbol',
|
||||
`nanobus.${method}: eventName should be type string or symbol`
|
||||
)
|
||||
}
|
||||
|
||||
export default class Nanobus {
|
||||
constructor (name) {
|
||||
this._name = name || 'nanobus'
|
||||
this._starListeners = []
|
||||
this._listeners = {}
|
||||
}
|
||||
|
||||
emit (eventName, ...data) {
|
||||
assertEventName(eventName, 'emit')
|
||||
|
||||
const emitTiming = nanotiming(this._name + "('" + eventName.toString() + "')")
|
||||
const listeners = this._listeners[eventName]
|
||||
if (listeners && listeners.length > 0) {
|
||||
this._emit(this._listeners[eventName], data)
|
||||
}
|
||||
|
||||
if (this._starListeners.length > 0) {
|
||||
this._emit(this._starListeners, eventName, data, emitTiming.uuid)
|
||||
}
|
||||
emitTiming()
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
on (eventName, listener) {
|
||||
assertEventName(eventName, 'on')
|
||||
equal(typeof listener, 'function', 'nanobus.on: listener should be type function')
|
||||
|
||||
if (eventName === '*') {
|
||||
this._starListeners.push(listener)
|
||||
} else {
|
||||
if (!this._listeners[eventName]) this._listeners[eventName] = []
|
||||
this._listeners[eventName].push(listener)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
addListener (eventName, listener) {
|
||||
return this.on(eventName, listener)
|
||||
}
|
||||
|
||||
prependListener (eventName, listener) {
|
||||
assertEventName(eventName, 'prependListener')
|
||||
equal(typeof listener, 'function', 'nanobus.prependListener: listener should be type function')
|
||||
|
||||
if (eventName === '*') {
|
||||
this._starListeners.unshift(listener)
|
||||
} else {
|
||||
if (!this._listeners[eventName]) this._listeners[eventName] = []
|
||||
this._listeners[eventName].unshift(listener)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
once (eventName, listener) {
|
||||
assertEventName(eventName, 'once')
|
||||
equal(typeof listener, 'function', 'nanobus.once: listener should be type function')
|
||||
|
||||
const self = this
|
||||
this.on(eventName, function once (...args) {
|
||||
listener.apply(self, args)
|
||||
self.removeListener(eventName, once)
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
prependOnceListener (eventName, listener) {
|
||||
assertEventName(eventName, 'prependOnceListener')
|
||||
equal(typeof listener, 'function', 'nanobus.prependOnceListener: listener should be type function')
|
||||
|
||||
const self = this
|
||||
this.prependListener(eventName, function once (...args) {
|
||||
listener.apply(self, args)
|
||||
self.removeListener(eventName, once)
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
removeListener (eventName, listener) {
|
||||
assertEventName(eventName, 'removeListener')
|
||||
equal(typeof listener, 'function', 'nanobus.removeListener: listener should be type function')
|
||||
|
||||
if (eventName === '*') {
|
||||
this._starListeners = this._starListeners.slice()
|
||||
return remove(this._starListeners, listener)
|
||||
} else {
|
||||
if (typeof this._listeners[eventName] !== 'undefined') {
|
||||
this._listeners[eventName] = this._listeners[eventName].slice()
|
||||
}
|
||||
return remove(this._listeners[eventName], listener)
|
||||
}
|
||||
|
||||
function remove (arr, listener) {
|
||||
if (!arr) return
|
||||
const index = arr.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
arr.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAllListeners (eventName) {
|
||||
if (eventName) {
|
||||
if (eventName === '*') {
|
||||
this._starListeners = []
|
||||
} else {
|
||||
this._listeners[eventName] = []
|
||||
}
|
||||
} else {
|
||||
this._starListeners = []
|
||||
this._listeners = {}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
listeners (eventName) {
|
||||
const listeners = eventName !== '*'
|
||||
? this._listeners[eventName]
|
||||
: this._starListeners
|
||||
|
||||
return listeners ? listeners.slice() : []
|
||||
}
|
||||
|
||||
_emit (arr, eventName, data, uuid) {
|
||||
if (typeof arr === 'undefined') return
|
||||
if (arr.length === 0) return
|
||||
if (data === undefined) {
|
||||
data = eventName
|
||||
eventName = null
|
||||
}
|
||||
|
||||
if (eventName) {
|
||||
if (uuid !== undefined) {
|
||||
data = [eventName].concat(data, uuid)
|
||||
} else {
|
||||
data = [eventName].concat(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Take a copy in case a listener mutates the array while we iterate.
|
||||
const listeners = arr.slice()
|
||||
for (let i = 0; i < listeners.length; i++) {
|
||||
const listener = listeners[i]
|
||||
listener.apply(listener, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Component instance cache for state.cache().
|
||||
// LRU ported from nanolru 1.0.0 (MIT) — https://github.com/s3ththompson/nanolru
|
||||
// Cache wrapper ported from choo 7.1.0 component/cache.js (MIT)
|
||||
|
||||
import { ok, equal } from './assert.js'
|
||||
|
||||
export class LRU {
|
||||
constructor (opts) {
|
||||
if (typeof opts === 'number') opts = { max: opts }
|
||||
if (!opts) opts = {}
|
||||
this.cache = {}
|
||||
this.head = this.tail = null
|
||||
this.length = 0
|
||||
this.max = opts.max || 1000
|
||||
}
|
||||
|
||||
get keys () {
|
||||
return Object.keys(this.cache)
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.cache = {}
|
||||
this.head = this.tail = null
|
||||
this.length = 0
|
||||
}
|
||||
|
||||
remove (key) {
|
||||
if (typeof key !== 'string') key = '' + key
|
||||
if (!Object.hasOwn(this.cache, key)) return
|
||||
|
||||
const element = this.cache[key]
|
||||
delete this.cache[key]
|
||||
this._unlink(key, element.prev, element.next)
|
||||
return element.value
|
||||
}
|
||||
|
||||
_unlink (key, prev, next) {
|
||||
this.length--
|
||||
|
||||
if (this.length === 0) {
|
||||
this.head = this.tail = null
|
||||
} else {
|
||||
if (this.head === key) {
|
||||
this.head = prev
|
||||
this.cache[this.head].next = null
|
||||
} else if (this.tail === key) {
|
||||
this.tail = next
|
||||
this.cache[this.tail].prev = null
|
||||
} else {
|
||||
this.cache[prev].next = next
|
||||
this.cache[next].prev = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peek (key) {
|
||||
if (!Object.hasOwn(this.cache, key)) return
|
||||
return this.cache[key].value
|
||||
}
|
||||
|
||||
set (key, value) {
|
||||
if (typeof key !== 'string') key = '' + key
|
||||
|
||||
let element
|
||||
|
||||
if (Object.hasOwn(this.cache, key)) {
|
||||
element = this.cache[key]
|
||||
element.value = value
|
||||
|
||||
// If it's already the head, there's nothing more to do:
|
||||
if (key === this.head) return value
|
||||
this._unlink(key, element.prev, element.next)
|
||||
} else {
|
||||
element = { value, next: null, prev: null }
|
||||
this.cache[key] = element
|
||||
|
||||
// Eviction is only possible if the key didn't already exist:
|
||||
if (this.length === this.max) this.evict()
|
||||
}
|
||||
|
||||
this.length++
|
||||
element.next = null
|
||||
element.prev = this.head
|
||||
|
||||
if (this.head) this.cache[this.head].next = key
|
||||
this.head = key
|
||||
|
||||
if (!this.tail) this.tail = key
|
||||
return value
|
||||
}
|
||||
|
||||
get (key) {
|
||||
if (typeof key !== 'string') key = '' + key
|
||||
if (!Object.hasOwn(this.cache, key)) return
|
||||
|
||||
const element = this.cache[key]
|
||||
|
||||
if (this.head !== key) {
|
||||
if (key === this.tail) {
|
||||
this.tail = element.next
|
||||
this.cache[this.tail].prev = null
|
||||
} else {
|
||||
// Set prev.next -> element.next:
|
||||
this.cache[element.prev].next = element.next
|
||||
}
|
||||
|
||||
// Set element.next.prev -> element.prev:
|
||||
this.cache[element.next].prev = element.prev
|
||||
|
||||
// Element is the new head
|
||||
this.cache[this.head].next = key
|
||||
element.prev = this.head
|
||||
element.next = null
|
||||
this.head = key
|
||||
}
|
||||
|
||||
return element.value
|
||||
}
|
||||
|
||||
evict () {
|
||||
if (!this.tail) return
|
||||
this.remove(this.tail)
|
||||
}
|
||||
}
|
||||
|
||||
export default class ComponentCache {
|
||||
constructor (state, emit, lru) {
|
||||
equal(typeof state, 'object', 'ComponentCache: state should be type object')
|
||||
equal(typeof emit, 'function', 'ComponentCache: emit should be type function')
|
||||
|
||||
if (typeof lru === 'number') this.cache = new LRU(lru)
|
||||
else this.cache = lru || new LRU(100)
|
||||
this.state = state
|
||||
this.emit = emit
|
||||
}
|
||||
|
||||
// Get & create component instances.
|
||||
render (Component, id, ...args) {
|
||||
equal(typeof Component, 'function', 'ComponentCache.render: Component should be type function')
|
||||
ok(typeof id === 'string' || typeof id === 'number', 'ComponentCache.render: id should be type string or type number')
|
||||
|
||||
let el = this.cache.get(id)
|
||||
if (!el) {
|
||||
el = new Component(id, this.state, this.emit, ...args)
|
||||
this.cache.set(id, el)
|
||||
}
|
||||
|
||||
return el
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Ported from document-ready 2.0.1 and scroll-to-anchor 1.0.0 (MIT)
|
||||
// https://github.com/bendrucker/document-ready — https://github.com/yoshuawuyts/scroll-to-anchor
|
||||
|
||||
import { notEqual } from './assert.js'
|
||||
|
||||
export function documentReady (callback) {
|
||||
notEqual(typeof document, 'undefined', 'documentReady only runs in the browser')
|
||||
const state = document.readyState
|
||||
if (state === 'complete' || state === 'interactive') {
|
||||
return setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function onLoad () {
|
||||
callback()
|
||||
})
|
||||
}
|
||||
|
||||
export function scrollToAnchor (anchor) {
|
||||
if (anchor) {
|
||||
try {
|
||||
const el = document.querySelector(anchor)
|
||||
if (el) el.scrollIntoView(true)
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Ported from nanohref 3.1.0 (MIT) — https://github.com/choojs/nanohref
|
||||
// Intercept same-origin anchor clicks for client-side navigation.
|
||||
|
||||
import { equal, notEqual } from './assert.js'
|
||||
|
||||
const safeExternalLink = /(noopener|noreferrer) (noopener|noreferrer)/
|
||||
const protocolLink = /^[\w-_]+:/
|
||||
|
||||
export default function href (cb, root) {
|
||||
notEqual(typeof window, 'undefined', 'nanohref: expected window to exist')
|
||||
|
||||
root = root || window.document
|
||||
|
||||
equal(typeof cb, 'function', 'nanohref: cb should be type function')
|
||||
equal(typeof root, 'object', 'nanohref: root should be type object')
|
||||
|
||||
window.addEventListener('click', function (e) {
|
||||
if ((e.button && e.button !== 0) ||
|
||||
e.ctrlKey || e.metaKey || e.altKey || e.shiftKey ||
|
||||
e.defaultPrevented) return
|
||||
|
||||
const anchor = (function traverse (node) {
|
||||
if (!node || node === root) return
|
||||
if (node.localName !== 'a' || node.href === undefined) {
|
||||
return traverse(node.parentNode)
|
||||
}
|
||||
return node
|
||||
})(e.target)
|
||||
|
||||
if (!anchor) return
|
||||
|
||||
if (window.location.protocol !== anchor.protocol ||
|
||||
window.location.hostname !== anchor.hostname ||
|
||||
window.location.port !== anchor.port ||
|
||||
anchor.hasAttribute('data-nanohref-ignore') ||
|
||||
anchor.hasAttribute('download') ||
|
||||
(anchor.getAttribute('target') === '_blank' &&
|
||||
safeExternalLink.test(anchor.getAttribute('rel'))) ||
|
||||
protocolLink.test(anchor.getAttribute('href'))) return
|
||||
|
||||
e.preventDefault()
|
||||
cb(anchor)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Lazy (async) routes — the v8 answer to choojs/choo#653.
|
||||
//
|
||||
// A lazy route handler loads its view on first match via a native dynamic
|
||||
// import (or any promise-returning loader) and caches it forever after.
|
||||
// In the browser, while the view is loading, the handler renders the
|
||||
// optional loading view — or the PENDING sentinel, which tells choo to
|
||||
// keep whatever is already on screen. When the module arrives the wrapper
|
||||
// emits a render and the real view morphs in. On the server, toStream()
|
||||
// awaits load() before rendering; toString() refuses lazy routes loudly
|
||||
// (the missing server story is what stalled #653 — here both halves ship
|
||||
// together).
|
||||
|
||||
import { equal } from './assert.js'
|
||||
|
||||
// Sentinel: "keep the current tree, a view is on its way"
|
||||
export const PENDING = { __chooPending: true }
|
||||
|
||||
export default function lazy (loader, loading) {
|
||||
equal(typeof loader, 'function', 'choo.lazy: loader should be type function')
|
||||
|
||||
let view = null
|
||||
let promise = null
|
||||
|
||||
function load () {
|
||||
if (!promise) {
|
||||
promise = Promise.resolve(loader()).then(function (mod) {
|
||||
view = (mod && mod.default) || mod
|
||||
equal(typeof view, 'function', 'choo.lazy: loader should resolve to a view function (module default export)')
|
||||
return view
|
||||
})
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
function lazyView (state, emit) {
|
||||
if (view) return view(state, emit)
|
||||
|
||||
load().then(function () {
|
||||
emit(state.events.RENDER)
|
||||
}, function (err) {
|
||||
// surface load failures instead of hanging on the loading view
|
||||
emit('error', err)
|
||||
throw err
|
||||
})
|
||||
|
||||
if (loading) return loading(state, emit)
|
||||
return PENDING
|
||||
}
|
||||
|
||||
lazyView.__lazy = true
|
||||
lazyView.load = load
|
||||
return lazyView
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Replaces nanoquery 1.3.0 (MIT) with the platform's URLSearchParams.
|
||||
// Semantics preserved: returns a plain object; repeated keys become arrays.
|
||||
|
||||
import { equal } from './assert.js'
|
||||
|
||||
export default function nanoquery (url) {
|
||||
equal(typeof url, 'string', 'nanoquery: url should be type string')
|
||||
|
||||
const query = {}
|
||||
const index = url.indexOf('?')
|
||||
if (index === -1) return query
|
||||
|
||||
const params = new URLSearchParams(url.slice(index + 1))
|
||||
for (const [key, value] of params) {
|
||||
if (Object.hasOwn(query, key)) {
|
||||
if (Array.isArray(query[key])) query[key].push(value)
|
||||
else query[key] = [query[key], value]
|
||||
} else {
|
||||
query[key] = value
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Ported from nanoraf 3.1.0 (MIT) — https://github.com/choojs/nanoraf
|
||||
// Only call requestAnimationFrame when needed.
|
||||
|
||||
import { ok, equal } from './assert.js'
|
||||
|
||||
export default function nanoraf (render, raf) {
|
||||
equal(typeof render, 'function', 'nanoraf: render should be a function')
|
||||
ok(typeof raf === 'function' || typeof raf === 'undefined', 'nanoraf: raf should be a function or undefined')
|
||||
|
||||
// Wrap rather than alias: calling an extracted requestAnimationFrame
|
||||
// with no receiver throws 'Illegal invocation' in strict-mode ESM
|
||||
// (the CJS original survived only thanks to sloppy-mode this-patching).
|
||||
if (!raf) raf = (cb) => globalThis.requestAnimationFrame(cb)
|
||||
let redrawScheduled = false
|
||||
let args = null
|
||||
|
||||
return function frame (...frameArgs) {
|
||||
if (args === null && !redrawScheduled) {
|
||||
redrawScheduled = true
|
||||
|
||||
raf(function redraw () {
|
||||
redrawScheduled = false
|
||||
const _args = args
|
||||
args = null
|
||||
render(..._args)
|
||||
})
|
||||
}
|
||||
|
||||
args = frameArgs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Ported from nanorouter 4.0.0 and wayfarer 7.0.1 with its trie (MIT)
|
||||
// https://github.com/choojs/nanorouter — https://github.com/yoshuawuyts/wayfarer
|
||||
// Consolidated into a single module: trie-based router with :params and
|
||||
// * wildcards, plus URL normalization on top.
|
||||
//
|
||||
// Normalization differs deliberately from v7: locations are parsed with the
|
||||
// WHATWG URL parser and each path segment is percent-decoded exactly once,
|
||||
// falling back to the raw segment instead of throwing on malformed input
|
||||
// (v7's decodeURI crashed on a literal '%'). Both route definitions and
|
||||
// incoming segments are NFC-normalized so 'café' matches 'café' regardless
|
||||
// of how the é was composed.
|
||||
|
||||
import { ok, equal, notEqual } from './assert.js'
|
||||
|
||||
function has (object, property) {
|
||||
return Object.prototype.hasOwnProperty.call(object, property)
|
||||
}
|
||||
|
||||
// Decode a path segment exactly once; keep the raw segment when it isn't
|
||||
// valid percent-encoding. Always NFC-normalize.
|
||||
export function decodeSegment (segment) {
|
||||
try {
|
||||
return decodeURIComponent(segment).normalize('NFC')
|
||||
} catch (e) {
|
||||
return segment.normalize('NFC')
|
||||
}
|
||||
}
|
||||
|
||||
class Trie {
|
||||
constructor () {
|
||||
this.trie = { nodes: {} }
|
||||
}
|
||||
|
||||
// create a node on the trie at route and return it
|
||||
create (route) {
|
||||
equal(typeof route, 'string', 'route should be a string')
|
||||
// strip leading '/' and split routes
|
||||
const routes = route.replace(/^\//, '').split('/')
|
||||
const self = this
|
||||
|
||||
function createNode (index, trie) {
|
||||
const thisRoute = has(routes, index) && routes[index]
|
||||
if (thisRoute === false) return trie
|
||||
|
||||
let node = null
|
||||
if (/^:|^\*/.test(thisRoute)) {
|
||||
// if node is a name match, set name and append to ':' node
|
||||
if (!has(trie.nodes, '$$')) {
|
||||
node = { nodes: {} }
|
||||
trie.nodes.$$ = node
|
||||
} else {
|
||||
node = trie.nodes.$$
|
||||
}
|
||||
|
||||
if (thisRoute[0] === '*') {
|
||||
trie.wildcard = true
|
||||
}
|
||||
|
||||
trie.name = thisRoute.replace(/^:|^\*/, '')
|
||||
} else {
|
||||
// Literal segment: store it decoded + NFC-normalized so route
|
||||
// definitions and incoming URLs compare in one canonical form.
|
||||
const key = decodeSegment(thisRoute)
|
||||
if (!has(trie.nodes, key)) {
|
||||
node = { nodes: {} }
|
||||
trie.nodes[key] = node
|
||||
} else {
|
||||
node = trie.nodes[key]
|
||||
}
|
||||
}
|
||||
|
||||
return createNode(index + 1, node)
|
||||
}
|
||||
|
||||
return createNode(0, self.trie)
|
||||
}
|
||||
|
||||
// match a route on the trie and return the node
|
||||
match (route) {
|
||||
equal(typeof route, 'string', 'route should be a string')
|
||||
|
||||
const routes = route.replace(/^\//, '').split('/')
|
||||
const params = {}
|
||||
|
||||
function search (index, trie) {
|
||||
// either there's no match, or we're done searching
|
||||
if (trie === undefined) return undefined
|
||||
if (routes[index] === undefined) return trie
|
||||
|
||||
// Segments arrive percent-encoded from the URL parser; decode each
|
||||
// exactly once (create() stored literal keys in the same form).
|
||||
const thisRoute = decodeSegment(routes[index])
|
||||
|
||||
if (has(trie.nodes, thisRoute)) {
|
||||
// match regular routes first
|
||||
return search(index + 1, trie.nodes[thisRoute])
|
||||
} else if (trie.name) {
|
||||
// match named routes
|
||||
params[trie.name] = thisRoute
|
||||
return search(index + 1, trie.nodes.$$)
|
||||
} else if (trie.wildcard) {
|
||||
// match wildcards; decode per segment so an encoded '/' can't
|
||||
// change the segment structure mid-decode
|
||||
params.wildcard = routes.slice(index).map(decodeSegment).join('/')
|
||||
// return early, or else search may keep recursing through the wildcard
|
||||
return trie.nodes.$$
|
||||
} else {
|
||||
// no matches found
|
||||
return search(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
let node = search(0, this.trie)
|
||||
|
||||
if (!node) return undefined
|
||||
node = Object.assign({}, node)
|
||||
node.params = params
|
||||
return node
|
||||
}
|
||||
|
||||
// mount a trie onto a node at route
|
||||
mount (route, trie) {
|
||||
equal(typeof route, 'string', 'route should be a string')
|
||||
equal(typeof trie, 'object', 'trie should be a object')
|
||||
|
||||
const split = route.replace(/^\//, '').split('/')
|
||||
let node = null
|
||||
|
||||
if (split.length === 1) {
|
||||
node = this.create(split[0])
|
||||
} else {
|
||||
node = this.create(split.join('/'))
|
||||
}
|
||||
|
||||
Object.assign(node.nodes, trie.nodes)
|
||||
if (trie.name) node.name = trie.name
|
||||
|
||||
// delegate properties from '/' to the new node
|
||||
// '/' cannot be reached once mounted
|
||||
if (node.nodes['']) {
|
||||
Object.keys(node.nodes['']).forEach(function (key) {
|
||||
if (key === 'nodes') return
|
||||
node[key] = node.nodes[''][key]
|
||||
})
|
||||
Object.assign(node.nodes, node.nodes[''].nodes)
|
||||
delete node.nodes[''].nodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wayfarer (dft) {
|
||||
const _default = (dft || '').replace(/^\//, '')
|
||||
const _trie = new Trie()
|
||||
|
||||
emit._trie = _trie
|
||||
emit.on = on
|
||||
emit.emit = emit
|
||||
emit.match = match
|
||||
emit._wayfarer = true
|
||||
|
||||
return emit
|
||||
|
||||
function on (route, cb) {
|
||||
equal(typeof route, 'string')
|
||||
equal(typeof cb, 'function')
|
||||
|
||||
route = route || '/'
|
||||
|
||||
if (cb._wayfarer && cb._trie) {
|
||||
_trie.mount(route, cb._trie.trie)
|
||||
} else {
|
||||
const node = _trie.create(route)
|
||||
node.cb = cb
|
||||
node.route = route
|
||||
}
|
||||
|
||||
return emit
|
||||
}
|
||||
|
||||
function emit (route, ...args) {
|
||||
const matched = match(route)
|
||||
return matched.cb(matched.params, ...args)
|
||||
}
|
||||
|
||||
function match (route) {
|
||||
notEqual(route, undefined, "'route' must be defined")
|
||||
|
||||
const matched = _trie.match(route)
|
||||
if (matched && matched.cb) return new Route(matched)
|
||||
|
||||
const dft = _trie.match(_default)
|
||||
if (dft && dft.cb) return new Route(dft)
|
||||
|
||||
throw new Error("route '" + route + "' did not match")
|
||||
}
|
||||
|
||||
function Route (matched) {
|
||||
this.cb = matched.cb
|
||||
this.route = matched.route
|
||||
this.params = matched.params
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce a location (path, full URL, or electron file:// URL) to a
|
||||
// matchable path: pathname plus any hash segments rewritten to slashes,
|
||||
// query dropped. Percent-decoding is NOT done here — the trie decodes per
|
||||
// segment — so a malformed sequence can never throw during routing.
|
||||
function pathname (routename) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(routename, 'http://localhost')
|
||||
} catch (e) {
|
||||
return routename
|
||||
}
|
||||
|
||||
let path = url.pathname
|
||||
// electron support: file:///path/to/index.html routes as '/'
|
||||
if (url.protocol === 'file:') {
|
||||
path = path.replace(/\/[^/]*\.html?$/, '') || '/'
|
||||
}
|
||||
|
||||
const hash = url.hash.replace(/^#/, '/').replaceAll('#', '/')
|
||||
return path + hash
|
||||
}
|
||||
|
||||
export default class Nanorouter {
|
||||
constructor (opts) {
|
||||
opts = opts || {}
|
||||
this.router = wayfarer(opts.default || '/404')
|
||||
}
|
||||
|
||||
on (routename, listener) {
|
||||
equal(typeof routename, 'string')
|
||||
routename = routename.replace(/^[#/]/, '')
|
||||
this.router.on(routename, listener)
|
||||
}
|
||||
|
||||
emit (routename) {
|
||||
equal(typeof routename, 'string')
|
||||
routename = pathname(routename)
|
||||
return this.router.emit(routename)
|
||||
}
|
||||
|
||||
match (routename) {
|
||||
equal(typeof routename, 'string')
|
||||
routename = pathname(routename)
|
||||
return this.router.match(routename)
|
||||
}
|
||||
}
|
||||
|
||||
export { Trie, wayfarer, ok }
|
||||
@@ -0,0 +1,56 @@
|
||||
// Ported from nanotiming 7.3.1 (MIT) — https://github.com/choojs/nanotiming
|
||||
// Unified browser/Node implementation on the global `performance` object.
|
||||
|
||||
import { equal } from './assert.js'
|
||||
|
||||
const perf = typeof performance !== 'undefined' && typeof performance.mark === 'function'
|
||||
? performance
|
||||
: null
|
||||
|
||||
function checkDisabled () {
|
||||
if (!perf) return true
|
||||
if (typeof process !== 'undefined' && process.env && process.env.DISABLE_NANOTIMING) return true
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
return window.localStorage.DISABLE_NANOTIMING === 'true'
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
nanotiming.disabled = checkDisabled()
|
||||
|
||||
export default function nanotiming (name) {
|
||||
equal(typeof name, 'string', 'nanotiming: name should be type string')
|
||||
|
||||
if (nanotiming.disabled) return noop
|
||||
|
||||
const uuid = (perf.now() * 10000).toFixed() % Number.MAX_SAFE_INTEGER
|
||||
const startName = 'start-' + uuid + '-' + name
|
||||
perf.mark(startName)
|
||||
|
||||
function end (cb) {
|
||||
const endName = 'end-' + uuid + '-' + name
|
||||
perf.mark(endName)
|
||||
|
||||
let err = null
|
||||
try {
|
||||
const measureName = name + ' [' + uuid + ']'
|
||||
perf.measure(measureName, startName, endName)
|
||||
perf.clearMarks(startName)
|
||||
perf.clearMarks(endName)
|
||||
} catch (e) { err = e }
|
||||
if (cb) cb(err, name)
|
||||
}
|
||||
|
||||
end.uuid = uuid
|
||||
return end
|
||||
}
|
||||
|
||||
function noop (cb) {
|
||||
if (cb) {
|
||||
cb(new Error('nanotiming: performance API unavailable or disabled'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uhhm/buuh",
|
||||
"version": "8.0.0",
|
||||
"description": "A 4kb framework for creating sturdy frontend applications",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./timing": "./lib/timing.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"lib"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uhhm/buuh-html": "^8.0.0"
|
||||
},
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"keywords": [
|
||||
"client",
|
||||
"frontend",
|
||||
"framework",
|
||||
"minimal",
|
||||
"composable",
|
||||
"tiny"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// End-to-end in happy-dom: the counter app running the real browser path —
|
||||
// start(), store, emit, nanoraf-batched re-render, nanomorph in place.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let choo, html
|
||||
|
||||
before(async () => {
|
||||
const win = new Window({ url: 'http://localhost/' })
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
|
||||
;({ default: choo } = await import('@uhhm/buuh'))
|
||||
;({ default: html } = await import('@uhhm/buuh-html/browser'))
|
||||
})
|
||||
|
||||
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
test('a full app renders, emits, and morphs in the browser', async () => {
|
||||
const app = choo()
|
||||
|
||||
app.use((state, emitter) => {
|
||||
state.count = 0
|
||||
emitter.on('increment', (n) => {
|
||||
state.count += n
|
||||
emitter.emit('render')
|
||||
})
|
||||
})
|
||||
|
||||
app.route('/', (state, emit) => {
|
||||
return html`
|
||||
<body>
|
||||
<h1>count is ${state.count}</h1>
|
||||
<button onclick=${() => emit('increment', 1)}>Increment</button>
|
||||
</body>
|
||||
`
|
||||
})
|
||||
|
||||
const tree = app.start()
|
||||
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 0')
|
||||
|
||||
// state event → render event → raf-batched morph of the same tree
|
||||
app.emit('increment', 1)
|
||||
await tick()
|
||||
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 1', 'emit re-rendered in place')
|
||||
|
||||
// and through an actual DOM click on the interpolated handler
|
||||
tree.querySelector('button').click()
|
||||
await tick()
|
||||
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 2', 'click handler emitted')
|
||||
|
||||
// raf batching: two synchronous emits, one render
|
||||
app.emit('increment', 1)
|
||||
app.emit('increment', 1)
|
||||
await tick()
|
||||
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 4', 'batched emits coalesced')
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
// Ported from choo 7.1.0 test/node.js (MIT), tape → node:test.
|
||||
// The hyperscript case now uses a plain object with outerHTML, which is the
|
||||
// contract toString actually relies on.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import html from '@uhhm/buuh-html'
|
||||
import raw from '@uhhm/buuh-html/raw'
|
||||
import choo, { Choo } from '@uhhm/buuh'
|
||||
|
||||
test('should render on the server with @uhhm/buuh-html', () => {
|
||||
const app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
const strong = '<strong>Hello filthy planet</strong>'
|
||||
return html`
|
||||
<p>${raw(strong)}</p>
|
||||
`
|
||||
})
|
||||
const res = app.toString('/')
|
||||
const exp = '<p><strong>Hello filthy planet</strong></p>'
|
||||
assert.strictEqual(res.toString().trim(), exp, 'result was OK')
|
||||
})
|
||||
|
||||
test('should render on the server with any view returning outerHTML', () => {
|
||||
const app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
return { outerHTML: '<p><strong>Hello filthy planet</strong></p>', nodeName: 'P' }
|
||||
})
|
||||
const res = app.toString('/')
|
||||
const exp = '<p><strong>Hello filthy planet</strong></p>'
|
||||
assert.strictEqual(res.toString().trim(), exp, 'result was OK')
|
||||
})
|
||||
|
||||
test('should expose a public API', () => {
|
||||
const app = choo()
|
||||
|
||||
assert.strictEqual(typeof app.route, 'function', 'app.route prototype method exists')
|
||||
assert.strictEqual(typeof app.toString, 'function', 'app.toString prototype method exists')
|
||||
assert.strictEqual(typeof app.start, 'function', 'app.start prototype method exists')
|
||||
assert.strictEqual(typeof app.mount, 'function', 'app.mount prototype method exists')
|
||||
assert.strictEqual(typeof app.emitter, 'object', 'app.emitter prototype method exists')
|
||||
|
||||
assert.strictEqual(typeof app.emit, 'function', 'app.emit instance method exists')
|
||||
assert.strictEqual(typeof app.router, 'object', 'app.router instance object exists')
|
||||
assert.strictEqual(typeof app.state, 'object', 'app.state instance object exists')
|
||||
})
|
||||
|
||||
test('the default export works with and without new, and instances are Choo', () => {
|
||||
assert.ok(choo() instanceof Choo, 'choo() returns a Choo instance')
|
||||
assert.ok(new Choo() instanceof Choo, 'new Choo() works')
|
||||
})
|
||||
|
||||
test('should enable history and href by default', () => {
|
||||
const app = choo()
|
||||
assert.ok(app._historyEnabled, 'history enabled')
|
||||
assert.ok(app._hrefEnabled, 'href enabled')
|
||||
})
|
||||
|
||||
test('router should pass state and emit to view', () => {
|
||||
let calls = 0
|
||||
const app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
assert.strictEqual(typeof state, 'object', 'state is an object')
|
||||
assert.strictEqual(typeof emit, 'function', 'emit is a function')
|
||||
calls++
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/')
|
||||
assert.strictEqual(calls, 1, 'view was rendered')
|
||||
})
|
||||
|
||||
test('router should support a default route', () => {
|
||||
let calls = 0
|
||||
const app = choo()
|
||||
app.route('*', function (state, emit) {
|
||||
calls++
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/random')
|
||||
assert.strictEqual(calls, 1, 'default route was rendered')
|
||||
})
|
||||
|
||||
test('enabling hash routing should treat hashes as slashes', () => {
|
||||
let calls = 0
|
||||
const app = choo({ hash: true })
|
||||
app.route('/account/security', function (state, emit) {
|
||||
calls++
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/account#security')
|
||||
assert.strictEqual(calls, 1, 'hash route was rendered')
|
||||
})
|
||||
|
||||
test('router should ignore hashes by default', () => {
|
||||
let calls = 0
|
||||
const app = choo()
|
||||
app.route('/account', function (state, emit) {
|
||||
calls++
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/account#security')
|
||||
assert.strictEqual(calls, 1, 'route was rendered')
|
||||
})
|
||||
|
||||
test('cache should default to 100 instances', () => {
|
||||
let pruned = 0
|
||||
const app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
let i
|
||||
for (i = 0; i <= 100; i++) state.cache(Component, i)
|
||||
state.cache(Component, 0)
|
||||
return html`<div></div>`
|
||||
|
||||
function Component (id) {
|
||||
if (id < i) pruned++
|
||||
}
|
||||
})
|
||||
app.toString('/')
|
||||
assert.strictEqual(pruned, 1, 'oldest instance was pruned when exceeding 100')
|
||||
})
|
||||
|
||||
test('cache option should override number of max instances', () => {
|
||||
let pruned = 0
|
||||
const app = choo({ cache: 1 })
|
||||
app.route('/', function (state, emit) {
|
||||
let instances = 0
|
||||
state.cache(Component, instances)
|
||||
state.cache(Component, instances)
|
||||
state.cache(Component, 0)
|
||||
return html`<div></div>`
|
||||
|
||||
function Component (id) {
|
||||
if (id < instances) pruned++
|
||||
instances++
|
||||
}
|
||||
})
|
||||
app.toString('/')
|
||||
assert.strictEqual(pruned, 1, 'oldest instance was pruned when exceeding 1')
|
||||
})
|
||||
|
||||
test('cache option should override default LRU cache', () => {
|
||||
let gets = 0
|
||||
let sets = 0
|
||||
const cache = {
|
||||
get (id) { gets++ },
|
||||
set (id, el) { sets++ }
|
||||
}
|
||||
const app = choo({ cache })
|
||||
app.route('/', function (state, emit) {
|
||||
state.cache(Component, 'foo')
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/')
|
||||
assert.strictEqual(gets, 1, 'called get')
|
||||
assert.strictEqual(sets, 1, 'called set')
|
||||
|
||||
function Component () {}
|
||||
})
|
||||
|
||||
// built-in state
|
||||
|
||||
test('state should include events', () => {
|
||||
let checked = false
|
||||
const app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
assert.ok(Object.hasOwn(state, 'events'), 'state has events property')
|
||||
assert.ok(Object.keys(state.events).length > 0, 'events object has keys')
|
||||
checked = true
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/')
|
||||
assert.ok(checked)
|
||||
})
|
||||
|
||||
test('state should include location on render', () => {
|
||||
let checked = false
|
||||
const app = choo()
|
||||
app.route('/:first/:second/*', function (state, emit) {
|
||||
const params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
|
||||
assert.strictEqual(state.href, '/foo/bar/file.txt', 'state has href')
|
||||
assert.strictEqual(state.route, ':first/:second/*', 'state has route')
|
||||
assert.ok(Object.hasOwn(state, 'params'), 'state has params')
|
||||
assert.deepStrictEqual(state.params, params, 'params match')
|
||||
assert.ok(Object.hasOwn(state, 'query'), 'state has query')
|
||||
assert.deepStrictEqual(state.query, { bin: 'baz' }, 'query match')
|
||||
checked = true
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/foo/bar/file.txt?bin=baz')
|
||||
assert.ok(checked)
|
||||
})
|
||||
|
||||
test('state should include location on store init', () => {
|
||||
let checked = false
|
||||
const app = choo()
|
||||
app.use(store)
|
||||
app.route('/:first/:second/*', function (state, emit) {
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/foo/bar/file.txt?bin=baz')
|
||||
assert.ok(checked)
|
||||
|
||||
function store (state, emit) {
|
||||
const params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
|
||||
assert.strictEqual(state.href, '/foo/bar/file.txt', 'state has href')
|
||||
assert.strictEqual(state.route, ':first/:second/*', 'state has route')
|
||||
assert.ok(Object.hasOwn(state, 'params'), 'state has params')
|
||||
assert.deepStrictEqual(state.params, params, 'params match')
|
||||
assert.ok(Object.hasOwn(state, 'query'), 'state has query')
|
||||
assert.deepStrictEqual(state.query, { bin: 'baz' }, 'query match')
|
||||
checked = true
|
||||
}
|
||||
})
|
||||
|
||||
test('state should include cache', () => {
|
||||
let constructed = 0
|
||||
const app = choo()
|
||||
app.route('/', function (state, emit) {
|
||||
assert.strictEqual(typeof state.cache, 'function', 'state has cache method')
|
||||
const cached = state.cache(Component, 'foo', 'arg')
|
||||
assert.strictEqual(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance')
|
||||
return html`<div></div>`
|
||||
})
|
||||
app.toString('/')
|
||||
assert.strictEqual(constructed, 1, 'component constructed once')
|
||||
|
||||
function Component (id, state, emit, arg) {
|
||||
assert.strictEqual(id, 'foo', 'id was prefixed to constructor args')
|
||||
assert.strictEqual(typeof state, 'object', 'state was prefixed to constructor args')
|
||||
assert.strictEqual(typeof emit, 'function', 'emit was prefixed to constructor args')
|
||||
assert.strictEqual(arg, 'arg', 'constructor args were forwarded')
|
||||
constructed++
|
||||
}
|
||||
})
|
||||
|
||||
test('state should not mutate on toString', () => {
|
||||
const app = choo()
|
||||
app.use(store)
|
||||
|
||||
const routes = ['foo', 'bar']
|
||||
const states = routes.map(function (route) {
|
||||
const state = {}
|
||||
app.route(`/${route}`, view)
|
||||
app.toString(`/${route}`, state)
|
||||
return state
|
||||
})
|
||||
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
assert.strictEqual(states[i].test, routes[i], 'store was used')
|
||||
assert.strictEqual(states[i].title, routes[i], 'title was added to state')
|
||||
}
|
||||
|
||||
function store (state, emitter) {
|
||||
state.test = null
|
||||
emitter.on('test', function (str) {
|
||||
assert.strictEqual(state.test, null, 'state has been reset')
|
||||
state.test = str
|
||||
})
|
||||
}
|
||||
|
||||
function view (state, emit) {
|
||||
emit('test', state.route)
|
||||
emit(state.events.DOMTITLECHANGE, state.route)
|
||||
return html`<body>Hello ${state.route}</body>`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
// The isomorphic handshake: server-render a view to a string, parse it
|
||||
// into the document the way a browser would, then mount() the same app —
|
||||
// the server DOM must be adopted (same element references), handlers must
|
||||
// come alive, and a tampered server render must produce a console warning.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let choo, browserHtml, serverString
|
||||
|
||||
before(async () => {
|
||||
// Server side first, before any window exists — exactly like production,
|
||||
// and toString() enforces it.
|
||||
;({ default: choo } = await import('@uhhm/buuh'))
|
||||
const { default: serverHtml } = await import('@uhhm/buuh-html/server')
|
||||
serverString = makeApp(serverHtml).toString('/')
|
||||
|
||||
const win = new Window({ url: 'http://localhost/' })
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
|
||||
;({ default: browserHtml } = await import('@uhhm/buuh-html/browser'))
|
||||
})
|
||||
|
||||
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// One view definition, parameterized by renderer — mirrors how the import
|
||||
// map serves browser.js to the browser and server.js to Node.
|
||||
function makeApp (html) {
|
||||
const app = choo()
|
||||
app.use((state, emitter) => {
|
||||
state.count = state.count || 0
|
||||
emitter.on('increment', (n) => {
|
||||
state.count += n
|
||||
emitter.emit('render')
|
||||
})
|
||||
})
|
||||
app.route('/', (state, emit) => html`
|
||||
<div class="app">
|
||||
<h1>count is ${state.count}</h1>
|
||||
<button onclick=${() => emit('increment', 1)}>Increment</button>
|
||||
</div>
|
||||
`)
|
||||
return app
|
||||
}
|
||||
|
||||
test('mount adopts server-rendered DOM and brings it alive', async () => {
|
||||
document.body.innerHTML = serverString
|
||||
const serverEl = document.querySelector('.app')
|
||||
const serverH1 = serverEl.querySelector('h1')
|
||||
|
||||
const warnings = []
|
||||
const warn = console.warn
|
||||
console.warn = (msg) => warnings.push(msg)
|
||||
|
||||
const app = makeApp(browserHtml)
|
||||
app.mount('.app')
|
||||
await tick()
|
||||
|
||||
console.warn = warn
|
||||
|
||||
assert.strictEqual(document.querySelector('.app'), serverEl, 'server root adopted, not replaced')
|
||||
assert.strictEqual(serverEl.querySelector('h1'), serverH1, 'server child adopted, not replaced')
|
||||
assert.deepStrictEqual(warnings, [], 'identical markup produced no mismatch warnings')
|
||||
|
||||
serverEl.querySelector('button').click()
|
||||
await tick()
|
||||
assert.strictEqual(serverH1.textContent, 'count is 1', 'adopted DOM is live')
|
||||
})
|
||||
|
||||
test('a tampered server render produces one mismatch warning, client wins', async () => {
|
||||
document.body.innerHTML = serverString.replace('count is 0', 'count is 999')
|
||||
|
||||
const warnings = []
|
||||
const warn = console.warn
|
||||
console.warn = (msg) => warnings.push(msg)
|
||||
|
||||
const app = makeApp(browserHtml)
|
||||
app.mount('.app')
|
||||
await tick()
|
||||
|
||||
console.warn = warn
|
||||
|
||||
assert.strictEqual(warnings.length, 1, 'exactly one warning')
|
||||
assert.match(warnings[0], /server and client markup differ/)
|
||||
assert.match(warnings[0], /count is 999/)
|
||||
assert.strictEqual(
|
||||
document.querySelector('h1').textContent,
|
||||
'count is 0',
|
||||
'client render won'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// Lazy routes in the browser (happy-dom): loading view while the module
|
||||
// is in flight, morph to the real view on arrival, PENDING keeps the
|
||||
// current tree during route changes, view cached after first load.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let choo, lazy, html
|
||||
|
||||
before(async () => {
|
||||
const win = new Window({ url: 'http://localhost/' })
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
|
||||
;({ default: choo, lazy } = await import('@uhhm/buuh'))
|
||||
;({ default: html } = await import('@uhhm/buuh-html/browser'))
|
||||
})
|
||||
|
||||
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
test('lazy route with a loading view: loading first, real view after', async () => {
|
||||
let resolveLoader
|
||||
const loaderDone = new Promise((resolve) => { resolveLoader = resolve })
|
||||
|
||||
const app = choo()
|
||||
app.route('/', lazy(
|
||||
() => loaderDone,
|
||||
() => html`<div><p>loading…</p></div>`
|
||||
))
|
||||
|
||||
const tree = app.start()
|
||||
assert.strictEqual(tree.textContent, 'loading…', 'loading view rendered while in flight')
|
||||
|
||||
resolveLoader({ default: (state) => html`<div><p>arrived</p></div>` })
|
||||
await tick()
|
||||
assert.strictEqual(tree.textContent, 'arrived', 'real view morphed in over the loading view')
|
||||
})
|
||||
|
||||
test('lazy route without a loading view: placeholder, then wholesale replace', async () => {
|
||||
let resolveLoader
|
||||
const loaderDone = new Promise((resolve) => { resolveLoader = resolve })
|
||||
|
||||
const app = choo()
|
||||
app.route('/', lazy(() => loaderDone))
|
||||
|
||||
const tree = app.start()
|
||||
assert.ok(tree.hasAttribute('data-choo-pending'), 'placeholder holds the spot')
|
||||
document.body.appendChild(tree)
|
||||
|
||||
resolveLoader({ default: (state) => html`<main><h1>real</h1></main>` })
|
||||
await tick()
|
||||
|
||||
assert.strictEqual(document.querySelector('main h1').textContent, 'real', 'real view replaced the placeholder in the DOM')
|
||||
assert.strictEqual(document.querySelector('[data-choo-pending]'), null, 'placeholder gone')
|
||||
document.querySelector('main').remove()
|
||||
})
|
||||
|
||||
test('loaded lazy views render synchronously ever after', async () => {
|
||||
const view = (state) => html`<div>cached ${state.count}</div>`
|
||||
const wrapped = lazy(() => Promise.resolve({ default: view }))
|
||||
|
||||
const app = choo()
|
||||
app.use((state, emitter) => {
|
||||
state.count = 0
|
||||
emitter.on('bump', () => { state.count++; emitter.emit('render') })
|
||||
})
|
||||
app.route('/', wrapped)
|
||||
|
||||
// no loading view: the placeholder must live in the DOM to be replaced
|
||||
const holder = document.createElement('section')
|
||||
document.body.appendChild(holder)
|
||||
holder.appendChild(app.start())
|
||||
await tick() // let the loader resolve + re-render
|
||||
|
||||
assert.strictEqual(holder.textContent, 'cached 0')
|
||||
|
||||
app.emit('bump')
|
||||
await tick()
|
||||
assert.strictEqual(holder.textContent, 'cached 1', 'subsequent renders are sync through the cache')
|
||||
holder.remove()
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// choo.toStream(): web-standard streaming SSR with prefetch and lazy routes.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import html from '@uhhm/buuh-html'
|
||||
import choo, { lazy } from '@uhhm/buuh'
|
||||
|
||||
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms))
|
||||
|
||||
async function readAll (stream) {
|
||||
const decoder = new TextDecoder()
|
||||
const chunks = []
|
||||
for await (const chunk of stream) chunks.push(decoder.decode(chunk, { stream: true }))
|
||||
return chunks
|
||||
}
|
||||
|
||||
test('toStream matches toString for a plain sync app', async () => {
|
||||
const makeApp = () => {
|
||||
const app = choo()
|
||||
app.route('/', (state) => html`<div><h1>hi</h1></div>`)
|
||||
return app
|
||||
}
|
||||
const chunks = await readAll(makeApp().toStream('/'))
|
||||
assert.strictEqual(chunks.join(''), makeApp().toString('/'))
|
||||
})
|
||||
|
||||
test('returns a web ReadableStream', () => {
|
||||
const app = choo()
|
||||
app.route('/', () => html`<div></div>`)
|
||||
assert.ok(app.toStream('/') instanceof ReadableStream)
|
||||
})
|
||||
|
||||
test('stores can defer rendering with state.prefetch promises', async () => {
|
||||
const app = choo()
|
||||
app.use((state) => {
|
||||
state.user = null
|
||||
state.prefetch.push(wait(10, null).then(() => { state.user = 'bendik' }))
|
||||
})
|
||||
app.route('/', (state) => html`<p>hello ${state.user}</p>`)
|
||||
|
||||
const chunks = await readAll(app.toStream('/'))
|
||||
assert.strictEqual(chunks.join(''), '<p>hello bendik</p>', 'render waited for prefetch')
|
||||
})
|
||||
|
||||
test('toString refuses prefetching stores with guidance', () => {
|
||||
const app = choo()
|
||||
app.use((state) => { state.prefetch.push(Promise.resolve()) })
|
||||
app.route('/', () => html`<div></div>`)
|
||||
assert.throws(() => app.toString('/'), /toStream/)
|
||||
})
|
||||
|
||||
test('async template holes stream: shell first, slow content later', async () => {
|
||||
const app = choo()
|
||||
app.route('/', (state) => html`<body><h1>shell</h1>${wait(15, html`<section>slow</section>`)}</body>`)
|
||||
|
||||
const stream = app.toStream('/')
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const started = performance.now()
|
||||
const first = decoder.decode((await reader.read()).value)
|
||||
const firstAt = performance.now() - started
|
||||
|
||||
assert.strictEqual(first, '<body><h1>shell</h1>', 'shell flushed immediately')
|
||||
assert.ok(firstAt < 10, `shell arrived before the slow hole resolved (${firstAt.toFixed(1)}ms)`)
|
||||
|
||||
let rest = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
rest += decoder.decode(value, { stream: true })
|
||||
}
|
||||
assert.strictEqual(rest, '<section>slow</section></body>')
|
||||
})
|
||||
|
||||
test('toStream awaits lazy route views; toString refuses them', async () => {
|
||||
const makeApp = () => {
|
||||
const app = choo()
|
||||
app.route('/', lazy(() => wait(10, {
|
||||
default: (state) => html`<main>lazy view</main>`
|
||||
})))
|
||||
return app
|
||||
}
|
||||
|
||||
assert.throws(() => makeApp().toString('/'), /toStream/)
|
||||
|
||||
const chunks = await readAll(makeApp().toStream('/'))
|
||||
assert.strictEqual(chunks.join(''), '<main>lazy view</main>')
|
||||
})
|
||||
|
||||
test('stream errors propagate through the stream, not as sync throws', async () => {
|
||||
const app = choo()
|
||||
app.route('/', () => html`<div>${Promise.reject(new Error('boom'))}</div>`)
|
||||
await assert.rejects(async () => readAll(app.toStream('/')), /boom/)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
// URL normalization: the non-ASCII and malformed-percent cases that made
|
||||
// v7 routing crash or mismatch. See lib/router.js header for the rules.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import html from '@uhhm/buuh-html'
|
||||
import choo from '@uhhm/buuh'
|
||||
|
||||
function view (state) {
|
||||
return html`<div></div>`
|
||||
}
|
||||
|
||||
test('a literal % in the path does not crash routing', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/deals/:label', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/deals/50%off')
|
||||
assert.strictEqual(seen.params.label, '50%off', 'raw segment kept when not decodable')
|
||||
assert.strictEqual(seen.href, '/deals/50%off', 'href kept raw when not decodable')
|
||||
})
|
||||
|
||||
test('percent-encoded UTF-8 params decode exactly once', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/user/:name', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/user/%F0%9F%9A%82')
|
||||
assert.strictEqual(seen.params.name, '🚂', 'param decoded')
|
||||
assert.strictEqual(seen.href, '/user/🚂', 'href decoded for humans')
|
||||
})
|
||||
|
||||
test('double-encoded input is not double-decoded', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/user/:name', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
// %2540 is '%40' encoded once; v7 double-decoded it all the way to '@'
|
||||
app.toString('/user/%2540')
|
||||
assert.strictEqual(seen.params.name, '%40', 'decoded exactly once')
|
||||
})
|
||||
|
||||
test('non-ASCII literal routes match their encoded locations', () => {
|
||||
const app = choo()
|
||||
let calls = 0
|
||||
app.route('/café', (state) => {
|
||||
calls++
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/caf%C3%A9')
|
||||
assert.strictEqual(calls, 1, 'encoded location matched unencoded route')
|
||||
})
|
||||
|
||||
test('NFD input matches an NFC route definition', () => {
|
||||
const app = choo()
|
||||
let calls = 0
|
||||
app.route('/café', (state) => { // composed é
|
||||
calls++
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/café') // decomposed e + combining acute
|
||||
assert.strictEqual(calls, 1, 'unicode-normalized before matching')
|
||||
})
|
||||
|
||||
test('unencoded non-ASCII locations route fine', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/user/:name', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/user/日本語')
|
||||
assert.strictEqual(seen.params.name, '日本語')
|
||||
})
|
||||
|
||||
test('wildcards decode per segment', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/files/*', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/files/caf%C3%A9/na%C3%AFve.txt')
|
||||
assert.strictEqual(seen.params.wildcard, 'café/naïve.txt')
|
||||
})
|
||||
|
||||
test('hash routing survives multiple hashes', () => {
|
||||
const app = choo({ hash: true })
|
||||
let calls = 0
|
||||
app.route('/docs/api/intro', (state) => {
|
||||
calls++
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/docs#api#intro')
|
||||
assert.strictEqual(calls, 1, 'every hash became a slash')
|
||||
})
|
||||
|
||||
test('query strings decode + and percent-encoding via URLSearchParams', () => {
|
||||
const app = choo()
|
||||
let seen
|
||||
app.route('/', (state) => {
|
||||
seen = state
|
||||
return view(state)
|
||||
})
|
||||
app.toString('/?q=caf%C3%A9+au+lait&tags=a&tags=b')
|
||||
assert.deepStrictEqual(seen.query, { q: 'café au lait', tags: ['a', 'b'] })
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Console devtools for choo v8, carrying forward the essentials of
|
||||
// choo-devtools 3.x (MIT): a window.choo handle with the live state, an
|
||||
// event log, emit-from-the-console, and render timings via the
|
||||
// PerformanceObserver watching nanotiming's measures. Usage:
|
||||
//
|
||||
// import devtools from '@uhhm/buuh-devtools'
|
||||
// app.use(devtools())
|
||||
//
|
||||
// The store is a no-op on the server and (by default) stays quiet in the
|
||||
// console; set localStorage.CHOO_DEVTOOLS_VERBOSE = 'true' to log every
|
||||
// event as it happens.
|
||||
|
||||
const MAX_LOG = 1000
|
||||
|
||||
export default function devtools (opts) {
|
||||
opts = opts || {}
|
||||
const max = opts.max || MAX_LOG
|
||||
|
||||
return function devtoolsStore (state, emitter, app) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const log = []
|
||||
let verbose = false
|
||||
try {
|
||||
verbose = window.localStorage.CHOO_DEVTOOLS_VERBOSE === 'true'
|
||||
} catch (e) {}
|
||||
|
||||
emitter.on('*', function (name, ...data) {
|
||||
const entry = { name: String(name), data, at: Date.now() }
|
||||
log.push(entry)
|
||||
if (log.length > max) log.shift()
|
||||
if (verbose && name !== state.events.RENDER) {
|
||||
console.debug('choo: %s', entry.name, ...data)
|
||||
}
|
||||
})
|
||||
|
||||
const timings = []
|
||||
if (typeof PerformanceObserver === 'function') {
|
||||
try {
|
||||
const observer = new PerformanceObserver(function (list) {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!/\[\d+\]$/.test(entry.name)) continue // nanotiming measures only
|
||||
timings.push({ name: entry.name.replace(/ \[\d+\]$/, ''), duration: entry.duration })
|
||||
if (timings.length > max) timings.shift()
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['measure'] })
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
window.choo = {
|
||||
state,
|
||||
emit: emitter.emit.bind(emitter),
|
||||
emitter,
|
||||
app,
|
||||
log,
|
||||
timings,
|
||||
// JSON snapshot of state (cache/functions excluded via toJSON)
|
||||
copy () {
|
||||
const json = JSON.stringify(state, null, 2)
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(json)
|
||||
return json
|
||||
},
|
||||
help () {
|
||||
console.log([
|
||||
'window.choo — choo devtools',
|
||||
' choo.state live app state',
|
||||
' choo.emit(name, …data) fire an event',
|
||||
' choo.log last ' + max + ' events',
|
||||
' choo.timings nanotiming render measures',
|
||||
' choo.copy() state as JSON (and to clipboard)',
|
||||
" localStorage.CHOO_DEVTOOLS_VERBOSE = 'true' log every event"
|
||||
].join('\n'))
|
||||
return '☰'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@uhhm/buuh-devtools",
|
||||
"version": "8.0.0",
|
||||
"description": "Console devtools for choo: window.choo, event log, perf timings",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"keywords": [
|
||||
"choo",
|
||||
"devtools",
|
||||
"debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let choo, html, devtools
|
||||
|
||||
before(async () => {
|
||||
const win = new Window({ url: 'http://localhost/' })
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
|
||||
;({ default: choo } = await import('@uhhm/buuh'))
|
||||
;({ default: html } = await import('@uhhm/buuh-html/browser'))
|
||||
;({ default: devtools } = await import('../index.js'))
|
||||
})
|
||||
|
||||
test('devtools exposes window.choo with live state, log and emit', async () => {
|
||||
const app = choo()
|
||||
app.use(devtools())
|
||||
app.use((state, emitter) => {
|
||||
state.count = 0
|
||||
emitter.on('bump', (n) => { state.count += n })
|
||||
})
|
||||
app.route('/', (state) => html`<div>${state.count}</div>`)
|
||||
app.start()
|
||||
|
||||
assert.ok(window.choo, 'window.choo exists')
|
||||
assert.strictEqual(window.choo.state.count, 0, 'live state exposed')
|
||||
|
||||
window.choo.emit('bump', 2)
|
||||
assert.strictEqual(window.choo.state.count, 2, 'emit from the console handle works')
|
||||
assert.ok(window.choo.log.some((e) => e.name === 'bump'), 'events land in the log')
|
||||
assert.strictEqual(typeof window.choo.help, 'function')
|
||||
|
||||
const json = JSON.parse(window.choo.copy())
|
||||
assert.strictEqual(json.count, 2, 'copy() serializes state')
|
||||
assert.strictEqual(json.cache, null, 'state.cache not serialized')
|
||||
})
|
||||
|
||||
test('devtools is a no-op on the server', async () => {
|
||||
// fresh import in a windowless world is covered by the guard: calling
|
||||
// the store with no window must not throw or touch globals
|
||||
const store = devtools()
|
||||
const hadWindow = globalThis.window
|
||||
delete globalThis.window
|
||||
try {
|
||||
assert.doesNotThrow(() => store({}, { on () {} }, {}))
|
||||
} finally {
|
||||
globalThis.window = hadWindow
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
// The v8 browser renderer: a runtime-only tagged template.
|
||||
//
|
||||
// Each unique template literal is parsed once, keyed by its (frozen,
|
||||
// per-call-site) strings array in a WeakMap: the static parts become a
|
||||
// <template> element with markers where the holes are, plus a list of
|
||||
// instructions (node paths + hole indices). Every render clones the
|
||||
// template and fills the holes. No compile step, no HTML re-parsing on
|
||||
// re-render — production speed is a property of the runtime, not of a
|
||||
// build tool (this replaces nanohtml's browserify/babel transform).
|
||||
//
|
||||
// Interpolated values never pass through innerHTML: child values become
|
||||
// text nodes or adopted DOM nodes, attribute values go through
|
||||
// setAttribute. Only the author-written static strings are parsed as HTML.
|
||||
//
|
||||
// Known limits (documented, matching or narrowing nanohtml's):
|
||||
// - dynamic tag names (html`<${tag}>`) are not supported
|
||||
// - holes inside <script>/<style>/<textarea> raw text are not supported
|
||||
// - an SVG fragment must include its <svg> root to get the right namespace
|
||||
|
||||
const BOOL_PROPS = [
|
||||
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
|
||||
'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden',
|
||||
'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline',
|
||||
'readonly', 'required', 'reversed', 'selected'
|
||||
]
|
||||
|
||||
const templateCache = new WeakMap()
|
||||
|
||||
const TEXT = 0
|
||||
const TAG = 1
|
||||
const DQ = 2
|
||||
const SQ = 3
|
||||
const COMMENT = 4
|
||||
|
||||
export default function html (strings, ...values) {
|
||||
let entry = templateCache.get(strings)
|
||||
if (!entry) {
|
||||
entry = parseTemplate(strings)
|
||||
templateCache.set(strings, entry)
|
||||
}
|
||||
return instantiate(entry, values)
|
||||
}
|
||||
|
||||
export { html }
|
||||
|
||||
// Build the marker HTML for a template literal and parse it once.
|
||||
function parseTemplate (strings) {
|
||||
let src = ''
|
||||
let state = TEXT
|
||||
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
const s = strings[i]
|
||||
for (let j = 0; j < s.length; j++) {
|
||||
const c = s[j]
|
||||
switch (state) {
|
||||
case TEXT:
|
||||
if (c === '<') {
|
||||
if (s.startsWith('!--', j + 1)) {
|
||||
state = COMMENT
|
||||
j += 3
|
||||
} else {
|
||||
state = TAG
|
||||
}
|
||||
}
|
||||
break
|
||||
case TAG:
|
||||
if (c === '>') state = TEXT
|
||||
else if (c === '"') state = DQ
|
||||
else if (c === "'") state = SQ
|
||||
break
|
||||
case DQ:
|
||||
if (c === '"') state = TAG
|
||||
break
|
||||
case SQ:
|
||||
if (c === "'") state = TAG
|
||||
break
|
||||
case COMMENT:
|
||||
if (c === '-' && s.startsWith('->', j + 1)) {
|
||||
state = TEXT
|
||||
j += 2
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
src += s
|
||||
|
||||
if (i < strings.length - 1) {
|
||||
if (state === TEXT) {
|
||||
src += `<!--__h${i}__-->`
|
||||
} else if (state === DQ || state === SQ) {
|
||||
src += `__h${i}__`
|
||||
} else if (state === TAG) {
|
||||
if (/=\s*$/.test(s)) {
|
||||
// unquoted attribute value: attr=${x}
|
||||
src += `"__h${i}__"`
|
||||
} else {
|
||||
// attribute-name position: spread object, <div ${attrs}>
|
||||
src += ` __h${i}__ `
|
||||
}
|
||||
}
|
||||
// COMMENT position: holes inside comments are dropped
|
||||
}
|
||||
}
|
||||
|
||||
const instructions = []
|
||||
|
||||
// <template> parsing drops document-level tags (<body>, <head>, <html>),
|
||||
// but choo views legitimately return html`<body>…`. Parse those as a
|
||||
// full document instead and use the element itself as the clone root.
|
||||
const docRoot = /^\s*<(html|head|body)[\s>]/i.exec(src)
|
||||
if (docRoot) {
|
||||
const parser = new window.DOMParser()
|
||||
const doc = parser.parseFromString(src, 'text/html')
|
||||
const tag = docRoot[1].toLowerCase()
|
||||
const root = tag === 'html' ? doc.documentElement : tag === 'head' ? doc.head : doc.body
|
||||
scanAttrs(root, [], instructions)
|
||||
walk(root, [], instructions)
|
||||
return { root, instructions }
|
||||
}
|
||||
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = src
|
||||
walk(template.content, [], instructions)
|
||||
return { root: template.content, instructions }
|
||||
}
|
||||
|
||||
function walk (node, path, out) {
|
||||
const kids = node.childNodes
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
const child = kids[i]
|
||||
const childPath = path.concat(i)
|
||||
|
||||
if (child.nodeType === 8) { // comment
|
||||
const m = /^__h(\d+)__$/.exec(child.data)
|
||||
if (m) out.push({ type: 'child', path: childPath, hole: +m[1] })
|
||||
} else if (child.nodeType === 1) { // element
|
||||
scanAttrs(child, childPath, out)
|
||||
walk(child, childPath, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanAttrs (el, path, out) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const nameMatch = /^__h(\d+)__$/.exec(attr.name)
|
||||
if (nameMatch) {
|
||||
el.removeAttribute(attr.name)
|
||||
out.push({ type: 'spread', path, hole: +nameMatch[1] })
|
||||
continue
|
||||
}
|
||||
if (/__h\d+__/.test(attr.value)) {
|
||||
const sole = /^__h(\d+)__$/.exec(attr.value)
|
||||
if (sole) {
|
||||
// whole value is one hole: apply with type awareness at render
|
||||
el.removeAttribute(attr.name)
|
||||
out.push({ type: 'attr', path, name: attr.name, hole: +sole[1] })
|
||||
} else {
|
||||
// static text mixed with holes: string composition
|
||||
const parts = attr.value.split(/__h(\d+)__/)
|
||||
.map((p, idx) => idx % 2 ? +p : p)
|
||||
.filter((p) => p !== '')
|
||||
out.push({ type: 'parts', path, name: attr.name, parts })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function instantiate (entry, values) {
|
||||
const frag = entry.root.cloneNode(true)
|
||||
|
||||
// Resolve every target before mutating: child replacements change
|
||||
// sibling indices, so paths are only valid against the pristine clone.
|
||||
const targets = entry.instructions.map((instr) => resolvePath(frag, instr.path))
|
||||
|
||||
entry.instructions.forEach((instr, i) => {
|
||||
const node = targets[i]
|
||||
if (instr.type === 'child') {
|
||||
node.replaceWith(toNode(values[instr.hole]))
|
||||
} else if (instr.type === 'attr') {
|
||||
setAttr(node, instr.name, values[instr.hole])
|
||||
} else if (instr.type === 'spread') {
|
||||
const obj = values[instr.hole]
|
||||
if (obj && typeof obj === 'object') {
|
||||
for (const key of Object.keys(obj)) setAttr(node, key, obj[key])
|
||||
}
|
||||
} else if (instr.type === 'parts') {
|
||||
const joined = instr.parts
|
||||
.map((p) => typeof p === 'number' ? toAttrString(values[p]) : p)
|
||||
.join('')
|
||||
node.setAttribute(instr.name, joined)
|
||||
}
|
||||
})
|
||||
|
||||
// A document-level root (<body> etc.) is already an element
|
||||
if (frag.nodeType === 1) return frag
|
||||
|
||||
// Single element root unwraps (choo views return one node);
|
||||
// anything else stays a fragment.
|
||||
let result = null
|
||||
for (const child of frag.childNodes) {
|
||||
if (child.nodeType === 3 && !child.data.trim()) continue
|
||||
if (result === null) {
|
||||
result = child
|
||||
} else {
|
||||
result = frag
|
||||
break
|
||||
}
|
||||
}
|
||||
return result === null ? frag : result
|
||||
}
|
||||
|
||||
function resolvePath (root, path) {
|
||||
let node = root
|
||||
for (const index of path) node = node.childNodes[index]
|
||||
return node
|
||||
}
|
||||
|
||||
function toNode (value) {
|
||||
if (value === null || value === undefined) {
|
||||
return document.createTextNode('')
|
||||
}
|
||||
if (value.nodeType) return value
|
||||
if (Array.isArray(value)) {
|
||||
const frag = document.createDocumentFragment()
|
||||
for (const item of value) frag.appendChild(toNode(item))
|
||||
return frag
|
||||
}
|
||||
if (value.__encoded) {
|
||||
// raw() output: author-vouched HTML, parsed on purpose
|
||||
const t = document.createElement('template')
|
||||
t.innerHTML = String(value)
|
||||
return t.content
|
||||
}
|
||||
return document.createTextNode(String(value))
|
||||
}
|
||||
|
||||
function setAttr (el, name, value) {
|
||||
if (typeof value === 'function') {
|
||||
// event handlers are set as properties so nanomorph can copy them
|
||||
if (name.startsWith('on')) el[name] = value
|
||||
return
|
||||
}
|
||||
if (BOOL_PROPS.includes(name)) {
|
||||
el[name] = !!value
|
||||
if (value) el.setAttribute(name, name)
|
||||
else el.removeAttribute(name)
|
||||
return
|
||||
}
|
||||
if (value === null || value === undefined || value === false) {
|
||||
el.removeAttribute(name)
|
||||
return
|
||||
}
|
||||
if (value === true) {
|
||||
el.setAttribute(name, '')
|
||||
return
|
||||
}
|
||||
el.setAttribute(name, String(value))
|
||||
if (name === 'value' && 'value' in el) el.value = String(value)
|
||||
}
|
||||
|
||||
function toAttrString (value) {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'function') return ''
|
||||
return String(value)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Hydration: adopt server-rendered DOM instead of replacing it.
|
||||
//
|
||||
// Because the server and browser renderers serialize identically, morphing
|
||||
// the first client render onto the server DOM leaves matching nodes
|
||||
// untouched — the server DOM is adopted in place (same element references,
|
||||
// form state preserved) and only real differences mutate. What hydrate
|
||||
// adds over a bare morph is detection: before morphing it walks both trees
|
||||
// and reports the first place server and client markup disagree, which is
|
||||
// exactly the class of bug (Date.now() in views, user-specific content,
|
||||
// stale caches) that otherwise surfaces as a silent flash of changed
|
||||
// content. The client render always wins.
|
||||
|
||||
import morph from './morph.js'
|
||||
|
||||
export default function hydrate (oldNode, newNode, opts) {
|
||||
// The client tree can hold adjacent text nodes ("count is " + "0")
|
||||
// where the parsed server HTML has one merged run ("count is 0") —
|
||||
// identical serialization, different granularity. Fold the client tree
|
||||
// to parser granularity so comparison and morph see matching shapes.
|
||||
if (newNode.normalize) newNode.normalize()
|
||||
|
||||
const onMismatch = opts && opts.onMismatch
|
||||
if (onMismatch) {
|
||||
const diff = firstDifference(oldNode, newNode, [])
|
||||
if (diff) onMismatch(diff)
|
||||
}
|
||||
return morph(oldNode, newNode)
|
||||
}
|
||||
|
||||
export { hydrate }
|
||||
|
||||
// Depth-first parallel walk; returns { path, reason, server, client } for
|
||||
// the first disagreement, or null when the trees agree.
|
||||
function firstDifference (a, b, path) {
|
||||
if (a.nodeType !== b.nodeType || a.nodeName !== b.nodeName) {
|
||||
return {
|
||||
path: pathString(path),
|
||||
reason: 'node',
|
||||
server: describe(a),
|
||||
client: describe(b)
|
||||
}
|
||||
}
|
||||
|
||||
if (a.nodeType === 3 || a.nodeType === 8) { // text, comment
|
||||
// whitespace-insensitive: the parser shuffles insignificant
|
||||
// whitespace (e.g. text after </body> reparents into body), and
|
||||
// morph reconciles it silently — only report meaningful text
|
||||
if (a.nodeValue.trim() !== b.nodeValue.trim()) {
|
||||
return {
|
||||
path: pathString(path),
|
||||
reason: 'text',
|
||||
server: a.nodeValue,
|
||||
client: b.nodeValue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (a.nodeType === 1) {
|
||||
const attrDiff = attrDifference(a, b)
|
||||
if (attrDiff) {
|
||||
return {
|
||||
path: pathString(path.concat(a.nodeName.toLowerCase())),
|
||||
reason: 'attribute',
|
||||
server: attrDiff.server,
|
||||
client: attrDiff.client
|
||||
}
|
||||
}
|
||||
|
||||
const aKids = significant(a.childNodes)
|
||||
const bKids = significant(b.childNodes)
|
||||
if (aKids.length !== bKids.length) {
|
||||
return {
|
||||
path: pathString(path.concat(a.nodeName.toLowerCase())),
|
||||
reason: 'children',
|
||||
server: aKids.length + ' child node(s)',
|
||||
client: bKids.length + ' child node(s)'
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < aKids.length; i++) {
|
||||
const diff = firstDifference(aKids[i], bKids[i], path.concat(a.nodeName.toLowerCase()))
|
||||
if (diff) return diff
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// child nodes that matter for comparison: everything except
|
||||
// whitespace-only text nodes and <script> elements — server pages
|
||||
// legitimately carry scripts (state serialization, analytics) that are
|
||||
// already spent by hydration time and that client views never render
|
||||
function significant (childNodes) {
|
||||
const out = []
|
||||
for (let i = 0; i < childNodes.length; i++) {
|
||||
const node = childNodes[i]
|
||||
if (node.nodeType === 3 && !node.nodeValue.trim()) continue
|
||||
if (node.nodeType === 1 && node.nodeName === 'SCRIPT') continue
|
||||
out.push(node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function attrDifference (a, b) {
|
||||
const aAttrs = a.attributes
|
||||
const bAttrs = b.attributes
|
||||
for (let i = 0; i < bAttrs.length; i++) {
|
||||
const name = bAttrs[i].name
|
||||
if (a.getAttribute(name) !== bAttrs[i].value) {
|
||||
return {
|
||||
server: name + '=' + JSON.stringify(a.getAttribute(name)),
|
||||
client: name + '=' + JSON.stringify(bAttrs[i].value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < aAttrs.length; i++) {
|
||||
const name = aAttrs[i].name
|
||||
if (!b.hasAttribute(name)) {
|
||||
return {
|
||||
server: name + '=' + JSON.stringify(aAttrs[i].value),
|
||||
client: name + ' (absent)'
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function describe (node) {
|
||||
if (!node) return '(missing)'
|
||||
if (node.nodeType === 3) return 'text ' + JSON.stringify(node.nodeValue)
|
||||
if (node.nodeType === 8) return 'comment'
|
||||
return '<' + node.nodeName.toLowerCase() + '>'
|
||||
}
|
||||
|
||||
function pathString (path) {
|
||||
return path.length ? path.join(' > ') : '(root)'
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Ported from nanomorph 5.4.3 (MIT) — https://github.com/choojs/nanomorph
|
||||
// Morph one DOM tree into another. Single-module consolidation of
|
||||
// index.js + lib/morph.js + lib/events.js.
|
||||
|
||||
/* eslint-disable eqeqeq */
|
||||
function equal (a, b, message) {
|
||||
if (a != b) throw new Error(message)
|
||||
}
|
||||
function notEqual (a, b, message) {
|
||||
if (a == b) throw new Error(message)
|
||||
}
|
||||
/* eslint-enable eqeqeq */
|
||||
|
||||
const ELEMENT_NODE = 1
|
||||
const TEXT_NODE = 3
|
||||
const COMMENT_NODE = 8
|
||||
|
||||
const events = [
|
||||
// attribute events (can be set with attributes)
|
||||
'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover',
|
||||
'onmousemove', 'onmouseout', 'onmouseenter', 'onmouseleave',
|
||||
'ontouchcancel', 'ontouchend', 'ontouchmove', 'ontouchstart',
|
||||
'ondragstart', 'ondrag', 'ondragenter', 'ondragleave', 'ondragover',
|
||||
'ondrop', 'ondragend', 'onkeydown', 'onkeypress', 'onkeyup', 'onunload',
|
||||
'onabort', 'onerror', 'onresize', 'onscroll', 'onselect', 'onchange',
|
||||
'onsubmit', 'onreset', 'onfocus', 'onblur', 'oninput',
|
||||
'onanimationend', 'onanimationiteration', 'onanimationstart',
|
||||
// other common events
|
||||
'oncontextmenu', 'onfocusin', 'onfocusout'
|
||||
]
|
||||
|
||||
// Morph one tree into another tree
|
||||
//
|
||||
// no parent
|
||||
// -> same: diff and walk children
|
||||
// -> not same: replace and return
|
||||
// old node doesn't exist
|
||||
// -> insert new node
|
||||
// new node doesn't exist
|
||||
// -> delete old node
|
||||
// nodes are not the same
|
||||
// -> diff nodes and apply patch to old node
|
||||
// nodes are the same
|
||||
// -> walk all child nodes and append to old node
|
||||
export default function nanomorph (oldTree, newTree, options) {
|
||||
equal(typeof oldTree, 'object', 'nanomorph: oldTree should be an object')
|
||||
equal(typeof newTree, 'object', 'nanomorph: newTree should be an object')
|
||||
|
||||
if (options && options.childrenOnly) {
|
||||
updateChildren(newTree, oldTree)
|
||||
return oldTree
|
||||
}
|
||||
|
||||
notEqual(
|
||||
newTree.nodeType,
|
||||
11,
|
||||
'nanomorph: newTree should have one root node (which is not a DocumentFragment)'
|
||||
)
|
||||
|
||||
return walk(newTree, oldTree)
|
||||
}
|
||||
|
||||
// Walk and morph a dom tree
|
||||
function walk (newNode, oldNode) {
|
||||
if (!oldNode) {
|
||||
return newNode
|
||||
} else if (!newNode) {
|
||||
return null
|
||||
} else if (newNode.isSameNode && newNode.isSameNode(oldNode)) {
|
||||
return oldNode
|
||||
} else if (newNode.tagName !== oldNode.tagName || getComponentId(newNode) !== getComponentId(oldNode)) {
|
||||
return newNode
|
||||
} else {
|
||||
morph(newNode, oldNode)
|
||||
updateChildren(newNode, oldNode)
|
||||
return oldNode
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentId (node) {
|
||||
return node.dataset ? node.dataset.nanomorphComponentId : undefined
|
||||
}
|
||||
|
||||
// Update the children of elements
|
||||
function updateChildren (newNode, oldNode) {
|
||||
let oldChild, newChild, morphed, oldMatch
|
||||
|
||||
// The offset is only ever increased, and used for [i - offset] in the loop
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; ; i++) {
|
||||
oldChild = oldNode.childNodes[i]
|
||||
newChild = newNode.childNodes[i - offset]
|
||||
|
||||
// Both nodes are empty, do nothing
|
||||
if (!oldChild && !newChild) {
|
||||
break
|
||||
|
||||
// There is no new child, remove old
|
||||
} else if (!newChild) {
|
||||
oldNode.removeChild(oldChild)
|
||||
i--
|
||||
|
||||
// There is no old child, add new
|
||||
} else if (!oldChild) {
|
||||
oldNode.appendChild(newChild)
|
||||
offset++
|
||||
|
||||
// Both nodes are the same, morph
|
||||
} else if (same(newChild, oldChild)) {
|
||||
morphed = walk(newChild, oldChild)
|
||||
if (morphed !== oldChild) {
|
||||
oldNode.replaceChild(morphed, oldChild)
|
||||
offset++
|
||||
}
|
||||
|
||||
// Both nodes do not share an ID or a placeholder, try reorder
|
||||
} else {
|
||||
oldMatch = null
|
||||
|
||||
// Try and find a similar node somewhere in the tree
|
||||
for (let j = i; j < oldNode.childNodes.length; j++) {
|
||||
if (same(oldNode.childNodes[j], newChild)) {
|
||||
oldMatch = oldNode.childNodes[j]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If there was a node with the same ID or placeholder in the old list
|
||||
if (oldMatch) {
|
||||
morphed = walk(newChild, oldMatch)
|
||||
if (morphed !== oldMatch) offset++
|
||||
oldNode.insertBefore(morphed, oldChild)
|
||||
|
||||
// It's safe to morph two nodes in-place if neither has an ID
|
||||
} else if (!newChild.id && !oldChild.id) {
|
||||
morphed = walk(newChild, oldChild)
|
||||
if (morphed !== oldChild) {
|
||||
oldNode.replaceChild(morphed, oldChild)
|
||||
offset++
|
||||
}
|
||||
|
||||
// Insert the node at the index if we couldn't morph or find a matching node
|
||||
} else {
|
||||
oldNode.insertBefore(newChild, oldChild)
|
||||
offset++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function same (a, b) {
|
||||
if (a.id) return a.id === b.id
|
||||
if (a.isSameNode) return a.isSameNode(b)
|
||||
if (a.tagName !== b.tagName) return false
|
||||
if (a.type === TEXT_NODE) return a.nodeValue === b.nodeValue
|
||||
return false
|
||||
}
|
||||
|
||||
// diff elements and apply the resulting patch to the old node
|
||||
function morph (newNode, oldNode) {
|
||||
const nodeType = newNode.nodeType
|
||||
const nodeName = newNode.nodeName
|
||||
|
||||
if (nodeType === ELEMENT_NODE) {
|
||||
copyAttrs(newNode, oldNode)
|
||||
}
|
||||
|
||||
if (nodeType === TEXT_NODE || nodeType === COMMENT_NODE) {
|
||||
if (oldNode.nodeValue !== newNode.nodeValue) {
|
||||
oldNode.nodeValue = newNode.nodeValue
|
||||
}
|
||||
}
|
||||
|
||||
// Some DOM nodes are weird
|
||||
// https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
|
||||
if (nodeName === 'INPUT') updateInput(newNode, oldNode)
|
||||
else if (nodeName === 'OPTION') updateOption(newNode, oldNode)
|
||||
else if (nodeName === 'TEXTAREA') updateTextarea(newNode, oldNode)
|
||||
|
||||
copyEvents(newNode, oldNode)
|
||||
}
|
||||
|
||||
function copyAttrs (newNode, oldNode) {
|
||||
const oldAttrs = oldNode.attributes
|
||||
const newAttrs = newNode.attributes
|
||||
let attrNamespaceURI = null
|
||||
let attrValue = null
|
||||
let fromValue = null
|
||||
let attrName = null
|
||||
let attr = null
|
||||
|
||||
for (let i = newAttrs.length - 1; i >= 0; --i) {
|
||||
attr = newAttrs[i]
|
||||
attrName = attr.name
|
||||
attrNamespaceURI = attr.namespaceURI
|
||||
attrValue = attr.value
|
||||
if (attrNamespaceURI) {
|
||||
attrName = attr.localName || attrName
|
||||
fromValue = oldNode.getAttributeNS(attrNamespaceURI, attrName)
|
||||
if (fromValue !== attrValue) {
|
||||
oldNode.setAttributeNS(attrNamespaceURI, attrName, attrValue)
|
||||
}
|
||||
} else {
|
||||
if (!oldNode.hasAttribute(attrName)) {
|
||||
oldNode.setAttribute(attrName, attrValue)
|
||||
} else {
|
||||
fromValue = oldNode.getAttribute(attrName)
|
||||
if (fromValue !== attrValue) {
|
||||
// apparently values are always cast to strings, ah well
|
||||
if (attrValue === 'null' || attrValue === 'undefined') {
|
||||
oldNode.removeAttribute(attrName)
|
||||
} else {
|
||||
oldNode.setAttribute(attrName, attrValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any extra attributes found on the original DOM element that
|
||||
// weren't found on the target element.
|
||||
for (let j = oldAttrs.length - 1; j >= 0; --j) {
|
||||
attr = oldAttrs[j]
|
||||
if (attr.specified !== false) {
|
||||
attrName = attr.name
|
||||
attrNamespaceURI = attr.namespaceURI
|
||||
|
||||
if (attrNamespaceURI) {
|
||||
attrName = attr.localName || attrName
|
||||
if (!newNode.hasAttributeNS(attrNamespaceURI, attrName)) {
|
||||
oldNode.removeAttributeNS(attrNamespaceURI, attrName)
|
||||
}
|
||||
} else {
|
||||
if (!newNode.hasAttributeNS(null, attrName)) {
|
||||
oldNode.removeAttribute(attrName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyEvents (newNode, oldNode) {
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const ev = events[i]
|
||||
if (newNode[ev]) { // if new element has a whitelisted attribute
|
||||
oldNode[ev] = newNode[ev] // update existing element
|
||||
} else if (oldNode[ev]) { // if existing element has it and new one doesnt
|
||||
oldNode[ev] = undefined // remove it from existing element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateOption (newNode, oldNode) {
|
||||
updateAttribute(newNode, oldNode, 'selected')
|
||||
}
|
||||
|
||||
// The "value" attribute is special for the <input> element since it sets the
|
||||
// initial value. Changing the "value" attribute without changing the "value"
|
||||
// property will have no effect since it is only used to the set the initial
|
||||
// value. Similar for the "checked" attribute, and "disabled".
|
||||
function updateInput (newNode, oldNode) {
|
||||
const newValue = newNode.value
|
||||
const oldValue = oldNode.value
|
||||
|
||||
updateAttribute(newNode, oldNode, 'checked')
|
||||
updateAttribute(newNode, oldNode, 'disabled')
|
||||
|
||||
// The "indeterminate" property can not be set using an HTML attribute.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox
|
||||
if (newNode.indeterminate !== oldNode.indeterminate) {
|
||||
oldNode.indeterminate = newNode.indeterminate
|
||||
}
|
||||
|
||||
// Persist file value since file inputs can't be changed programatically
|
||||
if (oldNode.type === 'file') return
|
||||
|
||||
if (newValue !== oldValue) {
|
||||
oldNode.setAttribute('value', newValue)
|
||||
oldNode.value = newValue
|
||||
}
|
||||
|
||||
if (newValue === 'null') {
|
||||
oldNode.value = ''
|
||||
oldNode.removeAttribute('value')
|
||||
}
|
||||
|
||||
if (!newNode.hasAttributeNS(null, 'value')) {
|
||||
oldNode.removeAttribute('value')
|
||||
} else if (oldNode.type === 'range') {
|
||||
// this is so elements like slider move their UI thingy
|
||||
oldNode.value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
function updateTextarea (newNode, oldNode) {
|
||||
const newValue = newNode.value
|
||||
if (newValue !== oldNode.value) {
|
||||
oldNode.value = newValue
|
||||
}
|
||||
|
||||
if (oldNode.firstChild && oldNode.firstChild.nodeValue !== newValue) {
|
||||
// Needed for IE. Apparently IE sets the placeholder as the
|
||||
// node value and vise versa. This ignores an empty update.
|
||||
if (newValue === '' && oldNode.firstChild.nodeValue === oldNode.placeholder) {
|
||||
return
|
||||
}
|
||||
|
||||
oldNode.firstChild.nodeValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
function updateAttribute (newNode, oldNode, name) {
|
||||
if (newNode[name] !== oldNode[name]) {
|
||||
oldNode[name] = newNode[name]
|
||||
if (newNode[name]) {
|
||||
oldNode.setAttribute(name, '')
|
||||
} else {
|
||||
oldNode.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@uhhm/buuh-html",
|
||||
"version": "8.0.0",
|
||||
"description": "HTML template literals that render to DOM in the browser and strings on the server",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"browser": "./browser.js",
|
||||
"default": "./server.js"
|
||||
},
|
||||
"./browser": "./browser.js",
|
||||
"./server": "./server.js",
|
||||
"./hydrate": "./hydrate.js",
|
||||
"./raw": "./raw.js",
|
||||
"./morph": "./morph.js"
|
||||
},
|
||||
"files": [
|
||||
"browser.js",
|
||||
"server.js",
|
||||
"hydrate.js",
|
||||
"raw.js",
|
||||
"morph.js"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"keywords": [
|
||||
"html",
|
||||
"template",
|
||||
"tagged-template",
|
||||
"server-side-rendering"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Ported from nanohtml 1.10.0 lib/raw-server.js (MIT)
|
||||
// Mark a string as pre-encoded so the html tag won't escape it.
|
||||
|
||||
export default function raw (tag) {
|
||||
// eslint-disable-next-line no-new-wrappers
|
||||
const wrapper = new String(tag)
|
||||
wrapper.__encoded = true
|
||||
return wrapper
|
||||
}
|
||||
|
||||
export { raw }
|
||||
@@ -0,0 +1,255 @@
|
||||
// Ported from nanohtml 1.10.0 lib/server.js (MIT) — https://github.com/choojs/nanohtml
|
||||
// Server-side tagged template. The browserify/babel transform branches of
|
||||
// the original are gone for good — this is a pure runtime tag.
|
||||
//
|
||||
// v8 shape: the tag builds a parts list — static/escaped string runs
|
||||
// interleaved with unresolved async holes (promises or async iterables in
|
||||
// child position). A fully-sync template collapses to one string and
|
||||
// toString() behaves exactly like v7. Async holes make the result
|
||||
// streamable: iterate it with `for await` (each chunk is a string) — that
|
||||
// is what choo's toStream() does. toString() on async content throws.
|
||||
// Async values are only allowed in child position; attribute values must
|
||||
// be synchronous.
|
||||
|
||||
const BOOL_PROPS = [
|
||||
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
|
||||
'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden',
|
||||
'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline',
|
||||
'readonly', 'required', 'reversed', 'selected'
|
||||
]
|
||||
|
||||
const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i')
|
||||
const handlerRx = /[^-a-z](on[a-z]+)=$/i
|
||||
const query = /(?:="|&)[^"]*=$/
|
||||
|
||||
// minimal parser state, only to distinguish child position from tag position
|
||||
const TEXT = 0
|
||||
const TAG = 1
|
||||
const DQ = 2
|
||||
const SQ = 3
|
||||
const COMMENT = 4
|
||||
|
||||
function scan (s, state) {
|
||||
for (let j = 0; j < s.length; j++) {
|
||||
const c = s[j]
|
||||
switch (state) {
|
||||
case TEXT:
|
||||
if (c === '<') {
|
||||
if (s.startsWith('!--', j + 1)) { state = COMMENT; j += 3 } else state = TAG
|
||||
}
|
||||
break
|
||||
case TAG:
|
||||
if (c === '>') state = TEXT
|
||||
else if (c === '"') state = DQ
|
||||
else if (c === "'") state = SQ
|
||||
break
|
||||
case DQ:
|
||||
if (c === '"') state = TAG
|
||||
break
|
||||
case SQ:
|
||||
if (c === "'") state = TAG
|
||||
break
|
||||
case COMMENT:
|
||||
if (c === '-' && s.startsWith('->', j + 1)) { state = TEXT; j += 2 }
|
||||
break
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function isThenable (value) {
|
||||
return value && typeof value.then === 'function'
|
||||
}
|
||||
|
||||
function isAsyncIterable (value) {
|
||||
return value && typeof value[Symbol.asyncIterator] === 'function'
|
||||
}
|
||||
|
||||
export class HtmlChunks {
|
||||
constructor (parts) {
|
||||
this.parts = parts // strings and { async: value } holes
|
||||
this.__encoded = true
|
||||
}
|
||||
|
||||
get async () {
|
||||
return this.parts.some((p) => typeof p !== 'string')
|
||||
}
|
||||
|
||||
toString () {
|
||||
let out = ''
|
||||
for (const part of this.parts) {
|
||||
if (typeof part !== 'string') {
|
||||
throw new Error(
|
||||
'@uhhm/buuh-html: this template has async content and cannot render synchronously — stream it (choo: use toStream() instead of toString())'
|
||||
)
|
||||
}
|
||||
out += part
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async * [Symbol.asyncIterator] () {
|
||||
for (const part of this.parts) {
|
||||
if (typeof part === 'string') {
|
||||
yield part
|
||||
} else {
|
||||
yield * resolveAsync(part.async)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve an async hole to string chunks, applying child-value semantics
|
||||
// to whatever it produces.
|
||||
async function * resolveAsync (value) {
|
||||
if (isThenable(value)) {
|
||||
yield * streamChild(await value)
|
||||
} else {
|
||||
for await (const item of value) {
|
||||
yield * streamChild(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function * streamChild (value) {
|
||||
if (value === null || value === undefined) return
|
||||
// HtmlChunks is itself async-iterable: its chunks are final HTML and
|
||||
// must never re-enter escaping, so check it before the generic paths
|
||||
if (value instanceof HtmlChunks) {
|
||||
yield * value[Symbol.asyncIterator]()
|
||||
return
|
||||
}
|
||||
if (isThenable(value) || isAsyncIterable(value)) {
|
||||
yield * resolveAsync(value)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) yield * streamChild(item)
|
||||
return
|
||||
}
|
||||
const str = handleChild(value)
|
||||
if (str !== '') yield str
|
||||
}
|
||||
|
||||
export default function html (pieces, ...values) {
|
||||
const parts = []
|
||||
let acc = ''
|
||||
let boolMatch
|
||||
let state = TEXT
|
||||
|
||||
for (let i = 0; i < pieces.length; i++) {
|
||||
const piece = pieces[i]
|
||||
state = scan(piece, state)
|
||||
if (i === pieces.length - 1) {
|
||||
acc += piece
|
||||
break
|
||||
}
|
||||
|
||||
const value = values[i]
|
||||
|
||||
if (state === TEXT) {
|
||||
// child position: this is where async content may live
|
||||
// (HtmlChunks is itself async-iterable, so check it first)
|
||||
acc += piece
|
||||
if (value instanceof HtmlChunks) {
|
||||
// splice nested parts so inner async holes stream through
|
||||
for (const part of value.parts) {
|
||||
if (typeof part === 'string') acc += part
|
||||
else { parts.push(acc, part); acc = '' }
|
||||
}
|
||||
} else if (isThenable(value) || isAsyncIterable(value)) {
|
||||
parts.push(acc, { async: value })
|
||||
acc = ''
|
||||
} else {
|
||||
acc += handleChild(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// tag position: attributes are synchronous, always
|
||||
if (isThenable(value) || isAsyncIterable(value)) {
|
||||
throw new Error('@uhhm/buuh-html: async values are only allowed in child position, not in attributes')
|
||||
}
|
||||
|
||||
// Event handlers are behavior, not markup: `onclick=${fn}` renders
|
||||
// nothing at all, matching the browser renderer (which sets the
|
||||
// handler as a property). v7 serialized a useless onclick="".
|
||||
const handlerMatch = handlerRx.exec(piece)
|
||||
if (handlerMatch && typeof value === 'function') {
|
||||
acc += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
|
||||
continue
|
||||
}
|
||||
|
||||
if ((boolMatch = boolPropRx.exec(piece))) {
|
||||
acc += piece.slice(0, boolMatch.index)
|
||||
if (value) {
|
||||
acc += boolMatch[1] + '="' + boolMatch[2] + '"'
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const handled = handleValue(value)
|
||||
if (piece[piece.length - 1] === '=' && !query.test(piece)) {
|
||||
acc += piece + '"' + handled + '"'
|
||||
} else {
|
||||
acc += piece + handled
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(acc)
|
||||
return new HtmlChunks(parts)
|
||||
}
|
||||
|
||||
// child-position (text) value → string
|
||||
function handleChild (value) {
|
||||
if (Array.isArray(value)) return value.map(handleChild).join('')
|
||||
if (typeof value === 'function') return ''
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value.__encoded) return value.toString()
|
||||
if (typeof value === 'object' && typeof value.outerHTML === 'string') return value.outerHTML
|
||||
return escape(value.toString())
|
||||
}
|
||||
|
||||
// tag-position (attribute) value → string; keeps v7 semantics including
|
||||
// object spread and arrays
|
||||
function handleValue (value) {
|
||||
// Handle each item in array as potential unescaped value
|
||||
if (Array.isArray(value)) return value.map(handleValue).join('')
|
||||
|
||||
if (typeof value === 'function') return ''
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value.__encoded) return value.toString()
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value.outerHTML === 'string') return value.outerHTML
|
||||
return Object.keys(value).reduce(function (str, key) {
|
||||
// handlers in spread objects are behavior too — never serialized
|
||||
if (typeof value[key] === 'function') return str
|
||||
|
||||
if (str.length > 0) str += ' '
|
||||
|
||||
if (BOOL_PROPS.indexOf(key) !== -1) {
|
||||
if (value[key]) {
|
||||
return str + key + '="' + key + '"'
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
const handled = handleValue(value[key])
|
||||
return str + key + '="' + handled + '"'
|
||||
}, '')
|
||||
}
|
||||
|
||||
return escape(value.toString())
|
||||
}
|
||||
|
||||
function escape (str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export { html }
|
||||
@@ -0,0 +1,148 @@
|
||||
// Browser renderer, exercised in happy-dom. Globals are installed before
|
||||
// the dynamic import because browser.js touches `document` at parse time.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let html, raw, morph
|
||||
|
||||
before(async () => {
|
||||
const win = new Window()
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
;({ default: html } = await import('../browser.js'))
|
||||
;({ default: raw } = await import('../raw.js'))
|
||||
;({ default: morph } = await import('../morph.js'))
|
||||
})
|
||||
|
||||
test('renders an element with text', () => {
|
||||
const el = html`<p>hello</p>`
|
||||
assert.strictEqual(el.tagName, 'P')
|
||||
assert.strictEqual(el.textContent, 'hello')
|
||||
})
|
||||
|
||||
test('interpolated strings become text, never markup', () => {
|
||||
const el = html`<p>${'<script>alert(1)</script>'}</p>`
|
||||
assert.strictEqual(el.querySelector('script'), null)
|
||||
assert.strictEqual(el.textContent, '<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
test('the same call site reuses its parsed template', () => {
|
||||
const view = (x) => html`<span>${x}</span>`
|
||||
const a = view('a')
|
||||
const b = view('b')
|
||||
assert.notStrictEqual(a, b, 'each render is a fresh node')
|
||||
assert.strictEqual(a.textContent, 'a')
|
||||
assert.strictEqual(b.textContent, 'b')
|
||||
})
|
||||
|
||||
test('nested templates embed as nodes', () => {
|
||||
const inner = html`<em>hi</em>`
|
||||
const el = html`<p>${inner}</p>`
|
||||
assert.strictEqual(el.querySelector('em'), inner)
|
||||
})
|
||||
|
||||
test('arrays render as siblings', () => {
|
||||
const items = ['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)
|
||||
const el = html`<ul>${items}</ul>`
|
||||
assert.strictEqual(el.querySelectorAll('li').length, 3)
|
||||
assert.strictEqual(el.textContent, 'abc')
|
||||
})
|
||||
|
||||
test('null and undefined children render as nothing', () => {
|
||||
const el = html`<p>${null}${undefined}ok</p>`
|
||||
assert.strictEqual(el.textContent, 'ok')
|
||||
})
|
||||
|
||||
test('numbers and booleans stringify', () => {
|
||||
const el = html`<p>${42} ${false}</p>`
|
||||
assert.strictEqual(el.textContent, '42 false')
|
||||
})
|
||||
|
||||
test('raw() parses vouched HTML', () => {
|
||||
const el = html`<p>${raw('<em>hi</em>')}</p>`
|
||||
assert.ok(el.querySelector('em'))
|
||||
})
|
||||
|
||||
test('unquoted attribute holes', () => {
|
||||
const el = html`<div class=${'a b'}></div>`
|
||||
assert.strictEqual(el.getAttribute('class'), 'a b')
|
||||
})
|
||||
|
||||
test('quoted attribute holes compose with static text', () => {
|
||||
const el = html`<div class="btn ${'primary'} lg"></div>`
|
||||
assert.strictEqual(el.getAttribute('class'), 'btn primary lg')
|
||||
})
|
||||
|
||||
test('boolean attributes toggle', () => {
|
||||
const on = html`<input disabled=${true} />`
|
||||
const off = html`<input disabled=${false} />`
|
||||
assert.ok(on.hasAttribute('disabled'))
|
||||
assert.strictEqual(on.disabled, true)
|
||||
assert.ok(!off.hasAttribute('disabled'))
|
||||
})
|
||||
|
||||
test('event handlers attach as properties', () => {
|
||||
let clicks = 0
|
||||
const el = html`<button onclick=${() => clicks++}>go</button>`
|
||||
assert.strictEqual(el.getAttribute('onclick'), null, 'no handler serialized to markup')
|
||||
el.click()
|
||||
assert.strictEqual(clicks, 1)
|
||||
})
|
||||
|
||||
test('spread objects set attributes and handlers', () => {
|
||||
let clicks = 0
|
||||
const el = html`<div ${{ class: 'x', hidden: true, onclick: () => clicks++ }}></div>`
|
||||
assert.strictEqual(el.getAttribute('class'), 'x')
|
||||
assert.ok(el.hasAttribute('hidden'))
|
||||
el.click()
|
||||
assert.strictEqual(clicks, 1)
|
||||
})
|
||||
|
||||
test('value attribute also sets the property', () => {
|
||||
const el = html`<input value=${'typed'} />`
|
||||
assert.strictEqual(el.value, 'typed')
|
||||
})
|
||||
|
||||
test('multiple roots return a fragment', () => {
|
||||
const frag = html`<li>a</li><li>b</li>`
|
||||
assert.strictEqual(frag.nodeType, 11)
|
||||
assert.strictEqual(frag.childNodes.length, 2)
|
||||
})
|
||||
|
||||
test('leading/trailing whitespace still unwraps a single root', () => {
|
||||
const el = html`
|
||||
<p>hi</p>
|
||||
`
|
||||
assert.strictEqual(el.tagName, 'P')
|
||||
})
|
||||
|
||||
test('document-level roots survive parsing, attributes included', () => {
|
||||
// <template> silently drops <body>/<head>/<html>; we must not
|
||||
const el = html`<body class=${'app'}><h1>${'hi'}</h1></body>`
|
||||
assert.strictEqual(el.tagName, 'BODY')
|
||||
assert.strictEqual(el.getAttribute('class'), 'app')
|
||||
assert.strictEqual(el.querySelector('h1').textContent, 'hi')
|
||||
})
|
||||
|
||||
test('re-render + morph updates in place and keeps handlers', () => {
|
||||
let clicks = 0
|
||||
const view = (n) => html`<div><button onclick=${() => clicks++}>count ${n}</button></div>`
|
||||
const tree = view(0)
|
||||
document.body.appendChild(tree)
|
||||
|
||||
morph(tree, view(1))
|
||||
assert.strictEqual(tree.textContent, 'count 1', 'text updated')
|
||||
tree.querySelector('button').click()
|
||||
assert.strictEqual(clicks, 1, 'handler from new tree is live')
|
||||
document.body.removeChild(tree)
|
||||
})
|
||||
|
||||
test('server and browser renderers agree on simple markup', async () => {
|
||||
// spot check: identical template, identical serialization
|
||||
const { default: serverHtml } = await import('../server.js')
|
||||
const browserEl = html`<p class=${'x'}>hi ${'there'}</p>`
|
||||
const serverStr = serverHtml`<p class=${'x'}>hi ${'there'}</p>`
|
||||
assert.strictEqual(browserEl.outerHTML, serverStr.toString())
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
// Hydration: adoption semantics and mismatch detection.
|
||||
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { Window } from 'happy-dom'
|
||||
|
||||
let html, hydrate
|
||||
|
||||
before(async () => {
|
||||
const win = new Window()
|
||||
globalThis.window = win
|
||||
globalThis.document = win.document
|
||||
;({ default: html } = await import('../browser.js'))
|
||||
;({ default: hydrate } = await import('../hydrate.js'))
|
||||
})
|
||||
|
||||
test('agreeing trees hydrate without a mismatch report', () => {
|
||||
const a = html`<div class="x"><h1>hi</h1><p>body</p></div>`
|
||||
const b = html`<div class="x"><h1>hi</h1><p>body</p></div>`
|
||||
let reported = null
|
||||
hydrate(a, b, { onMismatch: (d) => { reported = d } })
|
||||
assert.strictEqual(reported, null)
|
||||
})
|
||||
|
||||
test('adoption: matching child nodes keep their identity through hydrate', () => {
|
||||
const server = html`<div><h1>hi</h1><input value="typed by user" /></div>`
|
||||
const serverH1 = server.querySelector('h1')
|
||||
const serverInput = server.querySelector('input')
|
||||
|
||||
const client = html`<div><h1>hi</h1><input value="typed by user" /></div>`
|
||||
hydrate(server, client)
|
||||
|
||||
assert.strictEqual(server.querySelector('h1'), serverH1, 'h1 adopted in place')
|
||||
assert.strictEqual(server.querySelector('input'), serverInput, 'input adopted in place')
|
||||
})
|
||||
|
||||
test('text mismatches are reported with a path, client wins', () => {
|
||||
const server = html`<div><h1>count is 0</h1></div>`
|
||||
const client = html`<div><h1>count is 7</h1></div>`
|
||||
let reported = null
|
||||
hydrate(server, client, { onMismatch: (d) => { reported = d } })
|
||||
|
||||
assert.strictEqual(reported.reason, 'text')
|
||||
assert.match(reported.path, /div > h1/)
|
||||
assert.strictEqual(reported.server, 'count is 0')
|
||||
assert.strictEqual(reported.client, 'count is 7')
|
||||
assert.strictEqual(server.textContent, 'count is 7', 'client render won')
|
||||
})
|
||||
|
||||
test('attribute mismatches are reported', () => {
|
||||
const server = html`<div><a href="/old">go</a></div>`
|
||||
const client = html`<div><a href="/new">go</a></div>`
|
||||
let reported = null
|
||||
hydrate(server, client, { onMismatch: (d) => { reported = d } })
|
||||
|
||||
assert.strictEqual(reported.reason, 'attribute')
|
||||
assert.match(reported.server, /\/old/)
|
||||
assert.match(reported.client, /\/new/)
|
||||
})
|
||||
|
||||
test('child count mismatches are reported', () => {
|
||||
const server = html`<ul><li>a</li></ul>`
|
||||
const client = html`<ul><li>a</li><li>b</li></ul>`
|
||||
let reported = null
|
||||
hydrate(server, client, { onMismatch: (d) => { reported = d } })
|
||||
|
||||
assert.strictEqual(reported.reason, 'children')
|
||||
})
|
||||
|
||||
test('server-only <script> elements are not reported as mismatches', () => {
|
||||
// server pages carry state/analytics scripts the client never renders;
|
||||
// they are spent by hydration time (morph may drop them — harmless)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.innerHTML = '<div><h1>hi</h1><script>window.x=1</script></div>'
|
||||
const server = wrap.firstChild
|
||||
const client = html`<div><h1>hi</h1></div>`
|
||||
let reported = null
|
||||
hydrate(server, client, { onMismatch: (d) => { reported = d } })
|
||||
assert.strictEqual(reported, null)
|
||||
})
|
||||
|
||||
test('mismatch detection is optional and hydrate still morphs without it', () => {
|
||||
const server = html`<p>old</p>`
|
||||
const client = html`<p>new</p>`
|
||||
hydrate(server, client)
|
||||
assert.strictEqual(server.textContent, 'new')
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Server renderer behavior, matching nanohtml 1.x server semantics.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import html from '@uhhm/buuh-html'
|
||||
import raw from '@uhhm/buuh-html/raw'
|
||||
|
||||
test('renders a template to a string', () => {
|
||||
const res = html`<p>hello</p>`
|
||||
assert.strictEqual(res.toString(), '<p>hello</p>')
|
||||
})
|
||||
|
||||
test('escapes interpolated text', () => {
|
||||
const res = html`<p>${'<script>alert(1)</script>'}</p>`
|
||||
assert.strictEqual(res.toString(), '<p><script>alert(1)</script></p>')
|
||||
})
|
||||
|
||||
test('raw() bypasses escaping', () => {
|
||||
const res = html`<p>${raw('<em>hi</em>')}</p>`
|
||||
assert.strictEqual(res.toString(), '<p><em>hi</em></p>')
|
||||
})
|
||||
|
||||
test('nested templates are not double-encoded', () => {
|
||||
const inner = html`<em>&</em>`
|
||||
const res = html`<p>${inner}</p>`
|
||||
assert.strictEqual(res.toString(), '<p><em>&</em></p>')
|
||||
})
|
||||
|
||||
test('arrays of children are joined', () => {
|
||||
const items = ['a', 'b'].map((x) => html`<li>${x}</li>`)
|
||||
const res = html`<ul>${items}</ul>`
|
||||
assert.strictEqual(res.toString(), '<ul><li>a</li><li>b</li></ul>')
|
||||
})
|
||||
|
||||
test('unquoted attribute interpolation is quoted', () => {
|
||||
const res = html`<div class=${'a b'}></div>`
|
||||
assert.strictEqual(res.toString(), '<div class="a b"></div>')
|
||||
})
|
||||
|
||||
test('boolean attributes render when truthy and drop when falsy', () => {
|
||||
const on = html`<input disabled=${true} />`
|
||||
const off = html`<input disabled=${false} />`
|
||||
assert.strictEqual(on.toString(), '<input disabled="disabled" />')
|
||||
assert.strictEqual(off.toString(), '<input />'.replace(' ', ' '))
|
||||
})
|
||||
|
||||
test('event handler functions leave no trace in markup', () => {
|
||||
const res = html`<button onclick=${() => {}}>go</button>`
|
||||
assert.strictEqual(res.toString(), '<button >go</button>')
|
||||
})
|
||||
|
||||
test('handler-shaped attributes with non-function values still render', () => {
|
||||
const res = html`<a onclick=${'confirm()'}>x</a>`
|
||||
assert.strictEqual(res.toString(), '<a onclick="confirm()">x</a>')
|
||||
})
|
||||
|
||||
test('spread-style object interpolation renders attributes', () => {
|
||||
const res = html`<div ${{ class: 'x', hidden: true }}></div>`
|
||||
assert.strictEqual(res.toString(), '<div class="x" hidden="hidden"></div>')
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Async holes and streaming in the server tag.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import html from '@uhhm/buuh-html'
|
||||
import raw from '@uhhm/buuh-html/raw'
|
||||
|
||||
async function collect (chunks) {
|
||||
const out = []
|
||||
for await (const chunk of chunks) out.push(chunk)
|
||||
return out
|
||||
}
|
||||
|
||||
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms))
|
||||
|
||||
test('sync templates stream as a single chunk equal to toString()', async () => {
|
||||
const res = html`<p>hello ${'world'}</p>`
|
||||
const chunks = await collect(res)
|
||||
assert.deepStrictEqual(chunks, ['<p>hello world</p>'])
|
||||
assert.strictEqual(chunks.join(''), res.toString())
|
||||
})
|
||||
|
||||
test('a promise child splits the stream at the hole', async () => {
|
||||
const res = html`<main><h1>shell</h1>${wait(10, 'late')}<footer>end</footer></main>`
|
||||
const chunks = await collect(res)
|
||||
assert.strictEqual(chunks[0], '<main><h1>shell</h1>', 'everything before the hole flushes first')
|
||||
assert.strictEqual(chunks.join(''), '<main><h1>shell</h1>late<footer>end</footer></main>')
|
||||
})
|
||||
|
||||
test('toString() on async content throws with streaming guidance', () => {
|
||||
const res = html`<p>${Promise.resolve('x')}</p>`
|
||||
assert.throws(() => res.toString(), /toStream/)
|
||||
})
|
||||
|
||||
test('resolved promises can carry templates, arrays, and raw', async () => {
|
||||
const res = html`<ul>${wait(5, [html`<li>a</li>`, html`<li>${'<b>'}</li>`, raw('<li>raw</li>')])}</ul>`
|
||||
const chunks = await collect(res)
|
||||
assert.strictEqual(chunks.join(''), '<ul><li>a</li><li><b></li><li>raw</li></ul>')
|
||||
})
|
||||
|
||||
test('nested templates with async holes stream through their parents', async () => {
|
||||
const inner = html`<section>${wait(5, 'inner-late')}</section>`
|
||||
const res = html`<div><p>early</p>${inner}</div>`
|
||||
const chunks = await collect(res)
|
||||
assert.strictEqual(chunks[0], '<div><p>early</p><section>', 'parent flushes up to the nested hole')
|
||||
assert.strictEqual(chunks.join(''), '<div><p>early</p><section>inner-late</section></div>')
|
||||
})
|
||||
|
||||
test('async iterable children stream chunk by chunk', async () => {
|
||||
async function * rows () {
|
||||
for (let i = 0; i < 3; i++) yield html`<li>${i}</li>`
|
||||
}
|
||||
const res = html`<ul>${rows()}</ul>`
|
||||
const chunks = await collect(res)
|
||||
assert.strictEqual(chunks.join(''), '<ul><li>0</li><li>1</li><li>2</li></ul>')
|
||||
assert.ok(chunks.length >= 4, 'each yielded row is its own chunk')
|
||||
})
|
||||
|
||||
test('multiple async holes resolve in document order', async () => {
|
||||
// the second promise resolves first; output order must follow the document
|
||||
const res = html`<div>${wait(20, 'A')}|${wait(5, 'B')}</div>`
|
||||
const chunks = await collect(res)
|
||||
assert.strictEqual(chunks.join(''), '<div>A|B</div>')
|
||||
})
|
||||
|
||||
test('async values in attribute position throw immediately', () => {
|
||||
assert.throws(
|
||||
() => html`<div class=${Promise.resolve('x')}></div>`,
|
||||
/child position/
|
||||
)
|
||||
})
|
||||
|
||||
test('resolved async strings are escaped like any child', async () => {
|
||||
const res = html`<p>${wait(5, '<script>')}</p>`
|
||||
const chunks = await collect(res)
|
||||
assert.strictEqual(chunks.join(''), '<p><script></p>')
|
||||
})
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
// choo-migrate [--dry] <files or directories…>
|
||||
// Rewrites choo v7 sources toward v8: CJS → ESM for simple top-level
|
||||
// patterns, package specifiers remapped, and a per-file report of what
|
||||
// needs human hands. --dry prints the report without writing.
|
||||
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { join, extname } from 'node:path'
|
||||
|
||||
import transform from './lib/transform.js'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dry = args.includes('--dry')
|
||||
const targets = args.filter((a) => a !== '--dry')
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.log('usage: choo-migrate [--dry] <files or directories…>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const SKIP = new Set(['node_modules', '.git', 'dist', 'coverage'])
|
||||
|
||||
async function * walk (path) {
|
||||
const info = await stat(path)
|
||||
if (info.isFile()) {
|
||||
if (extname(path) === '.js' || extname(path) === '.mjs') yield path
|
||||
return
|
||||
}
|
||||
for (const entry of await readdir(path)) {
|
||||
if (SKIP.has(entry)) continue
|
||||
yield * walk(join(path, entry))
|
||||
}
|
||||
}
|
||||
|
||||
let changedCount = 0
|
||||
for (const target of targets) {
|
||||
for await (const file of walk(target)) {
|
||||
const source = await readFile(file, 'utf8')
|
||||
const { code, changed, notes } = transform(source)
|
||||
if (!changed && notes.length === 0) continue
|
||||
|
||||
console.log(`\n${file}${changed ? (dry ? ' (would change)' : ' (rewritten)') : ''}`)
|
||||
for (const note of notes) console.log(` ⚠ ${note}`)
|
||||
|
||||
if (changed && !dry) {
|
||||
await writeFile(file, code)
|
||||
changedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dry ? 'dry run — no files written' : changedCount + ' file(s) rewritten'}. Remember: package.json needs "type": "module" and deps on @uhhm/*.`)
|
||||
@@ -0,0 +1,98 @@
|
||||
// The v7 → v8 source transform. Deliberately regex-based and honest about
|
||||
// it: simple top-level CJS patterns are converted mechanically, package
|
||||
// specifiers are remapped, and everything the regexes cannot prove is
|
||||
// reported as a note instead of guessed at.
|
||||
|
||||
// old specifier → new specifier
|
||||
export const SPECIFIERS = {
|
||||
choo: '@uhhm/buuh',
|
||||
'choo/html': '@uhhm/buuh-html',
|
||||
'choo/html/raw': '@uhhm/buuh-html/raw',
|
||||
'choo/component': '@uhhm/buuh-component',
|
||||
nanohtml: '@uhhm/buuh-html',
|
||||
'nanohtml/raw': '@uhhm/buuh-html/raw',
|
||||
nanomorph: '@uhhm/buuh-html/morph',
|
||||
nanocomponent: '@uhhm/buuh-component',
|
||||
'choo-devtools': '@uhhm/buuh-devtools'
|
||||
}
|
||||
|
||||
// old specifier → guidance (no direct replacement package)
|
||||
export const RETIRED = {
|
||||
nanobus: "built into @uhhm/buuh — use app.emitter, or import Nanobus from '@uhhm/buuh' internals is no longer needed",
|
||||
nanorouter: 'built into @uhhm/buuh — app.route covers it',
|
||||
nanohref: 'built into @uhhm/buuh — link handling is automatic',
|
||||
nanotiming: "built into @uhhm/buuh — import from '@uhhm/buuh/timing' if you need it directly",
|
||||
nanoraf: 'retired — use requestAnimationFrame, or rely on choo render batching',
|
||||
nanoquery: 'retired — state.query is built in; use URLSearchParams directly elsewhere',
|
||||
nanoassert: 'retired — use plain checks or node:assert',
|
||||
nanolru: 'retired — the component cache is built into @uhhm/buuh',
|
||||
'choo-lazy-route': "replaced by lazy() from '@uhhm/buuh': app.route('/x', lazy(() => import('./x.js')))",
|
||||
'choo-service-worker': 'kept as a concept; bankai v10 will inject the asset manifest — check docs before migrating this one'
|
||||
}
|
||||
|
||||
export default function transform (source) {
|
||||
const notes = []
|
||||
let code = source
|
||||
|
||||
// --- CJS → ESM, simple top-level forms ---
|
||||
|
||||
// const { a, b } = require('x')
|
||||
code = code.replace(
|
||||
/^(\s*)(?:var|let|const)\s*\{([^}]+)\}\s*=\s*require\((['"])([^'"]+)\3\)\s*;?[^\S\n]*$/gm,
|
||||
(m, indent, names, q, spec) => `${indent}import {${names}} from '${spec}'`
|
||||
)
|
||||
|
||||
// const X = require('x').default / .thing
|
||||
code = code.replace(
|
||||
/^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\.default\s*;?[^\S\n]*$/gm,
|
||||
(m, indent, name, q, spec) => `${indent}import ${name} from '${spec}'`
|
||||
)
|
||||
code = code.replace(
|
||||
/^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\.([A-Za-z_$][\w$]*)\s*;?[^\S\n]*$/gm,
|
||||
(m, indent, name, q, spec, prop) => `${indent}import { ${prop} as ${name} } from '${spec}'`
|
||||
)
|
||||
|
||||
// const X = require('x')
|
||||
code = code.replace(
|
||||
/^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\s*;?[^\S\n]*$/gm,
|
||||
(m, indent, name, q, spec) => `${indent}import ${name} from '${spec}'`
|
||||
)
|
||||
|
||||
// bare require('x') statements (side-effect imports)
|
||||
code = code.replace(
|
||||
/^(\s*)require\((['"])([^'"]+)\2\)\s*;?[^\S\n]*$/gm,
|
||||
(m, indent, q, spec) => `${indent}import '${spec}'`
|
||||
)
|
||||
|
||||
// module.exports = X
|
||||
code = code.replace(/^(\s*)module\.exports\s*=\s*/gm, (m, indent, offset) => {
|
||||
return `${indent}export default `
|
||||
})
|
||||
|
||||
// --- specifier remapping (imports and any requires we couldn't convert) ---
|
||||
|
||||
code = code.replace(
|
||||
/(from\s*|import\s*\(\s*|import\s+|require\s*\(\s*)(['"])([^'"]+)\2/g,
|
||||
(m, lead, q, spec) => {
|
||||
if (SPECIFIERS[spec]) return `${lead}${q}${SPECIFIERS[spec]}${q}`
|
||||
if (RETIRED[spec]) {
|
||||
notes.push(`'${spec}': ${RETIRED[spec]}`)
|
||||
}
|
||||
return m
|
||||
}
|
||||
)
|
||||
|
||||
// --- things we refuse to guess at ---
|
||||
|
||||
if (/module\.exports\.[A-Za-z_$]/.test(code)) {
|
||||
notes.push('module.exports.<name> assignments found — convert to named `export` statements by hand')
|
||||
}
|
||||
if (/\brequire\s*\(/.test(code)) {
|
||||
notes.push('require() calls remain (non-top-level or dynamic) — convert to import/await import() by hand')
|
||||
}
|
||||
if (/\bexports\.[A-Za-z_$]/.test(code)) {
|
||||
notes.push('bare exports.<name> assignments found — convert to named `export` statements by hand')
|
||||
}
|
||||
|
||||
return { code, changed: code !== source, notes: [...new Set(notes)] }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@uhhm/buuh-migrate",
|
||||
"version": "8.0.0",
|
||||
"description": "Codemod: migrate choo v7 apps to buuh (choo v8) (ESM, new package names)",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"buuh-migrate": "./cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./lib/transform.js"
|
||||
},
|
||||
"files": [
|
||||
"cli.js",
|
||||
"lib"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"repository": "https://project.uhhm.no/uhhm/buuh",
|
||||
"keywords": [
|
||||
"choo",
|
||||
"codemod",
|
||||
"migration"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import transform from '../lib/transform.js'
|
||||
|
||||
test('converts a classic choo v7 header to v8 ESM', () => {
|
||||
const src = [
|
||||
"var choo = require('choo')",
|
||||
"var html = require('choo/html')",
|
||||
"var devtools = require('choo-devtools')",
|
||||
'',
|
||||
'var app = choo()',
|
||||
"app.use(devtools())",
|
||||
'module.exports = app'
|
||||
].join('\n')
|
||||
|
||||
const { code, changed, notes } = transform(src)
|
||||
assert.ok(changed)
|
||||
assert.match(code, /import choo from '@uhhm\/buuh'/)
|
||||
assert.match(code, /import html from '@uhhm\/buuh-html'/)
|
||||
assert.match(code, /import devtools from '@uhhm\/buuh-devtools'/)
|
||||
assert.match(code, /export default app/)
|
||||
assert.deepStrictEqual(notes, [])
|
||||
})
|
||||
|
||||
test('destructured and property requires', () => {
|
||||
const src = [
|
||||
"const { render } = require('some-lib')",
|
||||
"const thing = require('other-lib').thing",
|
||||
"const dflt = require('third-lib').default",
|
||||
"require('./side-effect')"
|
||||
].join('\n')
|
||||
|
||||
const { code } = transform(src)
|
||||
assert.match(code, /import { render } from 'some-lib'/)
|
||||
assert.match(code, /import { thing as thing } from 'other-lib'/)
|
||||
assert.match(code, /import dflt from 'third-lib'/)
|
||||
assert.match(code, /import '\.\/side-effect'/)
|
||||
})
|
||||
|
||||
test('already-ESM sources get specifiers remapped', () => {
|
||||
const src = [
|
||||
"import choo from 'choo'",
|
||||
"import html from 'nanohtml'",
|
||||
"import raw from 'nanohtml/raw'",
|
||||
"import morph from 'nanomorph'",
|
||||
"import Component from 'nanocomponent'"
|
||||
].join('\n')
|
||||
|
||||
const { code } = transform(src)
|
||||
assert.match(code, /from '@uhhm\/buuh'/)
|
||||
assert.match(code, /from '@uhhm\/buuh-html'\n/)
|
||||
assert.match(code, /from '@uhhm\/buuh-html\/raw'/)
|
||||
assert.match(code, /from '@uhhm\/buuh-html\/morph'/)
|
||||
assert.match(code, /from '@uhhm\/buuh-component'/)
|
||||
})
|
||||
|
||||
test('retired packages produce notes, not rewrites', () => {
|
||||
const src = [
|
||||
"var nanobus = require('nanobus')",
|
||||
"var lazyRoute = require('choo-lazy-route')"
|
||||
].join('\n')
|
||||
|
||||
const { code, notes } = transform(src)
|
||||
assert.match(code, /from 'nanobus'/, 'retired specifier left for the human')
|
||||
assert.ok(notes.some((n) => n.includes('nanobus')))
|
||||
assert.ok(notes.some((n) => n.includes('lazy()')), 'choo-lazy-route points at lazy()')
|
||||
})
|
||||
|
||||
test('dynamic import() specifiers are remapped too', () => {
|
||||
const { code } = transform("const mod = await import('choo/html')")
|
||||
assert.match(code, /import\('@uhhm\/buuh-html'\)/)
|
||||
})
|
||||
|
||||
test('unconvertible CJS is reported honestly', () => {
|
||||
const src = [
|
||||
"module.exports.helper = function () {}",
|
||||
"function f () { const x = require('choo') }"
|
||||
].join('\n')
|
||||
|
||||
const { code, notes } = transform(src)
|
||||
assert.match(code, /require\('@uhhm\/buuh'\)/, 'specifier remapped even inside functions')
|
||||
assert.ok(notes.some((n) => n.includes('module.exports')))
|
||||
assert.ok(notes.some((n) => n.includes('require() calls remain')))
|
||||
})
|
||||
|
||||
test('non-choo sources pass through untouched', () => {
|
||||
const src = "import fs from 'node:fs'\nexport const x = 1\n"
|
||||
const { changed, notes } = transform(src)
|
||||
assert.strictEqual(changed, false)
|
||||
assert.deepStrictEqual(notes, [])
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// Build the single-file CDN bundle: everything a browser needs as one
|
||||
// minified ES module, for import-map use against any static host that
|
||||
// serves JavaScript with the right MIME type.
|
||||
//
|
||||
// npm run bundle → dist-cdn/buuh.js (+ sourcemap)
|
||||
//
|
||||
// <script type="importmap">
|
||||
// { "imports": { "buuh": "https://cdn.uhhm.no/buuh@8.0.0.js" } }
|
||||
// </script>
|
||||
// <script type="module">
|
||||
// import { choo, html } from 'buuh'
|
||||
// </script>
|
||||
|
||||
import { build } from 'vite'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const { version } = JSON.parse(await readFile(join(root, 'packages/core/package.json'), 'utf8'))
|
||||
|
||||
const VIRTUAL = '\0bundle:entry'
|
||||
|
||||
await build({
|
||||
appType: 'custom',
|
||||
logLevel: 'warn',
|
||||
plugins: [{
|
||||
name: 'bundle-entry',
|
||||
resolveId: (id) => id === 'bundle:entry' ? VIRTUAL : undefined,
|
||||
load: (id) => id === VIRTUAL
|
||||
? [
|
||||
"export { default as choo, Choo, lazy } from '@uhhm/buuh'",
|
||||
"export { default as html } from '@uhhm/buuh-html'",
|
||||
"export { default as raw } from '@uhhm/buuh-html/raw'",
|
||||
"export { default as morph } from '@uhhm/buuh-html/morph'",
|
||||
"export { default as hydrate } from '@uhhm/buuh-html/hydrate'",
|
||||
"export { default as Component } from '@uhhm/buuh-component'",
|
||||
"export { default as devtools } from '@uhhm/buuh-devtools'"
|
||||
].join('\n')
|
||||
: undefined
|
||||
}],
|
||||
build: {
|
||||
outDir: join(root, 'dist-cdn'),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
lib: false,
|
||||
rollupOptions: {
|
||||
input: { buuh: 'bundle:entry' },
|
||||
output: { entryFileNames: 'buuh.js' },
|
||||
preserveEntrySignatures: 'strict'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`bundled buuh ${version} → dist-cdn/buuh.js`)
|
||||
@@ -0,0 +1,64 @@
|
||||
// The size budget: what does the framework itself cost on the wire?
|
||||
// Bundles @uhhm/buuh + @uhhm/buuh-html (browser condition) minified via
|
||||
// the same Vite/Rolldown pipeline apps use, then reports min+gzip and
|
||||
// min+brotli. CI fails if min+gzip exceeds the budget.
|
||||
//
|
||||
// npm run size
|
||||
|
||||
import { build } from 'vite'
|
||||
import { readFile, readdir, rm, mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
|
||||
|
||||
// The promise the README makes; raise only with a paper trail.
|
||||
// Context: v7's "4kb" was choo alone — nanohtml lived in the browserify
|
||||
// transform. This number is the whole framework: core, html engine,
|
||||
// morph, hydrate, router, bus. Currently ~7.97 kB gzip; the headroom is
|
||||
// margin, not license.
|
||||
const BUDGET_GZIP = 8.5 * 1024
|
||||
|
||||
const VIRTUAL = '\0size:entry'
|
||||
|
||||
const outDir = await mkdtemp(join(tmpdir(), 'choo-size-'))
|
||||
await build({
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
plugins: [{
|
||||
name: 'size-entry',
|
||||
resolveId: (id) => id === 'size:entry' ? VIRTUAL : undefined,
|
||||
load: (id) => id === VIRTUAL
|
||||
? "import choo, { Choo, lazy } from '@uhhm/buuh'\n" +
|
||||
"import html from '@uhhm/buuh-html'\n" +
|
||||
"import raw from '@uhhm/buuh-html/raw'\n" +
|
||||
'window.__keep = { choo, Choo, lazy, html, raw }\n'
|
||||
: undefined
|
||||
}],
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: { input: { framework: 'size:entry' } }
|
||||
}
|
||||
})
|
||||
|
||||
const assets = await readdir(join(outDir, 'assets'))
|
||||
const file = assets.find((name) => name.endsWith('.js'))
|
||||
const code = await readFile(join(outDir, 'assets', file))
|
||||
await rm(outDir, { recursive: true, force: true })
|
||||
|
||||
const gz = gzipSync(code, { level: constants.Z_BEST_COMPRESSION }).length
|
||||
const br = brotliCompressSync(code, {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY }
|
||||
}).length
|
||||
|
||||
const kb = (n) => (n / 1024).toFixed(2) + ' kB'
|
||||
console.log(`@uhhm/buuh + @uhhm/buuh-html (browser, minified)`)
|
||||
console.log(` raw: ${kb(code.length)}`)
|
||||
console.log(` gzip: ${kb(gz)} (budget ${kb(BUDGET_GZIP)})`)
|
||||
console.log(` brotli: ${kb(br)}`)
|
||||
|
||||
if (gz > BUDGET_GZIP) {
|
||||
console.error(`\nsize budget exceeded: ${kb(gz)} > ${kb(BUDGET_GZIP)} min+gzip`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nwithin budget ✔')
|
||||
@@ -0,0 +1,72 @@
|
||||
// bankai v10 end-to-end in real Chromium: the production pipeline
|
||||
// (build → serve → SSR page hydrates and runs) and the dev server
|
||||
// (Vite middleware + virtual entry + streaming SSR).
|
||||
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import build from '../../packages/bankai/lib/build.js'
|
||||
import serve from '../../packages/bankai/lib/serve.js'
|
||||
import dev from '../../packages/bankai/lib/dev.js'
|
||||
|
||||
const repo = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const entry = join(repo, 'examples', 'counter', 'app.js')
|
||||
|
||||
let browser, outDir, prod, devSrv
|
||||
|
||||
before(async () => {
|
||||
browser = await chromium.launch()
|
||||
outDir = await mkdtemp(join(tmpdir(), 'bankai-e2e-'))
|
||||
await build({ entry, outDir, title: 'counter' })
|
||||
prod = await serve({ outDir, port: 0 })
|
||||
devSrv = await dev({ entry, port: 0, title: 'counter dev' })
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
await prod?.close()
|
||||
await devSrv?.close()
|
||||
if (outDir) await rm(outDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function openAndCount (origin) {
|
||||
const page = await browser.newPage()
|
||||
const errors = []
|
||||
const warnings = []
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text())
|
||||
if (msg.type() === 'warning') warnings.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', (err) => errors.push(String(err)))
|
||||
|
||||
await page.goto(origin + '/')
|
||||
await page.waitForFunction(() => document.querySelector('h1')?.textContent === 'count is 0')
|
||||
|
||||
await page.click('button')
|
||||
await page.waitForFunction(() => document.querySelector('h1')?.textContent === 'count is 1')
|
||||
|
||||
return { page, errors, warnings }
|
||||
}
|
||||
|
||||
test('production: built app serves, hydrates and runs with no errors', async () => {
|
||||
const { page, errors, warnings } = await openAndCount(prod.origin)
|
||||
|
||||
assert.deepStrictEqual(errors, [], 'no console errors')
|
||||
const mismatches = warnings.filter((w) => w.includes('markup differ'))
|
||||
assert.deepStrictEqual(mismatches, [], 'no hydration mismatch warnings')
|
||||
|
||||
const state = await page.evaluate(() => window.choo ? 'devtools' : typeof window.initialState)
|
||||
assert.strictEqual(state, 'undefined', 'choo consumed initialState (deleted after merge)')
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test('dev server: Vite-transformed entry hydrates and runs', async () => {
|
||||
const { page, errors } = await openAndCount(devSrv.origin)
|
||||
assert.deepStrictEqual(errors, [], 'no console errors')
|
||||
await page.close()
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Real-browser pass (Playwright Chromium): the zero-build page and the
|
||||
// SSR + hydration page, with console output treated as part of the spec —
|
||||
// no errors, no hydration warnings.
|
||||
//
|
||||
// Run with: npm run test:e2e (needs `npx playwright install chromium-headless-shell`)
|
||||
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import { startServer } from './serve.js'
|
||||
|
||||
let browser, ctx, srv
|
||||
|
||||
before(async () => {
|
||||
srv = await startServer()
|
||||
browser = await chromium.launch()
|
||||
ctx = await browser.newContext()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
await srv?.close()
|
||||
})
|
||||
|
||||
// renders are raf-batched, so poll instead of reading synchronously
|
||||
function waitForCount (page, text) {
|
||||
return page.waitForFunction(
|
||||
(t) => document.querySelector('h1')?.textContent === t,
|
||||
text,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
}
|
||||
|
||||
async function openPage (path) {
|
||||
const page = await ctx.newPage()
|
||||
const console_ = { errors: [], warnings: [] }
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') console_.errors.push(msg.text())
|
||||
if (msg.type() === 'warning') console_.warnings.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', (err) => console_.errors.push(String(err)))
|
||||
await page.goto(srv.origin + path)
|
||||
return { page, console_ }
|
||||
}
|
||||
|
||||
test('zero-build page: import map + native ESM, no bundler', async () => {
|
||||
const { page, console_ } = await openPage('/examples/counter/')
|
||||
|
||||
await waitForCount(page, 'count is 0')
|
||||
|
||||
await page.click('button')
|
||||
await waitForCount(page, 'count is 1')
|
||||
|
||||
await page.click('button')
|
||||
await page.click('button')
|
||||
await waitForCount(page, 'count is 3')
|
||||
|
||||
assert.deepStrictEqual(console_.errors, [], 'no console errors')
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test('SSR page: content before JavaScript, then live after hydration', async () => {
|
||||
// the raw response must already contain the rendered view
|
||||
const res = await fetch(srv.origin + '/ssr')
|
||||
const rawHtml = await res.text()
|
||||
assert.match(rawHtml, /count is 0/, 'server sent rendered markup')
|
||||
|
||||
const { page, console_ } = await openPage('/ssr')
|
||||
await waitForCount(page, 'count is 0')
|
||||
|
||||
// hydrated: the server DOM must now respond to clicks
|
||||
await page.click('button')
|
||||
await waitForCount(page, 'count is 1')
|
||||
|
||||
assert.deepStrictEqual(console_.errors, [], 'no console errors')
|
||||
const mismatches = console_.warnings.filter((w) => w.includes('markup differ'))
|
||||
assert.deepStrictEqual(mismatches, [], 'no hydration mismatch warnings')
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test('SSR page: server DOM is adopted, not replaced', async () => {
|
||||
const { page } = await openPage('/ssr')
|
||||
await waitForCount(page, 'count is 0')
|
||||
|
||||
// Prove adoption by a surviving expando: mark the node, force a real
|
||||
// render, and confirm the same node object still holds the marker.
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('h1').__marker = 'server-node'
|
||||
})
|
||||
await page.click('button')
|
||||
await waitForCount(page, 'count is 1')
|
||||
const marker = await page.evaluate(() => document.querySelector('h1').__marker)
|
||||
assert.strictEqual(marker, 'server-node', 'h1 survived that render in place')
|
||||
await page.close()
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// Test server for e2e runs: static files from the repo root plus /ssr,
|
||||
// which server-renders the counter app and serves a hydration page — the
|
||||
// same app module the import map hands to the browser.
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, normalize, extname, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import createApp from '../../examples/counter/app.js'
|
||||
import createStreamingApp from '../../examples/streaming/app.js'
|
||||
|
||||
const root = normalize(join(dirname(fileURLToPath(import.meta.url)), '..', '..'))
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8'
|
||||
}
|
||||
|
||||
const IMPORT_MAP = JSON.stringify({
|
||||
imports: {
|
||||
'@uhhm/buuh': '/packages/core/index.js',
|
||||
'@uhhm/buuh/timing': '/packages/core/lib/timing.js',
|
||||
'@uhhm/buuh-html': '/packages/html/browser.js',
|
||||
'@uhhm/buuh-html/raw': '/packages/html/raw.js',
|
||||
'@uhhm/buuh-html/morph': '/packages/html/morph.js',
|
||||
'@uhhm/buuh-html/hydrate': '/packages/html/hydrate.js'
|
||||
}
|
||||
})
|
||||
|
||||
function ssrPage () {
|
||||
const body = createApp().toString('/')
|
||||
// When a view owns <body>, scripts belong in <head>: type="module" is
|
||||
// deferred by definition, and body must contain only what the view
|
||||
// renders or hydration would (rightly) flag the extra nodes.
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>choo v8 ssr counter</title>
|
||||
<script type="importmap">${IMPORT_MAP}</script>
|
||||
<script type="module">
|
||||
import createApp from '/examples/counter/app.js'
|
||||
createApp().mount('body')
|
||||
</script>
|
||||
</head>
|
||||
${body}
|
||||
</html>`
|
||||
}
|
||||
|
||||
export function startServer () {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
|
||||
if (url.pathname === '/ssr') {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
res.end(ssrPage())
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/stream') {
|
||||
const delay = Number(url.searchParams.get('delay')) || 500
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||
// padding nudges browsers to start parsing before the stream closes
|
||||
res.write('<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>choo streams</title></head>\n' + '<!-- ' + ' '.repeat(1024) + ' -->\n')
|
||||
const body = Readable.fromWeb(createStreamingApp({ delay }).toStream('/'))
|
||||
body.pipe(res, { end: false })
|
||||
body.on('end', () => res.end('\n</html>'))
|
||||
body.on('error', () => res.destroy())
|
||||
return
|
||||
}
|
||||
|
||||
let pathname = decodeURIComponent(url.pathname)
|
||||
if (pathname.endsWith('/')) pathname += 'index.html'
|
||||
const file = normalize(join(root, pathname))
|
||||
if (!file.startsWith(root)) {
|
||||
res.writeHead(403).end()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await readFile(file)
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[extname(file)] || 'application/octet-stream'
|
||||
})
|
||||
res.end(data)
|
||||
} catch (e) {
|
||||
res.writeHead(404).end('not found: ' + pathname)
|
||||
}
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve({
|
||||
server,
|
||||
origin: `http://127.0.0.1:${server.address().port}`,
|
||||
close: () => new Promise((r) => server.close(r))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Streaming SSR, proven at two levels: raw chunk timing over HTTP, and
|
||||
// progressive DOM construction in real Chromium.
|
||||
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import { startServer } from './serve.js'
|
||||
|
||||
const DELAY = 500
|
||||
|
||||
let browser, srv
|
||||
|
||||
before(async () => {
|
||||
srv = await startServer()
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
await srv?.close()
|
||||
})
|
||||
|
||||
test('HTTP level: shell bytes arrive before the slow section resolves', async () => {
|
||||
const started = performance.now()
|
||||
const res = await fetch(`${srv.origin}/stream?delay=${DELAY}`)
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let sawShellAt = null
|
||||
let text = ''
|
||||
for await (const chunk of res.body) {
|
||||
text += decoder.decode(chunk, { stream: true })
|
||||
if (sawShellAt === null && text.includes('<h1>choo streams</h1>')) {
|
||||
sawShellAt = performance.now() - started
|
||||
assert.ok(!text.includes('id="slow"'), 'slow section not in the early bytes')
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(sawShellAt !== null, 'shell was seen')
|
||||
assert.ok(sawShellAt < DELAY, `shell arrived at ${sawShellAt.toFixed(0)}ms, before the ${DELAY}ms hole`)
|
||||
assert.match(text, /id="slow"/, 'slow section arrived in the same response')
|
||||
assert.match(text, /<\/html>/, 'document completed')
|
||||
})
|
||||
|
||||
test('browser level: Chromium builds the shell DOM before the stream ends', async () => {
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`${srv.origin}/stream?delay=${DELAY}`, { waitUntil: 'commit' })
|
||||
|
||||
// the shell must be in the DOM while the slow section is still absent
|
||||
await page.waitForSelector('h1', { timeout: DELAY - 100 })
|
||||
assert.strictEqual(await page.$('#slow'), null, 'slow section not parsed yet')
|
||||
|
||||
// then the same document grows the slow section without navigation
|
||||
await page.waitForSelector('#slow', { timeout: DELAY * 4 })
|
||||
assert.match(await page.textContent('#slow'), /500ms later/)
|
||||
await page.close()
|
||||
})
|
||||
Reference in New Issue
Block a user