@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
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
// Turns a single .html file into one JS module that serves both renders.
|
|
2
|
+
|
|
3
|
+
import { parse, parseFragment } from 'parse5';
|
|
4
|
+
import { compileFragment, childrenOf, CompileError } from './codegen.js';
|
|
5
|
+
import { lineMap, sourceMap } from './sourcemap.js';
|
|
6
|
+
import { compileBindings } from './bind.js';
|
|
7
|
+
import {
|
|
8
|
+
ScriptError,
|
|
9
|
+
assertModule,
|
|
10
|
+
assertNoActionsObject,
|
|
11
|
+
assertNoCollisions,
|
|
12
|
+
bindDefaultExport,
|
|
13
|
+
toFunctionBody,
|
|
14
|
+
} from './script.js';
|
|
15
|
+
|
|
16
|
+
export { CompileError, ScriptError };
|
|
17
|
+
|
|
18
|
+
const PAGE_EXPORTS = new Set(['css', 'load', 'render', 'renderHead', 'renderTitle', 'renderHtmlAttrs', 'layouts', 'client', 'elements', 'headScript']);
|
|
19
|
+
const COMPONENT_EXPORTS = new Set([
|
|
20
|
+
'tag', 'light', 'css', 'elements', 'propDefs', 'propAttrs', 'stateDefs', 'members', 'render',
|
|
21
|
+
'coerce', 'def', 'init', 'define', 'default', 'bind', 'update', 'volatile', 'formAssociated',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What an element may declare about itself, in `<script properties>` or in
|
|
26
|
+
* `<script>`. Neither is a prop and neither is setup code: each decides
|
|
27
|
+
* something about the tag, the same for every element of it.
|
|
28
|
+
*
|
|
29
|
+
* `shadow` decides how the tag renders, so it is read before anything is
|
|
30
|
+
* compiled: every other file mentioning the tag compiles differently for it.
|
|
31
|
+
*/
|
|
32
|
+
export const ELEMENT_FLAGS = ['shadow', 'formAssociated'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One value per flag, from whichever block declared it.
|
|
36
|
+
*
|
|
37
|
+
* Declaring one twice is refused rather than resolved. Two homes for one fact
|
|
38
|
+
* leaves nothing to say which is right when they disagree.
|
|
39
|
+
*/
|
|
40
|
+
function resolveFlags(fromProps = {}, fromClient = {}, tag) {
|
|
41
|
+
const out = {};
|
|
42
|
+
|
|
43
|
+
for (const flag of ELEMENT_FLAGS) {
|
|
44
|
+
const a = fromProps[flag] ?? null;
|
|
45
|
+
const b = fromClient[flag] ?? null;
|
|
46
|
+
|
|
47
|
+
if (a !== null && b !== null) {
|
|
48
|
+
throw new ScriptError(
|
|
49
|
+
`<${tag}> declares \`${flag}\` in both <script properties> and <script>. ` +
|
|
50
|
+
`Keep the one that reads better and delete the other.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
out[flag] = a ?? b;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An element's flags, read without compiling it.
|
|
61
|
+
*
|
|
62
|
+
* The plugin needs `shadow` before it can compile anything, because how a tag
|
|
63
|
+
* renders decides how every other file that mentions it compiles. Same blocks
|
|
64
|
+
* and the same extractor as the compile itself, so there is one answer.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} source the whole .html file
|
|
67
|
+
* @param {string} [label] what to call it in an error
|
|
68
|
+
* @returns {Record<string, boolean>} one entry per `ELEMENT_FLAGS` name
|
|
69
|
+
*/
|
|
70
|
+
export function readFlags(source, label = 'element') {
|
|
71
|
+
const blocks = splitBlocks(source);
|
|
72
|
+
|
|
73
|
+
const fromProps = blocks.properties
|
|
74
|
+
? bindDefaultExport(blocks.properties, '__probe', `${label} <script properties>`, {
|
|
75
|
+
flags: ELEMENT_FLAGS,
|
|
76
|
+
}).flags
|
|
77
|
+
: {};
|
|
78
|
+
const fromClient = toFunctionBody(blocks.client, `${label} <script>`, {
|
|
79
|
+
lift: 'prototype',
|
|
80
|
+
flags: ELEMENT_FLAGS,
|
|
81
|
+
}).flags;
|
|
82
|
+
|
|
83
|
+
return resolveFlags(fromProps, fromClient, label);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Top-level <script>/<style> blocks are pulled out; everything else is template.
|
|
88
|
+
*
|
|
89
|
+
* <script server> data loading, page only
|
|
90
|
+
* <script properties> defaults + implied types for the element's properties
|
|
91
|
+
* <script state> internal reactive state, component only
|
|
92
|
+
* <script> client code, and `export const prototype` with it
|
|
93
|
+
* <style> scoped to the shadow root (component) or the page
|
|
94
|
+
*
|
|
95
|
+
* Each block carries the line it starts on so parse errors can point back into
|
|
96
|
+
* the .html file rather than into generated output.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} source
|
|
99
|
+
* @returns {object} the script blocks, the styles, the markup nodes and the
|
|
100
|
+
* `<html>` element read from a second parse
|
|
101
|
+
*/
|
|
102
|
+
export function splitBlocks(source) {
|
|
103
|
+
const doc = parseFragment(source, { sourceCodeLocationInfo: true });
|
|
104
|
+
|
|
105
|
+
// A second parse, in document mode, only to read `<html>`. The fragment parser
|
|
106
|
+
// above drops it: a nested html start tag cannot appear in a body, so it goes
|
|
107
|
+
// away with its attributes. In document mode it is the element it names, and a
|
|
108
|
+
// `<html>` inside a script block or a comment is still not one, because this
|
|
109
|
+
// is the real parser rather than a search for a string.
|
|
110
|
+
const html =
|
|
111
|
+
parse(source, { sourceCodeLocationInfo: true }).childNodes.find((n) => n.nodeName === 'html') ??
|
|
112
|
+
null;
|
|
113
|
+
const out = { server: null, properties: null, state: null, client: [], head: [], styles: [], nodes: [], html };
|
|
114
|
+
|
|
115
|
+
for (const node of doc.childNodes) {
|
|
116
|
+
if (node.nodeName === 'script') {
|
|
117
|
+
const attrs = new Set((node.attrs ?? []).map((a) => a.name));
|
|
118
|
+
const block = blockOf(node);
|
|
119
|
+
if (attrs.has('server')) out.server = block;
|
|
120
|
+
else if (attrs.has('properties')) out.properties = block;
|
|
121
|
+
else if (attrs.has('props')) {
|
|
122
|
+
throw new CompileError(
|
|
123
|
+
'`<script props>` is now `<script properties>`. A property is what the ' +
|
|
124
|
+
'platform calls it, and what the element gets.',
|
|
125
|
+
node,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
// Members used to have a block of their own. They belong with the setup
|
|
129
|
+
// code that calls them, so they moved into it.
|
|
130
|
+
else if (attrs.has('element')) {
|
|
131
|
+
throw new CompileError(
|
|
132
|
+
'`<script element>` is gone. Move its members into `<script>` as ' +
|
|
133
|
+
'`export const prototype = { … }`. The same object, next to the code that uses it.',
|
|
134
|
+
node,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// `<script state>` is the component's own, not in the document.
|
|
138
|
+
else if (attrs.has('state')) out.state = block;
|
|
139
|
+
// `<script head>` is emitted verbatim into <head>, ahead of everything
|
|
140
|
+
// else. Some things have to run before the body parses: a theme applied
|
|
141
|
+
// before first paint, or a `pagereveal` listener, which fires too early for
|
|
142
|
+
// any script in the body to see.
|
|
143
|
+
else if (attrs.has('head')) out.head.push({ ...block, attrs: node.attrs ?? [] });
|
|
144
|
+
else out.client.push(block);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (node.nodeName === 'style') {
|
|
148
|
+
out.styles.push(blockOf(node).code);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
out.nodes.push(node);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Drop the blank lines left behind where the blocks used to be.
|
|
155
|
+
while (out.nodes.length && isBlank(out.nodes[0])) out.nodes.shift();
|
|
156
|
+
while (out.nodes.length && isBlank(out.nodes.at(-1))) out.nodes.pop();
|
|
157
|
+
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Compiles one element. `export const shadow = true` in the file decides which
|
|
163
|
+
* kind it is, so the file answers for itself.
|
|
164
|
+
*
|
|
165
|
+
* Light is the default: styles scoped with `@scope`, markup inline, page CSS
|
|
166
|
+
* reaching it, and form controls and `<label for>` working because there is no
|
|
167
|
+
* boundary. A shadow root is the opt-in, with everything that follows from it.
|
|
168
|
+
*/
|
|
169
|
+
export function compileComponent(
|
|
170
|
+
source,
|
|
171
|
+
{ tag, shadow = false, components = new Map(), shadowTags = new Set(), runtime, filename = '', nested = [] },
|
|
172
|
+
) {
|
|
173
|
+
const blocks = splitBlocks(source);
|
|
174
|
+
const where = (kind) => `${filename || tag}.html <script${kind ? ` ${kind}` : ''}>`;
|
|
175
|
+
|
|
176
|
+
// A flag is a fact about the element rather than a prop or a piece of setup,
|
|
177
|
+
// so either block can carry it. An element that only needs one would otherwise
|
|
178
|
+
// need a block holding nothing else.
|
|
179
|
+
const props = blocks.properties
|
|
180
|
+
? bindDefaultExport(blocks.properties, '__propDefs', where('properties'), { flags: ELEMENT_FLAGS })
|
|
181
|
+
: { code: 'const __propDefs = {};', exports: [], defaultNode: null, flags: {} };
|
|
182
|
+
assertNoCollisions(props.exports, COMPONENT_EXPORTS, where('properties'));
|
|
183
|
+
|
|
184
|
+
// Members ride along in the client block: `export const prototype`, hoisted to
|
|
185
|
+
// module scope with anything it reads, because a prototype is shared and the
|
|
186
|
+
// setup body is per element.
|
|
187
|
+
const client = toFunctionBody(blocks.client, where(''), { lift: 'prototype', flags: ELEMENT_FLAGS });
|
|
188
|
+
assertNoLifecycle(client.lifted, where(''));
|
|
189
|
+
|
|
190
|
+
const flags = resolveFlags(props.flags, client.flags, tag);
|
|
191
|
+
const formAssociated = flags.formAssociated ?? false;
|
|
192
|
+
// The file decides. `shadow` is still an argument so a caller can compile one
|
|
193
|
+
// element on its own, but a file that says which it is always wins.
|
|
194
|
+
const isShadow = flags.shadow ?? shadow;
|
|
195
|
+
|
|
196
|
+
const state = blocks.state
|
|
197
|
+
? bindDefaultExport(blocks.state, '__stateDefs', where('state'))
|
|
198
|
+
: { code: 'const __stateDefs = {};', exports: [], defaultNode: null };
|
|
199
|
+
assertNoCollisions(state.exports, COMPONENT_EXPORTS, where('state'));
|
|
200
|
+
|
|
201
|
+
assertDistinct(props.defaultNode, state.defaultNode, tag);
|
|
202
|
+
|
|
203
|
+
// Whether this element is registered at all. A light element with no behavior
|
|
204
|
+
// is markup that was already rendered and ships nothing, so it can never see
|
|
205
|
+
// an attribute change and has nothing to update. Anchors would be bytes on
|
|
206
|
+
// every page that pay for a repaint that cannot happen.
|
|
207
|
+
const defined =
|
|
208
|
+
isShadow ||
|
|
209
|
+
Boolean(client.body.trim()) ||
|
|
210
|
+
client.lifted !== null ||
|
|
211
|
+
formAssociated === true ||
|
|
212
|
+
Boolean(blocks.state);
|
|
213
|
+
|
|
214
|
+
const template = compileFragment(blocks.nodes, {
|
|
215
|
+
components,
|
|
216
|
+
shadowTags,
|
|
217
|
+
page: false,
|
|
218
|
+
// A light element's `<slot>` is a compile-time hole, like a layout's. In a
|
|
219
|
+
// shadow root it is a real slot and must reach the browser untouched.
|
|
220
|
+
layout: !isShadow,
|
|
221
|
+
// Anchors are what an update writes through, so every element that can be
|
|
222
|
+
// updated needs them and no other element should carry them.
|
|
223
|
+
blocks: defined,
|
|
224
|
+
// A fragment emits a shadow element bare and lets it paint itself, so its
|
|
225
|
+
// own render is never what a fragment asks for. A light element's is.
|
|
226
|
+
fragments: !isShadow,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const styles = blocks.styles.join('\n').trim();
|
|
230
|
+
|
|
231
|
+
const bindings = defined
|
|
232
|
+
? compileBindings(blocks.nodes, {
|
|
233
|
+
components,
|
|
234
|
+
shadowTags,
|
|
235
|
+
blockOf: template.blockOf,
|
|
236
|
+
refs: new Map(template.components.map(({ tag: name, ref }) => [name, ref])),
|
|
237
|
+
// The runtime prepends <style> to the shadow root, so a component's own
|
|
238
|
+
// first node is not at index 0. A light element's styles are hoisted
|
|
239
|
+
// into <head>, so its root starts where the template does.
|
|
240
|
+
rootOffset: isShadow && styles ? 1 : 0,
|
|
241
|
+
})
|
|
242
|
+
: null;
|
|
243
|
+
|
|
244
|
+
// A light element updates the nodes it already has: text and attributes are
|
|
245
|
+
// written in place, which never touches what the caller slotted in. Structure
|
|
246
|
+
// is different. Rebuilding an `if` or an `each` means replacing children, and a
|
|
247
|
+
// light element does not own its children: the page's CSS reaches them, the
|
|
248
|
+
// page's script can hold them, and the caller's slotted markup sits among them.
|
|
249
|
+
// A shadow root is what makes that subtree the element's to replace.
|
|
250
|
+
const volatileProps = bindings?.volatile ?? [];
|
|
251
|
+
if (!isShadow && volatileProps.length) {
|
|
252
|
+
throw new CompileError(
|
|
253
|
+
`<${tag}> re-renders \`${volatileProps.join('`, `')}\` by rebuilding structure, ` +
|
|
254
|
+
`which a light element cannot do: it does not own its own children. ` +
|
|
255
|
+
`Add \`export const shadow = true\`, or move the \`if\` or \`each\` to the page.`,
|
|
256
|
+
blocks.nodes[0] ?? null,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const stray = blocks.nodes.find(
|
|
261
|
+
(node) => node.tagName === 'template' && node.attrs?.some((a) => a.name === 'shadowrootmode'),
|
|
262
|
+
);
|
|
263
|
+
if (stray) {
|
|
264
|
+
throw new CompileError(
|
|
265
|
+
isShadow
|
|
266
|
+
? `<${tag}> is a component, so it already has a shadow root. Drop the ` +
|
|
267
|
+
`<template shadowrootmode> wrapper and write the markup directly`
|
|
268
|
+
: `<${tag}> is a partial and has no shadow root. Move it to the components ` +
|
|
269
|
+
`directory if it needs one.`,
|
|
270
|
+
stray,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const warnings = [
|
|
275
|
+
...template.warnings,
|
|
276
|
+
...client.warnings,
|
|
277
|
+
...unusedProps(props.defaultNode, template.reads, blocks),
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
const code = `
|
|
281
|
+
${runtimeImport(runtime)}
|
|
282
|
+
${componentImports(template.components, { defines: true })}
|
|
283
|
+
${client.imports}
|
|
284
|
+
${props.code}
|
|
285
|
+
${state.code}
|
|
286
|
+
${client.lifted ? client.hoisted : 'const __members = {};'}
|
|
287
|
+
|
|
288
|
+
export const tag = ${JSON.stringify(tag)};
|
|
289
|
+
export const light = ${!isShadow};
|
|
290
|
+
export const formAssociated = ${formAssociated === true};
|
|
291
|
+
export const css = ${JSON.stringify(isShadow ? styles : scopeCss(styles, tag, nested))};
|
|
292
|
+
export const propDefs = __propDefs;
|
|
293
|
+
export const propAttrs = ${props.exports.includes('attributes') ? 'attributes' : '{}'};
|
|
294
|
+
export const stateDefs = __stateDefs;
|
|
295
|
+
export const members = __members;
|
|
296
|
+
${elementsExport(template.components)}
|
|
297
|
+
|
|
298
|
+
export function render(__d, __slots = {}, __fragment = false) {
|
|
299
|
+
let __o = '';
|
|
300
|
+
${indent(template.body)}
|
|
301
|
+
return __o;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function coerce(props) {
|
|
305
|
+
return coerceProps(propDefs, props, propAttrs);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
${template.blockDefs}
|
|
309
|
+
${bindingsCode(bindings)}
|
|
310
|
+
export const def = {
|
|
311
|
+
tag, light, css, elements, propDefs, propAttrs, stateDefs, members, render, coerce, bind,
|
|
312
|
+
update, volatile, formAssociated,
|
|
313
|
+
};
|
|
314
|
+
export default def;
|
|
315
|
+
|
|
316
|
+
export async function init(host, shadow, signal, internals) {
|
|
317
|
+
${client.body}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Defining an element defines what it renders. A page's entry lists the whole
|
|
321
|
+
// set up front for first paint. An element that arrives on its own, in a fragment
|
|
322
|
+
// found by the element watcher, has only itself to start from, and a shadow root
|
|
323
|
+
// it paints is out of reach of anything watching the document.
|
|
324
|
+
//
|
|
325
|
+
// The flag is for the cycle: an element may render itself.
|
|
326
|
+
let __defined = false;
|
|
327
|
+
|
|
328
|
+
export function define() {
|
|
329
|
+
if (__defined) return;
|
|
330
|
+
__defined = true;
|
|
331
|
+
${isShadow ? 'defineComponent' : 'defineLight'}(def, ${client.body.trim() ? 'init' : 'null'});
|
|
332
|
+
${template.components.map(({ ref }) => ` ${ref}_define();`).join('\n')}
|
|
333
|
+
}
|
|
334
|
+
`;
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
code,
|
|
338
|
+
warnings,
|
|
339
|
+
isShadow,
|
|
340
|
+
hasScript: Boolean(client.body.trim()),
|
|
341
|
+
components: template.components.map((c) => c.tag),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Where a mapped block starts, once the module is assembled.
|
|
347
|
+
*
|
|
348
|
+
* The assemblers are one template literal each. Rather than restructure them to
|
|
349
|
+
* count lines as they go, each block is written under a marker that `lineMap`
|
|
350
|
+
* finds, measures and removes. Nothing reaches the output.
|
|
351
|
+
*/
|
|
352
|
+
const MARK = {
|
|
353
|
+
body: '/*@transclude:body*/',
|
|
354
|
+
head: '/*@transclude:head*/',
|
|
355
|
+
title: '/*@transclude:title*/',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
export function compilePage(
|
|
359
|
+
source,
|
|
360
|
+
{
|
|
361
|
+
components = new Map(),
|
|
362
|
+
shadowTags = new Set(),
|
|
363
|
+
runtime,
|
|
364
|
+
filename = 'page',
|
|
365
|
+
// What the source map names. `filename` is what an error message says, which
|
|
366
|
+
// is the short route id; a stack wants the path an editor can open.
|
|
367
|
+
sourcePath = null,
|
|
368
|
+
layouts = [],
|
|
369
|
+
client = { tags: [], hasScript: false, needed: false },
|
|
370
|
+
},
|
|
371
|
+
) {
|
|
372
|
+
const blocks = splitBlocks(source);
|
|
373
|
+
const where = `${filename}.html <script server>`;
|
|
374
|
+
const headWhere = `${filename}.html <script head>`;
|
|
375
|
+
|
|
376
|
+
const server = blocks.server
|
|
377
|
+
? bindDefaultExport(blocks.server, '__load', where)
|
|
378
|
+
: { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
|
|
379
|
+
assertNoCollisions(server.exports, PAGE_EXPORTS, where);
|
|
380
|
+
assertNoActionsObject(server.exports, where);
|
|
381
|
+
|
|
382
|
+
const template = compileFragment(blocks.nodes, { components, shadowTags, page: true, html: blocks.html });
|
|
383
|
+
assertIncludesResolve(template.regionIncludes, template.regions);
|
|
384
|
+
|
|
385
|
+
const code = `
|
|
386
|
+
${runtimeImport(runtime)}
|
|
387
|
+
${componentImports(template.components)}
|
|
388
|
+
${layoutImports(layouts)}
|
|
389
|
+
${server.code}
|
|
390
|
+
|
|
391
|
+
export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
|
|
392
|
+
export const headScript = ${JSON.stringify(headScript(blocks, headWhere))};
|
|
393
|
+
${elementsExport(template.components)}
|
|
394
|
+
export const hasTitle = ${template.hasTitle};
|
|
395
|
+
export const layouts = [${layouts.map((_, i) => `__L${i}`).join(', ')}];
|
|
396
|
+
export const client = ${JSON.stringify(client)};
|
|
397
|
+
${regionsExport(template.regions)}
|
|
398
|
+
export const includes = ${JSON.stringify(template.includes ?? [])};
|
|
399
|
+
|
|
400
|
+
export async function load(ctx) {
|
|
401
|
+
if (typeof __load === 'function') return (await __load(ctx)) ?? {};
|
|
402
|
+
return __load ?? {};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function renderTitle(__d) {
|
|
406
|
+
let __o = '';
|
|
407
|
+
${MARK.title}
|
|
408
|
+
${indent(template.title)}
|
|
409
|
+
return __o;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function renderHtmlAttrs(__d) {
|
|
413
|
+
return ${template.htmlAttrs ?? '{}'};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function renderHead(__d) {
|
|
417
|
+
let __o = '';
|
|
418
|
+
${MARK.head}
|
|
419
|
+
${indent(template.head)}
|
|
420
|
+
return __o;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function render(__d, __slots = {}, __fragment = false) {
|
|
424
|
+
const __out = {};
|
|
425
|
+
${slotBodies(template)}
|
|
426
|
+
return __out;
|
|
427
|
+
}
|
|
428
|
+
`;
|
|
429
|
+
|
|
430
|
+
const mapped = withMap(code, template, source, sourcePath ?? `${filename}.html`);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
code: mapped.code,
|
|
434
|
+
map: mapped.map,
|
|
435
|
+
warnings: template.warnings,
|
|
436
|
+
components: template.components.map((c) => c.tag),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The module, its markers removed, with a map from its lines to the file's.
|
|
442
|
+
*
|
|
443
|
+
* Server-side only: a page module is never sent to a browser, so embedding the
|
|
444
|
+
* source costs a visitor nothing and is what lets a stack read on a host with no
|
|
445
|
+
* access to the file.
|
|
446
|
+
*
|
|
447
|
+
* @param {string} code the assembled module, markers and all
|
|
448
|
+
* @param {object} template what `compileFragment` returned
|
|
449
|
+
* @param {string} source the original `.html`
|
|
450
|
+
* @param {string} filename how it should be named in a stack
|
|
451
|
+
* @returns {{ code: string, map: string|null }}
|
|
452
|
+
*/
|
|
453
|
+
function withMap(code, template, source, filename) {
|
|
454
|
+
const blocks = [
|
|
455
|
+
{ marker: MARK.body, at: template.at?.body ?? [] },
|
|
456
|
+
{ marker: MARK.head, at: template.at?.head ?? [] },
|
|
457
|
+
{ marker: MARK.title, at: template.at?.title ?? [] },
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
const { code: clean, lines } = lineMap(code, blocks);
|
|
461
|
+
// Nothing mapped means nothing to say. An empty map is a file a tool will
|
|
462
|
+
// fetch and read to learn that it knows nothing.
|
|
463
|
+
if (!lines.some((line) => line !== null)) return { code: clean, map: null };
|
|
464
|
+
|
|
465
|
+
// Handed back rather than written into the code as a comment. Vite reads a
|
|
466
|
+
// map a `load` hook returns and composes it; an inline comment on the code it
|
|
467
|
+
// returns is not looked at, which is why the stack still named the virtual
|
|
468
|
+
// module and a generated line.
|
|
469
|
+
return { code: clean, map: sourceMap(lines, filename, source) };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* A layout is a page that renders a hole. `render` receives the slot map its
|
|
474
|
+
* child produced, and returns its own for the level above.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} source
|
|
477
|
+
* @param {{ id: string, components?: Map<string, string>,
|
|
478
|
+
* shadowTags?: Set<string>, runtime: string }} options
|
|
479
|
+
* @returns {{ code: string, warnings: string[], components: string[] }}
|
|
480
|
+
*/
|
|
481
|
+
export function compileLayout(source, { id, components = new Map(), shadowTags = new Set(), runtime }) {
|
|
482
|
+
const blocks = splitBlocks(source);
|
|
483
|
+
const where = `${id}/_layout.html <script server>`;
|
|
484
|
+
const headWhere = `${id}/_layout.html <script head>`;
|
|
485
|
+
|
|
486
|
+
const server = blocks.server
|
|
487
|
+
? bindDefaultExport(blocks.server, '__load', where)
|
|
488
|
+
: { code: 'const __load = null;', exports: [], imports: [], defaultNode: null };
|
|
489
|
+
assertNoCollisions(server.exports, PAGE_EXPORTS, where);
|
|
490
|
+
assertNoActionsObject(server.exports, where);
|
|
491
|
+
|
|
492
|
+
const template = compileFragment(blocks.nodes, {
|
|
493
|
+
components,
|
|
494
|
+
shadowTags,
|
|
495
|
+
page: true,
|
|
496
|
+
layout: true,
|
|
497
|
+
html: blocks.html,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const warnings = [...template.warnings];
|
|
501
|
+
if (!/__slots\[/.test(template.body)) {
|
|
502
|
+
warnings.push('no <slot>, so nothing rendered inside this layout would appear');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const code = `
|
|
506
|
+
${runtimeImport(runtime)}
|
|
507
|
+
${componentImports(template.components)}
|
|
508
|
+
${server.code}
|
|
509
|
+
|
|
510
|
+
export const css = ${JSON.stringify(blocks.styles.join('\n').trim())};
|
|
511
|
+
export const headScript = ${JSON.stringify(headScript(blocks, headWhere))};
|
|
512
|
+
${elementsExport(template.components)}
|
|
513
|
+
export const hasTitle = ${template.hasTitle};
|
|
514
|
+
|
|
515
|
+
export async function load(ctx) {
|
|
516
|
+
if (typeof __load === 'function') return (await __load(ctx)) ?? {};
|
|
517
|
+
return __load ?? {};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function renderTitle(__d) {
|
|
521
|
+
let __o = '';
|
|
522
|
+
${indent(template.title)}
|
|
523
|
+
return __o;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function renderHtmlAttrs(__d) {
|
|
527
|
+
return ${template.htmlAttrs ?? '{}'};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function renderHead(__d) {
|
|
531
|
+
let __o = '';
|
|
532
|
+
${indent(template.head)}
|
|
533
|
+
return __o;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function render(__d, __slots = {}, __fragment = false) {
|
|
537
|
+
const __out = {};
|
|
538
|
+
${slotBodies(template)}
|
|
539
|
+
return __out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export default { css, headScript, elements, hasTitle, load, renderTitle, renderHead, renderHtmlAttrs, render };
|
|
543
|
+
`;
|
|
544
|
+
|
|
545
|
+
return { code, warnings, components: template.components.map((c) => c.tag) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Wraps a light element's styles in `@scope`, rooted at its own tag. A custom
|
|
550
|
+
* element name is already a valid selector, so nothing has to be hashed.
|
|
551
|
+
*
|
|
552
|
+
* The `to` clause is the donut: styles stop at any light element nested inside,
|
|
553
|
+
* so an outer one cannot reach into one it merely contains.
|
|
554
|
+
*
|
|
555
|
+
* @param {string} css
|
|
556
|
+
* @param {string} tag the element the rules belong to
|
|
557
|
+
* @param {string[]} [nested] tags rendered inside it, which the scope has to reach
|
|
558
|
+
* @returns {string}
|
|
559
|
+
*/
|
|
560
|
+
export function scopeCss(css, tag, nested = []) {
|
|
561
|
+
if (!css) return '';
|
|
562
|
+
const limit = nested.length ? ` to (${nested.map((inner) => `${tag} ${inner}`).join(', ')})` : '';
|
|
563
|
+
const indented = css.split('\n').map((line) => (line ? ` ${line}` : line)).join('\n');
|
|
564
|
+
return `@scope (${tag})${limit} {\n${indented}\n}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Component tags a template uses. This is how only those get shipped.
|
|
569
|
+
*
|
|
570
|
+
* @param {string} source
|
|
571
|
+
* @param {Map<string, string>|Set<string>} registry every known tag
|
|
572
|
+
* @returns {Set<string>} the tags this source renders
|
|
573
|
+
*/
|
|
574
|
+
export function usedComponents(source, registry) {
|
|
575
|
+
const found = new Set();
|
|
576
|
+
|
|
577
|
+
const walk = (nodes) => {
|
|
578
|
+
for (const node of nodes) {
|
|
579
|
+
if (!node.tagName) continue;
|
|
580
|
+
if (registry.has(node.tagName)) found.add(node.tagName);
|
|
581
|
+
walk(childrenOf(node));
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
walk(splitBlocks(source).nodes);
|
|
586
|
+
return found;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Browser entry: define every component, then run the page's own client code.
|
|
591
|
+
* This one is a real module, so the client block keeps its imports and may use
|
|
592
|
+
* top-level await. It is only checked, not rewritten.
|
|
593
|
+
*
|
|
594
|
+
* `elements` adds the loader for everything else: the page's own tags are
|
|
595
|
+
* imported statically and defined before first paint, and any other tag in the
|
|
596
|
+
* app is one dynamic import away, taken only if it ever shows up in the DOM.
|
|
597
|
+
*
|
|
598
|
+
* @param {Array<{ source: string, filename: string }>} sources the files whose
|
|
599
|
+
* `<script>` blocks run in the browser, layouts first and the page last
|
|
600
|
+
* @param {{ tags?: string[] }} [what] the elements to define
|
|
601
|
+
* @param {{ runtime: string, elements?: boolean }} options required, not
|
|
602
|
+
* defaulted: `runtime` is written into the module's import, and without it the
|
|
603
|
+
* output says `from undefined` and fails only when something tries to load it
|
|
604
|
+
* @returns {{ code: string }} the code is empty when the page needs no entry
|
|
605
|
+
*/
|
|
606
|
+
export function compileClientEntry(sources, { tags = [] } = {}, { runtime, elements = false }) {
|
|
607
|
+
// Layouts first, page last: the same order they wrap in.
|
|
608
|
+
const blocks = sources.map(({ source, filename }) =>
|
|
609
|
+
assertModule(splitBlocks(source).client, `${filename} <script>`),
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
// Markup can arrive after the page did, from something this framework does not
|
|
613
|
+
// provide, and whatever it names has to be able to define itself.
|
|
614
|
+
const imports = elements
|
|
615
|
+
? `import { watch as __watch } from ${JSON.stringify(runtime)};\n` +
|
|
616
|
+
`import { elements as __elements } from ${JSON.stringify(ELEMENTS_ENTRY)};`
|
|
617
|
+
: '';
|
|
618
|
+
|
|
619
|
+
const start = elements ? '__watch(__elements);' : '';
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
code: `
|
|
623
|
+
${imports}
|
|
624
|
+
${tags.map((tag, i) => `import { define as __D${i} } from ${JSON.stringify(`virtual:transclude-component/${tag}`)};`).join('\n')}
|
|
625
|
+
|
|
626
|
+
${tags.map((_, i) => `__D${i}();`).join('\n')}
|
|
627
|
+
${start}
|
|
628
|
+
|
|
629
|
+
${blocks.join('\n')}
|
|
630
|
+
`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The id of the module `compileClientEntry` reaches for when `elements` is on. */
|
|
635
|
+
export const ELEMENTS_ENTRY = 'virtual:transclude-elements';
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* tag -> dynamic import, for every element in the app.
|
|
639
|
+
*
|
|
640
|
+
* A thunk rather than a URL: the bundler is the only thing that knows where the
|
|
641
|
+
* chunk lands, and `import()` is how you ask it. Nothing has to be written into
|
|
642
|
+
* a manifest, threaded through the server, or kept in sync with a hash.
|
|
643
|
+
*
|
|
644
|
+
* @param {Iterable<string>} tags every element the app defines
|
|
645
|
+
* @returns {{ code: string }}
|
|
646
|
+
*/
|
|
647
|
+
export function compileElementsEntry(tags) {
|
|
648
|
+
const entries = [...tags]
|
|
649
|
+
.sort()
|
|
650
|
+
.map(
|
|
651
|
+
(tag) =>
|
|
652
|
+
` ${JSON.stringify(tag)}: () => import(${JSON.stringify(`virtual:transclude-component/${tag}`)}),`,
|
|
653
|
+
);
|
|
654
|
+
return { code: `export const elements = {\n${entries.join('\n')}\n};\n` };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* `bind` finds the node behind every expression the compiler could place, once;
|
|
659
|
+
* `update` writes to them. `volatile` is the list of prop names whose change needs
|
|
660
|
+
* a full repaint, because nothing here can reach them.
|
|
661
|
+
*
|
|
662
|
+
* An element with nothing to bind gets the same shape, empty, so the runtime
|
|
663
|
+
* never has to ask whether it exists.
|
|
664
|
+
*/
|
|
665
|
+
function bindingsCode(bindings) {
|
|
666
|
+
if (!bindings) {
|
|
667
|
+
return [
|
|
668
|
+
'export function bind() { return null; }',
|
|
669
|
+
'export function update() { return false; }',
|
|
670
|
+
'export const volatile = [];',
|
|
671
|
+
].join('\n');
|
|
672
|
+
}
|
|
673
|
+
return [
|
|
674
|
+
'export function bind(__root, __d) {',
|
|
675
|
+
' const __b = [];',
|
|
676
|
+
bindings.cursors
|
|
677
|
+
? ` let ${Array.from({ length: bindings.cursors }, (_, i) => `__c${i}`).join(', ')};`
|
|
678
|
+
: '',
|
|
679
|
+
indent(bindings.locate),
|
|
680
|
+
' return __b;',
|
|
681
|
+
'}',
|
|
682
|
+
'',
|
|
683
|
+
'export function update(__b, __d) {',
|
|
684
|
+
' let __ok = true;',
|
|
685
|
+
indent(bindings.writes),
|
|
686
|
+
' return __ok;',
|
|
687
|
+
'}',
|
|
688
|
+
'',
|
|
689
|
+
bindings.parts,
|
|
690
|
+
`export const volatile = ${JSON.stringify(bindings.volatile)};`,
|
|
691
|
+
].join('\n');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Overwriting these on the prototype replaces the framework's own, and the
|
|
695
|
+
// element silently stops rendering. `adoptedCallback` is not among them: nothing
|
|
696
|
+
// implements it, so there is nothing to break.
|
|
697
|
+
const RESERVED_LIFECYCLE = {
|
|
698
|
+
connectedCallback: 'setup belongs in the <script> body, which runs on connect',
|
|
699
|
+
disconnectedCallback: 'return a cleanup function from the <script> body instead',
|
|
700
|
+
attributeChangedCallback: 'an attribute change already re-renders; use updated() for the side effect',
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/** The keys of an `export default { … }`, where it is a plain object literal. */
|
|
704
|
+
function objectKeys(defaultNode) {
|
|
705
|
+
if (defaultNode?.type !== 'ObjectExpression') return [];
|
|
706
|
+
return defaultNode.properties
|
|
707
|
+
.filter((prop) => prop.type === 'Property' && !prop.computed)
|
|
708
|
+
.map((prop) => (prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value)));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Props and state share one set of names in the template. `${open}` cannot say
|
|
713
|
+
* which one it meant, so a name can only belong to one of them.
|
|
714
|
+
*/
|
|
715
|
+
function assertDistinct(propsNode, stateNode, tag) {
|
|
716
|
+
const declared = new Set(objectKeys(propsNode));
|
|
717
|
+
for (const key of objectKeys(stateNode)) {
|
|
718
|
+
if (declared.has(key)) {
|
|
719
|
+
throw new CompileError(
|
|
720
|
+
`<${tag}>: \`${key}\` is declared in both <script properties> and <script state>. ` +
|
|
721
|
+
`A template reads them from one namespace, so the name has to be one or the other.`,
|
|
722
|
+
stateNode,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function assertNoLifecycle(defaultNode, label) {
|
|
729
|
+
if (defaultNode?.type !== 'ObjectExpression') return;
|
|
730
|
+
|
|
731
|
+
for (const prop of defaultNode.properties) {
|
|
732
|
+
if (prop.type !== 'Property' || prop.computed) continue;
|
|
733
|
+
const name = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
734
|
+
const advice = RESERVED_LIFECYCLE[name];
|
|
735
|
+
if (advice) {
|
|
736
|
+
throw new CompileError(`${label}: \`${name}\` belongs to the framework. ${advice}`, prop);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function escapeRegExp(text) {
|
|
742
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* A prop nobody reads is usually a rename that only got half done. A prop can
|
|
747
|
+
* fairly never appear in the template. `compact` drives `:host([compact])` in CSS
|
|
748
|
+
* and is toggled from the client block. So a plain word match against <style> and
|
|
749
|
+
* <script> is what keeps this quiet enough to leave on.
|
|
750
|
+
*/
|
|
751
|
+
function unusedProps(defaultNode, reads, blocks) {
|
|
752
|
+
if (defaultNode?.type !== 'ObjectExpression') return [];
|
|
753
|
+
|
|
754
|
+
const declared = [];
|
|
755
|
+
for (const prop of defaultNode.properties) {
|
|
756
|
+
if (prop.type !== 'Property' || prop.computed) return [];
|
|
757
|
+
if (prop.key.type === 'Identifier') declared.push(prop.key.name);
|
|
758
|
+
else if (prop.key.type === 'Literal') declared.push(String(prop.key.value));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const elsewhere = [...blocks.styles, ...blocks.client.map((b) => b.code)].join('\n');
|
|
762
|
+
|
|
763
|
+
return declared
|
|
764
|
+
.filter((name) => !reads.has(name))
|
|
765
|
+
.filter((name) => !new RegExp(`\\b${escapeRegExp(name)}\\b`).test(elsewhere))
|
|
766
|
+
.map(
|
|
767
|
+
(name) =>
|
|
768
|
+
`prop \`${name}\` is declared but never used. It is not read in the template, ` +
|
|
769
|
+
`and does not appear in <style> or <script>`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---- module assembly helpers ---------------------------------------------
|
|
774
|
+
|
|
775
|
+
function runtimeImport(runtime) {
|
|
776
|
+
return `import { escape as __e, attr as __a, attrProp as __ap, str as __str, json, shadow as __sh, data as __data, included as __incl, textAt as __textAt, setText as __setText, setParts as __setParts, setAttr as __setAttr, setAttrProp as __setAttrProp, blockAt as __blockAt, updateBlock as __updateBlock, coerceProps, defineComponent, defineLight, html } from ${JSON.stringify(runtime)};`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function layoutImports(layouts) {
|
|
780
|
+
return layouts
|
|
781
|
+
.map((layout, i) => `import __L${i} from ${JSON.stringify(`virtual:transclude-layout/${layout.id}`)};`)
|
|
782
|
+
.join('\n');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* `defines` pulls each nested element's `define` in alongside its def, so a
|
|
787
|
+
* component can register the elements it renders. Only a component needs that. A
|
|
788
|
+
* page or layout never renders itself into a document that has not already loaded
|
|
789
|
+
* its entry.
|
|
790
|
+
*/
|
|
791
|
+
function componentImports(used, { defines = false } = {}) {
|
|
792
|
+
return used
|
|
793
|
+
.map(({ tag, ref }) => {
|
|
794
|
+
const from = JSON.stringify(`virtual:transclude-component/${tag}`);
|
|
795
|
+
const named = defines ? `, { define as ${ref}_define }` : '';
|
|
796
|
+
return `import ${ref}${named} from ${from};`;
|
|
797
|
+
})
|
|
798
|
+
.join('\n');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* A light element's styles are hoisted into <head> once, so every level exports
|
|
803
|
+
* the elements it pulled in. Nested ones come along through their own export,
|
|
804
|
+
* and the document dedupes by tag.
|
|
805
|
+
*/
|
|
806
|
+
/**
|
|
807
|
+
* `<script head>` blocks, verbatim, in the order they were written. Attributes
|
|
808
|
+
* included.
|
|
809
|
+
*
|
|
810
|
+
* They used to be dropped, which turned `<script head src="/theme.js">` into
|
|
811
|
+
* `<script></script>`: no error, no script, nothing to see. `src` is the obvious
|
|
812
|
+
* one, but `type="module"`, `nonce`, `defer` and `integrity` all mean something
|
|
813
|
+
* here too.
|
|
814
|
+
*/
|
|
815
|
+
function headScript(blocks, where) {
|
|
816
|
+
return blocks.head
|
|
817
|
+
.map((block) => {
|
|
818
|
+
const attrs = (block.attrs ?? []).filter((attr) => attr.name !== 'head');
|
|
819
|
+
const external = attrs.find((attr) => attr.name === 'src');
|
|
820
|
+
|
|
821
|
+
// The browser ignores the body of a script with a src. Emitting both would
|
|
822
|
+
// silently throw away whichever the author meant.
|
|
823
|
+
if (external && block.code.trim()) {
|
|
824
|
+
throw new CompileError(
|
|
825
|
+
`${where}: a <script head src="${external.value}"> cannot also have a body. ` +
|
|
826
|
+
`the browser runs the file and ignores the code`,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
// This is emitted as a static string, so there is nothing to interpolate
|
|
830
|
+
// into. Left alone it would ship a literal `${…}` as the URL.
|
|
831
|
+
for (const attr of attrs) {
|
|
832
|
+
if (!attr.value.includes('${')) continue;
|
|
833
|
+
throw new CompileError(
|
|
834
|
+
`${where}: \`${attr.name}\` on a <script head> cannot interpolate. ` +
|
|
835
|
+
`the block is emitted into <head> before any data exists`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return `<script${attrs.map(serializeAttr).join('')}>${block.code}</script>`;
|
|
840
|
+
})
|
|
841
|
+
.join('\n');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** A static attribute, escaped the way an HTML serializer must. */
|
|
845
|
+
function serializeAttr({ name, value }) {
|
|
846
|
+
if (value === '') return ` ${name}`;
|
|
847
|
+
const escaped = value.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
848
|
+
return ` ${name}="${escaped}"`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function elementsExport(used) {
|
|
852
|
+
return `export const elements = [${used.map(({ ref }) => ref).join(', ')}];`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Each `[fragment]` region as a function of the page's own data. The same markup
|
|
857
|
+
* the document got, so a swap cannot drift from the page it replaces part of.
|
|
858
|
+
*/
|
|
859
|
+
/**
|
|
860
|
+
* Every `<transclude src="#id">` names a region this page has, and no
|
|
861
|
+
* region includes itself.
|
|
862
|
+
*
|
|
863
|
+
* Both are compile-time answers. A missing region would be a call to undefined
|
|
864
|
+
* on a page that looked fine, and a cycle would be a stack overflow while
|
|
865
|
+
* answering a request.
|
|
866
|
+
*/
|
|
867
|
+
function assertIncludesResolve(includes, regions) {
|
|
868
|
+
const names = new Set(Object.keys(regions ?? {}));
|
|
869
|
+
|
|
870
|
+
for (const { id, node } of includes ?? []) {
|
|
871
|
+
if (names.has(id)) continue;
|
|
872
|
+
throw new CompileError(
|
|
873
|
+
`<transclude src="#${id}"> names no region of this page. ` +
|
|
874
|
+
`A region is an element with an id and a "fragment" attribute.`,
|
|
875
|
+
node,
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// An edge from the region an include sits in to the region it pulls. An
|
|
880
|
+
// include outside every region cannot be part of a cycle: nothing includes
|
|
881
|
+
// the page body.
|
|
882
|
+
const edges = new Map();
|
|
883
|
+
for (const { id, within } of includes ?? []) {
|
|
884
|
+
if (!within) continue;
|
|
885
|
+
if (!edges.has(within)) edges.set(within, new Set());
|
|
886
|
+
edges.get(within).add(id);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const seen = new Set();
|
|
890
|
+
const walk = (name, chain) => {
|
|
891
|
+
if (chain.includes(name)) {
|
|
892
|
+
throw new CompileError(
|
|
893
|
+
`<transclude> includes itself: ${[...chain, name].map((n) => `#${n}`).join(' includes ')}. ` +
|
|
894
|
+
`Rendering it would not finish.`,
|
|
895
|
+
(includes ?? []).find(({ id }) => id === name)?.node ?? null,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
if (seen.has(name)) return;
|
|
899
|
+
seen.add(name);
|
|
900
|
+
for (const next of edges.get(name) ?? []) walk(next, [...chain, name]);
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
for (const name of edges.keys()) walk(name, []);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function regionsExport(regions) {
|
|
907
|
+
const entries = Object.entries(regions ?? {});
|
|
908
|
+
if (!entries.length) return 'export const regions = {};';
|
|
909
|
+
|
|
910
|
+
const bodies = entries.map(
|
|
911
|
+
([name, body]) =>
|
|
912
|
+
` ${JSON.stringify(name)}: (__d, __slots = {}, __fragment = true, __named = true) => {\n` +
|
|
913
|
+
` let __o = '';\n${indent(indent(body))}\n return __o;\n },`,
|
|
914
|
+
);
|
|
915
|
+
return `export const regions = {\n${bodies.join('\n')}\n};`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function blockOf(node) {
|
|
919
|
+
const text = node.childNodes[0];
|
|
920
|
+
return {
|
|
921
|
+
code: text?.value ?? '',
|
|
922
|
+
line: text?.sourceCodeLocation?.startLine ?? node.sourceCodeLocation?.startTag?.endLine ?? 1,
|
|
923
|
+
offset: text?.sourceCodeLocation?.startOffset ?? node.sourceCodeLocation?.startTag?.endOffset ?? 0,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Every level renders to a map of slots, not a string: `default` is its own
|
|
929
|
+
* content, and any `<template slot="x">` it declared is content for the level
|
|
930
|
+
* above. One shape for pages and layouts alike keeps the fold uniform.
|
|
931
|
+
*/
|
|
932
|
+
function slotBodies(template) {
|
|
933
|
+
// A slot this level does not render belongs to one further out, so it is
|
|
934
|
+
// handed on rather than dropped. Without that, a page could only fill a slot in
|
|
935
|
+
// its nearest layout.
|
|
936
|
+
const consumed = JSON.stringify([...(template.consumed ?? []), 'default']);
|
|
937
|
+
const parts = [
|
|
938
|
+
// A region's markup is emitted once and used twice, in the page and in the
|
|
939
|
+
// region's own function, so the id it carries is written conditionally. The
|
|
940
|
+
// copy that renders inline is the one that keeps the name.
|
|
941
|
+
` const __named = true;`,
|
|
942
|
+
` const __pass = new Set(${consumed});`,
|
|
943
|
+
` for (const __name in __slots) if (!__pass.has(__name)) __out[__name] = __slots[__name];`,
|
|
944
|
+
` {\n let __o = '';\n${MARK.body}\n${indent(indent(template.body))}\n __out.default = __o;\n }`,
|
|
945
|
+
];
|
|
946
|
+
|
|
947
|
+
for (const [name, body] of Object.entries(template.slots ?? {})) {
|
|
948
|
+
parts.push(
|
|
949
|
+
` {\n let __o = '';\n${indent(indent(body))}\n __out[${JSON.stringify(name)}] = __o;\n }`,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
return parts.join('\n');
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function indent(code) {
|
|
956
|
+
return code
|
|
957
|
+
.split('\n')
|
|
958
|
+
.map((line) => (line ? ` ${line}` : line))
|
|
959
|
+
.join('\n');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function isBlank(node) {
|
|
963
|
+
return node.nodeName === '#text' && /^\s*$/.test(node.value ?? '');
|
|
964
|
+
}
|