@transclude/core 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +65 -14
- package/bin/build.js +22 -4
- package/bin/check.js +30 -8
- package/bin/dev.js +10 -2
- package/bin/release.js +19 -4
- package/package.json +13 -4
- package/skills/transclude/SKILL.md +219 -0
- package/skills/transclude/references/elements.md +206 -0
- package/skills/transclude/references/fragments.md +168 -0
- package/skills/transclude/references/server.md +155 -0
- package/src/address.js +9 -2
- package/src/app.js +110 -81
- package/src/compiler/ambient.js +99 -0
- package/src/compiler/bind.js +34 -27
- package/src/compiler/codegen.js +59 -35
- package/src/compiler/directives.js +29 -0
- package/src/compiler/expr.js +10 -1
- package/src/compiler/html.js +41 -0
- package/src/compiler/index.js +63 -11
- package/src/compiler/script.js +40 -11
- package/src/compiler/shim.js +24 -45
- package/src/compiler/types.js +32 -5
- package/src/csp.js +7 -1
- package/src/document.js +63 -12
- package/src/extract.js +9 -8
- package/src/negotiate.js +1 -1
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/project.js +29 -1
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/routes.js +1 -1
- package/src/runtime/index.js +12 -9
- package/src/server.js +2 -2
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +6 -3
- package/src/typecheck.js +105 -11
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Elements
|
|
2
|
+
|
|
3
|
+
An `.html` file in `app/elements/` becomes a custom element. The file name is
|
|
4
|
+
the tag name and it needs a dash.
|
|
5
|
+
|
|
6
|
+
## Light DOM, the default
|
|
7
|
+
|
|
8
|
+
No shadow root, no boundary. Page CSS reaches it, `<label for>` works, and no
|
|
9
|
+
JavaScript is shipped.
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<!-- app/elements/site-note.html -->
|
|
13
|
+
<script properties>
|
|
14
|
+
export default {
|
|
15
|
+
tone: 'neutral',
|
|
16
|
+
};
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
:scope {
|
|
21
|
+
display: block;
|
|
22
|
+
border-left: 3px solid var(--rule);
|
|
23
|
+
padding: 0.5rem 0.9rem;
|
|
24
|
+
}
|
|
25
|
+
:scope[tone='warn'] {
|
|
26
|
+
border-color: #b4232c;
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
29
|
+
|
|
30
|
+
<p><slot>Nothing to say.</slot></p>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<site-note tone="warn">Rendered into the page's own DOM.</site-note>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`:scope` styles the element itself. Those rules are hoisted into `<head>` and
|
|
38
|
+
lose to page CSS of equal specificity, so a page can override an element it did
|
|
39
|
+
not write.
|
|
40
|
+
|
|
41
|
+
## Shadow root, opt-in
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<!-- app/elements/user-card.html -->
|
|
45
|
+
<script properties>
|
|
46
|
+
export default {
|
|
47
|
+
name: '',
|
|
48
|
+
tags: [],
|
|
49
|
+
};
|
|
50
|
+
export const shadow = true;
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<style>
|
|
54
|
+
h3 {
|
|
55
|
+
margin: 0;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
|
|
59
|
+
<h3>${name}</h3>
|
|
60
|
+
<ul>
|
|
61
|
+
<li each="tag of tags">${tag}</li>
|
|
62
|
+
</ul>
|
|
63
|
+
<slot></slot>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Reach for a shadow root when the element has to seal its styles and DOM off from
|
|
67
|
+
the page, or re-render on its own. Otherwise stay light: it is cheaper, and it
|
|
68
|
+
renders the same way whether the browser asked for a whole page or one fragment.
|
|
69
|
+
|
|
70
|
+
## Props
|
|
71
|
+
|
|
72
|
+
`<script properties>` declares them, with defaults that set the type. An
|
|
73
|
+
attribute is the value.
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<user-card name="Ada Lovelace" tags='["math", "engines"]'></user-card>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
An object or an array serializes as JSON, so the browser reads the same data
|
|
80
|
+
back off the element that the server had.
|
|
81
|
+
|
|
82
|
+
`false`, `null` and `undefined` drop an attribute rather than writing
|
|
83
|
+
`class="false"`. `true` writes the name bare.
|
|
84
|
+
|
|
85
|
+
## State
|
|
86
|
+
|
|
87
|
+
`<script state>` is data the element owns. Nothing observes it: assigning to it
|
|
88
|
+
schedules the render, the way an attribute change does for a prop.
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<!-- app/elements/tally-box.html -->
|
|
92
|
+
<script properties>
|
|
93
|
+
export default {
|
|
94
|
+
label: 'tally',
|
|
95
|
+
};
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<script state>
|
|
99
|
+
export default {
|
|
100
|
+
n: 0,
|
|
101
|
+
};
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<output>${n}</output>
|
|
105
|
+
<span>${label}</span>
|
|
106
|
+
|
|
107
|
+
<script>
|
|
108
|
+
export const prototype = {
|
|
109
|
+
bump(by = 1) {
|
|
110
|
+
this.n += by;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
host.addEventListener('click', () => host.bump(), { signal });
|
|
115
|
+
</script>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
<tally-box label="clicks"></tally-box>
|
|
120
|
+
<script type="module">
|
|
121
|
+
const box = document.querySelector('tally-box');
|
|
122
|
+
box.n = 10; // schedules a re-render
|
|
123
|
+
box.bump(); // 11
|
|
124
|
+
await box.updateComplete;
|
|
125
|
+
</script>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Behavior
|
|
129
|
+
|
|
130
|
+
A plain `<script>` block is the element's own code. `host` is the element,
|
|
131
|
+
`shadow` is its shadow root when it has one, `signal` is an `AbortSignal` that
|
|
132
|
+
fires when the element disconnects, and `internals` is its `ElementInternals`.
|
|
133
|
+
|
|
134
|
+
`export const prototype` puts members on the class prototype, shared by every
|
|
135
|
+
instance. The rest of the block is per-element setup and runs once each.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
export const prototype = {
|
|
139
|
+
dismiss() {
|
|
140
|
+
this.hidden = true;
|
|
141
|
+
this.dispatchEvent(new CustomEvent('dismiss', { bubbles: true }));
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A prototype member cannot read `host`, `shadow`, `signal` or `internals`. Those
|
|
147
|
+
are per-element, and reaching one from a shared member is a compile error.
|
|
148
|
+
|
|
149
|
+
**Always pass `{ signal }` to a listener on `document`, `window` or
|
|
150
|
+
`globalThis`.** One on `host` is collected with the element. One on `document`
|
|
151
|
+
holds its closure forever, and every element after it adds another.
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
document.addEventListener(
|
|
155
|
+
'keydown',
|
|
156
|
+
(event) => {
|
|
157
|
+
if (event.key === 'Escape') host.dismiss();
|
|
158
|
+
},
|
|
159
|
+
{ signal },
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Form association
|
|
164
|
+
|
|
165
|
+
```html
|
|
166
|
+
<!-- app/elements/tag-picker.html -->
|
|
167
|
+
<script properties>
|
|
168
|
+
export default {
|
|
169
|
+
name: '',
|
|
170
|
+
value: '',
|
|
171
|
+
};
|
|
172
|
+
export const formAssociated = true;
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<button type="button">${value || 'Pick tags'}</button>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```html
|
|
179
|
+
<form method="post">
|
|
180
|
+
<input name="text" />
|
|
181
|
+
<tag-picker name="tags"></tag-picker>
|
|
182
|
+
<button type="submit">Add</button>
|
|
183
|
+
</form>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The element is then a real form field: it submits, resets and validates with the
|
|
187
|
+
rest of them.
|
|
188
|
+
|
|
189
|
+
## Traps
|
|
190
|
+
|
|
191
|
+
**A light element cannot `if` or `each` over a value that changes.** It does not
|
|
192
|
+
own its children, so it writes into the DOM it already rendered rather than
|
|
193
|
+
replacing it. That is a compile error naming `shadow`. A shadow element compiles
|
|
194
|
+
the same `each` to a block with anchors and rebuilds it.
|
|
195
|
+
|
|
196
|
+
**A shadow element in a fragment paints on connect.** A declarative shadow root
|
|
197
|
+
is built when a browser parses a document, and nothing that swaps HTML parses
|
|
198
|
+
one. A light element arrives whole.
|
|
199
|
+
|
|
200
|
+
**An element with `<script state>` and no `<script>` still gets registered.**
|
|
201
|
+
State is behavior. Without the definition, `el.n = 1` sets a value no node hears
|
|
202
|
+
about.
|
|
203
|
+
|
|
204
|
+
**`setHTMLUnsafe()`, never `innerHTML`,** when inserting markup that holds an
|
|
205
|
+
element. `innerHTML` leaves a nested declarative shadow root as a dead
|
|
206
|
+
`<template>`.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Fragments and includes
|
|
2
|
+
|
|
3
|
+
## Fragments
|
|
4
|
+
|
|
5
|
+
A fragment is a part of a page that has its own URL. Give the element an `id`
|
|
6
|
+
and a `fragment` attribute.
|
|
7
|
+
|
|
8
|
+
```html
|
|
9
|
+
<script server>
|
|
10
|
+
import { people } from '../data/people.js';
|
|
11
|
+
|
|
12
|
+
export default async ({ url }) => {
|
|
13
|
+
const q = new URL(url).searchParams.get('q') ?? '';
|
|
14
|
+
return { q, matches: people.filter((p) => p.name.includes(q)) };
|
|
15
|
+
};
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<h1>People</h1>
|
|
19
|
+
|
|
20
|
+
<input name="q" value="${q}" />
|
|
21
|
+
|
|
22
|
+
<ul id="matches" fragment>
|
|
23
|
+
<li each="person of matches">${person.name}</li>
|
|
24
|
+
</ul>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That page answers three URLs.
|
|
28
|
+
|
|
29
|
+
| | |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `GET /?q=ada` | the whole document |
|
|
32
|
+
| `GET /?q=ada&fragment=matches` | the `<ul>` and nothing else |
|
|
33
|
+
| `GET /?fragment=nope` | 404 |
|
|
34
|
+
|
|
35
|
+
The `id` is the name. The page and the fragment come from one template, so the
|
|
36
|
+
two always agree.
|
|
37
|
+
|
|
38
|
+
### Asking for one
|
|
39
|
+
|
|
40
|
+
A fragment is an ordinary URL. Anything that fetches HTML can use it.
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
const res = await fetch('/?q=ada&fragment=matches');
|
|
44
|
+
document.getElementById('matches').setHTMLUnsafe(await res.text());
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**This framework ships nothing that swaps a fragment into a page.** htmx, Turbo,
|
|
48
|
+
htmz or a short `fetch` does that. Do not look for a trigger attribute and do
|
|
49
|
+
not invent one.
|
|
50
|
+
|
|
51
|
+
htmx sends the target's id in an `HX-Target` header. Setting `fragmentHeader:
|
|
52
|
+
'HX-Target'` in the config makes `?fragment=` optional. The query parameter is
|
|
53
|
+
strict and an unknown name is a 404; a header naming an unknown fragment is
|
|
54
|
+
ignored, because clients send it on every request.
|
|
55
|
+
|
|
56
|
+
### Actions and fragments
|
|
57
|
+
|
|
58
|
+
`POST /?fragment=nope` changes nothing and returns 404. The fragment is checked
|
|
59
|
+
before the action runs. `ctx.fragment` tells a form submission from a fetch: the
|
|
60
|
+
first wants a redirect, the second wants markup.
|
|
61
|
+
|
|
62
|
+
### Styles for swapped-in markup
|
|
63
|
+
|
|
64
|
+
A fragment can land on a page that never rendered the elements inside it. Set
|
|
65
|
+
`watchElements: true` in the config and every page carries a small script that
|
|
66
|
+
notices a new tag, loads its definition and adds its styles once. It is off by
|
|
67
|
+
default.
|
|
68
|
+
|
|
69
|
+
## Includes
|
|
70
|
+
|
|
71
|
+
`<transclude src>` puts the same content in more than one place. It is resolved
|
|
72
|
+
on the server and leaves no trace in the output.
|
|
73
|
+
|
|
74
|
+
### Same page
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<div id="pricing" fragment>
|
|
78
|
+
<p>Two pounds.</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<aside>
|
|
82
|
+
<transclude src="#pricing"></transclude>
|
|
83
|
+
</aside>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Renders as:
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<div id="pricing">
|
|
90
|
+
<p>Two pounds.</p>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<aside>
|
|
94
|
+
<div>
|
|
95
|
+
<p>Two pounds.</p>
|
|
96
|
+
</div>
|
|
97
|
+
</aside>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The included copy drops the `id`. Two elements carrying one id is invalid, and a
|
|
101
|
+
swap aimed at it would find the wrong one.
|
|
102
|
+
|
|
103
|
+
### Another route
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<transclude src="/notes#notes">
|
|
107
|
+
<p>The notes could not be read.</p>
|
|
108
|
+
</transclude>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The children are the fallback. The route is rendered in this process, not
|
|
112
|
+
fetched over HTTP.
|
|
113
|
+
|
|
114
|
+
### Another site
|
|
115
|
+
|
|
116
|
+
Off unless a host is allowed. Default deny.
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
export default {
|
|
120
|
+
proxy: {
|
|
121
|
+
allow: ['developer.mozilla.org', '*.docs.example'],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```html
|
|
127
|
+
<transclude src="https://developer.mozilla.org/en-US/docs/Web/HTML#reference">
|
|
128
|
+
<p>The reference could not be read just now.</p>
|
|
129
|
+
</transclude>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This copies someone else's work onto your origin. Name a host only when you have
|
|
133
|
+
the right to republish what it holds, and read its license: a share-alike
|
|
134
|
+
license can put conditions on the page you put the fragment in. Nothing is
|
|
135
|
+
attributed for you. Write the credit yourself.
|
|
136
|
+
|
|
137
|
+
Tuning:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
proxy: {
|
|
141
|
+
allow: ['developer.mozilla.org'],
|
|
142
|
+
maxBytes: 5 * 1024 * 1024,
|
|
143
|
+
timeout: 10_000,
|
|
144
|
+
redirects: 5,
|
|
145
|
+
maxAge: 60_000,
|
|
146
|
+
sanitize: true,
|
|
147
|
+
styles: 'keep',
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`<base>`, `<link>` and `<style>` are stripped from foreign markup, because none
|
|
152
|
+
of them stops at the fragment. `styles: 'strip'` drops `style` attributes too.
|
|
153
|
+
|
|
154
|
+
## Traps
|
|
155
|
+
|
|
156
|
+
**`<transclude>` has no self-closing form.** `<transclude src="#a" />` is read
|
|
157
|
+
as an open tag. The children are the fallback, so the rest of the page becomes
|
|
158
|
+
them, silently, and the include still works.
|
|
159
|
+
|
|
160
|
+
**An interpolated `src` is a compile error.** `src="/docs/${slug}#intro"` cannot
|
|
161
|
+
be resolved, because the set of includes has to be known before the render that
|
|
162
|
+
would produce the value.
|
|
163
|
+
|
|
164
|
+
**A page including itself is refused,** and so is a chain that comes back
|
|
165
|
+
around.
|
|
166
|
+
|
|
167
|
+
**A route include shares the host page's cookies.** That is deliberate: it makes
|
|
168
|
+
the host page personal too, so one visitor's render is never served to the next.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# The server
|
|
2
|
+
|
|
3
|
+
The server is [Hono](https://hono.dev). An app adds its own middleware in
|
|
4
|
+
`app/server.js`.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
// app/server.js
|
|
8
|
+
export default (app) => {
|
|
9
|
+
app.use('*', async (c, next) => {
|
|
10
|
+
await next();
|
|
11
|
+
c.res.headers.set('X-Served-By', 'transclude');
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It runs before anything that serves bytes, so a guard there covers prerendered
|
|
17
|
+
pages and public files.
|
|
18
|
+
|
|
19
|
+
## Cookies
|
|
20
|
+
|
|
21
|
+
`ctx.cookies` reads and writes.
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
export default async ({ cookies }) => {
|
|
25
|
+
const theme = cookies.get('theme') ?? 'light';
|
|
26
|
+
return { theme };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const POST = async ({ cookies, request }) => {
|
|
30
|
+
const form = await request.formData();
|
|
31
|
+
cookies.set('theme', String(form.get('theme')), { maxAge: 31536000 });
|
|
32
|
+
return Response.redirect('/', 303);
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`cookies.signed.get` and `cookies.signed.set` need `cookieSecret` in the config.
|
|
37
|
+
A signed cookie can be read by the client and not forged, which is what a
|
|
38
|
+
session needs. Without a secret, `cookies.signed` throws.
|
|
39
|
+
|
|
40
|
+
`Secure` follows the connection, so `http://localhost` works in dev and a
|
|
41
|
+
proxied TLS connection still gets it.
|
|
42
|
+
|
|
43
|
+
**Reading a cookie makes a page personal.** It is then not cached and not
|
|
44
|
+
prerendered. Writing one does not.
|
|
45
|
+
|
|
46
|
+
## Security
|
|
47
|
+
|
|
48
|
+
CSRF is on by default. A Content-Security-Policy is not:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
export default {
|
|
52
|
+
csp: true,
|
|
53
|
+
};
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each page gets a policy built from the hashes of what that page inlines. There
|
|
57
|
+
is nothing to stamp and no nonce to thread through.
|
|
58
|
+
|
|
59
|
+
Every `${…}` is escaped. `html(value)` turns that off for one value and
|
|
60
|
+
sanitizes nothing, so never wrap anything a visitor typed.
|
|
61
|
+
|
|
62
|
+
`frame-ancestors`, `report-uri`, `report-to` and `sandbox` are ignored in a
|
|
63
|
+
`<meta>` tag, so they ride in a response header. A static host serves the meta
|
|
64
|
+
half and not the header.
|
|
65
|
+
|
|
66
|
+
## Types
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm run check
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Runs TypeScript over every `.html` and `.js` route. With no annotations it
|
|
73
|
+
catches a misspelled field in a template, an unknown prop on an element, and a
|
|
74
|
+
prop given the wrong type. `strict: true` in the config turns on full
|
|
75
|
+
strictness.
|
|
76
|
+
|
|
77
|
+
Source is JavaScript with JSDoc. Do not convert it to TypeScript.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
`transclude.config.js` at the project root. An unknown key throws.
|
|
82
|
+
|
|
83
|
+
| Key | Default | What it does |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `appDir` | `'app'` | Where the app lives, relative to the project root. |
|
|
86
|
+
| `routesDir` | `'routes'` | Pages and endpoints. Relative to `appDir`. |
|
|
87
|
+
| `elementsDir` | `'elements'` | Custom elements. Relative to `appDir`. |
|
|
88
|
+
| `publicDir` | `'public'` | Copied to the site root as-is. Relative to `appDir`. |
|
|
89
|
+
| `outDir` | `'dist'` | Where the build writes. |
|
|
90
|
+
| `stylesheet` | — | One global stylesheet, relative to the project root. |
|
|
91
|
+
| `port` | `1960` | Dev and production both listen here. `PORT` wins. |
|
|
92
|
+
| `lang` | `'en'` | The `lang` on `<html>`. |
|
|
93
|
+
| `strict` | `false` | Full TypeScript strictness. |
|
|
94
|
+
| `csrf` | `true` | `false` to turn it off, or an object for `hono/csrf`. |
|
|
95
|
+
| `csp` | `false` | `true`, or `{ directives, reportOnly }`. |
|
|
96
|
+
| `cookieSecret` | `null` | Signs cookies. |
|
|
97
|
+
| `fragmentParam` | `'fragment'` | The query parameter that asks for a fragment. |
|
|
98
|
+
| `fragmentHeader` | `null` | A request header that may name one. Adds it to `Vary`. |
|
|
99
|
+
| `watchElements` | `false` | Defines and styles an element arriving in a swap. |
|
|
100
|
+
| `trailingSlash` | `'never'` | `'never'` redirects. `'ignore'` serves both. |
|
|
101
|
+
| `metadataBase` | — | The origin `ctx.absolute()` resolves against. |
|
|
102
|
+
| `sitemap` | `false` | `{ hostname }` mounts `/sitemap.xml`. |
|
|
103
|
+
| `feed` | `false` | `{ hostname, title, items }` mounts a feed. |
|
|
104
|
+
| `proxy` | `false` | `{ allow: [...] }` for cross-site includes. |
|
|
105
|
+
| `precache` | `false` | `true` writes `/precache.json`. |
|
|
106
|
+
| `onError` | `null` | `(error, { request, url, method })` per failed request. |
|
|
107
|
+
|
|
108
|
+
## The build
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
npm run build
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Writes `dist/`. Every route with no per-request state is rendered to a file. Add
|
|
115
|
+
this to a page that has to run for each request:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
export const prerender = false;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`prerender` is read off the page, never off its layouts. A layout that reads a
|
|
122
|
+
cookie makes every page under it request-dependent, and nothing says so.
|
|
123
|
+
|
|
124
|
+
## Runtimes
|
|
125
|
+
|
|
126
|
+
The same app runs on Node, Bun, Deno and workerd. `bin/serve.js`,
|
|
127
|
+
`bin/serve.bun.js` and `bin/serve.deno.js` only listen.
|
|
128
|
+
|
|
129
|
+
**workerd refuses to compile WebAssembly at runtime.** A loader that reaches
|
|
130
|
+
something built on Wasm fails there and nowhere else. A prerendered page never
|
|
131
|
+
runs its loader in production, so this stays hidden until something asks for a
|
|
132
|
+
fragment.
|
|
133
|
+
|
|
134
|
+
## Testing
|
|
135
|
+
|
|
136
|
+
The app is a function from a request to a response.
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
import test from 'node:test';
|
|
140
|
+
import assert from 'node:assert/strict';
|
|
141
|
+
import { app } from '@transclude/core/production';
|
|
142
|
+
|
|
143
|
+
test('the notes page lists notes', async () => {
|
|
144
|
+
const res = await app.request('http://localhost/notes');
|
|
145
|
+
assert.equal(res.status, 200);
|
|
146
|
+
assert.match(await res.text(), /<li/);
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`app` is a named export, and the URL has to be absolute. A relative one skips
|
|
151
|
+
the origin checks a real request goes through.
|
|
152
|
+
|
|
153
|
+
Nothing is stubbed. `node --test` does not read `.env` the way `npm start`
|
|
154
|
+
does, so add `--env-file-if-exists=.env` to the test script when the config
|
|
155
|
+
reads a secret.
|
package/src/address.js
CHANGED
|
@@ -69,8 +69,15 @@ export function parseV6(text) {
|
|
|
69
69
|
const halves = body.split('::');
|
|
70
70
|
if (halves.length > 2) return null;
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// Each half of a `::` is groups of up to four hex digits. A group that is not
|
|
73
|
+
// becomes NaN, which the caller checks for rather than throwing here.
|
|
74
|
+
const read = (part) => {
|
|
75
|
+
if (part === '') return [];
|
|
76
|
+
return part.split(':').map((group) => {
|
|
77
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) return NaN;
|
|
78
|
+
return parseInt(group, 16);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
74
81
|
|
|
75
82
|
const head = read(halves[0]);
|
|
76
83
|
const rest = halves.length === 2 ? read(halves[1]) : [];
|