ferrovue 0.1.0 → 0.2.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/dist/attrs.js ADDED
@@ -0,0 +1,488 @@
1
+ /* Attributes as Vue's server renderer writes them: `class`, `style`, booleans, merged objects. */
2
+ import { escapeHtml, hyphenate, isBooleanAttr, isSSRSafeAttrName, parseStringStyle, propsToAttrMap } from "@vue/shared";
3
+ import { fail, GenError, rustStr, STR } from "./model.js";
4
+ import { CONFIG_FILE, ctx } from "./context.js";
5
+ import { cond, expr, known, meet, truthy, unquote } from "./expr.js";
6
+ import { atom, bare, condition, logical, not, receiver, strArg } from "./parens.js";
7
+ import { Emitter } from "./emitter.js";
8
+ /** What a number written at run time is expected to take: most a page shows are shorter, and the
9
+ * reservation is an estimate. */
10
+ const NUMBER_BYTES = 6;
11
+ /** A string, a number or a boolean written escaped: now when it is known, by JavaScript itself. */
12
+ function display(e, v) {
13
+ if (v.ty.k === "str" && known(v) !== undefined)
14
+ e.lit(escapeHtml(unquote(v.code)));
15
+ else if ((v.ty.k === "int" || v.ty.k === "float") && v.num !== undefined)
16
+ e.lit(String(v.num));
17
+ else if (v.ty.k === "bool" && v.konst !== undefined)
18
+ e.lit(String(v.konst));
19
+ else if (v.ty.k === "str")
20
+ e.stmt(`fv::escape_into(out, ${strArg(v.code)});`);
21
+ else if (v.ty.k === "int" || v.ty.k === "float") {
22
+ e.stmt(`fv::${v.ty.k === "int" ? "push_int" : "push_number"}(out, ${bare(v.code)});`);
23
+ // Counted in the reservation, or a page of numbers outgrows it and is copied to a larger one.
24
+ e.expect(NUMBER_BYTES);
25
+ }
26
+ else if (v.ty.k === "bool")
27
+ e.stmt(`out.push_str(if ${condition(v.code)} { "true" } else { "false" });`);
28
+ else
29
+ return false;
30
+ return true;
31
+ }
32
+ /** `toDisplayString`, escaped. */
33
+ export function interpolate(e, v) {
34
+ if (display(e, v))
35
+ return;
36
+ switch (v.ty.k) {
37
+ case "undef":
38
+ return;
39
+ // `toDisplayString` of a query value: a string, nothing for `null`, an array as JSON.
40
+ case "query":
41
+ e.stmt(`${atom(v.code)}.write_display(out);`);
42
+ return;
43
+ case "opt":
44
+ e.open(`if let Some(v) = ${v.code}`);
45
+ interpolate(e, { code: "v", ty: v.ty.of });
46
+ e.close();
47
+ return;
48
+ default:
49
+ throw new GenError("only strings, numbers and booleans can be interpolated");
50
+ }
51
+ }
52
+ /** The value half of `key="value"`, escaped. */
53
+ export function attrValue(e, v) {
54
+ if (!display(e, v))
55
+ throw new GenError("an attribute value must be a string, a number or a boolean");
56
+ }
57
+ /** `ssrRenderAttr(key, value)`: absent for null/undefined, `key="value"` for anything else. */
58
+ export function renderAttr(e, key, v) {
59
+ if (v.ty.k === "undef")
60
+ return;
61
+ // Only a single query value is an attribute's value; `null` and an array leave it out.
62
+ if (v.ty.k === "query")
63
+ v = { code: `${atom(v.code)}.attr_value()`, ty: { k: "opt", of: STR } };
64
+ if (v.ty.k === "opt") {
65
+ e.open(`if let Some(v) = ${v.code}`);
66
+ renderAttr(e, key, { code: "v", ty: v.ty.of });
67
+ e.close();
68
+ return;
69
+ }
70
+ e.lit(` ${key}="`);
71
+ attrValue(e, v);
72
+ e.lit(`"`);
73
+ }
74
+ /** `ssrRenderDynamicAttr(key, value)`: as `renderAttr`, but a boolean attribute is present or
75
+ * absent, and an empty string renders the name alone. */
76
+ export function renderDynamicAttr(s, e, key, v, n) {
77
+ const name = propsToAttrMap[key] ?? key.toLowerCase();
78
+ if (!isSSRSafeAttrName(name))
79
+ fail(s.comp, `unsafe attribute name \`${name}\``, n);
80
+ if (v.ty.k === "undef")
81
+ return;
82
+ if (v.ty.k === "query")
83
+ v = { code: `${atom(v.code)}.attr_value()`, ty: { k: "opt", of: STR } };
84
+ const boolean = (t) => isBooleanAttr(name) || (name === "hidden" && (t.k === "bool" || t.k === "int" || t.k === "float"));
85
+ if (v.ty.k === "opt" && boolean(v.ty.of)) {
86
+ // Present, or truthy when it is not a string: one test, with nothing to bind.
87
+ e.open(`if ${v.ty.of.k === "str" ? `${atom(v.code)}.is_some()` : truthy(v)}`);
88
+ e.lit(` ${name}`);
89
+ e.close();
90
+ return;
91
+ }
92
+ if (v.ty.k === "opt") {
93
+ e.open(`if let Some(v) = ${v.code}`);
94
+ renderDynamicAttr(s, e, key, { code: "v", ty: v.ty.of }, n);
95
+ e.close();
96
+ return;
97
+ }
98
+ if (boolean(v.ty)) {
99
+ const k = v.ty.k === "str" ? true : known(v);
100
+ if (k === true)
101
+ e.lit(` ${name}`);
102
+ else if (k === undefined) {
103
+ e.open(`if ${condition(truthy(v))}`);
104
+ e.lit(` ${name}`);
105
+ e.close();
106
+ }
107
+ return;
108
+ }
109
+ if (v.ty.k === "str" && known(v) === false) {
110
+ e.lit(` ${name}`);
111
+ return;
112
+ }
113
+ if (v.ty.k === "str" && known(v) === undefined) {
114
+ e.open(`if ${receiver(v.code)}.is_empty()`);
115
+ e.lit(` ${name}`);
116
+ e.close(" else {");
117
+ e.lit(` ${name}="`);
118
+ // Re-evaluated rather than bound: every string expression here is a borrow or a pure call.
119
+ attrValue(e, v);
120
+ e.lit(`"`);
121
+ e.close();
122
+ return;
123
+ }
124
+ e.lit(` ${name}="`);
125
+ attrValue(e, v);
126
+ e.lit(`"`);
127
+ }
128
+ /** `normalizeClass`'s view of a class binding, flattened: a string, an array of items, an object
129
+ * of `name: condition`, `cond && "name"`, `cond ? "a" : null`. Every item is trimmed and the empty
130
+ * ones dropped before they are joined with one space, which is what `class_into` does too, so an
131
+ * object's keys become items of their own. */
132
+ export function classItems(s, n) {
133
+ switch (n.type) {
134
+ case "StringLiteral": {
135
+ const t = n.value.trim();
136
+ return t ? [{ lit: t }] : [];
137
+ }
138
+ case "ArrayExpression":
139
+ return n.elements.flatMap((el) => (el ? classItems(s, el) : []));
140
+ case "ObjectExpression":
141
+ // A computed name may hold spaces, which Vue keeps between the names it joins: such an object
142
+ // is normalised whole, at run time, as Vue normalises it.
143
+ // Normalised whole, at run time, as a JavaScript object: when a name is computed (and may hold
144
+ // spaces, repeat another, or be an array index), or a literal name repeats or is an index —
145
+ // which a JavaScript object lists first, in numeric order.
146
+ const literalNames = n.properties
147
+ .filter((p) => p.type === "ObjectProperty" && !p.computed)
148
+ .map((p) => (p.key.type === "Identifier" ? p.key.name : String(p.key.value)));
149
+ const arrayIndex = (k) => /^(0|[1-9]\d*)$/.test(k) && Number(k) < 2 ** 32 - 1;
150
+ if (n.properties.some((p) => p.type === "ObjectProperty" && p.computed) ||
151
+ literalNames.some(arrayIndex) ||
152
+ new Set(literalNames).size !== literalNames.length) {
153
+ const keys = [];
154
+ const entries = n.properties.map((p) => {
155
+ if (p.type !== "ObjectProperty")
156
+ fail(s.comp, "a class object holds `name: condition` pairs", p);
157
+ const key = p.computed ? expr(s, p.key) : { code: rustStr(p.key.type === "Identifier" ? p.key.name : String(p.key.value)), ty: STR };
158
+ if (key.ty.k !== "str")
159
+ fail(s.comp, "a computed class name is a string", p.key);
160
+ // Names are told apart, which two halves of surrogate pairs would not be.
161
+ for (const other of keys)
162
+ meet(s.comp, other, key, "a class object's names", p.key, "equal");
163
+ keys.push(key);
164
+ return `(${bare(cond(s, p.value))}, ${bare(key.code)})`;
165
+ });
166
+ return [{ code: `&*fv::class_object(&[${entries.join(", ")}])` }];
167
+ }
168
+ return n.properties.flatMap((p) => {
169
+ if (p.type !== "ObjectProperty")
170
+ fail(s.comp, "a class object holds `name: condition` pairs", p);
171
+ // A literal name is written as it is: an object's names are joined, not trimmed one by one.
172
+ let name;
173
+ if (!p.computed) {
174
+ const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
175
+ if (key !== key.trim() || !key)
176
+ fail(s.comp, `class name \`${key}\` has spaces around it`, p);
177
+ name = { lit: key };
178
+ }
179
+ else {
180
+ const k = expr(s, p.key);
181
+ if (k.ty.k !== "str")
182
+ fail(s.comp, "a computed class name is a string", p.key);
183
+ name = { code: `fv::js_trim(${strArg(k.code)})` };
184
+ }
185
+ const v = expr(s, p.value);
186
+ const on = known(v);
187
+ if (on !== undefined)
188
+ return on ? [name] : [];
189
+ const text = "lit" in name ? rustStr(name.lit) : name.code;
190
+ return [{ code: `if ${condition(truthy(v))} { ${bare(text)} } else { "" }` }];
191
+ });
192
+ case "LogicalExpression":
193
+ if (n.operator === "&&") {
194
+ return conditional(cond(s, n.left), classItems(s, n.right));
195
+ }
196
+ break;
197
+ case "ConditionalExpression":
198
+ if (n.consequent.type === "NullLiteral" || n.alternate.type === "NullLiteral") {
199
+ const branch = n.consequent.type === "NullLiteral" ? n.alternate : n.consequent;
200
+ const test = cond(s, n.test);
201
+ return conditional(n.consequent.type === "NullLiteral" ? not(test) : test, classItems(s, branch));
202
+ }
203
+ break;
204
+ }
205
+ const v = expr(s, n);
206
+ if (v.ty.k === "str")
207
+ return [{ code: v.code }];
208
+ if (v.ty.k === "opt" && v.ty.of.k === "str")
209
+ return [{ code: `${atom(v.code)}.unwrap_or("")` }];
210
+ if (v.ty.k === "undef")
211
+ return [];
212
+ return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
213
+ }
214
+ /** Class items that apply only when \`test\` holds: each one, or nothing, decided now if it can be. */
215
+ function conditional(test, items) {
216
+ if (test === "true" || test === "false")
217
+ return test === "true" ? items : [];
218
+ return items.map((it) => ({ code: `if ${condition(test)} { ${"lit" in it ? rustStr(it.lit) : bare(it.code)} } else { "" }` }));
219
+ }
220
+ /** `ssrRenderClass(value)`: normalised, then escaped. `after` says a class was already written. */
221
+ export function renderClass(s, e, n, after = false) {
222
+ const items = classItems(s, n);
223
+ // Literal items before the first run-time one are written now; the rest go to `class_into`,
224
+ // which decides at run time whether each needs a separator.
225
+ let wrote = after;
226
+ let i = 0;
227
+ for (; i < items.length; i++) {
228
+ const it = items[i];
229
+ if (!("lit" in it))
230
+ break;
231
+ e.lit((wrote ? " " : "") + escapeHtml(it.lit));
232
+ wrote = true;
233
+ }
234
+ if (i < items.length) {
235
+ const rest = items.slice(i).map((it) => ("lit" in it ? rustStr(it.lit) : it.code));
236
+ e.stmt(`fv::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
237
+ }
238
+ }
239
+ export const IGNORED_PROPS = new Set(["", "key", "ref", "innerHTML", "textContent", "ref_key", "ref_for"]);
240
+ /** `ssrRenderAttrs(_attrs)` on the root: the scope ids a parent handed it, already written as Vue
241
+ * writes them. They are all `_attrs` holds, since a parent passes a child only the props it
242
+ * declares and nothing else falls through. */
243
+ function inheritedAttrs(s, e) {
244
+ if (s.attrs !== null)
245
+ e.stmt(`out.push_str(${s.attrs});`);
246
+ }
247
+ /** `ssrGetDirectiveProps(_ctx, dir)`: a custom directive's server props, which are none for a
248
+ * directive the configuration declares client-only. `false` when `n` is not such a call. */
249
+ export function directiveProps(s, n) {
250
+ if (n?.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "_ssrGetDirectiveProps")
251
+ return false;
252
+ const dir = n.arguments[1];
253
+ // `$setup["vFocus"]` for an imported directive, `_directive_focus` for a registered one.
254
+ let name = null;
255
+ if (dir?.type === "MemberExpression") {
256
+ const local = dir.computed ? dir.property.value : dir.property.name;
257
+ name = hyphenate(local.replace(/^v(?=[A-Z])/, "")).replace(/^-/, "");
258
+ }
259
+ else if (dir?.type === "Identifier")
260
+ name = s.directives.get(dir.name) ?? null;
261
+ if (name === null || !ctx.clientDirectives.has(name)) {
262
+ fail(s.comp, `custom directive \`v-${name ?? "?"}\` may add attributes on the server; if it has no \`getSSRProps\`, list \`${name ?? "?"}\` in \`clientDirectives\` in ${CONFIG_FILE}`, n);
263
+ }
264
+ return true;
265
+ }
266
+ /** `ssrRenderAttrs(obj)`, key by key in the object's order, and the inherited scope ids where the
267
+ * root's `_attrs` is merged in. */
268
+ export function renderAttrs(s, e, n) {
269
+ const isAttrs = (a) => a.type === "Identifier" && a.name === "_attrs";
270
+ if (isAttrs(n)) {
271
+ inheritedAttrs(s, e);
272
+ return;
273
+ }
274
+ if (directiveProps(s, n))
275
+ return;
276
+ if (n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "_mergeProps") {
277
+ const merged = mergeProps(s, n);
278
+ const at = n.arguments.findIndex(isAttrs);
279
+ if (at < 0) {
280
+ renderAttrs(s, e, merged);
281
+ return;
282
+ }
283
+ // The inherited ids take their place among the keys: after those given before `_attrs`, before
284
+ // those first given after it, such as `v-show`'s `style` when there is no other.
285
+ const before = new Set(mergeProps(s, { ...n, arguments: n.arguments.slice(0, at) }).properties.map((p) => p.key.name ?? p.key.value));
286
+ const part = (early) => ({ ...merged, properties: merged.properties.filter((p) => before.has(p.key.name ?? p.key.value) === early) });
287
+ renderAttrs(s, e, part(true));
288
+ inheritedAttrs(s, e);
289
+ renderAttrs(s, e, part(false));
290
+ return;
291
+ }
292
+ if (n.type !== "ObjectExpression")
293
+ fail(s.comp, "attributes must be an object literal", n);
294
+ for (const p of n.properties) {
295
+ if (p.type !== "ObjectProperty" || p.computed)
296
+ fail(s.comp, "attribute objects hold plain keys", p);
297
+ const raw = p.key.type === "Identifier" ? p.key.name : p.key.value;
298
+ if (IGNORED_PROPS.has(raw) || /^on[^a-z]/.test(raw) || raw.startsWith("."))
299
+ continue;
300
+ // Vue strips the `^` attribute-binding prefix before it looks at the name.
301
+ const key = raw.startsWith("^") ? raw.slice(1) : raw;
302
+ if (key === "className") {
303
+ fail(s.comp, "`className` renders by its own rule in Vue; bind `class`", p);
304
+ }
305
+ else if (key === "class") {
306
+ e.lit(` class="`);
307
+ renderClass(s, e, p.value);
308
+ e.lit(`"`);
309
+ }
310
+ else if (key === "style") {
311
+ e.lit(` style="`);
312
+ renderStyle(s, e, p.value);
313
+ e.lit(`"`);
314
+ }
315
+ else {
316
+ renderDynamicAttr(s, e, key, expr(s, p.value), p);
317
+ }
318
+ }
319
+ }
320
+ /** `mergeProps(a, _attrs, b, …)` as one object literal, without `_attrs`: it holds only scope ids,
321
+ * which the caller writes after the rest. Of the rest, `class` and `style` values are merged as an
322
+ * array, and any other key keeps the place it first had with the last value given. */
323
+ export function mergeProps(s, n) {
324
+ const merged = new Map();
325
+ for (const a of n.arguments) {
326
+ if (a.type === "Identifier" && a.name === "_attrs")
327
+ continue;
328
+ if (directiveProps(s, a))
329
+ continue;
330
+ if (a.type !== "ObjectExpression")
331
+ fail(s.comp, "`_mergeProps` of object literals is supported", a);
332
+ for (const p of a.properties) {
333
+ if (p.type !== "ObjectProperty" || p.computed)
334
+ fail(s.comp, "attribute objects hold plain keys", p);
335
+ const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
336
+ const had = merged.get(key);
337
+ if (had && (key === "class" || key === "style")) {
338
+ merged.set(key, { ...p, value: { type: "ArrayExpression", elements: [had.value, p.value] } });
339
+ }
340
+ else if (had) {
341
+ // `Map` keeps a key where it was first set, as a JavaScript object does.
342
+ merged.set(key, { ...had, value: p.value });
343
+ }
344
+ else
345
+ merged.set(key, p);
346
+ }
347
+ }
348
+ return { type: "ObjectExpression", properties: [...merged.values()] };
349
+ }
350
+ /** The objects a style binding merges, in order: an object literal, a static `style` the compiler
351
+ * parsed, `v-show`'s `cond ? null : { display: "none" }`, and arrays of those. */
352
+ export function styleItems(s, n, when) {
353
+ // A condition known now: an object that never applies is left out, one that always does is not
354
+ // conditional.
355
+ if (when === "false")
356
+ return [];
357
+ if (when === "true")
358
+ when = null;
359
+ const both = (a, b) => (a === null ? b : logical(a, "&&", b));
360
+ switch (n.type) {
361
+ case "NullLiteral":
362
+ return [];
363
+ case "ArrayExpression":
364
+ return n.elements.flatMap((el) => (el ? styleItems(s, el, when) : []));
365
+ case "StringLiteral":
366
+ // Parsed as `normalizeStyle` parses a string inside an array, by Vue's own function.
367
+ return [{ cond: when, entries: Object.entries(parseStringStyle(n.value)).map(([key, v]) => ({ key, css: key, value: { type: "StringLiteral", value: v } })) }];
368
+ case "ConditionalExpression": {
369
+ const t = cond(s, n.test);
370
+ return [...styleItems(s, n.consequent, both(when, t)), ...styleItems(s, n.alternate, both(when, not(t)))];
371
+ }
372
+ case "LogicalExpression":
373
+ if (n.operator === "&&")
374
+ return styleItems(s, n.right, both(when, cond(s, n.left)));
375
+ break;
376
+ case "ObjectExpression":
377
+ return [{
378
+ cond: when,
379
+ entries: n.properties.map((p) => {
380
+ if (p.type !== "ObjectProperty" || p.computed)
381
+ fail(s.comp, "a style object holds plain `property: value` pairs", p);
382
+ const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
383
+ // An integer-like key comes first in a JavaScript object whatever the order written.
384
+ if (/^\d+$/.test(key) || key.startsWith(":"))
385
+ fail(s.comp, `style property \`${key}\``, p);
386
+ return { key, css: key.startsWith("--") ? key : hyphenate(key), value: p.value };
387
+ }),
388
+ }];
389
+ }
390
+ return fail(s.comp, "a style binding is an object, an array of objects, or a string on its own", n);
391
+ }
392
+ /** `ssrRenderStyle(value)`: a string as it is, or the objects merged — each property where it first
393
+ * appears, with the last value given — and written `name:value;`, all escaped. */
394
+ export function renderStyle(s, e, n) {
395
+ // A string on its own is written as it is, with no normalising.
396
+ if (n.type === "StringLiteral") {
397
+ e.lit(escapeHtml(n.value));
398
+ return;
399
+ }
400
+ if (n.type !== "ObjectExpression" && n.type !== "ArrayExpression" && n.type !== "ConditionalExpression" && n.type !== "LogicalExpression" && n.type !== "NullLiteral") {
401
+ const v = expr(s, n);
402
+ if (v.ty.k === "str")
403
+ e.stmt(`fv::escape_into(out, ${strArg(v.code)});`);
404
+ else if (v.ty.k === "opt" && v.ty.of.k === "str") {
405
+ e.open(`if let Some(v) = ${v.code}`);
406
+ e.stmt("fv::escape_into(out, v);");
407
+ e.close();
408
+ }
409
+ else if (v.ty.k !== "undef")
410
+ fail(s.comp, "a style binding is a string, an object or an array", n);
411
+ return;
412
+ }
413
+ const items = styleItems(s, n, null);
414
+ // Each property, where it first appears, with every place that sets it.
415
+ const order = [];
416
+ const sets = new Map();
417
+ for (const it of items) {
418
+ for (const en of it.entries) {
419
+ if (!sets.has(en.key)) {
420
+ order.push(en.key);
421
+ sets.set(en.key, []);
422
+ }
423
+ sets.get(en.key).push({ cond: it.cond, css: en.css, value: en.value });
424
+ }
425
+ }
426
+ const write = (css, value) => {
427
+ // A literal is stringified now, by JavaScript itself: `0.5`, `"1px"`.
428
+ if (value.type === "StringLiteral" || value.type === "NumericLiteral") {
429
+ e.lit(escapeHtml(`${css}:${String(value.value)};`));
430
+ return;
431
+ }
432
+ const v = expr(s, value);
433
+ const one = (w) => {
434
+ if (w.ty.k === "str" || w.ty.k === "int" || w.ty.k === "float") {
435
+ e.lit(`${escapeHtml(css)}:`);
436
+ display(e, w);
437
+ e.lit(";");
438
+ }
439
+ else if (w.ty.k === "opt") {
440
+ e.open(`if let Some(v) = ${w.code}`);
441
+ one({ code: "v", ty: w.ty.of });
442
+ e.close();
443
+ }
444
+ // A boolean or `undefined` writes nothing — and, set later, removes what was set before.
445
+ };
446
+ one(v);
447
+ };
448
+ // A property's place is where an object that is present first sets it. When that object is
449
+ // conditional and the property is set again after another one first appears, the order would be
450
+ // decided at run time.
451
+ const flat = items.flatMap((it, i) => it.entries.map((en) => ({ key: en.key, item: i, cond: it.cond })));
452
+ for (const key of order) {
453
+ const at = flat.filter((f) => f.key === key);
454
+ if (at.length < 2 || at[0].cond === null)
455
+ continue;
456
+ const first = flat.indexOf(at[0]);
457
+ const again = flat.indexOf(at[at.length - 1]);
458
+ const between = flat.slice(first + 1, again).some((f) => order.indexOf(f.key) > order.indexOf(key));
459
+ if (between)
460
+ fail(s.comp, `the place of style property \`${key}\` would depend on a condition; set it unconditionally first`, n);
461
+ }
462
+ for (const key of order) {
463
+ // The last place that sets it wins: walked back to the first that always applies.
464
+ const chain = [];
465
+ for (const set of sets.get(key).toReversed()) {
466
+ chain.push(set);
467
+ if (set.cond === null)
468
+ break;
469
+ }
470
+ if (chain.length === 1 && chain[0].cond === null) {
471
+ write(chain[0].css, chain[0].value);
472
+ continue;
473
+ }
474
+ chain.forEach((set, i) => {
475
+ if (set.cond === null) {
476
+ e.close(" else {");
477
+ }
478
+ else if (i === 0) {
479
+ e.open(`if ${condition(set.cond)}`);
480
+ }
481
+ else {
482
+ e.close(` else if ${condition(set.cond)} {`);
483
+ }
484
+ write(set.css, set.value);
485
+ });
486
+ e.close();
487
+ }
488
+ }
package/dist/cli.js CHANGED
@@ -2,13 +2,21 @@
2
2
  /* `ferrovue` — compile the project's components, run from its root.
3
3
  *
4
4
  * ferrovue write the Rust modules to the configured output directory
5
- * ferrovue --check write nothing; exit 1 if the committed modules are not what would be written */
6
- import { readFileSync, readdirSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { generate, loadConfig, write } from "./compiler.js";
5
+ * ferrovue --check write nothing; exit 1 if the committed modules are not what would be written
6
+ * ferrovue --watch write them, then again whenever a component, store, type file, the routes
7
+ * or the configuration changes, until interrupted */
8
+ import { readFileSync, readdirSync, watch } from "node:fs";
9
+ import { join, relative, sep } from "node:path";
10
+ import { GenError, generate, loadConfig, write } from "./compiler.js";
9
11
  const root = process.cwd();
10
- const config = loadConfig(root);
11
- if (process.argv.includes("--check")) {
12
+ /** A refused construct, or a broken configuration: the message alone, which names the file. */
13
+ function report(e) {
14
+ if (e instanceof GenError || e instanceof SyntaxError)
15
+ console.error(`error: ${e.message}`);
16
+ else
17
+ throw e;
18
+ }
19
+ function check(config) {
12
20
  const want = generate(root, config);
13
21
  const dir = join(root, config.out);
14
22
  let have = [];
@@ -33,11 +41,61 @@ if (process.argv.includes("--check")) {
33
41
  for (const name of extra)
34
42
  console.error(`not generated: ${config.out}/${name}`);
35
43
  console.error("run `ferrovue` to regenerate");
36
- process.exit(1);
44
+ return 1;
37
45
  }
38
46
  console.log(`${config.out} is up to date`);
47
+ return 0;
48
+ }
49
+ function once(config) {
50
+ const { files, changed, removed } = write(root, config);
51
+ const what = changed.length || removed.length ? `${changed.length} changed, ${removed.length} removed` : "nothing changed";
52
+ console.log(`${config.out}: ${files.length} files, ${what}`);
53
+ }
54
+ /** Regenerate on every change to what the components are compiled from, reporting errors and
55
+ * carrying on. The output directory itself, dependencies and build output are ignored. */
56
+ function watchProject() {
57
+ const run = () => {
58
+ try {
59
+ once(loadConfig(root));
60
+ }
61
+ catch (e) {
62
+ report(e);
63
+ }
64
+ };
65
+ run();
66
+ const ignored = (file) => {
67
+ let out = "";
68
+ try {
69
+ out = loadConfig(root).out;
70
+ }
71
+ catch {
72
+ // A broken configuration ignores nothing; the next run reports it.
73
+ }
74
+ const parts = file.split(sep);
75
+ return (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist") ||
76
+ (out !== "" && (file === out || file.startsWith(out + sep))) ||
77
+ !/\.(vue|ts|json)$/.test(file));
78
+ };
79
+ let timer = null;
80
+ watch(root, { recursive: true }, (_event, name) => {
81
+ if (!name || ignored(relative(root, join(root, name))))
82
+ return;
83
+ // Editors write a file in several steps: one run once they are done.
84
+ if (timer)
85
+ clearTimeout(timer);
86
+ timer = setTimeout(run, 50);
87
+ });
88
+ console.log("watching for changes (Ctrl-C to stop)");
89
+ }
90
+ try {
91
+ if (process.argv.includes("--watch"))
92
+ watchProject();
93
+ else if (process.argv.includes("--check"))
94
+ process.exitCode = check(loadConfig(root));
95
+ else
96
+ once(loadConfig(root));
39
97
  }
40
- else {
41
- const files = write(root, config);
42
- console.log(`wrote ${files.length} files to ${config.out}`);
98
+ catch (e) {
99
+ report(e);
100
+ process.exitCode = 1;
43
101
  }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,31 @@
1
+ import { type App, type Component, type Plugin } from "vue";
1
2
  import type { Pinia } from "pinia";
3
+ import type { Router } from "vue-router";
2
4
  /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
3
5
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
4
6
  export declare function hydrateState(pinia: Pinia, id?: string, doc?: Document): void;
7
+ export interface MountOptions {
8
+ /** One Pinia for every island, so they share stores. Call `hydrateState` on it first. */
9
+ pinia?: Pinia;
10
+ /** One router for every island that renders a `<RouterLink>` or reads the route. Mounting waits
11
+ * until it has resolved the current location, which the server rendered against. */
12
+ router?: Router;
13
+ /** Anything else each island's app should `use`: i18n, a component library. */
14
+ plugins?: Plugin[];
15
+ /** Where to look for islands: the whole document by default. */
16
+ root?: ParentNode;
17
+ /** Told about an island left as the server rendered it, because its component is not among
18
+ * those given or its props are malformed. Warns on the console by default. */
19
+ onError?: (element: Element, problem: string) => void;
20
+ }
21
+ /** The islands mounted. */
22
+ export interface Islands {
23
+ /** One app per island, in document order. */
24
+ apps: App[];
25
+ /** Unmount every island, as a page leaving would. */
26
+ unmount(): void;
27
+ }
28
+ /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
29
+ * `Html::island` wrote becomes an app of the component of that name, given the props the server
30
+ * rendered it with, mounted where it is. */
31
+ export declare function mountIslands(components: Record<string, Component>, options?: MountOptions): Promise<Islands>;
package/dist/client.js CHANGED
@@ -1,3 +1,5 @@
1
+ /* What the browser does with a page ferrovue rendered. */
2
+ import { createSSRApp } from "vue";
1
3
  /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
2
4
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
3
5
  export function hydrateState(pinia, id = "__pinia", doc = document) {
@@ -5,3 +7,45 @@ export function hydrateState(pinia, id = "__pinia", doc = document) {
5
7
  if (text)
6
8
  pinia.state.value = JSON.parse(text);
7
9
  }
10
+ /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
11
+ * `Html::island` wrote becomes an app of the component of that name, given the props the server
12
+ * rendered it with, mounted where it is. */
13
+ export async function mountIslands(components, options = {}) {
14
+ const root = options.root ?? document;
15
+ const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
16
+ if (options.router)
17
+ await options.router.isReady();
18
+ const apps = [];
19
+ for (const el of Array.from(root.querySelectorAll("[data-island]"))) {
20
+ const name = el.dataset.island ?? "";
21
+ const component = components[name];
22
+ if (!component) {
23
+ report(el, `no component called ${JSON.stringify(name)} was given`);
24
+ continue;
25
+ }
26
+ let props;
27
+ try {
28
+ props = JSON.parse(el.dataset.props ?? "{}");
29
+ }
30
+ catch {
31
+ report(el, `the props of ${name} are not JSON`);
32
+ continue;
33
+ }
34
+ const app = createSSRApp(component, props);
35
+ if (options.pinia)
36
+ app.use(options.pinia);
37
+ if (options.router)
38
+ app.use(options.router);
39
+ for (const plugin of options.plugins ?? [])
40
+ app.use(plugin);
41
+ app.mount(el);
42
+ apps.push(app);
43
+ }
44
+ return {
45
+ apps,
46
+ unmount() {
47
+ for (const app of apps.splice(0))
48
+ app.unmount();
49
+ },
50
+ };
51
+ }