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.
Files changed (79) hide show
  1. package/README.md +106 -13
  2. package/dist/args.d.ts +28 -0
  3. package/dist/args.js +69 -0
  4. package/dist/attrs.js +3 -0
  5. package/dist/calls.js +5 -2
  6. package/dist/children.js +18 -3
  7. package/dist/cli.js +80 -81
  8. package/dist/client-render.d.ts +23 -0
  9. package/dist/client-render.js +143 -0
  10. package/dist/client.d.ts +65 -6
  11. package/dist/client.js +269 -27
  12. package/dist/compiler.d.ts +9 -5
  13. package/dist/compiler.js +48 -14
  14. package/dist/component.d.ts +7 -3
  15. package/dist/component.js +37 -25
  16. package/dist/conformance.d.ts +24 -3
  17. package/dist/conformance.js +58 -7
  18. package/dist/constants.js +4 -2
  19. package/dist/context.d.ts +51 -4
  20. package/dist/context.js +173 -5
  21. package/dist/diagnostics.d.ts +6 -4
  22. package/dist/diagnostics.js +7 -3
  23. package/dist/editor.d.ts +18 -0
  24. package/dist/editor.js +59 -0
  25. package/dist/errors.d.ts +45 -3
  26. package/dist/errors.js +33 -3
  27. package/dist/expr.d.ts +2 -0
  28. package/dist/expr.js +91 -12
  29. package/dist/fallthrough.d.ts +1 -0
  30. package/dist/fallthrough.js +10 -2
  31. package/dist/files.d.ts +13 -0
  32. package/dist/files.js +49 -0
  33. package/dist/fixture.d.ts +3 -3
  34. package/dist/hydration.d.ts +12 -1
  35. package/dist/hydration.js +47 -1
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +1 -1
  38. package/dist/inputs.d.ts +24 -0
  39. package/dist/inputs.js +40 -0
  40. package/dist/link-router.d.ts +6 -2
  41. package/dist/link-router.js +7 -4
  42. package/dist/lists.d.ts +12 -1
  43. package/dist/lists.js +76 -2
  44. package/dist/model.d.ts +9 -1
  45. package/dist/model.js +2 -0
  46. package/dist/page-routes.d.ts +1 -0
  47. package/dist/paths.d.ts +5 -0
  48. package/dist/paths.js +7 -0
  49. package/dist/plugin.d.ts +2 -0
  50. package/dist/plugins/css-modules.d.ts +39 -0
  51. package/dist/plugins/css-modules.js +135 -0
  52. package/dist/plugins/head.js +4 -4
  53. package/dist/plugins/i18n.js +5 -12
  54. package/dist/plugins/index.js +2 -1
  55. package/dist/plugins/provide.js +4 -2
  56. package/dist/plugins/router.js +3 -2
  57. package/dist/plugins/scoped.d.ts +10 -0
  58. package/dist/plugins/scoped.js +17 -4
  59. package/dist/plugins/shared-types.js +2 -1
  60. package/dist/plugins/stores.js +7 -6
  61. package/dist/rust.d.ts +9 -1
  62. package/dist/rust.js +38 -15
  63. package/dist/schema.d.ts +25 -0
  64. package/dist/schema.js +73 -0
  65. package/dist/script.js +12 -5
  66. package/dist/slots.js +2 -0
  67. package/dist/ssr.js +42 -4
  68. package/dist/styles.d.ts +5 -1
  69. package/dist/styles.js +131 -4
  70. package/dist/testing.d.ts +2 -1
  71. package/dist/testing.js +1 -1
  72. package/dist/typescript.d.ts +11 -1
  73. package/dist/typescript.js +124 -33
  74. package/dist/vite.d.ts +18 -10
  75. package/dist/vite.js +106 -51
  76. package/dist/volar.d.ts +15 -0
  77. package/dist/volar.js +87 -0
  78. package/package.json +27 -7
  79. package/schema.json +333 -0
@@ -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`), and each recorded `.html` hydrates with no warning and the server's
62
- * nodes kept. Await it at the top of a test file run in a DOM environment such as happy-dom. */
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 {};
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { basename, dirname, join, relative, resolve } from "node:path";
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(relative(root, path), committed, text).trimEnd());
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, hydrate = false) => {
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, hydrate });
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`), and each recorded `.html` hydrates with no warning and the server's
272
- * nodes kept. Await it at the top of a test file run in a DOM environment such as happy-dom. */
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. Everything in it is replaced. */
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
- /** Read the project's configuration. */
6
- export function loadConfig(root, configPath) {
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", `${displayName} needs \`components\` and \`out\` directories`, { file: displayName });
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 ${displayName} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`, { file: displayName });
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 ${displayName} is \`true\` or \`false\`: whether each props struct gets \`new()\` and a setter per optional field`, { file: displayName });
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
+ }
@@ -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;
@@ -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: "error",
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
+ }
@@ -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[];