ferrovue 0.6.0 → 0.7.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 +106 -13
- package/dist/args.d.ts +28 -0
- package/dist/args.js +69 -0
- package/dist/attrs.js +3 -0
- package/dist/calls.js +5 -2
- package/dist/children.js +18 -3
- package/dist/cli.js +80 -81
- package/dist/client-render.d.ts +23 -0
- package/dist/client-render.js +143 -0
- package/dist/client.d.ts +65 -6
- package/dist/client.js +269 -27
- package/dist/compiler.d.ts +9 -5
- package/dist/compiler.js +48 -14
- package/dist/component.d.ts +7 -3
- package/dist/component.js +37 -25
- package/dist/conformance.d.ts +24 -3
- package/dist/conformance.js +58 -7
- package/dist/constants.js +4 -2
- package/dist/context.d.ts +51 -4
- package/dist/context.js +173 -5
- package/dist/diagnostics.d.ts +6 -4
- package/dist/diagnostics.js +7 -3
- package/dist/editor.d.ts +18 -0
- package/dist/editor.js +59 -0
- package/dist/errors.d.ts +45 -3
- package/dist/errors.js +33 -3
- package/dist/expr.d.ts +2 -0
- package/dist/expr.js +91 -12
- package/dist/fallthrough.d.ts +1 -0
- package/dist/fallthrough.js +10 -2
- package/dist/files.d.ts +13 -0
- package/dist/files.js +49 -0
- package/dist/fixture.d.ts +3 -3
- package/dist/hydration.d.ts +12 -1
- package/dist/hydration.js +47 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/inputs.d.ts +24 -0
- package/dist/inputs.js +40 -0
- package/dist/link-router.d.ts +6 -2
- package/dist/link-router.js +7 -4
- package/dist/lists.d.ts +12 -1
- package/dist/lists.js +76 -2
- package/dist/model.d.ts +9 -1
- package/dist/model.js +2 -0
- package/dist/page-routes.d.ts +1 -0
- package/dist/paths.d.ts +5 -0
- package/dist/paths.js +7 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugins/css-modules.d.ts +39 -0
- package/dist/plugins/css-modules.js +135 -0
- package/dist/plugins/head.js +4 -4
- package/dist/plugins/i18n.js +5 -12
- package/dist/plugins/index.js +2 -1
- package/dist/plugins/provide.js +4 -2
- package/dist/plugins/router.js +3 -2
- package/dist/plugins/scoped.d.ts +10 -0
- package/dist/plugins/scoped.js +17 -4
- package/dist/plugins/shared-types.js +2 -1
- package/dist/plugins/stores.js +7 -6
- package/dist/rust.d.ts +9 -1
- package/dist/rust.js +38 -15
- package/dist/schema.d.ts +25 -0
- package/dist/schema.js +73 -0
- package/dist/script.js +12 -5
- package/dist/slots.js +2 -0
- package/dist/ssr.js +42 -4
- package/dist/styles.d.ts +5 -1
- package/dist/styles.js +131 -4
- package/dist/testing.d.ts +2 -1
- package/dist/testing.js +1 -1
- package/dist/typescript.d.ts +11 -1
- package/dist/typescript.js +124 -33
- package/dist/vite.d.ts +18 -10
- package/dist/vite.js +106 -51
- package/dist/volar.d.ts +15 -0
- package/dist/volar.js +87 -0
- package/package.json +27 -7
- package/schema.json +333 -0
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSSRApp, createTextVNode, defineComponent, h, hydrateOnIdle, hydrateOnMediaQuery, hydrateOnVisible, onMounted, ref, } from "vue";
|
|
1
|
+
import { createApp, 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) {
|
|
@@ -12,12 +12,212 @@ function parseJson(text) {
|
|
|
12
12
|
return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
|
|
13
13
|
}
|
|
14
14
|
/** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
|
|
15
|
-
* the app mounts, so every store starts from it and the
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
* the app mounts, so every store starts from it and the markup agrees with the server's. The state
|
|
16
|
+
* is read from the script with the `id` given (`__pinia` by default) in `doc`, or given as
|
|
17
|
+
* `{ text }`: the `state` of a fetched page that `readPage` read. Either way the numbers JSON
|
|
18
|
+
* cannot carry (`NaN`, `Infinity`) are read back. Pinia is left alone when there is no state. */
|
|
19
|
+
export function hydrateState(pinia, from = "__pinia", doc = document) {
|
|
20
|
+
const text = typeof from === "string" ? doc.getElementById(from)?.textContent : from.text;
|
|
18
21
|
if (text)
|
|
19
22
|
pinia.state.value = parseJson(text);
|
|
20
23
|
}
|
|
24
|
+
const SPACE = /[\t\n\f\r ]/;
|
|
25
|
+
const TAG_END = /[\t\n\f\r />]/;
|
|
26
|
+
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"]);
|
|
27
|
+
const FOREIGN = new Set(["svg", "math"]);
|
|
28
|
+
const LEAVES_FOREIGN = new Set("b big blockquote body br center code dd div dl dt em embed h1 h2 h3 h4 h5 h6 head hr i img li listing menu meta nobr ol p pre ruby s small span strong strike sub sup table tt u ul var".split(" "));
|
|
29
|
+
const REFERENCES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
30
|
+
function decodeAttr(value) {
|
|
31
|
+
if (!value.includes("&"))
|
|
32
|
+
return value;
|
|
33
|
+
return value.replace(/&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|(amp|lt|gt|quot|apos);)/g, (_match, dec, hex, named) => {
|
|
34
|
+
if (named)
|
|
35
|
+
return REFERENCES[named];
|
|
36
|
+
const code = dec ? Number.parseInt(dec, 10) : Number.parseInt(hex, 16);
|
|
37
|
+
return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "\uFFFD";
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function readTag(html, at) {
|
|
41
|
+
let i = at;
|
|
42
|
+
while (i < html.length && !TAG_END.test(html[i]))
|
|
43
|
+
i++;
|
|
44
|
+
const name = html.slice(at, i).toLowerCase();
|
|
45
|
+
const attrs = new Map();
|
|
46
|
+
let selfClosing = false;
|
|
47
|
+
for (;;) {
|
|
48
|
+
while (i < html.length && (SPACE.test(html[i]) || html[i] === "/"))
|
|
49
|
+
selfClosing = html[i++] === "/";
|
|
50
|
+
if (i >= html.length)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (html[i] === ">")
|
|
53
|
+
return { name, attrs, end: i + 1, selfClosing };
|
|
54
|
+
selfClosing = false;
|
|
55
|
+
const start = i++;
|
|
56
|
+
while (i < html.length && !/[\t\n\f\r />=]/.test(html[i]))
|
|
57
|
+
i++;
|
|
58
|
+
const key = html.slice(start, i).toLowerCase();
|
|
59
|
+
while (i < html.length && SPACE.test(html[i]))
|
|
60
|
+
i++;
|
|
61
|
+
let value = "";
|
|
62
|
+
if (html[i] === "=") {
|
|
63
|
+
i++;
|
|
64
|
+
while (i < html.length && SPACE.test(html[i]))
|
|
65
|
+
i++;
|
|
66
|
+
const quote = html[i];
|
|
67
|
+
if (quote === '"' || quote === "'") {
|
|
68
|
+
const close = html.indexOf(quote, i + 1);
|
|
69
|
+
if (close < 0)
|
|
70
|
+
return undefined;
|
|
71
|
+
value = html.slice(i + 1, close);
|
|
72
|
+
i = close + 1;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const from = i;
|
|
76
|
+
while (i < html.length && !/[\t\n\f\r >]/.test(html[i]))
|
|
77
|
+
i++;
|
|
78
|
+
value = html.slice(from, i);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!attrs.has(key))
|
|
82
|
+
attrs.set(key, decodeAttr(value));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function tagAt(lower, at, name, closing) {
|
|
86
|
+
const open = closing ? `</${name}` : `<${name}`;
|
|
87
|
+
return lower.startsWith(open, at) && TAG_END.test(lower[at + open.length] ?? "");
|
|
88
|
+
}
|
|
89
|
+
function scriptDataEnd(lower, from) {
|
|
90
|
+
let escaped = 0;
|
|
91
|
+
let dashes = 0;
|
|
92
|
+
for (let i = from; i < lower.length; i++) {
|
|
93
|
+
if (escaped === 0) {
|
|
94
|
+
if (lower.startsWith("<!--", i)) {
|
|
95
|
+
escaped = 1;
|
|
96
|
+
dashes = 2;
|
|
97
|
+
i += 3;
|
|
98
|
+
}
|
|
99
|
+
else if (tagAt(lower, i, "script", true))
|
|
100
|
+
return i;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const ch = lower[i];
|
|
104
|
+
if (ch === "-") {
|
|
105
|
+
dashes++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === ">" && dashes >= 2)
|
|
109
|
+
escaped = 0;
|
|
110
|
+
else if (ch === "<" && tagAt(lower, i, "script", true)) {
|
|
111
|
+
if (escaped === 1)
|
|
112
|
+
return i;
|
|
113
|
+
escaped = 1;
|
|
114
|
+
}
|
|
115
|
+
else if (ch === "<" && escaped === 1 && tagAt(lower, i, "script", false))
|
|
116
|
+
escaped = 2;
|
|
117
|
+
dashes = 0;
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
function rawTextEnd(lower, from, name) {
|
|
122
|
+
if (name === "script")
|
|
123
|
+
return scriptDataEnd(lower, from);
|
|
124
|
+
for (let i = lower.indexOf(`</${name}`, from); i >= 0; i = lower.indexOf(`</${name}`, i + 1))
|
|
125
|
+
if (tagAt(lower, i, name, true))
|
|
126
|
+
return i;
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
function commentEnd(html, from) {
|
|
130
|
+
if (html.startsWith(">", from))
|
|
131
|
+
return from + 1;
|
|
132
|
+
if (html.startsWith("->", from))
|
|
133
|
+
return from + 2;
|
|
134
|
+
for (let i = html.indexOf("--", from); i >= 0; i = html.indexOf("--", i + 1)) {
|
|
135
|
+
if (html[i + 2] === ">")
|
|
136
|
+
return i + 3;
|
|
137
|
+
if (html.startsWith("!>", i + 2))
|
|
138
|
+
return i + 4;
|
|
139
|
+
}
|
|
140
|
+
return html.length;
|
|
141
|
+
}
|
|
142
|
+
function scriptText(html, id) {
|
|
143
|
+
const lower = html.toLowerCase();
|
|
144
|
+
let template = 0;
|
|
145
|
+
let foreign = 0;
|
|
146
|
+
let i = 0;
|
|
147
|
+
for (;;) {
|
|
148
|
+
const lt = html.indexOf("<", i);
|
|
149
|
+
if (lt < 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
i = lt + 1;
|
|
152
|
+
if (html.startsWith("<!--", lt)) {
|
|
153
|
+
i = commentEnd(html, lt + 4);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (foreign > 0 && html.startsWith("<![CDATA[", lt)) {
|
|
157
|
+
const close = html.indexOf("]]>", lt);
|
|
158
|
+
i = close < 0 ? html.length : close + 3;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const closing = html[lt + 1] === "/";
|
|
162
|
+
const at = closing ? lt + 2 : lt + 1;
|
|
163
|
+
if (!/[a-zA-Z]/.test(html[at] ?? "")) {
|
|
164
|
+
if (html[lt + 1] === "!" || html[lt + 1] === "?" || (closing && at < html.length)) {
|
|
165
|
+
const close = html.indexOf(">", at);
|
|
166
|
+
i = close < 0 ? html.length : close + 1;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const tag = readTag(html, at);
|
|
171
|
+
if (!tag)
|
|
172
|
+
return undefined;
|
|
173
|
+
i = tag.end;
|
|
174
|
+
if (FOREIGN.has(tag.name)) {
|
|
175
|
+
if (closing)
|
|
176
|
+
foreign = Math.max(0, foreign - 1);
|
|
177
|
+
else if (!tag.selfClosing)
|
|
178
|
+
foreign++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (foreign > 0) {
|
|
182
|
+
if (closing ? tag.name === "br" || tag.name === "p" : LEAVES_FOREIGN.has(tag.name))
|
|
183
|
+
foreign = 0;
|
|
184
|
+
else
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (tag.name === "template") {
|
|
188
|
+
template = closing ? Math.max(0, template - 1) : template + 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (closing || !RAW_TEXT.has(tag.name)) {
|
|
192
|
+
if (tag.name === "plaintext")
|
|
193
|
+
return undefined;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const end = rawTextEnd(lower, i, tag.name);
|
|
197
|
+
if (tag.name === "script" && template === 0 && tag.attrs.get("id") === id)
|
|
198
|
+
return html.slice(i, end < 0 ? html.length : end);
|
|
199
|
+
if (end < 0)
|
|
200
|
+
return undefined;
|
|
201
|
+
const close = readTag(html, end + 2);
|
|
202
|
+
if (!close)
|
|
203
|
+
return undefined;
|
|
204
|
+
i = close.end;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Read the record and the state of a page out of the HTML of its document, as a navigation that
|
|
208
|
+
* fetches the next page receives it. It needs no `DOMParser`, so it also runs in a worker and on a
|
|
209
|
+
* server. Each script is the one the browser's `getElementById` would find in the parsed document:
|
|
210
|
+
* the first `<script>` with that `id` outside a `<template>`, an `<svg>` and a `<math>`, with
|
|
211
|
+
* comments, attribute values and the text of `<script>`, `<style>`, `<textarea>`, `<title>` and the
|
|
212
|
+
* other raw-text elements passed over as the HTML parser passes over them. Throws when the page
|
|
213
|
+
* carries no record. */
|
|
214
|
+
export function readPage(html, options = {}) {
|
|
215
|
+
const id = options.record ?? "__fv_page";
|
|
216
|
+
const text = scriptText(html, id);
|
|
217
|
+
if (!text)
|
|
218
|
+
throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
|
|
219
|
+
return { record: parseJson(text), state: scriptText(html, options.state ?? "__pinia") || undefined };
|
|
220
|
+
}
|
|
21
221
|
const INTERACTIONS = ["pointerenter", "click", "focus"];
|
|
22
222
|
const strategy = (wait) => (el, hydrate) => wait(() => void hydrate(), (each) => void each(el)) ?? (() => { });
|
|
23
223
|
const idle = (el, hydrate) => {
|
|
@@ -100,15 +300,22 @@ export async function mountIslands(components, options = {}) {
|
|
|
100
300
|
report(el, `the props of ${name} are not JSON`);
|
|
101
301
|
return;
|
|
102
302
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
303
|
+
// One island that throws as it mounts (its setup, a plugin) is reported, and leaves the others
|
|
304
|
+
// to hydrate.
|
|
305
|
+
try {
|
|
306
|
+
const app = createSSRApp(component, props);
|
|
307
|
+
if (options.pinia)
|
|
308
|
+
app.use(options.pinia);
|
|
309
|
+
if (options.router)
|
|
310
|
+
app.use(options.router);
|
|
311
|
+
for (const plugin of options.plugins ?? [])
|
|
312
|
+
app.use(plugin);
|
|
313
|
+
app.mount(el);
|
|
314
|
+
apps.push(app);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
report(el, `${name} did not mount: ${e instanceof Error ? e.message : String(e)}`);
|
|
318
|
+
}
|
|
112
319
|
};
|
|
113
320
|
const now = [];
|
|
114
321
|
for (const el of elements) {
|
|
@@ -202,13 +409,15 @@ async function load(component) {
|
|
|
202
409
|
}
|
|
203
410
|
/** The app of a page: its root renders `layout` with the record's props, and each slot as the
|
|
204
411
|
* components the record names, in order, as a plain array, which hydrates the markup the server
|
|
205
|
-
* wrote with no fragment of its own. `
|
|
412
|
+
* wrote with no fragment of its own. With `hydrate: false` the same tree is rendered from scratch,
|
|
413
|
+
* so the first page and every page shown after it are one shape of app. `components` must hold
|
|
414
|
+
* every component the record names. */
|
|
206
415
|
export function createPageApp(layout, record, components, options = {}) {
|
|
207
416
|
const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
|
|
208
417
|
name,
|
|
209
418
|
() => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
|
|
210
419
|
]));
|
|
211
|
-
const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
|
|
420
|
+
const app = (options.hydrate === false ? createApp : createSSRApp)({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
|
|
212
421
|
if (options.pinia)
|
|
213
422
|
app.use(options.pinia);
|
|
214
423
|
if (options.router)
|
|
@@ -217,32 +426,65 @@ export function createPageApp(layout, record, components, options = {}) {
|
|
|
217
426
|
app.use(plugin);
|
|
218
427
|
return app;
|
|
219
428
|
}
|
|
429
|
+
function containerOf(options) {
|
|
430
|
+
const selector = options.container ?? "#app";
|
|
431
|
+
const container = typeof selector === "string" ? (options.doc ?? document).querySelector(selector) : selector;
|
|
432
|
+
if (!container)
|
|
433
|
+
throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
|
|
434
|
+
return container;
|
|
435
|
+
}
|
|
436
|
+
async function loadPage(caller, layout, record, components) {
|
|
437
|
+
const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
|
|
438
|
+
const loading = [...names].map(async (name) => {
|
|
439
|
+
if (!Object.hasOwn(components, name))
|
|
440
|
+
throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to ${caller}`);
|
|
441
|
+
return [name, await loadOrThrow(name, components[name])];
|
|
442
|
+
});
|
|
443
|
+
const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading)]);
|
|
444
|
+
return [root, Object.fromEntries(parts)];
|
|
445
|
+
}
|
|
220
446
|
/** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
|
|
221
447
|
* no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
|
|
222
448
|
* as the server rendered it, when the container or the record is missing or a component is not
|
|
223
449
|
* given or does not load. */
|
|
224
450
|
export async function mountPage(layout, components, options = {}) {
|
|
225
451
|
const doc = options.doc ?? document;
|
|
226
|
-
const
|
|
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`);
|
|
452
|
+
const container = containerOf(options);
|
|
230
453
|
const id = options.record ?? "__fv_page";
|
|
231
454
|
const text = doc.getElementById(id)?.textContent;
|
|
232
455
|
if (!text)
|
|
233
456
|
throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
|
|
234
457
|
const record = parseJson(text);
|
|
235
|
-
const
|
|
236
|
-
const
|
|
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);
|
|
458
|
+
const [[root, parts]] = await Promise.all([loadPage("mountPage", layout, record, components), options.router?.isReady()]);
|
|
459
|
+
const app = createPageApp(root, record, parts, options);
|
|
243
460
|
app.mount(container);
|
|
244
461
|
return app;
|
|
245
462
|
}
|
|
463
|
+
/** Show the next page of a navigation from its record, as `readPage` read it out of the fetched
|
|
464
|
+
* document: load the components it names (and no others), render the app `createPageApp` builds
|
|
465
|
+
* with `hydrate: false` into a copy of the container with no children, wait for the router, put the
|
|
466
|
+
* copy in the container's place, mount, and then unmount `previous`. The next app is mounted
|
|
467
|
+
* before the previous one is unmounted, so a router both use stays started. `router` must be at the
|
|
468
|
+
* next page's location: `linkRouter` with the `location` the navigation goes to. Rejects, leaving
|
|
469
|
+
* the page shown as it is, when the container is missing or a component is not given or does not
|
|
470
|
+
* load. */
|
|
471
|
+
export async function renderPage(layout, components, record, options = {}) {
|
|
472
|
+
const container = containerOf(options);
|
|
473
|
+
const [root, parts] = await loadPage("renderPage", layout, record, components);
|
|
474
|
+
const app = createPageApp(root, record, parts, { ...options, hydrate: false });
|
|
475
|
+
await options.router?.isReady();
|
|
476
|
+
const next = container.cloneNode(false);
|
|
477
|
+
container.replaceWith(next);
|
|
478
|
+
try {
|
|
479
|
+
app.mount(next);
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
next.replaceWith(container);
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
options.previous?.unmount();
|
|
486
|
+
return app;
|
|
487
|
+
}
|
|
246
488
|
async function loadOrThrow(name, component) {
|
|
247
489
|
const result = await load(component);
|
|
248
490
|
if (result instanceof Error)
|
package/dist/compiler.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type Config } from "./context.ts";
|
|
2
|
-
/** Every generated file, keyed by its name in the output directory.
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/** Every generated file, keyed by its name in the output directory. `sources` holds the text of
|
|
3
|
+
* components by path, read in place of their files, as an editor's unsaved buffers. */
|
|
4
|
+
export declare function generate(root: string, config?: Config, sources?: ReadonlyMap<string, string>): Map<string, string>;
|
|
5
|
+
/** Whether a file starts with the header ferrovue writes, so `write` removes only what an earlier
|
|
6
|
+
* run wrote and never a module of the app's own put in the output directory by mistake. */
|
|
7
|
+
export declare function isGenerated(file: string): boolean;
|
|
5
8
|
/** What \`write\` changed in the output directory. */
|
|
6
9
|
export interface Written {
|
|
7
10
|
/** Every file the output directory now holds. */
|
|
@@ -18,6 +21,7 @@ export interface Written {
|
|
|
18
21
|
* so a Rust build that watches them rebuilds no more than it must, and removing what no component
|
|
19
22
|
* produces any more. */
|
|
20
23
|
export declare function write(root: string, config?: Config): Written;
|
|
21
|
-
export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type TwinSpec, type TypeName } from "./context.ts";
|
|
22
|
-
export { GenError } from "./model.ts";
|
|
24
|
+
export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type RoutesSource, type ScopeIdMode, type TwinSpec, type TypeName } from "./context.ts";
|
|
25
|
+
export { GenError, type Location as SourceLocation } from "./model.ts";
|
|
26
|
+
export { docsUrl, ERRORS, type Code as ErrorCode, type ErrorDoc } from "./errors.ts";
|
|
23
27
|
export declare const VERSION: string;
|
package/dist/compiler.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, join,
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { relativePath } from "./paths.js";
|
|
3
4
|
import { ctx, loadConfig, tyOfName } from "./context.js";
|
|
4
5
|
import { failIn, snake } from "./model.js";
|
|
5
|
-
import {
|
|
6
|
+
import { readComponent, refuseOptionsApi } from "./component.js";
|
|
7
|
+
import { listDir } from "./files.js";
|
|
6
8
|
import { scopeFor } from "./script.js";
|
|
7
9
|
import { attrsFlow } from "./fallthrough.js";
|
|
8
10
|
import { prepareDynamic } from "./dynamic.js";
|
|
9
|
-
import { componentSource, isIsland, modSource } from "./rust.js";
|
|
11
|
+
import { componentSource, GENERATED, isIsland, modSource } from "./rust.js";
|
|
12
|
+
import { requireWhole } from "./typescript.js";
|
|
10
13
|
import { renderParams } from "./plugin.js";
|
|
11
14
|
import { PLUGINS } from "./plugins/index.js";
|
|
12
|
-
/** Every generated file, keyed by its name in the output directory.
|
|
13
|
-
|
|
15
|
+
/** Every generated file, keyed by its name in the output directory. `sources` holds the text of
|
|
16
|
+
* components by path, read in place of their files, as an editor's unsaved buffers. */
|
|
17
|
+
export function generate(root, config = loadConfig(root), sources = new Map()) {
|
|
18
|
+
ctx.sources = new Map([...sources].map(([file, text]) => [resolve(root, file), text]));
|
|
14
19
|
ctx.componentsDir = config.components.replace(/\/?$/, "/");
|
|
15
20
|
ctx.helperModule = config.helpers?.module ?? null;
|
|
16
21
|
ctx.trustedHtml = config.trustedHtml ?? null;
|
|
@@ -21,6 +26,8 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
21
26
|
ctx.typeStructs = new Map();
|
|
22
27
|
ctx.typeAliases = new Map();
|
|
23
28
|
ctx.typeFiles = new Map();
|
|
29
|
+
ctx.typeDecls = new Map();
|
|
30
|
+
ctx.typeReached = new Set();
|
|
24
31
|
ctx.typeRead = new Set();
|
|
25
32
|
ctx.constDecls = new Map();
|
|
26
33
|
ctx.typeConsts = new Map();
|
|
@@ -36,7 +43,7 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
36
43
|
]));
|
|
37
44
|
const dir = join(root, config.components);
|
|
38
45
|
const files = [
|
|
39
|
-
...
|
|
46
|
+
...listDir(root, config.components, "components")
|
|
40
47
|
.filter((f) => f.endsWith(".vue"))
|
|
41
48
|
.toSorted()
|
|
42
49
|
.map((f) => ({ file: join(dir, f), name: basename(f, ".vue") })),
|
|
@@ -44,7 +51,7 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
44
51
|
];
|
|
45
52
|
const modulesOf = new Map();
|
|
46
53
|
for (const f of files) {
|
|
47
|
-
const rel =
|
|
54
|
+
const rel = relativePath(root, f.file);
|
|
48
55
|
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(f.name)) {
|
|
49
56
|
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
57
|
}
|
|
@@ -56,10 +63,17 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
56
63
|
failIn(rel, "FV0007", `the component's module would be \`${file}\`, as ${taken}'s is: rename one of them`);
|
|
57
64
|
modulesOf.set(file, rel);
|
|
58
65
|
}
|
|
59
|
-
const
|
|
60
|
-
const
|
|
66
|
+
const read = files.map((f) => readComponent(f.file, root, f.name));
|
|
67
|
+
const children = new Set(read.flatMap((r) => [...r.comp.imports, ...r.comp.childProps.values()].filter((c) => c !== r.comp.name)));
|
|
68
|
+
for (const r of read)
|
|
69
|
+
if (r.optionsApi)
|
|
70
|
+
refuseOptionsApi(r.comp, r.optionsApi, children.has(r.comp.name));
|
|
71
|
+
const byName = new Map(read.map((r) => [r.comp.name, r]));
|
|
61
72
|
const components = new Map(read.map((r) => [r.comp.name, r.comp]));
|
|
62
73
|
ctx.components = components;
|
|
74
|
+
for (const c of components.values())
|
|
75
|
+
for (const f of c.props.fields)
|
|
76
|
+
requireWhole(c, f.ty);
|
|
63
77
|
const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
|
|
64
78
|
const analysed = prepareDynamic(read.map((r, i) => ({ ...r, scope: scopes[i] })));
|
|
65
79
|
const all = read.map((r, i) => ({ comp: r.comp, ssr: analysed[i], children: scopes[i].children, attrsBindings: scopes[i].attrsBindings }));
|
|
@@ -87,18 +101,27 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
87
101
|
return;
|
|
88
102
|
placed.add(r.comp.name);
|
|
89
103
|
for (const i of r.comp.imports) {
|
|
90
|
-
const dep =
|
|
104
|
+
const dep = byName.get(i);
|
|
91
105
|
if (dep)
|
|
92
106
|
place(dep);
|
|
93
107
|
}
|
|
94
108
|
ordered.push(r);
|
|
95
109
|
};
|
|
96
110
|
read.forEach(place);
|
|
97
|
-
|
|
111
|
+
const built = ordered.map((r) => {
|
|
98
112
|
ctx.narrowCount = 0;
|
|
99
|
-
|
|
113
|
+
const file = moduleFile(r.comp.module);
|
|
114
|
+
out.set(file, "");
|
|
115
|
+
return { file, source: componentSource(r.comp, r.ast, r.ssr, components) };
|
|
116
|
+
});
|
|
117
|
+
const reached = () => ctx.typeReached.size + [...components.values()].reduce((n, c) => n + c.reached.size, 0);
|
|
118
|
+
let modules = [];
|
|
119
|
+
for (let before = -1; before !== reached();) {
|
|
120
|
+
before = reached();
|
|
121
|
+
for (const { file, source } of built)
|
|
122
|
+
out.set(file, source());
|
|
123
|
+
modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
|
|
100
124
|
}
|
|
101
|
-
const modules = PLUGINS.flatMap((p) => p.modules?.() ?? []);
|
|
102
125
|
out.set("mod.rs", modSource(read.map((r) => r.comp), modules.map(([file]) => file)));
|
|
103
126
|
for (const [file, text] of modules) {
|
|
104
127
|
const taken = modulesOf.get(file);
|
|
@@ -108,6 +131,16 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
108
131
|
}
|
|
109
132
|
return out;
|
|
110
133
|
}
|
|
134
|
+
/** Whether a file starts with the header ferrovue writes, so `write` removes only what an earlier
|
|
135
|
+
* run wrote and never a module of the app's own put in the output directory by mistake. */
|
|
136
|
+
export function isGenerated(file) {
|
|
137
|
+
try {
|
|
138
|
+
return readFileSync(file, "utf8").startsWith(GENERATED);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
111
144
|
function moduleFile(module) {
|
|
112
145
|
return `${module.replace(/^r#/, "")}.rs`;
|
|
113
146
|
}
|
|
@@ -131,7 +164,7 @@ export function write(root, config = loadConfig(root)) {
|
|
|
131
164
|
changed.push(name);
|
|
132
165
|
}
|
|
133
166
|
}
|
|
134
|
-
const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f));
|
|
167
|
+
const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f) && isGenerated(join(target, f)));
|
|
135
168
|
for (const f of removed)
|
|
136
169
|
rmSync(join(target, f));
|
|
137
170
|
const islands = Object.fromEntries([...ctx.components.values()].filter(isIsland).map((c) => [c.name, c.file]));
|
|
@@ -139,6 +172,7 @@ export function write(root, config = loadConfig(root)) {
|
|
|
139
172
|
}
|
|
140
173
|
export { CONFIG_FILE, loadConfig, TYPES_MODULE } from "./context.js";
|
|
141
174
|
export { GenError } from "./model.js";
|
|
175
|
+
export { docsUrl, ERRORS } from "./errors.js";
|
|
142
176
|
export const VERSION = (() => {
|
|
143
177
|
try {
|
|
144
178
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
package/dist/component.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type Component, type N } from "./model.ts";
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
export declare function
|
|
2
|
+
/** Refuse a component written with the Options API, which `readComponent` read no further: as a
|
|
3
|
+
* child when another component imports it, as an island otherwise. */
|
|
4
|
+
export declare function refuseOptionsApi(comp: Component, script: N, isChild: boolean): never;
|
|
5
|
+
/** A component, read and its template compiled. One written with the Options API is read no
|
|
6
|
+
* further than its `<script>`, `optionsApi`, for `refuseOptionsApi` once every component is read. */
|
|
7
|
+
export declare function readComponent(file: string, root: string, name?: string): {
|
|
5
8
|
comp: Component;
|
|
6
9
|
ast: N[];
|
|
7
10
|
ssr: string;
|
|
11
|
+
optionsApi?: N;
|
|
8
12
|
};
|
package/dist/component.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { parse as parseJs } from "@babel/parser";
|
|
2
2
|
import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler-sfc";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
3
|
import { SourceMapConsumer } from "source-map-js";
|
|
5
|
-
import { basename
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { relativePath } from "./paths.js";
|
|
6
6
|
import { absence, blankComponent, fail, GenError, opt, snake, sourceAt, tagAst } from "./model.js";
|
|
7
|
-
import { ctx } from "./context.js";
|
|
8
|
-
import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
|
|
7
|
+
import { ctx, readSource } from "./context.js";
|
|
8
|
+
import { typesImports, declareTypes, defaultValue, definePropsType, ONE_NOTHING, reachLocal, readTypeFile, resolveImport, runtimeDefaults, structOf, tyOfTs } from "./typescript.js";
|
|
9
9
|
import { claim } from "./plugin.js";
|
|
10
10
|
import { asyncChildren } from "./script.js";
|
|
11
11
|
function vueErrorNode(err, within) {
|
|
@@ -59,9 +59,17 @@ function sourceDefault(ast, key) {
|
|
|
59
59
|
function blockAt(source, block) {
|
|
60
60
|
return sourceAt(source, source.lastIndexOf(`<${block.type}`, block.loc.start.offset));
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
function cssVarsAt(source, descriptor) {
|
|
63
|
+
const at = new Map();
|
|
64
|
+
for (const st of descriptor.styles) {
|
|
65
|
+
for (const m of st.content.matchAll(/v-bind\s*\(\s*(?:'([^']*)'|"([^"]*)"|([^)'"]*))/g)) {
|
|
66
|
+
const raw = m[1] ?? m[2] ?? m[3].trim();
|
|
67
|
+
if (!at.has(raw))
|
|
68
|
+
at.set(raw, sourceAt(source, st.loc.start.offset + m.index));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const block = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
|
|
72
|
+
return descriptor.cssVars.map((v) => at.get(v) ?? (block ? blockAt(source, block) : sourceAt(source, 0)));
|
|
65
73
|
}
|
|
66
74
|
function generics(comp, source, block) {
|
|
67
75
|
const text = block.attrs.generic;
|
|
@@ -114,27 +122,27 @@ function scriptOf(comp, descriptor) {
|
|
|
114
122
|
throw new GenError("FV0001", e.message, { file: comp.file, ...where }, e.message.split("\n")[0]);
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
/** Refuse a component written with the Options API, which `readComponent` read no further: as a
|
|
126
|
+
* child when another component imports it, as an island otherwise. */
|
|
127
|
+
export function refuseOptionsApi(comp, script, isChild) {
|
|
128
|
+
const needs = isChild ? "a child component must have" : "an island needs";
|
|
129
|
+
return fail(comp, "FV0003", `${needs} \`<script setup lang="ts">\`, or no script at all: a \`<script>\` without \`setup\` (the Options API, \`defineComponent\`) is not translated`, script);
|
|
130
|
+
}
|
|
131
|
+
/** A component, read and its template compiled. One written with the Options API is read no
|
|
132
|
+
* further than its `<script>`, `optionsApi`, for `refuseOptionsApi` once every component is read. */
|
|
133
|
+
export function readComponent(file, root, name = basename(file, ".vue")) {
|
|
134
|
+
const source = readSource(file);
|
|
119
135
|
const { descriptor, errors } = parseSfc(source, { filename: file });
|
|
120
|
-
const rel =
|
|
136
|
+
const rel = relativePath(root, file);
|
|
121
137
|
const comp = blankComponent(name, snake(name), rel);
|
|
122
138
|
comp.source = source;
|
|
123
139
|
if (errors.length)
|
|
124
140
|
fail(comp, "FV0002", String(errors[0]).replaceAll(file, rel), vueErrorNode(errors[0]) ?? sourceAt(source, 0));
|
|
125
|
-
if (descriptor.script && !descriptor.scriptSetup)
|
|
126
|
-
|
|
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));
|
|
128
|
-
}
|
|
141
|
+
if (descriptor.script && !descriptor.scriptSetup)
|
|
142
|
+
return { comp, ast: [], ssr: "", optionsApi: blockAt(source, descriptor.script) };
|
|
129
143
|
if (!descriptor.template)
|
|
130
144
|
fail(comp, "FV0004", "a component needs a `<template>`: a render function is not translated", sourceAt(source, 0));
|
|
131
|
-
|
|
132
|
-
if (st.module)
|
|
133
|
-
fail(comp, "FV1005", "`<style module>` renames classes in the bundler; use a global or scoped `<style>`, or a stylesheet", blockAt(source, st));
|
|
134
|
-
}
|
|
135
|
-
const cssVar = descriptor.styles.find((st) => /\bv-bind\s*\(/.test(st.content));
|
|
136
|
-
if (descriptor.cssVars.length)
|
|
137
|
-
fail(comp, "FV1006", "`v-bind()` in `<style>` sets variables the server does not render; bind `:style` instead", cssVar ? blockAt(source, cssVar) : undefined);
|
|
145
|
+
comp.cssVarsAt = cssVarsAt(source, descriptor);
|
|
138
146
|
for (const p of ctx.plugins)
|
|
139
147
|
p.sfc?.(comp, descriptor, file, source);
|
|
140
148
|
const script = descriptor.scriptSetup ? scriptOf(comp, descriptor) : null;
|
|
@@ -190,7 +198,7 @@ export function readComponent(file, root, isChild, name = basename(file, ".vue")
|
|
|
190
198
|
generics(comp, source, descriptor.scriptSetup);
|
|
191
199
|
const decls = declareTypes(comp, [...plainAst, ...ast], comp.structs, comp.aliases);
|
|
192
200
|
for (const d of decls)
|
|
193
|
-
comp.
|
|
201
|
+
comp.typeDecls.set(d.name, { members: d.members, node: d.node });
|
|
194
202
|
let propsTy = null;
|
|
195
203
|
for (const s of ast) {
|
|
196
204
|
const call = s.type === "ExpressionStatement" ? s.expression : s.type === "VariableDeclaration" ? s.declarations[0]?.init : null;
|
|
@@ -208,7 +216,12 @@ export function readComponent(file, root, isChild, name = basename(file, ".vue")
|
|
|
208
216
|
comp.props = structOf(comp, "Props", propsTy.members, comp.structs);
|
|
209
217
|
}
|
|
210
218
|
else if (propsTy.type === "TSTypeReference" && comp.structs.has(propsTy.typeName.name)) {
|
|
211
|
-
comp
|
|
219
|
+
reachLocal(comp, propsTy.typeName.name);
|
|
220
|
+
const st = comp.structs.get(propsTy.typeName.name);
|
|
221
|
+
const refused = st.refused?.values().next().value;
|
|
222
|
+
if (refused !== undefined)
|
|
223
|
+
throw refused;
|
|
224
|
+
comp.props = { name: "Props", fields: st.fields };
|
|
212
225
|
}
|
|
213
226
|
else {
|
|
214
227
|
fail(comp, "FV0304", "`defineProps` takes a type literal or an interface declared in the same block", propsTy);
|
|
@@ -251,9 +264,8 @@ export function readComponent(file, root, isChild, name = basename(file, ".vue")
|
|
|
251
264
|
const compiled = compileTemplate({
|
|
252
265
|
source: descriptor.template.content,
|
|
253
266
|
filename: file,
|
|
254
|
-
...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false }),
|
|
267
|
+
...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false, ssrCssVars: [], isProd: true }),
|
|
255
268
|
ssr: true,
|
|
256
|
-
ssrCssVars: [],
|
|
257
269
|
compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), sourceMap: true },
|
|
258
270
|
});
|
|
259
271
|
const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
|