@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/negotiate.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picks a content encoding from an Accept-Encoding header.
|
|
3
|
+
*
|
|
4
|
+
* Getting this wrong is not a missed optimisation, it is a corrupt response: a
|
|
5
|
+
* client that did not ask for brotli must never be handed brotli. So the rules
|
|
6
|
+
* are followed properly: q-values, `*`, and `q=0` as a refusal.
|
|
7
|
+
*
|
|
8
|
+
* @param {string|null|undefined} header the request's Accept-Encoding
|
|
9
|
+
* @param {string[]} available encodings this response actually has
|
|
10
|
+
* @returns {string|null} null means send it unencoded
|
|
11
|
+
*/
|
|
12
|
+
export function pickEncoding(header, available = []) {
|
|
13
|
+
if (!available.length) return null;
|
|
14
|
+
|
|
15
|
+
const accepted = parse(header);
|
|
16
|
+
// No header at all means the client stated no preference; sending identity is
|
|
17
|
+
// the only safe reading of that.
|
|
18
|
+
if (!accepted) return null;
|
|
19
|
+
|
|
20
|
+
let best = null;
|
|
21
|
+
for (const encoding of available) {
|
|
22
|
+
const quality = qualityOf(accepted, encoding);
|
|
23
|
+
if (quality <= 0) continue;
|
|
24
|
+
if (!best || quality > best.quality) best = { encoding, quality };
|
|
25
|
+
}
|
|
26
|
+
return best?.encoding ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether an unencoded response is still allowed.
|
|
31
|
+
*
|
|
32
|
+
* A client can refuse identity with `identity;q=0`, and then a body we have no
|
|
33
|
+
* encoding for cannot be sent at all.
|
|
34
|
+
*
|
|
35
|
+
* @param {string|null|undefined} header
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function identityAcceptable(header) {
|
|
39
|
+
const accepted = parse(header);
|
|
40
|
+
if (!accepted) return true;
|
|
41
|
+
return qualityOf(accepted, 'identity') > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parsed headers, keyed by the header itself.
|
|
46
|
+
*
|
|
47
|
+
* Every response negotiates, and clients send a handful of distinct
|
|
48
|
+
* Accept-Encoding strings between them, so the same dozen values are parsed for
|
|
49
|
+
* the life of the process. This was the largest piece of our own code in a
|
|
50
|
+
* profile of the request path.
|
|
51
|
+
*
|
|
52
|
+
* The value is shared, so nothing may write to it. `qualityOf` only reads.
|
|
53
|
+
*/
|
|
54
|
+
const parsed = new Map();
|
|
55
|
+
const PARSED_MAX = 64;
|
|
56
|
+
|
|
57
|
+
function parse(header) {
|
|
58
|
+
if (!header || !header.trim()) return null;
|
|
59
|
+
|
|
60
|
+
const held = parsed.get(header);
|
|
61
|
+
if (held !== undefined) return held;
|
|
62
|
+
|
|
63
|
+
const answer = read(header);
|
|
64
|
+
// A client can send anything, so the table is bounded. Oldest out first, which
|
|
65
|
+
// is enough: the values that matter are sent by every request and go back in.
|
|
66
|
+
if (parsed.size >= PARSED_MAX) parsed.delete(parsed.keys().next().value);
|
|
67
|
+
parsed.set(header, answer);
|
|
68
|
+
return answer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function read(header) {
|
|
72
|
+
const entries = new Map();
|
|
73
|
+
for (const part of header.split(',')) {
|
|
74
|
+
const [name, ...params] = part.trim().split(';');
|
|
75
|
+
if (!name) continue;
|
|
76
|
+
|
|
77
|
+
let quality = 1;
|
|
78
|
+
for (const param of params) {
|
|
79
|
+
const [key, value] = param.split('=').map((s) => s.trim());
|
|
80
|
+
if (key === 'q') {
|
|
81
|
+
const q = Number.parseFloat(value);
|
|
82
|
+
quality = Number.isFinite(q) ? q : 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
entries.set(name.trim().toLowerCase(), quality);
|
|
86
|
+
}
|
|
87
|
+
return entries.size ? entries : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function qualityOf(accepted, encoding) {
|
|
91
|
+
if (accepted.has(encoding)) return accepted.get(encoding);
|
|
92
|
+
if (accepted.has('*')) return accepted.get('*');
|
|
93
|
+
// identity is acceptable by default unless something above ruled it out.
|
|
94
|
+
return encoding === 'identity' ? 1 : 0;
|
|
95
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Vite plugin. Everything is exposed as a virtual module id rather than a real
|
|
2
|
+
// .html path: Vite's own html middleware would otherwise intercept requests for
|
|
3
|
+
// /src/components/user-card.html and serve it as a page.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
compileComponent,
|
|
10
|
+
compileLayout,
|
|
11
|
+
compilePage,
|
|
12
|
+
compileClientEntry,
|
|
13
|
+
compileElementsEntry,
|
|
14
|
+
ELEMENTS_ENTRY,
|
|
15
|
+
splitBlocks,
|
|
16
|
+
usedComponents,
|
|
17
|
+
readFlags,
|
|
18
|
+
} from './compiler/index.js';
|
|
19
|
+
import { resolveRoutesDir, scanRoutes } from './routes.js';
|
|
20
|
+
import { SERVER_FILE } from './server.js';
|
|
21
|
+
|
|
22
|
+
const P_COMPONENT = 'virtual:transclude-component/';
|
|
23
|
+
const P_PAGE = 'virtual:transclude-page/';
|
|
24
|
+
const P_CLIENT = 'virtual:transclude-client/';
|
|
25
|
+
const P_LAYOUT = 'virtual:transclude-layout/';
|
|
26
|
+
const SERVER_ENTRY = 'virtual:transclude-server';
|
|
27
|
+
const LAYOUT_FILE = '_layout.html';
|
|
28
|
+
|
|
29
|
+
const RUNTIME_FILE = fileURLToPath(new URL('./runtime/index.js', import.meta.url));
|
|
30
|
+
|
|
31
|
+
export default function transclude({
|
|
32
|
+
appDir = 'app',
|
|
33
|
+
elementsDir = 'elements',
|
|
34
|
+
routesDir = 'routes',
|
|
35
|
+
fragmentParam = 'fragment',
|
|
36
|
+
watchElements = false,
|
|
37
|
+
} = {}) {
|
|
38
|
+
// Off unless asked for. It puts a script on every page, and it only earns that
|
|
39
|
+
// when swapped-in markup names an element the page did not already render.
|
|
40
|
+
// A page that renders its own elements defines them without this.
|
|
41
|
+
const watching = watchElements === true;
|
|
42
|
+
let root;
|
|
43
|
+
let app;
|
|
44
|
+
let runtime;
|
|
45
|
+
let components = new Map();
|
|
46
|
+
let shadowTags = new Set();
|
|
47
|
+
let pages = new Map();
|
|
48
|
+
let endpoints = new Map();
|
|
49
|
+
let layouts = new Map();
|
|
50
|
+
// Virtual module id -> the .html file it came from, so relative imports inside
|
|
51
|
+
// a <script> block resolve against the author's file rather than nowhere.
|
|
52
|
+
const origin = new Map();
|
|
53
|
+
|
|
54
|
+
const scan = () => {
|
|
55
|
+
// One directory. An element is light unless its own file says otherwise,
|
|
56
|
+
// which is read below rather than taken from where the file sits.
|
|
57
|
+
components = readDir(path.resolve(app, elementsDir));
|
|
58
|
+
|
|
59
|
+
const scanned = scanRoutes(resolveRoutesDir(app, routesDir));
|
|
60
|
+
pages = new Map(
|
|
61
|
+
[...scanned.routes, scanned.notFound, scanned.error]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((route) => [route.id, route]),
|
|
64
|
+
);
|
|
65
|
+
endpoints = new Map(scanned.endpoints.map((route) => [route.id, route]));
|
|
66
|
+
// A dash keeps these valid custom element names. That is what makes a light element
|
|
67
|
+
// an undefined custom element rather than an unknown one, and what lets its
|
|
68
|
+
// styles be scoped to its own tag with no class or hash.
|
|
69
|
+
for (const tag of [...components.keys()]) {
|
|
70
|
+
if (!tag.includes('-')) {
|
|
71
|
+
components.delete(tag);
|
|
72
|
+
console.warn(`[transclude] ignoring ${tag}.html. Element names need a dash`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// How a tag renders decides how every file that mentions it compiles, so it
|
|
77
|
+
// has to be known for all of them before any of them is compiled.
|
|
78
|
+
shadowTags = new Set();
|
|
79
|
+
for (const [tag, file] of components) {
|
|
80
|
+
const flags = safely(() => readFlags(read(file), `${tag}.html`));
|
|
81
|
+
if (flags?.shadow) shadowTags.add(tag);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
layouts = scanLayouts(resolveRoutesDir(app, routesDir));
|
|
85
|
+
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Layouts that wrap a page, outermost first: every _layout.html from the routes
|
|
90
|
+
* root down to the page's own directory.
|
|
91
|
+
*/
|
|
92
|
+
const chainFor = (route) => {
|
|
93
|
+
const dirs = path.dirname(route.rel).split(path.sep).filter((d) => d && d !== '.');
|
|
94
|
+
const chain = [];
|
|
95
|
+
for (let i = 0; i <= dirs.length; i++) {
|
|
96
|
+
const id = i === 0 ? 'root' : dirs.slice(0, i).join('-');
|
|
97
|
+
if (layouts.has(id)) chain.push({ id, file: layouts.get(id) });
|
|
98
|
+
}
|
|
99
|
+
return chain;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every component the page can end up rendering, including ones only reached
|
|
104
|
+
* through another component: a re-render produces its children's markup too,
|
|
105
|
+
* and those need their definitions to upgrade.
|
|
106
|
+
*/
|
|
107
|
+
const componentClosure = (seeds) => {
|
|
108
|
+
const out = new Set();
|
|
109
|
+
const queue = [...seeds];
|
|
110
|
+
while (queue.length) {
|
|
111
|
+
const tag = queue.pop();
|
|
112
|
+
if (out.has(tag) || !components.has(tag)) continue;
|
|
113
|
+
out.add(tag);
|
|
114
|
+
for (const nested of safely(() => usedComponents(read(components.get(tag)), components)) ?? []) {
|
|
115
|
+
queue.push(nested);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A light element with no script has nothing to define. It is markup that was
|
|
120
|
+
// already rendered. A shadow one registers so it can re-render.
|
|
121
|
+
return [...out]
|
|
122
|
+
.filter((tag) => {
|
|
123
|
+
if (shadowTags.has(tag)) return true;
|
|
124
|
+
const blocks = safely(() => splitBlocks(read(components.get(tag))));
|
|
125
|
+
return Boolean(blocks?.client?.some((block) => block.code.trim()));
|
|
126
|
+
})
|
|
127
|
+
.sort();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const clientManifest = (route) => {
|
|
131
|
+
const files = [...chainFor(route).map((l) => l.file), route.file];
|
|
132
|
+
const seeds = new Set();
|
|
133
|
+
let hasScript = false;
|
|
134
|
+
|
|
135
|
+
const queue = [...files];
|
|
136
|
+
const visited = new Set();
|
|
137
|
+
while (queue.length) {
|
|
138
|
+
const file = queue.pop();
|
|
139
|
+
if (visited.has(file)) continue;
|
|
140
|
+
visited.add(file);
|
|
141
|
+
|
|
142
|
+
const source = read(file);
|
|
143
|
+
// A light element renders inline, so anything it uses is reached through
|
|
144
|
+
// it and still needs its own definition.
|
|
145
|
+
for (const tag of safely(() => usedComponents(source, components)) ?? []) {
|
|
146
|
+
seeds.add(tag);
|
|
147
|
+
if (!shadowTags.has(tag) && components.has(tag)) queue.push(components.get(tag));
|
|
148
|
+
}
|
|
149
|
+
// The block splitter already separates client <script> from server/props.
|
|
150
|
+
const blocks = safely(() => splitBlocks(source));
|
|
151
|
+
if (blocks?.client?.some((block) => block.code.trim())) hasScript = true;
|
|
152
|
+
}
|
|
153
|
+
const tags = componentClosure(seeds);
|
|
154
|
+
return {
|
|
155
|
+
tags,
|
|
156
|
+
hasScript,
|
|
157
|
+
// Asked by the dev server and by the build, which is the point: two copies
|
|
158
|
+
// of this rule is two servers that disagree about which pages ship JS.
|
|
159
|
+
//
|
|
160
|
+
// Elements to define or script to run, and otherwise nothing at all. The
|
|
161
|
+
// exception is fragments, where any page can be swapped into and needs the
|
|
162
|
+
// loader that defines whatever arrives.
|
|
163
|
+
needed: watching || tags.length > 0 || hasScript,
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const report = (label, warnings) => {
|
|
168
|
+
for (const w of warnings ?? []) console.warn(`[transclude] ${label}: ${w}`);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
name: 'transclude',
|
|
173
|
+
enforce: 'pre',
|
|
174
|
+
|
|
175
|
+
// Read by the build script: it needs the route table and which routes ship
|
|
176
|
+
// client JS before it can decide the rollup inputs.
|
|
177
|
+
api: {
|
|
178
|
+
manifest() {
|
|
179
|
+
if (!root) throw new Error('[transclude] plugin not configured yet');
|
|
180
|
+
const scanned = scanRoutes(resolveRoutesDir(app, routesDir));
|
|
181
|
+
return {
|
|
182
|
+
routes: scanned.routes.map((route) => ({
|
|
183
|
+
id: route.id,
|
|
184
|
+
pattern: route.pattern,
|
|
185
|
+
rel: route.rel,
|
|
186
|
+
params: route.params,
|
|
187
|
+
client: clientManifest(route),
|
|
188
|
+
})),
|
|
189
|
+
// No client entry, no prerendering, no layouts. An endpoint is a route
|
|
190
|
+
// and nothing else.
|
|
191
|
+
endpoints: scanned.endpoints.map((route) => ({
|
|
192
|
+
id: route.id,
|
|
193
|
+
pattern: route.pattern,
|
|
194
|
+
rel: route.rel,
|
|
195
|
+
params: route.params,
|
|
196
|
+
})),
|
|
197
|
+
notFound: scanned.notFound
|
|
198
|
+
? { id: scanned.notFound.id, rel: scanned.notFound.rel, params: [], client: clientManifest(scanned.notFound) }
|
|
199
|
+
: null,
|
|
200
|
+
error: scanned.error
|
|
201
|
+
? { id: scanned.error.id, rel: scanned.error.rel, params: [], client: clientManifest(scanned.error) }
|
|
202
|
+
: null,
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
configure(config) {
|
|
206
|
+
root = config.root;
|
|
207
|
+
app = path.resolve(root, appDir);
|
|
208
|
+
runtime = '/' + path.relative(root, RUNTIME_FILE).split(path.sep).join('/');
|
|
209
|
+
scan();
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
configResolved(config) {
|
|
214
|
+
root = config.root;
|
|
215
|
+
app = path.resolve(root, appDir);
|
|
216
|
+
runtime = '/' + path.relative(root, RUNTIME_FILE).split(path.sep).join('/');
|
|
217
|
+
scan();
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
resolveId(id, importer) {
|
|
221
|
+
if (id === SERVER_ENTRY || id === ELEMENTS_ENTRY) return '\0' + id;
|
|
222
|
+
if (
|
|
223
|
+
id.startsWith(P_COMPONENT) ||
|
|
224
|
+
id.startsWith(P_PAGE) ||
|
|
225
|
+
id.startsWith(P_CLIENT) ||
|
|
226
|
+
id.startsWith(P_LAYOUT)
|
|
227
|
+
) {
|
|
228
|
+
return '\0' + id;
|
|
229
|
+
}
|
|
230
|
+
// A virtual module has no directory, so Vite cannot resolve `../data/x.js`
|
|
231
|
+
// on its own. The block was authored in a real file; use that file's dir.
|
|
232
|
+
if (importer?.startsWith('\0virtual:transclude-') && /^\.\.?\//.test(id)) {
|
|
233
|
+
const source = origin.get(importer);
|
|
234
|
+
if (source) return path.resolve(path.dirname(source), id);
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
load(id) {
|
|
240
|
+
if (!id.startsWith('\0virtual:transclude-')) return null;
|
|
241
|
+
const virt = id.slice(1);
|
|
242
|
+
|
|
243
|
+
// Every element in the app, not only the ones some page renders: a
|
|
244
|
+
// fragment can name any of them, and which one it names is a runtime fact.
|
|
245
|
+
if (virt === ELEMENTS_ENTRY) return compileElementsEntry(components.keys()).code;
|
|
246
|
+
|
|
247
|
+
// One module that pulls in every page, so the SSR build is a single graph.
|
|
248
|
+
// The app's middleware comes through here too rather than being imported
|
|
249
|
+
// from source at runtime: the production server reads `dist` and nothing
|
|
250
|
+
// else, which is what makes its "your source is newer than this build"
|
|
251
|
+
// warning true.
|
|
252
|
+
if (virt === SERVER_ENTRY) {
|
|
253
|
+
const ids = [...pages.keys()];
|
|
254
|
+
const serverFile = path.resolve(app, SERVER_FILE);
|
|
255
|
+
const hasMiddleware = fs.existsSync(serverFile);
|
|
256
|
+
const specifier = '/' + path.relative(root, serverFile).split(path.sep).join('/');
|
|
257
|
+
|
|
258
|
+
// An endpoint is already a module. It needs no compiling, only pulling
|
|
259
|
+
// into the same graph, so production reads it from `dist` like everything
|
|
260
|
+
// else rather than importing app source at runtime.
|
|
261
|
+
const apiIds = [...endpoints.keys()];
|
|
262
|
+
const apiSpec = (route) =>
|
|
263
|
+
JSON.stringify('/' + path.relative(root, route.file).split(path.sep).join('/'));
|
|
264
|
+
|
|
265
|
+
return `
|
|
266
|
+
${ids.map((pageId, i) => `import * as __P${i} from ${JSON.stringify(`${P_PAGE}${pageId}`)};`).join('\n')}
|
|
267
|
+
${apiIds.map((apiId, i) => `import * as __E${i} from ${apiSpec(endpoints.get(apiId))};`).join('\n')}
|
|
268
|
+
${hasMiddleware ? `import __middleware from ${JSON.stringify(specifier)};` : ''}
|
|
269
|
+
|
|
270
|
+
export const pages = {
|
|
271
|
+
${ids.map((pageId, i) => ` ${JSON.stringify(pageId)}: __P${i},`).join('\n')}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const endpoints = {
|
|
275
|
+
${apiIds.map((apiId, i) => ` ${JSON.stringify(apiId)}: __E${i},`).join('\n')}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const middleware = ${hasMiddleware ? '__middleware ?? null' : 'null'};
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (virt.startsWith(P_COMPONENT)) {
|
|
283
|
+
const tag = virt.slice(P_COMPONENT.length);
|
|
284
|
+
const file = components.get(tag);
|
|
285
|
+
if (!file) throw new Error(`[transclude] no element <${tag}> in ${elementsDir}`);
|
|
286
|
+
origin.set(id, file);
|
|
287
|
+
// The donut: a light element's styles stop at any light element nested
|
|
288
|
+
// inside it.
|
|
289
|
+
const inner = [...(safely(() => usedComponents(read(file), components)) ?? [])];
|
|
290
|
+
const nested = inner.filter((child) => !shadowTags.has(child));
|
|
291
|
+
const out = compileComponent(read(file), {
|
|
292
|
+
tag,
|
|
293
|
+
shadow: shadowTags.has(tag),
|
|
294
|
+
components,
|
|
295
|
+
shadowTags,
|
|
296
|
+
runtime,
|
|
297
|
+
filename: tag,
|
|
298
|
+
nested,
|
|
299
|
+
});
|
|
300
|
+
report(tag, out.warnings);
|
|
301
|
+
return out.code;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (virt.startsWith(P_LAYOUT)) {
|
|
305
|
+
const layoutId = virt.slice(P_LAYOUT.length);
|
|
306
|
+
const file = layouts.get(layoutId);
|
|
307
|
+
if (!file) throw new Error(`[transclude] no layout "${layoutId}"`);
|
|
308
|
+
origin.set(id, file);
|
|
309
|
+
const out = compileLayout(read(file), { id: layoutId, components, shadowTags, runtime });
|
|
310
|
+
report(`${layoutId} layout`, out.warnings);
|
|
311
|
+
return out.code;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (virt.startsWith(P_PAGE)) {
|
|
315
|
+
const name = virt.slice(P_PAGE.length);
|
|
316
|
+
const route = pages.get(name);
|
|
317
|
+
if (!route) throw new Error(`[transclude] no page "${name}" in ${routesDir}`);
|
|
318
|
+
origin.set(id, route.file);
|
|
319
|
+
const out = compilePage(read(route.file), {
|
|
320
|
+
components,
|
|
321
|
+
shadowTags,
|
|
322
|
+
runtime,
|
|
323
|
+
filename: name,
|
|
324
|
+
// Absolute, not relative to the project. A bundler composing this map
|
|
325
|
+
// into its own resolves `sources` against the *output* directory, so a
|
|
326
|
+
// repo-relative path came out as `dist/server/app/routes/…` and the
|
|
327
|
+
// stack named whichever file that collided with.
|
|
328
|
+
sourcePath: route.file,
|
|
329
|
+
layouts: chainFor(route),
|
|
330
|
+
client: clientManifest(route),
|
|
331
|
+
});
|
|
332
|
+
report(name, out.warnings);
|
|
333
|
+
// The map goes back with it. Vite composes what a load hook returns; a
|
|
334
|
+
// comment on the code is not read, so a stack named the virtual module.
|
|
335
|
+
return out.map ? { code: out.code, map: out.map } : out.code;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const name = virt.slice(P_CLIENT.length);
|
|
339
|
+
const route = pages.get(name);
|
|
340
|
+
if (!route) throw new Error(`[transclude] no page "${name}" in ${routesDir}`);
|
|
341
|
+
origin.set(id, route.file);
|
|
342
|
+
const sources = [
|
|
343
|
+
...chainFor(route).map((l) => ({ source: read(l.file), filename: `${l.id}/_layout.html` })),
|
|
344
|
+
{ source: read(route.file), filename: route.rel },
|
|
345
|
+
];
|
|
346
|
+
return compileClientEntry(sources, clientManifest(route), {
|
|
347
|
+
runtime,
|
|
348
|
+
elements: watching,
|
|
349
|
+
}).code;
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
configureServer(server) {
|
|
353
|
+
server.watcher.on('all', (_event, file) => {
|
|
354
|
+
if (!file.endsWith('.html')) return;
|
|
355
|
+
if (!file.startsWith(app)) return;
|
|
356
|
+
|
|
357
|
+
scan();
|
|
358
|
+
for (const mod of server.moduleGraph.idToModuleMap.values()) {
|
|
359
|
+
if (mod.id?.startsWith('\0virtual:transclude-')) server.moduleGraph.invalidateModule(mod);
|
|
360
|
+
}
|
|
361
|
+
const hot = server.hot ?? server.ws;
|
|
362
|
+
hot?.send({ type: 'full-reload' });
|
|
363
|
+
});
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Browser URL for a virtual module id.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} page the route id
|
|
372
|
+
* @returns {string} the URL Vite serves its entry from
|
|
373
|
+
*/
|
|
374
|
+
export function clientEntryUrl(page) {
|
|
375
|
+
return `/@id/__x00__${P_CLIENT}${page}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {string} page
|
|
380
|
+
* @returns {string} the virtual module id for that page
|
|
381
|
+
*/
|
|
382
|
+
export function pageModuleId(page) {
|
|
383
|
+
return `${P_PAGE}${page}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `routes/_layout.html` -> "root", `routes/people/_layout.html` -> "people". */
|
|
387
|
+
function scanLayouts(dir, base = dir, out = new Map()) {
|
|
388
|
+
if (!fs.existsSync(dir)) return out;
|
|
389
|
+
|
|
390
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
391
|
+
const full = path.join(dir, entry.name);
|
|
392
|
+
if (entry.isDirectory()) {
|
|
393
|
+
if (!entry.name.startsWith('.')) scanLayouts(full, base, out);
|
|
394
|
+
} else if (entry.name === LAYOUT_FILE) {
|
|
395
|
+
const rel = path.relative(base, dir);
|
|
396
|
+
out.set(rel ? rel.split(path.sep).join('-') : 'root', full);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readDir(dir) {
|
|
403
|
+
const map = new Map();
|
|
404
|
+
if (!fs.existsSync(dir)) return map;
|
|
405
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
406
|
+
if (entry.endsWith('.html')) map.set(entry.slice(0, -5), path.join(dir, entry));
|
|
407
|
+
}
|
|
408
|
+
return map;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function read(file) {
|
|
412
|
+
return fs.readFileSync(file, 'utf8');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A broken file should not take the whole type pass down with it. The module load
|
|
416
|
+
// for that file will report the real error.
|
|
417
|
+
function safely(fn) {
|
|
418
|
+
try {
|
|
419
|
+
return fn();
|
|
420
|
+
} catch {
|
|
421
|
+
return { kind: 'unknown' };
|
|
422
|
+
}
|
|
423
|
+
}
|
package/src/pool.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `worker` over `items` with at most `limit` in flight, preserving order.
|
|
3
|
+
*
|
|
4
|
+
* Rendering itself is synchronous and CPU-bound, so this buys nothing on its
|
|
5
|
+
* own. Loaders wait on I/O, and a build of a hundred pages should not wait for a
|
|
6
|
+
* hundred round trips one at a time.
|
|
7
|
+
*
|
|
8
|
+
* @template T, R
|
|
9
|
+
* @param {T[]} items
|
|
10
|
+
* @param {number} limit at least 1, and never more than `items.length`
|
|
11
|
+
* @param {(item: T, index: number) => Promise<R>} worker
|
|
12
|
+
* @returns {Promise<R[]>} in the order `items` were given, not the order they finished
|
|
13
|
+
*/
|
|
14
|
+
export async function pool(items, limit, worker) {
|
|
15
|
+
const results = new Array(items.length);
|
|
16
|
+
const width = Math.max(1, Math.min(limit, items.length));
|
|
17
|
+
let next = 0;
|
|
18
|
+
|
|
19
|
+
const runners = Array.from({ length: width }, async () => {
|
|
20
|
+
for (;;) {
|
|
21
|
+
const index = next++;
|
|
22
|
+
if (index >= items.length) return;
|
|
23
|
+
results[index] = await worker(items[index], index);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await Promise.all(runners);
|
|
28
|
+
return results;
|
|
29
|
+
}
|
package/src/precache.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// What the build produced, as a list something else can cache.
|
|
2
|
+
//
|
|
3
|
+
// The framework ships no service worker. This is the same agreement fragments
|
|
4
|
+
// have: the server states a fact and somebody else acts on it. Workbox reads
|
|
5
|
+
// this format directly, and thirty lines of hand-written service worker reads it
|
|
6
|
+
// just as easily.
|
|
7
|
+
//
|
|
8
|
+
// No `node:` imports. The build calls it with maps it already holds, and the
|
|
9
|
+
// servers only ever send the string back.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A URL and what tells a cache it changed.
|
|
13
|
+
*
|
|
14
|
+
* `revision: null` means the URL is the version: an asset's filename carries its
|
|
15
|
+
* own hash, so the bytes behind it never change and it can be held forever.
|
|
16
|
+
* Everything else keeps a stable URL and different bytes between builds, so it
|
|
17
|
+
* carries its ETag instead. Getting this backwards is how a service worker
|
|
18
|
+
* serves last week's page and cannot be talked out of it.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {{ url: string, revision: string|null }} Entry
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} sources
|
|
25
|
+
* @param {Iterable<[string, object]>} sources.pages prerendered documents
|
|
26
|
+
* @param {Iterable<[string, object]>} sources.assets hashed build output
|
|
27
|
+
* @param {Iterable<[string, object]>} [sources.files] the author's public files
|
|
28
|
+
* @returns {Entry[]} sorted by URL, so two builds of the same site agree
|
|
29
|
+
*/
|
|
30
|
+
export function precacheList({ pages, assets, files = [] }) {
|
|
31
|
+
const entries = [];
|
|
32
|
+
|
|
33
|
+
// An asset's name holds its hash, so it needs no revision and can be cached
|
|
34
|
+
// until the name changes, which is what a new build does.
|
|
35
|
+
for (const [url] of assets) entries.push({ url, revision: null });
|
|
36
|
+
|
|
37
|
+
for (const source of [pages, files]) {
|
|
38
|
+
for (const [url, entry] of source) {
|
|
39
|
+
// Not `?? null`. A missing revision reads as "this URL is immutable", so a
|
|
40
|
+
// page whose ETag went missing would be cached until the visitor cleared
|
|
41
|
+
// it by hand. Refusing is the smaller failure.
|
|
42
|
+
if (!entry.etag) {
|
|
43
|
+
throw new Error(`[transclude] ${url} has no ETag, so nothing can say when it changed.`);
|
|
44
|
+
}
|
|
45
|
+
entries.push({ url, revision: entry.etag });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
entries.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The document served at `/precache.json`.
|
|
55
|
+
*
|
|
56
|
+
* `version` changes when any entry does, so a service worker can name its cache
|
|
57
|
+
* after it and drop the old one on activate without comparing lists.
|
|
58
|
+
*
|
|
59
|
+
* @param {Entry[]} entries
|
|
60
|
+
* @param {string} version
|
|
61
|
+
* @returns {string} JSON, with a trailing newline
|
|
62
|
+
*/
|
|
63
|
+
export function precacheDocument(entries, version) {
|
|
64
|
+
return `${JSON.stringify({ version, precache: entries }, null, 2)}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where it is served, and where the build writes it under `static/`. */
|
|
68
|
+
export const PRECACHE_PATH = '/precache.json';
|