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/dist/cli.js CHANGED
@@ -1,14 +1,35 @@
1
1
  #!/usr/bin/env node
2
- /* `ferrovue` — compile the project's components, run from its root.
2
+ /* `ferrovue`: compile the project's components, run from its root.
3
3
  *
4
- * ferrovue write the Rust modules to the configured output directory
5
- * ferrovue --check write nothing; exit 1 if the committed modules are not what would be written
6
- * ferrovue --watch write them, then again whenever a component, store, type file, the routes
7
- * or the configuration changes, until interrupted */
8
- import { readFileSync, readdirSync, watch } from "node:fs";
9
- import { join, relative, sep } from "node:path";
10
- import { GenError, generate, loadConfig, write } from "./compiler.js";
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 check(config) {
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
- for (const name of stale)
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
- for (const name of extra)
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
- if (process.argv.includes("--watch"))
92
- watchProject();
93
- else if (process.argv.includes("--check"))
94
- process.exitCode = check(loadConfig(root));
95
- else
96
- once(loadConfig(root));
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
- export declare function mountIslands(components: Record<string, Component>, options?: MountOptions): Promise<Islands>;
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 = JSON.parse(text);
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
- if (options.router)
17
- await options.router.isReady();
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 Array.from(root.querySelectorAll("[data-island]"))) {
50
+ for (const el of elements) {
20
51
  const name = el.dataset.island ?? "";
21
- const component = components[name];
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 = JSON.parse(el.dataset.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
+ }
@@ -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 children = read.map((r) => scopeFor(r.comp, r.ast, components).scope.children);
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: children[i] })));
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
- return { files: [...files.keys()], changed, removed };
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 (\`en.json\`, \`nl.json\`), the locale a page renders in
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 raw = JSON.parse(readFileSync(join(root, CONFIG_FILE), "utf8"));
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(`${CONFIG_FILE} needs \`components\` and \`out\` directories`);
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 ${CONFIG_FILE} is "filepath" or "filepath-source", as \`@vitejs/plugin-vue\` computes it`);
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
- return /^fv::js_(?:slice|substring|replace|replace_all|pad_start|pad_end)\(/.test(code) || /\.unwrap_or\(std::borrow::Cow::<str>::\w+\(.*\)\)$/s.test(code);
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;
@@ -0,0 +1,5 @@
1
+ import type { Component } from "vue";
2
+ declare const islands: Record<string, () => Promise<{
3
+ default: Component;
4
+ }>>;
5
+ export default islands;
@@ -0,0 +1,4 @@
1
+ const islands = (() => {
2
+ throw new Error("ferrovue/islands is written by the Vite plugin: add `ferrovue()` from `ferrovue/vite` to the plugins of the Vite config");
3
+ })();
4
+ export default islands;