@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/rewrite.js ADDED
@@ -0,0 +1,281 @@
1
+ // Making a foreign document safe to put in a page, and making its links work.
2
+ //
3
+ // Two jobs over one tree. Sanitizing decides what is allowed to survive the
4
+ // trip. Rewriting turns every relative URL into one that still points at the
5
+ // source, since the markup is about to be read on a different origin.
6
+ //
7
+ // Both run over the whole document once, before it is indexed, so several
8
+ // fragments from one page pay for it once.
9
+
10
+ import { parse } from 'parse5';
11
+
12
+ /** Removed outright. Their content goes with them. */
13
+ const STRIP = new Set(['script', 'iframe', 'object', 'embed', 'base', 'link', 'style']);
14
+
15
+ /** Attributes holding a URL, and what a URL there is allowed to be. */
16
+ const NAVIGATIONAL = new Set(['href', 'action', 'formaction', 'ping', 'longdesc', 'cite']);
17
+ const FETCHABLE = new Set(['src', 'poster', 'data', 'background']);
18
+ const SRCSET = new Set(['srcset', 'imagesrcset']);
19
+
20
+ /** Schemes an attribute may name. Anything else is dropped. */
21
+ const SAFE_SCHEME = /^(https?:|mailto:|tel:|ftp:)/i;
22
+
23
+ /**
24
+ * `<meta http-equiv="refresh">` navigates the page it lands in. Nothing about a
25
+ * transcluded fragment should be able to do that.
26
+ */
27
+ const isRefresh = (node) =>
28
+ node.tagName === 'meta' &&
29
+ node.attrs?.some((a) => a.name === 'http-equiv' && /^refresh$/i.test(a.value));
30
+
31
+ const kidsOf = (node) => node.childNodes ?? [];
32
+ const isElement = (node) => typeof node.tagName === 'string';
33
+
34
+ function remove(node) {
35
+ const siblings = node.parentNode?.childNodes;
36
+ if (!siblings) return;
37
+ const at = siblings.indexOf(node);
38
+ if (at !== -1) siblings.splice(at, 1);
39
+ }
40
+
41
+ /**
42
+ * Strip what must not travel.
43
+ *
44
+ * `<base>` is on the list for a reason that is easy to miss: it does not affect
45
+ * the fragment, it retargets every relative URL in the document the fragment is
46
+ * inserted into. `<link>` and `<style>` go because their rules are not scoped to
47
+ * the fragment: both restyle the whole page the fragment lands in, one by
48
+ * pulling a stylesheet from anywhere and one by carrying it. `<base>` and
49
+ * `<link>` are read for their own purposes before this runs.
50
+ *
51
+ * A `style` attribute is the other kind. It paints the element it sits on and
52
+ * nothing else, so it is kept unless `styles` says otherwise. Dropping them by
53
+ * default would flatten the source's own meaning: a highlighted code block
54
+ * carries its colors that way.
55
+ *
56
+ * @param {object} root a parse5 tree, modified in place
57
+ * @param {{ styles?: 'keep'|'strip' }} [options]
58
+ * @returns {string[]} what was taken out, for a caller that wants to report it
59
+ */
60
+ export function sanitize(root, { styles = 'keep' } = {}) {
61
+ const removed = [];
62
+
63
+ const visit = (node) => {
64
+ // A copy, because the walk removes from the live list.
65
+ for (const child of [...kidsOf(node)]) {
66
+ if (!isElement(child)) continue;
67
+
68
+ if (STRIP.has(child.tagName) || isRefresh(child)) {
69
+ removed.push(child.tagName);
70
+ remove(child);
71
+ continue;
72
+ }
73
+
74
+ child.attrs = (child.attrs ?? []).filter((attr) => {
75
+ // Every event handler, whatever it is called.
76
+ if (/^on/i.test(attr.name)) {
77
+ removed.push(`@${attr.name}`);
78
+ return false;
79
+ }
80
+ if (styles === 'strip' && attr.name === 'style') {
81
+ removed.push('@style');
82
+ return false;
83
+ }
84
+ return true;
85
+ });
86
+
87
+ for (const attr of child.attrs) {
88
+ if (!allowedUrl(child, attr)) {
89
+ removed.push(`@${attr.name}`);
90
+ attr.value = '';
91
+ }
92
+ }
93
+
94
+ visit(child);
95
+ }
96
+ };
97
+
98
+ visit(root);
99
+ return removed;
100
+ }
101
+
102
+ /**
103
+ * Whether a URL-bearing attribute may keep its value.
104
+ *
105
+ * `javascript:` is refused everywhere. `data:` is refused everywhere except an
106
+ * image source, where it is ordinary and cannot navigate anything.
107
+ */
108
+ function allowedUrl(element, attr) {
109
+ const holdsUrl =
110
+ NAVIGATIONAL.has(attr.name) || FETCHABLE.has(attr.name) || isXlink(attr.name);
111
+ if (!holdsUrl) return true;
112
+
113
+ const value = attr.value.trim();
114
+ if (!value) return true;
115
+
116
+ const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
117
+ if (!scheme) return true; // relative, resolved later
118
+
119
+ if (scheme === 'data') {
120
+ return element.tagName === 'img' && attr.name === 'src' && /^data:image\//i.test(value);
121
+ }
122
+ return SAFE_SCHEME.test(value);
123
+ }
124
+
125
+ const isXlink = (name) => name === 'xlink:href' || name === 'href';
126
+
127
+ // ---- absolute URLs ---------------------------------------------------------
128
+
129
+ /**
130
+ * The document's own idea of where it is.
131
+ *
132
+ * A `<base href>` wins over the URL the response came from, because that is
133
+ * what the source document's own relative links were written against.
134
+ *
135
+ * @param {string} html
136
+ * @param {string} responseUrl the URL after every redirect
137
+ * @returns {string} what relative URLs resolve against
138
+ */
139
+ export function baseOf(html, responseUrl) {
140
+ const found = parse(html);
141
+ let href = null;
142
+
143
+ const visit = (node) => {
144
+ for (const child of kidsOf(node)) {
145
+ if (!isElement(child)) continue;
146
+ if (child.tagName === 'base' && !href) {
147
+ href = child.attrs?.find((a) => a.name === 'href')?.value ?? null;
148
+ }
149
+ visit(child);
150
+ }
151
+ };
152
+ visit(found);
153
+
154
+ if (!href) return responseUrl;
155
+ try {
156
+ return new URL(href, responseUrl).href;
157
+ } catch {
158
+ return responseUrl;
159
+ }
160
+ }
161
+
162
+ const absolute = (value, base) => {
163
+ try {
164
+ return new URL(value, base).href;
165
+ } catch {
166
+ return value;
167
+ }
168
+ };
169
+
170
+ /**
171
+ * A `srcset`, as a list of candidates.
172
+ *
173
+ * It cannot be split on commas: a URL may contain one, and plenty do. A
174
+ * candidate's URL ends at whitespace, or at a comma that is part of the URL
175
+ * token itself, which is the rule the HTML parser uses.
176
+ *
177
+ * @param {string} value
178
+ * @returns {Array<{ url: string, descriptor: string }>} split on the commas that separate
179
+ * candidates rather than the ones inside a URL
180
+ */
181
+ export function parseSrcset(value) {
182
+ const out = [];
183
+ const ws = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
184
+ let i = 0;
185
+
186
+ while (i < value.length) {
187
+ while (i < value.length && (ws(value[i]) || value[i] === ',')) i += 1;
188
+ if (i >= value.length) break;
189
+
190
+ const from = i;
191
+ while (i < value.length && !ws(value[i])) i += 1;
192
+ let url = value.slice(from, i);
193
+ let descriptor = '';
194
+
195
+ if (url.endsWith(',')) {
196
+ url = url.replace(/,+$/, '');
197
+ } else {
198
+ while (i < value.length && ws(value[i])) i += 1;
199
+ const at = i;
200
+ while (i < value.length && value[i] !== ',') i += 1;
201
+ descriptor = value.slice(at, i).trim();
202
+ if (value[i] === ',') i += 1;
203
+ }
204
+
205
+ if (url) out.push({ url, descriptor });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
211
+
212
+ /**
213
+ * `url()` references inside a style attribute or a `<style>` block.
214
+ *
215
+ * @param {string} css
216
+ * @param {string} base
217
+ * @returns {string}
218
+ */
219
+ export function rewriteCss(css, base) {
220
+ return css.replace(CSS_URL, (whole, quote, url) => {
221
+ const value = url.trim();
222
+ if (!value || /^(data|blob):/i.test(value)) return whole;
223
+ return `url(${quote}${absolute(value, base)}${quote})`;
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Every relative URL made absolute against the source.
229
+ *
230
+ * A hash-only href is included on purpose. Left alone it would point at the
231
+ * page the fragment was inserted into, which is a different document that
232
+ * probably has no such id.
233
+ *
234
+ * @param {object} root modified in place
235
+ * @param {string} base
236
+ * @returns {object} the same root
237
+ */
238
+ export function absolutize(root, base) {
239
+ const visit = (node) => {
240
+ for (const child of kidsOf(node)) {
241
+ if (!isElement(child)) continue;
242
+
243
+ for (const attr of child.attrs ?? []) {
244
+ if (SRCSET.has(attr.name)) {
245
+ attr.value = parseSrcset(attr.value)
246
+ .map(({ url, descriptor }) =>
247
+ descriptor ? `${absolute(url, base)} ${descriptor}` : absolute(url, base),
248
+ )
249
+ .join(', ');
250
+ continue;
251
+ }
252
+
253
+ if (attr.name === 'style') {
254
+ attr.value = rewriteCss(attr.value, base);
255
+ continue;
256
+ }
257
+
258
+ const holdsUrl =
259
+ NAVIGATIONAL.has(attr.name) || FETCHABLE.has(attr.name) || isXlink(attr.name);
260
+ if (!holdsUrl || !attr.value.trim()) continue;
261
+ if (/^(data|blob|mailto:|tel:|javascript:)/i.test(attr.value.trim())) continue;
262
+
263
+ attr.value =
264
+ attr.name === 'ping'
265
+ ? attr.value.split(/\s+/).filter(Boolean).map((u) => absolute(u, base)).join(' ')
266
+ : absolute(attr.value, base);
267
+ }
268
+
269
+ if (child.tagName === 'style') {
270
+ for (const text of kidsOf(child)) {
271
+ if (text.nodeName === '#text') text.value = rewriteCss(text.value, base);
272
+ }
273
+ }
274
+
275
+ visit(child);
276
+ }
277
+ };
278
+
279
+ visit(root);
280
+ return root;
281
+ }
package/src/routes.js ADDED
@@ -0,0 +1,199 @@
1
+ // File-based routing. The directory tree is the route table; Hono does the
2
+ // matching. Pure functions only. The plugin and the server both scan, and they
3
+ // must not be able to disagree.
4
+ //
5
+ // routes/index.html -> /
6
+ // routes/about.html -> /about
7
+ // routes/blog/index.html -> /blog
8
+ // routes/blog/[slug].html -> /blog/:slug
9
+ // routes/docs/[...path].html -> /docs/:path{.+}
10
+ // routes/api/people.js -> /api/people, an endpoint rather than a page
11
+ // routes/404.html -> the not-found handler, not a route
12
+ // routes/500.html -> the error page, not a route
13
+ // routes/_partial.html -> ignored, as is anything under an _ directory
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const EXT = '.html';
19
+ /**
20
+ * A `.js` file in the routes tree is an endpoint: a route with no template, no
21
+ * layout and no regions, which answers with a `Response` of its own. Same
22
+ * filename rules as a page, so `[param]`, `[...rest]` and `index` all work,
23
+ * because it is the same route table.
24
+ */
25
+ const ENDPOINT_EXT = '.js';
26
+ const NOT_FOUND = '404';
27
+ /**
28
+ * Not a route either: nothing links to `/500`, and a request for it is not what
29
+ * makes it render. An unhandled throw is. Prerendered like the not-found page,
30
+ * because a page that renders when something is already broken should not need a
31
+ * loader, a database, or anything else that can also be broken.
32
+ */
33
+ const ERROR = '500';
34
+
35
+ /**
36
+ * @param {string} dir
37
+ * @returns {{ routes: object[], endpoints: object[], notFound: object|null,
38
+ * error: object|null }} the pages, the endpoints, and the two pages that are
39
+ * reached for rather than routed to
40
+ */
41
+ export function scanRoutes(dir) {
42
+ const routes = [];
43
+ const endpoints = [];
44
+ let notFound = null;
45
+ let error = null;
46
+
47
+ const seen = new Map();
48
+ for (const rel of walk(dir)) {
49
+ const route = toRoute(rel, path.join(dir, rel));
50
+
51
+ if (route.kind === 'page' && route.id === NOT_FOUND) {
52
+ notFound = route;
53
+ continue;
54
+ }
55
+ if (route.kind === 'page' && route.id === ERROR) {
56
+ error = route;
57
+ continue;
58
+ }
59
+ // One pattern, one answer. A page and an endpoint claiming the same URL is
60
+ // the same mistake as two pages claiming it.
61
+ const clash = seen.get(route.pattern) ?? seen.get(route.id);
62
+ if (clash) {
63
+ throw new Error(
64
+ `[transclude] ${rel} and ${clash} collide (${route.pattern}, id ${route.id}). Rename one`,
65
+ );
66
+ }
67
+ seen.set(route.pattern, rel);
68
+ seen.set(route.id, rel);
69
+ (route.kind === 'page' ? routes : endpoints).push(route);
70
+ }
71
+
72
+ routes.sort(bySpecificity);
73
+ endpoints.sort(bySpecificity);
74
+ return { routes, endpoints, notFound, error };
75
+ }
76
+
77
+ /**
78
+ * @param {string} rel the path under the routes directory
79
+ * @param {string} file
80
+ * @returns {object} its id, URL pattern, params and kind
81
+ */
82
+ export function toRoute(rel, file) {
83
+ const kind = rel.endsWith(ENDPOINT_EXT) ? 'endpoint' : 'page';
84
+ const ext = kind === 'endpoint' ? ENDPOINT_EXT : EXT;
85
+ const parts = rel.slice(0, -ext.length).split(path.sep);
86
+
87
+ // `blog/index.html` and `blog.html` both mean /blog; the trailing `index`
88
+ // is addressing, not a path segment.
89
+ const named = parts.at(-1) === 'index' ? parts.slice(0, -1) : parts;
90
+ const segments = named.map(parseSegment);
91
+
92
+ return {
93
+ kind,
94
+ id: idOf(parts),
95
+ file,
96
+ rel,
97
+ segments,
98
+ pattern: patternOf(segments),
99
+ params: segments.filter((s) => s.kind !== 'static').map((s) => s.name),
100
+ hasRest: segments.some((s) => s.kind === 'rest'),
101
+ };
102
+ }
103
+
104
+ function parseSegment(name) {
105
+ const rest = /^\[\.\.\.(.+)\]$/.exec(name);
106
+ if (rest) return { kind: 'rest', name: rest[1] };
107
+
108
+ const param = /^\[(.+)\]$/.exec(name);
109
+ if (param) return { kind: 'param', name: param[1] };
110
+
111
+ return { kind: 'static', name };
112
+ }
113
+
114
+ function patternOf(segments) {
115
+ if (!segments.length) return '/';
116
+ return (
117
+ '/' +
118
+ segments
119
+ .map((s) => {
120
+ if (s.kind === 'static') return s.name;
121
+ // Hono's `*` wildcard is not a named param; a regex param is.
122
+ return s.kind === 'rest' ? `:${s.name}{.+}` : `:${s.name}`;
123
+ })
124
+ .join('/')
125
+ );
126
+ }
127
+
128
+ /**
129
+ * A URL-safe, stable key for the virtual module ids. Brackets would have to
130
+ * survive a round trip through `/@id/...` and they are reserved characters.
131
+ *
132
+ * The separator must not be a dot. Vite decides whether to run a request
133
+ * through its transform pipeline partly by extension, and `people._name` parses
134
+ * as the extension `._name`, so dotted ids fall straight past the dev server and
135
+ * get answered by the app as a 404 page. Silent, and only on nested routes.
136
+ */
137
+ function idOf(parts) {
138
+ return parts
139
+ .map((part) =>
140
+ part
141
+ .replace(/^\[\.\.\.(.+)\]$/, '_$1_rest')
142
+ .replace(/^\[(.+)\]$/, '_$1'),
143
+ )
144
+ .join('-');
145
+ }
146
+
147
+ // Static beats dynamic, dynamic beats catch-all, and among equals the longer
148
+ // path wins. Registration order then makes Hono's behaviour deterministic
149
+ // rather than something to reason about per-router.
150
+ function bySpecificity(a, b) {
151
+ if (a.hasRest !== b.hasRest) return a.hasRest ? 1 : -1;
152
+ if (a.params.length !== b.params.length) return a.params.length - b.params.length;
153
+ if (a.segments.length !== b.segments.length) return b.segments.length - a.segments.length;
154
+ return a.pattern.localeCompare(b.pattern);
155
+ }
156
+
157
+ function walk(dir, base = dir, out = []) {
158
+ if (!fs.existsSync(dir)) return out;
159
+
160
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
161
+ // `_` marks something that is not a route: helpers and drafts.
162
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
163
+
164
+ const full = path.join(dir, entry.name);
165
+ if (entry.isDirectory()) walk(full, base, out);
166
+ else if (entry.name.endsWith(EXT) || entry.name.endsWith(ENDPOINT_EXT)) {
167
+ out.push(path.relative(base, full));
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * The routes directory, or a migration error.
175
+ *
176
+ * It was `pages/` until it started holding `.js` endpoints as well as `.html`
177
+ * pages, at which point the name was no longer true. A missing directory otherwise
178
+ * produces an empty route table and a site of 404s, which is a confusing way to
179
+ * learn about a rename.
180
+ *
181
+ * @param {string} app the app directory
182
+ * @param {string} routesDir from the config
183
+ * @returns {string}
184
+ * @throws when the old `pages/` name is still there
185
+ */
186
+ export function resolveRoutesDir(app, routesDir) {
187
+ const dir = path.resolve(app, routesDir);
188
+ if (fs.existsSync(dir)) return dir;
189
+
190
+ const legacy = path.resolve(app, 'pages');
191
+ if (fs.existsSync(legacy)) {
192
+ throw new Error(
193
+ `[transclude] ${path.relative(app, legacy)}/ is now ${routesDir}/. It holds .js ` +
194
+ `endpoints as well as .html pages. Rename the directory, or set ` +
195
+ `\`routesDir\` in transclude.config.js.`,
196
+ );
197
+ }
198
+ return dir;
199
+ }