ferrovue 0.2.0 → 0.3.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 +73 -4
- package/dist/attrs.d.ts +21 -5
- package/dist/attrs.js +305 -24
- package/dist/cli.js +131 -20
- package/dist/client.d.ts +11 -2
- package/dist/client.js +56 -8
- package/dist/compiler.d.ts +4 -0
- package/dist/compiler.js +17 -5
- package/dist/component.js +13 -0
- package/dist/context.d.ts +2 -2
- package/dist/context.js +23 -5
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +77 -0
- package/dist/expr.js +39 -1
- package/dist/islands.d.ts +5 -0
- package/dist/islands.js +4 -0
- package/dist/model.d.ts +21 -0
- package/dist/model.js +13 -0
- package/dist/router.js +52 -2
- package/dist/rust.d.ts +3 -0
- package/dist/rust.js +37 -10
- package/dist/scoped.d.ts +15 -1
- package/dist/scoped.js +88 -1
- package/dist/script.js +18 -2
- package/dist/stores.js +2 -0
- package/dist/template.d.ts +8 -5
- package/dist/template.js +131 -58
- package/dist/vite.js +45 -2
- package/package.json +5 -1
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* `ferrovue
|
|
2
|
+
/* `ferrovue`: compile the project's components, run from its root.
|
|
3
3
|
*
|
|
4
|
-
* ferrovue
|
|
5
|
-
* ferrovue --check
|
|
6
|
-
* ferrovue --
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { CONFIG_FILE, GenError, generate, loadConfig, VERSION, write } from "./compiler.js";
|
|
16
|
+
import { unifiedDiff } from "./diff.js";
|
|
11
17
|
const root = process.cwd();
|
|
18
|
+
const HELP = `Usage: ferrovue [command] [options]
|
|
19
|
+
|
|
20
|
+
Compile Vue components to Rust render functions, run from the project root.
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
init scaffold starter ferrovue.config.json and components
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--check check that generated files match committed files without writing
|
|
27
|
+
-d, --diff show unified diff of changes when checking (use with --check)
|
|
28
|
+
--watch watch components, stores, routes and config for changes and regenerate
|
|
29
|
+
-c, --config <path> path to configuration file (default: ferrovue.config.json)
|
|
30
|
+
-v, --version print the version and exit
|
|
31
|
+
-h, --help print this help and exit
|
|
32
|
+
`;
|
|
12
33
|
/** A refused construct, or a broken configuration: the message alone, which names the file. */
|
|
13
34
|
function report(e) {
|
|
14
35
|
if (e instanceof GenError || e instanceof SyntaxError)
|
|
@@ -16,7 +37,41 @@ function report(e) {
|
|
|
16
37
|
else
|
|
17
38
|
throw e;
|
|
18
39
|
}
|
|
19
|
-
function
|
|
40
|
+
function initProject(targetRoot, configPath) {
|
|
41
|
+
const configFile = configPath ? resolve(targetRoot, configPath) : join(targetRoot, CONFIG_FILE);
|
|
42
|
+
if (existsSync(configFile)) {
|
|
43
|
+
console.error(`error: configuration file already exists: ${relative(targetRoot, configFile) || CONFIG_FILE}`);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
const defaultComponents = "components";
|
|
47
|
+
const defaultOut = "src/generated";
|
|
48
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
49
|
+
const config = {
|
|
50
|
+
components: defaultComponents,
|
|
51
|
+
out: defaultOut,
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
|
|
54
|
+
console.log(`created ${relative(targetRoot, configFile) || CONFIG_FILE}`);
|
|
55
|
+
const compDir = join(targetRoot, defaultComponents);
|
|
56
|
+
mkdirSync(compDir, { recursive: true });
|
|
57
|
+
const helloVue = join(compDir, "Hello.vue");
|
|
58
|
+
if (!existsSync(helloVue)) {
|
|
59
|
+
writeFileSync(helloVue, `<script setup lang="ts">
|
|
60
|
+
defineProps<{ name: string }>();
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<p>Hello, {{ name }}!</p>
|
|
65
|
+
</template>
|
|
66
|
+
`);
|
|
67
|
+
console.log(`created ${join(defaultComponents, "Hello.vue")}`);
|
|
68
|
+
}
|
|
69
|
+
const outDir = join(targetRoot, defaultOut);
|
|
70
|
+
mkdirSync(outDir, { recursive: true });
|
|
71
|
+
console.log("\nNext steps:\n 1. Run `ferrovue` to generate Rust render functions into src/generated\n 2. In your Rust crate, declare `mod generated;` to use them");
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
function check(config, showDiff = false) {
|
|
20
75
|
const want = generate(root, config);
|
|
21
76
|
const dir = join(root, config.out);
|
|
22
77
|
let have = [];
|
|
@@ -36,10 +91,24 @@ function check(config) {
|
|
|
36
91
|
});
|
|
37
92
|
const extra = have.filter((name) => !want.has(name));
|
|
38
93
|
if (stale.length || extra.length) {
|
|
39
|
-
|
|
94
|
+
const committed = (name) => {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(join(dir, name), "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
for (const name of stale) {
|
|
40
103
|
console.error(`stale: ${config.out}/${name}`);
|
|
41
|
-
|
|
104
|
+
if (showDiff)
|
|
105
|
+
console.error(unifiedDiff(`${config.out}/${name}`, committed(name), want.get(name)));
|
|
106
|
+
}
|
|
107
|
+
for (const name of extra) {
|
|
42
108
|
console.error(`not generated: ${config.out}/${name}`);
|
|
109
|
+
if (showDiff)
|
|
110
|
+
console.error(unifiedDiff(`${config.out}/${name}`, committed(name), ""));
|
|
111
|
+
}
|
|
43
112
|
console.error("run `ferrovue` to regenerate");
|
|
44
113
|
return 1;
|
|
45
114
|
}
|
|
@@ -53,10 +122,10 @@ function once(config) {
|
|
|
53
122
|
}
|
|
54
123
|
/** Regenerate on every change to what the components are compiled from, reporting errors and
|
|
55
124
|
* carrying on. The output directory itself, dependencies and build output are ignored. */
|
|
56
|
-
function watchProject() {
|
|
125
|
+
function watchProject(configPath) {
|
|
57
126
|
const run = () => {
|
|
58
127
|
try {
|
|
59
|
-
once(loadConfig(root));
|
|
128
|
+
once(loadConfig(root, configPath));
|
|
60
129
|
}
|
|
61
130
|
catch (e) {
|
|
62
131
|
report(e);
|
|
@@ -66,7 +135,7 @@ function watchProject() {
|
|
|
66
135
|
const ignored = (file) => {
|
|
67
136
|
let out = "";
|
|
68
137
|
try {
|
|
69
|
-
out = loadConfig(root).out;
|
|
138
|
+
out = loadConfig(root, configPath).out;
|
|
70
139
|
}
|
|
71
140
|
catch {
|
|
72
141
|
// A broken configuration ignores nothing; the next run reports it.
|
|
@@ -88,12 +157,54 @@ function watchProject() {
|
|
|
88
157
|
console.log("watching for changes (Ctrl-C to stop)");
|
|
89
158
|
}
|
|
90
159
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
160
|
+
const rawArgs = process.argv.slice(2);
|
|
161
|
+
let configPath;
|
|
162
|
+
const args = [];
|
|
163
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
164
|
+
const arg = rawArgs[i];
|
|
165
|
+
if (arg === "-c" || arg === "--config") {
|
|
166
|
+
if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) {
|
|
167
|
+
console.error(`error: option '${arg}' requires an argument`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
configPath = rawArgs[++i];
|
|
171
|
+
}
|
|
172
|
+
else if (arg.startsWith("--config=")) {
|
|
173
|
+
configPath = arg.slice("--config=".length);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
args.push(arg);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const known = new Set(["init", "--check", "--diff", "-d", "--watch", "-v", "--version", "-h", "--help"]);
|
|
180
|
+
const unknown = args.find((a) => !known.has(a));
|
|
181
|
+
if (unknown !== undefined) {
|
|
182
|
+
console.error(`error: unknown option or command '${unknown}'\n\nRun \`ferrovue --help\` for usage.`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
if ((args.includes("--diff") || args.includes("-d")) && !args.includes("--check")) {
|
|
186
|
+
console.error("error: '--diff' requires '--check'");
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
190
|
+
console.log(VERSION);
|
|
191
|
+
}
|
|
192
|
+
else if (args.includes("--help") || args.includes("-h")) {
|
|
193
|
+
console.log(HELP.trimEnd());
|
|
194
|
+
}
|
|
195
|
+
else if (args.includes("init")) {
|
|
196
|
+
process.exitCode = initProject(root, configPath);
|
|
197
|
+
}
|
|
198
|
+
else if (args.includes("--watch")) {
|
|
199
|
+
watchProject(configPath);
|
|
200
|
+
}
|
|
201
|
+
else if (args.includes("--check")) {
|
|
202
|
+
const showDiff = args.includes("--diff") || args.includes("-d");
|
|
203
|
+
process.exitCode = check(loadConfig(root, configPath), showDiff);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
once(loadConfig(root, configPath));
|
|
207
|
+
}
|
|
97
208
|
}
|
|
98
209
|
catch (e) {
|
|
99
210
|
report(e);
|
package/dist/client.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ 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 — `() => import("./Counter.vue")` — so that the code
|
|
22
|
+
* of an island not on the page is never fetched. A function is taken for a loader as vue-router
|
|
23
|
+
* takes one for a lazy route: unless it has `props` or `displayName`, which mark a functional
|
|
24
|
+
* component, or `__vccOpts`, a class component. `ferrovue/islands` maps the name of every island
|
|
25
|
+
* to a loader. */
|
|
26
|
+
export type IslandComponent = Component | (() => Promise<Component | {
|
|
27
|
+
default: Component;
|
|
28
|
+
}>);
|
|
21
29
|
/** The islands mounted. */
|
|
22
30
|
export interface Islands {
|
|
23
31
|
/** One app per island, in document order. */
|
|
@@ -27,5 +35,6 @@ export interface Islands {
|
|
|
27
35
|
}
|
|
28
36
|
/** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
|
|
29
37
|
* `Html::island` wrote becomes an app of the component of that name, given the props the server
|
|
30
|
-
* rendered it with, mounted where it is.
|
|
31
|
-
|
|
38
|
+
* rendered it with, mounted where it is. A loader is called only for an island the page holds, all
|
|
39
|
+
* of them at once, and the islands mount in document order once every one has loaded. */
|
|
40
|
+
export declare function mountIslands(components: Record<string, IslandComponent>, options?: MountOptions): Promise<Islands>;
|
package/dist/client.js
CHANGED
|
@@ -1,31 +1,66 @@
|
|
|
1
1
|
/* What the browser does with a page ferrovue rendered. */
|
|
2
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`. */
|
|
5
|
+
const BARE = /"(?:[^"\\]|\\.)*"|-?Infinity|NaN/g;
|
|
6
|
+
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
|
+
function parseJson(text) {
|
|
11
|
+
// Plain JSON: almost everything the server writes, and all it wrote before it wrote these.
|
|
12
|
+
if (!/NaN|Infinity/.test(text))
|
|
13
|
+
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
|
+
let escaped = "\\u0000";
|
|
17
|
+
while (text.includes(escaped))
|
|
18
|
+
escaped += "\\u0000";
|
|
19
|
+
const tag = JSON.parse(`"${escaped}"`);
|
|
20
|
+
const quoted = text.replace(BARE, (match) => (match.startsWith('"') ? match : `"${escaped}${match}"`));
|
|
21
|
+
return JSON.parse(quoted, (_key, value) => (typeof value === "string" && value.startsWith(tag) ? NON_FINITE[value.slice(tag.length)] : value));
|
|
22
|
+
}
|
|
3
23
|
/** Give Pinia the state the server rendered with — what `ferrovue::state_script_into` wrote — before
|
|
4
24
|
* the app mounts, so every store starts from it and the hydrated markup agrees with the server's. */
|
|
5
25
|
export function hydrateState(pinia, id = "__pinia", doc = document) {
|
|
6
26
|
const text = doc.getElementById(id)?.textContent;
|
|
7
27
|
if (text)
|
|
8
|
-
pinia.state.value =
|
|
28
|
+
pinia.state.value = parseJson(text);
|
|
9
29
|
}
|
|
10
30
|
/** Hydrate every island on the page: each `<div data-island="Name" data-props="…">` that
|
|
11
31
|
* `Html::island` wrote becomes an app of the component of that name, given the props the server
|
|
12
|
-
* rendered it with, mounted where it is.
|
|
32
|
+
* rendered it with, mounted where it is. A loader is called only for an island the page holds, all
|
|
33
|
+
* of them at once, and the islands mount in document order once every one has loaded. */
|
|
13
34
|
export async function mountIslands(components, options = {}) {
|
|
14
35
|
const root = options.root ?? document;
|
|
15
36
|
const report = options.onError ?? ((el, problem) => console.warn(`[ferrovue] island left unhydrated: ${problem}`, el));
|
|
16
|
-
|
|
17
|
-
|
|
37
|
+
const elements = Array.from(root.querySelectorAll("[data-island]"));
|
|
38
|
+
// Each component the page names, loaded once however many islands it has.
|
|
39
|
+
const loading = new Map();
|
|
40
|
+
for (const el of elements) {
|
|
41
|
+
const name = el.dataset.island ?? "";
|
|
42
|
+
if (Object.hasOwn(components, name) && !loading.has(name))
|
|
43
|
+
loading.set(name, load(components[name]));
|
|
44
|
+
}
|
|
45
|
+
const [loaded] = await Promise.all([
|
|
46
|
+
Promise.all([...loading].map(async ([name, pending]) => [name, await pending])).then((pairs) => new Map(pairs)),
|
|
47
|
+
options.router?.isReady(),
|
|
48
|
+
]);
|
|
18
49
|
const apps = [];
|
|
19
|
-
for (const el of
|
|
50
|
+
for (const el of elements) {
|
|
20
51
|
const name = el.dataset.island ?? "";
|
|
21
|
-
const component =
|
|
52
|
+
const component = loaded.get(name);
|
|
22
53
|
if (!component) {
|
|
23
|
-
report(el, `no component called ${JSON.stringify(name)} was given`);
|
|
54
|
+
report(el, `no component called ${JSON.stringify(name)} was given (\`ferrovue/islands\` holds every component that has an \`island()\`)`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (component instanceof Error) {
|
|
58
|
+
report(el, `${name} did not load: ${component.message}`);
|
|
24
59
|
continue;
|
|
25
60
|
}
|
|
26
61
|
let props;
|
|
27
62
|
try {
|
|
28
|
-
props =
|
|
63
|
+
props = parseJson(el.dataset.props ?? "{}");
|
|
29
64
|
}
|
|
30
65
|
catch {
|
|
31
66
|
report(el, `the props of ${name} are not JSON`);
|
|
@@ -49,3 +84,16 @@ export async function mountIslands(components, options = {}) {
|
|
|
49
84
|
},
|
|
50
85
|
};
|
|
51
86
|
}
|
|
87
|
+
/** The component itself, or what its loader resolves to: a module's default export, or the
|
|
88
|
+
* component. A loader that rejects gives its error, which leaves only its own islands unhydrated. */
|
|
89
|
+
async function load(component) {
|
|
90
|
+
if (typeof component !== "function" || "props" in component || "displayName" in component || "__vccOpts" in component)
|
|
91
|
+
return component;
|
|
92
|
+
try {
|
|
93
|
+
const loaded = await component();
|
|
94
|
+
return typeof loaded === "object" && "default" in loaded ? loaded.default : loaded;
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/compiler.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export interface Written {
|
|
|
10
10
|
changed: string[];
|
|
11
11
|
/** The files removed, which the components no longer produce. */
|
|
12
12
|
removed: string[];
|
|
13
|
+
/** Every component that has an `island()`, by the name `data-island` carries, with its `.vue`
|
|
14
|
+
* file relative to the root: what `ferrovue/islands` loads. */
|
|
15
|
+
islands: Record<string, string>;
|
|
13
16
|
}
|
|
14
17
|
/** Write what \`generate\` produces to the configured directory: only the files whose text changed,
|
|
15
18
|
* so a Rust build that watches them rebuilds no more than it must, and removing what no component
|
|
@@ -17,3 +20,4 @@ export interface Written {
|
|
|
17
20
|
export declare function write(root: string, config?: Config): Written;
|
|
18
21
|
export { CONFIG_FILE, loadConfig, TYPES_MODULE, type Config, type HelperSpec, type TypeName } from "./context.ts";
|
|
19
22
|
export { GenError } from "./model.ts";
|
|
23
|
+
export declare const VERSION: string;
|
package/dist/compiler.js
CHANGED
|
@@ -24,8 +24,8 @@ import { readRoutes } from "./router.js";
|
|
|
24
24
|
import { readStores } from "./stores.js";
|
|
25
25
|
import { i18nSource, readLocales } from "./i18n.js";
|
|
26
26
|
import { scopeFor } from "./script.js";
|
|
27
|
-
import { scopeFlow } from "./scoped.js";
|
|
28
|
-
import { componentSource, modSource, routesSource, storesSource, typesSource } from "./rust.js";
|
|
27
|
+
import { attrsFlow, scopeFlow } from "./scoped.js";
|
|
28
|
+
import { componentSource, isIsland, modSource, routesSource, storesSource, typesSource } from "./rust.js";
|
|
29
29
|
/** Every generated file, keyed by its name in the output directory. */
|
|
30
30
|
export function generate(root, config = loadConfig(root)) {
|
|
31
31
|
ctx.componentsDir = config.components.replace(/\/?$/, "/");
|
|
@@ -66,9 +66,11 @@ export function generate(root, config = loadConfig(root)) {
|
|
|
66
66
|
// Whether a component reads a store or the route is known once its setup is read; both reach
|
|
67
67
|
// every component on the way down to one that does, and the route every one on the way down to
|
|
68
68
|
// a `<RouterLink>`.
|
|
69
|
-
const
|
|
69
|
+
const scopes = read.map((r) => scopeFor(r.comp, r.ast, components).scope);
|
|
70
70
|
// Which roots may be handed scope ids, and which slot content given a slot scope id.
|
|
71
|
-
scopeFlow(read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children:
|
|
71
|
+
scopeFlow(read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children })));
|
|
72
|
+
// Which components may be passed attributes beyond their props.
|
|
73
|
+
attrsFlow(read.map((r, i) => ({ comp: r.comp, ssr: r.ssr, children: scopes[i].children, attrsBindings: scopes[i].attrsBindings })));
|
|
72
74
|
for (const c of components.values())
|
|
73
75
|
c.usesRoute = c.routerLink || c.readsRoute;
|
|
74
76
|
for (let changed = true; changed;) {
|
|
@@ -165,7 +167,17 @@ export function write(root, config = loadConfig(root)) {
|
|
|
165
167
|
const removed = readdirSync(target).filter((f) => f.endsWith(".rs") && !files.has(f));
|
|
166
168
|
for (const f of removed)
|
|
167
169
|
rmSync(join(target, f));
|
|
168
|
-
|
|
170
|
+
const islands = Object.fromEntries([...ctx.components.values()].filter(isIsland).map((c) => [c.name, c.file]));
|
|
171
|
+
return { files: [...files.keys()], changed, removed, islands };
|
|
169
172
|
}
|
|
170
173
|
export { CONFIG_FILE, loadConfig, TYPES_MODULE } from "./context.js";
|
|
171
174
|
export { GenError } from "./model.js";
|
|
175
|
+
export const VERSION = (() => {
|
|
176
|
+
try {
|
|
177
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
178
|
+
return pkg.version ?? "unknown";
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return "unknown";
|
|
182
|
+
}
|
|
183
|
+
})();
|
package/dist/component.js
CHANGED
|
@@ -84,6 +84,8 @@ export function readComponent(file, root) {
|
|
|
84
84
|
inheritAttrs: true,
|
|
85
85
|
inherits: false,
|
|
86
86
|
passesSlotIds: false,
|
|
87
|
+
attrNames: new Set(),
|
|
88
|
+
idsInAttrs: false,
|
|
87
89
|
};
|
|
88
90
|
comp.source = source;
|
|
89
91
|
if (errors.length)
|
|
@@ -228,6 +230,17 @@ export function readComponent(file, root) {
|
|
|
228
230
|
ssrCssVars: [],
|
|
229
231
|
compilerOptions: { bindingMetadata: script.bindings, sourceMap: true },
|
|
230
232
|
});
|
|
233
|
+
// `ssrInjectFallthroughAttrs`: a root `<Transition>` or `<KeepAlive>` passes `_attrs` on only to
|
|
234
|
+
// a lone element or component without `v-if` or `v-for`.
|
|
235
|
+
const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
|
|
236
|
+
const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
|
|
237
|
+
const wrapper = roots.length === 1 && roots[0].type === 1 && roots[0].tagType === 1 && ["Transition", "transition", "KeepAlive", "keep-alive"].includes(roots[0].tag) ? roots[0] : null;
|
|
238
|
+
if (wrapper) {
|
|
239
|
+
const inner = kids(wrapper);
|
|
240
|
+
const lone = inner.length === 1 && inner[0].type === 1 && (inner[0].tagType === 0 || inner[0].tagType === 1) && !inner[0].props.some((p) => p.type === 7 && ["if", "else-if", "else", "for"].includes(p.name));
|
|
241
|
+
if (!lone)
|
|
242
|
+
comp.attrsDropped = { type: "VueTemplate", loc: { start: { line: wrapper.loc.start.line, column: wrapper.loc.start.column - 1 } }, __fv: "source" };
|
|
243
|
+
}
|
|
231
244
|
const start = descriptor.template.loc.start;
|
|
232
245
|
comp.templateStart = { line: start.line, column: start.column };
|
|
233
246
|
if (compiled.map)
|
package/dist/context.d.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface Config {
|
|
|
44
44
|
* `v-`: `["focus", "click-outside"]`. Any other custom directive is refused, since the server
|
|
45
45
|
* cannot know what its `getSSRProps` would add. */
|
|
46
46
|
clientDirectives?: string[];
|
|
47
|
-
/** vue-i18n: the directory of locale files (
|
|
47
|
+
/** vue-i18n: the directory of locale files (`en.json`, `nl.json`), the locale a page renders in
|
|
48
48
|
* when it names none, and the locales a missing message falls back to. */
|
|
49
49
|
i18n?: {
|
|
50
50
|
messages: string;
|
|
@@ -62,7 +62,7 @@ export interface Config {
|
|
|
62
62
|
export type ScopeIdMode = "filepath" | "filepath-source";
|
|
63
63
|
export declare const CONFIG_FILE = "ferrovue.config.json";
|
|
64
64
|
/** Read the project's configuration. */
|
|
65
|
-
export declare function loadConfig(root: string): Config;
|
|
65
|
+
export declare function loadConfig(root: string, configPath?: string): Config;
|
|
66
66
|
export declare function tyOfName(name: TypeName): Ty;
|
|
67
67
|
/** A route as the routes file lists it: a vue-router path, and the name it may have. */
|
|
68
68
|
export interface RouteDef {
|
package/dist/context.js
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
/* The project's configuration, and the state one `generate()` run builds up. */
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { BOOL, FLOAT, GenError, INT, opt, STR } from "./model.js";
|
|
5
5
|
export const CONFIG_FILE = "ferrovue.config.json";
|
|
6
6
|
/** Read the project's configuration. */
|
|
7
|
-
export function loadConfig(root) {
|
|
8
|
-
const
|
|
7
|
+
export function loadConfig(root, configPath) {
|
|
8
|
+
const filePath = configPath ? resolve(root, configPath) : join(root, CONFIG_FILE);
|
|
9
|
+
const displayName = configPath ?? CONFIG_FILE;
|
|
10
|
+
let text = "";
|
|
11
|
+
try {
|
|
12
|
+
text = readFileSync(filePath, "utf8");
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
if (e.code === "ENOENT") {
|
|
16
|
+
throw new GenError(`cannot find \`${displayName}\` in ${root}`);
|
|
17
|
+
}
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new GenError(`failed to parse \`${displayName}\`: ${e.message}`);
|
|
26
|
+
}
|
|
9
27
|
if (typeof raw.components !== "string" || typeof raw.out !== "string") {
|
|
10
|
-
throw new GenError(`${
|
|
28
|
+
throw new GenError(`${displayName} needs \`components\` and \`out\` directories`);
|
|
11
29
|
}
|
|
12
30
|
if (raw.scopeId !== undefined && raw.scopeId !== "filepath" && raw.scopeId !== "filepath-source") {
|
|
13
|
-
throw new GenError(`\`scopeId\` in ${
|
|
31
|
+
throw new GenError(`\`scopeId\` in ${displayName} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`);
|
|
14
32
|
}
|
|
15
33
|
return raw;
|
|
16
34
|
}
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** A unified diff of a module, as \`diff -u\` writes it, with three lines of context: what
|
|
2
|
+
* \`ferrovue\` would write against what is committed. Lines the two share at the start and the end
|
|
3
|
+
* are set aside first, so that a module changed in one place is compared over that place alone. */
|
|
4
|
+
export declare function unifiedDiff(path: string, before: string, after: string): string;
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* `ferrovue --check --diff`: what would be written, against what is committed. */
|
|
2
|
+
/** A unified diff of a module, as \`diff -u\` writes it, with three lines of context: what
|
|
3
|
+
* \`ferrovue\` would write against what is committed. Lines the two share at the start and the end
|
|
4
|
+
* are set aside first, so that a module changed in one place is compared over that place alone. */
|
|
5
|
+
export function unifiedDiff(path, before, after) {
|
|
6
|
+
const lines = (text) => (text === "" ? [] : text.replace(/\n$/, "").split("\n"));
|
|
7
|
+
const a = lines(before);
|
|
8
|
+
const b = lines(after);
|
|
9
|
+
let head = 0;
|
|
10
|
+
while (head < a.length && head < b.length && a[head] === b[head])
|
|
11
|
+
head++;
|
|
12
|
+
let tail = 0;
|
|
13
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
|
|
14
|
+
tail++;
|
|
15
|
+
const midA = a.slice(head, a.length - tail);
|
|
16
|
+
const midB = b.slice(head, b.length - tail);
|
|
17
|
+
if (!midA.length && !midB.length)
|
|
18
|
+
return "";
|
|
19
|
+
// The longest common subsequence of the middle, by dynamic programming over its lines.
|
|
20
|
+
const lcs = Array.from({ length: midA.length + 1 }, () => new Uint32Array(midB.length + 1));
|
|
21
|
+
for (let i = midA.length - 1; i >= 0; i--) {
|
|
22
|
+
for (let j = midB.length - 1; j >= 0; j--) {
|
|
23
|
+
lcs[i][j] = midA[i] === midB[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const edits = a.slice(0, head).map((text) => ({ op: " ", text }));
|
|
27
|
+
let i = 0;
|
|
28
|
+
let j = 0;
|
|
29
|
+
// Of two ways to keep as many lines, a removal comes first, as `diff -u` orders them.
|
|
30
|
+
while (i < midA.length || j < midB.length) {
|
|
31
|
+
if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
|
|
32
|
+
edits.push({ op: " ", text: midA[i++] });
|
|
33
|
+
j++;
|
|
34
|
+
}
|
|
35
|
+
else if (j < midB.length && (i === midA.length || lcs[i][j + 1] > lcs[i + 1][j]))
|
|
36
|
+
edits.push({ op: "+", text: midB[j++] });
|
|
37
|
+
else
|
|
38
|
+
edits.push({ op: "-", text: midA[i++] });
|
|
39
|
+
}
|
|
40
|
+
edits.push(...a.slice(a.length - tail).map((text) => ({ op: " ", text })));
|
|
41
|
+
// Each changed line with three lines around it, runs that touch joined into one hunk.
|
|
42
|
+
const CONTEXT = 3;
|
|
43
|
+
const hunks = [];
|
|
44
|
+
edits.forEach((e, k) => {
|
|
45
|
+
if (e.op === " ")
|
|
46
|
+
return;
|
|
47
|
+
const from = Math.max(0, k - CONTEXT);
|
|
48
|
+
const to = Math.min(edits.length - 1, k + CONTEXT);
|
|
49
|
+
const last = hunks.at(-1);
|
|
50
|
+
if (last && from <= last[1] + 1)
|
|
51
|
+
last[1] = to;
|
|
52
|
+
else
|
|
53
|
+
hunks.push([from, to]);
|
|
54
|
+
});
|
|
55
|
+
const out = [`--- ${a.length ? `a/${path}` : "/dev/null"}`, `+++ ${b.length ? `b/${path}` : "/dev/null"}`];
|
|
56
|
+
// Line numbers in each file where every edit starts.
|
|
57
|
+
let lineA = 1;
|
|
58
|
+
let lineB = 1;
|
|
59
|
+
const at = edits.map((e) => {
|
|
60
|
+
const here = [lineA, lineB];
|
|
61
|
+
if (e.op !== "+")
|
|
62
|
+
lineA++;
|
|
63
|
+
if (e.op !== "-")
|
|
64
|
+
lineB++;
|
|
65
|
+
return here;
|
|
66
|
+
});
|
|
67
|
+
for (const [from, to] of hunks) {
|
|
68
|
+
const span = edits.slice(from, to + 1);
|
|
69
|
+
const countA = span.filter((e) => e.op !== "+").length;
|
|
70
|
+
const countB = span.filter((e) => e.op !== "-").length;
|
|
71
|
+
// An empty side is numbered by the line before it, as `diff -u` numbers it.
|
|
72
|
+
const startA = countA ? at[from][0] : at[from][0] - 1;
|
|
73
|
+
const startB = countB ? at[from][1] : at[from][1] - 1;
|
|
74
|
+
out.push(`@@ -${startA},${countA} +${startB},${countB} @@`, ...span.map((e) => `${e.op}${e.text}`));
|
|
75
|
+
}
|
|
76
|
+
return out.join("\n");
|
|
77
|
+
}
|
package/dist/expr.js
CHANGED
|
@@ -74,7 +74,13 @@ export function asCow(v) {
|
|
|
74
74
|
/** Whether built code is a `Cow<str>` already: a runtime routine that borrows when it can
|
|
75
75
|
* (`js_slice`, `js_replace`, `js_pad_start`, …), or a fallback taken from one (`.unwrap_or(Cow…)`). */
|
|
76
76
|
export function yieldsCow(code) {
|
|
77
|
-
|
|
77
|
+
// The routine's call is the whole of it: `fv::js_slice(…).to_lowercase()` is a `String`.
|
|
78
|
+
const routine = /^fv::js_(?:slice|substring|replace|replace_all|pad_start|pad_end)(?=\()/.exec(code);
|
|
79
|
+
if (routine !== null)
|
|
80
|
+
return enclosed(code.slice(routine[0].length));
|
|
81
|
+
// The fallback is the last call of it: `format!("{}a", x.unwrap_or(Cow…))` is a `String`.
|
|
82
|
+
const fallback = code.lastIndexOf(".unwrap_or(std::borrow::Cow::<str>::");
|
|
83
|
+
return fallback >= 0 && enclosed(code.slice(fallback + ".unwrap_or".length));
|
|
78
84
|
}
|
|
79
85
|
/** Whether a value is a JavaScript number: an integer or a fraction. */
|
|
80
86
|
export function isNumber(ty) {
|
|
@@ -272,7 +278,27 @@ export function pathOf(n) {
|
|
|
272
278
|
return `${base}.${n.property.value}`;
|
|
273
279
|
return null;
|
|
274
280
|
}
|
|
281
|
+
/** `typeof x === "string"` or `typeof x !== "string"`: the `x` tested, and whether the test is
|
|
282
|
+
* negated. */
|
|
283
|
+
function typeofString(n) {
|
|
284
|
+
if (n.type !== "BinaryExpression" || (n.operator !== "===" && n.operator !== "!=="))
|
|
285
|
+
return null;
|
|
286
|
+
const isTypeof = (m) => m.type === "UnaryExpression" && m.operator === "typeof";
|
|
287
|
+
const isString = (m) => m.type === "StringLiteral" && m.value === "string";
|
|
288
|
+
const target = isTypeof(n.left) && isString(n.right) ? n.left.argument : isTypeof(n.right) && isString(n.left) ? n.right.argument : null;
|
|
289
|
+
return target ? { target, negated: n.operator === "!==" } : null;
|
|
290
|
+
}
|
|
275
291
|
export function presence(s, n) {
|
|
292
|
+
// A query value narrowed to a single string, which an attribute can then be bound to.
|
|
293
|
+
const t = typeofString(n);
|
|
294
|
+
if (t) {
|
|
295
|
+
const path = pathOf(t.target);
|
|
296
|
+
const v = expr(s, t.target);
|
|
297
|
+
if (path === null || v.ty.k !== "query")
|
|
298
|
+
return null;
|
|
299
|
+
const option = `${atom(v.code)}.attr_value()`;
|
|
300
|
+
return { path, of: STR, negated: t.negated, truthy: false, option, present: `${option}.is_some()`, pattern: (name) => `let Some(${name}) = ${option}` };
|
|
301
|
+
}
|
|
276
302
|
let target = n;
|
|
277
303
|
let negated = false;
|
|
278
304
|
let byTruth = true;
|
|
@@ -696,6 +722,9 @@ export function expr(s, n) {
|
|
|
696
722
|
case "_ctx": {
|
|
697
723
|
if (n.object.name === "_ctx" && prop === "$route")
|
|
698
724
|
return theRoute(s, n);
|
|
725
|
+
if (n.object.name === "_ctx" && prop === "$attrs") {
|
|
726
|
+
return fail(comp, "`$attrs` is bound whole, with `v-bind=\"$attrs\"`; a value read from it has no type: declare it as a prop", n);
|
|
727
|
+
}
|
|
699
728
|
const v = s.setup.get(prop);
|
|
700
729
|
if (v)
|
|
701
730
|
return v;
|
|
@@ -806,6 +835,15 @@ export function expr(s, n) {
|
|
|
806
835
|
case "BinaryExpression": {
|
|
807
836
|
if (n.operator !== "===" && n.operator !== "!==")
|
|
808
837
|
fail(comp, `\`${n.operator}\``, n);
|
|
838
|
+
// `typeof route.query.q === "string"`: a single value, not `null`, absent or repeated.
|
|
839
|
+
const tested = typeofString(n);
|
|
840
|
+
if (tested) {
|
|
841
|
+
const v = expr(s, tested.target);
|
|
842
|
+
if (v.ty.k !== "query")
|
|
843
|
+
fail(comp, '`typeof` tests a query value only, as `typeof route.query.q === "string"`', n);
|
|
844
|
+
const one = `${atom(v.code)}.attr_value().is_some()`;
|
|
845
|
+
return boolOf(tested.negated ? not(one) : one);
|
|
846
|
+
}
|
|
809
847
|
const a = expr(s, n.left);
|
|
810
848
|
const b = expr(s, n.right);
|
|
811
849
|
let eq;
|
package/dist/islands.js
ADDED