ferrovue 0.2.0 → 0.3.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/template.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /* The compiled template's statements: pushes, conditions, lists, child components and slots. */
2
- import { fail, GenError, INT, rustStr, sameTy, snake, STR } from "./model.js";
2
+ import { camelize, declares, fail, GenError, INT, rustStr, sameTy, snake, STR, takesAttrs } from "./model.js";
3
3
  import { ctx } from "./context.js";
4
4
  import { markHome } from "./typescript.js";
5
5
  import { asF64, boolOf, cond, expr, fieldVal, isObjectCall, known, narrowTo, presence, truthy } from "./expr.js";
6
6
  import { atom, bare, CMP, condition, occurrences, operand, OR, strArg, UNARY } from "./parens.js";
7
7
  import { Emitter } from "./emitter.js";
8
- import { IGNORED_PROPS, interpolate, renderAttr, renderAttrs, renderClass, renderDynamicAttr, renderStyle } from "./attrs.js";
8
+ import { attrOf, dollarAttrs, IGNORED_PROPS, interpolate, isAttrs, mergedParts, renderAttr, renderAttrs, renderClass, renderDynamicAttr, renderStyle } from "./attrs.js";
9
+ import { passedKey } from "./scoped.js";
10
+ import { isSSRSafeAttrName, propsToAttrMap } from "@vue/shared";
9
11
  import { routerLink } from "./router.js";
10
12
  import { rustTy } from "./rust.js";
11
13
  /** One `${...}` inside a pushed template literal. */
@@ -26,7 +28,7 @@ export function slot(s, e, n) {
26
28
  case "_ssrRenderAttr":
27
29
  if (a[0].type !== "StringLiteral")
28
30
  fail(s.comp, "attribute names are literal", n);
29
- renderAttr(e, a[0].value, expr(s, a[1]));
31
+ renderAttr(s, e, a[0].value, expr(s, a[1]), a[1]);
30
32
  return;
31
33
  // `:hidden`, whose rendering depends on the value's type: Vue cannot decide it at compile time.
32
34
  case "_ssrRenderDynamicAttr":
@@ -87,24 +89,51 @@ export function isComment(text) {
87
89
  return false;
88
90
  return text.length <= 8 || !text.replace(/<!--[^]*?-->/gm, "").trim();
89
91
  }
90
- /** Whether a `_push` argument is content to `ssrRenderSlot`, rather than only comments. */
91
- export function pushesContent(s, n) {
92
+ /** Whether a `_push` argument is content to `ssrRenderSlot` rather than only comments — `"run"`
93
+ * when that depends on the values it interpolates (`${of1}<!--[-->` is a comment when `of1` writes
94
+ * nothing). Vue asks it of each pushed string, so the generated code asks `fv::is_comment` of what
95
+ * that push wrote. */
96
+ export function pushesContent(n) {
92
97
  if (n.type === "StringLiteral")
93
98
  return !isComment(n.value);
94
99
  if (n.type === "TemplateLiteral") {
100
+ const quasis = n.quasis.map((q) => q.value.cooked);
95
101
  if (n.expressions.length === 0)
96
- return !isComment(n.quasis[0].value.cooked);
97
- if (n.quasis[0].value.cooked.startsWith("<!--")) {
98
- fail(s.comp, "slot content that starts with a comment holding an interpolation", n);
99
- }
100
- return true;
102
+ return !isComment(quasis[0]);
103
+ // A comment starts with `<!--` and ends with `-->`: literal text that cannot decides it now.
104
+ const first = quasis[0];
105
+ const last = quasis.at(-1);
106
+ if (!"<!--".startsWith(first) && !first.startsWith("<!--"))
107
+ return true;
108
+ if (!"-->".endsWith(last) && !last.endsWith("-->"))
109
+ return true;
110
+ /* An interpolated value is escaped, so it writes no `<` or `>`: every comment starts and ends in
111
+ * the literal text — unless a value is raw (`v-html`), or a marker could be split across text
112
+ * and a value. Then literal text left over once the comments are taken out is content, whatever
113
+ * the values write. */
114
+ const raw = n.expressions.some((x) => x.type !== "CallExpression" && !(x.type === "Identifier" && x.name === "_scopeId"));
115
+ const split = quasis.some((q, i) => (i > 0 && /^(?:-|--|!--|->|>)/.test(q)) || (i < quasis.length - 1 && /(?:<|<!|<!-|-|--)$/.test(q)));
116
+ if (!raw && !split && /\S/.test(quasis.join("\0").replace(/<!--[^]*?-->/g, "").replaceAll("\0", "")))
117
+ return true;
118
+ return "run";
101
119
  }
102
120
  // A component's render is a buffer, never a comment.
103
121
  return true;
104
122
  }
105
123
  export function push(s, e, n) {
106
- if (s.fill && pushesContent(s, n))
124
+ const content = s.fill ? pushesContent(n) : false;
125
+ if (content === true)
107
126
  e.stmt("filled = true;");
127
+ if (content !== "run") {
128
+ pushed(s, e, n);
129
+ return;
130
+ }
131
+ e.stmt("let fv_chunk = out.len();");
132
+ pushed(s, e, n);
133
+ e.stmt("filled |= !fv::is_comment(&out[fv_chunk..]);");
134
+ }
135
+ /** What one `_push` writes. */
136
+ function pushed(s, e, n) {
108
137
  const text = n.type === "StringLiteral" ? n.value : n.type === "TemplateLiteral" && !n.expressions.length ? n.quasis[0].value.cooked : null;
109
138
  if (s.vnode && text === "<!---->") {
110
139
  e.lit("<!--v-if-->");
@@ -165,9 +194,6 @@ function childAttrs(s, child, passesAttrs, inSlot, n) {
165
194
  }
166
195
  export function renderChild(s, e, n) {
167
196
  const [target, rawProps, slots, , slotScopeId] = n.arguments;
168
- // The parent's `_attrs`, which it passes on to a child that is its root.
169
- const isAttrs = (a) => a?.type === "Identifier" && a.name === "_attrs";
170
- const passesAttrs = isAttrs(rawProps) || (rawProps?.type === "CallExpression" && rawProps.arguments.some(isAttrs));
171
197
  let local = null;
172
198
  if (target.type === "MemberExpression" && target.object.name === "$setup") {
173
199
  local = target.computed ? target.property.value : target.property.name;
@@ -175,56 +201,67 @@ export function renderChild(s, e, n) {
175
201
  const isSelf = target.type === "Identifier" && target.name === s.selfAlias.name;
176
202
  const childName = isSelf ? s.comp.name : local ? s.children.get(local) : undefined;
177
203
  const child = childName ? s.components.get(childName) : undefined;
178
- // Inside a `v-for`, Vue merges a `{ ref_for: true }` marker into the props; it renders nothing.
179
- let props = rawProps;
180
- if (props?.type === "CallExpression" && props.callee.type === "Identifier" && props.callee.name === "_mergeProps" &&
181
- props.arguments.length === 2 && props.arguments[0].type === "ObjectExpression" &&
182
- props.arguments[0].properties.every((p) => p.type === "ObjectProperty" && IGNORED_PROPS.has(p.key.name ?? p.key.value))) {
183
- props = props.arguments[1];
184
- }
185
- // A component at the root of the template is handed the fallthrough attributes, which an island
186
- // never has.
187
- if (props?.type === "CallExpression" && props.callee.type === "Identifier" && props.callee.name === "_mergeProps" &&
188
- props.arguments.length === 2 && props.arguments[1].type === "Identifier" && props.arguments[1].name === "_attrs") {
189
- props = props.arguments[0];
190
- }
191
- // A child given no props at all: Vue passes `null`, or the empty fallthrough attributes.
192
- if ((props?.type === "Identifier" && props.name === "_attrs") || props?.type === "NullLiteral" || !props) {
193
- props = { type: "ObjectExpression", properties: [] };
194
- }
195
204
  if (!child)
196
205
  fail(s.comp, "a child component must be an imported island", n);
197
206
  if (child.routerView) {
198
207
  fail(s.comp, `${child.name} holds \`<RouterView>\`: the server renders it at the top, never as a child`, n);
199
208
  }
209
+ // What the props merge: object literals — a `{ ref_for: true }` marker inside a `v-for` among
210
+ // them, which renders nothing — the parent's `_attrs` when the child is its root, and `$attrs`.
211
+ const parts = !rawProps || rawProps.type === "NullLiteral" ? [] : mergedParts(rawProps);
212
+ const merges = parts.length > 0 && parts[0] !== rawProps;
213
+ const passesAttrs = parts.some(isAttrs);
214
+ const passed = (p) => s.fallthrough !== null && ((isAttrs(p) && s.comp.inheritAttrs) || dollarAttrs(s, p));
215
+ const marker = (p) => p.type === "ObjectExpression" && p.properties.length > 0 && p.properties.every((q) => q.type === "ObjectProperty" && IGNORED_PROPS.has(q.key.name ?? q.key.value));
216
+ const objects = parts.filter((p) => !isAttrs(p) && !dollarAttrs(s, p) && !marker(p));
217
+ // Attributes this component is passed reach the child's props too, where a prop would take them.
218
+ if (parts.some(passed)) {
219
+ for (const name of s.comp.attrNames) {
220
+ if (declares(child, name)) {
221
+ 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);
222
+ }
223
+ }
224
+ }
225
+ const ids = childAttrs(s, child, passesAttrs, !!slotScopeId, n);
200
226
  // `v-bind="x"`, where `x` is exactly the child's own `Props`: handed over as it is.
201
- if (props.type !== "ObjectExpression") {
202
- const v = expr(s, props);
227
+ if (objects.length === 1 && objects[0].type !== "ObjectExpression") {
228
+ const v = expr(s, objects[0]);
203
229
  // The component itself, however the template reached it: its own `Props` struct is the type.
204
230
  const own = child.name === s.comp.name && v.ty.k === "struct" && v.ty.name === "Props";
205
231
  if (own || (v.ty.k === "child" && v.ty.name === child.name)) {
206
- callChild(s, e, child, v.code, slots, childAttrs(s, child, passesAttrs, !!slotScopeId, n));
232
+ callChild(s, e, child, v.code, slots, childAttrsArg(s, child, parts, merges, null, ids));
207
233
  return;
208
234
  }
209
235
  fail(s.comp, `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, n);
210
236
  }
237
+ for (const p of objects) {
238
+ if (p.type !== "ObjectExpression")
239
+ fail(s.comp, `child props must be an object literal, or \`v-bind\` of ${child.name}'s own \`Props\``, p);
240
+ }
211
241
  const given = new Map();
212
- for (const p of props.properties) {
213
- if (p.type !== "ObjectProperty" || p.computed)
214
- fail(s.comp, "child props hold plain keys", p);
215
- const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
216
- // Listeners, `v-model`'s `onUpdate:…` among them, run on the client alone; a model's modifiers
217
- // change only what an update writes.
218
- if (IGNORED_PROPS.has(key) || /^on[^a-z]/.test(key))
219
- continue;
220
- if (key.endsWith("Modifiers") && child.props.fields.some((f) => f.js === (key === "modelModifiers" ? "modelValue" : key.slice(0, -"Modifiers".length))))
221
- continue;
222
- if (!child.props.fields.some((f) => f.js === key)) {
223
- // A key the child does not declare would fall through onto its root element as an
224
- // attribute, which the generated child does not render.
225
- fail(s.comp, `\`${key}\` is not a prop of ${child.name}`, p);
242
+ // Keys the child does not declare are attributes, which fall through to its root.
243
+ const fallthrough = new Set();
244
+ for (const obj of objects) {
245
+ for (const p of obj.properties) {
246
+ if (p.type !== "ObjectProperty" || p.computed)
247
+ fail(s.comp, "child props hold plain keys", p);
248
+ const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
249
+ // Listeners, `v-model`'s `onUpdate:…` among them, run on the client alone; a model's modifiers
250
+ // change only what an update writes.
251
+ if (!passedKey(child, key))
252
+ continue;
253
+ const field = child.props.fields.find((f) => camelize(f.js) === camelize(key));
254
+ if (field) {
255
+ given.set(field.js, p.value);
256
+ continue;
257
+ }
258
+ if (array(key))
259
+ fail(s.comp, `an attribute named \`${key}\`, which JavaScript would put before the others`, p);
260
+ const name = propsToAttrMap[key] ?? key.toLowerCase();
261
+ if (key !== "class" && key !== "style" && !isSSRSafeAttrName(name))
262
+ fail(s.comp, `unsafe attribute name \`${name}\``, p);
263
+ fallthrough.add(p);
226
264
  }
227
- given.set(key, p.value);
228
265
  }
229
266
  const inits = child.props.fields.map((f) => {
230
267
  const node = given.get(f.js);
@@ -238,7 +275,42 @@ export function renderChild(s, e, n) {
238
275
  const v = expr(s, node);
239
276
  return fieldInit(f.rust, ownInto(s.comp, { ...v, ty: markHome(v.ty, s.comp.name) }, markHome(f.ty, child.name), node));
240
277
  });
241
- callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, childAttrs(s, child, passesAttrs, !!slotScopeId, n));
278
+ callChild(s, e, child, `&super::${child.module}::Props { ${inits.join(", ")} }`, slots, childAttrsArg(s, child, parts, merges, fallthrough, ids));
279
+ }
280
+ /** Whether a name is an array index, which a JavaScript object lists before its other keys. */
281
+ function array(key) {
282
+ return /^(0|[1-9]\d*)$/.test(key) && Number(key) < 2 ** 32 - 1;
283
+ }
284
+ /** The last argument of a child's `render_scoped`: for a child a parent may pass attributes, the
285
+ * `fv::Attrs` this call passes — the keys of its props objects it does not declare, and what this
286
+ * component was passed, merged in their order — with the scope ids; for any other, the ids. */
287
+ function childAttrsArg(s, child, parts, merges, fallthrough, ids) {
288
+ const sources = [];
289
+ for (const p of parts) {
290
+ if (s.fallthrough !== null && ((isAttrs(p) && s.comp.inheritAttrs) || dollarAttrs(s, p))) {
291
+ sources.push(`${s.fallthrough}.list()`);
292
+ }
293
+ else if (p.type === "ObjectExpression" && fallthrough !== null) {
294
+ const entries = p.properties.filter((q) => fallthrough.has(q)).map((q) => {
295
+ const key = q.key.type === "Identifier" ? q.key.name : String(q.key.value);
296
+ return `(${rustStr(key)}, ${attrOf(s, key, q.value, "vnode")})`;
297
+ });
298
+ if (entries.length)
299
+ sources.push(`&[${entries.join(", ")}]`);
300
+ }
301
+ }
302
+ if (!takesAttrs(child)) {
303
+ if (sources.length)
304
+ throw new GenError(`${s.comp.file}: ${child.name} is passed attributes it does not take`);
305
+ return ids;
306
+ }
307
+ if (!sources.length)
308
+ return ids === null ? "&fv::Attrs::NONE" : `&fv::Attrs::scoped(${ids})`;
309
+ // One object literal, or `_attrs` or `$attrs` alone, is the child's as it is: only `_mergeProps`
310
+ // normalises what it merges, even one object.
311
+ if (sources.length === 1 && !merges)
312
+ return `&fv::Attrs::new(${sources[0]}, ${ids ?? '""'})`;
313
+ return `&fv::Attrs::merged(&[${sources.join(", ")}], ${ids ?? '""'})`;
242
314
  }
243
315
  /** `name: value` in a struct literal, or the name alone when the value is a variable of that name. */
244
316
  export function fieldInit(name, value) {
@@ -262,8 +334,8 @@ export function extraParams(c) {
262
334
  (c.usesTeleports ? ", fv_teleports: &fv::Teleports" : ""));
263
335
  }
264
336
  /** `render(out, props[, slots][, route])` for a child, with the slot content this template gives
265
- * it as closures; `render_scoped`, with the scope ids its root is handed last, for a child that may
266
- * be handed some. */
337
+ * it as closures; `render_scoped`, with the scope ids its root is handed last — and the attributes
338
+ * it is passed, as `fv::Attrs` — for a child that may be handed some. */
267
339
  export function callChild(s, e, child, propsCode, slots, attrs) {
268
340
  const given = new Map();
269
341
  if (slots && slots.type !== "NullLiteral") {
@@ -281,9 +353,10 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
281
353
  }
282
354
  }
283
355
  const m = `super::${child.module}`;
356
+ const scoped = child.inherits || takesAttrs(child);
284
357
  const route = (child.usesRoute ? ", fv_route" : "") + (child.usesStores ? ", fv_stores" : "") + (child.usesI18n ? ", fv_i18n" : "") + (child.usesTeleports ? ", fv_teleports" : "") +
285
- (child.inherits ? `, ${attrs ?? '""'}` : "");
286
- const render = child.inherits ? "render_scoped" : "render";
358
+ (scoped ? `, ${attrs ?? (takesAttrs(child) ? "&fv::Attrs::NONE" : '""')}` : "");
359
+ const render = scoped ? "render_scoped" : "render";
287
360
  if (!takesSlots(child)) {
288
361
  e.stmt(`${m}::${render}(out, ${propsCode}${route});`);
289
362
  return;
@@ -304,7 +377,7 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
304
377
  const sidParam = sid === null ? "" : `, ${sid}: &str`;
305
378
  /** The content's statements, then whether it pushed anything but comments. */
306
379
  const content = (inner) => {
307
- if (staticallyFilled(inner, body)) {
380
+ if (staticallyFilled(body)) {
308
381
  statements({ ...inner, fill: false }, e, body);
309
382
  e.stmt("true");
310
383
  }
@@ -356,7 +429,7 @@ export function callChild(s, e, child, propsCode, slots, attrs) {
356
429
  unread(opened, sid);
357
430
  e.close(")),");
358
431
  }
359
- else if (staticallyFilled(inner, body)) {
432
+ else if (staticallyFilled(body)) {
360
433
  e.open(`${field}: Some(fv::Slot::new(&|out: &mut String|`);
361
434
  statements({ ...inner, fill: false }, e, body);
362
435
  e.close(")),");
@@ -393,10 +466,10 @@ export function slotContent(s, value) {
393
466
  }
394
467
  /** Whether slot content always pushes something that is not a comment: a push of content outside
395
468
  * any `if` or loop. Then nothing has to be decided at run time. */
396
- export function staticallyFilled(s, body) {
469
+ export function staticallyFilled(body) {
397
470
  return body.some((st) => st.type === "ExpressionStatement" && st.expression.type === "CallExpression" &&
398
471
  st.expression.callee.type === "Identifier" && st.expression.callee.name === "_push" &&
399
- pushesContent(s, st.expression.arguments[0]));
472
+ pushesContent(st.expression.arguments[0]) === true);
400
473
  }
401
474
  /** `RowSlotProps`, `RowSlot`: the names of a scoped slot's props struct and content type. */
402
475
  export function slotTypeName(slotName, suffix) {
package/dist/vite.js CHANGED
@@ -6,7 +6,10 @@
6
6
  * A build regenerates them once and fails on a construct ferrovue refuses, naming the line in the
7
7
  * `.vue` file. The dev server regenerates them whenever a component, store, type file, the routes
8
8
  * or the configuration changes, and shows a refusal in its error overlay. Only files whose text
9
- * changed are rewritten, so `cargo watch` rebuilds no more than it must. */
9
+ * changed are rewritten, so `cargo watch` rebuilds no more than it must.
10
+ *
11
+ * It also writes `ferrovue/islands`: every component that has an `island()`, by name, each a lazy
12
+ * `import()` of its `.vue` file, for `mountIslands`. */
10
13
  import { readdirSync, readFileSync } from "node:fs";
11
14
  import { join, relative, resolve, sep } from "node:path";
12
15
  import { CONFIG_FILE, GenError, loadConfig, write } from "./compiler.js";
@@ -60,13 +63,45 @@ export function scopeIdMismatch(root, vue) {
60
63
  return null;
61
64
  return `\`<style scoped>\` ids: @vitejs/plugin-vue hashes "${vue.mode}" from ${vue.root}, ${CONFIG_FILE} "${mode}" from ${viteRoot}; set \`scopeId\` and \`viteRoot\` to match, or plugin-vue's \`features.componentIdGenerator\``;
62
65
  }
66
+ /** The import that names the islands, and the id the plugin gives the module it writes for it. */
67
+ const ISLANDS = "ferrovue/islands";
68
+ const ISLANDS_ID = `\0${ISLANDS}`;
69
+ /** The text of `ferrovue/islands`: each island's name, mapped to an `import()` of its `.vue` file,
70
+ * which the bundler splits into a chunk of its own. */
71
+ function islandsModule(root, islands) {
72
+ const entries = Object.entries(islands).map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(resolve(root, file).split(sep).join("/"))}),`);
73
+ return `export default {\n${entries.join("\n")}\n};\n`;
74
+ }
63
75
  export default function ferrovue(options = {}) {
64
76
  const root = resolve(options.root ?? process.cwd());
65
- const regenerate = () => write(root, loadConfig(root));
77
+ // The islands as the last generation found them, for `ferrovue/islands`.
78
+ let islands = null;
79
+ const regenerate = () => {
80
+ const written = write(root, loadConfig(root));
81
+ islands = written.islands;
82
+ return written;
83
+ };
66
84
  let vue = null;
67
85
  let building = true;
68
86
  return {
69
87
  name: "ferrovue",
88
+ // Before Vite's own resolver, which would find `ferrovue/islands` in the package instead.
89
+ enforce: "pre",
90
+ resolveId(id) {
91
+ return id === ISLANDS ? ISLANDS_ID : null;
92
+ },
93
+ load(id) {
94
+ if (id !== ISLANDS_ID)
95
+ return null;
96
+ try {
97
+ return islandsModule(root, islands ?? regenerate().islands);
98
+ }
99
+ catch (e) {
100
+ if (e instanceof GenError || e instanceof SyntaxError)
101
+ this.error(e.message);
102
+ throw e;
103
+ }
104
+ },
70
105
  configResolved(config) {
71
106
  vue = vueScopeIds(config);
72
107
  building = config.command === "build";
@@ -94,10 +129,18 @@ export default function ferrovue(options = {}) {
94
129
  if (!affects(root, file))
95
130
  return;
96
131
  try {
132
+ const before = JSON.stringify(islands);
97
133
  const { changed, removed } = regenerate();
98
134
  if (changed.length || removed.length) {
99
135
  server.config.logger.info(`ferrovue: ${changed.length} changed, ${removed.length} removed`, { timestamp: true });
100
136
  }
137
+ // A component that became an island, or stopped being one: the page loads the islands
138
+ // afresh, as the server now writes them.
139
+ const loaded = server.moduleGraph.getModuleById(ISLANDS_ID);
140
+ if (loaded && JSON.stringify(islands) !== before) {
141
+ server.moduleGraph.invalidateModule(loaded);
142
+ server.ws.send({ type: "full-reload" });
143
+ }
101
144
  }
102
145
  catch (e) {
103
146
  if (!(e instanceof GenError || e instanceof SyntaxError))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferrovue",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/client.d.ts",
45
45
  "default": "./dist/client.js"
46
46
  },
47
+ "./islands": {
48
+ "types": "./dist/islands.d.ts",
49
+ "default": "./dist/islands.js"
50
+ },
47
51
  "./vite": {
48
52
  "types": "./dist/vite.d.ts",
49
53
  "default": "./dist/vite.js"