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/conformance.d.ts
CHANGED
|
@@ -19,7 +19,18 @@ export interface ConformanceOptions extends Pick<RouterOptions, "pinia" | "vueRo
|
|
|
19
19
|
fixtures?: string;
|
|
20
20
|
/** Write each fixture's `.html` from Vue's render instead of comparing it: when
|
|
21
21
|
* `FERROVUE_FIXTURES_WRITE=1` is set, if not given. */
|
|
22
|
-
record?: boolean;
|
|
22
|
+
record?: boolean | undefined;
|
|
23
|
+
/** Check that a fresh client render of each fixture shows its recorded HTML, which an
|
|
24
|
+
* application that navigates between pages relies on: on when `clientDiffers` is given. */
|
|
25
|
+
clientRender?: boolean;
|
|
26
|
+
/** The fixtures (`Component/case.json`) whose fresh client render Vue itself makes differ from
|
|
27
|
+
* its server render, which the check of client renders then expects to differ: a `v-model`
|
|
28
|
+
* `<select>` whose value matches no option, which the client leaves with none selected; an
|
|
29
|
+
* attribute such as `disabled` bound on an element with no such property, which the client
|
|
30
|
+
* writes as `"true"`; a `<TransitionGroup>`'s `name`, which only the server writes; a slot's scope
|
|
31
|
+
* ids; and an async component beside a `<ClientOnly>`, whose content shows by the time the
|
|
32
|
+
* component has loaded. */
|
|
33
|
+
clientDiffers?: string[];
|
|
23
34
|
}
|
|
24
35
|
interface TestApi {
|
|
25
36
|
describe(name: string, body: () => void): void;
|
|
@@ -53,12 +64,22 @@ export declare function fixtureOptions(root: string, config: Config): {
|
|
|
53
64
|
routes: RouteEntry[] | null;
|
|
54
65
|
options: RouterOptions;
|
|
55
66
|
};
|
|
67
|
+
/** Render a fixture's app on the client from scratch, with `createApp` (`fixtureApp`'s `client`
|
|
68
|
+
* option), into a document laid out as `hydrationBody` lays out its recorded render, and say where
|
|
69
|
+
* the client's DOM differs from the main HTML the browser parsed, as `renderedShape` compares
|
|
70
|
+
* them: as mounting leaves it, where `<ClientOnly>` still shows its fallback, and, where that
|
|
71
|
+
* differs while async components are loading, once they have loaded. What is teleported and the
|
|
72
|
+
* head are not compared. `null` where they agree. */
|
|
73
|
+
export declare function clientRenderDifference(app: App, html: string): Promise<string | null>;
|
|
56
74
|
/** Register the conformance suite with the given `describe` and `it`. */
|
|
57
75
|
export declare function registerConformance(api: TestApi, options: ConformanceOptions): void;
|
|
58
76
|
/** Register vitest tests holding a project's components to Vue, as ferrovue's own suite does:
|
|
59
77
|
* every component has fixtures and every fixture a component, the generated Rust is what the
|
|
60
78
|
* generator writes now, Vue renders each fixture to its recorded `.html` (or records it, with
|
|
61
|
-
* `FERROVUE_FIXTURES_WRITE=1`),
|
|
62
|
-
* nodes kept.
|
|
79
|
+
* `FERROVUE_FIXTURES_WRITE=1`), each recorded `.html` hydrates with no warning and the server's
|
|
80
|
+
* nodes kept. With `clientRender` or `clientDiffers`, a fresh client render of each fixture shows
|
|
81
|
+
* its recorded `.html`, as `clientRenderDifference` compares them, except the fixtures listed in
|
|
82
|
+
* `clientDiffers`: what an application that navigates between pages relies on when it renders the
|
|
83
|
+
* next page from its record. Await it at the top of a test file run in a DOM environment such as happy-dom. */
|
|
63
84
|
export declare function conformanceSuite(options: ConformanceOptions): Promise<void>;
|
|
64
85
|
export {};
|
package/dist/conformance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join,
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { relativePath } from "./paths.js";
|
|
3
4
|
import { renderToString } from "vue/server-renderer";
|
|
4
5
|
import { generate, loadConfig } from "./compiler.js";
|
|
5
6
|
import { unifiedDiff } from "./diff.js";
|
|
@@ -7,6 +8,7 @@ import { allPages, fileRoutes, pagesFolder, routeEntries } from "./file-routes.j
|
|
|
7
8
|
import { fixtureApp, peer, readFixture } from "./fixture.js";
|
|
8
9
|
import { headRendered, settled } from "./settle.js";
|
|
9
10
|
import { attachSsrRender } from "./ssr.js";
|
|
11
|
+
import { applyCssVars, renderDifference, renderedShape } from "./client-render.js";
|
|
10
12
|
export const TELEPORTS = "<!--fv-teleports-->";
|
|
11
13
|
export const HEAD = "<!--fv-head-->";
|
|
12
14
|
/** The head a recorded render wrote after its body, if it wrote one. */
|
|
@@ -154,7 +156,7 @@ function driftProblems(root, config) {
|
|
|
154
156
|
else {
|
|
155
157
|
const committed = readFileSync(path, "utf8");
|
|
156
158
|
if (committed !== text)
|
|
157
|
-
problems.push(unifiedDiff(
|
|
159
|
+
problems.push(unifiedDiff(relativePath(root, path), committed, text).trimEnd());
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
const stale = existsSync(out) ? readdirSync(out).filter((f) => f.endsWith(".rs") && !files.has(f)) : [];
|
|
@@ -191,6 +193,37 @@ async function hydrateFixture(app, html) {
|
|
|
191
193
|
placeHead(null);
|
|
192
194
|
return problems;
|
|
193
195
|
}
|
|
196
|
+
/** Render a fixture's app on the client from scratch, with `createApp` (`fixtureApp`'s `client`
|
|
197
|
+
* option), into a document laid out as `hydrationBody` lays out its recorded render, and say where
|
|
198
|
+
* the client's DOM differs from the main HTML the browser parsed, as `renderedShape` compares
|
|
199
|
+
* them: as mounting leaves it, where `<ClientOnly>` still shows its fallback, and, where that
|
|
200
|
+
* differs while async components are loading, once they have loaded. What is teleported and the
|
|
201
|
+
* head are not compared. `null` where they agree. */
|
|
202
|
+
export async function clientRenderDifference(app, html) {
|
|
203
|
+
if (typeof document === "undefined")
|
|
204
|
+
throw new Error("rendering a fixture on the client needs a DOM: run the suite with `environment: \"happy-dom\"` (or jsdom)");
|
|
205
|
+
document.body.innerHTML = hydrationBody(html);
|
|
206
|
+
const root = document.getElementById("root");
|
|
207
|
+
const expected = renderedShape(root);
|
|
208
|
+
root.replaceChildren();
|
|
209
|
+
app.config.warnHandler = () => { };
|
|
210
|
+
const { warn } = console;
|
|
211
|
+
console.warn = () => { };
|
|
212
|
+
let difference;
|
|
213
|
+
try {
|
|
214
|
+
app.mount(root);
|
|
215
|
+
applyCssVars(app);
|
|
216
|
+
difference = await renderDifference(app, root, renderedShape(root), expected);
|
|
217
|
+
app.unmount();
|
|
218
|
+
await headRendered();
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
console.warn = warn;
|
|
222
|
+
}
|
|
223
|
+
document.body.innerHTML = "";
|
|
224
|
+
placeHead(null);
|
|
225
|
+
return difference;
|
|
226
|
+
}
|
|
194
227
|
/** Register the conformance suite with the given `describe` and `it`. */
|
|
195
228
|
export function registerConformance(api, options) {
|
|
196
229
|
const configFile = resolve(options.config);
|
|
@@ -217,12 +250,12 @@ export function registerConformance(api, options) {
|
|
|
217
250
|
const cases = casesIn(fixtures);
|
|
218
251
|
const { routes, options: configured } = fixtureOptions(root, config);
|
|
219
252
|
const appOptions = { ...configured, pinia: options.pinia, vueRouter: options.vueRouter, vueI18n: options.vueI18n };
|
|
220
|
-
const app = async (c,
|
|
253
|
+
const app = async (c, mode = {}) => {
|
|
221
254
|
const component = given.get(c.component);
|
|
222
255
|
if (!component)
|
|
223
256
|
throw new Error(`the fixtures in ${c.component}/ name no component in \`components\``);
|
|
224
257
|
const json = JSON.parse(readFileSync(`${c.base}.json`, "utf8"));
|
|
225
|
-
return fixtureApp(component, readFixture(json), routes, { ...appOptions,
|
|
258
|
+
return fixtureApp(component, readFixture(json), routes, { ...appOptions, ...mode });
|
|
226
259
|
};
|
|
227
260
|
api.describe("conformance", () => {
|
|
228
261
|
api.it("has fixtures for every component, and a component for every fixture", () => {
|
|
@@ -257,19 +290,37 @@ export function registerConformance(api, options) {
|
|
|
257
290
|
api.describe("the recorded HTML hydrates without a mismatch", () => {
|
|
258
291
|
for (const c of cases) {
|
|
259
292
|
api.it(`${c.component}/${c.name}`, async () => {
|
|
260
|
-
const problems = await hydrateFixture(await app(c, true), recordedHtml(c));
|
|
293
|
+
const problems = await hydrateFixture(await app(c, { hydrate: true }), recordedHtml(c));
|
|
261
294
|
if (problems.length)
|
|
262
295
|
throw new Error(`${c.component}/${c.name} did not hydrate exactly:\n${problems.join("\n")}`);
|
|
263
296
|
});
|
|
264
297
|
}
|
|
265
298
|
});
|
|
299
|
+
if (!(options.clientRender ?? options.clientDiffers !== undefined))
|
|
300
|
+
return;
|
|
301
|
+
const differs = new Set(options.clientDiffers ?? []);
|
|
302
|
+
api.describe("a fresh client render shows the recorded HTML", () => {
|
|
303
|
+
for (const c of cases) {
|
|
304
|
+
const key = `${c.component}/${c.name}`;
|
|
305
|
+
api.it(key, async () => {
|
|
306
|
+
const difference = await clientRenderDifference(await app(c, { client: true }), recordedHtml(c));
|
|
307
|
+
if (difference && !differs.has(key))
|
|
308
|
+
throw new Error(`${key}: ${difference}\n(list it in \`clientDiffers\` where Vue's own client render differs from its server render)`);
|
|
309
|
+
if (!difference && differs.has(key))
|
|
310
|
+
throw new Error(`${key} is in \`clientDiffers\`, but the client renders it as the server did: take it out`);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
});
|
|
266
314
|
});
|
|
267
315
|
}
|
|
268
316
|
/** Register vitest tests holding a project's components to Vue, as ferrovue's own suite does:
|
|
269
317
|
* every component has fixtures and every fixture a component, the generated Rust is what the
|
|
270
318
|
* generator writes now, Vue renders each fixture to its recorded `.html` (or records it, with
|
|
271
|
-
* `FERROVUE_FIXTURES_WRITE=1`),
|
|
272
|
-
* nodes kept.
|
|
319
|
+
* `FERROVUE_FIXTURES_WRITE=1`), each recorded `.html` hydrates with no warning and the server's
|
|
320
|
+
* nodes kept. With `clientRender` or `clientDiffers`, a fresh client render of each fixture shows
|
|
321
|
+
* its recorded `.html`, as `clientRenderDifference` compares them, except the fixtures listed in
|
|
322
|
+
* `clientDiffers`: what an application that navigates between pages relies on when it renders the
|
|
323
|
+
* next page from its record. Await it at the top of a test file run in a DOM environment such as happy-dom. */
|
|
273
324
|
export async function conformanceSuite(options) {
|
|
274
325
|
const vitest = await peer("vitest", "`conformanceSuite` registers vitest tests", () => import("vitest"));
|
|
275
326
|
registerConformance(vitest, options);
|
package/dist/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { blankComponent, BOOL, fail, FLOAT, INT, NULL, RUST_PRELUDE, rustStr, sameTy, snake, STR, UNDEF, withAbsence } from "./model.js";
|
|
2
|
-
import { ONE_NOTHING } from "./typescript.js";
|
|
2
|
+
import { ONE_NOTHING, reachType } from "./typescript.js";
|
|
3
3
|
import { ctx } from "./context.js";
|
|
4
4
|
import { numberVal } from "./numbers.js";
|
|
5
5
|
import { structLifetime } from "./rust.js";
|
|
@@ -190,6 +190,8 @@ function listVal(c) {
|
|
|
190
190
|
if (!c.items.every((i) => i.k === "object"))
|
|
191
191
|
fail(c.home, "FV0211", "a constant list holds plain values of one kind, or objects", c.node);
|
|
192
192
|
const objects = c.items;
|
|
193
|
+
if (c.typed !== null)
|
|
194
|
+
reachType(c.typed);
|
|
193
195
|
const st = c.typed !== null ? ctx.typeStructs.get(c.typed) : itemStruct(c, objects);
|
|
194
196
|
const name = rustConstName(c.path);
|
|
195
197
|
const owner = ctx.typeConsts.get(name);
|
|
@@ -198,7 +200,7 @@ function listVal(c) {
|
|
|
198
200
|
const life = structLifetime(st, blankComponent("types", "types", "types", ctx.typeStructs)) ? "<'static>" : "";
|
|
199
201
|
const rows = objects.map((o, i) => {
|
|
200
202
|
for (const key of o.fields.keys())
|
|
201
|
-
if (!st.fields.some((f) => f.js === key))
|
|
203
|
+
if (!st.fields.some((f) => f.js === key) && !st.refused?.has(key))
|
|
202
204
|
fail(c.home, "FV0204", `\`${st.name}\` has no field \`${key}\``, o.node);
|
|
203
205
|
const inits = st.fields.map((f) => `${f.rust}: ${fieldLiteral(c, f, o.fields.get(f.js), objects[i].node)}`);
|
|
204
206
|
return ` ${st.name} { ${inits.join(", ")} },`;
|
package/dist/context.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { Plugin } from "./plugin.ts";
|
|
2
2
|
import type { Declared } from "./constants.ts";
|
|
3
|
-
import { type Component, type Struct, type Ty } from "./model.ts";
|
|
3
|
+
import { type Component, type N, type Struct, type Ty, GenError } from "./model.ts";
|
|
4
|
+
import { type Schema } from "./schema.ts";
|
|
4
5
|
/** A type a helper takes or returns, as the configuration spells it. */
|
|
5
6
|
export type TypeName = "string" | "string?" | "int" | "int?" | "float" | "float?" | "bool";
|
|
6
7
|
/** A function a template may call, and the Rust function that is its twin. */
|
|
7
8
|
export interface HelperSpec {
|
|
8
9
|
/** The Rust path the generated code calls, e.g. `crate::helpers::format_count`. */
|
|
9
10
|
rust: string;
|
|
11
|
+
/** The type of each argument, in order. */
|
|
10
12
|
params: TypeName[];
|
|
13
|
+
/** The type it returns. */
|
|
11
14
|
returns: TypeName;
|
|
12
15
|
/** The longest string it returns, which is what a call adds to the buffer reservation. */
|
|
13
16
|
maxLen?: number;
|
|
@@ -24,13 +27,18 @@ export interface TwinSpec {
|
|
|
24
27
|
}
|
|
25
28
|
/** What `ferrovue.config.json` holds. Paths are relative to the project root. */
|
|
26
29
|
export interface Config {
|
|
30
|
+
/** The JSON Schema an editor checks the file against. ferrovue does not read it. */
|
|
31
|
+
$schema?: string;
|
|
27
32
|
/** The directory of `.vue` files to compile. */
|
|
28
33
|
components: string;
|
|
29
|
-
/** The directory the Rust modules are written to.
|
|
34
|
+
/** The directory the Rust modules are written to. A module ferrovue wrote there is replaced or
|
|
35
|
+
* removed; any other file is left alone. */
|
|
30
36
|
out: string;
|
|
31
37
|
/** The module components import their helpers from, and the Rust twin of each export. */
|
|
32
38
|
helpers?: {
|
|
39
|
+
/** The module a component imports the helpers from, as its imports name it: `./helpers`. */
|
|
33
40
|
module: string;
|
|
41
|
+
/** Each function a template may call, by its name in that module. */
|
|
34
42
|
functions: Record<string, HelperSpec>;
|
|
35
43
|
};
|
|
36
44
|
/** Components ferrovue does not compile, by the name a template gives them (`VBtn`, which
|
|
@@ -48,9 +56,13 @@ export interface Config {
|
|
|
48
56
|
/** The router, in full: its routes, the history's base, and the class names `createRouter`
|
|
49
57
|
* gives active links. `routes` alone is shorthand for `{ routes }`. */
|
|
50
58
|
router?: {
|
|
59
|
+
/** Where the routes come from, as `routes` takes them. */
|
|
51
60
|
routes: RoutesSource;
|
|
61
|
+
/** The base `createWebHistory` is given, which every link starts with. */
|
|
52
62
|
base?: string;
|
|
63
|
+
/** The class of a link to the current route or one of its parents. */
|
|
53
64
|
linkActiveClass?: string;
|
|
65
|
+
/** The class of a link to exactly the current route. */
|
|
54
66
|
linkExactActiveClass?: string;
|
|
55
67
|
};
|
|
56
68
|
/** The directory of Pinia stores (`.ts` files), whose state a component may read while it renders
|
|
@@ -63,8 +75,11 @@ export interface Config {
|
|
|
63
75
|
/** vue-i18n: the directory of locale files (`en.json`, `nl.json`), the locale a page renders in
|
|
64
76
|
* when it names none, and the locales a missing message falls back to. */
|
|
65
77
|
i18n?: {
|
|
78
|
+
/** The directory of locale files, one per locale: `en.json`, `nl.json`. */
|
|
66
79
|
messages: string;
|
|
80
|
+
/** The locale a page renders in when it names none. */
|
|
67
81
|
locale?: string;
|
|
82
|
+
/** The locale or locales, in order, a message missing from the page's locale is looked up in. */
|
|
68
83
|
fallbackLocale?: string | string[];
|
|
69
84
|
};
|
|
70
85
|
/** How a `<style scoped>` component's `data-v-` id is computed, which must be how
|
|
@@ -74,18 +89,40 @@ export interface Config {
|
|
|
74
89
|
scopeId?: ScopeIdMode;
|
|
75
90
|
/** Vite's root, from which a scope id hashes a component's path: the project root by default. */
|
|
76
91
|
viteRoot?: string;
|
|
92
|
+
/** Whether `@vitejs/plugin-vue` compiles for production, as Vite's `isProduction`: `true` (the
|
|
93
|
+
* default) for `vite build`, `false` for the dev server. It decides the names of the CSS
|
|
94
|
+
* variables `v-bind()` in `<style>` sets: a hash in production, the expression in development. */
|
|
95
|
+
isProduction?: boolean;
|
|
96
|
+
/** CSS modules (`<style module>`): how Vite's `css.modules` names their classes, which ferrovue
|
|
97
|
+
* computes as postcss-modules does. Without it, `<style module>` is refused. */
|
|
98
|
+
cssModules?: {
|
|
99
|
+
/** The pattern of a class's name, as Vite's `css.modules.generateScopedName`:
|
|
100
|
+
* `"[local]_[hash:base64:5]"`. */
|
|
101
|
+
generateScopedName: string;
|
|
102
|
+
/** Vite's `css.modules.hashPrefix`, hashed before a module's path. */
|
|
103
|
+
hashPrefix?: string;
|
|
104
|
+
/** The directory Vite runs in, its working directory, from which a hash takes a module's path,
|
|
105
|
+
* relative to this file's directory: `viteRoot` by default. */
|
|
106
|
+
context?: string;
|
|
107
|
+
};
|
|
77
108
|
/** Whether each props struct and shared type gets `new()` and a chainable setter per optional
|
|
78
109
|
* field: `true` by default. With `false`, props are built as struct literals. */
|
|
79
110
|
builders?: boolean;
|
|
80
111
|
}
|
|
81
112
|
export type ScopeIdMode = "filepath" | "filepath-source";
|
|
113
|
+
/** The options of Vite's `css.modules` that decide a class's name, as postcss-modules reads them. */
|
|
114
|
+
export type CssModulesConfig = NonNullable<Config["cssModules"]>;
|
|
82
115
|
/** Where the routes come from: a JSON file of routes, or a folder of pages. */
|
|
83
116
|
export type RoutesSource = string | {
|
|
117
|
+
/** The folder of pages, each a component, whose file names give the routes. */
|
|
84
118
|
pages: string;
|
|
85
119
|
};
|
|
86
120
|
export declare const CONFIG_FILE = "ferrovue.config.json";
|
|
87
|
-
/** Read the project's configuration. */
|
|
88
|
-
export declare function loadConfig(root: string, configPath?: string): Config;
|
|
121
|
+
/** Read the project's configuration, handing `warn` a warning for each deprecated key it holds. */
|
|
122
|
+
export declare function loadConfig(root: string, configPath?: string, warn?: (warning: GenError) => void): Config;
|
|
123
|
+
/** Check what a configuration file holds against `schema`, naming the first key that is unknown or
|
|
124
|
+
* of the wrong shape, and handing `warn` a warning for each deprecated key. */
|
|
125
|
+
export declare function checkConfig(raw: unknown, file: string, warn: (warning: GenError) => void, schema?: Schema): Config;
|
|
89
126
|
export declare function tyOfName(name: TypeName): Ty;
|
|
90
127
|
/** The Rust type of an `InlineHtml` prop, and of a `TrustedHtml` one when `trustedHtml` names it. */
|
|
91
128
|
export declare const INLINE_HTML = "ferrovue::InlineHtml";
|
|
@@ -108,6 +145,12 @@ export declare const ctx: {
|
|
|
108
145
|
typeStructs: Map<string, Struct>;
|
|
109
146
|
typeAliases: Map<string, any>;
|
|
110
147
|
typeFiles: Map<string, string>;
|
|
148
|
+
typeDecls: Map<string, {
|
|
149
|
+
home: Component;
|
|
150
|
+
members: N[];
|
|
151
|
+
node: N;
|
|
152
|
+
}[]>;
|
|
153
|
+
typeReached: Set<string>;
|
|
111
154
|
typeRead: Set<string>;
|
|
112
155
|
constDecls: Map<string, Map<string, Declared>>;
|
|
113
156
|
typeConsts: Map<string, {
|
|
@@ -119,4 +162,8 @@ export declare const ctx: {
|
|
|
119
162
|
narrowCount: number;
|
|
120
163
|
componentsDir: string;
|
|
121
164
|
vnodeTag: string | null;
|
|
165
|
+
sources: Map<string, string>;
|
|
122
166
|
};
|
|
167
|
+
/** A component's text: what the run was given for its path, as an editor's unsaved buffer, or the
|
|
168
|
+
* file on disk. */
|
|
169
|
+
export declare function readSource(file: string): string;
|
package/dist/context.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { BOOL, FLOAT, GenError, INT, opt, STR } from "./model.js";
|
|
4
|
+
import { formatWarning } from "./diagnostics.js";
|
|
5
|
+
import { CONFIG_SCHEMA, TYPE_NAMES } from "./schema.js";
|
|
4
6
|
export const CONFIG_FILE = "ferrovue.config.json";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
const warnOnConsole = (warning) => console.warn(formatWarning(warning));
|
|
8
|
+
/** Read the project's configuration, handing `warn` a warning for each deprecated key it holds. */
|
|
9
|
+
export function loadConfig(root, configPath, warn = warnOnConsole) {
|
|
7
10
|
const filePath = configPath ? resolve(root, configPath) : join(root, CONFIG_FILE);
|
|
8
11
|
const displayName = configPath ?? CONFIG_FILE;
|
|
9
12
|
let text = "";
|
|
@@ -23,15 +26,172 @@ export function loadConfig(root, configPath) {
|
|
|
23
26
|
catch (e) {
|
|
24
27
|
throw new GenError("FV1103", `failed to parse \`${displayName}\`: ${e.message}`, { file: displayName });
|
|
25
28
|
}
|
|
29
|
+
return checkConfig(raw, displayName, warn);
|
|
30
|
+
}
|
|
31
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32
|
+
const isStrings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
33
|
+
function distance(a, b) {
|
|
34
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
35
|
+
for (let i = 1; i <= a.length; i++) {
|
|
36
|
+
const next = [i];
|
|
37
|
+
for (let j = 1; j <= b.length; j++)
|
|
38
|
+
next.push(Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)));
|
|
39
|
+
row = next;
|
|
40
|
+
}
|
|
41
|
+
return row[b.length];
|
|
42
|
+
}
|
|
43
|
+
/** The known key a misspelt one most likely means: the same but for case, or one or two edits
|
|
44
|
+
* away (one for a short key). */
|
|
45
|
+
function closest(key, known) {
|
|
46
|
+
let best = null;
|
|
47
|
+
let least = Math.min(2, Math.floor(key.length / 3)) + 1;
|
|
48
|
+
for (const k of known) {
|
|
49
|
+
const d = k.toLowerCase() === key.toLowerCase() ? 0 : distance(key, k);
|
|
50
|
+
if (d < least)
|
|
51
|
+
[best, least] = [k, d];
|
|
52
|
+
}
|
|
53
|
+
return best;
|
|
54
|
+
}
|
|
55
|
+
/** Check what a configuration file holds against `schema`, naming the first key that is unknown or
|
|
56
|
+
* of the wrong shape, and handing `warn` a warning for each deprecated key. */
|
|
57
|
+
export function checkConfig(raw, file, warn, schema = CONFIG_SCHEMA) {
|
|
58
|
+
const refuse = (code, what) => {
|
|
59
|
+
throw new GenError(code, what, { file });
|
|
60
|
+
};
|
|
61
|
+
const wrong = (key, shape) => refuse("FV1115", `\`${key}\` in ${file} is ${shape}`);
|
|
62
|
+
const keys = (o, path, node) => {
|
|
63
|
+
const props = (node && node.properties) || {};
|
|
64
|
+
for (const k of Object.keys(o)) {
|
|
65
|
+
if (props[k])
|
|
66
|
+
continue;
|
|
67
|
+
const known = Object.keys(props).filter((x) => !x.startsWith("$") && !props[x].deprecated);
|
|
68
|
+
const near = closest(k, known);
|
|
69
|
+
const hint = near ? `did you mean \`${near}\`?` : `it takes ${known.map((x) => `\`${x}\``).join(", ")}`;
|
|
70
|
+
refuse("FV1114", `${path ? `\`${path}\` in ${file}` : file} has no key \`${k}\`: ${hint}`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const string = (o, key, path, required = false) => {
|
|
74
|
+
if (o[key] === undefined ? required : typeof o[key] !== "string")
|
|
75
|
+
wrong(path, "a string");
|
|
76
|
+
};
|
|
77
|
+
const typeName = (v, path) => {
|
|
78
|
+
if (typeof v !== "string" || !TYPE_NAMES.includes(v)) {
|
|
79
|
+
refuse("FV1107", `\`${path}\` in ${file} is ${JSON.stringify(v) ?? "missing"}, not a type a helper or twin takes: ${TYPE_NAMES.map((t) => `"${t}"`).join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const routesSource = (v, path) => {
|
|
83
|
+
if (typeof v === "string" || (isObject(v) && typeof v.pages === "string" && Object.keys(v).length === 1))
|
|
84
|
+
return;
|
|
85
|
+
refuse("FV1238", `\`${path}\` in ${file} is a JSON file of routes, or \`{ "pages": "…" }\`: the folder of pages vue-router's file-based routing reads`);
|
|
86
|
+
};
|
|
87
|
+
if (!isObject(raw)) {
|
|
88
|
+
const what = Array.isArray(raw) ? "an array" : raw === null ? "`null`" : `a ${typeof raw}`;
|
|
89
|
+
return refuse("FV1113", `${file} holds ${what}, where it holds an object: \`{ "components": "components", "out": "src/generated" }\``);
|
|
90
|
+
}
|
|
91
|
+
const shape = schema.properties ?? {};
|
|
92
|
+
const entry = (node) => node?.additionalProperties || undefined;
|
|
93
|
+
keys(raw, "", schema);
|
|
26
94
|
if (typeof raw.components !== "string" || typeof raw.out !== "string") {
|
|
27
|
-
throw new GenError("FV1104", `${
|
|
95
|
+
throw new GenError("FV1104", `${file} needs \`components\` and \`out\` directories`, { file });
|
|
28
96
|
}
|
|
29
97
|
if (raw.scopeId !== undefined && raw.scopeId !== "filepath" && raw.scopeId !== "filepath-source") {
|
|
30
|
-
throw new GenError("FV1105", `\`scopeId\` in ${
|
|
98
|
+
throw new GenError("FV1105", `\`scopeId\` in ${file} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`, { file });
|
|
31
99
|
}
|
|
32
100
|
if (raw.builders !== undefined && typeof raw.builders !== "boolean") {
|
|
33
|
-
throw new GenError("FV1106", `\`builders\` in ${
|
|
101
|
+
throw new GenError("FV1106", `\`builders\` in ${file} is \`true\` or \`false\`: whether each props struct gets \`new()\` and a setter per optional field`, { file });
|
|
102
|
+
}
|
|
103
|
+
for (const key of ["$schema", "stores", "trustedHtml", "viteRoot"])
|
|
104
|
+
string(raw, key, key);
|
|
105
|
+
if (raw.isProduction !== undefined && typeof raw.isProduction !== "boolean")
|
|
106
|
+
wrong("isProduction", "`true` or `false`: whether `@vitejs/plugin-vue` compiles for production");
|
|
107
|
+
if (raw.cssModules !== undefined) {
|
|
108
|
+
const m = raw.cssModules;
|
|
109
|
+
if (!isObject(m))
|
|
110
|
+
return wrong("cssModules", "an object: `{ generateScopedName, hashPrefix?, context? }`");
|
|
111
|
+
keys(m, "cssModules", shape.cssModules);
|
|
112
|
+
string(m, "generateScopedName", "cssModules.generateScopedName", true);
|
|
113
|
+
string(m, "hashPrefix", "cssModules.hashPrefix");
|
|
114
|
+
string(m, "context", "cssModules.context");
|
|
34
115
|
}
|
|
116
|
+
if (raw.clientDirectives !== undefined && !isStrings(raw.clientDirectives))
|
|
117
|
+
wrong("clientDirectives", 'a list of directive names, as `["focus"]`');
|
|
118
|
+
if (raw.routes !== undefined)
|
|
119
|
+
routesSource(raw.routes, "routes");
|
|
120
|
+
if (raw.router !== undefined) {
|
|
121
|
+
const r = raw.router;
|
|
122
|
+
if (!isObject(r))
|
|
123
|
+
return wrong("router", "an object: `{ routes, base?, linkActiveClass?, linkExactActiveClass? }`");
|
|
124
|
+
keys(r, "router", shape.router);
|
|
125
|
+
routesSource(r.routes, "router.routes");
|
|
126
|
+
for (const key of ["base", "linkActiveClass", "linkExactActiveClass"])
|
|
127
|
+
string(r, key, `router.${key}`);
|
|
128
|
+
}
|
|
129
|
+
if (raw.i18n !== undefined) {
|
|
130
|
+
const i = raw.i18n;
|
|
131
|
+
if (!isObject(i))
|
|
132
|
+
return wrong("i18n", "an object: `{ messages, locale?, fallbackLocale? }`");
|
|
133
|
+
keys(i, "i18n", shape.i18n);
|
|
134
|
+
string(i, "messages", "i18n.messages", true);
|
|
135
|
+
string(i, "locale", "i18n.locale");
|
|
136
|
+
if (i.fallbackLocale !== undefined && typeof i.fallbackLocale !== "string" && !isStrings(i.fallbackLocale))
|
|
137
|
+
wrong("i18n.fallbackLocale", "a locale or a list of them");
|
|
138
|
+
}
|
|
139
|
+
if (raw.helpers !== undefined) {
|
|
140
|
+
const h = raw.helpers;
|
|
141
|
+
if (!isObject(h))
|
|
142
|
+
return wrong("helpers", "an object: `{ module, functions }`");
|
|
143
|
+
keys(h, "helpers", shape.helpers);
|
|
144
|
+
string(h, "module", "helpers.module", true);
|
|
145
|
+
if (!isObject(h.functions))
|
|
146
|
+
return wrong("helpers.functions", "an object of functions by name, each `{ rust, params, returns, maxLen? }`");
|
|
147
|
+
for (const [name, spec] of Object.entries(h.functions)) {
|
|
148
|
+
const path = `helpers.functions.${name}`;
|
|
149
|
+
if (!isObject(spec))
|
|
150
|
+
return wrong(path, "an object: `{ rust, params, returns, maxLen? }`");
|
|
151
|
+
keys(spec, path, entry(shape.helpers?.properties?.functions));
|
|
152
|
+
string(spec, "rust", `${path}.rust`, true);
|
|
153
|
+
if (!Array.isArray(spec.params))
|
|
154
|
+
return wrong(`${path}.params`, 'the list of types the function takes, as `["int"]`');
|
|
155
|
+
spec.params.forEach((t, i) => typeName(t, `${path}.params[${i}]`));
|
|
156
|
+
typeName(spec.returns, `${path}.returns`);
|
|
157
|
+
if (spec.maxLen !== undefined && !(Number.isSafeInteger(spec.maxLen) && spec.maxLen >= 0))
|
|
158
|
+
wrong(`${path}.maxLen`, "a whole number, 0 or more");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (raw.twins !== undefined) {
|
|
162
|
+
if (!isObject(raw.twins))
|
|
163
|
+
return wrong("twins", "an object of twins by component name, each `{ rust, props?, slots? }`");
|
|
164
|
+
for (const [name, spec] of Object.entries(raw.twins)) {
|
|
165
|
+
const path = `twins.${name}`;
|
|
166
|
+
if (!isObject(spec))
|
|
167
|
+
return wrong(path, "an object: `{ rust, props?, slots? }`");
|
|
168
|
+
keys(spec, path, entry(shape.twins));
|
|
169
|
+
if (typeof spec.rust !== "string")
|
|
170
|
+
refuse("FV1110", `\`${path}\` in ${file} needs \`rust\`, the path of the function that renders it, as \`crate::ui::v_btn\``);
|
|
171
|
+
if (spec.props !== undefined) {
|
|
172
|
+
if (!isObject(spec.props))
|
|
173
|
+
return wrong(`${path}.props`, 'an object of prop types by name, as `{ "label": "string" }`');
|
|
174
|
+
for (const [prop, t] of Object.entries(spec.props))
|
|
175
|
+
typeName(t, `${path}.props.${prop}`);
|
|
176
|
+
}
|
|
177
|
+
if (spec.slots !== undefined && !isStrings(spec.slots))
|
|
178
|
+
wrong(`${path}.slots`, 'a list of slot names, as `["default"]`');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const deprecated = (o, node, path) => {
|
|
182
|
+
if (!isObject(o))
|
|
183
|
+
return;
|
|
184
|
+
for (const [k, v] of Object.entries(o)) {
|
|
185
|
+
const at = path ? `${path}.${k}` : k;
|
|
186
|
+
const prop = node.properties?.[k] ?? (node.additionalProperties || undefined);
|
|
187
|
+
if (!prop)
|
|
188
|
+
continue;
|
|
189
|
+
if (prop.deprecated)
|
|
190
|
+
warn(new GenError("FV1117", `\`${at}\` in ${file} is deprecated since ${prop.deprecated.since}, and goes in the next major release: use ${prop.deprecated.use}`, { file }));
|
|
191
|
+
deprecated(v, prop, at);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
deprecated(raw, schema, "");
|
|
35
195
|
return raw;
|
|
36
196
|
}
|
|
37
197
|
export function tyOfName(name) {
|
|
@@ -70,6 +230,8 @@ export const ctx = {
|
|
|
70
230
|
typeStructs: new Map(),
|
|
71
231
|
typeAliases: new Map(),
|
|
72
232
|
typeFiles: new Map(),
|
|
233
|
+
typeDecls: new Map(),
|
|
234
|
+
typeReached: new Set(),
|
|
73
235
|
typeRead: new Set(),
|
|
74
236
|
constDecls: new Map(),
|
|
75
237
|
typeConsts: new Map(),
|
|
@@ -77,4 +239,10 @@ export const ctx = {
|
|
|
77
239
|
narrowCount: 0,
|
|
78
240
|
componentsDir: "",
|
|
79
241
|
vnodeTag: null,
|
|
242
|
+
sources: new Map(),
|
|
80
243
|
};
|
|
244
|
+
/** A component's text: what the run was given for its path, as an editor's unsaved buffer, or the
|
|
245
|
+
* file on disk. */
|
|
246
|
+
export function readSource(file) {
|
|
247
|
+
return ctx.sources.get(resolve(file)) ?? readFileSync(file, "utf8");
|
|
248
|
+
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Code } from "./errors.ts";
|
|
2
2
|
import { GenError } from "./model.ts";
|
|
3
|
-
/** One refusal, as `ferrovue --format json` writes it. Lines and columns count from 1; the end is
|
|
3
|
+
/** One refusal or warning, as `ferrovue --format json` writes it. Lines and columns count from 1; the end is
|
|
4
4
|
* exclusive, and `null` where it is not known. */
|
|
5
5
|
export interface Diagnostic {
|
|
6
6
|
file: string | null;
|
|
@@ -9,15 +9,17 @@ export interface Diagnostic {
|
|
|
9
9
|
endLine: number | null;
|
|
10
10
|
endColumn: number | null;
|
|
11
11
|
code: Code;
|
|
12
|
-
severity: "error";
|
|
12
|
+
severity: "error" | "warning";
|
|
13
13
|
title: string;
|
|
14
14
|
message: string;
|
|
15
15
|
docs: string;
|
|
16
16
|
}
|
|
17
17
|
/** Whether the compiler raised `e` for its input: a refusal, or a file that does not parse. */
|
|
18
18
|
export declare function isRefusal(e: unknown): e is GenError | SyntaxError;
|
|
19
|
-
/** The diagnostic for a refusal. */
|
|
20
|
-
export declare function diagnose(e: GenError | SyntaxError): Diagnostic;
|
|
19
|
+
/** The diagnostic for a refusal, or for a warning raised as a `GenError` and not thrown. */
|
|
20
|
+
export declare function diagnose(e: GenError | SyntaxError, severity?: Diagnostic["severity"]): Diagnostic;
|
|
21
21
|
/** A refusal as the CLI and the Vite plugin show it: `error[FV0604]: `, the message with its
|
|
22
22
|
* location and quoted line, and where the code is documented. */
|
|
23
23
|
export declare function formatRefusal(e: GenError | SyntaxError): string;
|
|
24
|
+
/** A warning as the CLI shows it: `warning[FV1117]: `, the message, and where the code is documented. */
|
|
25
|
+
export declare function formatWarning(e: GenError): string;
|
package/dist/diagnostics.js
CHANGED
|
@@ -5,8 +5,8 @@ const UNPARSED = "FV0001";
|
|
|
5
5
|
export function isRefusal(e) {
|
|
6
6
|
return e instanceof GenError || e instanceof SyntaxError;
|
|
7
7
|
}
|
|
8
|
-
/** The diagnostic for a refusal. */
|
|
9
|
-
export function diagnose(e) {
|
|
8
|
+
/** The diagnostic for a refusal, or for a warning raised as a `GenError` and not thrown. */
|
|
9
|
+
export function diagnose(e, severity = "error") {
|
|
10
10
|
const code = e instanceof GenError ? e.code : UNPARSED;
|
|
11
11
|
const at = e instanceof GenError ? e.at : null;
|
|
12
12
|
return {
|
|
@@ -16,7 +16,7 @@ export function diagnose(e) {
|
|
|
16
16
|
endLine: at?.endLine ?? null,
|
|
17
17
|
endColumn: at?.endColumn ?? null,
|
|
18
18
|
code,
|
|
19
|
-
severity
|
|
19
|
+
severity,
|
|
20
20
|
title: ERRORS[code].title,
|
|
21
21
|
message: e instanceof GenError ? e.what : e.message.split("\n")[0],
|
|
22
22
|
docs: docsUrl(code),
|
|
@@ -28,3 +28,7 @@ export function formatRefusal(e) {
|
|
|
28
28
|
const code = e instanceof GenError ? e.code : UNPARSED;
|
|
29
29
|
return `error[${code}]: ${e.message}\n = docs: ${docsUrl(code)}`;
|
|
30
30
|
}
|
|
31
|
+
/** A warning as the CLI shows it: `warning[FV1117]: `, the message, and where the code is documented. */
|
|
32
|
+
export function formatWarning(e) {
|
|
33
|
+
return `warning[${e.code}]: ${e.message}\n = docs: ${docsUrl(e.code)}`;
|
|
34
|
+
}
|
package/dist/editor.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Diagnostic } from "./diagnostics.ts";
|
|
2
|
+
/** A refusal in the file an editor holds, with its range as offsets into the text it was given. The
|
|
3
|
+
* end is exclusive, and `null` where the compiler does not know it. */
|
|
4
|
+
export interface Located extends Diagnostic {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number | null;
|
|
7
|
+
}
|
|
8
|
+
/** Where an editor's checks find the project: the configuration file's path from the project root. */
|
|
9
|
+
export interface EditorOptions {
|
|
10
|
+
config?: string;
|
|
11
|
+
}
|
|
12
|
+
/** The directory holding `config` that is `dir` or the nearest above it. */
|
|
13
|
+
export declare function projectRoot(dir: string, config?: string): string | null;
|
|
14
|
+
/** The refusals in a component of a ferrovue project, compiling the project with `text` in place of
|
|
15
|
+
* the file. A project is the nearest directory above the file holding the configuration; a file in
|
|
16
|
+
* none has no refusals. The compiler stops at the first refusal, so a refusal in another file hides
|
|
17
|
+
* those in this one. */
|
|
18
|
+
export declare function refusalsIn(file: string, text: string, options?: EditorOptions): Located[];
|