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/router.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/* `<RouterLink>`, its locations, and the routes file. */
|
|
2
|
+
import { escapeHtml } from "@vue/shared";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fail, GenError, rustStr } from "./model.js";
|
|
6
|
+
import { allRoutes, CONFIG_FILE, ctx } from "./context.js";
|
|
7
|
+
import { expr, lonely } from "./expr.js";
|
|
8
|
+
import { bare, condition, strArg } from "./parens.js";
|
|
9
|
+
import { Emitter } from "./emitter.js";
|
|
10
|
+
import { classItems, IGNORED_PROPS, mergeProps, renderDynamicAttr, renderStyle } from "./attrs.js";
|
|
11
|
+
import { slotBody, statements } from "./template.js";
|
|
12
|
+
export const ROUTER_LINK_PROPS = new Set(["to", "class", "activeClass", "exactActiveClass", "ariaCurrentValue"]);
|
|
13
|
+
/** `<RouterLink>` props that change only what a click does, which the server does not render. */
|
|
14
|
+
export const ROUTER_LINK_INERT = new Set(["replace", "viewTransition"]);
|
|
15
|
+
/** The parameter names of a route path, in order. */
|
|
16
|
+
export function routeParams(path) {
|
|
17
|
+
return [...path.matchAll(/:(\w+)/g)].map((m) => m[1]);
|
|
18
|
+
}
|
|
19
|
+
/** A string written into a link, which vue-router percent-encodes: half of a surrogate pair makes
|
|
20
|
+
* `encodeURI` throw, so Vue renders no page at all. */
|
|
21
|
+
function noHalves(s, v, n) {
|
|
22
|
+
if (v.lone)
|
|
23
|
+
fail(s.comp, lonely("a `<RouterLink>` location"), n);
|
|
24
|
+
}
|
|
25
|
+
/** A value written into a URL — a parameter or a query value — as the `&str` vue-router stringifies. */
|
|
26
|
+
export function urlText(s, v, n) {
|
|
27
|
+
noHalves(s, v, n);
|
|
28
|
+
if (v.ty.k === "str")
|
|
29
|
+
return v.code;
|
|
30
|
+
if (v.ty.k === "int" || v.ty.k === "float")
|
|
31
|
+
return `&*fv::Js(${bare(v.code)}).to_string()`;
|
|
32
|
+
if (v.ty.k === "bool")
|
|
33
|
+
return `if ${condition(v.code)} { "true" } else { "false" }`;
|
|
34
|
+
return fail(s.comp, "a route parameter is a string or a number that is present", n);
|
|
35
|
+
}
|
|
36
|
+
/** \`let fv_link = …;\`: a \`<RouterLink>\`'s \`to\` resolved from the reader's route. */
|
|
37
|
+
export function resolveLink(s, e, to) {
|
|
38
|
+
if (to.type !== "ObjectExpression") {
|
|
39
|
+
const target = expr(s, to);
|
|
40
|
+
if (target.ty.k !== "str")
|
|
41
|
+
fail(s.comp, "`<RouterLink>`'s `to` is a string or an object literal", to);
|
|
42
|
+
noHalves(s, target, to);
|
|
43
|
+
e.stmt(`let fv_link = fv_route.link(${strArg(target.code)});`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const parts = new Map();
|
|
47
|
+
for (const p of to.properties) {
|
|
48
|
+
if (p.type !== "ObjectProperty" || p.computed)
|
|
49
|
+
fail(s.comp, "a `to` object holds plain keys", p);
|
|
50
|
+
const key = p.key.name ?? p.key.value;
|
|
51
|
+
if (["replace", "force", "state"].includes(key))
|
|
52
|
+
continue;
|
|
53
|
+
if (!["name", "path", "params", "query", "hash"].includes(key))
|
|
54
|
+
fail(s.comp, `\`${key}\` in a \`to\` object`, p);
|
|
55
|
+
parts.set(key, p.value);
|
|
56
|
+
}
|
|
57
|
+
e.open("let fv_link =");
|
|
58
|
+
const query = parts.get("query");
|
|
59
|
+
// The query, built only when the location has one.
|
|
60
|
+
const search = query ? "&fv_search" : '""';
|
|
61
|
+
if (query) {
|
|
62
|
+
e.stmt("let mut fv_search = String::new();");
|
|
63
|
+
if (query.type !== "ObjectExpression")
|
|
64
|
+
fail(s.comp, "a `to`'s `query` is an object literal", query);
|
|
65
|
+
for (const p of query.properties) {
|
|
66
|
+
if (p.type !== "ObjectProperty" || p.computed)
|
|
67
|
+
fail(s.comp, "a query holds plain keys", p);
|
|
68
|
+
const key = String(p.key.name ?? p.key.value);
|
|
69
|
+
const v = expr(s, p.value);
|
|
70
|
+
// An absent value leaves its key out, as `undefined` does.
|
|
71
|
+
if (v.ty.k === "undef")
|
|
72
|
+
continue;
|
|
73
|
+
if (v.ty.k === "opt") {
|
|
74
|
+
e.open(`if let Some(v) = ${v.code}`);
|
|
75
|
+
e.stmt(`fv::query_into(&mut fv_search, ${rustStr(key)}, ${strArg(urlText(s, { code: "v", ty: v.ty.of }, p.value))});`);
|
|
76
|
+
e.close();
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
e.stmt(`fv::query_into(&mut fv_search, ${rustStr(key)}, ${strArg(urlText(s, v, p.value))});`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let hash = '""';
|
|
83
|
+
const h = parts.get("hash");
|
|
84
|
+
if (h) {
|
|
85
|
+
const v = expr(s, h);
|
|
86
|
+
if (v.ty.k !== "str")
|
|
87
|
+
fail(s.comp, "a `to`'s `hash` is a string", h);
|
|
88
|
+
noHalves(s, v, h);
|
|
89
|
+
hash = strArg(v.code);
|
|
90
|
+
}
|
|
91
|
+
const name = parts.get("name");
|
|
92
|
+
const path = parts.get("path");
|
|
93
|
+
if (name) {
|
|
94
|
+
if (name.type !== "StringLiteral")
|
|
95
|
+
fail(s.comp, "a `to`'s `name` is a string literal, checked against the routes", name);
|
|
96
|
+
const route = allRoutes(ctx.routes).find((r) => r.name === name.value);
|
|
97
|
+
if (!route)
|
|
98
|
+
fail(s.comp, `no route is called \`${name.value}\``, name);
|
|
99
|
+
const wanted = routeParams(route.fullPath);
|
|
100
|
+
const given = new Map();
|
|
101
|
+
const params = parts.get("params");
|
|
102
|
+
if (params) {
|
|
103
|
+
if (params.type !== "ObjectExpression")
|
|
104
|
+
fail(s.comp, "a `to`'s `params` is an object literal", params);
|
|
105
|
+
for (const p of params.properties) {
|
|
106
|
+
if (p.type !== "ObjectProperty" || p.computed)
|
|
107
|
+
fail(s.comp, "params hold plain keys", p);
|
|
108
|
+
const key = String(p.key.name ?? p.key.value);
|
|
109
|
+
if (!wanted.includes(key))
|
|
110
|
+
fail(s.comp, `route \`${name.value}\` has no parameter \`${key}\``, p);
|
|
111
|
+
given.set(key, p.value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const missing = wanted.filter((w) => !given.has(w));
|
|
115
|
+
if (missing.length)
|
|
116
|
+
fail(s.comp, `route \`${name.value}\` needs \`${missing.join("`, `")}\`: give every parameter`, to);
|
|
117
|
+
const list = wanted.map((w) => `(${rustStr(w)}, ${urlText(s, expr(s, given.get(w)), given.get(w))})`);
|
|
118
|
+
e.stmt(`fv_route.link_named(${rustStr(name.value)}, &[${list.join(", ")}], ${search}, ${hash})`);
|
|
119
|
+
}
|
|
120
|
+
else if (path) {
|
|
121
|
+
if (parts.has("params"))
|
|
122
|
+
fail(s.comp, "a `to` with a `path` takes no `params`, which vue-router ignores", path);
|
|
123
|
+
const v = expr(s, path);
|
|
124
|
+
if (v.ty.k !== "str")
|
|
125
|
+
fail(s.comp, "a `to`'s `path` is a string", path);
|
|
126
|
+
noHalves(s, v, path);
|
|
127
|
+
e.stmt(`fv_route.link_path(${strArg(v.code)}, ${search}, ${hash})`);
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
fail(s.comp, "a `to` object has a `name` or a `path`", to);
|
|
131
|
+
e.close(";");
|
|
132
|
+
}
|
|
133
|
+
/** \`<RouterLink to="...">\` as vue-router renders it: \`aria-current\` and the active classes when it
|
|
134
|
+
* points where the reader is, then \`href\`, then the link's own class and attributes. */
|
|
135
|
+
export function routerLink(s, e, n) {
|
|
136
|
+
const [, rawProps, slots, , slotScopeId] = n.arguments;
|
|
137
|
+
let props = rawProps;
|
|
138
|
+
if (props?.type === "CallExpression" && props.callee.type === "Identifier" && props.callee.name === "_mergeProps") {
|
|
139
|
+
props = mergeProps(s, props);
|
|
140
|
+
}
|
|
141
|
+
if (props?.type !== "ObjectExpression")
|
|
142
|
+
fail(s.comp, "`<RouterLink>` takes literal attributes", n);
|
|
143
|
+
const fields = new Map();
|
|
144
|
+
const attrs = [];
|
|
145
|
+
for (const p of props.properties) {
|
|
146
|
+
if (p.type !== "ObjectProperty" || p.computed)
|
|
147
|
+
fail(s.comp, "`<RouterLink>` attributes hold plain keys", p);
|
|
148
|
+
const raw = p.key.type === "Identifier" ? p.key.name : p.key.value;
|
|
149
|
+
const key = raw.replace(/-(\w)/g, (_, c) => c.toUpperCase());
|
|
150
|
+
if (ROUTER_LINK_PROPS.has(key))
|
|
151
|
+
fields.set(key, p.value);
|
|
152
|
+
else if (/^on[^a-z]/.test(raw) || IGNORED_PROPS.has(raw) || ROUTER_LINK_INERT.has(key))
|
|
153
|
+
continue;
|
|
154
|
+
else if (key === "custom")
|
|
155
|
+
fail(s.comp, "`custom` on `<RouterLink>` renders a scoped slot, which is not supported", p);
|
|
156
|
+
else if (key === "href" || key === "ariaCurrent")
|
|
157
|
+
fail(s.comp, `\`${raw}\` on \`<RouterLink>\` is its own`, p);
|
|
158
|
+
else
|
|
159
|
+
attrs.push({ ...p, key: { type: "StringLiteral", value: raw } });
|
|
160
|
+
}
|
|
161
|
+
const to = fields.get("to");
|
|
162
|
+
if (!to)
|
|
163
|
+
fail(s.comp, "`<RouterLink>` needs `to`", n);
|
|
164
|
+
/* vue-router renders the link from virtual nodes (`renderElementVNode`). The `<a>` takes the ids
|
|
165
|
+
* of a component's root after its attributes: what the parent passes on when the link is its
|
|
166
|
+
* root, the id of the component that wrote it, and the slot scope ids around it — as
|
|
167
|
+
* `scope_attrs` gathers them. Its content takes that component's id, which the compiled template
|
|
168
|
+
* spells out, and the slot scope ids again, each once: unlike the raw `_scopeId` the compiled
|
|
169
|
+
* elements would write, so an element there is refused when there are some. */
|
|
170
|
+
const passed = (rawProps?.type === "Identifier" && rawProps.name === "_attrs") || rawProps?.arguments?.some((a) => a.type === "Identifier" && a.name === "_attrs");
|
|
171
|
+
const base = passed ? s.attrs : null;
|
|
172
|
+
const slotted = slotScopeId ? s.sid : null;
|
|
173
|
+
const scopeId = s.comp.scopeId;
|
|
174
|
+
const content = slots?.type === "ObjectExpression" ? slots.properties.filter((p) => (p.key?.name ?? p.key?.value) !== "_").map((p) => p.value) : [];
|
|
175
|
+
/** Whether the link's content holds a node that `is` picks out. */
|
|
176
|
+
const holds = (is) => {
|
|
177
|
+
const within = (x) => !!x && typeof x === "object" && (is(x) || Object.values(x).some(within));
|
|
178
|
+
return content.some((c) => slotBody(s, c).some(within));
|
|
179
|
+
};
|
|
180
|
+
const writesScopeId = (x) => x.type === "TemplateLiteral" && x.expressions.some((y) => y.type === "Identifier" && y.name === "_scopeId");
|
|
181
|
+
if (slotted !== null && holds(writesScopeId)) {
|
|
182
|
+
fail(s.comp, "an element inside a `<RouterLink>` in slot content given a slot scope id: vue-router writes those ids by rules of its own", to);
|
|
183
|
+
}
|
|
184
|
+
// A `<slot>` there is rendered as a virtual node too, which passes its own slot scope ids.
|
|
185
|
+
if ((scopeId !== null || slotted !== null || base !== null) && holds((x) => x.type === "CallExpression" && x.callee.name === "_ssrRenderSlot")) {
|
|
186
|
+
fail(s.comp, "a `<slot>` inside a `<RouterLink>` that takes scope ids: vue-router renders it by rules of its own", to);
|
|
187
|
+
}
|
|
188
|
+
if (!ctx.routes)
|
|
189
|
+
fail(s.comp, `\`<RouterLink>\` needs \`routes\` in ${CONFIG_FILE}: the paths it resolves against`, n);
|
|
190
|
+
/** A literal-string prop, which the class names and `aria-current` must be. */
|
|
191
|
+
const literal = (key, fallback) => {
|
|
192
|
+
const v = fields.get(key);
|
|
193
|
+
if (!v)
|
|
194
|
+
return fallback;
|
|
195
|
+
if (v.type !== "StringLiteral")
|
|
196
|
+
fail(s.comp, `\`${key}\` on \`<RouterLink>\` is a string literal`, v);
|
|
197
|
+
return v.value;
|
|
198
|
+
};
|
|
199
|
+
const activeClass = literal("activeClass", ctx.linkActive);
|
|
200
|
+
const exactClass = literal("exactActiveClass", ctx.linkExactActive);
|
|
201
|
+
const ariaCurrent = literal("ariaCurrentValue", "page");
|
|
202
|
+
e.open("");
|
|
203
|
+
resolveLink(s, e, to);
|
|
204
|
+
e.lit("<a");
|
|
205
|
+
e.open("if fv_link.exact");
|
|
206
|
+
e.lit(` aria-current="${escapeHtml(ariaCurrent)}"`);
|
|
207
|
+
e.close();
|
|
208
|
+
e.lit(' href="');
|
|
209
|
+
e.stmt("fv::escape_into(out, &fv_link.href);");
|
|
210
|
+
e.lit('" class="');
|
|
211
|
+
// `{ [activeClass]: isActive, [exactActiveClass]: isExactActive }`. When the two are one class,
|
|
212
|
+
// the later key wins: the class goes with `isExactActive`.
|
|
213
|
+
const linkItems = activeClass === exactClass
|
|
214
|
+
? [`if fv_link.exact { ${rustStr(exactClass)} } else { "" }`]
|
|
215
|
+
: [`if fv_link.active { ${rustStr(activeClass)} } else { "" }`, `if fv_link.exact { ${rustStr(exactClass)} } else { "" }`];
|
|
216
|
+
const cls = fields.get("class");
|
|
217
|
+
const own = cls ? classItems(s, cls).map((it) => ("lit" in it ? rustStr(it.lit) : it.code)) : [];
|
|
218
|
+
e.stmt(`fv::class_into(out, false, &[${[...linkItems, ...own].join(", ")}]);`);
|
|
219
|
+
e.lit('"');
|
|
220
|
+
for (const p of attrs) {
|
|
221
|
+
const key = p.key.value;
|
|
222
|
+
if (key === "style") {
|
|
223
|
+
e.lit(` style="`);
|
|
224
|
+
renderStyle(s, e, p.value);
|
|
225
|
+
e.lit(`"`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
renderDynamicAttr(s, e, key, expr(s, p.value), p);
|
|
229
|
+
}
|
|
230
|
+
if (base === null && slotted === null) {
|
|
231
|
+
if (scopeId !== null)
|
|
232
|
+
e.lit(` ${scopeId}`);
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
e.stmt(`out.push_str(&fv::scope_attrs(${base ?? '""'}, ${scopeId === null ? '""' : rustStr(scopeId)}, ${slotted ?? '""'}));`);
|
|
236
|
+
e.lit(">");
|
|
237
|
+
if (slots && slots.type !== "NullLiteral") {
|
|
238
|
+
if (slots.type !== "ObjectExpression")
|
|
239
|
+
fail(s.comp, "slots must be an object literal", slots);
|
|
240
|
+
for (const p of slots.properties) {
|
|
241
|
+
const key = p.key?.type === "Identifier" ? p.key.name : p.key?.value;
|
|
242
|
+
if (key === "_")
|
|
243
|
+
continue;
|
|
244
|
+
if (key !== "default")
|
|
245
|
+
fail(s.comp, "`<RouterLink>` has only its default slot", p);
|
|
246
|
+
statements({ ...s, fill: false, vnode: true }, e, slotBody(s, p.value));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
e.lit("</a>");
|
|
250
|
+
e.close();
|
|
251
|
+
}
|
|
252
|
+
/** The routes file: vue-router paths, each a string or `{ "path", "name" }`. */
|
|
253
|
+
export function readRoutes(root, file) {
|
|
254
|
+
const raw = JSON.parse(readFileSync(join(root, file), "utf8"));
|
|
255
|
+
if (!Array.isArray(raw))
|
|
256
|
+
throw new GenError(`${file} lists the routes in an array`);
|
|
257
|
+
const read = (list, parent) => list.map((r) => {
|
|
258
|
+
const o = (typeof r === "string" ? { path: r } : r);
|
|
259
|
+
if (typeof o?.path !== "string" || (o.name !== undefined && typeof o.name !== "string") || (o.children !== undefined && !Array.isArray(o.children))) {
|
|
260
|
+
throw new GenError(`${file}: a route is a path, or \`{ "path": "…", "name": "…", "children": [ … ] }\``);
|
|
261
|
+
}
|
|
262
|
+
// A child's path joins its parent's unless it starts with `/`; an empty one is the parent's.
|
|
263
|
+
const fullPath = parent === null || o.path.startsWith("/") ? o.path : `${parent}${parent.endsWith("/") || o.path === "" ? "" : "/"}${o.path}`;
|
|
264
|
+
const def = { path: o.path, fullPath };
|
|
265
|
+
if (o.name !== undefined)
|
|
266
|
+
def.name = o.name;
|
|
267
|
+
if (o.children)
|
|
268
|
+
def.children = read(o.children, fullPath);
|
|
269
|
+
return def;
|
|
270
|
+
});
|
|
271
|
+
return read(raw, null);
|
|
272
|
+
}
|
package/dist/rust.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Component, type N, type Struct, type Ty } from "./model.ts";
|
|
2
|
+
import { type RouteDef } from "./context.ts";
|
|
3
|
+
export declare function needsLifetime(ty: Ty, comp: Component, seen?: Set<string>): boolean;
|
|
4
|
+
export declare function structLifetime(st: Struct, comp: Component): boolean;
|
|
5
|
+
export declare function rustTy(ty: Ty, comp: Component): string;
|
|
6
|
+
export declare function structSource(st: Struct, comp: Component, doc: string): string;
|
|
7
|
+
export declare function header(source: string, edit?: string): string;
|
|
8
|
+
/** Rust expressions summing the lengths of the strings a value of type `ty` at `place` holds. */
|
|
9
|
+
export declare function textLen(comp: Component, place: string, ty: Ty, seen?: Set<string>): string[];
|
|
10
|
+
export declare function componentSource(comp: Component, ast: N[], ssr: string, components: Map<string, Component>): string;
|
|
11
|
+
export declare function modSource(comps: Component[]): string;
|
|
12
|
+
export declare function storesSource(dir: string): string;
|
|
13
|
+
export declare function typesSource(): string;
|
|
14
|
+
export declare function routesSource(routes: RouteDef[], file: string): string;
|