ferrovue 0.3.0 → 0.5.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 +225 -42
- package/dist/attrs.d.ts +3 -53
- package/dist/attrs.js +41 -469
- package/dist/calls.d.ts +4 -0
- package/dist/calls.js +221 -0
- package/dist/children.d.ts +16 -0
- package/dist/children.js +286 -0
- package/dist/classes.d.ts +13 -0
- package/dist/classes.js +176 -0
- package/dist/cli.js +31 -22
- package/dist/client.d.ts +35 -2
- package/dist/client.js +77 -14
- package/dist/compiler.d.ts +1 -1
- package/dist/compiler.js +31 -92
- package/dist/component.d.ts +3 -1
- package/dist/component.js +123 -104
- package/dist/constants.d.ts +42 -0
- package/dist/constants.js +292 -0
- package/dist/context.d.ts +29 -67
- package/dist/context.js +13 -38
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.js +30 -0
- package/dist/diff.d.ts +0 -3
- package/dist/diff.js +0 -9
- package/dist/emitter.d.ts +0 -8
- package/dist/emitter.js +0 -12
- package/dist/errors.d.ts +867 -0
- package/dist/errors.js +330 -0
- package/dist/expr.d.ts +1 -70
- package/dist/expr.js +142 -934
- package/dist/fallthrough.d.ts +9 -0
- package/dist/fallthrough.js +78 -0
- package/dist/fixture.d.ts +4 -10
- package/dist/fixture.js +38 -27
- package/dist/hydration.d.ts +15 -0
- package/dist/hydration.js +80 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/islands.d.ts +1 -0
- package/dist/islands.js +1 -0
- package/dist/link-router.d.ts +24 -0
- package/dist/link-router.js +36 -0
- package/dist/lists.d.ts +0 -11
- package/dist/lists.js +34 -60
- package/dist/loops.d.ts +3 -0
- package/dist/loops.js +168 -0
- package/dist/model.d.ts +46 -144
- package/dist/model.js +150 -18
- package/dist/narrowing.d.ts +27 -0
- package/dist/narrowing.js +157 -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/client-only.d.ts +8 -0
- package/dist/plugins/client-only.js +107 -0
- package/dist/plugins/i18n.d.ts +23 -0
- package/dist/{i18n.js → plugins/i18n.js} +88 -31
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +10 -0
- package/dist/plugins/router-link.d.ts +8 -0
- package/dist/{router.js → plugins/router-link.js} +52 -91
- 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/plugins/scoped.js +125 -0
- 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 +379 -0
- package/dist/plugins/teleport.d.ts +5 -0
- package/dist/plugins/teleport.js +55 -0
- package/dist/plugins/twins.d.ts +10 -0
- package/dist/plugins/twins.js +164 -0
- package/dist/routes.d.ts +10 -0
- package/dist/routes.js +6 -0
- package/dist/rust.d.ts +1 -7
- package/dist/rust.js +56 -174
- package/dist/script.d.ts +3 -6
- package/dist/script.js +127 -140
- package/dist/settle.d.ts +6 -0
- package/dist/settle.js +34 -0
- 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 +1 -42
- package/dist/template.js +25 -749
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +4 -10
- package/dist/types.d.ts +2 -0
- package/dist/typescript.d.ts +2 -14
- package/dist/typescript.js +53 -59
- package/dist/vite.d.ts +2 -1
- package/dist/vite.js +11 -32
- package/package.json +15 -6
- package/dist/i18n.d.ts +0 -27
- package/dist/router.d.ts +0 -17
- package/dist/scoped.d.ts +0 -24
- package/dist/scoped.js +0 -177
- package/dist/stores.d.ts +0 -6
- package/dist/stores.js +0 -229
package/dist/classes.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { escapeHtml } from "@vue/shared";
|
|
2
|
+
import { fail, nothing, rustStr, STR } from "./model.js";
|
|
3
|
+
import { expr } from "./expr.js";
|
|
4
|
+
import { cond, known, truthy } from "./narrowing.js";
|
|
5
|
+
import { meet } from "./strings.js";
|
|
6
|
+
import { atom, bare, condition, logical, not, strArg } from "./parens.js";
|
|
7
|
+
import { Emitter } from "./emitter.js";
|
|
8
|
+
import { valueAttr } from "./attrs.js";
|
|
9
|
+
export function classItems(s, n) {
|
|
10
|
+
switch (n.type) {
|
|
11
|
+
case "StringLiteral": {
|
|
12
|
+
const t = n.value.trim();
|
|
13
|
+
return t ? [{ lit: t }] : [];
|
|
14
|
+
}
|
|
15
|
+
case "ArrayExpression":
|
|
16
|
+
return n.elements.flatMap((el) => (el ? classItems(s, el) : []));
|
|
17
|
+
case "ObjectExpression":
|
|
18
|
+
const literalNames = n.properties
|
|
19
|
+
.filter((p) => p.type === "ObjectProperty" && !p.computed)
|
|
20
|
+
.map((p) => (p.key.type === "Identifier" ? p.key.name : String(p.key.value)));
|
|
21
|
+
const arrayIndex = (k) => /^(0|[1-9]\d*)$/.test(k) && Number(k) < 2 ** 32 - 1;
|
|
22
|
+
if (n.properties.some((p) => p.type === "ObjectProperty" && p.computed) ||
|
|
23
|
+
literalNames.some(arrayIndex) ||
|
|
24
|
+
new Set(literalNames).size !== literalNames.length) {
|
|
25
|
+
const keys = [];
|
|
26
|
+
const entries = n.properties.map((p) => {
|
|
27
|
+
if (p.type !== "ObjectProperty")
|
|
28
|
+
fail(s.comp, "FV1001", "a class object holds `name: condition` pairs", p);
|
|
29
|
+
const key = p.computed ? expr(s, p.key) : { code: rustStr(p.key.type === "Identifier" ? p.key.name : String(p.key.value)), ty: STR };
|
|
30
|
+
if (key.ty.k !== "str")
|
|
31
|
+
fail(s.comp, "FV1002", "a computed class name is a string", p.key);
|
|
32
|
+
for (const other of keys)
|
|
33
|
+
meet(s.comp, other, key, "a class object's names", p.key, "equal");
|
|
34
|
+
keys.push(key);
|
|
35
|
+
return `(${bare(cond(s, p.value))}, ${bare(key.code)})`;
|
|
36
|
+
});
|
|
37
|
+
return [{ code: `&*fv::class_object(&[${entries.join(", ")}])` }];
|
|
38
|
+
}
|
|
39
|
+
return n.properties.flatMap((p) => {
|
|
40
|
+
if (p.type !== "ObjectProperty")
|
|
41
|
+
fail(s.comp, "FV1001", "a class object holds `name: condition` pairs", p);
|
|
42
|
+
let name;
|
|
43
|
+
if (!p.computed) {
|
|
44
|
+
const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value);
|
|
45
|
+
if (key !== key.trim() || !key)
|
|
46
|
+
fail(s.comp, "FV1003", `class name \`${key}\` has spaces around it`, p);
|
|
47
|
+
name = { lit: key };
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const k = expr(s, p.key);
|
|
51
|
+
if (k.ty.k !== "str")
|
|
52
|
+
fail(s.comp, "FV1002", "a computed class name is a string", p.key);
|
|
53
|
+
name = { code: `fv::js_trim(${strArg(k.code)})` };
|
|
54
|
+
}
|
|
55
|
+
const v = expr(s, p.value);
|
|
56
|
+
const on = known(v);
|
|
57
|
+
if (on !== undefined)
|
|
58
|
+
return on ? [name] : [];
|
|
59
|
+
const text = "lit" in name ? rustStr(name.lit) : name.code;
|
|
60
|
+
return [{ code: `if ${condition(truthy(v))} { ${bare(text)} } else { "" }` }];
|
|
61
|
+
});
|
|
62
|
+
case "LogicalExpression":
|
|
63
|
+
if (n.operator === "&&") {
|
|
64
|
+
return conditional(cond(s, n.left), classItems(s, n.right));
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
case "ConditionalExpression":
|
|
68
|
+
if (n.consequent.type === "NullLiteral" || n.alternate.type === "NullLiteral") {
|
|
69
|
+
const branch = n.consequent.type === "NullLiteral" ? n.alternate : n.consequent;
|
|
70
|
+
const test = cond(s, n.test);
|
|
71
|
+
return conditional(n.consequent.type === "NullLiteral" ? not(test) : test, classItems(s, branch));
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const v = expr(s, n);
|
|
76
|
+
if (v.ty.k === "str")
|
|
77
|
+
return [{ code: v.code }];
|
|
78
|
+
if (v.ty.k === "opt" && v.ty.of.k === "str")
|
|
79
|
+
return [{ code: `${atom(v.code)}.unwrap_or("")` }];
|
|
80
|
+
if (nothing(v.ty))
|
|
81
|
+
return [];
|
|
82
|
+
return fail(s.comp, "FV1004", "a class is a string, an array, or an object of conditions", n);
|
|
83
|
+
}
|
|
84
|
+
function conditional(test, items) {
|
|
85
|
+
if (test === "true" || test === "false")
|
|
86
|
+
return test === "true" ? items : [];
|
|
87
|
+
return items.map((it) => ({ code: `if ${condition(test)} { ${"lit" in it ? rustStr(it.lit) : bare(it.code)} } else { "" }` }));
|
|
88
|
+
}
|
|
89
|
+
export function renderClass(s, e, n, after = false) {
|
|
90
|
+
const items = classItems(s, n);
|
|
91
|
+
let wrote = after;
|
|
92
|
+
let i = 0;
|
|
93
|
+
for (; i < items.length; i++) {
|
|
94
|
+
const it = items[i];
|
|
95
|
+
if (!("lit" in it))
|
|
96
|
+
break;
|
|
97
|
+
e.lit((wrote ? " " : "") + escapeHtml(it.lit));
|
|
98
|
+
wrote = true;
|
|
99
|
+
}
|
|
100
|
+
if (i < items.length) {
|
|
101
|
+
const rest = items.slice(i).map((it) => ("lit" in it ? rustStr(it.lit) : it.code));
|
|
102
|
+
e.stmt(`fv::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function classPresent(s, n) {
|
|
106
|
+
if (n.fvMerged)
|
|
107
|
+
return n.elements.reduce((acc, el) => logical(acc, "||", classPresent(s, el)), "false");
|
|
108
|
+
switch (n.type) {
|
|
109
|
+
case "StringLiteral":
|
|
110
|
+
case "NullLiteral":
|
|
111
|
+
case "ArrayExpression":
|
|
112
|
+
case "ObjectExpression":
|
|
113
|
+
return "true";
|
|
114
|
+
case "LogicalExpression":
|
|
115
|
+
if (n.operator === "&&")
|
|
116
|
+
return logical(not(cond(s, n.left)), "||", classPresent(s, n.right));
|
|
117
|
+
break;
|
|
118
|
+
case "ConditionalExpression":
|
|
119
|
+
if (n.consequent.type === "NullLiteral")
|
|
120
|
+
return logical(cond(s, n.test), "||", classPresent(s, n.alternate));
|
|
121
|
+
if (n.alternate.type === "NullLiteral")
|
|
122
|
+
return logical(not(cond(s, n.test)), "||", classPresent(s, n.consequent));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
const v = expr(s, n);
|
|
126
|
+
if (nothing(v.ty))
|
|
127
|
+
return "false";
|
|
128
|
+
if (v.ty.k === "opt")
|
|
129
|
+
return `${atom(v.code)}.is_some()`;
|
|
130
|
+
return "true";
|
|
131
|
+
}
|
|
132
|
+
export function maybeEqual(s, n) {
|
|
133
|
+
if (n.type === "StringLiteral")
|
|
134
|
+
return true;
|
|
135
|
+
if (n.fvMerged || ["ArrayExpression", "ObjectExpression", "NullLiteral", "LogicalExpression", "ConditionalExpression"].includes(n.type))
|
|
136
|
+
return false;
|
|
137
|
+
const v = expr(s, n);
|
|
138
|
+
return v.ty.k === "str" || (v.ty.k === "opt" && v.ty.of.k === "str");
|
|
139
|
+
}
|
|
140
|
+
export function literalClass(n) {
|
|
141
|
+
if (n.type === "StringLiteral")
|
|
142
|
+
return n.value.trim();
|
|
143
|
+
if (!n.fvMerged)
|
|
144
|
+
return null;
|
|
145
|
+
const parts = n.elements.map(literalClass);
|
|
146
|
+
return parts.includes(null) ? null : parts.filter(Boolean).join(" ");
|
|
147
|
+
}
|
|
148
|
+
export function classAttr(s, n, side) {
|
|
149
|
+
switch (n.type) {
|
|
150
|
+
case "StringLiteral":
|
|
151
|
+
return `fv::Attr::str(${rustStr(n.value)})`;
|
|
152
|
+
case "NullLiteral":
|
|
153
|
+
return 'fv::Attr::str("")';
|
|
154
|
+
case "ArrayExpression":
|
|
155
|
+
case "ObjectExpression": {
|
|
156
|
+
const items = classItems(s, n);
|
|
157
|
+
const names = items.every((it) => "lit" in it)
|
|
158
|
+
? rustStr(items.map((it) => ("lit" in it ? it.lit : "")).join(" "))
|
|
159
|
+
: `fv::class_names(&[${items.map((it) => ("lit" in it ? rustStr(it.lit) : it.code)).join(", ")}])`;
|
|
160
|
+
const literal = names.startsWith('"');
|
|
161
|
+
if (side === "vnode")
|
|
162
|
+
return literal ? `fv::Attr::str(${names})` : `fv::Attr::from(${names})`;
|
|
163
|
+
return `fv::Attr::Names(${literal ? `std::borrow::Cow::Borrowed(${names})` : `${names}.into()`})`;
|
|
164
|
+
}
|
|
165
|
+
case "LogicalExpression":
|
|
166
|
+
if (n.operator === "&&")
|
|
167
|
+
return `if ${condition(cond(s, n.left))} { ${classAttr(s, n.right, side)} } else { fv::Attr::str("") }`;
|
|
168
|
+
break;
|
|
169
|
+
case "ConditionalExpression":
|
|
170
|
+
return `if ${condition(cond(s, n.test))} { ${classAttr(s, n.consequent, side)} } else { ${classAttr(s, n.alternate, side)} }`;
|
|
171
|
+
}
|
|
172
|
+
const v = expr(s, n);
|
|
173
|
+
if (v.ty.k === "str" || nothing(v.ty) || (v.ty.k === "opt" && v.ty.of.k === "str"))
|
|
174
|
+
return valueAttr(s, v, n);
|
|
175
|
+
return fail(s.comp, "FV1004", "a class is a string, an array, or an object of conditions", n);
|
|
176
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* `ferrovue`: compile the project's components, run from its root.
|
|
3
|
-
*
|
|
4
|
-
* ferrovue write the Rust modules to the configured output directory
|
|
5
|
-
* ferrovue --check write nothing; exit 1 if the committed modules are not what would be written
|
|
6
|
-
* ferrovue --check --diff show a unified diff of differences
|
|
7
|
-
* ferrovue --watch write them, then again whenever a component, store, type file, the routes
|
|
8
|
-
* or the configuration changes, until interrupted
|
|
9
|
-
* ferrovue init scaffold a starter ferrovue.config.json and components directory
|
|
10
|
-
* ferrovue -c, --config path to configuration file (default: ferrovue.config.json)
|
|
11
|
-
* ferrovue -v, --version print the version and exit
|
|
12
|
-
* ferrovue -h, --help print this help and exit */
|
|
13
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
|
|
14
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
15
|
-
import { CONFIG_FILE,
|
|
4
|
+
import { CONFIG_FILE, generate, loadConfig, VERSION, write } from "./compiler.js";
|
|
16
5
|
import { unifiedDiff } from "./diff.js";
|
|
6
|
+
import { diagnose, formatRefusal, isRefusal } from "./diagnostics.js";
|
|
17
7
|
const root = process.cwd();
|
|
18
8
|
const HELP = `Usage: ferrovue [command] [options]
|
|
19
9
|
|
|
@@ -25,17 +15,23 @@ Commands:
|
|
|
25
15
|
Options:
|
|
26
16
|
--check check that generated files match committed files without writing
|
|
27
17
|
-d, --diff show unified diff of changes when checking (use with --check)
|
|
18
|
+
--format <format> how to write errors and results: human (default) or json
|
|
28
19
|
--watch watch components, stores, routes and config for changes and regenerate
|
|
29
20
|
-c, --config <path> path to configuration file (default: ferrovue.config.json)
|
|
30
21
|
-v, --version print the version and exit
|
|
31
22
|
-h, --help print this help and exit
|
|
32
23
|
`;
|
|
33
|
-
|
|
24
|
+
let json = false;
|
|
25
|
+
function emit(result) {
|
|
26
|
+
console.log(JSON.stringify({ diagnostics: [], ...result }));
|
|
27
|
+
}
|
|
34
28
|
function report(e) {
|
|
35
|
-
if (e
|
|
36
|
-
console.error(`error: ${e.message}`);
|
|
37
|
-
else
|
|
29
|
+
if (!isRefusal(e))
|
|
38
30
|
throw e;
|
|
31
|
+
if (json)
|
|
32
|
+
emit({ diagnostics: [diagnose(e)] });
|
|
33
|
+
else
|
|
34
|
+
console.error(formatRefusal(e));
|
|
39
35
|
}
|
|
40
36
|
function initProject(targetRoot, configPath) {
|
|
41
37
|
const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
|
|
@@ -79,7 +75,6 @@ function check(config, showDiff = false) {
|
|
|
79
75
|
have = readdirSync(dir).filter((f) => f.endsWith(".rs"));
|
|
80
76
|
}
|
|
81
77
|
catch {
|
|
82
|
-
// An absent directory is simply stale.
|
|
83
78
|
}
|
|
84
79
|
const stale = [...want.keys()].filter((name) => {
|
|
85
80
|
try {
|
|
@@ -90,6 +85,10 @@ function check(config, showDiff = false) {
|
|
|
90
85
|
}
|
|
91
86
|
});
|
|
92
87
|
const extra = have.filter((name) => !want.has(name));
|
|
88
|
+
if (json) {
|
|
89
|
+
emit({ out: config.out, stale: stale.map((name) => `${config.out}/${name}`), notGenerated: extra.map((name) => `${config.out}/${name}`) });
|
|
90
|
+
return stale.length || extra.length ? 1 : 0;
|
|
91
|
+
}
|
|
93
92
|
if (stale.length || extra.length) {
|
|
94
93
|
const committed = (name) => {
|
|
95
94
|
try {
|
|
@@ -117,11 +116,11 @@ function check(config, showDiff = false) {
|
|
|
117
116
|
}
|
|
118
117
|
function once(config) {
|
|
119
118
|
const { files, changed, removed } = write(root, config);
|
|
119
|
+
if (json)
|
|
120
|
+
return emit({ out: config.out, files, changed, removed });
|
|
120
121
|
const what = changed.length || removed.length ? `${changed.length} changed, ${removed.length} removed` : "nothing changed";
|
|
121
122
|
console.log(`${config.out}: ${files.length} files, ${what}`);
|
|
122
123
|
}
|
|
123
|
-
/** Regenerate on every change to what the components are compiled from, reporting errors and
|
|
124
|
-
* carrying on. The output directory itself, dependencies and build output are ignored. */
|
|
125
124
|
function watchProject(configPath) {
|
|
126
125
|
const run = () => {
|
|
127
126
|
try {
|
|
@@ -138,7 +137,6 @@ function watchProject(configPath) {
|
|
|
138
137
|
out = loadConfig(root, configPath).out;
|
|
139
138
|
}
|
|
140
139
|
catch {
|
|
141
|
-
// A broken configuration ignores nothing; the next run reports it.
|
|
142
140
|
}
|
|
143
141
|
const parts = file.split(sep);
|
|
144
142
|
return (parts.some((p) => p === "node_modules" || p === "target" || p === ".git" || p === "dist") ||
|
|
@@ -149,12 +147,11 @@ function watchProject(configPath) {
|
|
|
149
147
|
watch(root, { recursive: true }, (_event, name) => {
|
|
150
148
|
if (!name || ignored(relative(root, join(root, name))))
|
|
151
149
|
return;
|
|
152
|
-
// Editors write a file in several steps: one run once they are done.
|
|
153
150
|
if (timer)
|
|
154
151
|
clearTimeout(timer);
|
|
155
152
|
timer = setTimeout(run, 50);
|
|
156
153
|
});
|
|
157
|
-
console.log("watching for changes (Ctrl-C to stop)");
|
|
154
|
+
(json ? console.error : console.log)("watching for changes (Ctrl-C to stop)");
|
|
158
155
|
}
|
|
159
156
|
try {
|
|
160
157
|
const rawArgs = process.argv.slice(2);
|
|
@@ -172,6 +169,14 @@ try {
|
|
|
172
169
|
else if (arg.startsWith("--config=")) {
|
|
173
170
|
configPath = arg.slice("--config=".length);
|
|
174
171
|
}
|
|
172
|
+
else if (arg === "--format" || arg.startsWith("--format=")) {
|
|
173
|
+
const format = arg === "--format" ? rawArgs[++i] : arg.slice("--format=".length);
|
|
174
|
+
if (format !== "human" && format !== "json") {
|
|
175
|
+
console.error(`error: '--format' is 'human' or 'json'`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
json = format === "json";
|
|
179
|
+
}
|
|
175
180
|
else {
|
|
176
181
|
args.push(arg);
|
|
177
182
|
}
|
|
@@ -186,6 +191,10 @@ try {
|
|
|
186
191
|
console.error("error: '--diff' requires '--check'");
|
|
187
192
|
process.exit(1);
|
|
188
193
|
}
|
|
194
|
+
if ((args.includes("--diff") || args.includes("-d")) && json) {
|
|
195
|
+
console.error("error: '--diff' writes text, and does not combine with '--format json'");
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
189
198
|
if (args.includes("--version") || args.includes("-v")) {
|
|
190
199
|
console.log(VERSION);
|
|
191
200
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type App, type Component, type Plugin } from "vue";
|
|
2
2
|
import type { Pinia } from "pinia";
|
|
3
3
|
import type { Router } from "vue-router";
|
|
4
|
-
/** Give Pinia the state the server rendered with
|
|
4
|
+
/** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
|
|
5
5
|
* the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
|
|
6
6
|
export declare function hydrateState(pinia: Pinia, id?: string, doc?: Document): void;
|
|
7
7
|
export interface MountOptions {
|
|
@@ -18,7 +18,7 @@ export interface MountOptions {
|
|
|
18
18
|
* those given or its props are malformed. Warns on the console by default. */
|
|
19
19
|
onError?: (element: Element, problem: string) => void;
|
|
20
20
|
}
|
|
21
|
-
/** A component, or a function that loads one
|
|
21
|
+
/** A component, or a function that loads one (`() => import("./Counter.vue")`), so that the code
|
|
22
22
|
* of an island not on the page is never fetched. A function is taken for a loader as vue-router
|
|
23
23
|
* takes one for a lazy route: unless it has `props` or `displayName`, which mark a functional
|
|
24
24
|
* component, or `__vccOpts`, a class component. `ferrovue/islands` maps the name of every island
|
|
@@ -38,3 +38,36 @@ export interface Islands {
|
|
|
38
38
|
* rendered it with, mounted where it is. A loader is called only for an island the page holds, all
|
|
39
39
|
* of them at once, and the islands mount in document order once every one has loaded. */
|
|
40
40
|
export declare function mountIslands(components: Record<string, IslandComponent>, options?: MountOptions): Promise<Islands>;
|
|
41
|
+
/** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
|
|
42
|
+
* browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
|
|
43
|
+
* compile the default slot, so anything may go there: a component library's components, code that
|
|
44
|
+
* reads `window`. The swap happens only where Vue runs on the page, as in an island. */
|
|
45
|
+
export declare const ClientOnly: Component;
|
|
46
|
+
/** One component the server rendered into a slot of a page's layout, as `ferrovue::Part` records
|
|
47
|
+
* it: its name (`c`) and the props it was rendered from (`p`). */
|
|
48
|
+
export interface PagePart {
|
|
49
|
+
c: string;
|
|
50
|
+
p: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
/** What `ferrovue::PageRecord` writes: the layout's props, and each slot's parts in order. */
|
|
53
|
+
export interface PageRecord {
|
|
54
|
+
props: Record<string, unknown>;
|
|
55
|
+
slots: Record<string, PagePart[]>;
|
|
56
|
+
}
|
|
57
|
+
export interface PageOptions extends Pick<MountOptions, "pinia" | "router" | "plugins"> {
|
|
58
|
+
/** The element holding the layout's root, or a selector for it: `#app` by default. */
|
|
59
|
+
container?: Element | string;
|
|
60
|
+
/** The `id` of the record's script, as given to `PageRecord::script_into`: `__fv_page` by default. */
|
|
61
|
+
record?: string;
|
|
62
|
+
/** The document to read the page from. */
|
|
63
|
+
doc?: Document;
|
|
64
|
+
}
|
|
65
|
+
/** The app of a page: its root renders `layout` with the record's props, and each slot as the
|
|
66
|
+
* components the record names, in order, as a plain array, which hydrates the markup the server
|
|
67
|
+
* wrote with no fragment of its own. `components` must hold every component the record names. */
|
|
68
|
+
export declare function createPageApp(layout: Component, record: PageRecord, components: Record<string, Component>, options?: PageOptions): App;
|
|
69
|
+
/** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
|
|
70
|
+
* no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
|
|
71
|
+
* as the server rendered it, when the container or the record is missing or a component is not
|
|
72
|
+
* given or does not load. */
|
|
73
|
+
export declare function mountPage(layout: IslandComponent, components: Record<string, IslandComponent>, options?: PageOptions): Promise<App>;
|
package/dist/client.js
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
import { createSSRApp } from "vue";
|
|
3
|
-
/** A JSON string, skipped whole as it may hold the same letters, or a bare number JSON has no word
|
|
4
|
-
* for, which the server writes as JavaScript does where `serde_json` alone would write `null`. */
|
|
1
|
+
import { createSSRApp, createTextVNode, defineComponent, h, onMounted, ref } from "vue";
|
|
5
2
|
const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
|
|
6
3
|
const NON_FINITE = { NaN: Number.NaN, Infinity: Number.POSITIVE_INFINITY, "-Infinity": Number.NEGATIVE_INFINITY };
|
|
7
|
-
/** `JSON.parse`, reading back the bare `NaN`, `Infinity` and `-Infinity` that an island's props and
|
|
8
|
-
* the state script hold for a number that is not finite, so the client renders what the server did.
|
|
9
|
-
* Throws as `JSON.parse` does on anything else that is not JSON. */
|
|
10
4
|
function parseJson(text) {
|
|
11
|
-
// Plain JSON: almost everything the server writes, and all it wrote before it wrote these.
|
|
12
5
|
if (!/NaN|Infinity/.test(text))
|
|
13
6
|
return JSON.parse(text);
|
|
14
|
-
// Each token becomes a string that starts with more NULs in a row than any string in the text
|
|
15
|
-
// holds (JSON has one way to write a NUL, `\u0000`), so no other value can be mistaken for one.
|
|
16
7
|
let escaped = "\\u0000";
|
|
17
8
|
while (text.includes(escaped))
|
|
18
9
|
escaped += "\\u0000";
|
|
@@ -20,7 +11,7 @@ function parseJson(text) {
|
|
|
20
11
|
const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
|
|
21
12
|
return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
|
|
22
13
|
}
|
|
23
|
-
/** Give Pinia the state the server rendered with
|
|
14
|
+
/** Give Pinia the state the server rendered with (what `ferrovue::state_script_into` wrote) before
|
|
24
15
|
* the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
|
|
25
16
|
export function hydrateState(pinia, id = "__pinia", doc = document) {
|
|
26
17
|
const text = doc.getElementById(id)?.textContent;
|
|
@@ -35,7 +26,6 @@ export async function mountIslands(components, options = {}) {
|
|
|
35
26
|
const root = options.root ?? document;
|
|
36
27
|
const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
|
|
37
28
|
const elements = Array.from(root.querySelectorAll("[data-island]"));
|
|
38
|
-
// Each component the page names, loaded once however many islands it has.
|
|
39
29
|
const loading = new Map();
|
|
40
30
|
for (const el of elements) {
|
|
41
31
|
const name = el.dataset.island ?? "";
|
|
@@ -84,8 +74,32 @@ export async function mountIslands(components, options = {}) {
|
|
|
84
74
|
},
|
|
85
75
|
};
|
|
86
76
|
}
|
|
87
|
-
/**
|
|
88
|
-
*
|
|
77
|
+
/** Content only the browser renders. The server writes the `#fallback` slot, or nothing; the
|
|
78
|
+
* browser hydrates that fallback, then swaps in the default slot once mounted. ferrovue does not
|
|
79
|
+
* compile the default slot, so anything may go there: a component library's components, code that
|
|
80
|
+
* reads `window`. The swap happens only where Vue runs on the page, as in an island. */
|
|
81
|
+
export const ClientOnly = Object.assign(defineComponent({
|
|
82
|
+
name: "ClientOnly",
|
|
83
|
+
inheritAttrs: false,
|
|
84
|
+
setup(_, { slots }) {
|
|
85
|
+
const mounted = ref(false);
|
|
86
|
+
onMounted(() => {
|
|
87
|
+
mounted.value = true;
|
|
88
|
+
});
|
|
89
|
+
return () => (mounted.value ? slots.default?.() : slots.fallback?.());
|
|
90
|
+
},
|
|
91
|
+
}), {
|
|
92
|
+
ssrRender(ctx, push, parent) {
|
|
93
|
+
const fallback = ctx.$slots.fallback;
|
|
94
|
+
if (!fallback) {
|
|
95
|
+
push("<!---->");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
push("<!--[-->");
|
|
99
|
+
fallback({}, push, parent, "");
|
|
100
|
+
push("<!--]-->");
|
|
101
|
+
},
|
|
102
|
+
});
|
|
89
103
|
async function load(component) {
|
|
90
104
|
if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
|
|
91
105
|
return component;
|
|
@@ -97,3 +111,52 @@ async function load(component) {
|
|
|
97
111
|
return e instanceof Error ? e : new Error(String(e));
|
|
98
112
|
}
|
|
99
113
|
}
|
|
114
|
+
/** The app of a page: its root renders `layout` with the record's props, and each slot as the
|
|
115
|
+
* components the record names, in order, as a plain array, which hydrates the markup the server
|
|
116
|
+
* wrote with no fragment of its own. `components` must hold every component the record names. */
|
|
117
|
+
export function createPageApp(layout, record, components, options = {}) {
|
|
118
|
+
const slots = Object.fromEntries(Object.entries(record.slots).map(([name, parts]) => [
|
|
119
|
+
name,
|
|
120
|
+
() => (parts.length ? parts.map((part) => h(components[part.c], part.p)) : [createTextVNode("")]),
|
|
121
|
+
]));
|
|
122
|
+
const app = createSSRApp({ name: "FerrovuePage", render: () => h(layout, record.props, slots) });
|
|
123
|
+
if (options.pinia)
|
|
124
|
+
app.use(options.pinia);
|
|
125
|
+
if (options.router)
|
|
126
|
+
app.use(options.router);
|
|
127
|
+
for (const plugin of options.plugins ?? [])
|
|
128
|
+
app.use(plugin);
|
|
129
|
+
return app;
|
|
130
|
+
}
|
|
131
|
+
/** Hydrate a page `ferrovue::Page` rendered: read its record, load the components it names (and
|
|
132
|
+
* no others), wait for the router, and mount the layout on the container. Rejects, leaving the page
|
|
133
|
+
* as the server rendered it, when the container or the record is missing or a component is not
|
|
134
|
+
* given or does not load. */
|
|
135
|
+
export async function mountPage(layout, components, options = {}) {
|
|
136
|
+
const doc = options.doc ?? document;
|
|
137
|
+
const selector = options.container ?? "#app";
|
|
138
|
+
const container = typeof selector === "string" ? doc.querySelector(selector) : selector;
|
|
139
|
+
if (!container)
|
|
140
|
+
throw new Error(`[ferrovue] the page has no ${selector} to mount on`);
|
|
141
|
+
const id = options.record ?? "__fv_page";
|
|
142
|
+
const text = doc.getElementById(id)?.textContent;
|
|
143
|
+
if (!text)
|
|
144
|
+
throw new Error(`[ferrovue] the page has no record: no <script id="${id}">`);
|
|
145
|
+
const record = parseJson(text);
|
|
146
|
+
const names = new Set(Object.values(record.slots).flatMap((parts) => parts.map((part) => part.c)));
|
|
147
|
+
const loading = [...names].map(async (name) => {
|
|
148
|
+
if (!Object.hasOwn(components, name))
|
|
149
|
+
throw new Error(`[ferrovue] no component called ${JSON.stringify(name)} was given to mountPage`);
|
|
150
|
+
return [name, await loadOrThrow(name, components[name])];
|
|
151
|
+
});
|
|
152
|
+
const [root, parts] = await Promise.all([loadOrThrow("the layout", layout), Promise.all(loading), options.router?.isReady()]);
|
|
153
|
+
const app = createPageApp(root, record, Object.fromEntries(parts), options);
|
|
154
|
+
app.mount(container);
|
|
155
|
+
return app;
|
|
156
|
+
}
|
|
157
|
+
async function loadOrThrow(name, component) {
|
|
158
|
+
const result = await load(component);
|
|
159
|
+
if (result instanceof Error)
|
|
160
|
+
throw new Error(`[ferrovue] ${name} did not load: ${result.message}`, { cause: result });
|
|
161
|
+
return result;
|
|
162
|
+
}
|
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 TwinSpec, type TypeName } from "./context.ts";
|
|
22
22
|
export { GenError } from "./model.ts";
|
|
23
23
|
export declare const VERSION: string;
|