@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/src/server.js ADDED
@@ -0,0 +1,183 @@
1
+ // The Hono app both servers start from.
2
+ //
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
5
+ // does not guard a prerendered page. Two copies of that order is two servers
6
+ // that disagree, which has happened twice in this codebase already.
7
+
8
+ import { Hono } from 'hono';
9
+ import { csrf } from 'hono/csrf';
10
+ import { headerPolicy } from './csp.js';
11
+ import { trimTrailingSlash } from 'hono/trailing-slash';
12
+
13
+ /**
14
+ * `strict: false` so /about and /about/ are the same page.
15
+ *
16
+ * CSRF is on by default and the only middleware that is. This framework's whole
17
+ * form story is `<form method="post">`, and a cross-origin page can post one to
18
+ * you without any of the checks a `fetch` would have to pass. Hono's guard is
19
+ * scoped to exactly that hole: non-GET requests carrying one of the three
20
+ * content types a form element can send. JSON already needs a preflight, so it
21
+ * is not the way in.
22
+ *
23
+ * `middleware` is the app's own `server.js`, and it runs after, so it can add
24
+ * anything and cannot register itself ahead of the guard by mistake.
25
+ */
26
+ const OPTIONS = new Set(['csrf', 'csp', 'trailingSlash', 'publicFiles', 'middleware']);
27
+
28
+ /**
29
+ * @param {{ csrf?: object|boolean, csp?: object|boolean, trailingSlash?: string,
30
+ * publicFiles?: Function|null, middleware?: Function|null }} [options]
31
+ * @returns {object} a Hono app
32
+ * @throws on a key it does not know
33
+ */
34
+ export function baseApp(options = {}) {
35
+ const {
36
+ csrf: csrfOption = true,
37
+ csp: cspOption = false,
38
+ trailingSlash = 'never',
39
+ publicFiles = null,
40
+ middleware = null,
41
+ } = options;
42
+
43
+ // A key this does not know is a caller that thinks it configured something.
44
+ // `publicRoot` instead of `publicFiles` was exactly that: dev served no public
45
+ // files at all, production served them, and nothing said why.
46
+ const unknown = Object.keys(options).filter((key) => !OPTIONS.has(key));
47
+ if (unknown.length) {
48
+ throw new Error(
49
+ `[transclude] baseApp does not know ${unknown.join(', ')}. ` +
50
+ `It takes ${[...OPTIONS].join(', ')}.`,
51
+ );
52
+ }
53
+
54
+ if (trailingSlash !== 'never' && trailingSlash !== 'ignore') {
55
+ throw new Error(
56
+ `[transclude] trailingSlash must be 'never' or 'ignore', not ${JSON.stringify(trailingSlash)}`,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * One decision with two halves, which is why it is a single config key rather
62
+ * than a router option plus a middleware the author has to remember to add.
63
+ *
64
+ * `strict: false` does not merely match /about/ as well as /about: it strips
65
+ * the slash from `c.req.path` before any middleware runs. Measured: with it on,
66
+ * `trimTrailingSlash` never fires, because the thing it looks for is gone by
67
+ * the time it looks. The two exclude each other.
68
+ *
69
+ * So 'never' means strict routing plus a 301 to the one URL, and every
70
+ * URL this framework generates is already that form: `routes/about.html` is
71
+ * `/about`. 'ignore' is the loose router, which answers both with 200. Two URLs
72
+ * for one page, and nothing emits <link rel="canonical">.
73
+ *
74
+ * `alwaysRedirect` matters because Hono's default only redirects a request that
75
+ * already 404'd, and a catch-all route answers before it can. `/docs/intro/`
76
+ * would match `/docs/:path{.+}` as `intro/` and return 200, which is the exact
77
+ * duplicate this setting exists to remove. Redirecting ahead of the router also
78
+ * spares a doomed request the route table and every middleware after this one.
79
+ */
80
+ const app = new Hono({ strict: trailingSlash === 'never' });
81
+ if (trailingSlash === 'never') app.use('*', trimTrailingSlash({ alwaysRedirect: true }));
82
+
83
+ if (csrfOption) app.use('*', csrf(csrfOption === true ? undefined : csrfOption));
84
+
85
+ /**
86
+ * The one security header with no judgement in it.
87
+ *
88
+ * Without it a browser may sniff a response's bytes and decide the declared
89
+ * type was wrong, so a text file an app serves can be read as HTML and a
90
+ * `.json` as script. Nothing legitimate depends on sniffing, which is what
91
+ * separates this from `X-Frame-Options` or HSTS: both of those refuse
92
+ * something an app may actually want, so they stay the author's to set.
93
+ *
94
+ * Before everything, so a public file and a prerendered page get it too.
95
+ */
96
+ app.use('*', async (c, next) => {
97
+ await next();
98
+ c.header('X-Content-Type-Options', 'nosniff');
99
+ });
100
+
101
+ // The half of the policy a `<meta>` cannot carry. It names no hash, so it is
102
+ // the same string for every page and costs a prerendered one nothing. Before
103
+ // anything that answers, so a static file gets it too.
104
+ const header = headerPolicy(cspOption);
105
+ if (header) {
106
+ app.use('*', async (c, next) => {
107
+ await next();
108
+ c.header(header.name, header.value);
109
+ });
110
+ }
111
+ if (typeof middleware === 'function') middleware(app);
112
+
113
+ // After the app's middleware, so a guard can cover these too, and before the
114
+ // route table, so a real file always beats a `[...path]` catch-all.
115
+ //
116
+ // A handler rather than a directory: what can serve a file is the one thing that
117
+ // genuinely differs between runtimes. Node hands in Hono's `serveStatic`, which
118
+ // does byte ranges off a disk; a runtime with no disk hands in something that
119
+ // reads an asset binding. This file stays free of either.
120
+ if (publicFiles) app.use('*', publicFiles);
121
+
122
+ return app;
123
+ }
124
+
125
+ /** Where an app puts its middleware. Relative to `appDir`. */
126
+ export const SERVER_FILE = 'server.js';
127
+
128
+ /**
129
+ * An endpoint is a `.js` file in the routes tree: a route with no template, no
130
+ * layout and no regions, which answers with a `Response` of its own.
131
+ *
132
+ * // app/routes/api/notes.js
133
+ * export const GET = () => Response.json(notes);
134
+ * export const DELETE = ({ params }) => { … };
135
+ *
136
+ * Handlers are named for the method, spelled the way HTTP spells it. Uppercase
137
+ * is not decoration: `export const delete` is a syntax error and `DELETE` is not,
138
+ * not. A page's handlers are spelled the same way.
139
+ *
140
+ * Returning a `Response` is required rather than encouraged. There is no
141
+ * template to fall back to, and a handler that returns a bare object has almost
142
+ * certainly forgotten `Response.json`.
143
+ *
144
+ * @param {object} mod the endpoint module
145
+ * @param {object} ctx
146
+ * @param {string} method
147
+ * @returns {Promise<Response|null>} null when the module answers no such verb
148
+ */
149
+ export async function runEndpoint(mod, ctx, method) {
150
+ const name = method.toUpperCase();
151
+ // Membership, not shape. `mod.HELPERS` may well be a function, and a request
152
+ // naming it would otherwise reach it.
153
+ if (!ENDPOINT_METHODS.includes(name)) return null;
154
+
155
+ const handler = mod?.[name];
156
+ if (typeof handler !== 'function') return null;
157
+
158
+ const out = await handler(ctx);
159
+ if (out instanceof Response) return out;
160
+
161
+ throw new Error(
162
+ `${name} answered with ${out === undefined ? 'nothing' : typeof out}, ` +
163
+ `not a Response. An endpoint has no template to render instead`,
164
+ );
165
+ }
166
+
167
+ /**
168
+ * The methods an endpoint can answer. `app.all` routes every one of them to it,
169
+ * so this is the list that decides which exports are handlers. The shim reads
170
+ * the same list, or a name would be dispatched and not checked, or checked and
171
+ * never dispatched.
172
+ */
173
+ export const ENDPOINT_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
174
+
175
+ /**
176
+ * What an endpoint answers, for an `Allow` header.
177
+ *
178
+ * @param {object|null|undefined} mod
179
+ * @returns {string[]} sorted, for an Allow header
180
+ */
181
+ export function endpointMethods(mod) {
182
+ return ENDPOINT_METHODS.filter((name) => typeof mod?.[name] === 'function').sort();
183
+ }
package/src/sitemap.js ADDED
@@ -0,0 +1,124 @@
1
+ // GET /sitemap.xml, from the route table the framework already has.
2
+ //
3
+ // A page route with no parameters is one URL. A parameter route is as many as
4
+ // its `paths` export names, which is the same list the build prerenders, so a
5
+ // route that ships as files is listed without the author repeating it. Anything
6
+ // else (a route with no `paths`, an endpoint, an error page) is not a page a
7
+ // crawler can reach by guessing, so it is left out.
8
+
9
+ /** The protocol's cap for one file. Past it the response is an index of files. */
10
+ const LIMIT = 50000;
11
+
12
+ const escape = (text) =>
13
+ String(text)
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&apos;');
19
+
20
+ /** `2026-07-31`, which is what a sitemap wants and what a Date will not give. */
21
+ function day(value) {
22
+ if (!value) return null;
23
+ const date = value instanceof Date ? value : new Date(value);
24
+ return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
25
+ }
26
+
27
+ const excluded = (path, rules) =>
28
+ rules.some((rule) => (rule instanceof RegExp ? rule.test(path) : rule === path));
29
+
30
+ /**
31
+ * Every URL the sitemap lists, in route order.
32
+ *
33
+ * `paths()` is the page's own, the one the build calls, so the two cannot
34
+ * disagree about which URLs exist.
35
+ *
36
+ * @param {object} manifest
37
+ * @param {Record<string, object>} pages
38
+ * @param {{ entries?: object[] | (() => object[] | Promise<object[]>),
39
+ * exclude?: string[] }} [config] `entries` may be a function, so an app can
40
+ * build them from its own data rather than list them
41
+ * @returns {Promise<Array<{ path: string, lastmod?: string }>>}
42
+ */
43
+ export async function sitemapEntries(manifest, pages, { entries = [], exclude = [] } = {}) {
44
+ const found = [];
45
+
46
+ for (const route of manifest.routes ?? []) {
47
+ const page = pages[route.id];
48
+
49
+ if (!route.params.length) {
50
+ found.push({ path: route.pattern });
51
+ continue;
52
+ }
53
+
54
+ // A parameter route with no `paths` is server-rendered for URLs nobody has
55
+ // listed. Advertising the pattern would advertise `/people/:name`.
56
+ if (typeof page?.paths !== 'function') continue;
57
+
58
+ for (const params of (await page.paths()) ?? []) {
59
+ found.push({
60
+ path: route.pattern.replace(/:(\w+)(\{[^}]*\})?/g, (_, name) => String(params[name] ?? '')),
61
+ });
62
+ }
63
+ }
64
+
65
+ const extra = typeof entries === 'function' ? ((await entries()) ?? []) : entries;
66
+ const all = [...found, ...extra];
67
+
68
+ const seen = new Set();
69
+ return all.filter((entry) => {
70
+ if (seen.has(entry.path) || excluded(entry.path, exclude)) return false;
71
+ seen.add(entry.path);
72
+ return true;
73
+ });
74
+ }
75
+
76
+ function urlset(entries, hostname) {
77
+ const body = entries
78
+ .map(({ path, lastmod, changefreq, priority }) => {
79
+ const parts = [`<loc>${escape(new URL(path, hostname).href)}</loc>`];
80
+ const when = day(lastmod);
81
+ if (when) parts.push(`<lastmod>${when}</lastmod>`);
82
+ if (changefreq) parts.push(`<changefreq>${escape(changefreq)}</changefreq>`);
83
+ if (priority !== undefined) parts.push(`<priority>${escape(priority)}</priority>`);
84
+ return `<url>${parts.join('')}</url>`;
85
+ })
86
+ .join('\n');
87
+
88
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
89
+ }
90
+
91
+ function index(count, hostname, limit) {
92
+ const pages = Math.ceil(count / limit);
93
+ const body = Array.from({ length: pages }, (_, i) => {
94
+ const href = new URL(`/sitemap.xml?p=${i}`, hostname).href;
95
+ return `<sitemap><loc>${escape(href)}</loc></sitemap>`;
96
+ }).join('\n');
97
+
98
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
99
+ }
100
+
101
+ /**
102
+ * The document for one request.
103
+ *
104
+ * Past the cap the bare path answers with an index and `?p=` answers with a
105
+ * slice, because a file over 50000 URLs is not a sitemap a crawler will read.
106
+ *
107
+ * @param {object} manifest
108
+ * @param {Record<string, object>} pages
109
+ * @param {object} config the `sitemap` block, which has to name a hostname
110
+ * @param {number|null} [page] which sheet, when there are more URLs than one holds
111
+ * @returns {Promise<string>} an XML document
112
+ */
113
+ export async function sitemap(manifest, pages, config, page = null) {
114
+ const { hostname, limit = LIMIT } = config;
115
+ if (!hostname) throw new Error('[transclude] sitemap needs a hostname');
116
+
117
+ const entries = await sitemapEntries(manifest, pages, config);
118
+
119
+ if (entries.length <= limit) return urlset(entries, hostname);
120
+ if (page === null) return index(entries.length, hostname, limit);
121
+
122
+ const from = Number(page) * limit;
123
+ return urlset(entries.slice(from, from + limit), hostname);
124
+ }
@@ -0,0 +1,170 @@
1
+ // Built output, held in memory with an ETag per representation.
2
+ //
3
+ // The files never change for the life of the process, because they were produced
4
+ // at build time. The only reason to touch the disk again is if there are more of
5
+ // them than we are willing to hold.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { createHash } from 'node:crypto';
10
+
11
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
12
+
13
+ const TYPES = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.mjs': 'text/javascript; charset=utf-8',
17
+ '.css': 'text/css; charset=utf-8',
18
+ '.json': 'application/json; charset=utf-8',
19
+ '.svg': 'image/svg+xml',
20
+ '.map': 'application/json; charset=utf-8',
21
+ '.txt': 'text/plain; charset=utf-8',
22
+ '.ico': 'image/x-icon',
23
+ '.png': 'image/png',
24
+ '.jpg': 'image/jpeg',
25
+ '.webp': 'image/webp',
26
+ '.woff2': 'font/woff2',
27
+ };
28
+
29
+ /**
30
+ * @typedef {object} Entry
31
+ * @property {Buffer} body
32
+ * @property {string} etag
33
+ * @property {Map<string, { body: Buffer, etag: string }>} encodings one per content encoding
34
+ * @property {string} type the Content-Type to send
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} Store
39
+ * @property {number} count
40
+ * @property {number} bytes held in memory
41
+ * @property {number} onDisk left on disk because the budget ran out
42
+ * @property {number} encoded how many have a precompressed variant
43
+ * @property {Map<string, Entry|{ file: string }>} entries for the build, which
44
+ * serialises these for a runtime with no filesystem
45
+ * @property {(pathname: string) => Entry|null} get
46
+ */
47
+
48
+ /**
49
+ * Prerendered pages, keyed by the URL they stand for.
50
+ *
51
+ * @param {string} dir the directory of built pages
52
+ * @param {{ maxBytes?: number }} [options] how much to hold in memory
53
+ * @returns {Store}
54
+ */
55
+ export function loadStatic(dir, options = {}) {
56
+ return load(dir, pageUrl, options);
57
+ }
58
+
59
+ /**
60
+ * Build assets, keyed by their path under the output directory.
61
+ *
62
+ * @param {string} dir
63
+ * @param {{ maxBytes?: number }} [options]
64
+ * @returns {Store}
65
+ */
66
+ export function loadAssets(dir, options = {}) {
67
+ return load(dir, (relative) => `/${relative.split(path.sep).join('/')}`, options);
68
+ }
69
+
70
+ function load(dir, urlFor, { maxBytes = DEFAULT_MAX_BYTES } = {}) {
71
+ const entries = new Map();
72
+ let bytes = 0;
73
+ let onDisk = 0;
74
+
75
+ for (const file of walk(dir)) {
76
+ // .br and .gz are variants of a sibling, not resources of their own.
77
+ if (file.endsWith('.br') || file.endsWith('.gz')) continue;
78
+
79
+ const url = urlFor(path.relative(dir, file));
80
+ if (url === null) continue;
81
+
82
+ const size = fs.statSync(file).size + variantSize(file);
83
+ if (bytes + size > maxBytes) {
84
+ entries.set(url, { file });
85
+ onDisk++;
86
+ continue;
87
+ }
88
+
89
+ bytes += size;
90
+ entries.set(url, read(file));
91
+ }
92
+
93
+ return {
94
+ count: entries.size,
95
+ bytes,
96
+ onDisk,
97
+ /** For the build, which serialises these for runtimes with no filesystem. */
98
+ entries,
99
+ encoded: [...entries.values()].filter((e) => e.encodings?.size).length,
100
+
101
+ /** Entry for a request path, or null. Trailing slashes are the same resource. */
102
+ get(pathname) {
103
+ const clean = pathname.replace(/\/+$/, '') || '/';
104
+ const hit = entries.get(clean);
105
+ if (!hit) return null;
106
+ return hit.body !== undefined ? hit : read(hit.file);
107
+ },
108
+ };
109
+ }
110
+
111
+ function read(file) {
112
+ const body = fs.readFileSync(file);
113
+ const etag = etagOf(body);
114
+ const encodings = new Map();
115
+
116
+ // Each encoding is a different set of bytes, so it needs its own ETag. Otherwise
117
+ // a shared cache can hand a brotli body to a client that asked for gzip, and
118
+ // nothing shows the mismatch until it fails to decode.
119
+ for (const [encoding, suffix] of [['br', '.br'], ['gzip', '.gz']]) {
120
+ const variant = `${file}${suffix}`;
121
+ if (!fs.existsSync(variant)) continue;
122
+ encodings.set(encoding, {
123
+ body: fs.readFileSync(variant),
124
+ etag: `${etag.slice(0, -1)}-${encoding}"`,
125
+ });
126
+ }
127
+
128
+ return { body, etag, encodings, type: TYPES[path.extname(file)] ?? 'application/octet-stream' };
129
+ }
130
+
131
+ /**
132
+ * A cache key for a body, not a signature.
133
+ *
134
+ * Truncated on purpose: this says whether two responses are the same bytes, and
135
+ * nothing trusts it for anything else. Anything that needs a digest a browser
136
+ * will agree with uses `crypto.subtle`.
137
+ *
138
+ * @param {Buffer|Uint8Array|string} body
139
+ * @returns {string} a quoted ETag
140
+ */
141
+ export function etagOf(body) {
142
+ return `"${createHash('sha1').update(body).digest('base64url').slice(0, 20)}"`;
143
+ }
144
+
145
+ function variantSize(file) {
146
+ let total = 0;
147
+ for (const suffix of ['.br', '.gz']) {
148
+ const variant = `${file}${suffix}`;
149
+ if (fs.existsSync(variant)) total += fs.statSync(variant).size;
150
+ }
151
+ return total;
152
+ }
153
+
154
+ /** `index.html` -> `/`, `people/ada/index.html` -> `/people/ada`, `404.html` -> null. */
155
+ function pageUrl(relative) {
156
+ const posix = relative.split(path.sep).join('/');
157
+ if (posix === 'index.html') return '/';
158
+ if (posix.endsWith('/index.html')) return `/${posix.slice(0, -'/index.html'.length)}`;
159
+ return null;
160
+ }
161
+
162
+ function walk(dir, out = []) {
163
+ if (!fs.existsSync(dir)) return out;
164
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
165
+ const full = path.join(dir, entry.name);
166
+ if (entry.isDirectory()) walk(full, out);
167
+ else out.push(full);
168
+ }
169
+ return out;
170
+ }