@transclude/core 0.1.1 → 0.2.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 +44 -12
- package/bin/build.js +13 -1
- package/bin/release.js +19 -4
- package/package.json +12 -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/compiler/codegen.js +25 -0
- package/src/compiler/index.js +17 -3
- package/src/compiler/script.js +30 -8
- package/src/compiler/shim.js +10 -4
- package/src/negotiate.js +1 -1
- package/src/project.js +29 -1
- package/src/routes.js +1 -1
- package/src/runtime/index.js +5 -5
- package/src/server.js +2 -2
- package/src/static-cache.js +2 -2
- package/src/typecheck.js +1 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ HTML is the product. A page is an `.html` file, the directory tree is the route
|
|
|
4
4
|
table, and any fragment of a page is a URL of its own. Nothing has to run in the
|
|
5
5
|
browser for the page to be correct.
|
|
6
6
|
|
|
7
|
-
The same app runs on Node, Bun, Deno and workerd,
|
|
8
|
-
by default.
|
|
7
|
+
The same app runs on Node, Bun, Deno and workerd, the runtime behind
|
|
8
|
+
Cloudflare Workers, and ships no client JavaScript by default.
|
|
9
9
|
|
|
10
10
|
**[transclude.dev](https://transclude.dev)** has the documentation.
|
|
11
11
|
|
|
@@ -74,9 +74,10 @@ so a swap cannot drift from the page it replaces part of.
|
|
|
74
74
|
|
|
75
75
|
- **No client-side router, and no swapper.** Every link is a document request
|
|
76
76
|
unless you bring something that swaps. That is a decision, not a gap.
|
|
77
|
-
- **
|
|
78
|
-
the ETag. A `Link: rel=preload` goes out first, so a proxy can turn
|
|
79
|
-
103 while the page is still being made.
|
|
77
|
+
- **A page does not stream.** Its body is buffered so it can be hashed, which is
|
|
78
|
+
what buys the ETag. A `Link: rel=preload` goes out first, so a proxy can turn
|
|
79
|
+
it into a 103 while the page is still being made. An endpoint returns a
|
|
80
|
+
`Response` you build, so it can answer with a `ReadableStream` and stay open.
|
|
80
81
|
- **No session store and no database opinion.** Signed cookies are the building
|
|
81
82
|
block.
|
|
82
83
|
- **No byte ranges on workerd.** A Range request gets 200 rather than 206.
|
|
@@ -96,26 +97,57 @@ so a swap cannot drift from the page it replaces part of.
|
|
|
96
97
|
```sh
|
|
97
98
|
npm install
|
|
98
99
|
npm test # the framework's own, and they need no app
|
|
99
|
-
npm run test:examples # the
|
|
100
|
-
npm run showcase # the
|
|
100
|
+
npm run test:examples # the examples', against a build
|
|
101
|
+
npm run showcase # the showcase on http://localhost:1961
|
|
102
|
+
npm run todomvc # TodoMVC on http://localhost:1962
|
|
103
|
+
npm run blog # a prerendered blog on http://localhost:1963
|
|
104
|
+
npm run search # search over a fragment on http://localhost:1964
|
|
105
|
+
npm run htmx # the same, driven by htmx, on http://localhost:1965
|
|
106
|
+
npm run includes # transclusion on http://localhost:1966
|
|
107
|
+
npm run auth # a guarded section on http://localhost:1967
|
|
108
|
+
npm run live # server-sent events on http://localhost:1968
|
|
101
109
|
npm run check:src # type-check the framework itself
|
|
102
110
|
```
|
|
103
111
|
|
|
104
|
-
`examples
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
`examples/` holds apps built against this package the same way any other project
|
|
113
|
+
would be. `todomvc` is TodoMVC with forms and no client JavaScript, `blog` is a
|
|
114
|
+
prerendered site with a sitemap and a feed, `search` swaps a fragment into a
|
|
115
|
+
page that works without it, `htmx` does the same with htmx and the
|
|
116
|
+
`HX-Target` header, `includes` shows transclusion from three sources,
|
|
117
|
+
`auth` guards a section with a layout and a signed cookie, `live` pushes
|
|
118
|
+
updates over server-sent events, and `showcase` uses every feature and is where the
|
|
119
|
+
browser checks live, because those need an app to run against. `www/` is the site at transclude.dev: a landing page, the
|
|
120
|
+
documentation under `/docs`, and itself built with the framework.
|
|
108
121
|
|
|
109
122
|
### Trying the CLI against this checkout
|
|
110
123
|
|
|
111
124
|
```sh
|
|
112
|
-
npm link
|
|
125
|
+
cd create && npm link && cd .. # once, puts create-transclude on PATH
|
|
113
126
|
create-transclude my-app --template blank --link
|
|
114
127
|
```
|
|
115
128
|
|
|
116
129
|
`--link` points the new project at this checkout rather than the registry, which
|
|
117
130
|
is what you want while changing the framework: an edit here is an edit there.
|
|
118
131
|
|
|
132
|
+
## Working with an AI agent
|
|
133
|
+
|
|
134
|
+
`skills/transclude/` is an [Agent Skill](https://agentskills.io): the framework's
|
|
135
|
+
conventions, its API and the mistakes it refuses, in the format Claude Code,
|
|
136
|
+
Cursor, Copilot and others read. It ships with the package, so an installed
|
|
137
|
+
project has it at `node_modules/@transclude/core/skills/transclude/`.
|
|
138
|
+
|
|
139
|
+
Every HTML example in it is compiled by the real compiler in `npm test`. A skill
|
|
140
|
+
is documentation an agent acts on without a human reading it first, so an
|
|
141
|
+
example that stops compiling is worse than a missing one.
|
|
142
|
+
|
|
143
|
+
## Contributing
|
|
144
|
+
|
|
145
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) covers the layout, the tests and the writing
|
|
146
|
+
style. Everyone taking part agrees to the
|
|
147
|
+
[Code of Conduct](CODE_OF_CONDUCT.md).
|
|
148
|
+
|
|
149
|
+
Security problems go to admin@dakroub.co, not to a public issue.
|
|
150
|
+
|
|
119
151
|
## License
|
|
120
152
|
|
|
121
153
|
MIT
|
package/bin/build.js
CHANGED
|
@@ -316,8 +316,20 @@ const publicSrc = config.publicDir
|
|
|
316
316
|
const publicOut = path.join(dist, 'public');
|
|
317
317
|
let publicFiles = 0;
|
|
318
318
|
|
|
319
|
+
/**
|
|
320
|
+
* What an operating system leaves in a directory, which nobody put there.
|
|
321
|
+
*
|
|
322
|
+
* These were copied into the build and served: `/.DS_Store` answered 200 on the
|
|
323
|
+
* docs site and lists every file beside it. Dotfiles are not skipped wholesale,
|
|
324
|
+
* because `.well-known` is a directory people mean to publish.
|
|
325
|
+
*/
|
|
326
|
+
const JUNK = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
|
|
327
|
+
|
|
319
328
|
if (publicSrc && fs.existsSync(publicSrc)) {
|
|
320
|
-
fs.cpSync(publicSrc, publicOut, {
|
|
329
|
+
fs.cpSync(publicSrc, publicOut, {
|
|
330
|
+
recursive: true,
|
|
331
|
+
filter: (from) => !JUNK.has(path.basename(from)),
|
|
332
|
+
});
|
|
321
333
|
publicFiles = countFiles(publicOut);
|
|
322
334
|
}
|
|
323
335
|
|
package/bin/release.js
CHANGED
|
@@ -85,15 +85,30 @@ function setVersion(version) {
|
|
|
85
85
|
|
|
86
86
|
/** Everything, in the order that fails cheapest first. */
|
|
87
87
|
function verify() {
|
|
88
|
+
// Read rather than listed, so an example added to the repository is covered by
|
|
89
|
+
// the next release without anyone remembering this file.
|
|
90
|
+
const examples = fs
|
|
91
|
+
.readdirSync(path.join(root, 'examples'), { withFileTypes: true })
|
|
92
|
+
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, 'examples', entry.name, 'package.json')))
|
|
93
|
+
.map((entry) => entry.name)
|
|
94
|
+
.sort();
|
|
95
|
+
|
|
88
96
|
const steps = [
|
|
89
97
|
// `check:src` is not here. It exits non-zero on any diagnostic, and most of
|
|
90
98
|
// what it reports is a pattern TypeScript cannot model rather than a defect.
|
|
91
99
|
// The part that is a gate is a test, and `npm test` runs it.
|
|
92
100
|
['the framework', 'npm', ['test']],
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
|
|
102
|
+
// Built before tested, and that order is the gate rather than a nicety:
|
|
103
|
+
// every example's tests ask the built app for URLs and skip when there is
|
|
104
|
+
// nothing to ask. Without this a release could pass with all of them
|
|
105
|
+
// skipped, which is a green tick over nothing.
|
|
106
|
+
...examples.map((name) => [`${name}, built`, 'npm', ['run', 'build', '--prefix', `examples/${name}`]]),
|
|
107
|
+
['the examples', 'npm', ['run', 'test:examples']],
|
|
108
|
+
|
|
109
|
+
['the docs types', 'npm', ['run', 'check', '--prefix', 'www']],
|
|
110
|
+
['the docs build', 'npm', ['run', 'build', '--prefix', 'www']],
|
|
111
|
+
['the docs', 'npm', ['test', '--prefix', 'www']],
|
|
97
112
|
];
|
|
98
113
|
|
|
99
114
|
for (const [what, command, args] of steps) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"html",
|
|
@@ -52,14 +52,22 @@
|
|
|
52
52
|
"LICENSE",
|
|
53
53
|
"bin",
|
|
54
54
|
"editor",
|
|
55
|
+
"skills",
|
|
55
56
|
"src"
|
|
56
57
|
],
|
|
57
58
|
"scripts": {
|
|
58
59
|
"test": "node --test \"test/**/*.test.js\"",
|
|
59
|
-
"test:examples": "npm test --prefix examples/showcase",
|
|
60
|
-
"test:
|
|
60
|
+
"test:examples": "npm test --prefix examples/showcase && npm test --prefix examples/todomvc && npm test --prefix examples/blog && npm test --prefix examples/search && npm test --prefix examples/htmx && npm test --prefix examples/includes && npm test --prefix examples/auth && npm test --prefix examples/live",
|
|
61
|
+
"test:www": "npm test --prefix www",
|
|
61
62
|
"showcase": "npm run dev --prefix examples/showcase",
|
|
62
|
-
"
|
|
63
|
+
"todomvc": "npm run dev --prefix examples/todomvc",
|
|
64
|
+
"blog": "npm run dev --prefix examples/blog",
|
|
65
|
+
"search": "npm run dev --prefix examples/search",
|
|
66
|
+
"htmx": "npm run dev --prefix examples/htmx",
|
|
67
|
+
"includes": "npm run dev --prefix examples/includes",
|
|
68
|
+
"auth": "npm run dev --prefix examples/auth",
|
|
69
|
+
"live": "npm run dev --prefix examples/live",
|
|
70
|
+
"www": "npm run dev --prefix www",
|
|
63
71
|
"check:src": "tsc -p tsconfig.src.json",
|
|
64
72
|
"release": "node bin/release.js"
|
|
65
73
|
},
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: transclude
|
|
3
|
+
description: Build web apps with the transclude framework (@transclude/core), an HTML-first server-side framework on Hono. Use when working in a project that has a transclude.config.js, when writing .html pages, layouts or custom elements under app/routes/ and app/elements/, or when the user mentions transclude, fragments, transclusion, hypermedia pages, or server-rendered HTML with no client bundle.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
homepage: https://transclude.dev
|
|
7
|
+
package: "@transclude/core"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# transclude
|
|
11
|
+
|
|
12
|
+
HTML is the product. A page is an `.html` file, the server renders it, and what
|
|
13
|
+
arrives is markup a browser already knows how to display. Nothing has to run in
|
|
14
|
+
the browser for the page to be correct.
|
|
15
|
+
|
|
16
|
+
The directory tree is the route table. The same app runs on Node, Bun, Deno and
|
|
17
|
+
workerd.
|
|
18
|
+
|
|
19
|
+
## Start a project
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm create @transclude my-app
|
|
23
|
+
cd my-app
|
|
24
|
+
npm install
|
|
25
|
+
npm run dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Where files go
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
app/
|
|
32
|
+
routes/ # .html is a page, .js is an endpoint
|
|
33
|
+
index.html # /
|
|
34
|
+
notes.html # /notes
|
|
35
|
+
notes/[id].html # /notes/:id
|
|
36
|
+
_layout.html # wraps every route beside and below it
|
|
37
|
+
api/people.js # /api/people
|
|
38
|
+
api/_shared.js # not a route, the _ prefix says so
|
|
39
|
+
elements/ # every custom element, one file each
|
|
40
|
+
note-card.html # <note-card>, the name needs a dash
|
|
41
|
+
public/ # copied to the site root as-is
|
|
42
|
+
transclude.config.js
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## A page
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<script server>
|
|
49
|
+
import { notes } from '../data/notes.js';
|
|
50
|
+
|
|
51
|
+
export default async ({ url }) => {
|
|
52
|
+
const q = new URL(url).searchParams.get('q') ?? '';
|
|
53
|
+
return { q, notes: notes.filter((n) => n.text.includes(q)) };
|
|
54
|
+
};
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<title>Notes</title>
|
|
58
|
+
|
|
59
|
+
<h1>Notes</h1>
|
|
60
|
+
|
|
61
|
+
<div id="list" fragment>
|
|
62
|
+
<p if="!notes.length">Nothing yet.</p>
|
|
63
|
+
<ul else>
|
|
64
|
+
<li each="note of notes">${note.text}</li>
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The `<script server>` default export is the loader. It runs on the server and
|
|
70
|
+
returns the data the markup reads. Every name in `${…}` is a field of that data.
|
|
71
|
+
|
|
72
|
+
### Directives
|
|
73
|
+
|
|
74
|
+
| | |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `${expr}` | interpolation, escaped |
|
|
77
|
+
| `if="expr"` | render this element only when true |
|
|
78
|
+
| `else` | pairs with the `if` above it |
|
|
79
|
+
| `each="item of items"` | repeat the element |
|
|
80
|
+
| `key="expr"` | identity for a repeated element |
|
|
81
|
+
| `fragment` | this element has its own URL, see references/fragments.md |
|
|
82
|
+
| `slot="name"` | fill a named slot |
|
|
83
|
+
|
|
84
|
+
`html(value)` renders markup without escaping. It is a claim that the markup is
|
|
85
|
+
yours. It sanitizes nothing.
|
|
86
|
+
|
|
87
|
+
Globals available in an expression: `html`, `json`, `Math`, `JSON`, `String`,
|
|
88
|
+
`Number`, `Boolean`, `Array`, `Object`, `Date`, `isNaN`, `parseInt`,
|
|
89
|
+
`parseFloat`, `undefined`, `NaN`, `Infinity`. Every other name is data.
|
|
90
|
+
|
|
91
|
+
### The loader context
|
|
92
|
+
|
|
93
|
+
`ctx` carries `url`, `request`, `params`, `cookies`, `fragment`, `response` and
|
|
94
|
+
`absolute()`. Returning a `Response` from a loader answers the request and skips
|
|
95
|
+
the render, which is how a layout does a login redirect.
|
|
96
|
+
|
|
97
|
+
## Forms and actions
|
|
98
|
+
|
|
99
|
+
A page answers GET with its loader. Other verbs are named exports on the same
|
|
100
|
+
file.
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<script server>
|
|
104
|
+
import { notes } from '../data/notes.js';
|
|
105
|
+
|
|
106
|
+
export default async () => ({ notes: notes.all() });
|
|
107
|
+
|
|
108
|
+
export const POST = async ({ request }) => {
|
|
109
|
+
const form = await request.formData();
|
|
110
|
+
notes.add(String(form.get('text')));
|
|
111
|
+
};
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<form method="post">
|
|
115
|
+
<input name="text" required />
|
|
116
|
+
<button>Add</button>
|
|
117
|
+
</form>
|
|
118
|
+
|
|
119
|
+
<ul id="list" fragment>
|
|
120
|
+
<li each="note of notes">${note.text}</li>
|
|
121
|
+
</ul>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The action runs, then the loader renders what it left behind. `POST`, `PUT`,
|
|
125
|
+
`PATCH` and `DELETE` are the verbs. Return nothing to re-render, or return a
|
|
126
|
+
`Response` to redirect.
|
|
127
|
+
|
|
128
|
+
## Endpoints
|
|
129
|
+
|
|
130
|
+
A `.js` file in `routes/` returns a `Response`.
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
// app/routes/api/people.js
|
|
134
|
+
import { people } from '../../data/people.js';
|
|
135
|
+
|
|
136
|
+
export const GET = (ctx) => Response.json(people);
|
|
137
|
+
|
|
138
|
+
export const POST = async ({ request }) => {
|
|
139
|
+
const body = await request.json();
|
|
140
|
+
people.push(body);
|
|
141
|
+
return Response.json(body, { status: 201 });
|
|
142
|
+
};
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Layouts
|
|
146
|
+
|
|
147
|
+
`_layout.html` wraps every route in its directory and below. The page renders
|
|
148
|
+
into `<slot>`. Layouts nest, and each one loads its own data.
|
|
149
|
+
|
|
150
|
+
```html
|
|
151
|
+
<script server>
|
|
152
|
+
export default async () => ({ year: 2026 });
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<header><a href="/">Home</a></header>
|
|
156
|
+
<main><slot></slot></main>
|
|
157
|
+
<footer>${year}</footer>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Commands
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
npm run dev # dev server, hot reload
|
|
164
|
+
npm run build # writes dist/, prerenders what it can
|
|
165
|
+
npm start # serves the build
|
|
166
|
+
npm run check # type-check every .html and .js route
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Traps
|
|
170
|
+
|
|
171
|
+
These are the mistakes to avoid. Each one is a real compile error or a real
|
|
172
|
+
bug, not a style preference.
|
|
173
|
+
|
|
174
|
+
**`${…}` inside a nested `<script>` or `<style>` is a compile error.** Text
|
|
175
|
+
there reaches the page as written, so a value would land in code. `json(value)`
|
|
176
|
+
is the one way through, and only as the entire text of the script. For a style,
|
|
177
|
+
pass the value through a custom property, which is an attribute and is escaped.
|
|
178
|
+
|
|
179
|
+
**A literal `${` cannot be written in a template.** There is no escape. Pass any
|
|
180
|
+
text containing it in from the loader as data.
|
|
181
|
+
|
|
182
|
+
**Directive values are expressions, not interpolations.** Write
|
|
183
|
+
`each="note of notes"`, never `each="${notes}"`.
|
|
184
|
+
|
|
185
|
+
**A `fragment` element cannot carry `if`, `else` or `each`.** A fragment is one
|
|
186
|
+
element with one id, so it cannot be conditional or repeated. Put the condition
|
|
187
|
+
on something inside it.
|
|
188
|
+
|
|
189
|
+
**A light element cannot `if` or `each` over a value that changes.** It writes
|
|
190
|
+
into the DOM it already rendered and never replaces a child. That is a compile
|
|
191
|
+
error naming `shadow`. Add `export const shadow = true` or keep the list still.
|
|
192
|
+
|
|
193
|
+
**`setHTMLUnsafe()`, never `innerHTML`.** `innerHTML` does not process nested
|
|
194
|
+
declarative shadow roots, so a child element becomes a dead `<template>`.
|
|
195
|
+
|
|
196
|
+
**An element file name needs a dash.** `note-card.html` is a valid custom
|
|
197
|
+
element name. `card.html` is not, and the file is dropped.
|
|
198
|
+
|
|
199
|
+
**`<transclude>` has no self-closing form.** `<transclude src="#a" />` is read
|
|
200
|
+
as an open tag and the rest of the page becomes its fallback content.
|
|
201
|
+
|
|
202
|
+
**Reading a cookie makes a page personal.** It is then not cached and not
|
|
203
|
+
prerendered. Writing one does not do this; reading one does.
|
|
204
|
+
|
|
205
|
+
**`ctx.response` is shared by reference.** Set `ctx.status` and headers on it
|
|
206
|
+
directly. It is the object the whole chain holds.
|
|
207
|
+
|
|
208
|
+
**A `<template>`'s children are not `childNodes`.** They live on `.content`.
|
|
209
|
+
|
|
210
|
+
## Going further
|
|
211
|
+
|
|
212
|
+
- [references/elements.md](references/elements.md) — custom elements, light and
|
|
213
|
+
shadow, props, state, form association
|
|
214
|
+
- [references/fragments.md](references/fragments.md) — fragments, includes and
|
|
215
|
+
transclusion
|
|
216
|
+
- [references/server.md](references/server.md) — cookies, middleware, security,
|
|
217
|
+
config, deployment
|
|
218
|
+
|
|
219
|
+
Full documentation: https://transclude.dev/docs
|
|
@@ -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/compiler/codegen.js
CHANGED
|
@@ -44,6 +44,28 @@ export class CompileError extends Error {
|
|
|
44
44
|
* @param {object} [opts]
|
|
45
45
|
* @returns {object} the render body, the regions, the slots, the includes and the warnings
|
|
46
46
|
*/
|
|
47
|
+
/**
|
|
48
|
+
* An attribute name is emitted exactly as written, so `${…}` in one reaches the
|
|
49
|
+
* page as those characters rather than as a value. Nothing downstream reads it,
|
|
50
|
+
* which made this the one interpolation mistake that rendered instead of
|
|
51
|
+
* failing. A spread parses as an attribute name too, and is caught here.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} el
|
|
54
|
+
* @throws {CompileError} when a name holds an interpolation
|
|
55
|
+
*/
|
|
56
|
+
function assertStaticAttrNames(el) {
|
|
57
|
+
for (const attr of el.attrs ?? []) {
|
|
58
|
+
if (!attr.name.includes('${')) continue;
|
|
59
|
+
|
|
60
|
+
throw new CompileError(
|
|
61
|
+
`<${el.tagName}> interpolates an attribute name: "${attr.name}". ` +
|
|
62
|
+
`Only a value takes \${…}, so write the name out. To choose between two, ` +
|
|
63
|
+
`put the condition in the value: class="\${cond ? 'a' : 'b'}".`,
|
|
64
|
+
el,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
export function compileFragment(nodes, opts = {}) {
|
|
48
70
|
const gen = new Codegen(opts);
|
|
49
71
|
gen.emitChildren(nodes, gen.body, gen.rootScope, true);
|
|
@@ -553,6 +575,7 @@ class Codegen {
|
|
|
553
575
|
}
|
|
554
576
|
|
|
555
577
|
emitElement(el, out, scope, topLevel) {
|
|
578
|
+
assertStaticAttrNames(el);
|
|
556
579
|
const tag = el.tagName;
|
|
557
580
|
|
|
558
581
|
// In a layout, <slot> is where the child's content goes. In a component it
|
|
@@ -832,6 +855,8 @@ class Codegen {
|
|
|
832
855
|
* taking the first, which is the outermost. `renderDocument` serializes.
|
|
833
856
|
*/
|
|
834
857
|
htmlAttrsJs(el, scope) {
|
|
858
|
+
// <html> is read by a second parse, so it never reaches emitElement.
|
|
859
|
+
assertStaticAttrNames(el);
|
|
835
860
|
const pairs = el.attrs
|
|
836
861
|
.filter((attr) => !DIRECTIVES.has(attr.name))
|
|
837
862
|
.map((attr) => {
|
package/src/compiler/index.js
CHANGED
|
@@ -15,7 +15,13 @@ import {
|
|
|
15
15
|
|
|
16
16
|
export { CompileError, ScriptError };
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// Everything the generated page and layout modules define at the top level. A
|
|
19
|
+
// block naming one of these is checked whether it exports it or merely declares
|
|
20
|
+
// it, because both land in the same scope.
|
|
21
|
+
const PAGE_EXPORTS = new Set([
|
|
22
|
+
'css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs',
|
|
23
|
+
'layouts', 'client', 'elements', 'headScript', 'hasTitle', 'includes',
|
|
24
|
+
]);
|
|
19
25
|
const COMPONENT_EXPORTS = new Set([
|
|
20
26
|
'tag', 'light', 'css', 'elements', 'propDefs', 'propAttrs', 'stateDefs', 'members', 'render',
|
|
21
27
|
'coerce', 'def', 'init', 'define', 'default', 'bind', 'update', 'volatile', 'formAssociated',
|
|
@@ -141,6 +147,12 @@ export function splitBlocks(source) {
|
|
|
141
147
|
// before first paint, or a `pagereveal` listener, which fires too early for
|
|
142
148
|
// any script in the body to see.
|
|
143
149
|
else if (attrs.has('head')) out.head.push({ ...block, attrs: node.attrs ?? [] });
|
|
150
|
+
// A `src` means there is no code here to compile, so this is markup: an
|
|
151
|
+
// ordinary external script the page wants in its body. Read as a client
|
|
152
|
+
// block it became an empty one and the tag was dropped, `src` and all,
|
|
153
|
+
// with nothing said. A nested `<script src>` was always markup; only a
|
|
154
|
+
// top-level one went missing.
|
|
155
|
+
else if (attrs.has('src')) out.nodes.push(node);
|
|
144
156
|
else out.client.push(block);
|
|
145
157
|
continue;
|
|
146
158
|
}
|
|
@@ -375,8 +387,9 @@ export function compilePage(
|
|
|
375
387
|
|
|
376
388
|
const server = blocks.server
|
|
377
389
|
? bindDefaultExport(blocks.server, '__load', where)
|
|
378
|
-
: { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
|
|
390
|
+
: { code: 'const __load = null;', exports: [], imports: [], declared: [], defaultNode: null };
|
|
379
391
|
assertNoCollisions(server.exports, PAGE_EXPORTS, where);
|
|
392
|
+
assertNoCollisions(server.declared ?? [], PAGE_EXPORTS, where, 'declares');
|
|
380
393
|
assertNoActionsObject(server.exports, where);
|
|
381
394
|
|
|
382
395
|
const template = compileFragment(blocks.nodes, { components, shadowTags, page: true, html: blocks.html });
|
|
@@ -485,8 +498,9 @@ export function compileLayout(source, { id, components = new Map(), shadowTags =
|
|
|
485
498
|
|
|
486
499
|
const server = blocks.server
|
|
487
500
|
? bindDefaultExport(blocks.server, '__load', where)
|
|
488
|
-
: { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
|
|
501
|
+
: { code: 'const __load = null;', exports: [], imports: [], declared: [], defaultNode: null };
|
|
489
502
|
assertNoCollisions(server.exports, PAGE_EXPORTS, where);
|
|
503
|
+
assertNoCollisions(server.declared ?? [], PAGE_EXPORTS, where, 'declares');
|
|
490
504
|
assertNoActionsObject(server.exports, where);
|
|
491
505
|
|
|
492
506
|
const template = compileFragment(blocks.nodes, {
|
package/src/compiler/script.js
CHANGED
|
@@ -34,7 +34,7 @@ const PARSE_OPTIONS = {
|
|
|
34
34
|
* @param {{ flags?: string[] }} [options]
|
|
35
35
|
* @returns {{ code: string, exports: string[],
|
|
36
36
|
* imports: Array<{ source: string, specifiers: string }>,
|
|
37
|
-
* defaultNode: object|null, flags: object }}
|
|
37
|
+
* declared: string[], defaultNode: object|null, flags: object }}
|
|
38
38
|
*/
|
|
39
39
|
export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
|
|
40
40
|
const { code: source, line = 1 } = block;
|
|
@@ -56,9 +56,10 @@ export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
|
|
|
56
56
|
const node = ast.body.find((s) => s.type === 'ExportDefaultDeclaration') ?? null;
|
|
57
57
|
const exports = namedExportsOf(ast, source, line, label).filter((n) => !flags.includes(n));
|
|
58
58
|
const imports = importsOf(ast);
|
|
59
|
+
const declared = topLevelNames(ast);
|
|
59
60
|
|
|
60
61
|
if (!node) {
|
|
61
|
-
return { code: `${code}\nconst ${name} = null;`, exports, imports, defaultNode: null, flags: found };
|
|
62
|
+
return { code: `${code}\nconst ${name} = null;`, exports, imports, declared, defaultNode: null, flags: found };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// Slicing the *declaration* rather than the statement means
|
|
@@ -71,7 +72,7 @@ export function bindDefaultExport(block, name, label, { flags = [] } = {}) {
|
|
|
71
72
|
';' +
|
|
72
73
|
code.slice(node.end);
|
|
73
74
|
|
|
74
|
-
return { code: rewritten, exports, imports, defaultNode: node.declaration, flags: found };
|
|
75
|
+
return { code: rewritten, exports, imports, declared, defaultNode: node.declaration, flags: found };
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
@@ -506,19 +507,20 @@ function importsOf(ast) {
|
|
|
506
507
|
}
|
|
507
508
|
|
|
508
509
|
/**
|
|
509
|
-
* Guards against a block
|
|
510
|
+
* Guards against a block using a name the generated module already defines.
|
|
510
511
|
*
|
|
511
|
-
* @param {string[]}
|
|
512
|
+
* @param {string[]} names
|
|
512
513
|
* @param {Set<string>} reserved
|
|
513
514
|
* @param {string} label
|
|
515
|
+
* @param {string} [verb] how the block used it, for the message
|
|
514
516
|
* @returns {void}
|
|
515
517
|
* @throws naming the first collision
|
|
516
518
|
*/
|
|
517
|
-
export function assertNoCollisions(
|
|
518
|
-
for (const name of
|
|
519
|
+
export function assertNoCollisions(names, reserved, label, verb = 'exports') {
|
|
520
|
+
for (const name of names) {
|
|
519
521
|
if (reserved.has(name)) {
|
|
520
522
|
throw new ScriptError(
|
|
521
|
-
`${label}:
|
|
523
|
+
`${label}: ${verb} "${name}", which the generated module already defines. ` +
|
|
522
524
|
`Reserved: ${[...reserved].join(', ')}.`,
|
|
523
525
|
);
|
|
524
526
|
}
|
|
@@ -586,6 +588,26 @@ function namedExportsOf(ast, code, lineOffset, label) {
|
|
|
586
588
|
return names;
|
|
587
589
|
}
|
|
588
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Every name a block binds at the top level, imports included.
|
|
593
|
+
*
|
|
594
|
+
* The generated module puts this block's code beside its own `export const`
|
|
595
|
+
* statements, so any of these can collide, not only the exported ones. An
|
|
596
|
+
* import was the case that got through: `import { elements } from './x.js'`
|
|
597
|
+
* binds `elements` and exports nothing, so the export check never saw it and
|
|
598
|
+
* the build failed inside rolldown, pointing at a virtual module.
|
|
599
|
+
*
|
|
600
|
+
* @param {object} ast
|
|
601
|
+
* @returns {string[]}
|
|
602
|
+
*/
|
|
603
|
+
function topLevelNames(ast) {
|
|
604
|
+
return ast.body.flatMap((statement) =>
|
|
605
|
+
statement.type === 'ImportDeclaration'
|
|
606
|
+
? statement.specifiers.map((spec) => spec.local.name)
|
|
607
|
+
: declaredNames(statement),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
589
611
|
function patternNames(node) {
|
|
590
612
|
switch (node.type) {
|
|
591
613
|
case 'Identifier':
|
package/src/compiler/shim.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// check it.
|
|
3
3
|
//
|
|
4
4
|
// JavaScript rather than TypeScript, on purpose. A JSDoc `@type` in the
|
|
5
|
-
// author's own `<script props>` is
|
|
5
|
+
// author's own `<script props>` is honored in a .js file and silently ignored
|
|
6
6
|
// in a .ts one. The job is to check what the author wrote, so the shim speaks the
|
|
7
7
|
// same language they do. The scaffolding uses JSDoc too.
|
|
8
8
|
//
|
|
@@ -187,7 +187,7 @@ export function buildEndpointShim(source, { contextType }) {
|
|
|
187
187
|
// Anything not spelled like a method is a helper and gets no signature.
|
|
188
188
|
if (declared?.type === 'VariableDeclaration') {
|
|
189
189
|
const name = declared.declarations[0]?.id?.name;
|
|
190
|
-
// `@satisfies` on the
|
|
190
|
+
// `@satisfies` on the initializer: it contextually types the handler's own
|
|
191
191
|
// `ctx` *and* holds the return type, which an annotation would flatten.
|
|
192
192
|
if (isVerb(name)) edits.push({ at: node.start, insert: `/** @satisfies {${signature}} */\n` });
|
|
193
193
|
continue;
|
|
@@ -465,10 +465,16 @@ function emitModule(block, out, contextType, name = '__Data', binding = '__defau
|
|
|
465
465
|
return;
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
// A loader answering with a Response answers the request, and the template
|
|
469
|
+
// never renders. So the data a template reads is the loader's return with
|
|
470
|
+
// Response taken out of it. Without this, the documented way to write a login
|
|
471
|
+
// guard, `return Response.redirect(...)` from a layout, makes every name in
|
|
472
|
+
// that layout's own markup an error about a union it can never see.
|
|
473
|
+
// `ctx.action` has excluded Response since it was written, for the same reason.
|
|
468
474
|
out.add(
|
|
469
475
|
contextType
|
|
470
|
-
? `/** @typedef {__Shape<Awaited<ReturnType<typeof ${binding}
|
|
471
|
-
: `/** @typedef {__Shape<typeof ${binding}
|
|
476
|
+
? `/** @typedef {__Shape<Exclude<Awaited<ReturnType<typeof ${binding}>>, Response>>} ${name} */\n\n`
|
|
477
|
+
: `/** @typedef {__Shape<Exclude<typeof ${binding}, Response>>} ${name} */\n\n`,
|
|
472
478
|
);
|
|
473
479
|
}
|
|
474
480
|
|
package/src/negotiate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Picks a content encoding from an Accept-Encoding header.
|
|
3
3
|
*
|
|
4
|
-
* Getting this wrong is not a missed
|
|
4
|
+
* Getting this wrong is not a missed optimization, it is a corrupt response: a
|
|
5
5
|
* client that did not ask for brotli must never be handed brotli. So the rules
|
|
6
6
|
* are followed properly: q-values, `*`, and `q=0` as a refusal.
|
|
7
7
|
*
|
package/src/project.js
CHANGED
|
@@ -69,6 +69,34 @@ export function findRoot(from = process.cwd()) {
|
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* What a config means when it says nothing.
|
|
74
|
+
*
|
|
75
|
+
* These were documented as defaults and were not applied anywhere: `loadProject`
|
|
76
|
+
* handed the object back as written, so a config that left `outDir` out reached
|
|
77
|
+
* `path.join(root, undefined)` and threw `ERR_INVALID_ARG_TYPE`, which names
|
|
78
|
+
* neither the key nor the file. The starter templates set every one of them,
|
|
79
|
+
* which is why nothing caught it.
|
|
80
|
+
*
|
|
81
|
+
* `port` is not here. `portOf` already answers it, and it reads the environment
|
|
82
|
+
* first, which a plain default cannot do.
|
|
83
|
+
*/
|
|
84
|
+
const DEFAULTS = {
|
|
85
|
+
appDir: 'app',
|
|
86
|
+
routesDir: 'routes',
|
|
87
|
+
elementsDir: 'elements',
|
|
88
|
+
publicDir: 'public',
|
|
89
|
+
outDir: 'dist',
|
|
90
|
+
typesFile: 'app/transclude-env.d.ts',
|
|
91
|
+
stylesheet: null,
|
|
92
|
+
lang: 'en',
|
|
93
|
+
fragmentParam: 'fragment',
|
|
94
|
+
trailingSlash: 'never',
|
|
95
|
+
strict: false,
|
|
96
|
+
csrf: true,
|
|
97
|
+
csp: false,
|
|
98
|
+
};
|
|
99
|
+
|
|
72
100
|
/**
|
|
73
101
|
* The root and its config together, because nothing needs one without the other.
|
|
74
102
|
*
|
|
@@ -87,7 +115,7 @@ export async function loadProject(from = process.cwd()) {
|
|
|
87
115
|
throw new Error(`[transclude] ${CONFIG_FILE} must export a config object as its default`);
|
|
88
116
|
}
|
|
89
117
|
assertNoSplitDirs(config, file);
|
|
90
|
-
return { root, config, configFile: file };
|
|
118
|
+
return { root, config: { ...DEFAULTS, ...config }, configFile: file };
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
/**
|
package/src/routes.js
CHANGED
|
@@ -145,7 +145,7 @@ function idOf(parts) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
// Static beats dynamic, dynamic beats catch-all, and among equals the longer
|
|
148
|
-
// path wins. Registration order then makes Hono's
|
|
148
|
+
// path wins. Registration order then makes Hono's behavior deterministic
|
|
149
149
|
// rather than something to reason about per-router.
|
|
150
150
|
function bySpecificity(a, b) {
|
|
151
151
|
if (a.hasRest !== b.hasRest) return a.hasRest ? 1 : -1;
|
package/src/runtime/index.js
CHANGED
|
@@ -940,7 +940,7 @@ export function watch(loaders, root = globalThis.document) {
|
|
|
940
940
|
*/
|
|
941
941
|
/**
|
|
942
942
|
* A light element has no shadow root to repaint, and repainting would destroy
|
|
943
|
-
* the children the page put inside it. So it upgrades for
|
|
943
|
+
* the children the page put inside it. So it upgrades for behavior only: the
|
|
944
944
|
* markup it was served is the markup it keeps.
|
|
945
945
|
*
|
|
946
946
|
* @param {object} def
|
|
@@ -949,17 +949,17 @@ export function watch(loaders, root = globalThis.document) {
|
|
|
949
949
|
*/
|
|
950
950
|
export function defineLight(def, init) {
|
|
951
951
|
// Before every other exit below: styles are the half of this that an element
|
|
952
|
-
// with no
|
|
952
|
+
// with no behavior still has, and the half a swapped-in one arrives without.
|
|
953
953
|
adoptStyles(def);
|
|
954
954
|
|
|
955
955
|
if (typeof customElements === 'undefined') return;
|
|
956
956
|
if (customElements.get(def.tag)) return;
|
|
957
|
-
// No
|
|
957
|
+
// No behavior to attach means nothing to register. A light element with no
|
|
958
958
|
// <script> is markup that was already rendered, and it ships no JavaScript at
|
|
959
959
|
// all, accessors included. That is the trade the
|
|
960
960
|
// zero-JS default makes.
|
|
961
961
|
//
|
|
962
|
-
// Being a form control counts as
|
|
962
|
+
// Being a form control counts as behavior: a shadow root is not required to be
|
|
963
963
|
// one, and an element that submits a value has to exist to do it.
|
|
964
964
|
if (!init && !hasMembers(def) && !def.formAssociated && !hasState(def)) return;
|
|
965
965
|
|
|
@@ -1253,7 +1253,7 @@ export function defineComponent(def, init) {
|
|
|
1253
1253
|
this.#adopt();
|
|
1254
1254
|
}
|
|
1255
1255
|
// Runs on every connect, not just the first: moving an element in the DOM
|
|
1256
|
-
// disconnects and reconnects it, and
|
|
1256
|
+
// disconnects and reconnects it, and behavior that was torn down on the
|
|
1257
1257
|
// way out has to come back on the way in.
|
|
1258
1258
|
this.#ready = true;
|
|
1259
1259
|
this.#abort = new AbortController();
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The Hono app both servers start from.
|
|
2
2
|
//
|
|
3
3
|
// One function rather than two `new Hono()` calls, because the order things are
|
|
4
|
-
// registered in *is* the
|
|
4
|
+
// registered in *is* the behavior: a guard registered after the static handler
|
|
5
5
|
// does not guard a prerendered page. Two copies of that order is two servers
|
|
6
6
|
// that disagree, which has happened twice in this codebase already.
|
|
7
7
|
|
|
@@ -83,7 +83,7 @@ export function baseApp(options = {}) {
|
|
|
83
83
|
if (csrfOption) app.use('*', csrf(csrfOption === true ? undefined : csrfOption));
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* The one security header with no
|
|
86
|
+
* The one security header with no judgment in it.
|
|
87
87
|
*
|
|
88
88
|
* Without it a browser may sniff a response's bytes and decide the declared
|
|
89
89
|
* type was wrong, so a text file an app serves can be read as HTML and a
|
package/src/static-cache.js
CHANGED
|
@@ -41,7 +41,7 @@ const TYPES = {
|
|
|
41
41
|
* @property {number} onDisk left on disk because the budget ran out
|
|
42
42
|
* @property {number} encoded how many have a precompressed variant
|
|
43
43
|
* @property {Map<string, Entry|{ file: string }>} entries for the build, which
|
|
44
|
-
*
|
|
44
|
+
* serializes these for a runtime with no filesystem
|
|
45
45
|
* @property {(pathname: string) => Entry|null} get
|
|
46
46
|
*/
|
|
47
47
|
|
|
@@ -94,7 +94,7 @@ function load(dir, urlFor, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
|
94
94
|
count: entries.size,
|
|
95
95
|
bytes,
|
|
96
96
|
onDisk,
|
|
97
|
-
/** For the build, which
|
|
97
|
+
/** For the build, which serializes these for runtimes with no filesystem. */
|
|
98
98
|
entries,
|
|
99
99
|
encoded: [...entries.values()].filter((e) => e.encodings?.size).length,
|
|
100
100
|
|
package/src/typecheck.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// and rewriting is where source mapping breaks down.
|
|
7
7
|
//
|
|
8
8
|
// JavaScript rather than TypeScript because a JSDoc `@type` in the author's own
|
|
9
|
-
// `<script props>` is
|
|
9
|
+
// `<script props>` is honored in a .js file and silently ignored in a .ts one.
|
|
10
10
|
//
|
|
11
11
|
// Shims are self-contained: route contexts and component props are inlined as
|
|
12
12
|
// type literals rather than imported. transclude-env.d.ts is written *from* the shims,
|