@transclude/core 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/build.js +469 -0
  4. package/bin/check.js +78 -0
  5. package/bin/dev.js +348 -0
  6. package/bin/release.js +176 -0
  7. package/bin/serve.bun.js +15 -0
  8. package/bin/serve.deno.js +15 -0
  9. package/bin/serve.js +12 -0
  10. package/editor/server.js +172 -0
  11. package/editor/vscode/extension.js +49 -0
  12. package/editor/vscode/package.json +32 -0
  13. package/editor/vscode/syntaxes/transclude.injection.json +41 -0
  14. package/package.json +82 -0
  15. package/src/address.js +183 -0
  16. package/src/app.js +492 -0
  17. package/src/cache.js +137 -0
  18. package/src/compiler/bind.js +496 -0
  19. package/src/compiler/codegen.js +1061 -0
  20. package/src/compiler/expr.js +221 -0
  21. package/src/compiler/index.js +964 -0
  22. package/src/compiler/interp.js +82 -0
  23. package/src/compiler/script.js +620 -0
  24. package/src/compiler/shim.js +756 -0
  25. package/src/compiler/sourcemap.js +140 -0
  26. package/src/compiler/types.js +163 -0
  27. package/src/compress.js +104 -0
  28. package/src/cookies.js +157 -0
  29. package/src/csp.js +192 -0
  30. package/src/document.js +604 -0
  31. package/src/extract.js +339 -0
  32. package/src/feed.js +194 -0
  33. package/src/include.js +89 -0
  34. package/src/lookup.js +49 -0
  35. package/src/negotiate.js +95 -0
  36. package/src/plugin.js +423 -0
  37. package/src/pool.js +29 -0
  38. package/src/precache.js +68 -0
  39. package/src/production.js +159 -0
  40. package/src/project.js +110 -0
  41. package/src/proxy.js +319 -0
  42. package/src/public-files.js +77 -0
  43. package/src/rewrite.js +281 -0
  44. package/src/routes.js +199 -0
  45. package/src/runtime/index.js +1345 -0
  46. package/src/server.js +183 -0
  47. package/src/sitemap.js +124 -0
  48. package/src/static-cache.js +170 -0
  49. package/src/typecheck.js +492 -0
  50. package/src/worker.js +87 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Dakroub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # transclude
2
+
3
+ HTML is the product. A page is an `.html` file, the directory tree is the route
4
+ table, and any fragment of a page is a URL of its own. Nothing has to run in the
5
+ browser for the page to be correct.
6
+
7
+ The same app runs on Node, Bun, Deno and workerd, and ships no client JavaScript
8
+ by default.
9
+
10
+ **[transclude.dev](https://transclude.dev)** has the documentation.
11
+
12
+ ```sh
13
+ npm create @transclude my-app
14
+ cd my-app
15
+ npm install
16
+ npm run dev
17
+ ```
18
+
19
+ ## What a page looks like
20
+
21
+ ```html
22
+ <script server>
23
+ import { notes } from '../data/notes.js';
24
+
25
+ // Answers GET. Whatever it returns is what the template reads.
26
+ export default async () => ({ notes: notes.all() });
27
+
28
+ // Answers everything else. A <form method="post"> reaches this.
29
+ export const POST = async ({ request, url }) => {
30
+ notes.add((await request.formData()).get('text'));
31
+ // 303, so a reload is a GET and does not submit again.
32
+ return Response.redirect(new URL(url).origin + '/notes', 303);
33
+ };
34
+ </script>
35
+
36
+ <title>Notes</title>
37
+
38
+ <form method="post">
39
+ <input name="text" required />
40
+ <button>Add</button>
41
+ </form>
42
+
43
+ <!-- An id plus `fragment` makes this a resource: /notes?fragment=list -->
44
+ <ul id="list" fragment>
45
+ <li each="note of notes">${note.text}</li>
46
+ </ul>
47
+ ```
48
+
49
+ That page works with JavaScript turned off. It also answers
50
+ `GET /notes?fragment=list` with just the `<ul>`, from the same compiled markup,
51
+ so a swap cannot drift from the page it replaces part of.
52
+
53
+ ## What is in it
54
+
55
+ - **Pages and endpoints.** An `.html` file answers GET; its `POST`, `PUT`,
56
+ `PATCH` and `DELETE` exports answer the rest, so a plain `<form method="post">`
57
+ works. A `.js` file in the same tree returns a `Response`.
58
+ - **Fragments.** Mark an element `fragment` and it has a URL of its own. htmx,
59
+ Turbo or a short `fetch` swaps it in. The framework ships nothing that does.
60
+ - **Includes.** `<transclude src="#id">` puts a fragment in a second place,
61
+ `src="/other#id"` reads another route of the app, and `src="https://…#id"`
62
+ reads a document somebody else wrote, through an allowlist.
63
+ - **Elements.** An `.html` file in `app/elements/` becomes a custom element.
64
+ Light DOM by default: no boundary, page CSS reaches it, `<label for>` works,
65
+ and it ships no JavaScript. `export const shadow = true` opts into a shadow
66
+ root and a re-render on an attribute change.
67
+ - **Types without writing TypeScript.** `npm run check` catches a misspelled
68
+ field, an unknown prop and a wrong-typed one, from the shapes your loaders
69
+ return. Annotations are optional.
70
+ - **A build that is files.** Prerendered pages, compressed once at rest, with a
71
+ strong ETag per encoding. `dist/static` is self-contained.
72
+
73
+ ## What it does not do
74
+
75
+ - **No client-side router, and no swapper.** Every link is a document request
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.
80
+ - **No session store and no database opinion.** Signed cookies are the building
81
+ block.
82
+ - **No byte ranges on workerd.** A Range request gets 200 rather than 206.
83
+ Ranges are what a filesystem buys, and a worker has none.
84
+ - **`@scope` is soft scoping.** A light element's styles lose to page CSS of
85
+ equal specificity: right for content, a hazard for widgets.
86
+
87
+ ## The packages
88
+
89
+ | | |
90
+ | --- | --- |
91
+ | [`@transclude/core`](https://www.npmjs.com/package/@transclude/core) | the framework |
92
+ | [`@transclude/create`](https://www.npmjs.com/package/@transclude/create) | `npm create @transclude` |
93
+
94
+ ## Working on it
95
+
96
+ ```sh
97
+ npm install
98
+ 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
101
+ npm run check:src # type-check the framework itself
102
+ ```
103
+
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.
108
+
109
+ ### Trying the CLI against this checkout
110
+
111
+ ```sh
112
+ npm link # once, puts create-transclude on PATH
113
+ create-transclude my-app --template blank --link
114
+ ```
115
+
116
+ `--link` points the new project at this checkout rather than the registry, which
117
+ is what you want while changing the framework: an edit here is an edit there.
118
+
119
+ ## License
120
+
121
+ MIT
package/bin/build.js ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ // Production build.
3
+ //
4
+ // dist/client hashed, minified client entries, one per route that needs JS
5
+ // dist/server the SSR bundle, plain ESM, no Vite at runtime
6
+ // dist/static prerendered HTML for every route whose URLs are knowable
7
+ // dist/routes.json what is left for the server to render on demand
8
+ //
9
+ // Page and layout CSS is inlined into <head> by the document assembler, and
10
+ // component CSS lives inside each shadow root, so there are no stylesheet
11
+ // assets to emit or link.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { build } from 'vite';
17
+ import transclude from '../src/plugin.js';
18
+ import { loadProject } from '../src/project.js';
19
+ import { absoluteFrom, renderRoute, responseOf } from '../src/document.js';
20
+ import { feed, feedPath } from '../src/feed.js';
21
+ import { includeContext } from '../src/include.js';
22
+ import { nodeLookup } from '../src/lookup.js';
23
+ import { sitemap } from '../src/sitemap.js';
24
+ import { etagOf, loadAssets, loadStatic } from '../src/static-cache.js';
25
+ import { PRECACHE_PATH, precacheDocument, precacheList } from '../src/precache.js';
26
+ import { cookiesOf } from '../src/cookies.js';
27
+ import { pool } from '../src/pool.js';
28
+ import { precompress } from '../src/compress.js';
29
+
30
+ const { root, config } = await loadProject();
31
+ const dist = path.join(root, config.outDir);
32
+
33
+ const plugin = transclude(config);
34
+ plugin.api.configure({ root });
35
+ const manifest = plugin.api.manifest();
36
+
37
+ fs.rmSync(dist, { recursive: true, force: true });
38
+
39
+ // ---- client ---------------------------------------------------------------
40
+
41
+ // `needed` is decided in the plugin, which is also what the dev server asks. Two
42
+ // copies of that rule is two servers that disagree about which pages ship JS.
43
+ const clientRoutes = [...manifest.routes, manifest.notFound, manifest.error]
44
+ .filter(Boolean)
45
+ .filter((route) => route.client.needed);
46
+
47
+ const assets = new Map();
48
+
49
+ // A prerendered page reads its sources once, here, and carries the result. That
50
+ // is the whole reason includes are resolved before the render rather than during
51
+ // it: a page written to a file cannot fetch anything later.
52
+ const include = includeContext({
53
+ config,
54
+ routes: manifest.routes ?? [],
55
+ pageFor: (id) => pages[id],
56
+ lookup: nodeLookup(),
57
+ });
58
+ let stylesheet = null;
59
+
60
+ const clientInput = Object.fromEntries(
61
+ clientRoutes.map((route) => [route.id, `virtual:transclude-client/${route.id}`]),
62
+ );
63
+ // The site stylesheet is an entry of its own, so Vite processes it and rollup
64
+ // hashes it. One cacheable file shared by every page.
65
+ if (config.stylesheet) clientInput.__global = path.join(root, config.stylesheet);
66
+
67
+ if (Object.keys(clientInput).length) {
68
+ const output = await build({
69
+ root,
70
+ logLevel: 'warn',
71
+ plugins: [transclude(config)],
72
+ // Copied once, here, into dist/public. Vite would otherwise put a copy in both
73
+ // the client and the SSR output, and neither is where it is served from.
74
+ publicDir: false,
75
+ build: {
76
+ outDir: `${config.outDir}/client`,
77
+ emptyOutDir: true,
78
+ rollupOptions: { input: clientInput },
79
+ },
80
+ });
81
+
82
+ const chunks = (Array.isArray(output) ? output[0] : output).output;
83
+ for (const chunk of chunks) {
84
+ if (chunk.type === 'asset' && chunk.fileName.endsWith('.css')) {
85
+ stylesheet = `/${chunk.fileName}`;
86
+ continue;
87
+ }
88
+ if (chunk.isEntry && chunk.name && chunk.name !== '__global') {
89
+ assets.set(chunk.name, `/${chunk.fileName}`);
90
+ }
91
+ }
92
+ }
93
+
94
+ // ---- server ---------------------------------------------------------------
95
+
96
+ await build({
97
+ root,
98
+ logLevel: 'warn',
99
+ plugins: [transclude(config)],
100
+ publicDir: false,
101
+ build: {
102
+ outDir: `${config.outDir}/server`,
103
+ emptyOutDir: true,
104
+ // `ssr: true` rather than a path: Vite resolves a string entry against the
105
+ // project root before any plugin sees it, which a virtual id cannot survive.
106
+ ssr: true,
107
+ rollupOptions: {
108
+ input: { entry: 'virtual:transclude-server' },
109
+ output: { entryFileNames: '[name].js' },
110
+ },
111
+ },
112
+ });
113
+
114
+ // ---- prerender ------------------------------------------------------------
115
+
116
+ const { pages } = await import(pathToFileURL(path.join(dist, 'server/entry.js')).href);
117
+
118
+ /**
119
+ * A static route has one URL. A dynamic route has as many as its `paths` export
120
+ * names, and none at all if it does not export one, in which case it stays a
121
+ * server render.
122
+ *
123
+ * `export const prerender = false` is the third case: a page whose output
124
+ * depends on something no build can know, which is almost always the query
125
+ * string. A prerendered file is one file for every URL that resolves to it, so
126
+ * `?q=` cannot change it.
127
+ */
128
+ async function urlsFor(route) {
129
+ if (pages[route.id]?.prerender === false) return [];
130
+ if (!route.params.length) return [{ url: route.pattern, params: {} }];
131
+
132
+ const paths = pages[route.id]?.paths;
133
+ if (typeof paths !== 'function') return [];
134
+
135
+ const listed = (await paths()) ?? [];
136
+ return listed.map((params) => ({
137
+ url: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
138
+ params,
139
+ }));
140
+ }
141
+
142
+ /**
143
+ * A prerendered file has no status and no headers. It is a file. So a loader
144
+ * that answered with a Response, or set a status other than 200, is saying this
145
+ * URL is not a page you can write down, and the build says so rather than
146
+ * writing a file that lies about it.
147
+ */
148
+ async function render(route, { url, params }) {
149
+ const response = responseOf();
150
+ const ctx = {
151
+ url: `http://localhost${url}`,
152
+ params,
153
+ route: { id: route.id, pattern: route.pattern ?? '', path: url },
154
+ request: null,
155
+ fragment: null,
156
+ action: null,
157
+ response,
158
+ cookies: cookiesOf(null, response, config.cookieSecret),
159
+ absolute: absoluteFrom(config.metadataBase, null),
160
+ };
161
+
162
+ const html = await renderRoute(pages[route.id], ctx, {
163
+ clientEntry: assets.get(route.id) ?? null,
164
+ stylesheet,
165
+ csp: config.csp,
166
+ lang: config.lang,
167
+ include,
168
+ });
169
+
170
+ if (html instanceof Response) {
171
+ throw new Error(`answered with ${html.status} instead of markup, so it cannot be prerendered`);
172
+ }
173
+ if (ctx.response.status !== 200) {
174
+ throw new Error(`answered ${ctx.response.status}, which no file can carry`);
175
+ }
176
+ // A file carries no headers either. A Set-Cookie or a Cache-Control written here
177
+ // would be thrown away, which is worse than being told.
178
+ const [header] = [...ctx.response.headers.keys()];
179
+ if (header) {
180
+ throw new Error(`set a ${header} header, which no file can carry`);
181
+ }
182
+ // Reading a cookie is what makes a page personal, and there is no request
183
+ // here to read one from. Whatever this file says about the reader is what a
184
+ // reader with no cookies would have seen, and every visitor gets that copy.
185
+ // A layout or an included route can do this without the page mentioning it,
186
+ // which is what makes it worth saying out loud.
187
+ if (ctx.cookies.personal) {
188
+ throw new Error(
189
+ `read a cookie, so it is different for each visitor and cannot be one file. ` +
190
+ `Give it \`export const prerender = false\`, or stop reading the cookie ` +
191
+ `here or in what it includes`,
192
+ );
193
+ }
194
+ return html;
195
+ }
196
+
197
+ const write = (relative, html) => {
198
+ const file = path.join(dist, 'static', relative);
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ fs.writeFileSync(file, html);
201
+ return relative;
202
+ };
203
+
204
+ // Ask every route for its URLs first, so the render pass is one flat list and
205
+ // can run wide rather than route by route.
206
+ const dynamic = [];
207
+ const targets = [];
208
+
209
+ for (const route of manifest.routes) {
210
+ const urls = await urlsFor(route);
211
+ if (!urls.length) {
212
+ dynamic.push(route);
213
+ continue;
214
+ }
215
+ for (const target of urls) targets.push({ route, target });
216
+ }
217
+
218
+ if (manifest.notFound) {
219
+ targets.push({
220
+ route: { ...manifest.notFound, pattern: '' },
221
+ target: { url: '/404', params: {} },
222
+ file: '404.html',
223
+ label: '404.html (not-found page)',
224
+ });
225
+ }
226
+
227
+ if (manifest.error) {
228
+ targets.push({
229
+ route: { ...manifest.error, pattern: '' },
230
+ target: { url: '/500', params: {} },
231
+ file: '500.html',
232
+ label: '500.html (error page)',
233
+ });
234
+ }
235
+
236
+ const CONCURRENCY = Number(process.env.TRANSCLUDE_BUILD_CONCURRENCY ?? 8);
237
+
238
+ const outcomes = await pool(targets, CONCURRENCY, async ({ route, target, file, label }) => {
239
+ const rel =
240
+ file ?? (target.url === '/' ? 'index.html' : `${target.url.replace(/^\//, '')}/index.html`);
241
+ try {
242
+ write(rel, await render(route, target));
243
+ return { url: label ?? target.url, ok: true };
244
+ } catch (err) {
245
+ // One bad page should not take the build down without saying which.
246
+ return { url: target.url, ok: false, error: err };
247
+ }
248
+ });
249
+
250
+ const failures = outcomes.filter((outcome) => !outcome.ok);
251
+ const prerendered = outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.url);
252
+
253
+ // A file, like every other page. The served route answers the same document, but
254
+ // `dist/static` is meant to be servable by a host that runs none of this, and a
255
+ // site with no sitemap there would be missing one only on the host that needs it
256
+ // written down most.
257
+ if (config.sitemap) {
258
+ write('sitemap.xml', await sitemap({ routes: manifest.routes }, pages, config.sitemap));
259
+ prerendered.push('/sitemap.xml');
260
+ }
261
+
262
+ if (config.feed) {
263
+ const at = feedPath(config.feed);
264
+ write(at.replace(/^\//, ''), await feed(config.feed));
265
+ prerendered.push(at);
266
+ }
267
+
268
+ if (failures.length) {
269
+ console.error(`\n${failures.length} page${failures.length === 1 ? '' : 's'} failed to render:`);
270
+ for (const failure of failures) {
271
+ console.error(` ${failure.url}\n ${failure.error.message}`);
272
+ }
273
+ process.exitCode = 1;
274
+ }
275
+
276
+ fs.writeFileSync(
277
+ path.join(dist, 'routes.json'),
278
+ JSON.stringify(
279
+ {
280
+ dynamic: dynamic.map((route) => ({
281
+ id: route.id,
282
+ pattern: route.pattern,
283
+ params: route.params,
284
+ client: assets.get(route.id) ?? null,
285
+ })),
286
+ // Every route, prerendered or not. A fragment is rendered on demand even
287
+ // where the document it belongs to was written to a file at build time. Its
288
+ // data is a request away, and its URL is a query on the same path.
289
+ routes: manifest.routes.map((route) => ({
290
+ id: route.id,
291
+ pattern: route.pattern,
292
+ params: route.params,
293
+ client: assets.get(route.id) ?? null,
294
+ })),
295
+ // Never prerendered: an endpoint answers with a Response, and a file
296
+ // cannot carry one.
297
+ endpoints: manifest.endpoints.map((route) => ({
298
+ id: route.id,
299
+ pattern: route.pattern,
300
+ params: route.params,
301
+ })),
302
+ notFound: manifest.notFound ? { id: manifest.notFound.id } : null,
303
+ error: manifest.error ? { id: manifest.error.id } : null,
304
+ stylesheet,
305
+ },
306
+ null,
307
+ 2,
308
+ ),
309
+ );
310
+
311
+ // ---- public ---------------------------------------------------------------
312
+
313
+ const publicSrc = config.publicDir
314
+ ? path.join(root, config.appDir, config.publicDir)
315
+ : null;
316
+ const publicOut = path.join(dist, 'public');
317
+ let publicFiles = 0;
318
+
319
+ if (publicSrc && fs.existsSync(publicSrc)) {
320
+ fs.cpSync(publicSrc, publicOut, { recursive: true });
321
+ publicFiles = countFiles(publicOut);
322
+ }
323
+
324
+ function countFiles(dir) {
325
+ let total = 0;
326
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
327
+ total += entry.isDirectory() ? countFiles(path.join(dir, entry.name)) : 1;
328
+ }
329
+ return total;
330
+ }
331
+
332
+ // ---- assets, for runtimes with no filesystem ------------------------------
333
+ //
334
+ // The Node server reads `dist` off a disk. A worker cannot, so the same bytes are
335
+ // emitted as a module it can import. Plain bytes only, with no `.br` or `.gz`. An
336
+ // edge runtime compresses for you, and shipping three copies of every file into a
337
+ // bundle with a size limit is the wrong trade.
338
+
339
+ function assetModule() {
340
+ const encode = (map, urlFor) => {
341
+ const out = [];
342
+ for (const [url, entry] of map) {
343
+ const body = entry.body ?? fs.readFileSync(entry.file);
344
+ out.push(
345
+ ` ${JSON.stringify(urlFor ? urlFor(url) : url)}: ` +
346
+ `{ type: ${JSON.stringify(entry.type)}, body: ${JSON.stringify(body.toString('base64'))} },`,
347
+ );
348
+ }
349
+ return `{\n${out.join('\n')}\n}`;
350
+ };
351
+
352
+ const publicMap = new Map();
353
+ if (fs.existsSync(publicOut)) {
354
+ for (const file of walkAll(publicOut)) {
355
+ if (file.endsWith('.br') || file.endsWith('.gz')) continue;
356
+ const url = '/' + path.relative(publicOut, file).split(path.sep).join('/');
357
+ publicMap.set(url, { body: fs.readFileSync(file), type: mimeOf(file) });
358
+ }
359
+ }
360
+
361
+ const page = (name) => {
362
+ const file = path.join(dist, 'static', name);
363
+ if (!fs.existsSync(file)) return 'null';
364
+ return (
365
+ `{ type: "text/html; charset=utf-8", ` +
366
+ `body: ${JSON.stringify(fs.readFileSync(file).toString('base64'))} }`
367
+ );
368
+ };
369
+
370
+ return `// Generated by \`npm run build\`. Bytes for a runtime that cannot read a disk.
371
+ export const statics = ${encode(loadStatic(path.join(dist, 'static')).entries)};
372
+
373
+ export const assets = ${encode(loadAssets(path.join(dist, 'client')).entries)};
374
+
375
+ export const publicFiles = ${encode(publicMap)};
376
+
377
+ export const precache = ${precacheJson === null ? 'null' : JSON.stringify(precacheJson)};
378
+
379
+ export const notFound = ${page('404.html')};
380
+ export const errorPage = ${page('500.html')};
381
+ `;
382
+ }
383
+
384
+ function walkAll(dir, out = []) {
385
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
386
+ const full = path.join(dir, entry.name);
387
+ if (entry.isDirectory()) walkAll(full, out);
388
+ else out.push(full);
389
+ }
390
+ return out;
391
+ }
392
+
393
+ const MIMES = {
394
+ '.html': 'text/html; charset=utf-8',
395
+ '.css': 'text/css; charset=utf-8',
396
+ '.js': 'text/javascript; charset=utf-8',
397
+ '.json': 'application/json; charset=utf-8',
398
+ '.svg': 'image/svg+xml',
399
+ '.ico': 'image/x-icon',
400
+ '.png': 'image/png',
401
+ '.jpg': 'image/jpeg',
402
+ '.webp': 'image/webp',
403
+ '.woff2': 'font/woff2',
404
+ '.txt': 'text/plain; charset=utf-8',
405
+ };
406
+ const mimeOf = (file) => MIMES[path.extname(file)] ?? 'application/octet-stream';
407
+
408
+ // ---- precache manifest ----------------------------------------------------
409
+ //
410
+ // Written before the asset module, so a runtime with no disk carries it too, and
411
+ // before compression, so it ships with a .br and a .gz like anything else.
412
+
413
+ let precacheJson = null;
414
+ if (config.precache) {
415
+ const files = fs.existsSync(publicOut)
416
+ ? walkAll(publicOut)
417
+ .filter((file) => !file.endsWith('.br') && !file.endsWith('.gz'))
418
+ .map((file) => [
419
+ `/${path.relative(publicOut, file).split(path.sep).join('/')}`,
420
+ { etag: etagOf(fs.readFileSync(file)) },
421
+ ])
422
+ : [];
423
+
424
+ // Every entry read, so every one has an ETag. The default budget leaves a
425
+ // large file on disk with no hash, and `precacheList` refuses that rather than
426
+ // calling it immutable.
427
+ const entries = precacheList({
428
+ pages: loadStatic(path.join(dist, 'static'), { maxBytes: Infinity }).entries,
429
+ assets: loadAssets(path.join(dist, 'client'), { maxBytes: Infinity }).entries,
430
+ files,
431
+ });
432
+
433
+ precacheJson = precacheDocument(entries, etagOf(JSON.stringify(entries)).replaceAll('"', ''));
434
+ write(PRECACHE_PATH.replace(/^\//, ''), precacheJson);
435
+ }
436
+
437
+ fs.writeFileSync(path.join(dist, 'server/assets.js'), assetModule());
438
+
439
+ // ---- compress -------------------------------------------------------------
440
+
441
+ // `precompress` only touches extensions it knows are worth compressing, so a
442
+ // public directory full of images costs nothing here.
443
+ const compressed = await precompress([
444
+ path.join(dist, 'static'),
445
+ path.join(dist, 'client'),
446
+ publicOut,
447
+ ]);
448
+
449
+ const summary = [
450
+ `${prerendered.length} page${prerendered.length === 1 ? '' : 's'} prerendered`,
451
+ `${CONCURRENCY} at a time`,
452
+ `${dynamic.length} route${dynamic.length === 1 ? '' : 's'} left to the server`,
453
+ `${assets.size} client entr${assets.size === 1 ? 'y' : 'ies'}`,
454
+ ...(publicFiles ? [`${publicFiles} public file${publicFiles === 1 ? '' : 's'}`] : []),
455
+ ];
456
+ console.log(`\n${summary.join(', ')}`);
457
+ for (const url of prerendered) console.log(` ${url}`);
458
+ for (const route of dynamic) console.log(` ${route.pattern} (server-rendered)`);
459
+
460
+ if (compressed.files) {
461
+ const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
462
+ const pct = (n) => `${Math.round((1 - n / compressed.raw) * 100)}%`;
463
+ console.log(
464
+ `\n${compressed.files} files precompressed: ${kb(compressed.raw)} raw, ` +
465
+ `${kb(compressed.gzip)} gzip (−${pct(compressed.gzip)}), ` +
466
+ `${kb(compressed.brotli)} brotli (−${pct(compressed.brotli)})`,
467
+ );
468
+ }
469
+ if (!dynamic.length) console.log('\ndist/static is self-contained. Any static host will serve it.');
package/bin/check.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // `npm run check`. Type checks every .html file through TypeScript.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import ts from 'typescript';
7
+ import { createChecker, positionAt } from '../src/typecheck.js';
8
+ import { emitTypes } from '../src/compiler/types.js';
9
+ import { loadProject } from '../src/project.js';
10
+
11
+ const { root, config } = await loadProject();
12
+ const checker = createChecker({ root, ...config });
13
+
14
+ // transclude-env.d.ts is an output, not an input: the shims are self-contained, so the
15
+ // types can be written from what tsc made of them rather than the other way
16
+ // round. Nothing downstream reads it. It exists for the author and the editor.
17
+ const types = path.join(root, config.typesFile);
18
+ const next = emitTypes(checker.describe());
19
+ if (!fs.existsSync(types) || fs.readFileSync(types, 'utf8') !== next) {
20
+ fs.writeFileSync(types, next);
21
+ console.log(`wrote ${path.relative(root, types)}`);
22
+ }
23
+
24
+ // Nothing downstream reads this file, so nothing else would notice it being
25
+ // wrong. Parse what we just wrote, or a bad identifier ships silently.
26
+ const emitted = ts.createProgram([types], { noEmit: true, skipLibCheck: true });
27
+ const broken = [
28
+ ...emitted.getSyntacticDiagnostics(),
29
+ ...emitted.getSemanticDiagnostics(),
30
+ ];
31
+ if (broken.length) {
32
+ console.error(`\n${path.relative(root, types)} is not valid TypeScript:`);
33
+ for (const diagnostic of broken.slice(0, 5)) {
34
+ const at = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
35
+ console.error(
36
+ ` ${at ? `line ${at.line + 1}: ` : ''}${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`,
37
+ );
38
+ }
39
+ if (broken.length > 5) console.error(` …and ${broken.length - 5} more`);
40
+ process.exit(1);
41
+ }
42
+ const files = checker.files();
43
+
44
+ let errors = 0;
45
+ let warnings = 0;
46
+
47
+ for (const file of files) {
48
+ const diagnostics = checker.check(file);
49
+ if (!diagnostics.length) continue;
50
+
51
+ const source = fs.readFileSync(file, 'utf8');
52
+ const lines = source.split('\n');
53
+ const relative = path.relative(root, file);
54
+
55
+ for (const diagnostic of diagnostics) {
56
+ const { line, column } = positionAt(source, diagnostic.offset);
57
+ if (diagnostic.severity === 'error') errors++;
58
+ else warnings++;
59
+
60
+ console.log(`\n${relative}:${line}:${column + 1} ${diagnostic.severity} TS${diagnostic.code}`);
61
+ console.log(` ${diagnostic.message}`);
62
+
63
+ const text = lines[line - 1] ?? '';
64
+ const trimmed = text.replace(/^\s+/, '');
65
+ const shift = text.length - trimmed.length;
66
+ console.log(`\n ${trimmed}`);
67
+ console.log(` ${' '.repeat(Math.max(0, column - shift))}${'~'.repeat(Math.max(1, Math.min(diagnostic.length, 60)))}`);
68
+ }
69
+ }
70
+
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
+ );
77
+
78
+ process.exitCode = errors ? 1 : 0;