@antelopejs/dms-frontend 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/commands/build-action.js +3 -0
  2. package/dist/commands/dev-action.d.ts +2 -0
  3. package/dist/commands/dev-action.js +12 -5
  4. package/dist/commands/dev.js +3 -1
  5. package/dist/commands/start-action.js +36 -5
  6. package/dist/common.d.ts +1 -0
  7. package/dist/common.js +1 -0
  8. package/dist/config.js +3 -0
  9. package/dist/derived-outputs.d.ts +1 -5
  10. package/dist/derived-outputs.js +46 -33
  11. package/dist/dev-modules.d.ts +5 -0
  12. package/dist/dev-modules.js +88 -0
  13. package/dist/entry-weight.d.ts +24 -0
  14. package/dist/entry-weight.js +86 -0
  15. package/dist/help.js +5 -1
  16. package/dist/layer-watch.js +3 -2
  17. package/dist/layers.d.ts +1 -0
  18. package/dist/locale-files.d.ts +6 -0
  19. package/dist/locale-files.js +75 -0
  20. package/dist/manifest.js +2 -0
  21. package/dist/private-values.d.ts +1 -0
  22. package/dist/private-values.js +16 -0
  23. package/dist/project-modules-child.d.ts +13 -0
  24. package/dist/project-modules-child.js +26 -0
  25. package/dist/project-modules.d.ts +1 -0
  26. package/dist/project-modules.js +73 -38
  27. package/dist/workspace-setup.js +41 -3
  28. package/dist/workspace.d.ts +6 -0
  29. package/package.json +24 -7
  30. package/skills/dms-frontend/SKILL.md +1 -0
  31. package/templates/vue/app-runtime.ts +8 -1
  32. package/templates/vue/entry-weight.mjs +132 -0
  33. package/templates/vue/frontend-build-loader.ts +227 -9
  34. package/templates/vue/frontend-build.ts +34 -1
  35. package/templates/vue/frontend-module.ts +144 -1
  36. package/templates/vue/gitignore +1 -0
  37. package/templates/vue/main.ts +21 -0
  38. package/templates/vue/network-error.ts +3 -1
  39. package/templates/vue/package.json +1 -1
  40. package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
  41. package/templates/vue/pnpm-workspace.yaml +5 -0
  42. package/templates/vue/server/client-manifest.mjs +12 -0
  43. package/templates/vue/server/in-place-render.mjs +110 -0
  44. package/templates/vue/server/page-props.mjs +10 -0
  45. package/templates/vue/server/proxy-transfers.mjs +35 -9
  46. package/templates/vue/server.mjs +39 -29
  47. package/templates/vue/sfc-global-types.mjs +119 -0
  48. package/templates/vue/ssr-renderer.ts +8 -0
  49. package/templates/vue/ui-kbd.mjs +45 -0
  50. package/templates/vue/vite.config.ts +34 -2
@@ -0,0 +1,132 @@
1
+ // What each frontend module adds to the client entry.
2
+ //
3
+ // Every page loads the entry, and everything it imports statically, before it
4
+ // runs anything: a module's `dms.frontend.ts`, its plugins and whatever they
5
+ // import land there, on every page of every application that loads the
6
+ // module. The client build records, from the bundle's own metadata, how much
7
+ // of the entry each module's sources make up; `ajs dms build` reports it and
8
+ // warns about a module above its threshold.
9
+ //
10
+ // Kept beside vite.config.ts rather than inside it so the plugin can be
11
+ // exercised on its own, the way client-chunking.mjs is.
12
+
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { dirname, join, relative, sep } from "node:path";
15
+
16
+ /** Where the client build writes the attribution, under `dist/client`. */
17
+ export const ENTRY_WEIGHT_FILE = ".vite/entry-weight.json";
18
+
19
+ /** Owners of the entry's bytes that are no frontend module. */
20
+ export const SHARED_OWNER = "shared";
21
+ export const TRANSLATIONS_OWNER = "translations";
22
+
23
+ const LOCALE_CATALOG = /[\\/]locales\.generated[\\/]/;
24
+
25
+ function stripQuery(id) {
26
+ const query = id.indexOf("?");
27
+ return query === -1 ? id : id.slice(0, query);
28
+ }
29
+
30
+ function isInside(root, file) {
31
+ const path = relative(root, file);
32
+ return Boolean(path) && !path.startsWith("..") && !path.startsWith(sep);
33
+ }
34
+
35
+ /**
36
+ * The owner of a module id of the bundle: the frontend module whose sources
37
+ * hold the file, by its manifest name; the merged translation catalogs; else
38
+ * what every page shares (the renderer's runtime, the UI and icon libraries,
39
+ * an installed package).
40
+ *
41
+ * @param {{ root: string, name?: string, packageName?: string, id: string }[]} modules
42
+ */
43
+ export function entryOwner(id, modules) {
44
+ if (id.startsWith("\0")) return SHARED_OWNER;
45
+ const file = stripQuery(id);
46
+ if (/[\\/]node_modules[\\/]/.test(file)) return SHARED_OWNER;
47
+ if (LOCALE_CATALOG.test(file)) return TRANSLATIONS_OWNER;
48
+ const module = modules.find(({ root }) => isInside(root, file));
49
+ return module
50
+ ? (module.name ?? module.packageName ?? module.id)
51
+ : SHARED_OWNER;
52
+ }
53
+
54
+ /**
55
+ * The files a page loads before its first line runs: the entry chunks and
56
+ * their static imports, transitively, by file name.
57
+ */
58
+ function entryClosure(bundle) {
59
+ const chunks = Object.values(bundle).filter(
60
+ (output) => output.type === "chunk",
61
+ );
62
+ const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
63
+ const seen = new Set();
64
+ const visit = (chunk) => {
65
+ if (!chunk || seen.has(chunk.fileName)) return;
66
+ seen.add(chunk.fileName);
67
+ for (const file of chunk.imports ?? []) visit(byName.get(file));
68
+ };
69
+ for (const chunk of chunks) if (chunk.isEntry) visit(chunk);
70
+ return [...seen].map((file) => byName.get(file));
71
+ }
72
+
73
+ /**
74
+ * The bytes of the entry each owner accounts for. Each chunk's bytes are
75
+ * shared out among the modules it holds in proportion to the code Rolldown
76
+ * rendered for each (`renderedLength`, before minification): an estimate that
77
+ * needs nothing the bundle does not already hold.
78
+ *
79
+ * @returns {{ files: string[], bytes: number, owners: Record<string, number> }}
80
+ */
81
+ export function attributeEntry(bundle, modules) {
82
+ const files = entryClosure(bundle);
83
+ const owners = {};
84
+ let bytes = 0;
85
+ for (const chunk of files) {
86
+ const size = Buffer.byteLength(chunk.code);
87
+ bytes += size;
88
+ const rendered = Object.entries(chunk.modules ?? {});
89
+ const total = rendered.reduce(
90
+ (sum, [, info]) => sum + info.renderedLength,
91
+ 0,
92
+ );
93
+ if (!total) {
94
+ owners[SHARED_OWNER] = (owners[SHARED_OWNER] ?? 0) + size;
95
+ continue;
96
+ }
97
+ for (const [id, info] of rendered) {
98
+ const owner = entryOwner(id, modules);
99
+ owners[owner] =
100
+ (owners[owner] ?? 0) + (size * info.renderedLength) / total;
101
+ }
102
+ }
103
+ for (const owner of Object.keys(owners))
104
+ owners[owner] = Math.round(owners[owner]);
105
+ return { files: files.map((chunk) => chunk.fileName), bytes, owners };
106
+ }
107
+
108
+ /**
109
+ * Records, in the client build, what each frontend module adds to the entry
110
+ * (`ENTRY_WEIGHT_FILE`). Server builds are left alone: their bundle is no
111
+ * page's entry.
112
+ */
113
+ export function recordEntryWeight(modules) {
114
+ let ssrBuild = false;
115
+ return {
116
+ name: "dms-entry-weight",
117
+ configResolved(config) {
118
+ ssrBuild = Boolean(config.build.ssr);
119
+ },
120
+ // Once the bundle is written: plugins that generate the bundle after this
121
+ // one still rewrite the chunks' code.
122
+ writeBundle(options, bundle) {
123
+ if (ssrBuild || !options.dir) return;
124
+ const file = join(options.dir, ENTRY_WEIGHT_FILE);
125
+ mkdirSync(dirname(file), { recursive: true });
126
+ writeFileSync(
127
+ file,
128
+ `${JSON.stringify(attributeEntry(bundle, modules), null, 2)}\n`,
129
+ );
130
+ },
131
+ };
132
+ }
@@ -1,6 +1,9 @@
1
1
  import { isAbsolute, posix, resolve } from "node:path";
2
- import { normalizePath } from "vite";
3
- import type { DmsFrontendBuildSetup } from "./frontend-build.ts";
2
+ import { normalizePath, type Plugin } from "vite";
3
+ import type {
4
+ DmsFrontendBuild,
5
+ DmsFrontendBuildSetup,
6
+ } from "./frontend-build.ts";
4
7
 
5
8
  /** A module's `dms.frontend.build.ts`, as the generated loader imports it. */
6
9
  export interface FrontendBuildDeclaration {
@@ -22,12 +25,31 @@ export interface AutoImportDirectory {
22
25
  /** A module's private code: never auto-imported, whatever it declares. */
23
26
  const PRIVATE_DIRECTORY = /(?:^|\/)app\/build(?:\/|$)/;
24
27
 
25
- function declaredDirectories(setup: DmsFrontendBuildSetup): string[] {
26
- const directories: string[] = [];
28
+ interface FrontendModuleName {
29
+ id: string;
30
+ name?: string;
31
+ }
32
+
33
+ type VitePluginFactory = Parameters<DmsFrontendBuild["registerVitePlugins"]>[0];
34
+
35
+ interface DeclaredBuild {
36
+ directories: string[];
37
+ pluginFactories: VitePluginFactory[];
38
+ }
39
+
40
+ /** Each setup runs once, whichever declaration is read first. */
41
+ const declarations = new WeakMap<DmsFrontendBuildSetup, DeclaredBuild>();
42
+
43
+ function declared(setup: DmsFrontendBuildSetup): DeclaredBuild {
44
+ const known = declarations.get(setup);
45
+ if (known) return known;
46
+ const build: DeclaredBuild = { directories: [], pluginFactories: [] };
27
47
  setup({
28
- registerAutoImports: (entries) => directories.push(...entries),
48
+ registerAutoImports: (entries) => build.directories.push(...entries),
49
+ registerVitePlugins: (create) => build.pluginFactories.push(create),
29
50
  });
30
- return directories;
51
+ declarations.set(setup, build);
52
+ return build;
31
53
  }
32
54
 
33
55
  function rejection(directory: string): string | undefined {
@@ -50,15 +72,15 @@ function rejection(directory: string): string | undefined {
50
72
  */
51
73
  export function autoImportDirectories(
52
74
  modules: readonly FrontendModuleRoot[],
53
- declarations: readonly FrontendBuildDeclaration[],
75
+ buildDeclarations: readonly FrontendBuildDeclaration[],
54
76
  warn: (message: string) => void,
55
77
  ): AutoImportDirectory[] {
56
78
  const roots = new Map(modules.map((module) => [module.id, module.root]));
57
- return declarations.flatMap(({ id, setup }) => {
79
+ return buildDeclarations.flatMap(({ id, setup }) => {
58
80
  const root = roots.get(id);
59
81
  if (root === undefined) return [];
60
82
  const scan = (pattern: string) => normalizePath(resolve(root, pattern));
61
- const accepted = declaredDirectories(setup).filter((directory) => {
83
+ const accepted = declared(setup).directories.filter((directory) => {
62
84
  const reason = rejection(directory);
63
85
  if (reason)
64
86
  warn(
@@ -76,3 +98,199 @@ export function autoImportDirectories(
76
98
  ];
77
99
  });
78
100
  }
101
+
102
+ /** The keys of a Vite plugin that hold a value, not a function Vite calls. */
103
+ const PLUGIN_PROPERTIES = new Set([
104
+ "name",
105
+ "enforce",
106
+ "apply",
107
+ "sharedDuringBuild",
108
+ "perEnvironmentState",
109
+ "api",
110
+ ]);
111
+
112
+ /** The hooks whose function result is itself a hook, run after the server starts. */
113
+ const SERVER_HOOKS = new Set(["configureServer", "configurePreviewServer"]);
114
+
115
+ const attributedErrors = new WeakSet<object>();
116
+
117
+ /** The fields of a Rollup or Vite error that diagnostics and the overlay read. */
118
+ const ERROR_FIELDS = [
119
+ "id",
120
+ "loc",
121
+ "frame",
122
+ "plugin",
123
+ "pluginCode",
124
+ "hook",
125
+ "code",
126
+ ] as const;
127
+
128
+ /**
129
+ * A new error naming the module, with the original as its cause: the original
130
+ * may be frozen. It carries the original's diagnostic fields and its stack
131
+ * frames, so the error still points at the file and the line.
132
+ */
133
+ function attribute(label: string, error: unknown): Error {
134
+ if (error instanceof Error && attributedErrors.has(error)) return error;
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ const attributed = new Error(`Frontend module ${label}: ${message}`, {
137
+ cause: error,
138
+ });
139
+ attributedErrors.add(attributed);
140
+ if (!(error instanceof Error)) return attributed;
141
+ for (const field of ERROR_FIELDS) {
142
+ if (field in error)
143
+ Reflect.set(attributed, field, Reflect.get(error, field));
144
+ }
145
+ const frames = error.stack?.split(error.message).slice(1).join(error.message);
146
+ if (frames !== undefined && error.stack?.includes(error.message)) {
147
+ attributed.stack = `${error.name}: ${attributed.message}${frames}`;
148
+ }
149
+ return attributed;
150
+ }
151
+
152
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
153
+ return (
154
+ typeof value === "object" &&
155
+ value !== null &&
156
+ "then" in value &&
157
+ typeof value.then === "function"
158
+ );
159
+ }
160
+
161
+ function attributedHook(
162
+ label: string,
163
+ hook: unknown,
164
+ returnsHook: boolean,
165
+ ): unknown {
166
+ if (typeof hook === "function") {
167
+ const attributedResult = (result: unknown): unknown =>
168
+ returnsHook ? attributedHook(label, result, false) : result;
169
+ return function (this: unknown, ...args: unknown[]) {
170
+ try {
171
+ const result: unknown = hook.apply(this, args);
172
+ if (isThenable(result)) {
173
+ return Promise.resolve(result).then(
174
+ attributedResult,
175
+ (error: unknown) => {
176
+ throw attribute(label, error);
177
+ },
178
+ );
179
+ }
180
+ return attributedResult(result);
181
+ } catch (error) {
182
+ throw attribute(label, error);
183
+ }
184
+ };
185
+ }
186
+ if (typeof hook === "object" && hook !== null && "handler" in hook) {
187
+ return {
188
+ ...hook,
189
+ handler: attributedHook(label, hook.handler, returnsHook),
190
+ };
191
+ }
192
+ return hook;
193
+ }
194
+
195
+ function isPlugin(value: unknown): value is Plugin {
196
+ return (
197
+ typeof value === "object" &&
198
+ value !== null &&
199
+ !Array.isArray(value) &&
200
+ "name" in value &&
201
+ typeof value.name === "string" &&
202
+ value.name !== ""
203
+ );
204
+ }
205
+
206
+ /** Every property name of an object, inherited ones included. */
207
+ function propertyNames(plugin: object): Set<string> {
208
+ const names = new Set<string>();
209
+ for (
210
+ let object: object | null = plugin;
211
+ object !== null && object !== Object.prototype;
212
+ object = Object.getPrototypeOf(object)
213
+ ) {
214
+ for (const name of Object.getOwnPropertyNames(object)) {
215
+ if (name !== "constructor") names.add(name);
216
+ }
217
+ }
218
+ return names;
219
+ }
220
+
221
+ /**
222
+ * A copy of the plugin whose hooks, own or inherited from its class, name the
223
+ * module in the errors they throw. The module's own object is left as it is.
224
+ */
225
+ function attributedPlugin(label: string, plugin: Plugin): Plugin {
226
+ const copy: Record<string, unknown> = {};
227
+ for (const key of propertyNames(plugin)) {
228
+ const value: unknown = Reflect.get(plugin, key);
229
+ copy[key] = PLUGIN_PROPERTIES.has(key)
230
+ ? value
231
+ : attributedHook(label, value, SERVER_HOOKS.has(key));
232
+ }
233
+ if (!isPlugin(copy)) throw invalidPlugins(label);
234
+ return copy;
235
+ }
236
+
237
+ function invalidPlugins(label: string): Error {
238
+ return new Error(
239
+ `Frontend module ${label} registered Vite plugins that are not valid: the function given to registerVitePlugins must return an array of Vite plugin objects, each with a name (false, null and undefined entries are skipped, nested arrays are flattened), or nothing.`,
240
+ );
241
+ }
242
+
243
+ function flattened(label: string, entries: readonly unknown[]): Plugin[] {
244
+ return entries.flatMap((entry): Plugin[] => {
245
+ if (entry === false || entry === null || entry === undefined) return [];
246
+ if (Array.isArray(entry)) return flattened(label, entry);
247
+ if (isThenable(entry)) throw promisedPlugins(label);
248
+ if (!isPlugin(entry)) throw invalidPlugins(label);
249
+ return [entry];
250
+ });
251
+ }
252
+
253
+ function promisedPlugins(label: string): Error {
254
+ return new Error(
255
+ `Frontend module ${label} registered Vite plugins as a Promise: Vite's configuration is built synchronously, so the function given to registerVitePlugins must return the plugins themselves, not a Promise.`,
256
+ );
257
+ }
258
+
259
+ function createdPlugins(label: string, create: VitePluginFactory): Plugin[] {
260
+ let created: unknown;
261
+ try {
262
+ created = create();
263
+ } catch (error) {
264
+ throw attribute(label, error);
265
+ }
266
+ if (created === undefined) return [];
267
+ if (isThenable(created)) throw promisedPlugins(label);
268
+ if (!Array.isArray(created)) throw invalidPlugins(label);
269
+ return flattened(label, created);
270
+ }
271
+
272
+ /**
273
+ * The Vite plugins the modules registered with `registerVitePlugins`, in module
274
+ * order and then call order. Each module's factories run here, in the build
275
+ * process. A factory that throws, returns a Promise, or returns anything but an
276
+ * array of plugin objects (or nothing) fails with a message naming the module,
277
+ * and so does an error thrown by a hook of one of its plugins. As in a Vite
278
+ * configuration, false, null and undefined entries are skipped and nested
279
+ * arrays are flattened.
280
+ */
281
+ export function vitePlugins(
282
+ modules: readonly FrontendModuleName[],
283
+ buildDeclarations: readonly FrontendBuildDeclaration[],
284
+ ): Plugin[] {
285
+ const names = new Map(
286
+ modules.map((module) => [module.id, module.name ?? module.id]),
287
+ );
288
+ return buildDeclarations.flatMap(({ id, setup }) => {
289
+ const label = names.get(id) ?? id;
290
+ return declared(setup).pluginFactories.flatMap((create) =>
291
+ createdPlugins(label, create).map((plugin) =>
292
+ attributedPlugin(label, plugin),
293
+ ),
294
+ );
295
+ });
296
+ }
@@ -1,3 +1,17 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ /**
4
+ * What a `registerVitePlugins` function may return, as in a Vite
5
+ * configuration's `plugins`, except that a Promise is refused: Vite's
6
+ * configuration is built synchronously.
7
+ */
8
+ export type DmsVitePluginEntry =
9
+ | Plugin
10
+ | false
11
+ | null
12
+ | undefined
13
+ | readonly DmsVitePluginEntry[];
14
+
1
15
  /**
2
16
  * What a frontend module's `dms.frontend.build.ts` declares about how the
3
17
  * application is built from it. Read when Vite starts, by `ajs dms dev` and
@@ -14,6 +28,24 @@ export interface DmsFrontendBuild {
14
28
  * holds the module's private code, which its own files import by path.
15
29
  */
16
30
  registerAutoImports(directories: readonly string[]): void;
31
+
32
+ /**
33
+ * Adds Vite plugins to the application's build, for `ajs dms dev`,
34
+ * `ajs dms build` and the preview alike.
35
+ *
36
+ * `create` runs in the build process each time Vite loads its configuration,
37
+ * and returns real Vite plugin objects (an array, each with a `name`; false,
38
+ * null and undefined entries are skipped and nested arrays flattened, as in a
39
+ * Vite configuration) or nothing. It is synchronous: a Promise is refused,
40
+ * because the configuration is built synchronously. The plugins come after
41
+ * the renderer's own plugins, in module order, then in the order of the
42
+ * calls. A value that is not an array of plugin objects, or an error thrown
43
+ * by `create` or by any hook of a plugin (the function `configureServer`
44
+ * returns included), fails Vite with a message that names the module.
45
+ */
46
+ registerVitePlugins(
47
+ create: () => readonly DmsVitePluginEntry[] | undefined,
48
+ ): void;
17
49
  }
18
50
 
19
51
  export type DmsFrontendBuildSetup = (build: DmsFrontendBuild) => void;
@@ -27,10 +59,11 @@ export type DmsFrontendBuildSetup = (build: DmsFrontendBuild) => void;
27
59
  *
28
60
  * export default defineDmsFrontendBuild((build) => {
29
61
  * build.registerAutoImports(["layers/*\/app/composables"]);
62
+ * build.registerVitePlugins(() => [boundaryPlugin()]);
30
63
  * });
31
64
  * ```
32
65
  *
33
- * A module without this file has nothing auto-imported. The file runs in
66
+ * A module without this file has nothing auto-imported and adds no plugin. The file runs in
34
67
  * Node.js when Vite starts, not in the application: it may import Node.js
35
68
  * built-ins, never the application's own code.
36
69
  */
@@ -12,7 +12,9 @@ import { defu } from "defu";
12
12
  import { type FetchOptions, ofetch } from "ofetch";
13
13
  import {
14
14
  type App,
15
+ type AppConfig,
15
16
  type Component,
17
+ type ComponentInternalInstance,
16
18
  type ComponentPublicInstance,
17
19
  type ComputedRef,
18
20
  camelize,
@@ -1853,6 +1855,9 @@ function startAsyncData<T>(
1853
1855
  const idle = entry.result.status.value === "idle";
1854
1856
  if (runtime.isServer) {
1855
1857
  if (!immediate || !onServer) return undefined;
1858
+ // A render that already answers a redirect throws its HTML away: what it
1859
+ // still has to read would reach nobody.
1860
+ if (runtime.serverRedirect) return undefined;
1856
1861
  if (idle && options.lazy) entry.schedule();
1857
1862
  else if (idle) void entry.load();
1858
1863
  const settled = entry.settled();
@@ -2488,6 +2493,35 @@ export function defineDmsPageMeta(meta: Record<string, unknown>): void {
2488
2493
  onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
2489
2494
  }
2490
2495
  function ignoreSettlement(): void {}
2496
+ /**
2497
+ * What Vue's development build passes `errorHandler` for an error thrown by a
2498
+ * component's `setup`, sync or async (production passes a link instead; it has
2499
+ * no warnings to scope either).
2500
+ */
2501
+ const SETUP_FUNCTION_ERROR = "setup function";
2502
+ /**
2503
+ * Whether a Vue warning comes from a component whose `setup` failed, or from
2504
+ * under it. Vue still renders such a component, against the setup state that
2505
+ * never came: in development, where a single-file component's template is
2506
+ * compiled apart from its setup, the template reads the missing bindings, and
2507
+ * every component it uses becomes `undefined` ("Invalid vnode type when
2508
+ * creating vnode: undefined", then "Component <Anonymous> is missing template
2509
+ * or render function" for the comment Vue renders in its place). The error
2510
+ * page replaces that output (a server renders the page again, the browser
2511
+ * swaps it), and the failure itself is already reported.
2512
+ *
2513
+ * Walks internal instances: a `<script setup>` component exposes itself, so
2514
+ * its children's `$parent` is an expose proxy, not the proxy `errorHandler`
2515
+ * received.
2516
+ */
2517
+ function withinFailedSetup(
2518
+ instance: ComponentInternalInstance | null,
2519
+ failedSetups: WeakSet<ComponentInternalInstance>,
2520
+ ): boolean {
2521
+ for (let current = instance; current; current = current.parent)
2522
+ if (failedSetups.has(current)) return true;
2523
+ return false;
2524
+ }
2491
2525
  /** Shows the error page in place of the page, keeping the first failure. */
2492
2526
  function failPage(runtime: DmsFrontendRuntime, reason: unknown): void {
2493
2527
  runtime.currentError.value ??=
@@ -2578,6 +2612,80 @@ export const DmsLink: Component = defineComponent({
2578
2612
  },
2579
2613
  });
2580
2614
 
2615
+ /** How a keyboard hint prints its modifiers: macOS symbols, or PC key names. */
2616
+ export type DmsKbdPlatform = "mac" | "other";
2617
+ /**
2618
+ * Where Nuxt UI's key hints read the platform: called in the setup of each
2619
+ * component that prints one, it returns the platform that component renders.
2620
+ * It must read on the server the platform it renders there, and keep it while
2621
+ * its caller hydrates, so the browser's first render matches the server's.
2622
+ */
2623
+ export type DmsKbdPlatformSource = () => Readonly<Ref<DmsKbdPlatform>>;
2624
+
2625
+ const KBD_PLATFORM_KEY: InjectionKey<DmsKbdPlatformSource> =
2626
+ Symbol("dms:kbd-platform");
2627
+ const MODIFIER_KBD_KEYS: Record<
2628
+ DmsKbdPlatform,
2629
+ Record<"meta" | "ctrl" | "alt", string>
2630
+ > = {
2631
+ mac: { meta: "⌘", ctrl: "⌃", alt: "⌥" },
2632
+ other: { meta: "Ctrl", ctrl: "Ctrl", alt: "Alt" },
2633
+ };
2634
+ const isModifierKbdKey = (value: string): value is "meta" | "ctrl" | "alt" =>
2635
+ value === "meta" || value === "ctrl" || value === "alt";
2636
+
2637
+ /**
2638
+ * Gives Nuxt UI's key hints (`UKbd`, the `kbds` of its menus, the dashboard
2639
+ * search button) the application's keyboard platform, so the server prints
2640
+ * `meta` as "⌘" or "Ctrl" from the first render instead of a blank the
2641
+ * browser fills in after mounting.
2642
+ */
2643
+ export function provideDmsKbdPlatform(
2644
+ app: App,
2645
+ source: DmsKbdPlatformSource,
2646
+ ): void {
2647
+ app.provide(KBD_PLATFORM_KEY, source);
2648
+ }
2649
+
2650
+ interface NuxtUiKbd {
2651
+ macOS: ComputedRef<unknown>;
2652
+ getKbdKey: (value?: string) => string | undefined;
2653
+ }
2654
+
2655
+ /**
2656
+ * Nuxt UI's `useKbd` as the DMS runs it (`ui-kbd.mjs` routes it here): a hint
2657
+ * renders the same on the server and in the hydrating browser. Nuxt UI prints
2658
+ * `meta`, `ctrl` and `alt` from a map shared by every hint and filled by the
2659
+ * first one's `onMounted`, so a hint hydrating after that (in an async
2660
+ * component) printed "⌘" over the server's blank. Each hint now keeps the
2661
+ * server's value until its own `onMounted`; with a platform provided
2662
+ * (`provideDmsKbdPlatform`), that value is the platform's from the start.
2663
+ *
2664
+ * @internal
2665
+ */
2666
+ export function useDmsKbd(
2667
+ shared: () => NuxtUiKbd,
2668
+ keys: Readonly<Record<string, string>>,
2669
+ ): NuxtUiKbd {
2670
+ const { macOS } = shared();
2671
+ const instance = getCurrentInstance();
2672
+ const source = instance ? inject(KBD_PLATFORM_KEY, null) : null;
2673
+ const platform = source ? source() : null;
2674
+ // A hydrating component's vnode is bound to its server node before setup.
2675
+ const hydrating = ref(!!instance?.vnode.el);
2676
+ if (hydrating.value) onMounted(() => (hydrating.value = false));
2677
+ return {
2678
+ macOS,
2679
+ getKbdKey(value) {
2680
+ if (!value) return undefined;
2681
+ if (!isModifierKbdKey(value)) return keys[value] || value;
2682
+ if (platform) return MODIFIER_KBD_KEYS[platform.value][value];
2683
+ if (typeof window === "undefined" || hydrating.value) return " ";
2684
+ return MODIFIER_KBD_KEYS[macOS.value ? "mac" : "other"][value];
2685
+ },
2686
+ };
2687
+ }
2688
+
2581
2689
  export const DmsClientOnly = defineComponent({
2582
2690
  name: "DmsClientOnly",
2583
2691
  setup(_, { slots }) {
@@ -2817,6 +2925,17 @@ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
2817
2925
  return readonly(useDmsRuntime().pageLoading);
2818
2926
  }
2819
2927
 
2928
+ /**
2929
+ * Whether the browser is hydrating the page the server rendered, whose first
2930
+ * render must match the server's HTML: from the start of the application to
2931
+ * the tick the page's `<Suspense>` resolves. False on the server, in a page
2932
+ * the server did not render, and after a client navigation. As Nuxt's
2933
+ * `nuxtApp.isHydrating`; read once, it is not reactive.
2934
+ */
2935
+ export function isDmsHydrating(): boolean {
2936
+ return useDmsRuntime().hydrating;
2937
+ }
2938
+
2820
2939
  /**
2821
2940
  * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
2822
2941
  * in step with it. Cleared before the boundary unmounts, so a page left while
@@ -3368,7 +3487,31 @@ export async function installDmsPlugins(
3368
3487
  if (browserRuntime === runtime) browserRuntime = undefined;
3369
3488
  });
3370
3489
  }
3371
- app.config.errorHandler = (reason) => failPage(runtime, reason);
3490
+ const failedSetups = new WeakSet<ComponentInternalInstance>();
3491
+ const warnOutsideFailedSetups: AppConfig["warnHandler"] = (
3492
+ message,
3493
+ instance,
3494
+ trace,
3495
+ ) => {
3496
+ // The comment Vue renders in place of an `undefined` component has no
3497
+ // public instance: the template rendering it is the one that failed.
3498
+ const source = instance?.$ ?? getCurrentInstance();
3499
+ if (!withinFailedSetup(source, failedSetups))
3500
+ console.warn(`[Vue warn]: ${message}${trace ? `\n${trace}` : ""}`);
3501
+ };
3502
+ app.config.errorHandler = (reason, instance, info) => {
3503
+ if (instance && info === SETUP_FUNCTION_ERROR) {
3504
+ failedSetups.add(instance.$);
3505
+ // From the first failure on only: until then Vue keeps its own output,
3506
+ // whose component trace shows the props. An app that already has a
3507
+ // handler keeps it. A server renders a single page per app; in the
3508
+ // browser the app outlives Inertia visits, so once a page has failed
3509
+ // the handler stays for the session and later warnings print as plain
3510
+ // text (no inspectable objects), still all of them.
3511
+ app.config.warnHandler ??= warnOutsideFailedSetups;
3512
+ }
3513
+ failPage(runtime, reason);
3514
+ };
3372
3515
  app.config.globalProperties.$dms = appContext;
3373
3516
  Reflect.set(app, "$dms", appContext);
3374
3517
  Reflect.set(app, "$i18n", i18n);
@@ -10,6 +10,7 @@ frontend-modules/**/i18n/locales/
10
10
  # CSS hot update on plain navigation.
11
11
  auto-imports.d.ts
12
12
  components.d.ts
13
+ sfc-global-types.generated.d.ts
13
14
  # Build output, which the email bundle watcher rewrites during development:
14
15
  # read as Tailwind sources, each rebuild would push a burst of CSS updates.
15
16
  dist/
@@ -60,6 +60,26 @@ if (import.meta.env.DEV) adoptViteDevStyles();
60
60
  // the generated server answers those queries from the installed collections.
61
61
  addAPIProvider("", sameOriginIconProvider(location.origin));
62
62
 
63
+ /**
64
+ * Shows the URL of the page the server sent, when it rendered a query the page
65
+ * completed in place of the one requested (`renderDocument` in server.mjs).
66
+ * Replaced before Inertia starts: Inertia records a first page whose URL is
67
+ * not the address bar's as a new history entry, and Back would then lead to
68
+ * the URL the page completes again.
69
+ */
70
+ function showServedUrl(url: string): void {
71
+ const served = new URL(url, location.href);
72
+ if (
73
+ served.pathname === location.pathname &&
74
+ served.search !== location.search
75
+ )
76
+ history.replaceState(
77
+ history.state,
78
+ "",
79
+ `${served.pathname}${served.search}${location.hash}`,
80
+ );
81
+ }
82
+
63
83
  /**
64
84
  * Sets the modules up, then starts the app.
65
85
  *
@@ -84,6 +104,7 @@ async function startDmsApp(): Promise<void> {
84
104
  createInertiaApp({
85
105
  resolve: resolveDmsInertiaPage,
86
106
  async setup({ App, el, props, plugin }) {
107
+ showServedUrl(props.initialPage.url);
87
108
  const root = { render: () => h(App, props) };
88
109
  const app = el.hasAttribute("data-server-rendered")
89
110
  ? createSSRApp(root)