ferrovue 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +378 -63
  2. package/dist/attrs.d.ts +3 -2
  3. package/dist/attrs.js +31 -26
  4. package/dist/calls.js +17 -15
  5. package/dist/children.d.ts +11 -4
  6. package/dist/children.js +110 -44
  7. package/dist/classes.d.ts +4 -0
  8. package/dist/classes.js +33 -11
  9. package/dist/cli.js +31 -5
  10. package/dist/client.d.ts +44 -6
  11. package/dist/client.js +182 -18
  12. package/dist/compiler.d.ts +1 -1
  13. package/dist/compiler.js +42 -10
  14. package/dist/component.d.ts +3 -1
  15. package/dist/component.js +114 -20
  16. package/dist/conformance.d.ts +64 -0
  17. package/dist/conformance.js +276 -0
  18. package/dist/constants.d.ts +42 -0
  19. package/dist/constants.js +292 -0
  20. package/dist/context.d.ts +40 -7
  21. package/dist/context.js +14 -5
  22. package/dist/diagnostics.d.ts +23 -0
  23. package/dist/diagnostics.js +30 -0
  24. package/dist/dynamic.d.ts +18 -0
  25. package/dist/dynamic.js +576 -0
  26. package/dist/errors.d.ts +1034 -0
  27. package/dist/errors.js +425 -0
  28. package/dist/expr.d.ts +1 -0
  29. package/dist/expr.js +120 -49
  30. package/dist/file-routes.d.ts +33 -0
  31. package/dist/file-routes.js +224 -0
  32. package/dist/fixture.d.ts +19 -10
  33. package/dist/fixture.js +23 -12
  34. package/dist/hydration.d.ts +15 -0
  35. package/dist/hydration.js +80 -0
  36. package/dist/index.d.ts +3 -2
  37. package/dist/index.js +2 -1
  38. package/dist/link-router.d.ts +24 -0
  39. package/dist/link-router.js +37 -0
  40. package/dist/lists.d.ts +4 -0
  41. package/dist/lists.js +32 -23
  42. package/dist/loops.js +18 -14
  43. package/dist/model.d.ts +43 -1
  44. package/dist/model.js +167 -7
  45. package/dist/narrowing.d.ts +8 -0
  46. package/dist/narrowing.js +40 -9
  47. package/dist/numbers.js +3 -2
  48. package/dist/page-routes.d.ts +9 -0
  49. package/dist/page-routes.js +10 -0
  50. package/dist/plugin.d.ts +13 -1
  51. package/dist/plugin.js +3 -0
  52. package/dist/plugins/client-only.d.ts +8 -0
  53. package/dist/plugins/client-only.js +110 -0
  54. package/dist/plugins/head.d.ts +15 -0
  55. package/dist/plugins/head.js +385 -0
  56. package/dist/plugins/i18n.js +14 -14
  57. package/dist/plugins/index.js +5 -1
  58. package/dist/plugins/provide.d.ts +44 -0
  59. package/dist/plugins/provide.js +528 -0
  60. package/dist/plugins/router-link.js +39 -32
  61. package/dist/plugins/router.d.ts +4 -1
  62. package/dist/plugins/router.js +65 -19
  63. package/dist/plugins/scoped.js +1 -1
  64. package/dist/plugins/shared-types.js +1 -1
  65. package/dist/plugins/stores.js +45 -33
  66. package/dist/plugins/teleport.js +3 -3
  67. package/dist/plugins/twins.d.ts +10 -0
  68. package/dist/plugins/twins.js +164 -0
  69. package/dist/routes.d.ts +13 -0
  70. package/dist/routes.js +12 -0
  71. package/dist/rust.js +64 -32
  72. package/dist/script.d.ts +3 -0
  73. package/dist/script.js +138 -20
  74. package/dist/settle.d.ts +8 -0
  75. package/dist/settle.js +39 -0
  76. package/dist/slots.js +36 -22
  77. package/dist/ssr.d.ts +3 -0
  78. package/dist/ssr.js +29 -0
  79. package/dist/strings.d.ts +3 -0
  80. package/dist/strings.js +27 -13
  81. package/dist/styles.js +13 -13
  82. package/dist/template.js +102 -21
  83. package/dist/testing.d.ts +3 -3
  84. package/dist/testing.js +3 -29
  85. package/dist/types.d.ts +5 -0
  86. package/dist/typescript.d.ts +2 -1
  87. package/dist/typescript.js +52 -21
  88. package/dist/vhtml.d.ts +4 -0
  89. package/dist/vhtml.js +63 -0
  90. package/dist/vite.d.ts +8 -2
  91. package/dist/vite.js +75 -14
  92. package/package.json +23 -5
package/dist/children.js CHANGED
@@ -1,17 +1,18 @@
1
1
  import { isSSRSafeAttrName, propsToAttrMap } from "@vue/shared";
2
- import { camelize, declares, fail, GenError, rustStr, sameTy, snake, takesAttrs } from "./model.js";
2
+ import { camelize, declares, fail, nothing, rustStr, sameTy, snake, takesAttrs } from "./model.js";
3
3
  import { ctx } from "./context.js";
4
4
  import { markHome } from "./typescript.js";
5
- import { expr, fieldVal } from "./expr.js";
5
+ import { expr, fieldVal, holdsNothing } from "./expr.js";
6
6
  import { asF64 } from "./numbers.js";
7
7
  import { atom, bare } from "./parens.js";
8
8
  import { Emitter } from "./emitter.js";
9
9
  import { attrOf, dollarAttrs, IGNORED_PROPS, isAttrs, mergedParts } from "./attrs.js";
10
10
  import { passedKey } from "./fallthrough.js";
11
- import { claim, paramsOf, slotFieldsOf } from "./plugin.js";
11
+ import { claim, paramsOf, slotContextOf, slotFieldsOf } from "./plugin.js";
12
12
  import { statements } from "./template.js";
13
13
  import { slotContent, slotFieldBorrows, staticallyFilled } from "./slots.js";
14
- export function renderChild(s, e, n) {
14
+ import { slotAsVnodes } from "./dynamic.js";
15
+ export function renderChild(s, e, n, twin) {
15
16
  const [target, rawProps, slots, , slotScopeId] = n.arguments;
16
17
  let local = null;
17
18
  if (target.type === "MemberExpression" && target.object.name === "$setup") {
@@ -19,9 +20,10 @@ export function renderChild(s, e, n) {
19
20
  }
20
21
  const isSelf = target.type === "Identifier" && target.name === s.selfAlias.name;
21
22
  const childName = isSelf ? s.comp.name : local ? s.children.get(local) : undefined;
22
- const child = childName ? s.components.get(childName) : undefined;
23
+ const child = twin?.comp ?? (childName ? s.components.get(childName) : undefined);
23
24
  if (!child)
24
- fail(s.comp, "a child component must be an imported island", n);
25
+ fail(s.comp, "FV0501", `a child component must be imported from a \`.vue\` file among the components compiled, in ${ctx.componentsDir.replace(/\/$/, "")}`, n);
26
+ const later = !twin && local !== null && s.loadedLater.has(local);
25
27
  for (const p of ctx.plugins)
26
28
  p.child?.(s, child, n);
27
29
  const parts = !rawProps || rawProps.type === "NullLiteral" ? [] : mergedParts(rawProps);
@@ -33,30 +35,33 @@ export function renderChild(s, e, n) {
33
35
  if (parts.some(passed)) {
34
36
  for (const name of s.comp.attrNames) {
35
37
  if (declares(child, name)) {
36
- fail(s.comp, `\`${name}\`, an attribute ${s.comp.name} may be passed, would reach ${child.name} as its prop \`${camelize(name)}\`, which an attribute has no type for`, n);
38
+ fail(s.comp, "FV0502", `\`${name}\`, an attribute ${s.comp.name} may be passed, would reach ${child.name} as its prop \`${camelize(name)}\`, which an attribute has no type for`, n);
37
39
  }
38
40
  }
39
41
  }
40
42
  const ids = claim((p) => p.childIds?.(s, child, passesAttrs, !!slotScopeId, n)) ?? null;
41
43
  if (objects.length === 1 && objects[0].type !== "ObjectExpression") {
44
+ if (twin)
45
+ fail(s.comp, "FV1501", `the props of ${child.name}, a Rust twin, are attributes or an object literal`, objects[0]);
42
46
  const v = expr(s, objects[0]);
43
47
  const own = child.name === s.comp.name && v.ty.k === "struct" && v.ty.name === "Props";
44
48
  if (own || (v.ty.k === "child" && v.ty.name === child.name)) {
45
- callChild(s, e, child, v.code, slots, childAttrsArg(s, child, parts, merges, null, ids));
49
+ callChild(s, e, child, v.code, slots, childAttrsArg(s, child, parts, merges, null, ids, n), later);
46
50
  return;
47
51
  }
48
- fail(s.comp, `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, n);
52
+ fail(s.comp, "FV0503", `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, n);
49
53
  }
50
54
  for (const p of objects) {
51
55
  if (p.type !== "ObjectExpression")
52
- fail(s.comp, `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, p);
56
+ fail(s.comp, "FV0503", `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, p);
53
57
  }
54
58
  const given = new Map();
55
- const fallthrough = new Set();
59
+ const fallthrough = new Map();
60
+ const loaded = !merges && local !== null && s.loadedLater.has(local);
56
61
  for (const obj of objects) {
57
62
  for (const p of obj.properties) {
58
63
  if (p.type !== "ObjectProperty" || p.computed)
59
- fail(s.comp, "child props hold plain keys", p);
64
+ fail(s.comp, "FV0504", "child props hold plain keys", p);
60
65
  const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
61
66
  if (!passedKey(child, key))
62
67
  continue;
@@ -66,29 +71,63 @@ export function renderChild(s, e, n) {
66
71
  continue;
67
72
  }
68
73
  if (array(key))
69
- fail(s.comp, `an attribute named \`${key}\`, which JavaScript would put before the others`, p);
74
+ fail(s.comp, "FV0505", `an attribute named \`${key}\`, which JavaScript would put before the others`, p);
70
75
  const name = propsToAttrMap[key] ?? key.toLowerCase();
71
76
  if (key !== "class" && key !== "style" && !isSSRSafeAttrName(name))
72
- fail(s.comp, `unsafe attribute name \`${name}\``, p);
73
- fallthrough.add(p);
77
+ fail(s.comp, "FV0404", `unsafe attribute name \`${name}\``, p);
78
+ fallthrough.set(p, loaded && key === "class" ? loadedClass(s, p.value) : p.value);
74
79
  }
75
80
  }
81
+ if (twin) {
82
+ const inits = child.props.fields.map((f) => twin.init(s, f, given.get(f.js), n));
83
+ twin.call(s, e, inits, slots, childAttrsArg(s, child, parts, merges, fallthrough, ids, n));
84
+ return;
85
+ }
76
86
  const inits = child.props.fields.map((f) => {
77
87
  const node = given.get(f.js);
78
88
  if (!node) {
79
89
  if (f.ty.k !== "opt")
80
- fail(s.comp, `${child.name} requires \`${f.js}\``, n);
90
+ fail(s.comp, "FV0506", `${child.name} requires \`${f.js}\``, n);
91
+ if (f.ty.none !== undefined)
92
+ fail(s.comp, "FV0507", `${child.name} requires \`${f.js}\`, which is \`T | null\`: Vue would hand it \`undefined\`; pass \`null\` for none`, n);
81
93
  return `${f.rust}: None`;
82
94
  }
83
95
  const v = expr(s, node);
84
96
  return fieldInit(f.rust, ownInto(s.comp, { ...v, ty: markHome(v.ty, s.comp.name) }, markHome(f.ty, child.name), node));
85
97
  });
86
- callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, childAttrsArg(s, child, parts, merges, fallthrough, ids));
98
+ callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, childAttrsArg(s, child, parts, merges, fallthrough, ids, n), later);
87
99
  }
88
100
  export function array(key) {
89
101
  return /^(0|[1-9]\d*)$/.test(key) && Number(key) < 2 ** 32 - 1;
90
102
  }
91
- function childAttrsArg(s, child, parts, merges, fallthrough, ids) {
103
+ /** A string `:class` given to a component `defineAsyncComponent` loads, in props no `mergeProps`
104
+ * normalised: Vue's wrapper hands on the class once as written and once trimmed, so a class with
105
+ * whitespace around it is written twice. */
106
+ const lengthOf = (n) => ({ type: "MemberExpression", object: n, property: { type: "Identifier", name: "length" }, computed: false });
107
+ function holdsClassLiteral(n) {
108
+ if (n.type === "ObjectExpression" || n.type === "ArrayExpression")
109
+ return true;
110
+ if (n.type === "ConditionalExpression")
111
+ return holdsClassLiteral(n.consequent) || holdsClassLiteral(n.alternate);
112
+ if (n.type === "LogicalExpression")
113
+ return holdsClassLiteral(n.left) || holdsClassLiteral(n.right);
114
+ return false;
115
+ }
116
+ function loadedClass(s, value) {
117
+ if (value.type === "ConditionalExpression" && (holdsClassLiteral(value.consequent) || holdsClassLiteral(value.alternate))) {
118
+ return { ...value, consequent: loadedClass(s, value.consequent), alternate: loadedClass(s, value.alternate) };
119
+ }
120
+ if (holdsClassLiteral(value) || expr(s, value).ty.k !== "str")
121
+ return value;
122
+ const trimmed = { type: "CallExpression", callee: { type: "MemberExpression", object: value, property: { type: "Identifier", name: "trim" }, computed: false }, arguments: [] };
123
+ return {
124
+ type: "ConditionalExpression",
125
+ test: { type: "BinaryExpression", operator: "===", left: lengthOf(trimmed), right: lengthOf(value) },
126
+ consequent: value,
127
+ alternate: { type: "ArrayExpression", elements: [trimmed, value] },
128
+ };
129
+ }
130
+ function childAttrsArg(s, child, parts, merges, fallthrough, ids, n) {
92
131
  const sources = [];
93
132
  for (const p of parts) {
94
133
  if (s.fallthrough !== null && ((isAttrs(p) && s.comp.inheritAttrs) || dollarAttrs(s, p))) {
@@ -97,7 +136,7 @@ function childAttrsArg(s, child, parts, merges, fallthrough, ids) {
97
136
  else if (p.type === "ObjectExpression" && fallthrough !== null) {
98
137
  const entries = p.properties.filter((q) => fallthrough.has(q)).map((q) => {
99
138
  const key = q.key.type === "Identifier" ? q.key.name : String(q.key.value);
100
- return `(${rustStr(key)}, ${attrOf(s, key, q.value, "vnode")})`;
139
+ return `(${rustStr(key)}, ${attrOf(s, key, fallthrough.get(q), "vnode")})`;
101
140
  });
102
141
  if (entries.length)
103
142
  sources.push(`&[${entries.join(", ")}]`);
@@ -105,7 +144,7 @@ function childAttrsArg(s, child, parts, merges, fallthrough, ids) {
105
144
  }
106
145
  if (!takesAttrs(child)) {
107
146
  if (sources.length)
108
- throw new GenError(`${s.comp.file}: ${child.name} is passed attributes it does not take`);
147
+ fail(s.comp, "FV0508", `${child.name} is passed attributes it does not take`, n);
109
148
  return ids;
110
149
  }
111
150
  if (!sources.length)
@@ -120,34 +159,48 @@ export function fieldInit(name, value) {
120
159
  export function takesSlots(c) {
121
160
  return c.slotNames.length > 0 || slotFieldsOf(c).length > 0;
122
161
  }
123
- export function extraParams(c) {
124
- return (takesSlots(c) ? ", fv_slots: Slots<'_>" : "") + paramsOf(c).map((p) => `, ${p.name}: ${p.ty}`).join("");
162
+ export function extraParams(c, unread = () => false) {
163
+ return (takesSlots(c) ? ", fv_slots: Slots<'_>" : "") + paramsOf(c).map((p) => `, ${unread(p.name) ? "_" : ""}${p.name}: ${p.ty}`).join("");
164
+ }
165
+ export function callChild(s, e, child, propsCode, slots, attrs, later = false) {
166
+ const m = `super::${child.module}`;
167
+ const scoped = child.inherits || takesAttrs(child);
168
+ const trailing = paramsOf(child).map((p) => `, ${p.name}`).join("") + (scoped ? `, ${attrs ?? (takesAttrs(child) ? "&fv::Attrs::NONE" : '""')}` : "");
169
+ const render = scoped ? "render_scoped" : "render";
170
+ const around = later ? ctx.plugins.flatMap((p) => p.loadedLater?.(child) ?? []) : [];
171
+ if (around.length) {
172
+ e.open("");
173
+ for (const line of around)
174
+ e.stmt(line);
175
+ }
176
+ callWith(s, e, child, m, `${m}::${render}(out, ${propsCode}`, `${m}::Slots`, trailing, slots);
177
+ if (around.length)
178
+ e.close();
125
179
  }
126
- export function callChild(s, e, child, propsCode, slots, attrs) {
180
+ export function callWith(s, e, child, m, head, slotsTy, trailing, slots, vnode = false) {
127
181
  const given = new Map();
128
182
  if (slots && slots.type !== "NullLiteral") {
129
183
  if (slots.type !== "ObjectExpression")
130
- fail(s.comp, "slots must be an object literal", slots);
184
+ fail(s.comp, "FV0901", "slots must be an object literal", slots);
131
185
  for (const p of slots.properties) {
132
186
  const key = p.key?.type === "Identifier" ? p.key.name : p.key?.value;
133
187
  if (key === "_")
134
188
  continue;
135
189
  if (p.type !== "ObjectProperty" || p.computed)
136
- fail(s.comp, "slots hold plain keys", p);
190
+ fail(s.comp, "FV0902", "slots hold plain keys", p);
137
191
  if (!child.slotNames.includes(key))
138
- fail(s.comp, `${child.name} has no slot \`${key}\``, p);
192
+ fail(s.comp, "FV0903", `${child.name} has no slot \`${key}\``, p);
139
193
  given.set(key, p.value);
140
194
  }
141
195
  }
142
- const m = `super::${child.module}`;
143
- const scoped = child.inherits || takesAttrs(child);
144
- const trailing = paramsOf(child).map((p) => `, ${p.name}`).join("") + (scoped ? `, ${attrs ?? (takesAttrs(child) ? "&fv::Attrs::NONE" : '""')}` : "");
145
- const render = scoped ? "render_scoped" : "render";
146
196
  if (!takesSlots(child)) {
147
- e.stmt(`${m}::${render}(out, ${propsCode}${trailing});`);
197
+ e.stmt(`${head}${trailing});`);
148
198
  return;
149
199
  }
150
- e.open(`${m}::${render}(out, ${propsCode}, ${m}::Slots`);
200
+ e.open(`${head}, ${slotsTy}`);
201
+ const context = slotContextOf(child);
202
+ const contextParams = context.map((p) => `, ${p.name}: ${p.ty}`).join("");
203
+ const opaque = vnode ? child.name : s.opaque;
151
204
  for (const name of child.slotNames) {
152
205
  const field = snake(name);
153
206
  const value = given.get(name);
@@ -156,6 +209,7 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
156
209
  continue;
157
210
  }
158
211
  const { body, param } = slotContent(s, value);
212
+ const asVnodes = vnode || s.vnode || slotAsVnodes(child, name);
159
213
  const shape = child.slotShapes.get(name);
160
214
  const takesNone = param?.type === "Identifier" && param.name === "_";
161
215
  const sid = child.passesSlotIds ? `fv_sid${++ctx.narrowCount}` : null;
@@ -182,7 +236,7 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
182
236
  if (param?.type === "ObjectPattern") {
183
237
  for (const p of param.properties) {
184
238
  if (p.type !== "ObjectProperty" || p.computed || p.value.type !== "Identifier") {
185
- fail(s.comp, "slot props are destructured into plain names, without defaults", p);
239
+ fail(s.comp, "FV0904", "slot props are destructured into plain names, without defaults", p);
186
240
  }
187
241
  locals.set(p.value.name, fieldVal(s.comp, sp, ty, p.key.name ?? p.key.value, p));
188
242
  }
@@ -191,20 +245,31 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
191
245
  locals.set(param.name, { code: sp, ty });
192
246
  }
193
247
  else if (!takesNone)
194
- fail(s.comp, "slot props are a name or an object pattern", param);
248
+ fail(s.comp, "FV0905", "slot props are a name or an object pattern", param);
195
249
  const life = shape.fields.some((f) => slotFieldBorrows(f.ty)) ? "<'_>" : "";
196
- e.open(`${field}: Some(&|out: &mut String, ${sp}: &${m}::${shape.name}${life}${sidParam}| -> bool`);
250
+ e.open(`${field}: Some(&|out: &mut String, ${sp}: &${m}::${shape.name}${life}${sidParam}${contextParams}| -> bool`);
197
251
  const opened = e.lines.length - 1;
198
- content({ ...s, locals, sid });
252
+ content({ ...s, locals, sid, vnode: asVnodes, opaque });
199
253
  unread(opened, sp);
200
254
  unread(opened, sid);
255
+ for (const p of context)
256
+ unread(opened, p.name);
201
257
  e.close("),");
202
258
  continue;
203
259
  }
204
260
  if (!takesNone)
205
- fail(s.comp, `\`<slot${name === "default" ? "" : ` name="${name}"`}>\` in ${child.name} passes no props`, param);
206
- const inner = { ...s, sid };
207
- if (sid !== null) {
261
+ fail(s.comp, "FV0906", `\`<slot${name === "default" ? "" : ` name="${name}"`}>\` in ${child.name} passes no props`, param);
262
+ const inner = { ...s, sid, vnode: asVnodes, opaque };
263
+ if (context.length) {
264
+ e.open(`${field}: Some(&|out: &mut String${sidParam}${contextParams}| -> bool`);
265
+ const opened = e.lines.length - 1;
266
+ content(inner);
267
+ unread(opened, sid);
268
+ for (const p of context)
269
+ unread(opened, p.name);
270
+ e.close("),");
271
+ }
272
+ else if (sid !== null) {
208
273
  e.open(`${field}: Some(fv::Slot::slotted(&|out: &mut String${sidParam}| -> bool`);
209
274
  const opened = e.lines.length - 1;
210
275
  content(inner);
@@ -227,14 +292,15 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
227
292
  e.close(`${trailing});`);
228
293
  }
229
294
  export function ownInto(comp, v, want, node) {
295
+ holdsNothing(comp, v, want, node, "the prop");
296
+ if (want.k === "opt" && nothing(v.ty))
297
+ return "None";
230
298
  if (want.k === "str") {
231
299
  if (v.ty.k !== "str")
232
- fail(comp, "a string prop needs a string", node);
300
+ fail(comp, "FV0509", "a string prop needs a string", node);
233
301
  return `std::borrow::Cow::Borrowed(${bare(v.code)})`;
234
302
  }
235
303
  if (want.k === "opt" && want.of.k === "str") {
236
- if (v.ty.k === "undef")
237
- return "None";
238
304
  if (v.ty.k === "str")
239
305
  return `Some(std::borrow::Cow::Borrowed(${bare(v.code)}))`;
240
306
  if (v.ty.k === "opt" && v.ty.of.k === "str")
@@ -257,7 +323,7 @@ export function ownInto(comp, v, want, node) {
257
323
  if (want.k === "opt" && objecty(want.of) && sameTy(v.ty, want.of))
258
324
  return `Some(${atom(v.code)}.to_owned())`;
259
325
  if (objecty(want) && v.ty.k === want.k && JSON.stringify(v.ty).includes('"struct"')) {
260
- fail(comp, `a ${JSON.stringify(v.ty)} where the child takes a ${JSON.stringify(want)}: two components share a type only when both import it from one \`.ts\` file`, node);
326
+ fail(comp, "FV0510", `a ${JSON.stringify(v.ty)} where the child takes a ${JSON.stringify(want)}: two components share a type only when both import it from one \`.ts\` file`, node);
261
327
  }
262
328
  if (want.k === "list" && v.ty.k === "list" && v.ty.of.k === "undef")
263
329
  return "Vec::new()";
@@ -269,5 +335,5 @@ export function ownInto(comp, v, want, node) {
269
335
  if (want.of.k === "int" || want.of.k === "float" || want.of.k === "bool")
270
336
  return `${atom(v.code)}.to_vec()`;
271
337
  }
272
- return fail(comp, `a ${JSON.stringify(v.ty)} into a ${JSON.stringify(want)} prop`, node);
338
+ return fail(comp, "FV0511", `a ${JSON.stringify(v.ty)} into a ${JSON.stringify(want)} prop`, node);
273
339
  }
package/dist/classes.d.ts CHANGED
@@ -7,6 +7,10 @@ export type ClassItem = {
7
7
  };
8
8
  export declare function classItems(s: Scope, n: N): ClassItem[];
9
9
  export declare function renderClass(s: Scope, e: Emitter, n: N, after?: boolean): void;
10
+ /** The class list of an element in content Vue renders from virtual nodes, in the order Vue's
11
+ * client compiler merges a static `class` written before `:class`: the static names first, at the
12
+ * place of the static `class`. */
13
+ export declare function vnodeClass(s: Scope, n: N): N;
10
14
  export declare function classPresent(s: Scope, n: N): string;
11
15
  export declare function maybeEqual(s: Scope, n: N): boolean;
12
16
  export declare function literalClass(n: N): string | null;
package/dist/classes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { escapeHtml } from "@vue/shared";
2
- import { fail, rustStr, STR } from "./model.js";
2
+ import { directiveOwner, fail, nothing, rustStr, STR } from "./model.js";
3
3
  import { expr } from "./expr.js";
4
4
  import { cond, known, truthy } from "./narrowing.js";
5
5
  import { meet } from "./strings.js";
@@ -25,10 +25,10 @@ export function classItems(s, n) {
25
25
  const keys = [];
26
26
  const entries = n.properties.map((p) => {
27
27
  if (p.type !== "ObjectProperty")
28
- fail(s.comp, "a class object holds `name: condition` pairs", p);
28
+ fail(s.comp, "FV1001", "a class object holds `name: condition` pairs", p);
29
29
  const key = p.computed ? expr(s, p.key) : { code: rustStr(p.key.type === "Identifier" ? p.key.name : String(p.key.value)), ty: STR };
30
30
  if (key.ty.k !== "str")
31
- fail(s.comp, "a computed class name is a string", p.key);
31
+ fail(s.comp, "FV1002", "a computed class name is a string", p.key);
32
32
  for (const other of keys)
33
33
  meet(s.comp, other, key, "a class object's names", p.key, "equal");
34
34
  keys.push(key);
@@ -38,18 +38,18 @@ export function classItems(s, n) {
38
38
  }
39
39
  return n.properties.flatMap((p) => {
40
40
  if (p.type !== "ObjectProperty")
41
- fail(s.comp, "a class object holds `name: condition` pairs", p);
41
+ fail(s.comp, "FV1001", "a class object holds `name: condition` pairs", p);
42
42
  let name;
43
43
  if (!p.computed) {
44
44
  const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
45
45
  if (key !== key.trim() || !key)
46
- fail(s.comp, `class name \`${key}\` has spaces around it`, p);
46
+ fail(s.comp, "FV1003", `class name \`${key}\` has spaces around it`, p);
47
47
  name = { lit: key };
48
48
  }
49
49
  else {
50
50
  const k = expr(s, p.key);
51
51
  if (k.ty.k !== "str")
52
- fail(s.comp, "a computed class name is a string", p.key);
52
+ fail(s.comp, "FV1002", "a computed class name is a string", p.key);
53
53
  name = { code: `fv::js_trim(${strArg(k.code)})` };
54
54
  }
55
55
  const v = expr(s, p.value);
@@ -77,9 +77,9 @@ export function classItems(s, n) {
77
77
  return [{ code: v.code }];
78
78
  if (v.ty.k === "opt" && v.ty.of.k === "str")
79
79
  return [{ code: `${atom(v.code)}.unwrap_or("")` }];
80
- if (v.ty.k === "undef")
80
+ if (nothing(v.ty))
81
81
  return [];
82
- return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
82
+ return fail(s.comp, "FV1004", "a class is a string, an array, or an object of conditions", n);
83
83
  }
84
84
  function conditional(test, items) {
85
85
  if (test === "true" || test === "false")
@@ -102,6 +102,28 @@ export function renderClass(s, e, n, after = false) {
102
102
  e.stmt(`fv::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
103
103
  }
104
104
  }
105
+ const UNWRITTEN = new Set(["on", "if", "else-if", "else", "for", "slot", "once", "memo", "cloak", "pre"]);
106
+ /** The class list of an element in content Vue renders from virtual nodes, in the order Vue's
107
+ * client compiler merges a static `class` written before `:class`: the static names first, at the
108
+ * place of the static `class`. */
109
+ export function vnodeClass(s, n) {
110
+ const last = n.type === "ArrayExpression" ? n.elements.at(-1) : null;
111
+ if (n.fvMerged || last?.type !== "StringLiteral" || n.elements.length < 2)
112
+ return n;
113
+ const el = directiveOwner(s.comp, n.elements[0]);
114
+ if (!el)
115
+ return n;
116
+ const own = el.props.findIndex((p) => p.type === 6 && p.name === "class");
117
+ const bound = el.props.findIndex((p) => p.type === 7 && p.name === "bind" && p.arg?.content === "class");
118
+ if (own < 0 || bound < own)
119
+ return n;
120
+ const between = el.props.slice(own + 1, bound).find((p) => p.type === 6 || !UNWRITTEN.has(p.name));
121
+ if (between) {
122
+ const at = { type: "VueTemplate", loc: { start: { line: between.loc.start.line, column: between.loc.start.column - 1 } }, __fv: "source" };
123
+ fail(s.comp, "FV1013", "in content Vue renders from virtual nodes, a `class` written before `:class` is written where the `class` stands, and Vue's server compiler moves it to where `:class` stands; write the two next to each other, or `:class` first", at);
124
+ }
125
+ return { ...n, elements: [last, ...n.elements.slice(0, -1)] };
126
+ }
105
127
  export function classPresent(s, n) {
106
128
  if (n.fvMerged)
107
129
  return n.elements.reduce((acc, el) => logical(acc, "||", classPresent(s, el)), "false");
@@ -123,7 +145,7 @@ export function classPresent(s, n) {
123
145
  break;
124
146
  }
125
147
  const v = expr(s, n);
126
- if (v.ty.k === "undef")
148
+ if (nothing(v.ty))
127
149
  return "false";
128
150
  if (v.ty.k === "opt")
129
151
  return `${atom(v.code)}.is_some()`;
@@ -170,7 +192,7 @@ export function classAttr(s, n, side) {
170
192
  return `if ${condition(cond(s, n.test))} { ${classAttr(s, n.consequent, side)} } else { ${classAttr(s, n.alternate, side)} }`;
171
193
  }
172
194
  const v = expr(s, n);
173
- if (v.ty.k === "str" || v.ty.k === "undef" || (v.ty.k === "opt" && v.ty.of.k === "str"))
195
+ if (v.ty.k === "str" || nothing(v.ty) || (v.ty.k === "opt" && v.ty.of.k === "str"))
174
196
  return valueAttr(s, v, n);
175
- return fail(s.comp, "a class is a string, an array, or an object of conditions", n);
197
+ return fail(s.comp, "FV1004", "a class is a string, an array, or an object of conditions", n);
176
198
  }
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
- import { CONFIG_FILE, GenError, generate, loadConfig, VERSION, write } from "./compiler.js";
4
+ import { CONFIG_FILE, generate, loadConfig, VERSION, write } from "./compiler.js";
5
5
  import { unifiedDiff } from "./diff.js";
6
+ import { diagnose, formatRefusal, isRefusal } from "./diagnostics.js";
6
7
  const root = process.cwd();
7
8
  const HELP = `Usage: ferrovue [command] [options]
8
9
 
@@ -14,16 +15,23 @@ Commands:
14
15
  Options:
15
16
  --check check that generated files match committed files without writing
16
17
  -d, --diff show unified diff of changes when checking (use with --check)
18
+ --format <format> how to write errors and results: human (default) or json
17
19
  --watch watch components, stores, routes and config for changes and regenerate
18
20
  -c, --config <path> path to configuration file (default: ferrovue.config.json)
19
21
  -v, --version print the version and exit
20
22
  -h, --help print this help and exit
21
23
  `;
24
+ let json = false;
25
+ function emit(result) {
26
+ console.log(JSON.stringify({ diagnostics: [], ...result }));
27
+ }
22
28
  function report(e) {
23
- if (e instanceof GenError || e instanceof SyntaxError)
24
- console.error(`error: ${e.message}`);
25
- else
29
+ if (!isRefusal(e))
26
30
  throw e;
31
+ if (json)
32
+ emit({ diagnostics: [diagnose(e)] });
33
+ else
34
+ console.error(formatRefusal(e));
27
35
  }
28
36
  function initProject(targetRoot, configPath) {
29
37
  const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
@@ -77,6 +85,10 @@ function check(config, showDiff = false) {
77
85
  }
78
86
  });
79
87
  const extra = have.filter((name) => !want.has(name));
88
+ if (json) {
89
+ emit({ out: config.out, stale: stale.map((name) => `${config.out}/${name}`), notGenerated: extra.map((name) => `${config.out}/${name}`) });
90
+ return stale.length || extra.length ? 1 : 0;
91
+ }
80
92
  if (stale.length || extra.length) {
81
93
  const committed = (name) => {
82
94
  try {
@@ -104,6 +116,8 @@ function check(config, showDiff = false) {
104
116
  }
105
117
  function once(config) {
106
118
  const { files, changed, removed } = write(root, config);
119
+ if (json)
120
+ return emit({ out: config.out, files, changed, removed });
107
121
  const what = changed.length || removed.length ? `${changed.length} changed, ${removed.length} removed` : "nothing changed";
108
122
  console.log(`${config.out}: ${files.length} files, ${what}`);
109
123
  }
@@ -137,7 +151,7 @@ function watchProject(configPath) {
137
151
  clearTimeout(timer);
138
152
  timer = setTimeout(run, 50);
139
153
  });
140
- console.log("watching for changes (Ctrl-C to stop)");
154
+ (json ? console.error : console.log)("watching for changes (Ctrl-C to stop)");
141
155
  }
142
156
  try {
143
157
  const rawArgs = process.argv.slice(2);
@@ -155,6 +169,14 @@ try {
155
169
  else if (arg.startsWith("--config=")) {
156
170
  configPath = arg.slice("--config=".length);
157
171
  }
172
+ else if (arg === "--format" || arg.startsWith("--format=")) {
173
+ const format = arg === "--format" ? rawArgs[++i] : arg.slice("--format=".length);
174
+ if (format !== "human" && format !== "json") {
175
+ console.error(`error: '--format' is 'human' or 'json'`);
176
+ process.exit(1);
177
+ }
178
+ json = format === "json";
179
+ }
158
180
  else {
159
181
  args.push(arg);
160
182
  }
@@ -169,6 +191,10 @@ try {
169
191
  console.error("error: '--diff' requires '--check'");
170
192
  process.exit(1);
171
193
  }
194
+ if ((args.includes("--diff") || args.includes("-d")) && json) {
195
+ console.error("error: '--diff' writes text, and does not combine with '--format json'");
196
+ process.exit(1);
197
+ }
172
198
  if (args.includes("--version") || args.includes("-v")) {
173
199
  console.log(VERSION);
174
200
  }
package/dist/client.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type App, type Component, type Plugin } from "vue";
2
2
  import type { Pinia } from "pinia";
3
3
  import type { Router } from "vue-router";
4
- /** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
4
+ /** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
5
5
  * the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
6
6
  export declare function hydrateState(pinia: Pinia, id?: string, doc?: Document): void;
7
7
  export interface MountOptions {
@@ -15,10 +15,11 @@ export interface MountOptions {
15
15
  /** Where to look for islands: the whole document by default. */
16
16
  root?: ParentNode;
17
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. */
18
+ * those given, did not load or its props are malformed, and about one hydrated at once because
19
+ * its `data-hydrate` is not one this version knows. Warns on the console by default. */
19
20
  onError?: (element: Element, problem: string) => void;
20
21
  }
21
- /** A component, or a function that loads one — `() => import("./Counter.vue")` — so that the code
22
+ /** A component, or a function that loads one (`() => import("./Counter.vue")`), so that the code
22
23
  * of an island not on the page is never fetched. A function is taken for a loader as vue-router
23
24
  * takes one for a lazy route: unless it has `props` or `displayName`, which mark a functional
24
25
  * component, or `__vccOpts`, a class component. `ferrovue/islands` maps the name of every island
@@ -28,13 +29,50 @@ export type IslandComponent = Component | (() => Promise<Component | {
28
29
  }>);
29
30
  /** The islands mounted. */
30
31
  export interface Islands {
31
- /** One app per island, in document order. */
32
+ /** One app per island hydrated so far: those hydrated at once in document order, then each
33
+ * island with a `data-hydrate` as it hydrates. */
32
34
  apps: App[];
33
- /** Unmount every island, as a page leaving would. */
35
+ /** Unmount every island and stop waiting to hydrate the others, as a page leaving would. */
34
36
  unmount(): void;
35
37
  }
36
38
  /** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
37
39
  * `Html::island` wrote becomes an app of the component of that name, given the props the server
38
40
  * rendered it with, mounted where it is. A loader is called only for an island the page holds, all
39
- * of them at once, and the islands mount in document order once every one has loaded. */
41
+ * of them at once, and the islands mount in document order once every one has loaded. An island
42
+ * with a `data-hydrate` (`Html::hydrate`) waits instead: once it is visible (or within a root margin
43
+ * of the viewport), the browser is idle,
44
+ * it is interacted with or a media query matches, its component is loaded and it hydrates. */
40
45
  export declare function mountIslands(components: Record<string, IslandComponent>, options?: MountOptions): Promise<Islands>;
46
+ /** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
47
+ * browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
48
+ * compile the default slot, so anything may go there: a component library's components, code that
49
+ * reads `window`. The swap happens only where Vue runs on the page, as in an island. */
50
+ export declare const ClientOnly: Component;
51
+ /** One component the server rendered into a slot of a page's layout, as `ferrovue::Part` records
52
+ * it: its name (`c`) and the props it was rendered from (`p`). */
53
+ export interface PagePart {
54
+ c: string;
55
+ p: Record<string, unknown>;
56
+ }
57
+ /** What `ferrovue::PageRecord` writes: the layout's props, and each slot's parts in order. */
58
+ export interface PageRecord {
59
+ props: Record<string, unknown>;
60
+ slots: Record<string, PagePart[]>;
61
+ }
62
+ export interface PageOptions extends Pick<MountOptions, "pinia" | "router" | "plugins"> {
63
+ /** The element holding the layout's root, or a selector for it: `#app` by default. */
64
+ container?: Element | string;
65
+ /** The `id` of the record's script, as given to `PageRecord::script_into`: `__fv_page` by default. */
66
+ record?: string;
67
+ /** The document to read the page from. */
68
+ doc?: Document;
69
+ }
70
+ /** The app of a page: its root renders `layout` with the record's props, and each slot as the
71
+ * components the record names, in order, as a plain array, which hydrates the markup the server
72
+ * wrote with no fragment of its own. `components` must hold every component the record names. */
73
+ export declare function createPageApp(layout: Component, record: PageRecord, components: Record<string, Component>, options?: PageOptions): App;
74
+ /** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
75
+ * no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
76
+ * as the server rendered it, when the container or the record is missing or a component is not
77
+ * given or does not load. */
78
+ export declare function mountPage(layout: IslandComponent, components: Record<string, IslandComponent>, options?: PageOptions): Promise<App>;