@transclude/core 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +65 -14
- package/bin/build.js +22 -4
- package/bin/check.js +30 -8
- package/bin/dev.js +10 -2
- package/bin/release.js +19 -4
- package/package.json +13 -4
- package/skills/transclude/SKILL.md +219 -0
- package/skills/transclude/references/elements.md +206 -0
- package/skills/transclude/references/fragments.md +168 -0
- package/skills/transclude/references/server.md +155 -0
- package/src/address.js +9 -2
- package/src/app.js +110 -81
- package/src/compiler/ambient.js +99 -0
- package/src/compiler/bind.js +34 -27
- package/src/compiler/codegen.js +59 -35
- package/src/compiler/directives.js +29 -0
- package/src/compiler/expr.js +10 -1
- package/src/compiler/html.js +41 -0
- package/src/compiler/index.js +63 -11
- package/src/compiler/script.js +40 -11
- package/src/compiler/shim.js +24 -45
- package/src/compiler/types.js +32 -5
- package/src/csp.js +7 -1
- package/src/document.js +63 -12
- package/src/extract.js +9 -8
- package/src/negotiate.js +1 -1
- package/src/plugin.js +28 -7
- package/src/precache.js +11 -1
- package/src/project.js +29 -1
- package/src/proxy.js +9 -1
- package/src/rewrite.js +12 -5
- package/src/routes.js +1 -1
- package/src/runtime/index.js +12 -9
- package/src/server.js +2 -2
- package/src/sitemap.js +3 -3
- package/src/static-cache.js +6 -3
- package/src/typecheck.js +105 -11
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
|
|
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,
|
|
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
|
|
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
|
-
- **
|
|
78
|
-
the ETag. A `Link: rel=preload` goes out first, so a proxy can turn
|
|
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
|
|
100
|
-
npm run showcase # the
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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(
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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(` ${
|
|
88
|
+
console.log(` ${pad}${run}`);
|
|
68
89
|
}
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.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:
|
|
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
|
-
"
|
|
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
|