ferrovue 0.14.0 → 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.0/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:
@@ -226,7 +227,7 @@ ferrovue compiles `<script setup lang="ts">` components, and components with no
226
227
  |---|---|
227
228
  | Prop types | `string`, `number` (integers, `i64`, written and computed as JavaScript does, exact within ±2⁵³), `Float` from `ferrovue/types` (fractions, `f64`), `boolean`, unions of two or more string literals (`"sm" \| "md"`, an alias, or written in place), each a Rust enum generated for it (`Size::Md`, `"md".parse::<Size>()`, written and serialised as its literal; see the crate's [`props`](https://docs.rs/ferrovue/latest/ferrovue/guide/props/index.html#unions-of-string-literals) guide), or an enum of the application's own that `unions` names for the alias; a list of such a union, a list of its enum (`Size[]` is `Vec<Size>`); `T \| undefined`, `T \| null` (an `Option` written as `null`), arrays (`T[]`, `Array<T>`, `readonly T[]`), interfaces and object type aliases (recursive ones too), object types written in place, mapped types and `Record`s over a union of names, indexed types (`Card["size"]`), tuples (`[label: string, n: number]`, a tuple struct written as an array), dictionaries (`Record<string, T>`, `{ [key: string]: T }`, `ferrovue::props::Record` in Rust, which keeps JavaScript's order of keys), another component's exported `Props`, `TrustedHtml`, `InlineHtml`, `Date` (a prop, optional or nullable too, and inside lists, dictionaries and interfaces: `ferrovue::date::JsDate`, or `chrono::DateTime<Utc>` with the `chrono` feature) |
228
229
  | Shared types | Interfaces and type aliases imported from `.ts` files (generated once, into `types.rs`; imported by a relative path or through an alias the project declares, in its tsconfig.json's `compilerOptions.paths` or, with the Vite plugin, Vite's `resolve.alias`: `@/lib/format`), from a store's file, or from another component's `.vue` file; objects of a shared type can be passed between components. A TypeScript `enum` of string members is a Rust enum, as a union of string literals is, its variants named after its members; one of numbers is a number |
229
- | Constants | Constants imported from `.ts` files, object constants declared in `<script setup>`, and `enum`s (imported, or declared in the component, with members set to such constants or to earlier members), evaluated when the component is compiled: strings, numbers, booleans, `null`, arithmetic and joined strings of them as JavaScript evaluates them (`60 * MINUTE`, `` `${BASE}/feed` ``, `"#" + 1.5`, and `**` where it gives an exact integer within ±2⁵³), lists of them, objects of them read field by field (`LABELS.save`, `Tone.Loud`, `Rank[5]`), and lists of objects of them, which become a `const` in `types.rs` (`{ value, label }` items get a type named after the constant, `OptionsItem`, unless the constant is typed with an interface: `OPTIONS: Option[]`; a field that is `null` in some items is `T | null`); lookup tables: an object constant (`as const`, `Record<Size, T>` or untyped, keys computed from enum members too) read by a key whose type is a union of string or integer literals or an `enum` (a prop, a `computed`, a `v-for` item's field, a `?:` of literals, or built from one by `.toUpperCase()`, `.toLowerCase()` or a template literal), or by a constant number (`O[HALF]` reads `O["0.5"]`), compiled to a `match` over the members (over the variants of a union's enum, with no catch-all arm; a table of a union's members, `Record<Tone, Tone>`, reaches a union prop as a `match` from variant to variant), read on by field (`THEME[tone].fg`) in text, attributes, `:class` and `:style`, with `undefined` for a member the object lacks (`ICONS[kind] ?? "none"`, `ICONS[kind]?.label`, `v-if="ICONS[kind]"`, `kind && ICONS[kind]`); a free-text key tested with `Object.hasOwn(ROLES, key)`, against the object's own keys, which narrows the key for `ROLES[key]` in what follows; `Object.keys()` of an object constant; a constant `new Map([[key, value], …])` of string or integer keys, read with `.get(key)` (`undefined` for a key it lacks) and `.has(key)`. See the crate's [`props`](https://docs.rs/ferrovue/latest/ferrovue/guide/props/index.html#constants) guide |
230
+ | Constants | Constants imported from `.ts` files (exported on their declaration, by name in `export { A, B as C }`, as `export default A`, or passed on with `export { A } from` and `export * from`), object constants declared in `<script setup>`, and `enum`s (imported, or declared in the component, with members set to such constants or to earlier members), evaluated when the component is compiled: strings, numbers, booleans, `null`, arithmetic and joined strings of them as JavaScript evaluates them (`60 * MINUTE`, `` `${BASE}/feed` ``, `"#" + 1.5`, and `**` where it gives an exact integer within ±2⁵³), lists of them, objects of them read field by field (`LABELS.save`, `Tone.Loud`, `Rank[5]`), and lists of objects of them, which become a `const` in `types.rs` (`{ value, label }` items get a type named after the constant, `OptionsItem`, unless the constant is typed with an interface: `OPTIONS: Option[]`; a field that is `null` in some items is `T | null`); lookup tables: an object constant (`as const`, `Record<Size, T>` or untyped, keys computed from enum members too) read by a key whose type is a union of string or integer literals or an `enum` (a prop, a `computed`, a `v-for` item's field, a `?:` of literals, or built from one by `.toUpperCase()`, `.toLowerCase()` or a template literal), or by a constant number (`O[HALF]` reads `O["0.5"]`), compiled to a `match` over the members (over the variants of a union's enum, with no catch-all arm; a table of a union's members, `Record<Tone, Tone>`, reaches a union prop as a `match` from variant to variant), read on by field (`THEME[tone].fg`) in text, attributes, `:class` and `:style`, with `undefined` for a member the object lacks (`ICONS[kind] ?? "none"`, `ICONS[kind]?.label`, `v-if="ICONS[kind]"`, `kind && ICONS[kind]`); a free-text key tested with `Object.hasOwn(ROLES, key)`, against the object's own keys, which narrows the key for `ROLES[key]` in what follows; `Object.keys()` of an object constant; a constant `new Map([[key, value], …])` of string or integer keys, read with `.get(key)` (`undefined` for a key it lacks) and `.has(key)`. See the crate's [`props`](https://docs.rs/ferrovue/latest/ferrovue/guide/props/index.html#constants) guide |
230
231
  | Props | Props declared in place, by an interface of the component, or by one imported from a `.ts` file by a relative path (`defineProps<CardProps>()`), extended or joined with `&`; `withDefaults`, destructured props with defaults and new names (`const { size = "md", label: text } = defineProps<…>()`), the props object (`const props = defineProps<…>()`, then `props.label` in the template or in script code), optional booleans (Vue casts an absent one to `false`), `defineModel` (named, required, with defaults), components with no props. A generic component (`<script setup generic="T extends Item">`) renders each type parameter as its constraint, which is all the template can rely on |
231
232
  | Setup | `ref`/`shallowRef`, `computed` (an expression, or a body of statements: see Statements; one that may throw, through `.repeat()`, `.padStart()`, a date, `$t` or a helper, is evaluated where the render first reads it, once, as Vue does), refs that start empty, typed by their type argument (`ref<Row[]>([])`, `ref<Row>()`, `ref<string \| undefined>(undefined)`), refs of a union of string literals, held as its enum (`ref<Choice>(props.tone ?? "")`, a member, a value of the union, a lookup table's value, or a `?:` or `??` of them), plain `const`/`let`, `.value` in script code, helpers with Rust twins, functions (see Functions), a plain `<script>` block beside setup. Lifecycle hooks, `watch` (not `immediate`), `defineEmits`, `defineSlots`, `defineOptions`, `defineExpose` and template refs (`ref(null)`, `useTemplateRef`) are client-only, as is a function only an event handler calls. The template may name them only from event handlers, which the server drops |
232
233
  | Statements | in a `computed`'s body and in a list method's callback (`.map()`, `.filter()`, `.find()`, `.some()`, `.reduce()`, a `.toSorted()` comparator and the others): `const` and `let` of any value the template may compute, destructured too (`const { name, posts: count } = author`); `if` / `else if` / `else` and early `return`, narrowing what follows as TypeScript narrows it (`if (!user) return ""; return user.name`, `if (!a \|\| !a.title) return "-"`); `switch` over a string or a number, with stacked `case`s, `default` and `break`; assignments to the body's `let`s (`=`, `+=`, `-=`, `*=`, `++`, `--` and the others), with the type TypeScript gives them; `for (const x of list)` over a list, `list.entries()`, `Object.keys()` or `Object.entries()`, `for (const ch of text)` over a string by its characters (a character beyond U+FFFF is one), and counting loops `for (let i = 0; i < n; i++)` to an integer bound, with `break`, `continue`, nested loops and `return` inside them, carrying strings, numbers and booleans from one item to the next (`let total = 0; for (const r of rows) total += r.n;`, thousands separators, the largest of a list). Each value is evaluated once, where JavaScript evaluates it, and only on the path that reaches it. `while`, `do…while` and `for…in` loops, assignments to props, refs, `const`s, parameters and fields, `try`, `throw`, `var` and a path that ends without `return` are refused. See the crate's [`script_logic`](https://docs.rs/ferrovue/latest/ferrovue/guide/script_logic/index.html) guide |
@@ -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/attrs.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { type N, type Scope, type Val } from "./model.ts";
1
+ import { type N, type Piece, type Scope, type Val } from "./model.ts";
2
2
  import { type ErrorCode } from "./errors.ts";
3
3
  import { Emitter } from "./emitter.ts";
4
+ export declare function displayPieces(e: Emitter, pieces: Piece[]): void;
4
5
  export declare function display(e: Emitter, v: Val): boolean;
5
6
  export declare function interpolate(s: Scope, e: Emitter, v: Val, n: N): void;
6
7
  export declare function attrValue(e: Emitter, v: Val): void;
package/dist/attrs.js CHANGED
@@ -6,23 +6,29 @@ import { CONFIG_FILE } from "./config.js";
6
6
  import { ctx } from "./context.js";
7
7
  import { describeTy, expr, shown } from "./expr.js";
8
8
  import { known, truthy } from "./narrowing.js";
9
- import { asCow, isTemporary, unquote } from "./strings.js";
10
- import { atom, bare, condition, ifElse, not, receiver, scrutinee, strArg } from "./parens.js";
9
+ import { asCow, isTemporary, piecesOf, unquote } from "./strings.js";
10
+ import { atom, bare, condition, ifElse, logical, not, receiver, scrutinee, strArg } from "./parens.js";
11
11
  import { Emitter } from "./emitter.js";
12
12
  import { isDollarAttrs } from "./fallthrough.js";
13
13
  import { claim } from "./plugin.js";
14
14
  import { classAttr, classPresent, literalClass, maybeEqual, renderClass } from "./classes.js";
15
15
  import { mergedStyle, renderStyle, styleAttr } from "./styles.js";
16
16
  const NUMBER_BYTES = 6;
17
- function piecesOf(v) {
18
- const f = v.format;
19
- if (v.ty.k !== "str" || f === undefined || v.code !== `&*format!(${rustStr(f.text)}, ${f.args.join(", ")})`)
20
- return undefined;
21
- return f.pieces;
22
- }
23
17
  function emptyTest(v) {
24
18
  if (neverEmpty(v.ty))
25
19
  return "false";
20
+ const branches = v.branches;
21
+ if (branches !== undefined && branches.of === v.code) {
22
+ const [yes, no] = [emptyTest(branches.yes), emptyTest(branches.no)];
23
+ const binds = branches.test.startsWith("let ");
24
+ if (yes === no && !binds)
25
+ return yes;
26
+ if (!binds && yes === "false")
27
+ return logical(not(branches.test), "&&", no);
28
+ if (!binds && no === "false")
29
+ return logical(branches.test, "&&", yes);
30
+ return `if ${branches.test} { ${yes} } else { ${no} }`;
31
+ }
26
32
  const pieces = piecesOf(v);
27
33
  if (pieces === undefined)
28
34
  return `${receiver(v.code)}.is_empty()`;
@@ -31,17 +37,32 @@ function emptyTest(v) {
31
37
  return "false";
32
38
  return strs.map((p) => `${receiver(p.code)}.is_empty()`).join(" && ");
33
39
  }
40
+ export function displayPieces(e, pieces) {
41
+ for (const p of pieces) {
42
+ if (typeof p === "string")
43
+ e.lit(escapeHtml(p));
44
+ else if ("shown" in p)
45
+ e.stmt(`{ use std::fmt::Write as _; let _ = write!(out, "{}", ${p.shown}); }`);
46
+ else
47
+ display(e, p);
48
+ }
49
+ }
34
50
  export function display(e, v) {
35
51
  const pieces = piecesOf(v);
36
52
  if (pieces !== undefined) {
37
- for (const p of pieces) {
38
- if (typeof p === "string")
39
- e.lit(escapeHtml(p));
40
- else if ("shown" in p)
41
- e.stmt(`{ use std::fmt::Write as _; let _ = write!(out, "{}", ${p.shown}); }`);
42
- else
43
- display(e, p);
44
- }
53
+ displayPieces(e, pieces);
54
+ return true;
55
+ }
56
+ const branches = v.branches;
57
+ if (branches !== undefined && branches.of === v.code) {
58
+ e.open(`if ${branches.test}`);
59
+ display(e, branches.yes);
60
+ e.close(" else {");
61
+ const at = e.lines.length;
62
+ display(e, branches.no);
63
+ e.close();
64
+ if (e.lines.length === at + 1)
65
+ e.lines.splice(at - 1, 2, e.lines[at - 1].replace(/\} else \{$/, "}"));
45
66
  return true;
46
67
  }
47
68
  if (v.ty.k === "str" && known(v) !== undefined)
package/dist/classes.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { type N, type Scope } from "./model.ts";
1
+ import { type N, type Scope, type Val } from "./model.ts";
2
2
  import { Emitter } from "./emitter.ts";
3
3
  export type ClassItem = {
4
4
  lit: string;
5
5
  } | {
6
6
  code: string;
7
+ join?: Val;
7
8
  };
8
9
  export declare function classItems(s: Scope, n: N): ClassItem[];
9
10
  export declare function renderClass(s: Scope, e: Emitter, n: N, after?: boolean): void;
package/dist/classes.js CHANGED
@@ -3,10 +3,10 @@ import { directiveOwner, fail, nothing, rustStr, STR } from "./model.js";
3
3
  import { expr } from "./expr.js";
4
4
  import { choice, cond, known, narrowing, narrowTo, presence, truthy } from "./narrowing.js";
5
5
  import { pickPresent } from "./constants.js";
6
- import { meet, unquote } from "./strings.js";
6
+ import { meet, piecesOf, unquote } from "./strings.js";
7
7
  import { atom, bare, condition, logical, not, occurrences, strArg } from "./parens.js";
8
8
  import { Emitter } from "./emitter.js";
9
- import { stringAttr } from "./attrs.js";
9
+ import { displayPieces, stringAttr } from "./attrs.js";
10
10
  import { fresh, jsHint } from "./names.js";
11
11
  export function classItems(s, n) {
12
12
  switch (n.type) {
@@ -79,7 +79,7 @@ export function classItems(s, n) {
79
79
  if (literal !== null && literal === literal.trim())
80
80
  return [{ lit: literal }];
81
81
  if (v.ty.k === "str")
82
- return [{ code: v.code }];
82
+ return [piecesOf(v) === undefined ? { code: v.code } : { code: v.code, join: v }];
83
83
  if (v.ty.k === "opt" && v.ty.of.k === "str")
84
84
  return [{ code: `${atom(v.code)}.unwrap_or("")` }];
85
85
  if (nothing(v.ty))
@@ -118,10 +118,49 @@ export function renderClass(s, e, n, after = false) {
118
118
  e.lit((wrote ? " " : "") + escapeHtml(it.lit));
119
119
  wrote = true;
120
120
  }
121
- if (i < items.length) {
122
- const rest = items.slice(i).map((it) => ("lit" in it ? rustStr(it.lit) : it.code));
123
- e.stmt(`fv::attrs::class_into(out, ${wrote}, &[${rest.join(", ")}]);`);
121
+ let group = [];
122
+ let at = null;
123
+ for (; i < items.length; i++) {
124
+ const it = items[i];
125
+ const parts = "join" in it && it.join !== undefined ? trimmedParts(it.join) : null;
126
+ if (parts === null) {
127
+ if (!group.length && !wrote && items.slice(i + 1).some((later) => "join" in later && later.join !== undefined)) {
128
+ at = fresh("at", "class");
129
+ e.stmt(`let ${at} = out.len();`);
130
+ }
131
+ group.push("lit" in it ? rustStr(it.lit) : it.code);
132
+ continue;
133
+ }
134
+ if (group.length)
135
+ e.stmt(`fv::attrs::class_into(out, ${wrote}, &[${group.join(", ")}]);`);
136
+ if (wrote)
137
+ e.lit(" ");
138
+ else if (group.length && at !== null)
139
+ e.stmt(`if out.len() != ${at} { out.push(' '); }`);
140
+ displayPieces(e, parts);
141
+ [group, at, wrote] = [[], null, true];
142
+ }
143
+ if (group.length)
144
+ e.stmt(`fv::attrs::class_into(out, ${wrote}, &[${group.join(", ")}]);`);
145
+ }
146
+ /** The pieces of a joined class name with the spaces `normalizeClass` trims taken off its ends,
147
+ * when only its first or last piece may hold them and the join writes a name: null otherwise. */
148
+ function trimmedParts(v) {
149
+ const pieces = [...piecesOf(v)];
150
+ const solid = (p, start) => p !== undefined && (typeof p === "string" ? p !== "" && p === (start ? p.trimStart() : p.trimEnd()) : "shown" in p || p.ty.k !== "str");
151
+ for (const start of [true, false]) {
152
+ const edge = start ? 0 : pieces.length - 1;
153
+ const next = start ? 1 : pieces.length - 2;
154
+ const p = pieces[edge];
155
+ if (typeof p === "string")
156
+ pieces[edge] = start ? p.trimStart() : p.trimEnd();
157
+ if (solid(pieces[edge], start))
158
+ continue;
159
+ if (typeof p === "string" || "shown" in p || pieces.length < 2 || !solid(pieces[next], start))
160
+ return null;
161
+ pieces[edge] = { code: `fv::strings::js_trim_${start ? "start" : "end"}(${strArg(p.code)})`, ty: STR };
124
162
  }
163
+ return pieces;
125
164
  }
126
165
  const UNWRITTEN = new Set(["on", "if", "else-if", "else", "for", "slot", "once", "memo", "cloak", "pre"]);
127
166
  /** The class list of an element in content Vue renders from virtual nodes, in the order Vue's
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) : [];
@@ -2,9 +2,9 @@ import { absence, enumOf, fail, FLOAT, joinAbsence, joinTy, literalsOf, literalT
2
2
  import { GenError } from "./errors.js";
3
3
  import { annotatedStr, exactlyEnum, intoEnum, knownLiteral } from "./enums.js";
4
4
  import { ctx } from "./context.js";
5
- import { atom, bare, enclosed, occurrences, strArg } from "./parens.js";
5
+ import { atom, bare, condition, enclosed, occurrences, strArg } from "./parens.js";
6
6
  import { collected, sliced, typedEmpty, untypedEmpty } from "./lists.js";
7
- import { asCow, isTemporary, loneOf } from "./strings.js";
7
+ import { asCow, isTemporary, loneOf, piecesOf } from "./strings.js";
8
8
  import { asF64, intFromF64, isCast, isNumber } from "./numbers.js";
9
9
  import { choice, known, literalNarrowing, narrowTo, presence, truthTested, truthy } from "./narrowing.js";
10
10
  import { ownedStruct, ownedStructs } from "./objects.js";
@@ -65,7 +65,14 @@ function branches(s, n, t) {
65
65
  a = { ...a, ty: b.ty };
66
66
  if (emptyB && a.ty.k === "list")
67
67
  b = { ...b, ty: a.ty };
68
- return choiceOf({ comp, n, a, b, emptyA, emptyB, choose: chooser(t, p, name, a), lone: loneOf(a, b) });
68
+ const [x, y] = [a, b];
69
+ const head = () => {
70
+ if (!p)
71
+ return { test: condition(truthy(t)), yes: x, no: y };
72
+ const [present, absent] = p.negated ? [y, x] : [x, y];
73
+ return { test: occurrences(present.code, name) ? p.pattern(name) : condition(p.present), yes: present, no: absent };
74
+ };
75
+ return choiceOf({ comp, n, a, b, emptyA, emptyB, choose: chooser(t, p, name, a), head, lone: loneOf(a, b) });
69
76
  }
70
77
  function chooser(t, p, name, a) {
71
78
  const second = (code) => (a.ty.k === "str" ? strArg(code) : bare(code));
@@ -90,7 +97,9 @@ function choiceOf(c) {
90
97
  if (owned !== null)
91
98
  return owned;
92
99
  if (sameTy(a.ty, b.ty) && a.ty.k === "str" && (isTemporary(a) || isTemporary(b))) {
93
- return { code: `&*(${choose(asCow(a), asCow(b))})`, ty: STR, ...lone };
100
+ const code = `&*(${choose(asCow(a), asCow(b))})`;
101
+ const joins = (v) => piecesOf(v) !== undefined || v.branches?.of === v.code;
102
+ return { code, ty: STR, ...lone, ...(joins(a) || joins(b) ? { branches: { of: code, ...c.head() } } : {}) };
94
103
  }
95
104
  if (sameTy(a.ty, b.ty) && a.ty.k === "list" && a.ty.of.k !== "undef")
96
105
  return listChoice(c);
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);
@@ -1,4 +1,4 @@
1
- import { type Component, type N, type Scope, type Val } from "./model.ts";
1
+ import { type Component, type Fn, type N, type Scope, type Val } from "./model.ts";
2
2
  export type Const = {
3
3
  k: "scalar";
4
4
  js: Scalar;
@@ -25,10 +25,30 @@ type Scalar = string | number | boolean | null;
25
25
  export interface Declared {
26
26
  node: N;
27
27
  home: Component;
28
- exported: boolean;
29
28
  }
30
29
  /** Remember the constants and enums a file declares at its top level, to evaluate when one is used. */
31
30
  export declare function declareConsts(home: Component, body: N[]): Map<string, Declared>;
31
+ /** A name a `.ts` file exports: one of its own, by its local name, or one of another file's, by the
32
+ * name that file exports it as (`null` for a module that is no `.ts` file of the project, or for a
33
+ * namespace, `export * as ns from`). */
34
+ export type Export = {
35
+ local: string;
36
+ } | {
37
+ file: string | null;
38
+ name: string | null;
39
+ };
40
+ /** What a `.ts` file exports: its names, and the files whose names `export * from` passes on. */
41
+ export interface Exports {
42
+ names: Map<string, Export>;
43
+ stars: (string | null)[];
44
+ }
45
+ /** The names a `.ts` file exports: by `export const`, `export function`, `export enum`,
46
+ * `export { a, b as c }`, `export default a`, and from other files by `export { a } from` and `export * from`. */
47
+ export declare function exportsOf(rel: string, body: N[]): Exports;
48
+ /** The constant or enum a `.ts` file exports as `name`. */
49
+ export declare function exportedConst(file: string, name: string): Declared | undefined;
50
+ /** The function a `.ts` file exports as `name`. */
51
+ export declare function exportedFunction(file: string, name: string): Fn | undefined;
32
52
  /** What a declared constant or enum stands for, evaluated once. */
33
53
  export declare function constOfDecl(d: Declared): Const;
34
54
  /** The type an enum stands for: the union of its members' literal types. */
package/dist/constants.js CHANGED
@@ -2,7 +2,8 @@ import { absence, blankComponent, BOOL, distinctFields, enumOf, enumTy, fail, FL
2
2
  import { GenError } from "./errors.js";
3
3
  import { aliasEnum, knownLiteral, matchOver, overVariants } from "./enums.js";
4
4
  const TYPES_HOME = blankComponent("types", "types", "types");
5
- import { ONE_NOTHING, reachType, tyOfTs } from "./typescript.js";
5
+ import { ONE_NOTHING, reachType, readTypeFile, tyOfTs } from "./typescript.js";
6
+ import { resolveImport } from "./resolve.js";
6
7
  import { ctx } from "./context.js";
7
8
  import { asF64, exactPower, numberVal } from "./numbers.js";
8
9
  import { describeTy, expr } from "./expr.js";
@@ -18,19 +19,100 @@ let enumScope = null;
18
19
  export function declareConsts(home, body) {
19
20
  const found = new Map();
20
21
  for (const st of body) {
21
- const exported = st.type === "ExportNamedDeclaration";
22
- const d = exported ? st.declaration : st;
22
+ const d = st.type === "ExportNamedDeclaration" ? st.declaration : st;
23
23
  if (d?.type === "TSEnumDeclaration")
24
- found.set(d.id.name, { node: d, home, exported });
24
+ found.set(d.id.name, { node: d, home });
25
25
  if (d?.type === "VariableDeclaration" && d.kind === "const") {
26
26
  for (const v of d.declarations)
27
27
  if (v.id.type === "Identifier" && v.init)
28
- found.set(v.id.name, { node: v, home, exported });
28
+ found.set(v.id.name, { node: v, home });
29
29
  }
30
30
  }
31
31
  files.set(home, found);
32
32
  return found;
33
33
  }
34
+ /** The names a `.ts` file exports: by `export const`, `export function`, `export enum`,
35
+ * `export { a, b as c }`, `export default a`, and from other files by `export { a } from` and `export * from`. */
36
+ export function exportsOf(rel, body) {
37
+ const names = new Map();
38
+ const stars = [];
39
+ const nameOf = (n) => n.name ?? n.value;
40
+ for (const st of body) {
41
+ if (st.type === "ExportDefaultDeclaration") {
42
+ const d = st.declaration;
43
+ names.set("default", { local: d.type === "Identifier" ? d.name : "default" });
44
+ }
45
+ if (st.exportKind === "type")
46
+ continue;
47
+ if (st.type === "ExportAllDeclaration") {
48
+ const file = resolveImport(rel, st.source.value);
49
+ if (st.exported)
50
+ names.set(nameOf(st.exported), { file: null, name: null });
51
+ else
52
+ stars.push(file);
53
+ }
54
+ if (st.type !== "ExportNamedDeclaration")
55
+ continue;
56
+ const d = st.declaration;
57
+ if (d?.type === "VariableDeclaration")
58
+ for (const v of d.declarations)
59
+ if (v.id.type === "Identifier")
60
+ names.set(v.id.name, { local: v.id.name });
61
+ if (d?.id?.type === "Identifier")
62
+ names.set(d.id.name, { local: d.id.name });
63
+ if (d)
64
+ continue;
65
+ const from = st.source ? resolveImport(rel, st.source.value) : undefined;
66
+ for (const sp of st.specifiers) {
67
+ if (sp.exportKind === "type")
68
+ continue;
69
+ const as = nameOf(sp.exported);
70
+ if (sp.type === "ExportNamespaceSpecifier")
71
+ names.set(as, { file: null, name: null });
72
+ else if (sp.type === "ExportSpecifier")
73
+ names.set(as, from === undefined ? { local: sp.local.name } : { file: from, name: nameOf(sp.local) });
74
+ }
75
+ }
76
+ return { names, stars };
77
+ }
78
+ /** The file and local name of what a `.ts` file exports as `name`, followed through `export … from`
79
+ * and `export * from`; `undefined` when nothing is exported by that name, or two `export *` give it. */
80
+ function exportOrigin(file, name, seen = new Set()) {
81
+ const key = `${file}\0${name}`;
82
+ if (seen.has(key))
83
+ return undefined;
84
+ seen.add(key);
85
+ readTypeFile(file);
86
+ const exports = ctx.tsModules.get(file)?.exports;
87
+ const e = exports?.names.get(name);
88
+ if (e !== undefined) {
89
+ if ("local" in e)
90
+ return { file, local: e.local };
91
+ return e.file === null || e.name === null ? undefined : exportOrigin(e.file, e.name, seen);
92
+ }
93
+ if (name === "default")
94
+ return undefined;
95
+ let found;
96
+ for (const star of exports?.stars ?? []) {
97
+ const o = star === null ? undefined : exportOrigin(star, name, new Set(seen));
98
+ if (o === undefined)
99
+ continue;
100
+ if (found !== undefined && (found.file !== o.file || found.local !== o.local))
101
+ return undefined;
102
+ found = o;
103
+ }
104
+ return found;
105
+ }
106
+ /** The constant or enum a `.ts` file exports as `name`. */
107
+ export function exportedConst(file, name) {
108
+ const o = exportOrigin(file, name);
109
+ return o === undefined ? undefined : ctx.constDecls.get(o.file)?.get(o.local);
110
+ }
111
+ /** The function a `.ts` file exports as `name`. */
112
+ export function exportedFunction(file, name) {
113
+ const o = exportOrigin(file, name);
114
+ return o === undefined ? undefined : ctx.tsModules.get(o.file)?.functions.get(o.local);
115
+ }
34
116
  /** What a declared constant or enum stands for, evaluated once. */
35
117
  export function constOfDecl(d) {
36
118
  const done = evaluated.get(d.node);
package/dist/context.d.ts CHANGED
@@ -1,6 +1,6 @@
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
- import type { Declared } from "./constants.ts";
3
+ import type { Declared, Exports } from "./constants.ts";
4
4
  import type { Alias } from "./compiler.ts";
5
5
  import type { TsProject } from "./resolve.ts";
6
6
  import { type Component, type EnumDef, type Fn, type N, type Struct, type Ty } from "./model.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>;
@@ -51,10 +52,8 @@ export declare const ctx: {
51
52
  tsModules: Map<string, {
52
53
  home: Component;
53
54
  body: N[];
54
- functions: Map<string, {
55
- fn: Fn;
56
- exported: boolean;
57
- }>;
55
+ functions: Map<string, Fn>;
56
+ exports: Exports;
58
57
  }>;
59
58
  typeConsts: Map<string, {
60
59
  of: 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
@@ -4,6 +4,7 @@ import { basename } from "node:path";
4
4
  import { camelize, directiveOwner, fail, isAttribute, literalsOf, placeOf, rustStr, templateAst } from "./model.js";
5
5
  import { GenError } from "./errors.js";
6
6
  import { ctx } from "./context.js";
7
+ import { exportedConst } from "./constants.js";
7
8
  import { matchOver } from "./enums.js";
8
9
  import { expr } from "./expr.js";
9
10
  import { cond, known } from "./narrowing.js";
@@ -142,7 +143,7 @@ function propType(s, name) {
142
143
  }
143
144
  function fieldLiterals(ty) {
144
145
  const literals = literalsOf(ty.k === "opt" ? ty.of : ty);
145
- 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;
146
147
  }
147
148
  function keysOf(s, n, at) {
148
149
  const name = propOf(s, n);
@@ -208,8 +209,8 @@ function tableOf(s, n) {
208
209
  if (!sp)
209
210
  continue;
210
211
  const file = resolveImport(s.comp.file, st.source.value);
211
- const d = file === null ? undefined : ctx.constDecls.get(file)?.get(sp.imported.name ?? sp.imported.value);
212
- if (!d?.exported || d.node.type !== "VariableDeclarator")
212
+ const d = file === null ? undefined : exportedConst(file, sp.imported.name ?? sp.imported.value);
213
+ if (!d || d.node.type !== "VariableDeclarator")
213
214
  return null;
214
215
  const entries = objectEntries(d.home, name, d.node.init);
215
216
  if (!entries)
@@ -5,10 +5,7 @@ export declare function functionOf(st: N): {
5
5
  node: N;
6
6
  } | null;
7
7
  /** The functions a `.ts` file declares at its top level, exported or not. */
8
- export declare function declareFunctions(home: Component, body: N[], file: string): Map<string, {
9
- fn: Fn;
10
- exported: boolean;
11
- }>;
8
+ export declare function declareFunctions(home: Component, body: N[], file: string): Map<string, Fn>;
12
9
  /** A call of a function the server translates: its arguments evaluated in order at the call, each
13
10
  * bound once, then its body, with JavaScript's defaults for what is not passed. */
14
11
  export declare function callFunction(s: Scope, name: string, fn: Fn, args: N[], n: N): Val;