ferrovue 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -20
- package/dist/attrs.d.ts +51 -0
- package/dist/attrs.js +488 -0
- package/dist/cli.js +68 -10
- package/dist/client.d.ts +27 -0
- package/dist/client.js +44 -0
- package/dist/compiler.d.ts +16 -53
- package/dist/compiler.js +87 -3178
- package/dist/component.d.ts +6 -0
- package/dist/component.js +251 -0
- package/dist/context.d.ts +151 -0
- package/dist/context.js +84 -0
- package/dist/emitter.d.ts +23 -0
- package/dist/emitter.js +58 -0
- package/dist/expr.d.ts +76 -0
- package/dist/expr.js +1267 -0
- package/dist/fixture.d.ts +38 -0
- package/dist/fixture.js +78 -0
- package/dist/i18n.d.ts +27 -0
- package/dist/i18n.js +175 -0
- package/dist/lists.d.ts +19 -0
- package/dist/lists.js +253 -0
- package/dist/model.d.ts +248 -0
- package/dist/model.js +102 -0
- package/dist/parens.d.ts +51 -0
- package/dist/parens.js +239 -0
- package/dist/router.d.ts +17 -0
- package/dist/router.js +272 -0
- package/dist/rust.d.ts +14 -0
- package/dist/rust.js +496 -0
- package/dist/scoped.d.ts +10 -0
- package/dist/scoped.js +90 -0
- package/dist/script.d.ts +16 -0
- package/dist/script.js +316 -0
- package/dist/stores.d.ts +6 -0
- package/dist/stores.js +227 -0
- package/dist/template.d.ts +45 -0
- package/dist/template.js +939 -0
- package/dist/testing.d.ts +2 -22
- package/dist/testing.js +11 -55
- package/dist/types.d.ts +1 -0
- package/dist/typescript.d.ts +31 -0
- package/dist/typescript.js +310 -0
- package/dist/vite.d.ts +23 -0
- package/dist/vite.js +114 -0
- package/package.json +26 -8
package/dist/rust.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/* The Rust source written for each component, and the modules beside them. */
|
|
2
|
+
import { parse as parseJs } from "@babel/parser";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { fail, GenError, rustStr, snake, tagAst } from "./model.js";
|
|
5
|
+
import { allRoutes, ctx } from "./context.js";
|
|
6
|
+
import { lookupStruct } from "./typescript.js";
|
|
7
|
+
import { childOf } from "./expr.js";
|
|
8
|
+
import { Emitter } from "./emitter.js";
|
|
9
|
+
import { extraParams, fieldInit, slotFieldBorrows, slotFieldTy, slotTypeName, statements, takesSlots } from "./template.js";
|
|
10
|
+
import { storeHome } from "./stores.js";
|
|
11
|
+
import { scopeFor } from "./script.js";
|
|
12
|
+
export function needsLifetime(ty, comp, seen = new Set()) {
|
|
13
|
+
switch (ty.k) {
|
|
14
|
+
// A record's keys are strings.
|
|
15
|
+
case "str":
|
|
16
|
+
case "record":
|
|
17
|
+
return true;
|
|
18
|
+
case "opt":
|
|
19
|
+
case "list":
|
|
20
|
+
return needsLifetime(ty.of, comp, seen);
|
|
21
|
+
case "struct": {
|
|
22
|
+
// A struct that holds itself borrows only if something else in it does.
|
|
23
|
+
if (seen.has(ty.name))
|
|
24
|
+
return false;
|
|
25
|
+
const { st, owner } = lookupStruct(comp, ty);
|
|
26
|
+
const inner = new Set(seen).add(ty.name);
|
|
27
|
+
return !!st && st.fields.some((f) => needsLifetime(f.ty, owner, inner));
|
|
28
|
+
}
|
|
29
|
+
case "child": {
|
|
30
|
+
const child = childOf(ty.name);
|
|
31
|
+
return structLifetime(child.props, child);
|
|
32
|
+
}
|
|
33
|
+
// A configured type that borrows says so by naming the props' lifetime: `Trusted<'a>`.
|
|
34
|
+
case "html":
|
|
35
|
+
return ctx.trustedHtml?.includes("'a") ?? false;
|
|
36
|
+
default:
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function structLifetime(st, comp) {
|
|
41
|
+
return st.fields.some((f) => needsLifetime(f.ty, comp, new Set([st.name])));
|
|
42
|
+
}
|
|
43
|
+
export function rustTy(ty, comp) {
|
|
44
|
+
switch (ty.k) {
|
|
45
|
+
case "str":
|
|
46
|
+
return "Cow<'a, str>";
|
|
47
|
+
case "int":
|
|
48
|
+
return "i64";
|
|
49
|
+
case "float":
|
|
50
|
+
return "f64";
|
|
51
|
+
case "bool":
|
|
52
|
+
return "bool";
|
|
53
|
+
case "opt":
|
|
54
|
+
return `Option<${rustTy(ty.of, comp)}>`;
|
|
55
|
+
case "list":
|
|
56
|
+
return `Vec<${rustTy(ty.of, comp)}>`;
|
|
57
|
+
case "record":
|
|
58
|
+
return `ferrovue::Record<'a, ${rustTy(ty.of, comp)}>`;
|
|
59
|
+
case "struct": {
|
|
60
|
+
const { st, owner, path } = lookupStruct(comp, ty);
|
|
61
|
+
if (!st)
|
|
62
|
+
throw new GenError(`no type \`${ty.name}\``);
|
|
63
|
+
return `${path}${ty.name}${structLifetime(st, owner) ? "<'a>" : ""}`;
|
|
64
|
+
}
|
|
65
|
+
case "html":
|
|
66
|
+
return ctx.trustedHtml;
|
|
67
|
+
case "child": {
|
|
68
|
+
const child = childOf(ty.name);
|
|
69
|
+
return `super::${child.module}::Props${structLifetime(child.props, child) ? "<'a>" : ""}`;
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
throw new GenError("no Rust type for `undefined`");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A constructor parameter for a field of type \`ty\`, and how its argument becomes the field's
|
|
76
|
+
* value: strings from anything that turns into a \`Cow\`, lists of them from any iterator. */
|
|
77
|
+
function param(ty, comp, arg) {
|
|
78
|
+
if (ty.k === "str")
|
|
79
|
+
return { ty: "impl Into<Cow<'a, str>>", value: `${arg}.into()` };
|
|
80
|
+
if (ty.k === "list" && ty.of.k === "str") {
|
|
81
|
+
return { ty: "impl IntoIterator<Item = impl Into<Cow<'a, str>>>", value: `${arg}.into_iter().map(Into::into).collect()` };
|
|
82
|
+
}
|
|
83
|
+
return { ty: rustTy(ty, comp), value: arg };
|
|
84
|
+
}
|
|
85
|
+
/** \`new\` with every required field, and a chainable setter for each optional one, so building
|
|
86
|
+
* props from Rust takes neither \`Cow\` nor \`None\`. */
|
|
87
|
+
function builderSource(st, comp, life) {
|
|
88
|
+
const required = st.fields.filter((f) => f.ty.k !== "opt");
|
|
89
|
+
const optional = st.fields.filter((f) => f.ty.k === "opt");
|
|
90
|
+
const arg = (f) => f.rust.replace(/^r#/, "");
|
|
91
|
+
const params = required.map((f) => `${arg(f)}: ${param(f.ty, comp, arg(f)).ty}`).join(", ");
|
|
92
|
+
const inits = st.fields
|
|
93
|
+
.map((f) => (f.ty.k === "opt" ? `${f.rust}: None` : fieldInit(f.rust, param(f.ty, comp, arg(f)).value)))
|
|
94
|
+
.join(", ");
|
|
95
|
+
const setters = optional
|
|
96
|
+
.filter((f) => f.rust !== "new")
|
|
97
|
+
.map((f) => {
|
|
98
|
+
const p = param(f.ty.of, comp, arg(f));
|
|
99
|
+
return `
|
|
100
|
+
/// Set \`${f.js}\`, which is absent otherwise.
|
|
101
|
+
pub fn ${f.rust}(mut self, ${arg(f)}: ${p.ty}) -> Self {
|
|
102
|
+
self.${f.rust} = Some(${p.value});
|
|
103
|
+
self
|
|
104
|
+
}`;
|
|
105
|
+
})
|
|
106
|
+
.join("\n");
|
|
107
|
+
const usesA = /'a/.test(params + setters);
|
|
108
|
+
// A struct without strings has no lifetime for its constructor to name.
|
|
109
|
+
const impl = life ? "impl<'a>" : usesA ? "impl<'a>" : "impl";
|
|
110
|
+
return `${impl} ${st.name}${life} {
|
|
111
|
+
/// ${st.name} with ${required.length ? "its required fields" : "nothing set"}${optional.length ? ", every optional one absent" : ""}.
|
|
112
|
+
${required.length > 7 ? " // One argument per required field, however many the type declares.\n #[allow(clippy::too_many_arguments)]\n" : ""} pub fn new(${params}) -> Self {
|
|
113
|
+
${st.name} { ${inits} }
|
|
114
|
+
}
|
|
115
|
+
${setters}
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
export function structSource(st, comp, doc) {
|
|
120
|
+
const life = structLifetime(st, comp) ? "<'a>" : "";
|
|
121
|
+
const fields = st.fields
|
|
122
|
+
.map((f) => {
|
|
123
|
+
const attrs = f.ty.k === "opt"
|
|
124
|
+
? ` #[serde(rename = ${rustStr(f.js)}, default, skip_serializing_if = "Option::is_none")]`
|
|
125
|
+
: ` #[serde(rename = ${rustStr(f.js)})]`;
|
|
126
|
+
return `${attrs}\n pub ${f.rust}: ${rustTy(f.ty, comp)},`;
|
|
127
|
+
})
|
|
128
|
+
.join("\n");
|
|
129
|
+
/* `Deserialize` only for the conformance suite. Props go out as JSON and never come back in, and a
|
|
130
|
+
* `Deserialize` in production would be a way to make a `TrustedHtml` value out of any string. */
|
|
131
|
+
return `${doc}#[derive(Debug, Clone, serde::Serialize)]
|
|
132
|
+
#[cfg_attr(test, derive(serde::Deserialize))]
|
|
133
|
+
pub struct ${st.name}${life} {
|
|
134
|
+
${fields}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
${builderSource(st, comp, life)}
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
export function header(source, edit = "the `.vue` file") {
|
|
141
|
+
return `// @generated by ferrovue from ${source}. Do not edit: change ${edit} and run
|
|
142
|
+
// \`ferrovue\`.
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
/** Rust expressions summing the lengths of the strings a value of type `ty` at `place` holds. */
|
|
146
|
+
export function textLen(comp, place, ty, seen = new Set()) {
|
|
147
|
+
switch (ty.k) {
|
|
148
|
+
case "str":
|
|
149
|
+
return [`${place}.len()`];
|
|
150
|
+
case "html":
|
|
151
|
+
return [`fv::TrustedHtml::trusted_html(&${place}).len()`];
|
|
152
|
+
case "opt":
|
|
153
|
+
if (ty.of.k === "str")
|
|
154
|
+
return [`${place}.as_deref().map_or(0, str::len)`];
|
|
155
|
+
if (ty.of.k === "html")
|
|
156
|
+
return [`${place}.as_ref().map_or(0, |v| fv::TrustedHtml::trusted_html(v).len())`];
|
|
157
|
+
if (ty.of.k === "struct") {
|
|
158
|
+
const inner = textLen(comp, "v", ty.of, seen);
|
|
159
|
+
return inner.length ? [`${place}.as_ref().map_or(0, |v| ${inner.join(" + ")})`] : [];
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
case "struct": {
|
|
163
|
+
// A recursive structure is counted to its first level: the reservation is an estimate.
|
|
164
|
+
if (seen.has(ty.name))
|
|
165
|
+
return [];
|
|
166
|
+
const { st, owner } = lookupStruct(comp, ty);
|
|
167
|
+
const inner = new Set(seen).add(ty.name);
|
|
168
|
+
return (st?.fields ?? []).flatMap((f) => textLen(owner, `${place}.${f.rust}`, f.ty, inner));
|
|
169
|
+
}
|
|
170
|
+
case "list": {
|
|
171
|
+
const inner = textLen(comp, "v", ty.of, seen);
|
|
172
|
+
return inner.length ? [`${place}.iter().map(|v| ${inner.join(" + ")}).sum::<usize>()`] : [];
|
|
173
|
+
}
|
|
174
|
+
case "record": {
|
|
175
|
+
const inner = textLen(comp, "v", ty.of, seen);
|
|
176
|
+
return [`${place}.iter().map(|(k, ${inner.length ? "v" : "_"})| ${["k.len()", ...inner].join(" + ")}).sum::<usize>()`];
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** `render`, and for a component a parent may hand scope ids to, `render_scoped`, which takes them
|
|
183
|
+
* last, as `ssrRenderAttrs` writes them onto its root; `render` hands it none. */
|
|
184
|
+
function renderSource(comp, life, args, e) {
|
|
185
|
+
const doc = "/// Write the component's server render into `out`.\n";
|
|
186
|
+
// Props or ids the render never reads, as when a component only passes its slot on.
|
|
187
|
+
const props = e.reads("props", 0) ? "props" : "_props";
|
|
188
|
+
if (!comp.inherits)
|
|
189
|
+
return `${doc}pub fn render(out: &mut String, ${props}: &Props${life}${extraParams(comp)}) {\n${e.lines.join("\n")}\n}`;
|
|
190
|
+
// A root that is a fragment, or a `<Teleport>`, takes no ids.
|
|
191
|
+
const attrs = e.reads("fv_attrs", 0) ? "fv_attrs" : "_fv_attrs";
|
|
192
|
+
return `${doc}pub fn render(out: &mut String, props: &Props${life}${extraParams(comp)}) {
|
|
193
|
+
render_scoped(out, props${args}, "");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// [\`render\`], with the scope ids a parent hands the root: \` data-v-…\` each.
|
|
197
|
+
#[doc(hidden)]
|
|
198
|
+
pub fn render_scoped(out: &mut String, ${props}: &Props${life}${extraParams(comp)}, ${attrs}: &str) {
|
|
199
|
+
${e.lines.join("\n")}
|
|
200
|
+
}`;
|
|
201
|
+
}
|
|
202
|
+
export function componentSource(comp, ast, ssr, components) {
|
|
203
|
+
const { scope, lets } = scopeFor(comp, ast, components);
|
|
204
|
+
const program = parseJs(ssr, { sourceType: "module" }).program;
|
|
205
|
+
tagAst(program, "template");
|
|
206
|
+
const fn = program.body.find((s) => s.type === "ExportNamedDeclaration" && s.declaration?.id?.name === "ssrRender")?.declaration;
|
|
207
|
+
if (!fn)
|
|
208
|
+
fail(comp, "the compiled template has no `ssrRender`");
|
|
209
|
+
const e = new Emitter();
|
|
210
|
+
for (const l of lets)
|
|
211
|
+
e.stmt(l);
|
|
212
|
+
statements(scope, e, fn.body.body);
|
|
213
|
+
// A setup value the render never reads is not computed. Checked from the last up, since one may
|
|
214
|
+
// read another declared before it.
|
|
215
|
+
for (let i = lets.length - 1; i >= 0; i--) {
|
|
216
|
+
const name = /^let (\w+)/.exec(lets[i])[1];
|
|
217
|
+
if (!e.reads(name, i + 1))
|
|
218
|
+
e.lines.splice(i, 1);
|
|
219
|
+
}
|
|
220
|
+
e.flush();
|
|
221
|
+
/* The literal markup, a loop's once per item, what the helpers can write, and every string the
|
|
222
|
+
* props hold. Literals in untaken branches overcount and escaping or a string used twice
|
|
223
|
+
* undercounts, so it is an estimate — close enough that the buffer is sized once, where
|
|
224
|
+
* reserving the literals alone leaves it to grow again at the first long label. */
|
|
225
|
+
const text = textLen(comp, "props", { k: "struct", name: "Props" });
|
|
226
|
+
const fixed = e.literalBytes + scope.helperBytes.n;
|
|
227
|
+
const reserve = [...(fixed || !(e.perItem.length + text.length) ? [String(fixed)] : []), ...e.perItem, ...text];
|
|
228
|
+
e.lines.unshift(` out.reserve(${reserve.join(" + ")});`);
|
|
229
|
+
const life = structLifetime(comp.props, comp) ? "<'_>" : "";
|
|
230
|
+
const gen = life ? "<'p, 'a>" : "<'p>";
|
|
231
|
+
const named = life ? "<'a>" : "";
|
|
232
|
+
// An `interface Props` that `defineProps` takes is the props struct itself, written once below.
|
|
233
|
+
const structs = [...comp.structs.values()]
|
|
234
|
+
.filter((st) => st.name !== "Props" && !st.slot)
|
|
235
|
+
.map((st) => structSource(st, comp, `/// \`${st.name}\` in \`${basename(comp.file)}\`.\n`))
|
|
236
|
+
.join("\n");
|
|
237
|
+
// `Cow` is imported only where a field is one: a lifetime that comes from another component's props
|
|
238
|
+
// alone borrows through that type, not through a `Cow` written here.
|
|
239
|
+
const usesCow = /Cow</.test(structs + structSource(comp.props, comp, ""));
|
|
240
|
+
const plain = !takesSlots(comp) && !comp.usesRoute && !comp.usesStores && !comp.usesI18n && !comp.usesTeleports;
|
|
241
|
+
const slotFields = [
|
|
242
|
+
...comp.slotNames.map((n) => {
|
|
243
|
+
const outlet = `\`<slot${n === "default" ? "" : ` name="${n}"`}>\``;
|
|
244
|
+
const type = comp.slotShapes.has(n) ? `&'s ${slotTypeName(n, "Slot")}<'s>` : "fv::Slot<'s>";
|
|
245
|
+
return ` /// ${outlet}\n pub ${snake(n)}: Option<${type}>,`;
|
|
246
|
+
}),
|
|
247
|
+
...(comp.routerView ? [" /// The page `<RouterView>` shows.\n pub router_view: fv::Slot<'s>,"] : []),
|
|
248
|
+
];
|
|
249
|
+
const slotTypes = [...comp.slotShapes.entries()]
|
|
250
|
+
.map(([n, shape]) => {
|
|
251
|
+
const outlet = `\`<slot${n === "default" ? "" : ` name="${n}"`}>\``;
|
|
252
|
+
const borrows = shape.fields.some((f) => slotFieldBorrows(f.ty));
|
|
253
|
+
const fields = shape.fields.map((f) => ` pub ${f.rust}: ${slotFieldTy(f.ty, comp)},`).join("\n");
|
|
254
|
+
const props = `${shape.name}${borrows ? "<'v>" : ""}`;
|
|
255
|
+
return `/// The props ${outlet} passes the content a parent gives it, borrowed for the render.
|
|
256
|
+
pub struct ${props} {
|
|
257
|
+
${fields}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// A parent's content for ${outlet}, given its props${comp.passesSlotIds ? " and the slot scope id to write onto its elements" : ""}: returns whether it wrote anything but comments.
|
|
261
|
+
pub type ${slotTypeName(n, "Slot")}<'s> = dyn ${borrows ? "for<'v> " : ""}Fn(&mut String, &${props}${comp.passesSlotIds ? ", &str" : ""}) -> bool + 's;
|
|
262
|
+
|
|
263
|
+
`;
|
|
264
|
+
})
|
|
265
|
+
.join("");
|
|
266
|
+
const slotsStruct = takesSlots(comp)
|
|
267
|
+
? `${slotTypes}/// What a parent puts in the slots \`${basename(comp.file)}\` renders.
|
|
268
|
+
#[derive(Clone, Copy${comp.routerView ? "" : ", Default"})]
|
|
269
|
+
pub struct Slots<'s> {
|
|
270
|
+
${slotFields.join("\n")}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
`
|
|
274
|
+
: "";
|
|
275
|
+
const args = (takesSlots(comp) ? ", fv_slots" : "") + (comp.usesRoute ? ", fv_route" : "") + (comp.usesStores ? ", fv_stores" : "") + (comp.usesI18n ? ", fv_i18n" : "") + (comp.usesTeleports ? ", fv_teleports" : "");
|
|
276
|
+
const params = (takesSlots(comp) ? ", fv_slots: Slots<'p>" : "") +
|
|
277
|
+
(comp.usesRoute ? ", fv_route: &'p fv::Route<'p>" : "") +
|
|
278
|
+
(comp.usesStores ? ", fv_stores: &'p super::stores::Stores<'p>" : "") +
|
|
279
|
+
(comp.usesI18n ? ", fv_i18n: &'p fv::I18n" : "") +
|
|
280
|
+
(comp.usesTeleports ? ", fv_teleports: &'p fv::Teleports" : "");
|
|
281
|
+
const wrappers = plain
|
|
282
|
+
? `/// The component's markup, for a maud page that shows it without hydrating it.
|
|
283
|
+
pub fn html${gen}(props: &'p Props${named}) -> fv::Html<'p, Props${named}> {
|
|
284
|
+
fv::Html::markup(props, render)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// The component as an island the client hydrates.
|
|
288
|
+
pub fn island${gen}(props: &'p Props${named}) -> fv::Html<'p, Props${named}> {
|
|
289
|
+
fv::Html::island(NAME, props, render)
|
|
290
|
+
}
|
|
291
|
+
`
|
|
292
|
+
: `/// The component's markup, for a maud page that shows it.
|
|
293
|
+
pub fn html${gen}(props: &'p Props${named}${params}) -> fv::Html<'p, Props${named}, impl Fn(&mut String, &Props${named}) + 'p> {
|
|
294
|
+
fv::Html::markup(props, move |out: &mut String, props: &Props${named}| render(out, props${args}))
|
|
295
|
+
}
|
|
296
|
+
`;
|
|
297
|
+
return `${header(comp.file)}
|
|
298
|
+
${usesCow ? "use std::borrow::Cow;\n\n" : ""}use ferrovue as fv;
|
|
299
|
+
|
|
300
|
+
/// The component's name, as \`data-island\` carries it.
|
|
301
|
+
pub const NAME: &str = ${rustStr(comp.name)};
|
|
302
|
+
|
|
303
|
+
${structs ? structs + "\n" : ""}${structSource(comp.props, comp, `/// The props \`${basename(comp.file)}\` declares.\n`)}
|
|
304
|
+
${slotsStruct}${renderSource(comp, life, args, e)}
|
|
305
|
+
|
|
306
|
+
${wrappers}`;
|
|
307
|
+
}
|
|
308
|
+
export function modSource(comps) {
|
|
309
|
+
const arms = comps
|
|
310
|
+
.map((c) => {
|
|
311
|
+
const lines = [`let props: ${c.module}::Props = serde_json::from_str(json).map_err(|e| e.to_string())?;`];
|
|
312
|
+
let args = "";
|
|
313
|
+
if (takesSlots(c) || c.usesRoute || c.usesStores || c.usesI18n)
|
|
314
|
+
lines.push("let fixture: Fixture = serde_json::from_str(json).map_err(|e| e.to_string())?;");
|
|
315
|
+
if (takesSlots(c)) {
|
|
316
|
+
const names = [...c.slotNames, ...(c.routerView ? ["routerView"] : [])];
|
|
317
|
+
for (const n of names) {
|
|
318
|
+
const local = `s_${snake(n).replace(/^r#/, "")}`;
|
|
319
|
+
const shape = c.slotShapes.get(n);
|
|
320
|
+
if (shape) {
|
|
321
|
+
// A fixture's content for a scoped slot is static: it is given the props and ignores them.
|
|
322
|
+
const life = shape.fields.some((f) => slotFieldBorrows(f.ty)) ? "<'_>" : "";
|
|
323
|
+
lines.push(`let ${local} = |out: &mut String, _: &${c.module}::${shape.name}${life}${c.passesSlotIds ? ", _: &str" : ""}| -> bool { out.push_str(fixture.slot(${rustStr(n)}).unwrap_or_default()); true };`);
|
|
324
|
+
}
|
|
325
|
+
else
|
|
326
|
+
lines.push(`let ${local} = |out: &mut String| out.push_str(fixture.slot(${rustStr(n)}).unwrap_or_default());`);
|
|
327
|
+
}
|
|
328
|
+
const fields = c.slotNames.map((n) => {
|
|
329
|
+
const local = `s_${snake(n).replace(/^r#/, "")}`;
|
|
330
|
+
const value = c.slotShapes.has(n) ? `&${local} as &${c.module}::${slotTypeName(n, "Slot")}` : `ferrovue::Slot::new(&${local})`;
|
|
331
|
+
return `${snake(n)}: fixture.slot(${rustStr(n)}).map(|_| ${value})`;
|
|
332
|
+
});
|
|
333
|
+
if (c.routerView)
|
|
334
|
+
fields.push("router_view: ferrovue::Slot::new(&s_router_view)");
|
|
335
|
+
args += `, ${c.module}::Slots { ${fields.join(", ")} }`;
|
|
336
|
+
}
|
|
337
|
+
if (c.usesRoute) {
|
|
338
|
+
lines.push("let router = route_table::router();", "let route = router.at(&fixture.route);");
|
|
339
|
+
args += ", &route";
|
|
340
|
+
}
|
|
341
|
+
if (c.usesStores) {
|
|
342
|
+
lines.push("let state: stores::Stores = serde_json::from_value(fixture.stores.clone()).map_err(|e| e.to_string())?;");
|
|
343
|
+
args += ", &state";
|
|
344
|
+
}
|
|
345
|
+
if (c.usesI18n) {
|
|
346
|
+
lines.push("let i18n = i18n::i18n(fixture.locale.as_deref().unwrap_or(i18n::LOCALE));");
|
|
347
|
+
args += ", &i18n";
|
|
348
|
+
}
|
|
349
|
+
if (c.usesTeleports) {
|
|
350
|
+
lines.push("let teleports = ferrovue::Teleports::new();");
|
|
351
|
+
args += ", &teleports";
|
|
352
|
+
}
|
|
353
|
+
lines.push(`${c.module}::render(&mut out, &props${args});`);
|
|
354
|
+
// What was teleported follows the render, as the conformance suite records Vue's.
|
|
355
|
+
if (c.usesTeleports)
|
|
356
|
+
lines.push("teleports_into(&mut out, teleports);");
|
|
357
|
+
return ` ${rustStr(c.name)} => {\n${lines.map((l) => " " + l).join("\n")}\n }`;
|
|
358
|
+
})
|
|
359
|
+
.join("\n");
|
|
360
|
+
const fixture = comps.some((c) => takesSlots(c) || c.usesRoute || c.usesStores || c.usesI18n)
|
|
361
|
+
? `
|
|
362
|
+
/// What a fixture holds besides the props: each slot's content, and the location it renders at.
|
|
363
|
+
#[cfg(test)]
|
|
364
|
+
#[derive(serde::Deserialize)]
|
|
365
|
+
struct Fixture {
|
|
366
|
+
#[serde(rename = "$slots", default)]
|
|
367
|
+
slots: std::collections::HashMap<String, String>,
|
|
368
|
+
#[serde(rename = "$route", default = "Fixture::root")]
|
|
369
|
+
route: String,
|
|
370
|
+
#[serde(rename = "$stores", default = "Fixture::no_stores")]
|
|
371
|
+
stores: serde_json::Value,
|
|
372
|
+
#[serde(rename = "$locale", default)]
|
|
373
|
+
locale: Option<String>,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[cfg(test)]
|
|
377
|
+
impl Fixture {
|
|
378
|
+
fn root() -> String {
|
|
379
|
+
"/".to_owned()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn no_stores() -> serde_json::Value {
|
|
383
|
+
serde_json::Value::Object(Default::default())
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fn slot(&self, name: &str) -> Option<&str> {
|
|
387
|
+
self.slots.get(name).map(String::as_str)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
`
|
|
391
|
+
: "";
|
|
392
|
+
return `${header(ctx.componentsDir)}
|
|
393
|
+
//! The component renderers, one module per \`.vue\` file.
|
|
394
|
+
|
|
395
|
+
// The modules pass rustc's default warnings and clippy's default lints, with one exception:
|
|
396
|
+
// \`dead_code\`. Every component gets the whole of its API (\`render\`, \`html\`, \`island\`, \`NAME\`, a
|
|
397
|
+
// constructor and a setter per optional prop) and an app calls only what it needs.
|
|
398
|
+
#![allow(dead_code)]
|
|
399
|
+
|
|
400
|
+
${comps.map((c) => `pub mod ${c.module};`).join("\n")}${ctx.routes ? "\npub mod route_table;" : ""}${ctx.stores.size ? "\npub mod stores;" : ""}${ctx.typeStructs.size ? "\npub mod types;" : ""}${ctx.i18n ? "\npub mod i18n;" : ""}
|
|
401
|
+
${fixture}
|
|
402
|
+
${comps.some((c) => c.usesTeleports) ? `/// What was teleported, after a marker, as the conformance suite writes Vue's: \`{"target":"…"}\`.
|
|
403
|
+
#[cfg(test)]
|
|
404
|
+
fn teleports_into(out: &mut String, teleports: ferrovue::Teleports) {
|
|
405
|
+
let targets = teleports.into_targets();
|
|
406
|
+
if targets.is_empty() {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let pairs: Vec<String> = targets
|
|
410
|
+
.iter()
|
|
411
|
+
.map(|(t, html)| format!("{}:{}", serde_json::to_string(t).unwrap(), serde_json::to_string(html).unwrap()))
|
|
412
|
+
.collect();
|
|
413
|
+
out.push_str("<!--fv-teleports-->{");
|
|
414
|
+
out.push_str(&pairs.join(","));
|
|
415
|
+
out.push('}');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
` : ""}/// Render one component from its props as JSON, for the conformance suite.
|
|
419
|
+
#[cfg(test)]
|
|
420
|
+
pub fn render_json(component: &str, json: &str) -> Result<String, String> {
|
|
421
|
+
let mut out = String::new();
|
|
422
|
+
match component {
|
|
423
|
+
${arms}
|
|
424
|
+
other => return Err(format!("no component called {other}")),
|
|
425
|
+
}
|
|
426
|
+
Ok(out)
|
|
427
|
+
}
|
|
428
|
+
`;
|
|
429
|
+
}
|
|
430
|
+
export function storesSource(dir) {
|
|
431
|
+
const home = storeHome(dir);
|
|
432
|
+
// Test-only `Default` lets a fixture name only the stores it reads; the rest are never looked at.
|
|
433
|
+
const testDerive = (src) => src.replace("#[cfg_attr(test, derive(serde::Deserialize))]", "#[cfg_attr(test, derive(Default, serde::Deserialize))]\n#[cfg_attr(test, serde(default))]");
|
|
434
|
+
const structs = [...ctx.storeStructs.values()]
|
|
435
|
+
.map((st) => testDerive(structSource(st, home, `/// \`${st.name}\` in \`${ctx.storeFiles.get(st.name)}\`.\n`)))
|
|
436
|
+
.join("\n");
|
|
437
|
+
const all = {
|
|
438
|
+
name: "Stores",
|
|
439
|
+
fields: [...ctx.stores.values()].map((st) => ({ js: st.id, rust: st.field, ty: { k: "struct", name: st.state, store: true } })),
|
|
440
|
+
};
|
|
441
|
+
const top = testDerive(structSource(all, home, "/// Every store's state, keyed by id as `pinia.state.value` is: what the page sends the client.\n"));
|
|
442
|
+
return `${header(dir, "the store files")}
|
|
443
|
+
//! The Pinia stores' state, which components read while they render on the server.
|
|
444
|
+
|
|
445
|
+
${/Cow</.test(structs) ? "use std::borrow::Cow;\n\n" : ""}${structs}
|
|
446
|
+
${top}`;
|
|
447
|
+
}
|
|
448
|
+
export function typesSource() {
|
|
449
|
+
const home = storeHome("types");
|
|
450
|
+
home.structs = ctx.typeStructs;
|
|
451
|
+
home.module = "types";
|
|
452
|
+
const files = [...new Set(ctx.typeFiles.values())].toSorted();
|
|
453
|
+
const structs = [...ctx.typeStructs.values()]
|
|
454
|
+
.map((st) => structSource(st, home, `/// \`${st.name}\` in \`${ctx.typeFiles.get(st.name)}\`.\n`))
|
|
455
|
+
.join("\n");
|
|
456
|
+
return `${header(files.join(", "), "the type files")}
|
|
457
|
+
//! The types components import from shared \`.ts\` files, written once so that components passing
|
|
458
|
+
//! them to one another agree on them.
|
|
459
|
+
|
|
460
|
+
${/Cow</.test(structs) ? "use std::borrow::Cow;\n\n" : ""}${structs}`;
|
|
461
|
+
}
|
|
462
|
+
/** Route definitions as \`ferrovue::RouteDef\` literals, children nested. */
|
|
463
|
+
function routeDefs(routes, depth) {
|
|
464
|
+
const pad = " ".repeat(depth);
|
|
465
|
+
return routes
|
|
466
|
+
.map((r) => {
|
|
467
|
+
const name = r.name === undefined ? "None" : `Some(${rustStr(r.name)})`;
|
|
468
|
+
const children = r.children?.length ? `&[\n${routeDefs(r.children, depth + 1)}\n${pad}]` : "&[]";
|
|
469
|
+
return `${pad}ferrovue::RouteDef { path: ${rustStr(r.path)}, name: ${name}, children: ${children} },`;
|
|
470
|
+
})
|
|
471
|
+
.join("\n");
|
|
472
|
+
}
|
|
473
|
+
export function routesSource(routes, file) {
|
|
474
|
+
return `${header(file, "the routes file")}
|
|
475
|
+
//! The app's routes: what \`<RouterLink>\` resolves against and \`useRoute()\` reads.
|
|
476
|
+
|
|
477
|
+
/// Each route: its vue-router path, its name if it has one, and the routes nested in it.
|
|
478
|
+
pub const ROUTES: &[ferrovue::RouteDef<'static>] = &[
|
|
479
|
+
${routeDefs(routes, 1)}
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
/// Every route's full path, nested ones included.
|
|
483
|
+
pub const PATHS: &[&str] = &[
|
|
484
|
+
${allRoutes(routes).map((r) => ` ${rustStr(r.fullPath)},`).join("\n")}
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
/// The history's base, which every link's \`href\` starts with.
|
|
488
|
+
pub const BASE: &str = ${rustStr(ctx.routerBase)};
|
|
489
|
+
|
|
490
|
+
/// The router these routes make: build it once, and resolve each request's location with
|
|
491
|
+
/// [\`ferrovue::Router::at\`].
|
|
492
|
+
pub fn router() -> ferrovue::Router {
|
|
493
|
+
ferrovue::Router::tree(ROUTES).with_base(BASE)
|
|
494
|
+
}
|
|
495
|
+
`;
|
|
496
|
+
}
|
package/dist/scoped.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Component } from "./model.ts";
|
|
2
|
+
/** Set every component's `inherits` and `passesSlotIds`, each a least fixed point: a component's
|
|
3
|
+
* slot content may be given an id when one of its outlets passes one, or forwards what the slot
|
|
4
|
+
* content it sits in was given; a root may be handed ids by a scoped parent, by a parent that
|
|
5
|
+
* passes on what it inherits, or by slot content that may be given an id. */
|
|
6
|
+
export declare function scopeFlow(read: {
|
|
7
|
+
comp: Component;
|
|
8
|
+
ssr: string;
|
|
9
|
+
children: Map<string, string>;
|
|
10
|
+
}[]): void;
|
package/dist/scoped.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* Where scope ids reach: which components' roots a parent may hand ids to, and which components'
|
|
2
|
+
* slot content may be given a slot scope id. Both decide a generated signature — `render` taking
|
|
3
|
+
* `fv_attrs`, slot content taking `fv_sid` — so they are worked out for every component before any
|
|
4
|
+
* is generated.
|
|
5
|
+
*
|
|
6
|
+
* With `<style scoped>`, Vue's server renderer writes the component's id onto each of its elements
|
|
7
|
+
* (the compiled template spells it out) and hands ids to a child component's root through the
|
|
8
|
+
* child's `_attrs`: the id of the component that created it, what its parent passes on when the
|
|
9
|
+
* child is that parent's root, and the slot scope ids of the slot content it is rendered in
|
|
10
|
+
* (`renderComponentSubTree`). A component with `:slotted()` styles gives its slot content
|
|
11
|
+
* `data-v-…-s` as well, which the content writes onto its elements as `_scopeId`. */
|
|
12
|
+
import { parse as parseJs } from "@babel/parser";
|
|
13
|
+
/** The child components and outlets in one component's compiled template. */
|
|
14
|
+
function flowOf(comp, ssr, children) {
|
|
15
|
+
const calls = [];
|
|
16
|
+
const outlets = [];
|
|
17
|
+
// `const _component_Tree = _resolveComponent("Tree", true)`: the component itself, by name.
|
|
18
|
+
const self = new Set();
|
|
19
|
+
const resolve = (target) => {
|
|
20
|
+
if (target?.type === "MemberExpression" && target.object.name === "$setup") {
|
|
21
|
+
return children.get(target.computed ? target.property.value : target.property.name) ?? null;
|
|
22
|
+
}
|
|
23
|
+
return target?.type === "Identifier" && self.has(target.name) ? comp.name : null;
|
|
24
|
+
};
|
|
25
|
+
const mentions = (n, name) => !!n && typeof n === "object" && (n.type === "Identifier" ? n.name === name : Object.values(n).some((v) => mentions(v, name)));
|
|
26
|
+
const walk = (n, within) => {
|
|
27
|
+
if (!n || typeof n !== "object")
|
|
28
|
+
return;
|
|
29
|
+
if (Array.isArray(n)) {
|
|
30
|
+
for (const x of n)
|
|
31
|
+
walk(x, within);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (n.type === "VariableDeclarator" && n.init?.type === "CallExpression" && n.init.callee.name === "_resolveComponent") {
|
|
35
|
+
if (n.init.arguments[0]?.value === comp.name && n.init.arguments[1])
|
|
36
|
+
self.add(n.id.name);
|
|
37
|
+
}
|
|
38
|
+
if (n.type === "CallExpression" && n.callee.type === "Identifier") {
|
|
39
|
+
const a = n.arguments;
|
|
40
|
+
if (n.callee.name === "_ssrRenderComponent") {
|
|
41
|
+
const child = resolve(a[0]);
|
|
42
|
+
if (child)
|
|
43
|
+
calls.push({ child, passesAttrs: mentions(a[1], "_attrs"), within: a[4] ? within : null });
|
|
44
|
+
// Slot content given to the child: its `_scopeId` is what the child's outlets pass.
|
|
45
|
+
walk(a[2], child);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (n.callee.name === "_ssrRenderSlot" || n.callee.name === "_ssrRenderSlotInner") {
|
|
49
|
+
const id = a[6];
|
|
50
|
+
const passes = !id || id.type === "NullLiteral" ? "none" : id.type === "Identifier" && id.name === "_scopeId" ? "forward" : "id";
|
|
51
|
+
outlets.push({ passes, within });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const [key, v] of Object.entries(n))
|
|
55
|
+
if (key !== "loc" && key !== "start" && key !== "end")
|
|
56
|
+
walk(v, within);
|
|
57
|
+
};
|
|
58
|
+
walk(parseJs(ssr, { sourceType: "module" }).program, null);
|
|
59
|
+
return { calls, outlets };
|
|
60
|
+
}
|
|
61
|
+
/** Set every component's `inherits` and `passesSlotIds`, each a least fixed point: a component's
|
|
62
|
+
* slot content may be given an id when one of its outlets passes one, or forwards what the slot
|
|
63
|
+
* content it sits in was given; a root may be handed ids by a scoped parent, by a parent that
|
|
64
|
+
* passes on what it inherits, or by slot content that may be given an id. */
|
|
65
|
+
export function scopeFlow(read) {
|
|
66
|
+
const flows = read.map((r) => ({ comp: r.comp, ...flowOf(r.comp, r.ssr, r.children) }));
|
|
67
|
+
const byName = new Map(read.map((r) => [r.comp.name, r.comp]));
|
|
68
|
+
const passes = (name) => (name !== null && byName.get(name)?.passesSlotIds) ?? false;
|
|
69
|
+
for (let changed = true; changed;) {
|
|
70
|
+
changed = false;
|
|
71
|
+
for (const f of flows) {
|
|
72
|
+
if (!f.comp.passesSlotIds && f.outlets.some((o) => o.passes === "id" || (o.passes === "forward" && passes(o.within)))) {
|
|
73
|
+
f.comp.passesSlotIds = changed = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (let changed = true; changed;) {
|
|
78
|
+
changed = false;
|
|
79
|
+
for (const f of flows) {
|
|
80
|
+
for (const c of f.calls) {
|
|
81
|
+
const child = byName.get(c.child);
|
|
82
|
+
if (!child || child.inherits)
|
|
83
|
+
continue;
|
|
84
|
+
if (f.comp.scopeId !== null || (c.passesAttrs && f.comp.inherits && child.inheritAttrs) || passes(c.within)) {
|
|
85
|
+
child.inherits = changed = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/script.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Component, type N, type Scope } from "./model.ts";
|
|
2
|
+
/** Lifecycle hooks, which never run on the server: setup may register them freely. */
|
|
3
|
+
export declare const CLIENT_HOOKS: Set<string>;
|
|
4
|
+
/** Compiler macros and calls with no effect on the server's render. */
|
|
5
|
+
export declare const INERT_CALLS: Set<string>;
|
|
6
|
+
/** A statement in setup that is a call on its own: allowed only when it cannot change the render. */
|
|
7
|
+
export declare function setupStatement(comp: Component, st: N): void;
|
|
8
|
+
/** What a setup binding's value is computed from: `ref(x)` and `shallowRef(x)` hold `x`,
|
|
9
|
+
* `computed(() => x)` is `x`, and a plain `const y = x` is `x`. */
|
|
10
|
+
export declare function setupSource(init: N): N | null;
|
|
11
|
+
/** The names a destructuring pattern binds. */
|
|
12
|
+
export declare function patternNames(p: N): string[];
|
|
13
|
+
export declare function scopeFor(comp: Component, ast: N[], components: Map<string, Component>): {
|
|
14
|
+
scope: Scope;
|
|
15
|
+
lets: string[];
|
|
16
|
+
};
|