@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.
- package/dist/commands/build-action.js +3 -0
- package/dist/commands/dev-action.d.ts +2 -0
- package/dist/commands/dev-action.js +12 -5
- package/dist/commands/dev.js +3 -1
- package/dist/commands/start-action.js +36 -5
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.js +3 -0
- package/dist/derived-outputs.d.ts +1 -5
- package/dist/derived-outputs.js +46 -33
- package/dist/dev-modules.d.ts +5 -0
- package/dist/dev-modules.js +88 -0
- package/dist/entry-weight.d.ts +24 -0
- package/dist/entry-weight.js +86 -0
- package/dist/help.js +5 -1
- package/dist/layer-watch.js +3 -2
- package/dist/layers.d.ts +1 -0
- package/dist/locale-files.d.ts +6 -0
- package/dist/locale-files.js +75 -0
- package/dist/manifest.js +2 -0
- package/dist/private-values.d.ts +1 -0
- package/dist/private-values.js +16 -0
- package/dist/project-modules-child.d.ts +13 -0
- package/dist/project-modules-child.js +26 -0
- package/dist/project-modules.d.ts +1 -0
- package/dist/project-modules.js +73 -38
- package/dist/workspace-setup.js +41 -3
- package/dist/workspace.d.ts +6 -0
- package/package.json +24 -7
- package/skills/dms-frontend/SKILL.md +1 -0
- package/templates/vue/app-runtime.ts +8 -1
- package/templates/vue/entry-weight.mjs +132 -0
- package/templates/vue/frontend-build-loader.ts +227 -9
- package/templates/vue/frontend-build.ts +34 -1
- package/templates/vue/frontend-module.ts +144 -1
- package/templates/vue/gitignore +1 -0
- package/templates/vue/main.ts +21 -0
- package/templates/vue/network-error.ts +3 -1
- package/templates/vue/package.json +1 -1
- package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
- package/templates/vue/pnpm-workspace.yaml +5 -0
- package/templates/vue/server/client-manifest.mjs +12 -0
- package/templates/vue/server/in-place-render.mjs +110 -0
- package/templates/vue/server/page-props.mjs +10 -0
- package/templates/vue/server/proxy-transfers.mjs +35 -9
- package/templates/vue/server.mjs +39 -29
- package/templates/vue/sfc-global-types.mjs +119 -0
- package/templates/vue/ssr-renderer.ts +8 -0
- package/templates/vue/ui-kbd.mjs +45 -0
- 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 {
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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);
|
package/templates/vue/gitignore
CHANGED
|
@@ -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/
|
package/templates/vue/main.ts
CHANGED
|
@@ -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)
|