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.
- package/README.md +103 -15
- package/dist/attrs.d.ts +8 -42
- package/dist/attrs.js +148 -297
- package/dist/calls.d.ts +4 -0
- package/dist/calls.js +219 -0
- package/dist/children.d.ts +9 -0
- package/dist/children.js +273 -0
- package/dist/classes.d.ts +13 -0
- package/dist/classes.js +176 -0
- package/dist/cli.js +121 -27
- package/dist/client.d.ts +11 -2
- package/dist/client.js +45 -9
- package/dist/compiler.d.ts +4 -0
- package/dist/compiler.js +37 -90
- package/dist/component.js +19 -82
- package/dist/context.d.ts +6 -68
- package/dist/context.js +25 -38
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +68 -0
- package/dist/emitter.d.ts +0 -8
- package/dist/emitter.js +0 -12
- package/dist/expr.d.ts +0 -70
- package/dist/expr.js +30 -855
- package/dist/fallthrough.d.ts +9 -0
- package/dist/fallthrough.js +78 -0
- package/dist/fixture.d.ts +2 -1
- package/dist/fixture.js +37 -22
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/islands.d.ts +6 -0
- package/dist/islands.js +5 -0
- package/dist/lists.d.ts +0 -11
- package/dist/lists.js +4 -39
- package/dist/loops.d.ts +3 -0
- package/dist/loops.js +164 -0
- package/dist/model.d.ts +22 -130
- package/dist/model.js +37 -9
- package/dist/narrowing.d.ts +19 -0
- package/dist/narrowing.js +126 -0
- package/dist/numbers.d.ts +8 -0
- package/dist/numbers.js +73 -0
- package/dist/parens.d.ts +0 -27
- package/dist/parens.js +0 -44
- package/dist/plugin.d.ts +84 -0
- package/dist/plugin.js +26 -0
- package/dist/plugins/i18n.d.ts +23 -0
- package/dist/{i18n.js → plugins/i18n.js} +76 -19
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +8 -0
- package/dist/plugins/router-link.d.ts +8 -0
- package/dist/{router.js → plugins/router-link.js} +65 -57
- package/dist/plugins/router.d.ts +42 -0
- package/dist/plugins/router.js +256 -0
- package/dist/plugins/scoped.d.ts +12 -0
- package/dist/{scoped.js → plugins/scoped.js} +55 -20
- package/dist/plugins/shared-types.d.ts +2 -0
- package/dist/plugins/shared-types.js +7 -0
- package/dist/plugins/stores.d.ts +37 -0
- package/dist/plugins/stores.js +377 -0
- package/dist/plugins/teleport.d.ts +5 -0
- package/dist/plugins/teleport.js +55 -0
- package/dist/rust.d.ts +2 -5
- package/dist/rust.js +66 -161
- package/dist/script.d.ts +0 -6
- package/dist/script.js +22 -121
- package/dist/slots.d.ts +13 -0
- package/dist/slots.js +160 -0
- package/dist/strings.d.ts +13 -0
- package/dist/strings.js +208 -0
- package/dist/styles.d.ts +14 -0
- package/dist/styles.js +181 -0
- package/dist/template.d.ts +2 -40
- package/dist/template.js +40 -691
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -8
- package/dist/types.d.ts +2 -0
- package/dist/typescript.d.ts +0 -13
- package/dist/typescript.js +8 -41
- package/dist/vite.d.ts +1 -0
- package/dist/vite.js +35 -14
- package/package.json +11 -2
- package/dist/i18n.d.ts +0 -27
- package/dist/router.d.ts +0 -17
- package/dist/scoped.d.ts +0 -10
- package/dist/stores.d.ts +0 -6
- package/dist/stores.js +0 -227
|
@@ -1,28 +1,27 @@
|
|
|
1
|
-
/* `<RouterLink>`, its locations, and the routes file. */
|
|
2
1
|
import { escapeHtml } from "@vue/shared";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
2
|
+
import { camelize, fail, rustStr } from "../model.js";
|
|
3
|
+
import { CONFIG_FILE } from "../context.js";
|
|
4
|
+
import { expr } from "../expr.js";
|
|
5
|
+
import { lonely } from "../strings.js";
|
|
6
|
+
import { bare, condition, strArg } from "../parens.js";
|
|
7
|
+
import { Emitter } from "../emitter.js";
|
|
8
|
+
import { attrOf, dollarAttrs, IGNORED_PROPS, isAttrs, mergedParts, mergeProps, renderDynamicAttr } from "../attrs.js";
|
|
9
|
+
import { renderStyle } from "../styles.js";
|
|
10
|
+
import { classItems } from "../classes.js";
|
|
11
|
+
import { statements } from "../template.js";
|
|
12
|
+
import { slotBody } from "../slots.js";
|
|
13
|
+
import { runOf } from "../plugin.js";
|
|
14
|
+
import { scopeIdOf } from "./scoped.js";
|
|
15
|
+
import { allRoutes, router } from "./router.js";
|
|
12
16
|
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
17
|
export const ROUTER_LINK_INERT = new Set(["replace", "viewTransition"]);
|
|
15
|
-
/** The parameter names of a route path, in order. */
|
|
16
18
|
export function routeParams(path) {
|
|
17
19
|
return [...path.matchAll(/:(\w+)/g)].map((m) => m[1]);
|
|
18
20
|
}
|
|
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
21
|
function noHalves(s, v, n) {
|
|
22
22
|
if (v.lone)
|
|
23
23
|
fail(s.comp, lonely("a `<RouterLink>` location"), n);
|
|
24
24
|
}
|
|
25
|
-
/** A value written into a URL — a parameter or a query value — as the `&str` vue-router stringifies. */
|
|
26
25
|
export function urlText(s, v, n) {
|
|
27
26
|
noHalves(s, v, n);
|
|
28
27
|
if (v.ty.k === "str")
|
|
@@ -33,7 +32,6 @@ export function urlText(s, v, n) {
|
|
|
33
32
|
return `if ${condition(v.code)} { "true" } else { "false" }`;
|
|
34
33
|
return fail(s.comp, "a route parameter is a string or a number that is present", n);
|
|
35
34
|
}
|
|
36
|
-
/** \`let fv_link = …;\`: a \`<RouterLink>\`'s \`to\` resolved from the reader's route. */
|
|
37
35
|
export function resolveLink(s, e, to) {
|
|
38
36
|
if (to.type !== "ObjectExpression") {
|
|
39
37
|
const target = expr(s, to);
|
|
@@ -56,7 +54,6 @@ export function resolveLink(s, e, to) {
|
|
|
56
54
|
}
|
|
57
55
|
e.open("let fv_link =");
|
|
58
56
|
const query = parts.get("query");
|
|
59
|
-
// The query, built only when the location has one.
|
|
60
57
|
const search = query ? "&fv_search" : '""';
|
|
61
58
|
if (query) {
|
|
62
59
|
e.stmt("let mut fv_search = String::new();");
|
|
@@ -67,7 +64,6 @@ export function resolveLink(s, e, to) {
|
|
|
67
64
|
fail(s.comp, "a query holds plain keys", p);
|
|
68
65
|
const key = String(p.key.name ?? p.key.value);
|
|
69
66
|
const v = expr(s, p.value);
|
|
70
|
-
// An absent value leaves its key out, as `undefined` does.
|
|
71
67
|
if (v.ty.k === "undef")
|
|
72
68
|
continue;
|
|
73
69
|
if (v.ty.k === "opt") {
|
|
@@ -93,7 +89,7 @@ export function resolveLink(s, e, to) {
|
|
|
93
89
|
if (name) {
|
|
94
90
|
if (name.type !== "StringLiteral")
|
|
95
91
|
fail(s.comp, "a `to`'s `name` is a string literal, checked against the routes", name);
|
|
96
|
-
const route = allRoutes(
|
|
92
|
+
const route = allRoutes(runOf(router).routes).find((r) => r.name === name.value);
|
|
97
93
|
if (!route)
|
|
98
94
|
fail(s.comp, `no route is called \`${name.value}\``, name);
|
|
99
95
|
const wanted = routeParams(route.fullPath);
|
|
@@ -130,8 +126,6 @@ export function resolveLink(s, e, to) {
|
|
|
130
126
|
fail(s.comp, "a `to` object has a `name` or a `path`", to);
|
|
131
127
|
e.close(";");
|
|
132
128
|
}
|
|
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
129
|
export function routerLink(s, e, n) {
|
|
136
130
|
const [, rawProps, slots, , slotScopeId] = n.arguments;
|
|
137
131
|
let props = rawProps;
|
|
@@ -161,18 +155,11 @@ export function routerLink(s, e, n) {
|
|
|
161
155
|
const to = fields.get("to");
|
|
162
156
|
if (!to)
|
|
163
157
|
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
158
|
const passed = (rawProps?.type === "Identifier" && rawProps.name === "_attrs") || rawProps?.arguments?.some((a) => a.type === "Identifier" && a.name === "_attrs");
|
|
171
159
|
const base = passed ? s.attrs : null;
|
|
172
160
|
const slotted = slotScopeId ? s.sid : null;
|
|
173
|
-
const scopeId = s.comp
|
|
161
|
+
const scopeId = scopeIdOf(s.comp);
|
|
174
162
|
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
163
|
const holds = (is) => {
|
|
177
164
|
const within = (x) => !!x && typeof x === "object" && (is(x) || Object.values(x).some(within));
|
|
178
165
|
return content.some((c) => slotBody(s, c).some(within));
|
|
@@ -181,13 +168,11 @@ export function routerLink(s, e, n) {
|
|
|
181
168
|
if (slotted !== null && holds(writesScopeId)) {
|
|
182
169
|
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
170
|
}
|
|
184
|
-
// A `<slot>` there is rendered as a virtual node too, which passes its own slot scope ids.
|
|
185
171
|
if ((scopeId !== null || slotted !== null || base !== null) && holds((x) => x.type === "CallExpression" && x.callee.name === "_ssrRenderSlot")) {
|
|
186
172
|
fail(s.comp, "a `<slot>` inside a `<RouterLink>` that takes scope ids: vue-router renders it by rules of its own", to);
|
|
187
173
|
}
|
|
188
|
-
if (!
|
|
174
|
+
if (!runOf(router).routes)
|
|
189
175
|
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
176
|
const literal = (key, fallback) => {
|
|
192
177
|
const v = fields.get(key);
|
|
193
178
|
if (!v)
|
|
@@ -196,20 +181,32 @@ export function routerLink(s, e, n) {
|
|
|
196
181
|
fail(s.comp, `\`${key}\` on \`<RouterLink>\` is a string literal`, v);
|
|
197
182
|
return v.value;
|
|
198
183
|
};
|
|
199
|
-
const activeClass = literal("activeClass",
|
|
200
|
-
const exactClass = literal("exactActiveClass",
|
|
184
|
+
const activeClass = literal("activeClass", runOf(router).linkActive);
|
|
185
|
+
const exactClass = literal("exactActiveClass", runOf(router).linkExactActive);
|
|
201
186
|
const ariaCurrent = literal("ariaCurrentValue", "page");
|
|
202
187
|
e.open("");
|
|
203
188
|
resolveLink(s, e, to);
|
|
204
189
|
e.lit("<a");
|
|
190
|
+
const parts = mergedParts(rawProps);
|
|
191
|
+
const passedOn = (p) => s.fallthrough !== null && ((isAttrs(p) && s.comp.inheritAttrs) || dollarAttrs(s, p));
|
|
192
|
+
const fallsThrough = parts.some(passedOn);
|
|
193
|
+
if (fallsThrough) {
|
|
194
|
+
for (const name of s.comp.attrNames) {
|
|
195
|
+
const key = camelize(name);
|
|
196
|
+
if ((ROUTER_LINK_PROPS.has(key) && key !== "class") || ROUTER_LINK_INERT.has(key) || key === "custom" || key === "viewTransition") {
|
|
197
|
+
fail(s.comp, `\`${name}\`, an attribute ${s.comp.name} may be passed, would reach its \`<RouterLink>\` as a prop`, n);
|
|
198
|
+
}
|
|
199
|
+
if (key === "href" || key === "ariaCurrent")
|
|
200
|
+
fail(s.comp, `\`${name}\`, an attribute ${s.comp.name} may be passed, would replace its \`<RouterLink>\`'s own`, n);
|
|
201
|
+
}
|
|
202
|
+
e.open(`if ${s.fallthrough}.is_empty()`);
|
|
203
|
+
}
|
|
205
204
|
e.open("if fv_link.exact");
|
|
206
205
|
e.lit(` aria-current="${escapeHtml(ariaCurrent)}"`);
|
|
207
206
|
e.close();
|
|
208
207
|
e.lit(' href="');
|
|
209
208
|
e.stmt("fv::escape_into(out, &fv_link.href);");
|
|
210
209
|
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
210
|
const linkItems = activeClass === exactClass
|
|
214
211
|
? [`if fv_link.exact { ${rustStr(exactClass)} } else { "" }`]
|
|
215
212
|
: [`if fv_link.active { ${rustStr(activeClass)} } else { "" }`, `if fv_link.exact { ${rustStr(exactClass)} } else { "" }`];
|
|
@@ -233,6 +230,38 @@ export function routerLink(s, e, n) {
|
|
|
233
230
|
}
|
|
234
231
|
else
|
|
235
232
|
e.stmt(`out.push_str(&fv::scope_attrs(${base ?? '""'}, ${scopeId === null ? '""' : rustStr(scopeId)}, ${slotted ?? '""'}));`);
|
|
233
|
+
if (fallsThrough) {
|
|
234
|
+
e.close(" else {");
|
|
235
|
+
const sources = [];
|
|
236
|
+
for (const p of parts) {
|
|
237
|
+
if (passedOn(p))
|
|
238
|
+
sources.push(`${s.fallthrough}.list()`);
|
|
239
|
+
else if (p.type === "ObjectExpression") {
|
|
240
|
+
const entries = p.properties
|
|
241
|
+
.filter((q) => {
|
|
242
|
+
const raw = q.key?.type === "Identifier" ? q.key.name : String(q.key?.value);
|
|
243
|
+
const key = camelize(raw);
|
|
244
|
+
return key === "class" || (!ROUTER_LINK_PROPS.has(key) && !ROUTER_LINK_INERT.has(key) && !/^on[^a-z]/.test(raw) && !IGNORED_PROPS.has(raw));
|
|
245
|
+
})
|
|
246
|
+
.map((q) => {
|
|
247
|
+
const raw = q.key.type === "Identifier" ? q.key.name : String(q.key.value);
|
|
248
|
+
return `(${rustStr(raw)}, ${attrOf(s, raw, q.value, "vnode")})`;
|
|
249
|
+
});
|
|
250
|
+
if (entries.length)
|
|
251
|
+
sources.push(`&[${entries.join(", ")}]`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const ids = base === null && slotted === null
|
|
255
|
+
? rustStr(scopeId === null ? "" : ` ${scopeId}`)
|
|
256
|
+
: `&fv::scope_attrs(${base ?? '""'}, ${scopeId === null ? '""' : rustStr(scopeId)}, ${slotted ?? '""'})`;
|
|
257
|
+
const linkOwn = [
|
|
258
|
+
`("aria-current", if fv_link.exact { fv::Attr::str(${rustStr(ariaCurrent)}) } else { fv::Attr::Undefined })`,
|
|
259
|
+
'("href", fv::Attr::str(&fv_link.href))',
|
|
260
|
+
`("class", fv::Attr::from(fv::class_names(&[${linkItems.join(", ")}])))`,
|
|
261
|
+
];
|
|
262
|
+
e.stmt(`fv::attrs_into(out, &[&[${linkOwn.join(", ")}], &fv::merge_props(&[${sources.join(", ")}])], 1, ${ids});`);
|
|
263
|
+
e.close();
|
|
264
|
+
}
|
|
236
265
|
e.lit(">");
|
|
237
266
|
if (slots && slots.type !== "NullLiteral") {
|
|
238
267
|
if (slots.type !== "ObjectExpression")
|
|
@@ -249,24 +278,3 @@ export function routerLink(s, e, n) {
|
|
|
249
278
|
e.lit("</a>");
|
|
250
279
|
e.close();
|
|
251
280
|
}
|
|
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
|
-
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Component } from "../model.ts";
|
|
2
|
+
import { type Plugin } from "../plugin.ts";
|
|
3
|
+
declare module "../model.ts" {
|
|
4
|
+
interface PluginTys {
|
|
5
|
+
route: {
|
|
6
|
+
k: "route";
|
|
7
|
+
};
|
|
8
|
+
params: {
|
|
9
|
+
k: "params";
|
|
10
|
+
};
|
|
11
|
+
queryobj: {
|
|
12
|
+
k: "queryobj";
|
|
13
|
+
};
|
|
14
|
+
query: {
|
|
15
|
+
k: "query";
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export interface RouteDef {
|
|
20
|
+
path: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
children?: RouteDef[];
|
|
23
|
+
fullPath: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function allRoutes(routes: RouteDef[]): RouteDef[];
|
|
26
|
+
interface RouterRun {
|
|
27
|
+
routes: RouteDef[] | null;
|
|
28
|
+
file: string | null;
|
|
29
|
+
base: string;
|
|
30
|
+
linkActive: string;
|
|
31
|
+
linkExactActive: string;
|
|
32
|
+
readers: Set<Component>;
|
|
33
|
+
views: Set<Component>;
|
|
34
|
+
}
|
|
35
|
+
interface RouterScope {
|
|
36
|
+
useRoute: string | null;
|
|
37
|
+
components: Map<string, "RouterLink" | "RouterView">;
|
|
38
|
+
}
|
|
39
|
+
export declare function readRoutes(root: string, file: string): RouteDef[];
|
|
40
|
+
export declare function routesSource(routes: RouteDef[], file: string): string;
|
|
41
|
+
export declare const router: Plugin<RouterRun, RouterScope>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BOOL, fail, GenError, opt, rustStr, STR } from "../model.js";
|
|
4
|
+
import { CONFIG_FILE } from "../context.js";
|
|
5
|
+
import { expr } from "../expr.js";
|
|
6
|
+
import { boolOf, pathOf } from "../narrowing.js";
|
|
7
|
+
import { atom, bare, not, strArg } from "../parens.js";
|
|
8
|
+
import { runOf, scopeOf } from "../plugin.js";
|
|
9
|
+
import { header } from "../rust.js";
|
|
10
|
+
import { scopeIdOf } from "./scoped.js";
|
|
11
|
+
import { routerLink } from "./router-link.js";
|
|
12
|
+
export function allRoutes(routes) {
|
|
13
|
+
return routes.flatMap((r) => [r, ...allRoutes(r.children ?? [])]);
|
|
14
|
+
}
|
|
15
|
+
function typeofString(n) {
|
|
16
|
+
if (n.type !== "BinaryExpression" || (n.operator !== "===" && n.operator !== "!=="))
|
|
17
|
+
return null;
|
|
18
|
+
const isTypeof = (m) => m.type === "UnaryExpression" && m.operator === "typeof";
|
|
19
|
+
const isString = (m) => m.type === "StringLiteral" && m.value === "string";
|
|
20
|
+
const target = isTypeof(n.left) && isString(n.right) ? n.left.argument : isTypeof(n.right) && isString(n.left) ? n.right.argument : null;
|
|
21
|
+
return target ? { target, negated: n.operator === "!==" } : null;
|
|
22
|
+
}
|
|
23
|
+
function theRoute(s, n) {
|
|
24
|
+
if (!runOf(router).routes)
|
|
25
|
+
fail(s.comp, `reading the route needs \`routes\` in ${CONFIG_FILE}`, n);
|
|
26
|
+
runOf(router).readers.add(s.comp);
|
|
27
|
+
return { code: "fv_route", ty: { k: "route" } };
|
|
28
|
+
}
|
|
29
|
+
function routeField(s, base, prop, n) {
|
|
30
|
+
if (base.ty.k === "params") {
|
|
31
|
+
return { code: `fv_route.param(${rustStr(prop)})`, ty: opt(STR) };
|
|
32
|
+
}
|
|
33
|
+
if (base.ty.k === "queryobj")
|
|
34
|
+
return { code: `fv_route.query(${rustStr(prop)})`, ty: { k: "query" } };
|
|
35
|
+
switch (prop) {
|
|
36
|
+
case "path":
|
|
37
|
+
return { code: "fv_route.path()", ty: STR };
|
|
38
|
+
case "hash":
|
|
39
|
+
return { code: "fv_route.hash()", ty: STR };
|
|
40
|
+
case "name":
|
|
41
|
+
return { code: "fv_route.name()", ty: opt(STR) };
|
|
42
|
+
case "params":
|
|
43
|
+
return { code: "fv_route", ty: { k: "params" } };
|
|
44
|
+
case "query":
|
|
45
|
+
return { code: "fv_route", ty: { k: "queryobj" } };
|
|
46
|
+
case "fullPath":
|
|
47
|
+
return { code: "fv_route.full_path()", ty: STR };
|
|
48
|
+
}
|
|
49
|
+
return fail(s.comp, `\`route.${prop}\` is not available on the server: \`path\`, \`fullPath\`, \`hash\`, \`name\`, \`params\` and \`query\` are`, n);
|
|
50
|
+
}
|
|
51
|
+
export function readRoutes(root, file) {
|
|
52
|
+
const raw = JSON.parse(readFileSync(join(root, file), "utf8"));
|
|
53
|
+
if (!Array.isArray(raw))
|
|
54
|
+
throw new GenError(`${file} lists the routes in an array`);
|
|
55
|
+
const read = (list, parent) => list.map((r) => {
|
|
56
|
+
const o = (typeof r === "string" ? { path: r } : r);
|
|
57
|
+
if (typeof o?.path !== "string" || (o.name !== undefined && typeof o.name !== "string") || (o.children !== undefined && !Array.isArray(o.children))) {
|
|
58
|
+
throw new GenError(`${file}: a route is a path, or \`{ "path": "…", "name": "…", "children": [ … ] }\``);
|
|
59
|
+
}
|
|
60
|
+
const fullPath = parent === null || o.path.startsWith("/") ? o.path : `${parent}${parent.endsWith("/") || o.path === "" ? "" : "/"}${o.path}`;
|
|
61
|
+
const def = { path: o.path, fullPath };
|
|
62
|
+
if (o.name !== undefined)
|
|
63
|
+
def.name = o.name;
|
|
64
|
+
if (o.children)
|
|
65
|
+
def.children = read(o.children, fullPath);
|
|
66
|
+
return def;
|
|
67
|
+
});
|
|
68
|
+
return read(raw, null);
|
|
69
|
+
}
|
|
70
|
+
function routeDefs(routes, depth) {
|
|
71
|
+
const pad = " ".repeat(depth);
|
|
72
|
+
return routes
|
|
73
|
+
.map((r) => {
|
|
74
|
+
const name = r.name === undefined ? "None" : `Some(${rustStr(r.name)})`;
|
|
75
|
+
const children = r.children?.length ? `&[\n${routeDefs(r.children, depth + 1)}\n${pad}]` : "&[]";
|
|
76
|
+
return `${pad}ferrovue::RouteDef { path: ${rustStr(r.path)}, name: ${name}, children: ${children} },`;
|
|
77
|
+
})
|
|
78
|
+
.join("\n");
|
|
79
|
+
}
|
|
80
|
+
export function routesSource(routes, file) {
|
|
81
|
+
return `${header(file, "the routes file")}
|
|
82
|
+
//! The app's routes: what \`<RouterLink>\` resolves against and \`useRoute()\` reads.
|
|
83
|
+
|
|
84
|
+
/// Each route: its vue-router path, its name if it has one, and the routes nested in it.
|
|
85
|
+
pub const ROUTES: &[ferrovue::RouteDef<'static>] = &[
|
|
86
|
+
${routeDefs(routes, 1)}
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/// Every route's full path, nested ones included.
|
|
90
|
+
pub const PATHS: &[&str] = &[
|
|
91
|
+
${allRoutes(routes).map((r) => ` ${rustStr(r.fullPath)},`).join("\n")}
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/// The history's base, which every link's \`href\` starts with.
|
|
95
|
+
pub const BASE: &str = ${rustStr(runOf(router).base)};
|
|
96
|
+
|
|
97
|
+
/// The router these routes make: build it once, and resolve each request's location with
|
|
98
|
+
/// [\`ferrovue::Router::at\`].
|
|
99
|
+
pub fn router() -> ferrovue::Router {
|
|
100
|
+
ferrovue::Router::tree(ROUTES).with_base(BASE)
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
export const router = {
|
|
105
|
+
name: "router",
|
|
106
|
+
configure(config, root) {
|
|
107
|
+
const r = config.router ?? (config.routes ? { routes: config.routes } : null);
|
|
108
|
+
return {
|
|
109
|
+
routes: r ? readRoutes(root, r.routes) : null,
|
|
110
|
+
file: r?.routes ?? null,
|
|
111
|
+
base: r?.base ?? "",
|
|
112
|
+
linkActive: r?.linkActiveClass ?? "router-link-active",
|
|
113
|
+
linkExactActive: r?.linkExactActiveClass ?? "router-link-exact-active",
|
|
114
|
+
readers: new Set(),
|
|
115
|
+
views: new Set(),
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
compiled(comp, code, script) {
|
|
119
|
+
const imported = (exported) => script.some((st) => st.type === "ImportDeclaration" && st.source.value === "vue-router" &&
|
|
120
|
+
st.specifiers.some((sp) => sp.type === "ImportSpecifier" && (sp.imported.name ?? sp.imported.value) === exported));
|
|
121
|
+
const run = runOf(router);
|
|
122
|
+
if (code.includes('_resolveComponent("RouterLink")') || imported("RouterLink") || code.includes("_ctx.$route"))
|
|
123
|
+
run.readers.add(comp);
|
|
124
|
+
if (code.includes('_resolveComponent("RouterView")') || imported("RouterView"))
|
|
125
|
+
run.views.add(comp);
|
|
126
|
+
},
|
|
127
|
+
scope: () => ({ useRoute: null, components: new Map() }),
|
|
128
|
+
scriptImport(s, st, from) {
|
|
129
|
+
if (from !== "vue-router")
|
|
130
|
+
return false;
|
|
131
|
+
const own = scopeOf(router, s);
|
|
132
|
+
for (const sp of st.specifiers) {
|
|
133
|
+
const name = sp.type === "ImportSpecifier" ? (sp.imported.name ?? sp.imported.value) : null;
|
|
134
|
+
if (name === "useRoute")
|
|
135
|
+
own.useRoute = sp.local.name;
|
|
136
|
+
else if (name === "RouterLink" || name === "RouterView")
|
|
137
|
+
own.components.set(sp.local.name, name);
|
|
138
|
+
else
|
|
139
|
+
s.clientOnly.set(sp.local.name, `\`${name}\` from vue-router does not run on the server`);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
scriptBinding(s, d) {
|
|
144
|
+
const { useRoute } = scopeOf(router, s);
|
|
145
|
+
const init = d.init;
|
|
146
|
+
if (useRoute === null || d.id.type !== "Identifier" || init?.type !== "CallExpression" || init.callee.type !== "Identifier" || init.callee.name !== useRoute)
|
|
147
|
+
return false;
|
|
148
|
+
s.setup.set(d.id.name, theRoute(s, d));
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
global: (s, name, n) => (name === "$route" ? theRoute(s, n) : null),
|
|
152
|
+
member(s, base, prop, n, computed) {
|
|
153
|
+
if (base.ty.k === "params" || base.ty.k === "queryobj" || (base.ty.k === "route" && !computed))
|
|
154
|
+
return routeField(s, base, prop, n);
|
|
155
|
+
return null;
|
|
156
|
+
},
|
|
157
|
+
equality(s, n) {
|
|
158
|
+
const tested = typeofString(n);
|
|
159
|
+
if (!tested)
|
|
160
|
+
return null;
|
|
161
|
+
const v = expr(s, tested.target);
|
|
162
|
+
if (v.ty.k !== "query")
|
|
163
|
+
fail(s.comp, '`typeof` tests a query value only, as `typeof route.query.q === "string"`', n);
|
|
164
|
+
const one = `${atom(v.code)}.attr_value().is_some()`;
|
|
165
|
+
return boolOf(tested.negated ? not(one) : one);
|
|
166
|
+
},
|
|
167
|
+
presence(s, n) {
|
|
168
|
+
const t = typeofString(n);
|
|
169
|
+
if (!t)
|
|
170
|
+
return undefined;
|
|
171
|
+
const path = pathOf(t.target);
|
|
172
|
+
const v = expr(s, t.target);
|
|
173
|
+
if (path === null || v.ty.k !== "query")
|
|
174
|
+
return null;
|
|
175
|
+
const option = `${atom(v.code)}.attr_value()`;
|
|
176
|
+
return { path, of: STR, negated: t.negated, truthy: false, option, present: `${option}.is_some()`, pattern: (name) => `let Some(${name}) = ${option}` };
|
|
177
|
+
},
|
|
178
|
+
values: {
|
|
179
|
+
describe: (ty) => (ty.k === "query" ? "a query value" : undefined),
|
|
180
|
+
truthy: (v) => (v.ty.k === "query" ? `${atom(v.code)}.truthy()` : undefined),
|
|
181
|
+
interpolate(e, v) {
|
|
182
|
+
if (v.ty.k !== "query")
|
|
183
|
+
return false;
|
|
184
|
+
e.stmt(`${atom(v.code)}.write_display(out);`);
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
unbindable: (ty) => ty.k === "query"
|
|
188
|
+
? { what: "a query value, which is an array when its key is repeated", fix: 'narrow it to one string, as `typeof route.query.q === "string" ? route.query.q : ""`' }
|
|
189
|
+
: undefined,
|
|
190
|
+
nullish(s, a, b, n) {
|
|
191
|
+
if (a.ty.k !== "query")
|
|
192
|
+
return undefined;
|
|
193
|
+
if (b.ty.k !== "str")
|
|
194
|
+
fail(s.comp, "`??` after a query value takes a string", n);
|
|
195
|
+
return { code: `${atom(a.code)}.or(${bare(b.code)})`, ty: a.ty };
|
|
196
|
+
},
|
|
197
|
+
equals(a, b) {
|
|
198
|
+
if (a.ty.k === "query" && b.ty.k === "str")
|
|
199
|
+
return `${atom(a.code)}.is(${strArg(b.code)})`;
|
|
200
|
+
if (b.ty.k === "query" && a.ty.k === "str")
|
|
201
|
+
return `${atom(b.code)}.is(${strArg(a.code)})`;
|
|
202
|
+
if (a.ty.k === "query" && b.ty.k === "undef")
|
|
203
|
+
return `${atom(a.code)}.is_undefined()`;
|
|
204
|
+
if (b.ty.k === "query" && a.ty.k === "undef")
|
|
205
|
+
return `${atom(b.code)}.is_undefined()`;
|
|
206
|
+
return undefined;
|
|
207
|
+
},
|
|
208
|
+
isArray: (v) => (v.ty.k === "query" ? { code: `${atom(v.code)}.is_array()`, ty: BOOL } : undefined),
|
|
209
|
+
},
|
|
210
|
+
resolveComponent(s, local, name) {
|
|
211
|
+
if (name !== "RouterLink" && name !== "RouterView")
|
|
212
|
+
return false;
|
|
213
|
+
scopeOf(router, s).components.set(local, name);
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
216
|
+
component(s, e, n) {
|
|
217
|
+
const target = n.arguments[0];
|
|
218
|
+
const { components } = scopeOf(router, s);
|
|
219
|
+
const routed = target.type === "Identifier"
|
|
220
|
+
? components.get(target.name)
|
|
221
|
+
: target.type === "MemberExpression" && target.object.name === "$setup"
|
|
222
|
+
? components.get(target.computed ? target.property.value : target.property.name)
|
|
223
|
+
: undefined;
|
|
224
|
+
if (routed === "RouterLink")
|
|
225
|
+
routerLink(s, e, n);
|
|
226
|
+
else if (routed === "RouterView") {
|
|
227
|
+
if (scopeIdOf(s.comp) !== null)
|
|
228
|
+
fail(s.comp, "`<RouterView>` in a component with `<style scoped>` gives the page this component's id, which the server's page does not carry", n);
|
|
229
|
+
e.stmt("fv_slots.router_view.render_to(out);");
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
return false;
|
|
233
|
+
return true;
|
|
234
|
+
},
|
|
235
|
+
child(s, child, n) {
|
|
236
|
+
if (runOf(router).views.has(child)) {
|
|
237
|
+
fail(s.comp, `${child.name} holds \`<RouterView>\`: the server renders it at the top, never as a child`, n);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
slotFields: (comp) => (runOf(router).views.has(comp) ? [{ js: "routerView", rust: "router_view", doc: "The page `<RouterView>` shows." }] : []),
|
|
241
|
+
params: [
|
|
242
|
+
{
|
|
243
|
+
name: "fv_route",
|
|
244
|
+
ty: "&fv::Route<'_>",
|
|
245
|
+
pageTy: "&'p fv::Route<'p>",
|
|
246
|
+
reads: (c) => runOf(router).readers.has(c),
|
|
247
|
+
test: { lines: ["let router = route_table::router();", "let route = router.at(&fixture.route);"], arg: "&route", fixture: true },
|
|
248
|
+
fixtureField: ' #[serde(rename = "$route", default = "Fixture::root")]\n route: String,',
|
|
249
|
+
fixtureDefault: ' fn root() -> String {\n "/".to_owned()\n }',
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
modules() {
|
|
253
|
+
const { routes, file } = runOf(router);
|
|
254
|
+
return routes ? [["route_table.rs", routesSource(routes, file)]] : [];
|
|
255
|
+
},
|
|
256
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Component } from "../model.ts";
|
|
2
|
+
import { type ScopeIdMode } from "../context.ts";
|
|
3
|
+
import { type Plugin } from "../plugin.ts";
|
|
4
|
+
interface ScopedRun {
|
|
5
|
+
mode: ScopeIdMode;
|
|
6
|
+
viteRoot: string;
|
|
7
|
+
ids: Map<Component, string>;
|
|
8
|
+
slotted: Set<Component>;
|
|
9
|
+
}
|
|
10
|
+
export declare function scopeIdOf(comp: Component): string | null;
|
|
11
|
+
export declare const scoped: Plugin<ScopedRun>;
|
|
12
|
+
export {};
|
|
@@ -1,20 +1,23 @@
|
|
|
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
1
|
import { parse as parseJs } from "@babel/parser";
|
|
13
|
-
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { relative, resolve as resolvePath, sep } from "node:path";
|
|
4
|
+
import { fail, rustStr } from "../model.js";
|
|
5
|
+
import {} from "../context.js";
|
|
6
|
+
import { runOf } from "../plugin.js";
|
|
7
|
+
export function scopeIdOf(comp) {
|
|
8
|
+
return runOf(scoped).ids.get(comp) ?? null;
|
|
9
|
+
}
|
|
10
|
+
function scopeHash(file, source) {
|
|
11
|
+
const { mode, viteRoot } = runOf(scoped);
|
|
12
|
+
const path = relative(viteRoot, resolvePath(file)).split(sep).join("/");
|
|
13
|
+
return createHash("sha256")
|
|
14
|
+
.update(mode === "filepath" ? path : path + source)
|
|
15
|
+
.digest("hex")
|
|
16
|
+
.slice(0, 8);
|
|
17
|
+
}
|
|
14
18
|
function flowOf(comp, ssr, children) {
|
|
15
19
|
const calls = [];
|
|
16
20
|
const outlets = [];
|
|
17
|
-
// `const _component_Tree = _resolveComponent("Tree", true)`: the component itself, by name.
|
|
18
21
|
const self = new Set();
|
|
19
22
|
const resolve = (target) => {
|
|
20
23
|
if (target?.type === "MemberExpression" && target.object.name === "$setup") {
|
|
@@ -41,7 +44,6 @@ function flowOf(comp, ssr, children) {
|
|
|
41
44
|
const child = resolve(a[0]);
|
|
42
45
|
if (child)
|
|
43
46
|
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
47
|
walk(a[2], child);
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
@@ -58,11 +60,7 @@ function flowOf(comp, ssr, children) {
|
|
|
58
60
|
walk(parseJs(ssr, { sourceType: "module" }).program, null);
|
|
59
61
|
return { calls, outlets };
|
|
60
62
|
}
|
|
61
|
-
|
|
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) {
|
|
63
|
+
function scopeFlow(read) {
|
|
66
64
|
const flows = read.map((r) => ({ comp: r.comp, ...flowOf(r.comp, r.ssr, r.children) }));
|
|
67
65
|
const byName = new Map(read.map((r) => [r.comp.name, r.comp]));
|
|
68
66
|
const passes = (name) => (name !== null && byName.get(name)?.passesSlotIds) ?? false;
|
|
@@ -81,10 +79,47 @@ export function scopeFlow(read) {
|
|
|
81
79
|
const child = byName.get(c.child);
|
|
82
80
|
if (!child || child.inherits)
|
|
83
81
|
continue;
|
|
84
|
-
if (f.comp
|
|
82
|
+
if (scopeIdOf(f.comp) !== null || (c.passesAttrs && f.comp.inherits && child.inheritAttrs) || passes(c.within)) {
|
|
85
83
|
child.inherits = changed = true;
|
|
86
84
|
}
|
|
87
85
|
}
|
|
88
86
|
}
|
|
89
87
|
}
|
|
90
88
|
}
|
|
89
|
+
function childIds(s, child, passesAttrs, inSlot, n) {
|
|
90
|
+
const base = passesAttrs && child.inheritAttrs ? s.attrs : null;
|
|
91
|
+
const own = scopeIdOf(s.comp);
|
|
92
|
+
const slotted = inSlot ? s.sid : null;
|
|
93
|
+
let code;
|
|
94
|
+
if (base === null && slotted === null)
|
|
95
|
+
code = own === null ? null : rustStr(` ${own}`);
|
|
96
|
+
else if (own === null && slotted === null)
|
|
97
|
+
code = base;
|
|
98
|
+
else
|
|
99
|
+
code = `&fv::scope_attrs(${base ?? '""'}, ${own === null ? '""' : rustStr(own)}, ${slotted ?? '""'})`;
|
|
100
|
+
if (code !== null && !child.inherits)
|
|
101
|
+
fail(s.comp, `${child.name} is handed scope ids its render does not take`, n);
|
|
102
|
+
return code;
|
|
103
|
+
}
|
|
104
|
+
export const scoped = {
|
|
105
|
+
name: "scoped",
|
|
106
|
+
configure: (config, root) => ({
|
|
107
|
+
mode: config.scopeId ?? "filepath-source",
|
|
108
|
+
viteRoot: resolvePath(root, config.viteRoot ?? "."),
|
|
109
|
+
ids: new Map(),
|
|
110
|
+
slotted: new Set(),
|
|
111
|
+
}),
|
|
112
|
+
sfc(comp, descriptor, file, source) {
|
|
113
|
+
const run = runOf(scoped);
|
|
114
|
+
if (descriptor.styles.some((st) => st.scoped))
|
|
115
|
+
run.ids.set(comp, `data-v-${scopeHash(file, source)}`);
|
|
116
|
+
if (descriptor.slotted)
|
|
117
|
+
run.slotted.add(comp);
|
|
118
|
+
},
|
|
119
|
+
templateOptions(comp) {
|
|
120
|
+
const id = scopeIdOf(comp);
|
|
121
|
+
return { id: id ?? comp.name, scoped: id !== null, slotted: runOf(scoped).slotted.has(comp) };
|
|
122
|
+
},
|
|
123
|
+
analyse: (read) => scopeFlow(read),
|
|
124
|
+
childIds,
|
|
125
|
+
};
|