ferrovue 0.4.0 → 0.6.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 +378 -63
- package/dist/attrs.d.ts +3 -2
- package/dist/attrs.js +31 -26
- package/dist/calls.js +17 -15
- package/dist/children.d.ts +11 -4
- package/dist/children.js +110 -44
- package/dist/classes.d.ts +4 -0
- package/dist/classes.js +33 -11
- package/dist/cli.js +31 -5
- package/dist/client.d.ts +44 -6
- package/dist/client.js +182 -18
- package/dist/compiler.d.ts +1 -1
- package/dist/compiler.js +42 -10
- package/dist/component.d.ts +3 -1
- package/dist/component.js +114 -20
- package/dist/conformance.d.ts +64 -0
- package/dist/conformance.js +276 -0
- package/dist/constants.d.ts +42 -0
- package/dist/constants.js +292 -0
- package/dist/context.d.ts +40 -7
- package/dist/context.js +14 -5
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.js +30 -0
- package/dist/dynamic.d.ts +18 -0
- package/dist/dynamic.js +576 -0
- package/dist/errors.d.ts +1034 -0
- package/dist/errors.js +425 -0
- package/dist/expr.d.ts +1 -0
- package/dist/expr.js +120 -49
- package/dist/file-routes.d.ts +33 -0
- package/dist/file-routes.js +224 -0
- package/dist/fixture.d.ts +19 -10
- package/dist/fixture.js +23 -12
- package/dist/hydration.d.ts +15 -0
- package/dist/hydration.js +80 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/link-router.d.ts +24 -0
- package/dist/link-router.js +37 -0
- package/dist/lists.d.ts +4 -0
- package/dist/lists.js +32 -23
- package/dist/loops.js +18 -14
- package/dist/model.d.ts +43 -1
- package/dist/model.js +167 -7
- package/dist/narrowing.d.ts +8 -0
- package/dist/narrowing.js +40 -9
- package/dist/numbers.js +3 -2
- package/dist/page-routes.d.ts +9 -0
- package/dist/page-routes.js +10 -0
- package/dist/plugin.d.ts +13 -1
- package/dist/plugin.js +3 -0
- package/dist/plugins/client-only.d.ts +8 -0
- package/dist/plugins/client-only.js +110 -0
- package/dist/plugins/head.d.ts +15 -0
- package/dist/plugins/head.js +385 -0
- package/dist/plugins/i18n.js +14 -14
- package/dist/plugins/index.js +5 -1
- package/dist/plugins/provide.d.ts +44 -0
- package/dist/plugins/provide.js +528 -0
- package/dist/plugins/router-link.js +39 -32
- package/dist/plugins/router.d.ts +4 -1
- package/dist/plugins/router.js +65 -19
- package/dist/plugins/scoped.js +1 -1
- package/dist/plugins/shared-types.js +1 -1
- package/dist/plugins/stores.js +45 -33
- package/dist/plugins/teleport.js +3 -3
- package/dist/plugins/twins.d.ts +10 -0
- package/dist/plugins/twins.js +164 -0
- package/dist/routes.d.ts +13 -0
- package/dist/routes.js +12 -0
- package/dist/rust.js +64 -32
- package/dist/script.d.ts +3 -0
- package/dist/script.js +138 -20
- package/dist/settle.d.ts +8 -0
- package/dist/settle.js +39 -0
- package/dist/slots.js +36 -22
- package/dist/ssr.d.ts +3 -0
- package/dist/ssr.js +29 -0
- package/dist/strings.d.ts +3 -0
- package/dist/strings.js +27 -13
- package/dist/styles.js +13 -13
- package/dist/template.js +102 -21
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +3 -29
- package/dist/types.d.ts +5 -0
- package/dist/typescript.d.ts +2 -1
- package/dist/typescript.js +52 -21
- package/dist/vhtml.d.ts +4 -0
- package/dist/vhtml.js +63 -0
- package/dist/vite.d.ts +8 -2
- package/dist/vite.js +75 -14
- package/package.json +23 -5
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSSRApp } from "vue";
|
|
1
|
+
import { createSSRApp, createTextVNode, defineComponent, h, hydrateOnIdle, hydrateOnMediaQuery, hydrateOnVisible, onMounted, ref, } from "vue";
|
|
2
2
|
const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
|
|
3
3
|
const NON_FINITE = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY };
|
|
4
4
|
function parseJson(text) {
|
|
@@ -11,42 +11,86 @@ function parseJson(text) {
|
|
|
11
11
|
const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
|
|
12
12
|
return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
|
|
13
13
|
}
|
|
14
|
-
/** Give Pinia the state the server rendered with
|
|
14
|
+
/** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
|
|
15
15
|
* the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
|
|
16
16
|
export function hydrateState(pinia, id = "__pinia", doc = document) {
|
|
17
17
|
const text = doc.getElementById(id)?.textContent;
|
|
18
18
|
if (text)
|
|
19
19
|
pinia.state.value = parseJson(text);
|
|
20
20
|
}
|
|
21
|
+
const INTERACTIONS = ["pointerenter", "click", "focus"];
|
|
22
|
+
const strategy = (wait) => (el, hydrate) => wait(() => void hydrate(), (each) => void each(el)) ?? (() => { });
|
|
23
|
+
const idle = (el, hydrate) => {
|
|
24
|
+
if (typeof requestIdleCallback === "function")
|
|
25
|
+
return strategy(hydrateOnIdle())(el, hydrate);
|
|
26
|
+
const timer = setTimeout(() => void hydrate(), 200);
|
|
27
|
+
return () => clearTimeout(timer);
|
|
28
|
+
};
|
|
29
|
+
const interaction = (names) => (el, hydrate) => {
|
|
30
|
+
const events = names.length ? names : INTERACTIONS;
|
|
31
|
+
const early = [];
|
|
32
|
+
const stop = () => {
|
|
33
|
+
for (const name of events)
|
|
34
|
+
el.removeEventListener(name, listen, true);
|
|
35
|
+
};
|
|
36
|
+
const replay = () => {
|
|
37
|
+
stop();
|
|
38
|
+
for (const e of early.splice(0)) {
|
|
39
|
+
if (e.target instanceof Node && e.target.isConnected)
|
|
40
|
+
e.target.dispatchEvent(new e.constructor(e.type, e));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function listen(e) {
|
|
44
|
+
if (early.push(e) === 1)
|
|
45
|
+
void hydrate().then((mounted) => (mounted ? replay() : stop()));
|
|
46
|
+
}
|
|
47
|
+
for (const name of events)
|
|
48
|
+
el.addEventListener(name, listen, true);
|
|
49
|
+
return stop;
|
|
50
|
+
};
|
|
51
|
+
function trigger(when) {
|
|
52
|
+
const colon = when.indexOf(":");
|
|
53
|
+
const [kind, arg] = colon < 0 ? [when, undefined] : [when.slice(0, colon), when.slice(colon + 1)];
|
|
54
|
+
if (kind === "visible")
|
|
55
|
+
return strategy(hydrateOnVisible(arg ? { rootMargin: arg } : undefined));
|
|
56
|
+
if (kind === "idle" && arg === undefined)
|
|
57
|
+
return idle;
|
|
58
|
+
if (kind === "interaction")
|
|
59
|
+
return interaction((arg ?? "").split(/\s+/).filter(Boolean));
|
|
60
|
+
if (kind === "media" && arg !== undefined)
|
|
61
|
+
return strategy(hydrateOnMediaQuery(arg || "all"));
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
21
64
|
/** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
|
|
22
65
|
* `Html::island` wrote becomes an app of the component of that name, given the props the server
|
|
23
66
|
* rendered it with, mounted where it is. A loader is called only for an island the page holds, all
|
|
24
|
-
* of them at once, and the islands mount in document order once every one has loaded.
|
|
67
|
+
* of them at once, and the islands mount in document order once every one has loaded. An island
|
|
68
|
+
* with a `data-hydrate` (`Html::hydrate`) waits instead: once it is visible (or within a root margin
|
|
69
|
+
* of the viewport), the browser is idle,
|
|
70
|
+
* it is interacted with or a media query matches, its component is loaded and it hydrates. */
|
|
25
71
|
export async function mountIslands(components, options = {}) {
|
|
26
72
|
const root = options.root ?? document;
|
|
27
73
|
const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
|
|
28
74
|
const elements = Array.from(root.querySelectorAll("[data-island]"));
|
|
29
75
|
const loading = new Map();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
loading.set(name, load(components[name]));
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
options.router?.isReady(),
|
|
38
|
-
]);
|
|
76
|
+
const loadIsland = (name) => {
|
|
77
|
+
let pending = loading.get(name);
|
|
78
|
+
if (!pending)
|
|
79
|
+
loading.set(name, (pending = load(components[name])));
|
|
80
|
+
return pending;
|
|
81
|
+
};
|
|
82
|
+
const ready = options.router?.isReady();
|
|
39
83
|
const apps = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
84
|
+
const waiting = [];
|
|
85
|
+
let stopped = false;
|
|
86
|
+
const mount = (el, name, component) => {
|
|
43
87
|
if (!component) {
|
|
44
88
|
report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
|
|
45
|
-
|
|
89
|
+
return;
|
|
46
90
|
}
|
|
47
91
|
if (component instanceof Error) {
|
|
48
92
|
report(el, `${name} did not load: ${component.message}`);
|
|
49
|
-
|
|
93
|
+
return;
|
|
50
94
|
}
|
|
51
95
|
let props;
|
|
52
96
|
try {
|
|
@@ -54,7 +98,7 @@ export async function mountIslands(components, options = {}) {
|
|
|
54
98
|
}
|
|
55
99
|
catch {
|
|
56
100
|
report(el, `the props of ${name} are not JSON`);
|
|
57
|
-
|
|
101
|
+
return;
|
|
58
102
|
}
|
|
59
103
|
const app = createSSRApp(component, props);
|
|
60
104
|
if (options.pinia)
|
|
@@ -65,15 +109,86 @@ export async function mountIslands(components, options = {}) {
|
|
|
65
109
|
app.use(plugin);
|
|
66
110
|
app.mount(el);
|
|
67
111
|
apps.push(app);
|
|
112
|
+
};
|
|
113
|
+
const now = [];
|
|
114
|
+
for (const el of elements) {
|
|
115
|
+
const name = el.dataset.island ?? "";
|
|
116
|
+
const given = Object.hasOwn(components, name);
|
|
117
|
+
const when = el.dataset.hydrate;
|
|
118
|
+
const wait = given && when !== undefined ? trigger(when) : undefined;
|
|
119
|
+
if (!wait) {
|
|
120
|
+
if (given && when !== undefined)
|
|
121
|
+
report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which this version of ferrovue does not know, so it hydrated at once`);
|
|
122
|
+
if (given)
|
|
123
|
+
void loadIsland(name);
|
|
124
|
+
now.push(el);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
let started = false;
|
|
128
|
+
try {
|
|
129
|
+
waiting.push(wait(el, async () => {
|
|
130
|
+
if (started || stopped)
|
|
131
|
+
return false;
|
|
132
|
+
started = true;
|
|
133
|
+
const [component] = await Promise.all([loadIsland(name), ready]);
|
|
134
|
+
if (stopped)
|
|
135
|
+
return false;
|
|
136
|
+
mount(el, name, component);
|
|
137
|
+
return true;
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
report(el, `${name} has data-hydrate=${JSON.stringify(when)}, which the browser rejects (${e instanceof Error ? e.message : String(e)}), so it hydrated at once`);
|
|
142
|
+
void loadIsland(name);
|
|
143
|
+
now.push(el);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const names = new Set(now.map((el) => el.dataset.island ?? "").filter((name) => Object.hasOwn(components, name)));
|
|
147
|
+
const [loaded] = await Promise.all([
|
|
148
|
+
Promise.all([...names].map(async (name) => [name, await loadIsland(name)])).then((pairs) => new Map(pairs)),
|
|
149
|
+
ready,
|
|
150
|
+
]);
|
|
151
|
+
for (const el of now) {
|
|
152
|
+
const name = el.dataset.island ?? "";
|
|
153
|
+
mount(el, name, loaded.get(name));
|
|
68
154
|
}
|
|
69
155
|
return {
|
|
70
156
|
apps,
|
|
71
157
|
unmount() {
|
|
158
|
+
stopped = true;
|
|
159
|
+
for (const stop of waiting.splice(0))
|
|
160
|
+
stop();
|
|
72
161
|
for (const app of apps.splice(0))
|
|
73
162
|
app.unmount();
|
|
74
163
|
},
|
|
75
164
|
};
|
|
76
165
|
}
|
|
166
|
+
/** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
|
|
167
|
+
* browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
|
|
168
|
+
* compile the default slot, so anything may go there: a component library's components, code that
|
|
169
|
+
* reads `window`. The swap happens only where Vue runs on the page, as in an island. */
|
|
170
|
+
export const ClientOnly = Object.assign(defineComponent({
|
|
171
|
+
name: "ClientOnly",
|
|
172
|
+
inheritAttrs: false,
|
|
173
|
+
setup(_, { slots }) {
|
|
174
|
+
const mounted = ref(false);
|
|
175
|
+
onMounted(() => {
|
|
176
|
+
mounted.value = true;
|
|
177
|
+
});
|
|
178
|
+
return () => (mounted.value ? slots.default?.() : slots.fallback?.());
|
|
179
|
+
},
|
|
180
|
+
}), {
|
|
181
|
+
ssrRender(ctx, push, parent) {
|
|
182
|
+
const fallback = ctx.$slots.fallback;
|
|
183
|
+
if (!fallback) {
|
|
184
|
+
push("<!---->");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
push("<!--[-->");
|
|
188
|
+
fallback({}, push, parent, "");
|
|
189
|
+
push("<!--]-->");
|
|
190
|
+
},
|
|
191
|
+
});
|
|
77
192
|
async function load(component) {
|
|
78
193
|
if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
|
|
79
194
|
return component;
|
|
@@ -85,3 +200,52 @@ async function load(component) {
|
|
|
85
200
|
return e instanceof Error ? e : new Error(String(e));
|
|
86
201
|
}
|
|
87
202
|
}
|
|
203
|
+
/** The app of a page: its root renders `layout` with the record's props, and each slot as the
|
|
204
|
+
* components the record names, in order, as a plain array, which hydrates the markup the server
|
|
205
|
+
* wrote with no fragment of its own. `components` must hold every component the record names. */
|
|
206
|
+
export function createPageApp(layout, record, components, options = {}) {
|
|
207
|
+
const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
|
|
208
|
+
name,
|
|
209
|
+
() => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
|
|
210
|
+
]));
|
|
211
|
+
const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
|
|
212
|
+
if (options.pinia)
|
|
213
|
+
app.use(options.pinia);
|
|
214
|
+
if (options.router)
|
|
215
|
+
app.use(options.router);
|
|
216
|
+
for (const plugin of options.plugins ?? [])
|
|
217
|
+
app.use(plugin);
|
|
218
|
+
return app;
|
|
219
|
+
}
|
|
220
|
+
/** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
|
|
221
|
+
* no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
|
|
222
|
+
* as the server rendered it, when the container or the record is missing or a component is not
|
|
223
|
+
* given or does not load. */
|
|
224
|
+
export async function mountPage(layout, components, options = {}) {
|
|
225
|
+
const doc = options.doc ?? document;
|
|
226
|
+
const selector = options.container ?? "#app";
|
|
227
|
+
const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
|
|
228
|
+
if (!container)
|
|
229
|
+
throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
|
|
230
|
+
const id = options.record ?? "__fv_page";
|
|
231
|
+
const text = doc.getElementById(id)?.textContent;
|
|
232
|
+
if (!text)
|
|
233
|
+
throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
|
|
234
|
+
const record = parseJson(text);
|
|
235
|
+
const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
|
|
236
|
+
const loading = [...names].map(async (name) => {
|
|
237
|
+
if (!Object.hasOwn(components, name))
|
|
238
|
+
throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to mountPage`);
|
|
239
|
+
return [name, await loadOrThrow(name, components[name])];
|
|
240
|
+
});
|
|
241
|
+
const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading), options.router?.isReady()]);
|
|
242
|
+
const app = createPageApp(root, record, Object.fromEntries(parts), options);
|
|
243
|
+
app.mount(container);
|
|
244
|
+
return app;
|
|
245
|
+
}
|
|
246
|
+
async function loadOrThrow(name, component) {
|
|
247
|
+
const result = await load(component);
|
|
248
|
+
if (result instanceof Error)
|
|
249
|
+
throw new Error(`[ferrovue] ${name} did not load: ${result.message}`, { cause: result });
|
|
250
|
+
return result;
|
|
251
|
+
}
|
package/dist/compiler.d.ts
CHANGED
|
@@ -18,6 +18,6 @@ export interface Written {
|
|
|
18
18
|
* so a Rust build that watches them rebuilds no more than it must, and removing what no component
|
|
19
19
|
* produces any more. */
|
|
20
20
|
export declare function write(root: string, config?: Config): Written;
|
|
21
|
-
export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TypeName } from "./context.ts";
|
|
21
|
+
export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type TwinSpec, type TypeName } from "./context.ts";
|
|
22
22
|
export { GenError } from "./model.ts";
|
|
23
23
|
export declare const VERSION: string;
|
package/dist/compiler.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
3
|
import { ctx, loadConfig, tyOfName } from "./context.js";
|
|
4
|
-
import {
|
|
4
|
+
import { failIn, snake } from "./model.js";
|
|
5
|
+
import { importsOf, readComponent } from "./component.js";
|
|
5
6
|
import { scopeFor } from "./script.js";
|
|
6
7
|
import { attrsFlow } from "./fallthrough.js";
|
|
8
|
+
import { prepareDynamic } from "./dynamic.js";
|
|
7
9
|
import { componentSource, isIsland, modSource } from "./rust.js";
|
|
8
10
|
import { renderParams } from "./plugin.js";
|
|
9
11
|
import { PLUGINS } from "./plugins/index.js";
|
|
@@ -12,12 +14,16 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
12
14
|
ctx.componentsDir = config.components.replace(/\/?$/, "/");
|
|
13
15
|
ctx.helperModule = config.helpers?.module ?? null;
|
|
14
16
|
ctx.trustedHtml = config.trustedHtml ?? null;
|
|
17
|
+
ctx.builders = config.builders ?? true;
|
|
15
18
|
ctx.clientDirectives = new Set(config.clientDirectives ?? []);
|
|
16
19
|
ctx.rootDir = root;
|
|
20
|
+
ctx.vnodeTag = null;
|
|
17
21
|
ctx.typeStructs = new Map();
|
|
18
22
|
ctx.typeAliases = new Map();
|
|
19
23
|
ctx.typeFiles = new Map();
|
|
20
24
|
ctx.typeRead = new Set();
|
|
25
|
+
ctx.constDecls = new Map();
|
|
26
|
+
ctx.typeConsts = new Map();
|
|
21
27
|
ctx.plugins = PLUGINS;
|
|
22
28
|
ctx.runs = new Map();
|
|
23
29
|
for (const p of PLUGINS)
|
|
@@ -29,15 +35,34 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
29
35
|
{ rust: h.rust, params: h.params.map(tyOfName), ret: tyOfName(h.returns), maxLen: h.maxLen ?? 0 },
|
|
30
36
|
]));
|
|
31
37
|
const dir = join(root, config.components);
|
|
32
|
-
const files =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const files = [
|
|
39
|
+
...readdirSync(dir)
|
|
40
|
+
.filter((f) => f.endsWith(".vue"))
|
|
41
|
+
.toSorted()
|
|
42
|
+
.map((f) => ({ file: join(dir, f), name: basename(f, ".vue") })),
|
|
43
|
+
...PLUGINS.flatMap((p) => p.components?.() ?? []).map((c) => ({ file: join(root, c.file), name: c.name })),
|
|
44
|
+
];
|
|
45
|
+
const modulesOf = new Map();
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
const rel = relative(root, f.file);
|
|
48
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(f.name)) {
|
|
49
|
+
failIn(rel, "FV0007", `the component is called \`${f.name}\` after its file, which is not a Rust name: name the file in PascalCase, letters and digits (\`UserCard.vue\`)`);
|
|
50
|
+
}
|
|
51
|
+
const file = moduleFile(snake(f.name));
|
|
52
|
+
if (file === "mod.rs")
|
|
53
|
+
failIn(rel, "FV0007", `the component's module would be \`mod.rs\`, which holds the generated modules: rename the file`);
|
|
54
|
+
const taken = modulesOf.get(file);
|
|
55
|
+
if (taken)
|
|
56
|
+
failIn(rel, "FV0007", `the component's module would be \`${file}\`, as ${taken}'s is: rename one of them`);
|
|
57
|
+
modulesOf.set(file, rel);
|
|
58
|
+
}
|
|
59
|
+
const children = new Set(files.flatMap((f) => importsOf(readFileSync(f.file, "utf8")).filter((c) => c !== f.name)));
|
|
60
|
+
const read = files.map((f) => readComponent(f.file, root, children.has(f.name), f.name));
|
|
37
61
|
const components = new Map(read.map((r) => [r.comp.name, r.comp]));
|
|
38
62
|
ctx.components = components;
|
|
39
63
|
const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
|
|
40
|
-
const
|
|
64
|
+
const analysed = prepareDynamic(read.map((r, i) => ({ ...r, scope: scopes[i] })));
|
|
65
|
+
const all = read.map((r, i) => ({ comp: r.comp, ssr: analysed[i], children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
|
|
41
66
|
for (const p of PLUGINS)
|
|
42
67
|
p.analyse?.(all);
|
|
43
68
|
attrsFlow(all);
|
|
@@ -71,14 +96,21 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
71
96
|
read.forEach(place);
|
|
72
97
|
for (const r of ordered) {
|
|
73
98
|
ctx.narrowCount = 0;
|
|
74
|
-
out.set(
|
|
99
|
+
out.set(moduleFile(r.comp.module), componentSource(r.comp, r.ast, r.ssr, components));
|
|
75
100
|
}
|
|
76
101
|
const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
|
|
77
102
|
out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
|
|
78
|
-
for (const [file, text] of modules)
|
|
103
|
+
for (const [file, text] of modules) {
|
|
104
|
+
const taken = modulesOf.get(file);
|
|
105
|
+
if (taken)
|
|
106
|
+
failIn(taken, "FV0007", `the component's module would be \`${file}\`, which ferrovue writes for the project's ${file.replace(/\.rs$/, "").replaceAll("_", " ")}: rename the file`);
|
|
79
107
|
out.set(file, text);
|
|
108
|
+
}
|
|
80
109
|
return out;
|
|
81
110
|
}
|
|
111
|
+
function moduleFile(module) {
|
|
112
|
+
return `${module.replace(/^r#/, "")}.rs`;
|
|
113
|
+
}
|
|
82
114
|
/** Write what \`generate\` produces to the configured directory: only the files whose text changed,
|
|
83
115
|
* so a Rust build that watches them rebuilds no more than it must, and removing what no component
|
|
84
116
|
* produces any more. */
|
package/dist/component.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Component, type N } from "./model.ts";
|
|
2
|
-
|
|
2
|
+
/** The `.vue` files a component imports, by name. */
|
|
3
|
+
export declare function importsOf(source: string): string[];
|
|
4
|
+
export declare function readComponent(file: string, root: string, isChild: boolean, name?: string): {
|
|
3
5
|
comp: Component;
|
|
4
6
|
ast: N[];
|
|
5
7
|
ssr: string;
|
package/dist/component.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { parse as parseJs } from "@babel/parser";
|
|
1
2
|
import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { SourceMapConsumer } from "source-map-js";
|
|
4
5
|
import { basename, relative } from "node:path";
|
|
5
|
-
import { blankComponent, fail, opt, snake, tagAst } from "./model.js";
|
|
6
|
+
import { absence, blankComponent, fail, GenError, opt, snake, sourceAt, tagAst } from "./model.js";
|
|
6
7
|
import { ctx } from "./context.js";
|
|
7
|
-
import { typesImports, declareTypes, defaultValue, definePropsType, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
|
|
8
|
+
import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
|
|
8
9
|
import { claim } from "./plugin.js";
|
|
10
|
+
import { asyncChildren } from "./script.js";
|
|
9
11
|
function vueErrorNode(err, within) {
|
|
10
12
|
const loc = err.loc;
|
|
11
13
|
if (!loc)
|
|
@@ -29,36 +31,116 @@ function inheritAttrs(comp, statements) {
|
|
|
29
31
|
if (p.type !== "ObjectProperty" || p.computed || (p.key.name ?? p.key.value) !== "inheritAttrs")
|
|
30
32
|
continue;
|
|
31
33
|
if (p.value.type !== "BooleanLiteral")
|
|
32
|
-
fail(comp, "`inheritAttrs` is `true` or `false`", p.value);
|
|
34
|
+
fail(comp, "FV0101", "`inheritAttrs` is `true` or `false`", p.value);
|
|
33
35
|
found = p.value.value;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
return found;
|
|
37
39
|
}
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
+
function sourceDefault(ast, key) {
|
|
41
|
+
const named = (p) => (p.key?.name ?? p.key?.value) === key;
|
|
42
|
+
for (const st of ast) {
|
|
43
|
+
for (const d of st.type === "VariableDeclaration" ? st.declarations : []) {
|
|
44
|
+
if (d.id.type !== "ObjectPattern" || !definePropsType(d.init))
|
|
45
|
+
continue;
|
|
46
|
+
const p = d.id.properties.find((q) => q.type === "ObjectProperty" && named(q));
|
|
47
|
+
if (p?.value.type === "AssignmentPattern")
|
|
48
|
+
return p.value.right;
|
|
49
|
+
}
|
|
50
|
+
const call = st.type === "ExpressionStatement" ? st.expression : st.type === "VariableDeclaration" ? st.declarations[0]?.init : null;
|
|
51
|
+
if (call?.type !== "CallExpression" || call.callee.name !== "withDefaults" || call.arguments[1]?.type !== "ObjectExpression")
|
|
52
|
+
continue;
|
|
53
|
+
const p = call.arguments[1].properties.find((q) => q.type === "ObjectProperty" && named(q));
|
|
54
|
+
if (p)
|
|
55
|
+
return p.value;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function blockAt(source, block) {
|
|
60
|
+
return sourceAt(source, source.lastIndexOf(`<${block.type}`, block.loc.start.offset));
|
|
61
|
+
}
|
|
62
|
+
/** The `.vue` files a component imports, by name. */
|
|
63
|
+
export function importsOf(source) {
|
|
64
|
+
return [...source.matchAll(/\bfrom\s*["'](?:[^"']*\/)?([^"'/]+)\.vue["']/g)].map((m) => m[1]);
|
|
65
|
+
}
|
|
66
|
+
function generics(comp, source, block) {
|
|
67
|
+
const text = block.attrs.generic;
|
|
68
|
+
if (typeof text !== "string")
|
|
69
|
+
return;
|
|
70
|
+
const open = source.lastIndexOf("<script", block.loc.start.offset);
|
|
71
|
+
const attr = source.slice(open, block.loc.start.offset).search(/\bgeneric\s*=\s*["']/);
|
|
72
|
+
const valueAt = open + attr + /^generic\s*=\s*["']/.exec(source.slice(open + attr))[0].length;
|
|
73
|
+
const at = sourceAt(source, valueAt).loc.start;
|
|
74
|
+
const prefix = "type T<";
|
|
75
|
+
let params;
|
|
76
|
+
try {
|
|
77
|
+
params = parseJs(`${prefix}${text}> = 0;`, { sourceType: "module", plugins: ["typescript"] }).program.body[0].typeParameters.params;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return fail(comp, "FV0301", "`generic` is a list of type parameters, as `T extends string`", sourceAt(source, valueAt));
|
|
81
|
+
}
|
|
82
|
+
const rebase = (n) => {
|
|
83
|
+
if (!n || typeof n !== "object")
|
|
84
|
+
return;
|
|
85
|
+
if (Array.isArray(n))
|
|
86
|
+
return n.forEach(rebase);
|
|
87
|
+
const start = n.loc?.start;
|
|
88
|
+
if (start && typeof n.type === "string") {
|
|
89
|
+
n.loc = { ...n.loc, start: start.line === 1 ? { line: at.line, column: at.column + start.column - prefix.length } : { line: at.line + start.line - 1, column: start.column } };
|
|
90
|
+
}
|
|
91
|
+
for (const [k, v] of Object.entries(n))
|
|
92
|
+
if (k !== "loc" && v && typeof v === "object")
|
|
93
|
+
rebase(v);
|
|
94
|
+
};
|
|
95
|
+
rebase(params);
|
|
96
|
+
tagAst(params, "source");
|
|
97
|
+
for (const p of params) {
|
|
98
|
+
const name = typeof p.name === "string" ? p.name : p.name.name;
|
|
99
|
+
if (!p.constraint)
|
|
100
|
+
fail(comp, "FV0302", `\`${name}\` in \`generic\` has no constraint: the server renders \`${name}\` as the type it extends, \`${name} extends string\``, p);
|
|
101
|
+
comp.aliases.set(name, p.constraint);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function scriptOf(comp, descriptor) {
|
|
105
|
+
try {
|
|
106
|
+
return compileScript(descriptor, { id: comp.name });
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
if (!(e instanceof SyntaxError))
|
|
110
|
+
throw e;
|
|
111
|
+
const at = e.loc;
|
|
112
|
+
const block = descriptor.scriptSetup.loc.start;
|
|
113
|
+
const where = !at || descriptor.script ? {} : at.line === 1 ? { line: block.line, column: block.column + at.column } : { line: block.line + at.line - 1, column: at.column + 1 };
|
|
114
|
+
throw new GenError("FV0001", e.message, { file: comp.file, ...where }, e.message.split("\n")[0]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function readComponent(file, root, isChild, name = basename(file, ".vue")) {
|
|
40
118
|
const source = readFileSync(file, "utf8");
|
|
41
119
|
const { descriptor, errors } = parseSfc(source, { filename: file });
|
|
42
120
|
const rel = relative(root, file);
|
|
43
121
|
const comp = blankComponent(name, snake(name), rel);
|
|
44
122
|
comp.source = source;
|
|
45
123
|
if (errors.length)
|
|
46
|
-
fail(comp, String(errors[0]), vueErrorNode(errors[0]));
|
|
47
|
-
if (
|
|
48
|
-
|
|
124
|
+
fail(comp, "FV0002", String(errors[0]).replaceAll(file, rel), vueErrorNode(errors[0]) ?? sourceAt(source, 0));
|
|
125
|
+
if (descriptor.script && !descriptor.scriptSetup) {
|
|
126
|
+
const needs = isChild ? "a child component must have" : "an island needs";
|
|
127
|
+
fail(comp, "FV0003", `${needs} \`<script setup lang="ts">\`, or no script at all: a \`<script>\` without \`setup\` (the Options API, \`defineComponent\`) is not translated`, blockAt(source, descriptor.script));
|
|
49
128
|
}
|
|
129
|
+
if (!descriptor.template)
|
|
130
|
+
fail(comp, "FV0004", "a component needs a `<template>`: a render function is not translated", sourceAt(source, 0));
|
|
50
131
|
for (const st of descriptor.styles) {
|
|
51
132
|
if (st.module)
|
|
52
|
-
fail(comp, "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet");
|
|
133
|
+
fail(comp, "FV1005", "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet", blockAt(source, st));
|
|
53
134
|
}
|
|
135
|
+
const cssVar = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
|
|
54
136
|
if (descriptor.cssVars.length)
|
|
55
|
-
fail(comp, "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead");
|
|
137
|
+
fail(comp, "FV1006", "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead", cssVar ? blockAt(source, cssVar) : undefined);
|
|
56
138
|
for (const p of ctx.plugins)
|
|
57
139
|
p.sfc?.(comp, descriptor, file, source);
|
|
58
|
-
const script =
|
|
59
|
-
const ast = script
|
|
140
|
+
const script = descriptor.scriptSetup ? scriptOf(comp, descriptor) : null;
|
|
141
|
+
const ast = script?.scriptSetupAst ?? [];
|
|
60
142
|
tagAst(ast, "source");
|
|
61
|
-
const plainAst = script
|
|
143
|
+
const plainAst = script?.scriptAst ?? [];
|
|
62
144
|
tagAst(plainAst, "source");
|
|
63
145
|
for (const s of [...plainAst, ...ast]) {
|
|
64
146
|
if (s.type !== "ImportDeclaration" || !s.source.value.endsWith(".vue"))
|
|
@@ -72,6 +154,8 @@ export function readComponent(file, root) {
|
|
|
72
154
|
}
|
|
73
155
|
}
|
|
74
156
|
}
|
|
157
|
+
for (const loaded of asyncChildren(comp, ast).values())
|
|
158
|
+
comp.imports.add(loaded);
|
|
75
159
|
typesImports(comp, [...plainAst, ...ast]);
|
|
76
160
|
comp.inheritAttrs = inheritAttrs(comp, [...plainAst, ...ast]);
|
|
77
161
|
for (const st of [...plainAst, ...ast]) {
|
|
@@ -102,6 +186,8 @@ export function readComponent(file, root) {
|
|
|
102
186
|
comp.aliases.set(sp.local.name, ctx.typeAliases.get(typeName));
|
|
103
187
|
}
|
|
104
188
|
}
|
|
189
|
+
if (descriptor.scriptSetup)
|
|
190
|
+
generics(comp, source, descriptor.scriptSetup);
|
|
105
191
|
const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
|
|
106
192
|
for (const d of decls)
|
|
107
193
|
comp.structs.set(d.name, structOf(comp, d.name, d.members, comp.structs));
|
|
@@ -110,7 +196,7 @@ export function readComponent(file, root) {
|
|
|
110
196
|
const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
|
|
111
197
|
const inner = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "withDefaults" ? call.arguments[0] : call;
|
|
112
198
|
if (inner?.type === "CallExpression" && inner.callee.type === "Identifier" && inner.callee.name === "defineProps" && !inner.typeParameters) {
|
|
113
|
-
fail(comp, "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
|
|
199
|
+
fail(comp, "FV0303", "props are declared with a type, `defineProps<{ ... }>()`: a runtime declaration has no Rust type", inner);
|
|
114
200
|
}
|
|
115
201
|
const found = definePropsType(call);
|
|
116
202
|
if (found)
|
|
@@ -125,7 +211,7 @@ export function readComponent(file, root) {
|
|
|
125
211
|
comp.props = { name: "Props", fields: comp.structs.get(propsTy.typeName.name).fields };
|
|
126
212
|
}
|
|
127
213
|
else {
|
|
128
|
-
fail(comp, "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
|
|
214
|
+
fail(comp, "FV0304", "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
|
|
129
215
|
}
|
|
130
216
|
for (const st of ast) {
|
|
131
217
|
if (st.type !== "VariableDeclaration")
|
|
@@ -136,22 +222,29 @@ export function readComponent(file, root) {
|
|
|
136
222
|
continue;
|
|
137
223
|
const t = call.typeParameters?.params?.[0];
|
|
138
224
|
if (!t)
|
|
139
|
-
fail(comp, "`defineModel` needs its type: `defineModel<string>()`", call);
|
|
225
|
+
fail(comp, "FV0305", "`defineModel` needs its type: `defineModel<string>()`", call);
|
|
140
226
|
const named = call.arguments[0]?.type === "StringLiteral" ? call.arguments[0].value : "modelValue";
|
|
141
227
|
const options = call.arguments.find((a) => a.type === "ObjectExpression");
|
|
142
228
|
const required = options?.properties.some((p) => p.type === "ObjectProperty" && (p.key.name ?? p.key.value) === "required" && p.value.type === "BooleanLiteral" && p.value.value);
|
|
143
229
|
const base = tyOfTs(comp, t, comp.structs);
|
|
230
|
+
if (!required && absence(base) === "null")
|
|
231
|
+
fail(comp, "FV0306", `a \`defineModel\` of \`T | null\` that is not \`required\` may be absent, which is \`undefined\`, or \`null\`: ${ONE_NOTHING}`, call);
|
|
144
232
|
comp.props.fields.push({ js: named, rust: snake(named), ty: required ? base : opt(base) });
|
|
145
233
|
comp.models.set(d.id.name, named);
|
|
146
234
|
}
|
|
147
235
|
}
|
|
148
|
-
const defaults = runtimeDefaults(comp, script
|
|
236
|
+
const defaults = runtimeDefaults(comp, script?.content ?? "");
|
|
149
237
|
for (const f of comp.props.fields) {
|
|
150
238
|
if (f.ty.k !== "opt")
|
|
151
239
|
continue;
|
|
152
240
|
const node = defaults.get(f.js);
|
|
241
|
+
if (f.ty.none !== undefined) {
|
|
242
|
+
if (node)
|
|
243
|
+
fail(comp, "FV0307", `a default for \`${f.js}\`, which is \`T | null\`: Vue gives it only when \`${f.js}\` is absent, which its Rust type, an \`Option\`, cannot be; fall back in the template with \`??\``, sourceDefault(ast, f.js) ?? node);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
153
246
|
if (node)
|
|
154
|
-
f.dflt = defaultValue(comp, f, node);
|
|
247
|
+
f.dflt = defaultValue(comp, f, node, sourceDefault(ast, f.js) ?? undefined);
|
|
155
248
|
else if (f.ty.of.k === "bool")
|
|
156
249
|
f.dflt = "false";
|
|
157
250
|
}
|
|
@@ -161,7 +254,7 @@ export function readComponent(file, root) {
|
|
|
161
254
|
...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false }),
|
|
162
255
|
ssr: true,
|
|
163
256
|
ssrCssVars: [],
|
|
164
|
-
compilerOptions: { bindingMetadata: script.bindings, sourceMap: true },
|
|
257
|
+
compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), sourceMap: true },
|
|
165
258
|
});
|
|
166
259
|
const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
|
|
167
260
|
const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
|
|
@@ -172,12 +265,13 @@ export function readComponent(file, root) {
|
|
|
172
265
|
if (!lone)
|
|
173
266
|
comp.attrsDropped = { type: "VueTemplate", loc: { start: { line: wrapper.loc.start.line, column: wrapper.loc.start.column - 1 } }, __fv: "source" };
|
|
174
267
|
}
|
|
268
|
+
comp.templateAst = descriptor.template.ast;
|
|
175
269
|
const start = descriptor.template.loc.start;
|
|
176
270
|
comp.templateStart = { line: start.line, column: start.column };
|
|
177
271
|
if (compiled.map)
|
|
178
272
|
comp.templateMap = new SourceMapConsumer(compiled.map);
|
|
179
273
|
if (compiled.errors.length)
|
|
180
|
-
fail(comp, String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
|
|
274
|
+
fail(comp, "FV0005", String(compiled.errors[0]), vueErrorNode(compiled.errors[0], comp.templateStart));
|
|
181
275
|
for (const m of compiled.code.matchAll(/_ssrRenderSlot\(_ctx\.\$slots, "([^"]+)"/g)) {
|
|
182
276
|
if (!comp.slotNames.includes(m[1]))
|
|
183
277
|
comp.slotNames.push(m[1]);
|