ferrovue 0.2.0 → 0.4.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 (86) hide show
  1. package/README.md +103 -15
  2. package/dist/attrs.d.ts +8 -42
  3. package/dist/attrs.js +148 -297
  4. package/dist/calls.d.ts +4 -0
  5. package/dist/calls.js +219 -0
  6. package/dist/children.d.ts +9 -0
  7. package/dist/children.js +273 -0
  8. package/dist/classes.d.ts +13 -0
  9. package/dist/classes.js +176 -0
  10. package/dist/cli.js +121 -27
  11. package/dist/client.d.ts +11 -2
  12. package/dist/client.js +45 -9
  13. package/dist/compiler.d.ts +4 -0
  14. package/dist/compiler.js +37 -90
  15. package/dist/component.js +19 -82
  16. package/dist/context.d.ts +6 -68
  17. package/dist/context.js +25 -38
  18. package/dist/diff.d.ts +1 -0
  19. package/dist/diff.js +68 -0
  20. package/dist/emitter.d.ts +0 -8
  21. package/dist/emitter.js +0 -12
  22. package/dist/expr.d.ts +0 -70
  23. package/dist/expr.js +30 -855
  24. package/dist/fallthrough.d.ts +9 -0
  25. package/dist/fallthrough.js +78 -0
  26. package/dist/fixture.d.ts +2 -1
  27. package/dist/fixture.js +37 -22
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +1 -0
  30. package/dist/islands.d.ts +6 -0
  31. package/dist/islands.js +5 -0
  32. package/dist/lists.d.ts +0 -11
  33. package/dist/lists.js +4 -39
  34. package/dist/loops.d.ts +3 -0
  35. package/dist/loops.js +164 -0
  36. package/dist/model.d.ts +22 -130
  37. package/dist/model.js +37 -9
  38. package/dist/narrowing.d.ts +19 -0
  39. package/dist/narrowing.js +126 -0
  40. package/dist/numbers.d.ts +8 -0
  41. package/dist/numbers.js +73 -0
  42. package/dist/parens.d.ts +0 -27
  43. package/dist/parens.js +0 -44
  44. package/dist/plugin.d.ts +84 -0
  45. package/dist/plugin.js +26 -0
  46. package/dist/plugins/i18n.d.ts +23 -0
  47. package/dist/{i18n.js → plugins/i18n.js} +76 -19
  48. package/dist/plugins/index.d.ts +2 -0
  49. package/dist/plugins/index.js +8 -0
  50. package/dist/plugins/router-link.d.ts +8 -0
  51. package/dist/{router.js → plugins/router-link.js} +65 -57
  52. package/dist/plugins/router.d.ts +42 -0
  53. package/dist/plugins/router.js +256 -0
  54. package/dist/plugins/scoped.d.ts +12 -0
  55. package/dist/{scoped.js → plugins/scoped.js} +55 -20
  56. package/dist/plugins/shared-types.d.ts +2 -0
  57. package/dist/plugins/shared-types.js +7 -0
  58. package/dist/plugins/stores.d.ts +37 -0
  59. package/dist/plugins/stores.js +377 -0
  60. package/dist/plugins/teleport.d.ts +5 -0
  61. package/dist/plugins/teleport.js +55 -0
  62. package/dist/rust.d.ts +2 -5
  63. package/dist/rust.js +66 -161
  64. package/dist/script.d.ts +0 -6
  65. package/dist/script.js +22 -121
  66. package/dist/slots.d.ts +13 -0
  67. package/dist/slots.js +160 -0
  68. package/dist/strings.d.ts +13 -0
  69. package/dist/strings.js +208 -0
  70. package/dist/styles.d.ts +14 -0
  71. package/dist/styles.js +181 -0
  72. package/dist/template.d.ts +2 -40
  73. package/dist/template.js +40 -691
  74. package/dist/testing.d.ts +1 -0
  75. package/dist/testing.js +1 -8
  76. package/dist/types.d.ts +2 -0
  77. package/dist/typescript.d.ts +0 -13
  78. package/dist/typescript.js +8 -41
  79. package/dist/vite.d.ts +1 -0
  80. package/dist/vite.js +35 -14
  81. package/package.json +11 -2
  82. package/dist/i18n.d.ts +0 -27
  83. package/dist/router.d.ts +0 -17
  84. package/dist/scoped.d.ts +0 -10
  85. package/dist/stores.d.ts +0 -6
  86. package/dist/stores.js +0 -227
package/dist/script.js CHANGED
@@ -1,27 +1,22 @@
1
- /* `<script setup>` read: what the server evaluates, and what stays on the client. */
2
1
  import { basename } from "node:path";
3
- import { fail, GenError, snake, STR } from "./model.js";
2
+ import { fail, GenError, snake, takesAttrs } from "./model.js";
4
3
  import { CONFIG_FILE, ctx } from "./context.js";
5
4
  import { definePropsType } from "./typescript.js";
6
- import { expr, fieldVal, storeGetter, theRoute } from "./expr.js";
5
+ import { expr, fieldVal } from "./expr.js";
7
6
  import { collected, heldList } from "./lists.js";
8
- import { storeImport } from "./stores.js";
9
7
  import { bare, operand, UNARY } from "./parens.js";
10
- /** Lifecycle hooks, which never run on the server: setup may register them freely. */
8
+ import { known } from "./narrowing.js";
11
9
  export const CLIENT_HOOKS = new Set([
12
10
  "onMounted", "onBeforeMount", "onUnmounted", "onBeforeUnmount", "onUpdated", "onBeforeUpdate",
13
11
  "onActivated", "onDeactivated", "onErrorCaptured", "onRenderTracked", "onRenderTriggered",
14
12
  ]);
15
- /** Compiler macros and calls with no effect on the server's render. */
16
13
  export const INERT_CALLS = new Set(["defineEmits", "defineSlots", "defineOptions", "defineExpose", "defineProps", "withDefaults", "provide"]);
17
- /** A statement in setup that is a call on its own: allowed only when it cannot change the render. */
18
14
  export function setupStatement(comp, st) {
19
15
  const c = st.expression;
20
16
  const name = c?.type === "CallExpression" && c.callee.type === "Identifier" ? c.callee.name : null;
21
17
  if (name !== null && (CLIENT_HOOKS.has(name) || INERT_CALLS.has(name)))
22
18
  return;
23
19
  if (name === "watch") {
24
- // A watcher's callback runs on the server only with `immediate`, and could then change state.
25
20
  const options = c.arguments[2];
26
21
  const immediate = options?.type === "ObjectExpression" && options.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "immediate" && !(p.value.type === "BooleanLiteral" && !p.value.value));
27
22
  if (!immediate)
@@ -35,8 +30,6 @@ export function setupStatement(comp, st) {
35
30
  fail(comp, "`onServerPrefetch` fetches on the server; pass the data in as props instead", st);
36
31
  fail(comp, `\`${st.expression?.type === "CallExpression" ? `${name ?? "a call"}()` : st.expression?.type}\` in setup could change what renders, and is not translated`, st);
37
32
  }
38
- /** What a setup binding's value is computed from: `ref(x)` and `shallowRef(x)` hold `x`,
39
- * `computed(() => x)` is `x`, and a plain `const y = x` is `x`. */
40
33
  export function setupSource(init) {
41
34
  if (init.type === "CallExpression" && init.callee.type === "Identifier") {
42
35
  const name = init.callee.name;
@@ -56,7 +49,6 @@ export function setupSource(init) {
56
49
  return setupSource(init.expression);
57
50
  return init;
58
51
  }
59
- /** The names a destructuring pattern binds. */
60
52
  export function patternNames(p) {
61
53
  switch (p?.type) {
62
54
  case "Identifier":
@@ -87,21 +79,20 @@ export function scopeFor(comp, ast, components) {
87
79
  narrowed: new Map(),
88
80
  selfAlias: { name: null },
89
81
  helperBytes: { n: 0 },
90
- router: new Map(),
91
82
  directives: new Map(),
92
- i18nT: new Set(),
93
83
  fill: false,
94
84
  vnode: false,
95
- attrs: comp.inherits ? "fv_attrs" : null,
85
+ attrs: comp.inherits ? (takesAttrs(comp) ? "fv_attrs.ids()" : "fv_attrs") : null,
86
+ fallthrough: takesAttrs(comp) ? "fv_attrs" : null,
87
+ attrsBindings: new Set(),
96
88
  sid: null,
89
+ plugins: new Map(),
97
90
  };
91
+ for (const p of ctx.plugins)
92
+ if (p.scope)
93
+ scope.plugins.set(p, p.scope(scope));
98
94
  const lets = [];
99
- const storeHooks = new Map();
100
- let storeToRefsName = null;
101
- let useRouteName = null;
102
- let useI18nName = null;
103
- /** Setup bindings holding a store, by name. */
104
- const storeValues = new Map();
95
+ let useAttrsName = null;
105
96
  for (const st of ast) {
106
97
  if (st.type === "ImportDeclaration") {
107
98
  const from = st.source.value;
@@ -109,46 +100,14 @@ export function scopeFor(comp, ast, components) {
109
100
  const def = st.specifiers.find((x) => x.type === "ImportDefaultSpecifier");
110
101
  if (def)
111
102
  scope.children.set(def.local.name, basename(from, ".vue"));
103
+ continue;
112
104
  }
113
- else if (storeImport(comp, from)) {
114
- for (const sp of st.specifiers) {
115
- const hook = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
116
- // A type from the store's file, which `readComponent` has already resolved.
117
- if (st.importKind === "type" || sp.importKind === "type" || (hook !== null && ctx.storeStructs.has(hook)))
118
- continue;
119
- const store = hook ? ctx.stores.get(hook) : undefined;
120
- if (!store || store.module !== storeImport(comp, from))
121
- fail(comp, "import a store by its `use…` hook", sp);
122
- storeHooks.set(sp.local.name, store);
123
- }
124
- }
125
- else if (from === "vue-router") {
126
- for (const sp of st.specifiers) {
127
- const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
128
- if (name === "useRoute")
129
- useRouteName = sp.local.name;
130
- else if (name === "RouterLink" || name === "RouterView")
131
- scope.router.set(sp.local.name, name);
132
- else
133
- scope.clientOnly.set(sp.local.name, `\`${name}\` from vue-router does not run on the server`);
134
- }
135
- }
136
- else if (from === "vue-i18n") {
137
- for (const sp of st.specifiers) {
138
- const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
139
- if (name === "useI18n")
140
- useI18nName = sp.local.name;
141
- else
142
- scope.clientOnly.set(sp.local.name, `\`${name}\` from vue-i18n does not run on the server`);
143
- }
144
- }
145
- else if (from === "pinia") {
105
+ if (ctx.plugins.some((p) => p.scriptImport?.(scope, st, from)))
106
+ continue;
107
+ if (from === "vue") {
146
108
  for (const sp of st.specifiers) {
147
- const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
148
- if (name === "storeToRefs")
149
- storeToRefsName = sp.local.name;
150
- else
151
- scope.clientOnly.set(sp.local.name, `\`${name}\` from Pinia does not run on the server`);
109
+ if (sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === "useAttrs")
110
+ useAttrsName = sp.local.name;
152
111
  }
153
112
  }
154
113
  else if (ctx.helperModule !== null && from === ctx.helperModule) {
@@ -172,14 +131,10 @@ export function scopeFor(comp, ast, components) {
172
131
  setupStatement(comp, st);
173
132
  continue;
174
133
  }
175
- // Setup runs on the server too, so a statement there can change what renders: `x.value = ...`
176
- // after a `ref`, an `if`, a loop. None of it is translated, so none of it may exist.
177
134
  if (st.type !== "VariableDeclaration")
178
135
  fail(comp, `\`${st.type}\` in setup is not supported`, st);
179
136
  for (const d of st.declarations) {
180
137
  const init0 = d.init;
181
- // `const { size = "md", label: l } = defineProps<...>()`: each name reads that prop, whose
182
- // default `compileScript` already resolved.
183
138
  if (d.id.type === "ObjectPattern" && definePropsType(init0)) {
184
139
  for (const p of d.id.properties) {
185
140
  if (p.type !== "ObjectProperty" || p.computed)
@@ -192,67 +147,19 @@ export function scopeFor(comp, ast, components) {
192
147
  }
193
148
  continue;
194
149
  }
195
- const calls = (name) => name !== null && init0?.type === "CallExpression" && init0.callee.type === "Identifier" && init0.callee.name === name;
196
- // `const { t, locale } = useI18n()`: \`t\` translates, \`locale\` is the request's locale.
197
- if (d.id.type === "ObjectPattern" && calls(useI18nName)) {
198
- for (const p of d.id.properties) {
199
- if (p.type !== "ObjectProperty" || p.computed || p.value.type !== "Identifier") {
200
- fail(comp, "`useI18n()` is destructured into plain names", p);
201
- }
202
- const key = p.key.name ?? p.key.value;
203
- if (key === "t")
204
- scope.i18nT.add(p.value.name);
205
- else if (key === "locale") {
206
- scope.setup.set(p.value.name, { code: "fv_i18n.locale()", ty: STR });
207
- scope.refs.add(p.value.name);
208
- }
209
- else
210
- scope.clientOnly.set(p.value.name, `\`${key}\` from \`useI18n()\` does not run on the server`);
211
- }
212
- comp.readsI18n = true;
213
- continue;
214
- }
215
- // `const { density, label: l } = storeToRefs(prefs)`: each name is that field of the state.
216
- if (d.id.type === "ObjectPattern" && calls(storeToRefsName)) {
217
- const arg = init0.arguments[0];
218
- const store = arg?.type === "Identifier" ? storeValues.get(arg.name) : undefined;
219
- if (!store || init0.arguments.length !== 1)
220
- fail(comp, "`storeToRefs` takes a store bound in this setup", d);
221
- for (const p of d.id.properties) {
222
- if (p.type !== "ObjectProperty" || p.computed || p.value.type !== "Identifier") {
223
- fail(comp, "`storeToRefs` is destructured into plain names", p);
224
- }
225
- const key = p.key.type === "Identifier" ? p.key.name : p.key.value;
226
- const state = { code: `fv_stores.${store.field}`, ty: { k: "struct", name: store.state, store: true } };
227
- scope.setup.set(p.value.name, storeGetter(scope, state, key, p) ?? fieldVal(comp, state.code, state.ty, key, p));
228
- scope.refs.add(p.value.name);
229
- }
230
- comp.readsStores = true;
150
+ if (ctx.plugins.some((p) => p.scriptBinding?.(scope, d)))
231
151
  continue;
232
- }
233
152
  if (d.id.type !== "Identifier") {
234
- // Destructuring anything else: client-side state the template may not read.
235
153
  for (const name of patternNames(d.id))
236
154
  scope.clientOnly.set(name, "it is destructured from a value the server does not have");
237
155
  continue;
238
156
  }
239
157
  const local = d.id.name;
240
- // `const prefs = usePrefs()`: the store's state, as the server was given it.
241
- const hook = init0?.type === "CallExpression" && init0.callee.type === "Identifier" ? storeHooks.get(init0.callee.name) : undefined;
242
- if (hook) {
243
- if (init0.arguments.length)
244
- fail(comp, "a store hook takes no arguments", d);
245
- storeValues.set(local, hook);
246
- scope.setup.set(local, { code: `fv_stores.${hook.field}`, ty: { k: "struct", name: hook.state, store: true } });
247
- comp.readsStores = true;
248
- continue;
249
- }
250
- // `const route = useRoute()`: the reader's location.
251
- if (useRouteName !== null && init0?.type === "CallExpression" && init0.callee.type === "Identifier" && init0.callee.name === useRouteName) {
252
- scope.setup.set(local, theRoute(scope, d));
158
+ if (useAttrsName !== null && init0?.type === "CallExpression" && init0.callee.type === "Identifier" && init0.callee.name === useAttrsName) {
159
+ scope.attrsBindings.add(local);
160
+ scope.clientOnly.set(local, "`useAttrs()` is bound whole, with `v-bind`; a value read from it has no type");
253
161
  continue;
254
162
  }
255
- // `const model = defineModel<string>()`: the model's prop.
256
163
  const model = comp.models.get(local);
257
164
  if (model !== undefined) {
258
165
  scope.setup.set(local, fieldVal(comp, "props", { k: "struct", name: "Props" }, model, d));
@@ -275,9 +182,6 @@ export function scopeFor(comp, ast, components) {
275
182
  scope.clientOnly.set(local, "it is not a value the server computes: only `ref(…)`, `computed(() => …)` and plain expressions are");
276
183
  continue;
277
184
  }
278
- // Evaluated once, as setup is on the server. A value the server cannot evaluate is client-side
279
- // state — a template ref, an element, a `reactive` object — which the template may still name
280
- // from an event handler; a reference the server needs fails in `expr` with this reason.
281
185
  let v;
282
186
  try {
283
187
  v = expr(scope, source);
@@ -288,25 +192,22 @@ export function scopeFor(comp, ast, components) {
288
192
  scope.clientOnly.set(local, e.message.replace(/^[^:]*: /, ""));
289
193
  continue;
290
194
  }
291
- if (v.ty.k === "undef") {
195
+ if (v.ty.k === "undef" || (v.ty.k === "str" && known(v) !== undefined)) {
292
196
  scope.setup.set(local, v);
293
197
  continue;
294
198
  }
295
199
  const name = `s_${snake(local).replace(/^r#/, "")}`;
296
200
  const lone = v.lone ? { lone: true } : {};
297
- // A computed list is kept as its items, and read again by item.
298
201
  if (v.ty.k === "list" && v.iter !== undefined) {
299
202
  lets.push(`let ${name} = ${collected(v)};`);
300
203
  scope.setup.set(local, heldList(name, v.ty.of, v.lone));
301
204
  continue;
302
205
  }
303
- // An optional string a temporary owns is kept as that `Option<Cow>`.
304
206
  if (v.held !== undefined) {
305
207
  lets.push(`let ${name} = ${v.held};`);
306
208
  scope.setup.set(local, { code: `${name}.as_deref()`, ty: v.ty, ...lone });
307
209
  continue;
308
210
  }
309
- // A list, a record or an object is a place in the props: borrowed, never moved out of them.
310
211
  const place = v.ty.k === "list" || v.ty.k === "record" || v.ty.k === "struct" || v.ty.k === "child";
311
212
  lets.push(`let ${name} = ${place ? `&${operand(v.code, UNARY)}` : bare(v.code)};`);
312
213
  scope.setup.set(local, { code: name, ty: v.ty, ...lone, ...(v.konst !== undefined ? { konst: v.konst } : {}) });
@@ -0,0 +1,13 @@
1
+ import { type Component, type N, type Scope, type Ty, type Val } from "./model.ts";
2
+ import { Emitter } from "./emitter.ts";
3
+ export declare function slotBody(s: Scope, value: N): N[];
4
+ export declare function slotContent(s: Scope, value: N): {
5
+ body: N[];
6
+ param: N;
7
+ };
8
+ export declare function staticallyFilled(body: N[]): boolean;
9
+ export declare function slotTypeName(slotName: string, suffix: string): string;
10
+ export declare function slotFieldBorrows(ty: Ty): boolean;
11
+ export declare function slotFieldTy(ty: Ty, comp: Component): string;
12
+ export declare function slotFieldValue(s: Scope, v: Val, n: N): string;
13
+ export declare function slotOutlet(s: Scope, e: Emitter, c: N): void;
package/dist/slots.js ADDED
@@ -0,0 +1,160 @@
1
+ import { fail, GenError, rustStr, snake } from "./model.js";
2
+ import { expr } from "./expr.js";
3
+ import { atom, bare, operand, strArg, UNARY } from "./parens.js";
4
+ import { Emitter } from "./emitter.js";
5
+ import { rustTy } from "./rust.js";
6
+ import { fieldInit } from "./children.js";
7
+ import { pushesContent, statements } from "./template.js";
8
+ function borrowed(code) {
9
+ return code.startsWith("&") || /^\w+$/.test(code) || /^fv_sp\d+\.\w+$/.test(code) ? code : `&${operand(code, UNARY)}`;
10
+ }
11
+ export function slotBody(s, value) {
12
+ return slotContent(s, value).body;
13
+ }
14
+ export function slotContent(s, value) {
15
+ const fn = value.type === "CallExpression" && value.callee.type === "Identifier" && value.callee.name === "_withCtx"
16
+ ? value.arguments[0]
17
+ : null;
18
+ if (fn?.type !== "ArrowFunctionExpression" || fn.body.type !== "BlockStatement") {
19
+ fail(s.comp, "unexpected slot content", value);
20
+ }
21
+ const branch = fn.body.body.length === 1 ? fn.body.body[0] : null;
22
+ if (branch?.type !== "IfStatement" || branch.test.type !== "Identifier" || branch.test.name !== "_push") {
23
+ fail(s.comp, "unexpected slot content", fn);
24
+ }
25
+ const body = branch.consequent.type === "BlockStatement" ? branch.consequent.body : [branch.consequent];
26
+ return { body, param: fn.params[0] };
27
+ }
28
+ export function staticallyFilled(body) {
29
+ return body.some((st) => st.type === "ExpressionStatement" && st.expression.type === "CallExpression" &&
30
+ st.expression.callee.type === "Identifier" && st.expression.callee.name === "_push" &&
31
+ pushesContent(st.expression.arguments[0]) === true);
32
+ }
33
+ export function slotTypeName(slotName, suffix) {
34
+ return slotName.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("") + suffix;
35
+ }
36
+ export function slotFieldBorrows(ty) {
37
+ const copied = (t) => t.k === "int" || t.k === "float" || t.k === "bool";
38
+ return !(copied(ty) || (ty.k === "opt" && copied(ty.of)));
39
+ }
40
+ export function slotFieldTy(ty, comp) {
41
+ const owned = (t) => rustTy(t, comp).replace(/'a\b/g, "'v");
42
+ switch (ty.k) {
43
+ case "int":
44
+ return "i64";
45
+ case "float":
46
+ return "f64";
47
+ case "bool":
48
+ return "bool";
49
+ case "str":
50
+ return "&'v str";
51
+ case "list":
52
+ return `&'v [${owned(ty.of)}]`;
53
+ case "struct":
54
+ case "child":
55
+ case "html":
56
+ return `&'v ${owned(ty)}`;
57
+ case "opt":
58
+ return `Option<${slotFieldTy(ty.of, comp)}>`;
59
+ default:
60
+ throw new GenError(`no Rust type for a slot prop of type ${JSON.stringify(ty)}`);
61
+ }
62
+ }
63
+ export function slotFieldValue(s, v, n) {
64
+ switch (v.ty.k) {
65
+ case "str":
66
+ return strArg(v.code);
67
+ case "int":
68
+ case "float":
69
+ case "bool":
70
+ return bare(v.code);
71
+ case "list":
72
+ if (v.code.startsWith("["))
73
+ fail(s.comp, "a slot prop is not an array literal: pass a list the component holds", n);
74
+ if (v.iter !== undefined)
75
+ fail(s.comp, "a slot prop is not a computed list: pass a list the component holds", n);
76
+ return borrowed(v.code);
77
+ case "struct":
78
+ case "child":
79
+ case "html":
80
+ return borrowed(v.code);
81
+ case "opt":
82
+ if (v.ty.of.k === "list")
83
+ return `${atom(v.code)}.map(|v| &v[..])`;
84
+ if (v.ty.of.k === "opt" || v.ty.of.k === "undef")
85
+ break;
86
+ return v.code;
87
+ }
88
+ return fail(s.comp, "a slot prop is a string, a number, a boolean, an object or a list", n);
89
+ }
90
+ export function slotOutlet(s, e, c) {
91
+ const [, nameNode, slotProps, fallback] = c.arguments;
92
+ if (nameNode?.type !== "StringLiteral")
93
+ fail(s.comp, "a slot's name is literal", c);
94
+ if (slotProps?.type !== "ObjectExpression")
95
+ fail(s.comp, "a slot's props are attributes or a `v-bind` object literal", c);
96
+ const slotName = nameNode.value;
97
+ const field = `fv_slots.${snake(slotName)}`;
98
+ const outlet = `\`<slot${slotName === "default" ? "" : ` name="${slotName}"`}>\``;
99
+ let fn = "fv::slot_into";
100
+ let args = field;
101
+ if (slotProps.properties.length) {
102
+ const fields = [];
103
+ const values = [];
104
+ for (const p of slotProps.properties) {
105
+ if (p.type !== "ObjectProperty" || p.computed)
106
+ fail(s.comp, "slot props hold plain names", p);
107
+ const key = p.key.name ?? p.key.value;
108
+ if (!/^[A-Za-z_$][\w$]*$/.test(key))
109
+ fail(s.comp, `slot prop \`${key}\` is not a plain name; write it in camelCase`, p);
110
+ const v = expr(s, p.value);
111
+ fields.push({ js: key, rust: snake(key), ty: v.ty });
112
+ values.push(fieldInit(snake(key), slotFieldValue(s, v, p.value)));
113
+ }
114
+ const name = slotTypeName(slotName, "SlotProps");
115
+ const shape = { name, fields, slot: true };
116
+ const had = s.comp.slotShapes.get(slotName);
117
+ if (had && JSON.stringify(had.fields) !== JSON.stringify(fields)) {
118
+ fail(s.comp, `every ${outlet} passes the same props, of the same types`, c);
119
+ }
120
+ if (!had) {
121
+ if (s.comp.structs.has(name) && !s.comp.structs.get(name).slot)
122
+ fail(s.comp, `\`${name}\` names this slot's props; rename the interface`, c);
123
+ s.comp.slotShapes.set(slotName, shape);
124
+ s.comp.structs.set(name, shape);
125
+ }
126
+ fn = "fv::scoped_slot_into";
127
+ args = `${field}, &${name} { ${values.join(", ")} }`;
128
+ }
129
+ else if (s.comp.slotShapes.has(slotName)) {
130
+ fail(s.comp, `every ${outlet} passes the same props, of the same types`, c);
131
+ }
132
+ const id = c.arguments[6];
133
+ let slotted = null;
134
+ if (id?.type === "StringLiteral")
135
+ slotted = rustStr(id.value);
136
+ else if (id?.type === "BinaryExpression" && id.operator === "+" && id.left.type === "StringLiteral" && id.right.type === "Identifier" && id.right.name === "_scopeId") {
137
+ slotted = s.sid === null ? rustStr(id.left.value) : `&[${rustStr(id.left.value)}, ${s.sid}].concat()`;
138
+ }
139
+ else if (id?.type === "Identifier" && id.name === "_scopeId")
140
+ slotted = s.sid;
141
+ else if (id && id.type !== "NullLiteral")
142
+ fail(s.comp, "unexpected slot scope id", id);
143
+ if (s.comp.passesSlotIds) {
144
+ fn = fn === "fv::slot_into" ? "fv::slot_into_slotted" : "fv::scoped_slot_into_slotted";
145
+ args += `, ${slotted ?? '""'}`;
146
+ }
147
+ else if (slotted !== null)
148
+ fail(s.comp, "a slot scope id passed to content that does not take one", c);
149
+ const call = (rest) => (s.fill ? `if ${fn}(out, ${args}, ${rest}) { filled = true; }` : `${fn}(out, ${args}, ${rest});`);
150
+ if (fallback?.type === "NullLiteral" || !fallback) {
151
+ e.stmt(call("None"));
152
+ return;
153
+ }
154
+ if (fallback.type !== "ArrowFunctionExpression" || fallback.body.type !== "BlockStatement") {
155
+ fail(s.comp, "unexpected slot fallback", fallback);
156
+ }
157
+ e.open(`${s.fill ? "if " : ""}${fn}(out, ${args}, Some(&mut |out: &mut String|`);
158
+ statements(s, e, fallback.body.body);
159
+ e.close(s.fill ? ")) { filled = true; }" : "));");
160
+ }
@@ -0,0 +1,13 @@
1
+ import { type Component, type N, type Scope, type Val } from "./model.ts";
2
+ export declare function lonely(what: string): string;
3
+ export declare function meet(comp: Component, a: Val, b: Val, what: string, n: N, how: "equal" | "order" | "search" | "join"): void;
4
+ export declare function loneOf(...vs: (Val | undefined)[]): {
5
+ lone?: true;
6
+ };
7
+ export declare function isTemporary(v: Val): boolean;
8
+ export declare function asCow(v: Val): string;
9
+ export declare function yieldsCow(code: string): boolean;
10
+ export declare function unquote(literal: string): string;
11
+ export declare function stringsEqual(a: Val, b: Val): string;
12
+ export declare function formatted(comp: Component, parts: (string | Val)[], n: N, what: string): Val;
13
+ export declare function stringMethod(s: Scope, target: Val, method: string, args: N[], n: N): Val | null;
@@ -0,0 +1,208 @@
1
+ import { BOOL, fail, INT, opt, rustStr, STR } from "./model.js";
2
+ import { atom, bare, binary, condition, enclosed, receiver, strArg } from "./parens.js";
3
+ import { computed } from "./lists.js";
4
+ import { expr } from "./expr.js";
5
+ import { known } from "./narrowing.js";
6
+ import { asF64, isNumber } from "./numbers.js";
7
+ export function lonely(what) {
8
+ return `${what} with a string that may hold half of a surrogate pair (cut by \`slice\`, \`substring\`, \`at\`, \`charAt\`, \`split("")\` and the like): JavaScript keeps the half, which can match, order or join with another where ferrovue holds U+FFFD`;
9
+ }
10
+ export function meet(comp, a, b, what, n, how) {
11
+ const literal = (v) => v.code.startsWith('"');
12
+ const fffd = (v) => literal(v) && v.code.includes("\u{FFFD}");
13
+ const low = (v) => literal(v) && !/[\u{D800}-\u{10FFFF}]/u.test(v.code);
14
+ const refused = how === "order" ? (a.lone && !low(b)) || (b.lone && !low(a))
15
+ : how === "join" ? a.lone && b.lone
16
+ : how === "search" ? b.lone || (a.lone && fffd(b))
17
+ : (a.lone && b.lone) || (a.lone && fffd(b)) || (b.lone && fffd(a));
18
+ if (refused)
19
+ fail(comp, lonely(what), n);
20
+ }
21
+ export function loneOf(...vs) {
22
+ return vs.some((v) => v?.lone) ? { lone: true } : {};
23
+ }
24
+ function nonEmptyLiteral(v) {
25
+ return /^"[^"]/.test(v.code);
26
+ }
27
+ export function isTemporary(v) {
28
+ if (v.ty.k !== "str")
29
+ return false;
30
+ const trimmed = /^fv::js_trim(?:_start|_end)?\(([^]*)\)$/.exec(v.code);
31
+ if (trimmed?.[1] !== undefined)
32
+ return isTemporary({ code: trimmed[1].replace(/^&(?!\*)/, "&*"), ty: v.ty });
33
+ return /^&\*(?!\**[\w.]+$)/.test(v.code) || /\.unwrap_or\(&\*(?!\**[\w.]+\)$)/.test(v.code);
34
+ }
35
+ export function asCow(v) {
36
+ if (!isTemporary(v))
37
+ return `std::borrow::Cow::<str>::Borrowed(${bare(v.code)})`;
38
+ const built = /^&\*(.*)$/s.exec(v.code)?.[1];
39
+ if (built === undefined)
40
+ return `std::borrow::Cow::<str>::Owned(${atom(v.code)}.to_owned())`;
41
+ if (enclosed(built))
42
+ return bare(built);
43
+ if (yieldsCow(built))
44
+ return built.startsWith("fv::") ? `std::borrow::Cow::<str>::Owned(${built}.into_owned())` : bare(built);
45
+ if (/^fv::js_(?:char_at|trim|trim_start|trim_end)\(/.test(built))
46
+ return `std::borrow::Cow::<str>::Owned(${built}.to_owned())`;
47
+ return `std::borrow::Cow::<str>::Owned(${built})`;
48
+ }
49
+ export function yieldsCow(code) {
50
+ const routine = /^fv::js_(?:slice|substring|replace|replace_all|pad_start|pad_end)(?=\()/.exec(code);
51
+ if (routine !== null)
52
+ return enclosed(code.slice(routine[0].length));
53
+ const fallback = code.lastIndexOf(".unwrap_or(std::borrow::Cow::<str>::");
54
+ return fallback >= 0 && enclosed(code.slice(fallback + ".unwrap_or".length));
55
+ }
56
+ export function unquote(literal) {
57
+ const escapes = { n: "\n", r: "\r", t: "\t" };
58
+ return literal.slice(1, -1).replace(/\\(?:u\{([0-9a-f]+)\}|(.))/g, (_, hex, c) => (hex ? String.fromCodePoint(parseInt(hex, 16)) : (escapes[c] ?? c)));
59
+ }
60
+ export function stringsEqual(a, b) {
61
+ if (known(a) !== undefined && known(b) !== undefined)
62
+ return String(unquote(a.code) === unquote(b.code));
63
+ if (a.code === '""' || b.code === '""')
64
+ return `${receiver(a.code === '""' ? b.code : a.code)}.is_empty()`;
65
+ return binary(a.code, "==", b.code);
66
+ }
67
+ export function formatted(comp, parts, n, what) {
68
+ let fmt = "";
69
+ const args = [];
70
+ let before;
71
+ const lone = loneOf(...parts.map((p) => (typeof p === "string" ? undefined : p)));
72
+ for (const part of parts) {
73
+ if (typeof part === "string" || (part.ty.k === "str" && known(part) !== undefined)) {
74
+ const text = typeof part === "string" ? part : unquote(part.code);
75
+ if (text !== "")
76
+ before = undefined;
77
+ fmt += text.replace(/[{}]/g, (c) => c + c);
78
+ continue;
79
+ }
80
+ if (before)
81
+ meet(comp, before, part, what, n, "join");
82
+ before = part;
83
+ if (part.format) {
84
+ fmt += part.format.text;
85
+ args.push(...part.format.args);
86
+ continue;
87
+ }
88
+ if (part.ty.k === "str")
89
+ args.push(receiver(part.code).replace(/^(fv::Js\(.*\))\.to_string\(\)$/s, "$1"));
90
+ else if (isNumber(part.ty))
91
+ args.push(`fv::Js(${bare(part.code)})`);
92
+ else if (part.ty.k === "bool")
93
+ args.push(`if ${condition(part.code)} { "true" } else { "false" }`);
94
+ else
95
+ fail(comp, "a template literal interpolates strings, numbers and booleans that are present", n);
96
+ fmt += "{}";
97
+ }
98
+ if (!args.length)
99
+ return { code: rustStr(fmt.replace(/\{\{|\}\}/g, (c) => c[0])), ty: STR };
100
+ const one = parts.filter((p) => (typeof p === "string" ? p !== "" : p.code !== '""'));
101
+ if (fmt === "{}" && one.length === 1 && typeof one[0] !== "string" && one[0].ty.k === "str")
102
+ return one[0];
103
+ return { code: `&*format!(${rustStr(fmt)}, ${args.join(", ")})`, ty: STR, format: { text: fmt, args }, ...lone };
104
+ }
105
+ export function stringMethod(s, target, method, args, n) {
106
+ const comp = s.comp;
107
+ const t = strArg(target.code);
108
+ const arity = (least, most) => {
109
+ if (args.length < least || args.length > most) {
110
+ fail(comp, `\`.${method}()\` takes ${least === most ? least : `${least} to ${most}`} argument${most === 1 ? "" : "s"} here`, n);
111
+ }
112
+ };
113
+ const str = (i) => {
114
+ if (args[i]?.type === "RegExpLiteral")
115
+ fail(comp, `\`.${method}()\` with a regular expression, which the server does not run: give it a string`, args[i]);
116
+ const v = expr(s, args[i]);
117
+ if (v.ty.k !== "str")
118
+ fail(comp, `\`.${method}()\` takes a string that is present`, args[i]);
119
+ return v;
120
+ };
121
+ const num = (i, fallback) => {
122
+ if (args[i] === undefined)
123
+ return fallback;
124
+ const v = expr(s, args[i]);
125
+ if (v.ty.k === "undef")
126
+ return fallback;
127
+ if (!isNumber(v.ty))
128
+ fail(comp, `\`.${method}()\` takes a number that is present`, args[i]);
129
+ return asF64(v);
130
+ };
131
+ const cut = { lone: true };
132
+ switch (method) {
133
+ case "includes":
134
+ case "startsWith":
135
+ case "endsWith": {
136
+ arity(1, 1);
137
+ const x = str(0);
138
+ meet(comp, target, x, `\`.${method}()\``, n, "search");
139
+ const rust = { includes: "contains", startsWith: "starts_with", endsWith: "ends_with" }[method];
140
+ return { code: `${receiver(target.code)}.${rust}(${bare(x.code)})`, ty: BOOL };
141
+ }
142
+ case "indexOf":
143
+ case "lastIndexOf": {
144
+ arity(1, 1);
145
+ const x = str(0);
146
+ meet(comp, target, x, `\`.${method}()\``, n, "search");
147
+ return { code: `fv::js_${method === "indexOf" ? "index_of" : "last_index_of"}(${t}, ${strArg(x.code)})`, ty: INT };
148
+ }
149
+ case "slice":
150
+ case "substring": {
151
+ arity(0, 2);
152
+ const end = num(1, "");
153
+ return { code: `&*fv::js_${method}(${t}, ${num(0, "0.0")}, ${end ? `Some(${end})` : "None"})`, ty: STR, ...cut };
154
+ }
155
+ case "at": {
156
+ arity(1, 1);
157
+ const at = `fv::js_at(${t}, ${num(0, "0.0")})`;
158
+ if (!isTemporary(target))
159
+ return { code: at, ty: opt(STR), ...cut };
160
+ const held = `${at}.map(|v| std::borrow::Cow::<str>::Owned(v.to_owned()))`;
161
+ return { code: `${atom(held)}.as_deref()`, ty: opt(STR), held, ...cut };
162
+ }
163
+ case "charAt":
164
+ arity(0, 1);
165
+ return { code: `${isTemporary(target) ? "&*" : ""}fv::js_char_at(${t}, ${num(0, "0.0")})`, ty: STR, ...cut };
166
+ case "split": {
167
+ arity(1, 1);
168
+ const sep = str(0);
169
+ meet(comp, target, sep, "`.split()`", n, "search");
170
+ return computed(`fv::js_split(${t}, ${strArg(sep.code)}).into_iter()`, STR, target.lone || !nonEmptyLiteral(sep));
171
+ }
172
+ case "replace":
173
+ case "replaceAll": {
174
+ arity(2, 2);
175
+ if (args[1].type === "ArrowFunctionExpression" || args[1].type === "FunctionExpression") {
176
+ fail(comp, `\`.${method}()\` with a function, which the server does not run: give it a string`, args[1]);
177
+ }
178
+ const [pattern, replacement] = [str(0), str(1)];
179
+ meet(comp, target, pattern, `\`.${method}()\``, n, "search");
180
+ meet(comp, target, replacement, `\`.${method}()\``, n, "join");
181
+ const lone = target.lone || replacement.lone || (method === "replaceAll" && !nonEmptyLiteral(pattern));
182
+ return { code: `&*fv::js_${method === "replace" ? "replace" : "replace_all"}(${t}, ${strArg(pattern.code)}, ${strArg(replacement.code)})`, ty: STR, ...(lone ? cut : {}) };
183
+ }
184
+ case "padStart":
185
+ case "padEnd": {
186
+ arity(1, 2);
187
+ const fill = args.length > 1 ? str(1) : { code: '" "', ty: STR };
188
+ const whole = fill.code.startsWith('"') && !/[\u{10000}-\u{10FFFF}]/u.test(fill.code);
189
+ if (fill.lone || (method === "padStart" && target.lone && !whole))
190
+ fail(comp, lonely(`\`.${method}()\``), n);
191
+ return { code: `&*fv::js_${method === "padStart" ? "pad_start" : "pad_end"}(${t}, ${num(0, "0.0")}, ${strArg(fill.code)})`, ty: STR, ...(target.lone || !whole ? cut : {}) };
192
+ }
193
+ case "repeat": {
194
+ arity(1, 1);
195
+ const c = args[0];
196
+ if ((c.type === "UnaryExpression" && c.operator === "-" && c.argument.type === "NumericLiteral" && c.argument.value > 0) || (c.type === "Identifier" && c.name === "Infinity")) {
197
+ fail(comp, "`.repeat()` with a negative or infinite count, which throws a `RangeError`", c);
198
+ }
199
+ if (target.lone)
200
+ fail(comp, lonely("`.repeat()`"), n);
201
+ return { code: `&*fv::js_repeat(${t}, ${num(0, "0.0")})`, ty: STR };
202
+ }
203
+ case "toLocaleUpperCase":
204
+ case "toLocaleLowerCase":
205
+ return fail(comp, `\`.${method}()\` maps case by the locale the server runs in, which the browser need not share: use \`.${method.replace("Locale", "")}()\``, n);
206
+ }
207
+ return null;
208
+ }