@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Joe Dakroub
3
+ Copyright (c) 2026 Atelier Dakroub
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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, and ships no client JavaScript
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
- - **No streaming.** The body is buffered so it can be hashed, which is what buys
78
- the ETag. A `Link: rel=preload` goes out first, so a proxy can turn it into a
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 demo's, against a build
100
- npm run showcase # the demo on http://localhost:1961
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/showcase` is an app built against this package the same way any other
105
- project would be. It is where the browser checks live, because those need an app
106
- to run against, and it is what the four runtimes are checked with. `docs/` is the
107
- site at transclude.dev, and is itself built with the framework.
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 # once, puts create-transclude on PATH
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, { recursive: true });
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
- ['the demo', 'npm', ['run', 'test:examples']],
94
- ['the docs', 'npm', ['test', '--prefix', 'docs']],
95
- ['the docs types', 'npm', ['run', 'check', '--prefix', 'docs']],
96
- ['the docs build', 'npm', ['run', 'build', '--prefix', 'docs']],
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.1.1",
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:docs": "npm test --prefix docs",
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
- "docs": "npm run dev --prefix docs",
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.
@@ -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) => {
@@ -15,7 +15,13 @@ import {
15
15
 
16
16
  export { CompileError, ScriptError };
17
17
 
18
- const PAGE_EXPORTS = new Set(['css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs', 'layouts', 'client', 'elements', 'headScript']);
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, {
@@ -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 exporting a name the generated module already uses.
510
+ * Guards against a block using a name the generated module already defines.
510
511
  *
511
- * @param {string[]} exports
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(exports, reserved, label) {
518
- for (const name of exports) {
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}: exports "${name}", which the generated module already defines. ` +
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':
@@ -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 honoured in a .js file and silently ignored
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 initialiser: it contextually types the handler's own
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}>>>} ${name} */\n\n`
471
- : `/** @typedef {__Shape<typeof ${binding}>} ${name} */\n\n`,
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 optimisation, it is a corrupt response: a
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 behaviour deterministic
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;
@@ -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 behaviour only: the
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 behaviour still has, and the half a swapped-in one arrives without.
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 behaviour to attach means nothing to register. A light element with 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 behaviour: a shadow root is not required to be
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 behaviour that was torn down on the
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 behaviour: a guard registered after the static handler
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 judgement in it.
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
@@ -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
- * serialises these for a runtime with no filesystem
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 serialises these for runtimes with no filesystem. */
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 honoured in a .js file and silently ignored in a .ts one.
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,