@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.
@@ -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
- const read = (part) =>
73
- part === '' ? [] : part.split(':').map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
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]) : [];