@transclude/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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
@@ -1,11 +1,28 @@
1
- # transclude
1
+ <h1 align="center">transclude</h1>
2
+
3
+ <p align="center">An HTML-first server-side web framework.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml"
7
+ ><img alt="CI" src="https://github.com/transclude-dev/transclude/actions/workflows/ci.yml/badge.svg"
8
+ /></a>
9
+ <a href="https://www.npmjs.com/package/@transclude/core"
10
+ ><img alt="npm" src="https://img.shields.io/npm/v/%40transclude%2Fcore?color=0b7285"
11
+ /></a>
12
+ <a href="https://transclude.dev/docs/runtimes"
13
+ ><img alt="node" src="https://img.shields.io/node/v/%40transclude%2Fcore?color=0b7285"
14
+ /></a>
15
+ <a href="https://github.com/transclude-dev/transclude/blob/main/LICENSE"
16
+ ><img alt="MIT" src="https://img.shields.io/npm/l/%40transclude%2Fcore?color=0b7285"
17
+ /></a>
18
+ </p>
2
19
 
3
20
  HTML is the product. A page is an `.html` file, the directory tree is the route
4
21
  table, and any fragment of a page is a URL of its own. Nothing has to run in the
5
22
  browser for the page to be correct.
6
23
 
7
- The same app runs on Node, Bun, Deno and workerd, and ships no client JavaScript
8
- by default.
24
+ The same app runs on Node, Bun, Deno and workerd, the runtime behind
25
+ Cloudflare Workers, and ships no client JavaScript by default.
9
26
 
10
27
  **[transclude.dev](https://transclude.dev)** has the documentation.
11
28
 
@@ -52,7 +69,7 @@ so a swap cannot drift from the page it replaces part of.
52
69
 
53
70
  ## What is in it
54
71
 
55
- - **Pages and endpoints.** An `.html` file answers GET; its `POST`, `PUT`,
72
+ - **Pages and endpoints.** An `.html` file responds to GET; its `POST`, `PUT`,
56
73
  `PATCH` and `DELETE` exports answer the rest, so a plain `<form method="post">`
57
74
  works. A `.js` file in the same tree returns a `Response`.
58
75
  - **Fragments.** Mark an element `fragment` and it has a URL of its own. htmx,
@@ -74,9 +91,10 @@ so a swap cannot drift from the page it replaces part of.
74
91
 
75
92
  - **No client-side router, and no swapper.** Every link is a document request
76
93
  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.
94
+ - **A page does not stream.** Its body is buffered so it can be hashed, which is
95
+ what buys the ETag. A `Link: rel=preload` goes out first, so a proxy can turn
96
+ it into a 103 while the page is still being made. An endpoint returns a
97
+ `Response` you build, so it can answer with a `ReadableStream` and stay open.
80
98
  - **No session store and no database opinion.** Signed cookies are the building
81
99
  block.
82
100
  - **No byte ranges on workerd.** A Range request gets 200 rather than 206.
@@ -96,26 +114,59 @@ so a swap cannot drift from the page it replaces part of.
96
114
  ```sh
97
115
  npm install
98
116
  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
117
+ npm run test:examples # the examples', against a build
118
+ npm run showcase # the showcase on http://localhost:1961
119
+ npm run todomvc # TodoMVC on http://localhost:1962
120
+ npm run blog # a prerendered blog on http://localhost:1963
121
+ npm run search # search over a fragment on http://localhost:1964
122
+ npm run htmx # the same, driven by htmx, on http://localhost:1965
123
+ npm run includes # transclusion on http://localhost:1966
124
+ npm run auth # a guarded section on http://localhost:1967
125
+ npm run live # server-sent events on http://localhost:1968
126
+ npm run elements # light and shadow elements on http://localhost:1969
101
127
  npm run check:src # type-check the framework itself
102
128
  ```
103
129
 
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.
130
+ `examples/` holds apps built against this package the same way any other project
131
+ would be. `todomvc` is TodoMVC with forms and no client JavaScript, `blog` is a
132
+ prerendered site with a sitemap and a feed, `search` swaps a fragment into a
133
+ page that works without it, `htmx` does the same with htmx and the
134
+ `HX-Target` header, `includes` shows transclusion from three sources,
135
+ `auth` guards a section with a layout and a signed cookie, `live` pushes
136
+ updates over server-sent events, `elements` puts a light and a shadow element
137
+ side by side, and `showcase` uses every feature and is where the
138
+ browser checks live, because those need an app to run against. `www/` is the site at transclude.dev: a landing page, the
139
+ documentation under `/docs`, and itself built with the framework.
108
140
 
109
141
  ### Trying the CLI against this checkout
110
142
 
111
143
  ```sh
112
- npm link # once, puts create-transclude on PATH
144
+ cd create && npm link && cd .. # once, puts create-transclude on PATH
113
145
  create-transclude my-app --template blank --link
114
146
  ```
115
147
 
116
148
  `--link` points the new project at this checkout rather than the registry, which
117
149
  is what you want while changing the framework: an edit here is an edit there.
118
150
 
151
+ ## Working with an AI agent
152
+
153
+ `skills/transclude/` is an [Agent Skill](https://agentskills.io): the framework's
154
+ conventions, its API and the mistakes it refuses, in the format Claude Code,
155
+ Cursor, Copilot and others read. It ships with the package, so an installed
156
+ project has it at `node_modules/@transclude/core/skills/transclude/`.
157
+
158
+ Every HTML example in it is compiled by the real compiler in `npm test`. A skill
159
+ is documentation an agent acts on without a human reading it first, so an
160
+ example that stops compiling is worse than a missing one.
161
+
162
+ ## Contributing
163
+
164
+ [CONTRIBUTING.md](CONTRIBUTING.md) covers the layout, the tests and the writing
165
+ style. Everyone taking part agrees to the
166
+ [Code of Conduct](CODE_OF_CONDUCT.md).
167
+
168
+ Security problems go to admin@dakroub.co, not to a public issue.
169
+
119
170
  ## License
120
171
 
121
172
  MIT
package/bin/build.js CHANGED
@@ -16,7 +16,7 @@ import { pathToFileURL } from 'node:url';
16
16
  import { build } from 'vite';
17
17
  import transclude from '../src/plugin.js';
18
18
  import { loadProject } from '../src/project.js';
19
- import { absoluteFrom, renderRoute, responseOf } from '../src/document.js';
19
+ import { absoluteFrom, renderRoute, responseOf, urlFor } from '../src/document.js';
20
20
  import { feed, feedPath } from '../src/feed.js';
21
21
  import { includeContext } from '../src/include.js';
22
22
  import { nodeLookup } from '../src/lookup.js';
@@ -111,9 +111,15 @@ await build({
111
111
  },
112
112
  });
113
113
 
114
+ // A worker entry imports this bundle, and an editor set to check the app's JS
115
+ // then checks a file nobody wrote. Twenty errors in the showcase, all of them
116
+ // about generated code. The banner is what keeps it out of that program.
117
+ const entry = path.join(dist, 'server/entry.js');
118
+ fs.writeFileSync(entry, `// @ts-nocheck\n${fs.readFileSync(entry, 'utf8')}`);
119
+
114
120
  // ---- prerender ------------------------------------------------------------
115
121
 
116
- const { pages } = await import(pathToFileURL(path.join(dist, 'server/entry.js')).href);
122
+ const { pages } = await import(pathToFileURL(entry).href);
117
123
 
118
124
  /**
119
125
  * A static route has one URL. A dynamic route has as many as its `paths` export
@@ -134,7 +140,7 @@ async function urlsFor(route) {
134
140
 
135
141
  const listed = (await paths()) ?? [];
136
142
  return listed.map((params) => ({
137
- url: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
143
+ url: urlFor(route, params),
138
144
  params,
139
145
  }));
140
146
  }
@@ -316,8 +322,20 @@ const publicSrc = config.publicDir
316
322
  const publicOut = path.join(dist, 'public');
317
323
  let publicFiles = 0;
318
324
 
325
+ /**
326
+ * What an operating system leaves in a directory, which nobody put there.
327
+ *
328
+ * These were copied into the build and served: `/.DS_Store` answered 200 on the
329
+ * docs site and lists every file beside it. Dotfiles are not skipped wholesale,
330
+ * because `.well-known` is a directory people mean to publish.
331
+ */
332
+ const JUNK = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
333
+
319
334
  if (publicSrc && fs.existsSync(publicSrc)) {
320
- fs.cpSync(publicSrc, publicOut, { recursive: true });
335
+ fs.cpSync(publicSrc, publicOut, {
336
+ recursive: true,
337
+ filter: (from) => !JUNK.has(path.basename(from)),
338
+ });
321
339
  publicFiles = countFiles(publicOut);
322
340
  }
323
341
 
package/bin/check.js CHANGED
@@ -23,7 +23,22 @@ if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
23
23
 
24
24
  // Nothing downstream reads this file, so nothing else would notice it being
25
25
  // wrong. Parse what we just wrote, or a bad identifier ships silently.
26
- const emitted = ts.createProgram([types], { noEmit: true, skipLibCheck: true });
26
+ //
27
+ // `skipLibCheck` has to be off, and it was on. This is a .d.ts, which is the one
28
+ // kind of file that flag skips, so the guard checked nothing at all: every
29
+ // project shipped a file naming `__Cookies` and declaring it nowhere. An editor
30
+ // missed it too, because a jsconfig.json implies the same flag.
31
+ //
32
+ // `types: []` keeps it to this file: whatever `@types` a project happens to have
33
+ // installed is not what is being checked here, and one of them failing to
34
+ // resolve its own dependency would read as our file being broken.
35
+ const emitted = ts.createProgram([types], {
36
+ noEmit: true,
37
+ skipLibCheck: false,
38
+ types: [],
39
+ target: ts.ScriptTarget.ESNext,
40
+ lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
41
+ });
27
42
  const broken = [
28
43
  ...emitted.getSyntacticDiagnostics(),
29
44
  ...emitted.getSemanticDiagnostics(),
@@ -63,16 +78,23 @@ for (const file of files) {
63
78
  const text = lines[line - 1] ?? '';
64
79
  const trimmed = text.replace(/^\s+/, '');
65
80
  const shift = text.length - trimmed.length;
81
+ // The caret line is drawn under the trimmed source, so the column moves left
82
+ // by however much indentation was cut. A run is capped so one long span does
83
+ // not wrap the terminal.
84
+ const pad = ' '.repeat(Math.max(0, column - shift));
85
+ const run = '~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)));
86
+
66
87
  console.log(`\n ${trimmed}`);
67
- console.log(` ${' '.repeat(Math.max(0, column - shift))}${'~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)))}`);
88
+ console.log(` ${pad}${run}`);
68
89
  }
69
90
  }
70
91
 
71
- const total = errors + warnings;
72
- console.log(
73
- total
74
- ? `\n${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} in ${files.length} files`
75
- : `\nNo type errors in ${files.length} files.`,
76
- );
92
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
93
+
94
+ if (errors + warnings) {
95
+ console.log(`\n${plural(errors, 'error')}, ${plural(warnings, 'warning')} in ${files.length} files`);
96
+ } else {
97
+ console.log(`\nNo type errors in ${files.length} files.`);
98
+ }
77
99
 
78
100
  process.exitCode = errors ? 1 : 0;
package/bin/dev.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  runAction,
20
20
  withEnvelope,
21
21
  } from '../src/document.js';
22
- import { clientEntryUrl, pageModuleId } from '../src/plugin.js';
22
+ import transclude, { clientEntryUrl, pageModuleId } from '../src/plugin.js';
23
23
  import { resolveRoutesDir, scanRoutes } from '../src/routes.js';
24
24
  import { baseApp, endpointMethods, runEndpoint, SERVER_FILE } from '../src/server.js';
25
25
  import { randomBytes } from 'node:crypto';
@@ -71,6 +71,11 @@ const server = http.createServer();
71
71
  const vite = await createViteServer({
72
72
  root,
73
73
  appType: 'custom',
74
+ // Passed here rather than left to the project's own `vite.config.js`, which is
75
+ // where dev used to get it. A project needs no Vite config at all, and the one
76
+ // built here is the one `loadProject` filled in, so dev compiles against the
77
+ // same config the build does. `configResolved` ignores a second registration.
78
+ plugins: [transclude(config)],
74
79
  server: { middlewareMode: true, hmr: { server } },
75
80
  // Vite would serve these itself, ahead of Hono, and production would serve
76
81
  // them a different way, which is how dev and production come to disagree. One
@@ -229,7 +234,10 @@ async function loadMiddleware() {
229
234
  if (!fs.existsSync(serverFile)) return null;
230
235
 
231
236
  const url = `/${config.appDir}/${SERVER_FILE}`;
232
- const node = await vite.moduleGraph.getModuleByUrl(url, true);
237
+ // Vite's second argument is `ssr`. This module is only ever loaded through
238
+ // `ssrLoadModule`, so the SSR graph is the one holding it.
239
+ const ssr = true;
240
+ const node = await vite.moduleGraph.getModuleByUrl(url, ssr);
233
241
  if (node) vite.moduleGraph.invalidateModule(node);
234
242
 
235
243
  const mod = await vite.ssrLoadModule(url);
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.3.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,23 @@
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 && npm test --prefix examples/elements",
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
+ "elements": "npm run dev --prefix examples/elements",
71
+ "www": "npm run dev --prefix www",
63
72
  "check:src": "tsc -p tsconfig.src.json",
64
73
  "release": "node bin/release.js"
65
74
  },
@@ -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 responds to 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