ferrovue 0.14.1 → 0.15.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 CHANGED
@@ -57,7 +57,7 @@ the configuration at your project root yourself:
57
57
 
58
58
  ```json
59
59
  {
60
- "$schema": "https://cdn.jsdelivr.net/npm/ferrovue@0.14.1/schema.json",
60
+ "$schema": "https://cdn.jsdelivr.net/npm/ferrovue@0.15.0/schema.json",
61
61
  "components": "client/components",
62
62
  "out": "src/generated"
63
63
  }
@@ -87,6 +87,7 @@ read `$schema`. Offline, the copy in the installed package works as well:
87
87
  | `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed (default `.`) |
88
88
  | `isProduction` | no | Whether `@vitejs/plugin-vue` compiles for production, as Vite's `isProduction`: `true` (the default) for `vite build`, `false` for the dev server. It names the variables `v-bind()` in `<style>` sets. See [`scoped_styles`](https://docs.rs/ferrovue/latest/ferrovue/guide/scoped_styles/index.html) |
89
89
  | `comments` | no | Whether templates' HTML comments render, as plugin-vue's `template.compilerOptions.comments`: by default kept when `isProduction` is `false` and left out otherwise. See [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#html-comments) |
90
+ | `whitespace` | no | `"trim-edges"` trims the whitespace where a template's text meets its element's tags, with `trimEdges` from `ferrovue/whitespace` in plugin-vue's `template.compilerOptions.nodeTransforms` for the client; `"condense"`, the default, spaces text as Vue does. See [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#whitespace) |
90
91
  | `cssModules` | no | `{ generateScopedName, hashPrefix?, context? }`, as Vite's `css.modules` names CSS module classes: needed for `<style module>`. `context` is the directory Vite runs in (default `viteRoot`). See [`scoped_styles`](https://docs.rs/ferrovue/latest/ferrovue/guide/scoped_styles/index.html) |
91
92
  | `builders` | no | `false` leaves out each props struct's and shared type's `new()` and setters, for an app that builds its props as struct literals (default `true`) |
92
93
 
@@ -174,7 +175,7 @@ The crate's features:
174
175
 
175
176
  - `router` and `i18n`, both on by default: vue-router and vue-i18n. An application with neither
176
177
  routes nor translations can leave them out, and build neither `ferrovue-router` nor
177
- `ferrovue-i18n`: `ferrovue = { version = "0.14", default-features = false }`.
178
+ `ferrovue-i18n`: `ferrovue = { version = "0.15", default-features = false }`.
178
179
  - `axum` and `actix-web`: `Html` is an `IntoResponse` or a `Responder`, so a handler responds with
179
180
  the component as `text/html; charset=utf-8`. `into_html(props)` and `into_island(props)` take the
180
181
  props by value, so a handler that builds them returns the page:
@@ -241,6 +242,7 @@ ferrovue compiles `<script setup lang="ts">` components, and components with no
241
242
  | Lists | `v-for` over arrays (of strings, numbers, objects or child props), array literals and number ranges (`n in 5`); with an index; with destructured items (`{ id, name } in rows`); nested; on `<template>`. `.includes()` (a number looked for in a list of numbers whether either is a `number` or a `Float`, as JavaScript compares them), `.join()`, and `.filter()`, `.map()`, `.some()`, `.every()`, `.find()`, `.findIndex()`, `.findLast()`, `.findLastIndex()` with an arrow function of the item (a name, or destructured) and its index, whose body is an expression or a body of statements (see Statements), narrowing inside as outside, or with `Boolean`; `.filter(Boolean)` of a list or an array literal whose items may be `null` or `undefined`, which keeps the ones present and truthy as a list of the values (`[name, title, city].filter(Boolean).join(" · ")`); `Boolean(x)`; `.slice()`; an item by its index, `list[i]` with an integer `i` and `list.at(i)` (from the end when negative), `undefined` outside the list and narrowed as other optional values are (`v-if="rows[0]"`); `.indexOf()` and `.lastIndexOf()` of a string, number or boolean, compared with `===` (`NaN` found nowhere, `-0` found as `0`); `.concat()` of lists of one item type (numbers whether `number` or `Float`); `.reduce()` and `.reduceRight()` with an initial value, into a string, a number or a boolean; `.toReversed()`, and `.toSorted()` by `String(x)` as JavaScript sorts without a comparator (`[10, 9, 1]` to `[1, 10, 9]`) or by a comparator `(a, b) => a - b`, `a.k - b.k` of integers or `?:` over `<` and `>` of integers or strings, stable as JavaScript's; `[...list].sort(…)` and `[...list].reverse()`; spread in array literals (`[...a, x, ...b]`) of one item type; `.flat()` of lists of lists, to a literal depth or `Infinity`, and `.flatMap()` with an arrow function that gives a list (flattened one level) or a value (kept as `.map()` keeps it); chained and nested; a computed list read by `v-for`, `.join()`, `.length`, `.includes()`, kept by `computed`, or handed to a child |
242
243
  | Dictionaries | `v-for="(value, key, index) in r"` over a `Record<string, T>`, and `([key, value], index) in Object.entries(r)`, in JavaScript's order: keys that are array indices first, in numeric order, then the others as the props give them; `Object.keys(r)`, `Object.values(r)` (of a record of lists too, which `.flat()` joins), `Object.hasOwn(r, key)`, and `Object.entries(r)` read by the list methods, whose arrow functions destructure each pair (`([key, value]) => …`, `([, value]) => …`, `([key, { id }]) => …`) or read its parts (`pair[0]`, `pair[1]`), and walked by `v-for="[key, value] in Object.entries(r).filter(…)"`, kept by `computed` and sorted by a comparator of either part; handed to a child |
243
244
  | HTML comments | left out, as Vue's production build leaves them out, or kept with `comments` or `"isProduction": false`, as on the dev server: written as Vue writes them, at the root, between roots, inside elements and in slot content |
245
+ | Whitespace | text condensed as Vue condenses it, or with `"whitespace": "trim-edges"` also trimmed where it meets its element's tags, the client compiled alike with `trimEdges`: in elements, components and `<template v-slot>`, keeping `<pre>`, `<textarea>`, `v-pre` and a static `white-space: pre*` style. See [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#whitespace) |
244
246
  | Attributes | static and bound attributes, boolean attributes, `:hidden`, `data-*` and `aria-*`, `v-bind` objects |
245
247
  | `class` | strings, arrays, objects (`{ active: on }`, computed keys), `cond && "x"`, `cond ? "x" : null`, merged with a static `class` |
246
248
  | `style` | objects (camelCase or kebab-case keys, `--custom` properties), arrays of objects, strings, merged with a static `style`, and `v-show`; later values override earlier ones as in Vue. A global `<style>` block is allowed |
@@ -248,7 +250,7 @@ ferrovue compiles `<script setup lang="ts">` components, and components with no
248
250
  | `v-bind()` in `<style>` | variables bound to props, setup values, `computed`s and constants, quoted expressions included (`v-bind('size + "px"')`), in a scoped or global `<style>`: written into the root's `style` after its own and what a parent passes, on each root of a fragment, and handed to a root that is a component, as Vue's server writes them; `undefined` and `null` as `initial`, an empty string as a space; named as plugin-vue names them for production or development (`isProduction`) |
249
251
  | CSS modules | `<style module>` and `<style module="name">`, read as `$style.x`, `name.x`, `$style['x-y']` and through `useCssModule()`, each class a constant in the generated code, when `cssModules` mirrors Vite's `css.modules.generateScopedName`: computed as postcss-modules computes it in Vite (`[local]`, `[name]`, `[hash:base64:5]`, `[path]` and the rest), composed classes, `@value`s and keyframes included |
250
252
  | Components | imported child components, `v-bind` of a child's own `Props`, listeners in any form (`@x`, `v-on="obj"`, `@[name]`), which the server skips, `v-model` on a child's `defineModel`, recursion; props named in `kebab-case` or `camelCase`; a `boolean` prop written bare (`<Button disabled />`), as `""` or as its kebab-case name, which Vue casts to `true`; props named like listeners (`on1`, `onSale`), left out when falsy where Vue's `mergeProps` joins a child's props, as Vue leaves them out; `defineAsyncComponent(() => import("./X.vue"))` (or `{ loader: … }`), rendered as the component it loads, which Vue's server renderer waits for |
251
- | `<component :is>` | over a closed set of choices, compiled to a `match`: an imported component (`:is="Card"`), an HTML element's name (`is="h2"`), a `computed` or `?:` choosing among them, a prop or a `defineModel` typed as a union of string literals (`as: "h1" \| "h2"`, a `match` over its enum's variants), and an object of imported components (or element names) declared in setup or imported from a `.ts` file, read by such a prop (`ICONS[name]`); props, fallthrough attributes, slots and scope ids reach each choice as they reach a static child; also inside `<KeepAlive>` and `<Transition>`. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#component-is) guide |
253
+ | `<component :is>` | over a closed set of choices, compiled to a `match`: an imported component (`:is="Card"`), an HTML element's name (`is="h2"`), a `computed` or `?:` choosing among them, a prop or a `defineModel` typed as a union of string literals (`as: "h1" \| "h2"`, a `match` over its enum's variants) or as one (`as: "h1"`), and an object of imported components (or element names) declared in setup or imported from a `.ts` file, read by such a prop (`ICONS[name]`); props, fallthrough attributes, slots and scope ids reach each choice as they reach a static child; also inside `<KeepAlive>` and `<Transition>`. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#component-is) guide |
252
254
  | Escape hatches | `<ClientOnly>` from `ferrovue`, whose default slot the server never renders, so anything may go in it; components listed in `twins`, rendered by Rust functions of yours. See the crate's [`errors_and_limits`](https://docs.rs/ferrovue/latest/ferrovue/guide/errors_and_limits/index.html#client-only-content) guide |
253
255
  | Fallthrough attributes | what a parent passes a child beyond its props (static and bound attributes, `class`, `style`, `data-*`, `aria-*`, booleans, `undefined`): onto its single root, merged with the root's own class and style and replacing its other attributes where they stand, as Vue's `mergeProps` merges them; none for two roots; on through a root that is a component, or a `<RouterLink>`; with `inheritAttrs: false`, onto the elements or components that bind `v-bind="$attrs"` or a `useAttrs()` binding, before or after their own; beside scope ids. Listeners are dropped, as Vue's server drops them. See the crate's [`generated_code`](https://docs.rs/ferrovue/latest/ferrovue/guide/generated_code/index.html#fallthrough-attributes) guide |
254
256
  | Slots | default and named slots, fallbacks, `$slots.name` tests and the same through `useSlots()`, scoped slots (`<slot :item="x">` and `#item="{ item }"` or `v-slot="props"`), whose props a parent can hand to its own children |
package/dist/compiler.js CHANGED
@@ -47,6 +47,7 @@ function generateAll(root, config, sources, options) {
47
47
  ctx.mappedUnions = new Map();
48
48
  ctx.builders = config.builders ?? true;
49
49
  ctx.comments = templateComments(config);
50
+ ctx.whitespace = config.whitespace ?? "condense";
50
51
  ctx.clientDirectives = new Set(config.clientDirectives ?? []);
51
52
  ctx.rootDir = root;
52
53
  ctx.vnodeTag = null;
package/dist/component.js CHANGED
@@ -14,6 +14,7 @@ import { claim } from "./plugin.js";
14
14
  import { asyncChildren } from "./script.js";
15
15
  import { declareConsts } from "./constants.js";
16
16
  import { resolveImport } from "./resolve.js";
17
+ import { trimEdges } from "./whitespace.js";
17
18
  function vueErrorNode(err, within) {
18
19
  const loc = err.loc;
19
20
  if (!loc)
@@ -451,7 +452,7 @@ export function readComponent(file, root, name = basename(file, ".vue")) {
451
452
  filename: file,
452
453
  ...(claim((p) => p.templateOptions?.(comp)) ?? { id: name, scoped: false, slotted: false, ssrCssVars: [], isProd: true }),
453
454
  ssr: true,
454
- compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), comments: ctx.comments, sourceMap: true, nodeTransforms: [mergedControls(controls)] },
455
+ compilerOptions: { ...(script ? { bindingMetadata: script.bindings } : {}), comments: ctx.comments, sourceMap: true, nodeTransforms: [...(ctx.whitespace === "trim-edges" ? [trimEdges] : []), mergedControls(controls)] },
455
456
  });
456
457
  const kids = (n) => n.children.filter((c) => c.type !== 3 && !(c.type === 2 && !c.content.trim()));
457
458
  const roots = descriptor.template.ast ? kids(descriptor.template.ast) : [];
package/dist/config.d.ts CHANGED
@@ -103,6 +103,11 @@ export interface Config {
103
103
  * `template.compilerOptions.comments`: by default as Vue decides it, kept when `isProduction` is
104
104
  * `false` and left out when it is `true`. */
105
105
  comments?: boolean;
106
+ /** How the text of templates is spaced: `"condense"` (the default) as Vue spaces it, or
107
+ * `"trim-edges"`, which also trims the whitespace where text meets its element's tags. The
108
+ * client compiles the same way with `trimEdges` from `ferrovue/whitespace` in
109
+ * `@vitejs/plugin-vue`'s `template.compilerOptions.nodeTransforms`. */
110
+ whitespace?: WhitespaceMode;
106
111
  /** CSS modules (`<style module>`): how Vite's `css.modules` names their classes, which ferrovue
107
112
  * computes as postcss-modules does. Without it, `<style module>` is refused. */
108
113
  cssModules?: {
@@ -120,6 +125,10 @@ export interface Config {
120
125
  builders?: boolean;
121
126
  }
122
127
  export type ScopeIdMode = "filepath" | "filepath-source";
128
+ /** How the text of templates is spaced: `"condense"`, Vue's own rule, or `"trim-edges"`, which
129
+ * also trims text where it meets its element's tags, as `trimEdges` from `ferrovue/whitespace`
130
+ * does for the client. */
131
+ export type WhitespaceMode = "condense" | "trim-edges";
123
132
  /** Where the routes come from: a JSON file of routes, or a folder of pages. */
124
133
  export type RoutesSource = string | {
125
134
  /** The folder of pages, each a component, whose file names give the routes. */
@@ -254,7 +254,7 @@ export function registerConformance(api, options) {
254
254
  for (const [name, component] of given) {
255
255
  const source = sources.get(name);
256
256
  if (source)
257
- attachSsrRender(source, name, component, { comments: templateComments(config) });
257
+ attachSsrRender(source, name, component, { comments: templateComments(config), whitespace: config.whitespace ?? "condense" });
258
258
  }
259
259
  const cases = casesIn(fixtures);
260
260
  const { routes, options: configured } = fixtureOptions(root, config);
package/dist/context.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Config, TypeName } from "./config.ts";
1
+ import type { Config, TypeName, WhitespaceMode } from "./config.ts";
2
2
  import type { Plugin } from "./plugin.ts";
3
3
  import type { Declared, Exports } from "./constants.ts";
4
4
  import type { Alias } from "./compiler.ts";
@@ -33,6 +33,7 @@ export declare const ctx: {
33
33
  mappedUnions: Map<string, EnumDef>;
34
34
  builders: boolean;
35
35
  comments: boolean;
36
+ whitespace: WhitespaceMode;
36
37
  clientDirectives: Set<string>;
37
38
  plugins: readonly Plugin[];
38
39
  runs: Map<Plugin<unknown, unknown>, unknown>;
package/dist/context.js CHANGED
@@ -102,6 +102,8 @@ export function checkConfig(raw, file, warn, schema = CONFIG_SCHEMA) {
102
102
  wrong("isProduction", "`true` or `false`: whether `@vitejs/plugin-vue` compiles for production");
103
103
  if (raw.comments !== undefined && typeof raw.comments !== "boolean")
104
104
  wrong("comments", "`true` or `false`: whether the HTML comments of templates render");
105
+ if (raw.whitespace !== undefined && raw.whitespace !== "condense" && raw.whitespace !== "trim-edges")
106
+ wrong("whitespace", '`"condense"` or `"trim-edges"`: how the text of templates is spaced');
105
107
  if (raw.cssModules !== undefined) {
106
108
  const m = raw.cssModules;
107
109
  if (!isObject(m))
@@ -225,6 +227,7 @@ export const ctx = {
225
227
  mappedUnions: new Map(),
226
228
  builders: true,
227
229
  comments: false,
230
+ whitespace: "condense",
228
231
  clientDirectives: new Set(),
229
232
  plugins: [],
230
233
  runs: new Map(),
package/dist/dynamic.js CHANGED
@@ -143,7 +143,7 @@ function propType(s, name) {
143
143
  }
144
144
  function fieldLiterals(ty) {
145
145
  const literals = literalsOf(ty.k === "opt" ? ty.of : ty);
146
- return (ty.k === "opt" ? ty.of : ty).k === "str" && literals !== undefined && literals.length > 1 && literals.every((l) => typeof l === "string") ? literals : null;
146
+ return (ty.k === "opt" ? ty.of : ty).k === "str" && literals !== undefined && literals.every((l) => typeof l === "string") ? literals : null;
147
147
  }
148
148
  function keysOf(s, n, at) {
149
149
  const name = propOf(s, n);
package/dist/paths.d.ts CHANGED
@@ -3,3 +3,5 @@ import * as nodePath from "node:path";
3
3
  * code, a diagnostic or a key, or compares with a configured folder such as `out`, reads the same
4
4
  * on Windows as elsewhere. `path` is Node's own, and `path.win32` in a test. */
5
5
  export declare function relativePath(from: string, to: string, path?: Pick<typeof nodePath, "relative" | "sep">): string;
6
+ /** A path written with `/` on every platform, as Vite writes the ids and imports it reads. */
7
+ export declare function slashed(file: string, path?: Pick<typeof nodePath, "sep">): string;
package/dist/paths.js CHANGED
@@ -3,5 +3,9 @@ import * as nodePath from "node:path";
3
3
  * code, a diagnostic or a key, or compares with a configured folder such as `out`, reads the same
4
4
  * on Windows as elsewhere. `path` is Node's own, and `path.win32` in a test. */
5
5
  export function relativePath(from, to, path = nodePath) {
6
- return path.relative(from, to).split(path.sep).join("/");
6
+ return slashed(path.relative(from, to), path);
7
+ }
8
+ /** A path written with `/` on every platform, as Vite writes the ids and imports it reads. */
9
+ export function slashed(file, path = nodePath) {
10
+ return file.split(path.sep).join("/");
7
11
  }
@@ -1,4 +1,4 @@
1
- import { resolve as resolvePath, sep } from "node:path";
1
+ import { resolve as resolvePath } from "node:path";
2
2
  import genericNames from "generic-names";
3
3
  import postcss, {} from "postcss";
4
4
  import extractImports from "postcss-modules-extract-imports";
@@ -7,6 +7,7 @@ import modulesScope from "postcss-modules-scope";
7
7
  import modulesValues from "postcss-modules-values";
8
8
  import { fail, rustStr, sourceAt, STR, UNDEF } from "../model.js";
9
9
  import { CONFIG_FILE } from "../config.js";
10
+ import { slashed } from "../paths.js";
10
11
  import {} from "../context.js";
11
12
  import { runOf, scopeOf } from "../plugin.js";
12
13
  import { scopeHash } from "./scoped.js";
@@ -19,7 +20,7 @@ export function styleRequest(file, index, style, id) {
19
20
  if (!IGNORED_ATTRS.has(name))
20
21
  query += `&${encodeURIComponent(name)}${value ? `=${encodeURIComponent(value)}` : ""}`;
21
22
  }
22
- return `${resolvePath(file).split(sep).join("/")}?vue&type=style&index=${index}${style.scoped ? `&scoped=${id}` : ""}${query}&lang.module.css`;
23
+ return `${slashed(resolvePath(file))}?vue&type=style&index=${index}${style.scoped ? `&scoped=${id}` : ""}${query}&lang.module.css`;
23
24
  }
24
25
  /** The classes, keyframes and values a CSS module exports, by name, as postcss-modules names them
25
26
  * with `names`. */
@@ -1,17 +1,18 @@
1
1
  import { parse as parseJs } from "@babel/parser";
2
2
  import { createHash } from "node:crypto";
3
- import { relative, resolve as resolvePath, sep } from "node:path";
3
+ import { resolve as resolvePath } from "node:path";
4
4
  import { fail, namesSelf, rustStr } from "../model.js";
5
5
  import {} from "../config.js";
6
6
  import { runOf } from "../plugin.js";
7
7
  import { untilSettled } from "../fallthrough.js";
8
+ import { relativePath } from "../paths.js";
8
9
  export function scopeIdOf(comp) {
9
10
  return runOf(scoped).ids.get(comp) ?? null;
10
11
  }
11
12
  /** The id `@vitejs/plugin-vue` gives a component, `descriptor.id`: a hash of its path from Vite's
12
13
  * root, followed by its source unless `mode` hashes the path alone. */
13
14
  export function componentId(file, source, mode, viteRoot) {
14
- const path = relative(viteRoot, resolvePath(file)).split(sep).join("/");
15
+ const path = relativePath(viteRoot, resolvePath(file));
15
16
  return createHash("sha256")
16
17
  .update(mode === "filepath" ? path : path + source)
17
18
  .digest("hex")
package/dist/schema.js CHANGED
@@ -63,6 +63,7 @@ export const CONFIG_SCHEMA = {
63
63
  viteRoot: { type: "string", default: "." },
64
64
  isProduction: { type: "boolean", default: true },
65
65
  comments: { type: "boolean" },
66
+ whitespace: { type: "string", enum: ["condense", "trim-edges"], default: "condense" },
66
67
  cssModules: {
67
68
  type: "object",
68
69
  properties: { generateScopedName: STRING, hashPrefix: { type: "string", default: "" }, context: STRING },
package/dist/ssr.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import type { Component } from "vue";
2
+ import type { WhitespaceMode } from "./config.ts";
2
3
  /** How `attachSsrRender` compiles a component's template. */
3
4
  export interface SsrRenderOptions {
4
5
  /** Whether the template's HTML comments render, as `comments` in `ferrovue.config.json`
5
6
  * decides it for the generated code: `false` by default. */
6
7
  comments?: boolean;
8
+ /** How the template's text is spaced, as `whitespace` in `ferrovue.config.json` decides it for
9
+ * the generated code: `"condense"` by default. */
10
+ whitespace?: WhitespaceMode;
7
11
  }
8
12
  /** Give a component compiled for the browser the `ssrRender` its SSR build would have. */
9
13
  export declare function attachSsrRender(file: string, name: string, component: Component, options?: SsrRenderOptions): void;
package/dist/ssr.js CHANGED
@@ -3,6 +3,7 @@ import { compileScript, compileTemplate, parse as parseSfc } from "@vue/compiler
3
3
  import { getEscapedCssVarName } from "@vue/shared";
4
4
  import * as vue from "vue";
5
5
  import * as serverRenderer from "vue/server-renderer";
6
+ import { trimEdges } from "./whitespace.js";
6
7
  const KEY = /(?:^|[{,])\s*"((?:[^"\\]|\\.)*)"\s*:/g;
7
8
  function cssVarNames(file, component, count) {
8
9
  const text = ["setup", "ssrRender"]
@@ -43,7 +44,7 @@ export function attachSsrRender(file, name, component, options = {}) {
43
44
  ssr: true,
44
45
  ssrCssVars: descriptor.cssVars,
45
46
  isProd: true,
46
- compilerOptions: { ...(script?.bindings ? { bindingMetadata: script.bindings } : {}), comments },
47
+ compilerOptions: { ...(script?.bindings ? { bindingMetadata: script.bindings } : {}), comments, ...(options.whitespace === "trim-edges" ? { nodeTransforms: [trimEdges] } : {}) },
47
48
  }).code.replace(/^(\s*)":--[^"]*":/gm, (_, indent) => `${indent}":--${varNames[named++]}":`);
48
49
  const body = code
49
50
  .replace(/import \{([^}]*)\} from "vue"/g, (_, names) => `const {${names.replace(/ as /g, ": ")}} = __vue;`)
package/dist/strings.js CHANGED
@@ -303,7 +303,7 @@ export function regexShape(pattern, flags) {
303
303
  const start = at;
304
304
  while (/[0-9]/.test(peek() ?? ""))
305
305
  at++;
306
- return at > start ? Number(pattern.slice(start, at)) : null;
306
+ return at > start ? Math.min(Number(pattern.slice(start, at)), 2 ** 31 - 1) : null;
307
307
  };
308
308
  const quantifier = () => {
309
309
  const c = peek();
package/dist/template.js CHANGED
@@ -35,7 +35,6 @@ function heldContent(p) {
35
35
  return p.properties.find((q) => q.type !== "ObjectProperty" || q.computed || CONTENT_KEYS.has(q.key.name ?? q.key.value)) ?? null;
36
36
  }
37
37
  function directedContent(s, n) {
38
- s.temps.add(n.left.name);
39
38
  const parts = mergedParts(n.right);
40
39
  const held = parts.map(heldContent).find((p) => p !== null);
41
40
  if (held === undefined)
@@ -60,10 +59,7 @@ function rootControlKnown(s, names) {
60
59
  }
61
60
  function unpassedValue(s, n) {
62
61
  const test = n?.type === "ConditionalExpression" ? n.test : null;
63
- if (test?.type !== "BinaryExpression" || test.operator !== "in" || test.left.type !== "StringLiteral" || test.right.type !== "Identifier" || !(s.temps.has(test.right.name) || s.comp.rootControl !== undefined))
64
- return n;
65
- rootControlKnown(s, [test.left.value]);
66
- return n.alternate;
62
+ return s.temps.has(test?.right?.name) ? n.alternate : n;
67
63
  }
68
64
  function modelAttrs(s, n) {
69
65
  const [set, merged] = n.expressions;
@@ -125,6 +121,8 @@ export function slot(s, e, n) {
125
121
  renderDynamicAttr(s, e, a[0].value, attrShown(s, a[0].value, a[1]), n);
126
122
  return;
127
123
  case "_ssrRenderAttrs":
124
+ if (a[0].type === "AssignmentExpression")
125
+ s.temps.add(a[0].left.name);
128
126
  if (a.length > 1 && !(a[1].type === "StringLiteral" && a[1].value === "textarea" && rootControlKnown(s, ["value"])))
129
127
  fail(s.comp, "FV0006", "`ssrRenderAttrs` with a tag argument", n);
130
128
  if (a[0].type === "SequenceExpression") {
@@ -132,9 +130,7 @@ export function slot(s, e, n) {
132
130
  return;
133
131
  }
134
132
  if (a[0].type === "AssignmentExpression") {
135
- if (a.length > 1)
136
- s.temps.add(a[0].left.name);
137
- else
133
+ if (a.length === 1)
138
134
  directedContent(s, a[0]);
139
135
  renderAttrs(s, e, a[0].right);
140
136
  return;
package/dist/vite.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
2
- import { join, posix, resolve, sep } from "node:path";
2
+ import { join, posix, resolve } from "node:path";
3
3
  import { write } from "./compiler.js";
4
4
  import { CONFIG_FILE, loadConfig } from "./config.js";
5
5
  import { templateComments } from "./context.js";
@@ -7,6 +7,7 @@ import {} from "./errors.js";
7
7
  import { formatRefusal, formatWarning, isRefusal } from "./diagnostics.js";
8
8
  import { allPages, fileRoutes, pagesFolder } from "./file-routes.js";
9
9
  import { affects, inputsOf, watched } from "./inputs.js";
10
+ import { slashed } from "./paths.js";
10
11
  /** How `@vitejs/plugin-vue`, as this build configures it, computes a `<style scoped>` id: from the
11
12
  * path alone or the path and source, hashed from Vite's root; and whether it compiles for production.
12
13
  * `null` without the plugin, or with an id generator of its own. */
@@ -27,6 +28,18 @@ function vueComments(config) {
27
28
  const set = vue.api?.options?.template?.compilerOptions?.comments;
28
29
  return typeof set === "boolean" ? set : !config.isProduction;
29
30
  }
31
+ const TRIM_EDGES = Symbol.for("ferrovue.whitespace");
32
+ /** How `@vitejs/plugin-vue`, as this build configures it, spaces the text of templates:
33
+ * `"trim-edges"` when its `template.compilerOptions.nodeTransforms` holds `trimEdges`, from any
34
+ * copy of ferrovue. `null` without the plugin. */
35
+ function vueWhitespace(config) {
36
+ const vue = config.plugins.find((p) => p.name === "vite:vue");
37
+ if (!vue)
38
+ return null;
39
+ const transforms = vue.api?.options?.template?.compilerOptions?.nodeTransforms;
40
+ const trims = Array.isArray(transforms) && transforms.some((t) => typeof t === "function" && t[TRIM_EDGES] === "trim-edges");
41
+ return trims ? "trim-edges" : "condense";
42
+ }
30
43
  function componentSources(root, config) {
31
44
  let files = [];
32
45
  try {
@@ -88,6 +101,20 @@ function commentsMismatch(root, vue, configFile = CONFIG_FILE) {
88
101
  const kept = (on) => (on ? "keeps them" : "leaves them out");
89
102
  return `the HTML comments of templates: @vitejs/plugin-vue ${kept(vue)}, where ${configFile} ${kept(own)}; set \`"comments": ${vue}\` in ${configFile}`;
90
103
  }
104
+ /** Why the server would space the text of templates otherwise than the client, when plugin-vue
105
+ * trims it at the edges, `vue`, where the configuration, `configFile`, keeps it, or the other way
106
+ * round. */
107
+ function whitespaceMismatch(root, vue, configFile = CONFIG_FILE) {
108
+ if (vue === null)
109
+ return null;
110
+ const own = loadConfig(root, configFile, ignore).whitespace ?? "condense";
111
+ if (own === vue)
112
+ return null;
113
+ if (own === "trim-edges") {
114
+ return `the whitespace at the edges of templates' text: ${configFile} trims it with \`"whitespace": "trim-edges"\`, where @vitejs/plugin-vue keeps it; add \`trimEdges\` from \`ferrovue/whitespace\` to plugin-vue's \`template.compilerOptions.nodeTransforms\``;
115
+ }
116
+ return `the whitespace at the edges of templates' text: @vitejs/plugin-vue trims it with \`trimEdges\`, where ${configFile} keeps it; set \`"whitespace": "trim-edges"\` in ${configFile}`;
117
+ }
91
118
  /** Why the class names of the server's CSS modules would differ from those Vite gives the client,
92
119
  * when a component has `<style module>` and Vite's `css`, run from `cwd`, names them otherwise than
93
120
  * `cssModules` in the configuration, `configFile`. */
@@ -126,13 +153,13 @@ const ISLANDS_ID = `\0${ISLANDS}`;
126
153
  const ROUTES = "ferrovue/routes";
127
154
  const ROUTES_ID = `\0${ROUTES}`;
128
155
  function islandsModule(root, islands) {
129
- const entries = Object.entries(islands).map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(resolve(root, file).split(sep).join("/"))}),`);
156
+ const entries = Object.entries(islands).map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(slashed(resolve(root, file)))}),`);
130
157
  return `export default {\n${entries.join("\n")}\n};\n`;
131
158
  }
132
159
  function routesTree(root, routes, indent, lazy) {
133
160
  return routes
134
161
  .map((r) => {
135
- const page = r.file ? `() => import(${JSON.stringify(resolve(root, r.file).split(sep).join("/"))})` : null;
162
+ const page = r.file ? `() => import(${JSON.stringify(slashed(resolve(root, r.file)))})` : null;
136
163
  const fields = [
137
164
  `path: ${JSON.stringify(r.path)}`,
138
165
  ...(r.name !== undefined ? [`name: ${JSON.stringify(r.name)}`] : []),
@@ -212,6 +239,7 @@ export default function ferrovue(options = {}) {
212
239
  };
213
240
  let vue = null;
214
241
  let comments = null;
242
+ let whitespace = null;
215
243
  let aliases = [];
216
244
  let css;
217
245
  let building = true;
@@ -239,6 +267,7 @@ export default function ferrovue(options = {}) {
239
267
  readsImportedTypes(config);
240
268
  vue = vueScopeIds(config);
241
269
  comments = vueComments(config);
270
+ whitespace = vueWhitespace(config);
242
271
  aliases = config.resolve.alias;
243
272
  css = config.css;
244
273
  building = config.command === "build";
@@ -246,7 +275,10 @@ export default function ferrovue(options = {}) {
246
275
  buildStart() {
247
276
  try {
248
277
  regenerate((w) => this.warn(formatWarning(w)));
249
- const mismatch = scopeIdMismatch(root, vue, configFile) ?? cssModulesMismatch(root, css, process.cwd(), configFile) ?? (building ? commentsMismatch(root, comments, configFile) : null);
278
+ const mismatch = scopeIdMismatch(root, vue, configFile) ??
279
+ cssModulesMismatch(root, css, process.cwd(), configFile) ??
280
+ whitespaceMismatch(root, whitespace, configFile) ??
281
+ (building ? commentsMismatch(root, comments, configFile) : null);
250
282
  if (mismatch && building)
251
283
  this.error(`ferrovue: ${mismatch}`);
252
284
  if (mismatch)
@@ -0,0 +1,7 @@
1
+ /** A node transform for `@vitejs/plugin-vue`'s `template.compilerOptions.nodeTransforms` that
2
+ * trims the whitespace at the start and end of an element's text, where it meets the element's
3
+ * tags, as `"whitespace": "trim-edges"` makes ferrovue's Rust render it. It trims the content of
4
+ * elements, components and `<template v-slot>`, and leaves `<pre>`, `<textarea>`, `v-pre` and an
5
+ * element whose `style` sets `white-space: pre`, `pre-wrap`, `pre-line` or `break-spaces` as they
6
+ * are, with everything inside them. */
7
+ export declare function trimEdges(node: object): void;
@@ -0,0 +1,58 @@
1
+ const ROOT = 0;
2
+ const ELEMENT = 1;
3
+ const TEXT = 2;
4
+ const PLAIN = 0;
5
+ const COMPONENT = 1;
6
+ const TEMPLATE = 3;
7
+ const LEADING = /^[ \t\n\f\r]+/;
8
+ const TRAILING = /[ \t\n\f\r]+$/;
9
+ const KEPT_TAGS = new Set(["pre", "textarea"]);
10
+ const PRE_STYLE = /(?:^|;)\s*white-space\s*:\s*(?:pre|pre-wrap|pre-line|break-spaces)\s*(?:!important\s*)?(?:;|$)/i;
11
+ const OPEN_TAG = /^<[^\s/>]+(?:[^"'>]|"[^"]*"|'[^']*')*/;
12
+ const QUOTED = /"[^"]*"|'[^']*'/g;
13
+ const V_PRE = /\sv-pre(?=[\s/=]|$)/;
14
+ const MARK = Symbol.for("ferrovue.whitespace");
15
+ function keeps(el) {
16
+ if (KEPT_TAGS.has(el.tag.toLowerCase()))
17
+ return true;
18
+ if (el.props.some((p) => p.type === 6 && p.name === "style" && p.value !== undefined && PRE_STYLE.test(p.value.content)))
19
+ return true;
20
+ const open = OPEN_TAG.exec(el.loc.source)?.[0] ?? "";
21
+ return V_PRE.test(open.replace(QUOTED, '""'));
22
+ }
23
+ function trims(el) {
24
+ if (el.tagType === PLAIN || el.tagType === COMPONENT)
25
+ return true;
26
+ return el.tagType === TEMPLATE && el.props.some((p) => p.type === 7 && p.name === "slot");
27
+ }
28
+ function trimChildren(el) {
29
+ const children = el.children;
30
+ const first = children[0];
31
+ if (first?.type === TEXT)
32
+ first.content = first.content.replace(LEADING, "");
33
+ const last = children.at(-1);
34
+ if (last?.type === TEXT)
35
+ last.content = last.content.replace(TRAILING, "");
36
+ el.children = children.filter((c) => c.type !== TEXT || c.content !== "");
37
+ }
38
+ function walk(node) {
39
+ for (const child of node.children ?? []) {
40
+ if (child.type !== ELEMENT || keeps(child))
41
+ continue;
42
+ if (trims(child))
43
+ trimChildren(child);
44
+ walk(child);
45
+ }
46
+ }
47
+ /** A node transform for `@vitejs/plugin-vue`'s `template.compilerOptions.nodeTransforms` that
48
+ * trims the whitespace at the start and end of an element's text, where it meets the element's
49
+ * tags, as `"whitespace": "trim-edges"` makes ferrovue's Rust render it. It trims the content of
50
+ * elements, components and `<template v-slot>`, and leaves `<pre>`, `<textarea>`, `v-pre` and an
51
+ * element whose `style` sets `white-space: pre`, `pre-wrap`, `pre-line` or `break-spaces` as they
52
+ * are, with everything inside them. */
53
+ export function trimEdges(node) {
54
+ const n = node;
55
+ if (n.type === ROOT)
56
+ walk(n);
57
+ }
58
+ Object.defineProperty(trimEdges, MARK, { value: "trim-edges" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferrovue",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -85,6 +85,10 @@
85
85
  "types": "./dist/vite.d.ts",
86
86
  "default": "./dist/vite.js"
87
87
  },
88
+ "./whitespace": {
89
+ "types": "./dist/whitespace.d.ts",
90
+ "default": "./dist/whitespace.js"
91
+ },
88
92
  "./volar": {
89
93
  "types": "./dist/volar.d.ts",
90
94
  "default": "./dist/volar.js"
@@ -96,8 +100,8 @@
96
100
  "ferrovue": "./dist/cli.js"
97
101
  },
98
102
  "dependencies": {
99
- "@babel/parser": "^7.29.9",
100
- "@intlify/message-compiler": "11.4.16",
103
+ "@babel/parser": "^7.29.11",
104
+ "@intlify/message-compiler": "11.4.18",
101
105
  "@vue/compiler-sfc": "3.5.44",
102
106
  "@vue/shared": "3.5.44",
103
107
  "generic-names": "^4.0.0",
@@ -114,14 +118,14 @@
114
118
  "@vue/language-service": "3.3.13",
115
119
  "@vue/server-renderer": "3.5.44",
116
120
  "pinia": "^4.1.0",
117
- "playwright": "1.64.1",
121
+ "playwright": "1.65.0",
118
122
  "typescript": "^7.0.2",
119
123
  "typescript-js": "npm:typescript@6.0.3",
120
- "vite": "^8.3.5",
121
- "vitest": "^5.0.5",
124
+ "vite": "^8.3.6",
125
+ "vitest": "^5.1.0",
122
126
  "vscode-uri": "3.2.0",
123
127
  "vue": "3.5.44",
124
- "vue-i18n": "11.4.16",
128
+ "vue-i18n": "11.4.18",
125
129
  "vue-router": "^5.4.0"
126
130
  },
127
131
  "peerDependencies": {
package/schema.json CHANGED
@@ -310,6 +310,16 @@
310
310
  "markdownDescription": "Whether the HTML comments of templates render, as `@vitejs/plugin-vue`'s `template.compilerOptions.comments`: by default as Vue decides it, kept when `isProduction` is `false` and left out when it is `true`.",
311
311
  "type": "boolean"
312
312
  },
313
+ "whitespace": {
314
+ "description": "How the text of templates is spaced: `\"condense\"` (the default) as Vue spaces it, or `\"trim-edges\"`, which also trims the whitespace where text meets its element's tags. The client compiles the same way with `trimEdges` from `ferrovue/whitespace` in `@vitejs/plugin-vue`'s `template.compilerOptions.nodeTransforms`.",
315
+ "markdownDescription": "How the text of templates is spaced: `\"condense\"` (the default) as Vue spaces it, or `\"trim-edges\"`, which also trims the whitespace where text meets its element's tags. The client compiles the same way with `trimEdges` from `ferrovue/whitespace` in `@vitejs/plugin-vue`'s `template.compilerOptions.nodeTransforms`.",
316
+ "type": "string",
317
+ "enum": [
318
+ "condense",
319
+ "trim-edges"
320
+ ],
321
+ "default": "condense"
322
+ },
313
323
  "cssModules": {
314
324
  "description": "CSS modules (`<style module>`): how Vite's `css.modules` names their classes, which ferrovue computes as postcss-modules does. Without it, `<style module>` is refused.",
315
325
  "markdownDescription": "CSS modules (`<style module>`): how Vite's `css.modules` names their classes, which ferrovue computes as postcss-modules does. Without it, `<style module>` is refused.",