@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- 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
|
+
}
|