@antelopejs/dms-frontend 0.9.1 → 0.9.3
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 +9 -1
- package/dist/cancellation.js +2 -1
- package/dist/config.js +11 -7
- package/dist/derived-outputs.js +6 -113
- package/dist/dev-modules.js +3 -2
- package/dist/discovery.js +19 -12
- package/dist/fs-sync.js +1 -2
- package/dist/internal/guards.d.ts +5 -0
- package/dist/internal/guards.js +22 -0
- package/dist/internal/locale-catalogs.d.ts +6 -0
- package/dist/internal/locale-catalogs.js +115 -0
- package/dist/layer-aliases.js +2 -1
- package/dist/layers.js +2 -1
- package/dist/locale-files.js +9 -4
- package/dist/manifest-secrets.js +11 -4
- package/dist/manifest.js +11 -5
- package/dist/materialize.js +6 -5
- package/dist/module-set.js +2 -4
- package/dist/ports.js +8 -1
- package/dist/private-values.js +26 -17
- package/dist/project-modules.js +17 -11
- package/dist/renderer-range.js +2 -1
- package/dist/server-process.js +17 -16
- package/dist/update-check.js +23 -16
- package/dist/verify-source-runner.js +2 -2
- package/dist/workspace-setup.js +1 -2
- package/dist/workspace.js +11 -10
- package/package.json +7 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/skills/dms-frontend/cases.json +77 -0
- package/templates/vue/app-runtime.ts +88 -7
- package/templates/vue/client-chunking.mjs +0 -5
- package/templates/vue/client-server-guard.mjs +10 -13
- package/templates/vue/entry-weight.mjs +12 -14
- package/templates/vue/frontend-module.ts +159 -3563
- package/templates/vue/frontend-runtime/async-data-entry.ts +320 -0
- package/templates/vue/frontend-runtime/async-data.ts +404 -0
- package/templates/vue/frontend-runtime/components.ts +386 -0
- package/templates/vue/frontend-runtime/cookies.ts +125 -0
- package/templates/vue/frontend-runtime/data-access.ts +132 -0
- package/templates/vue/frontend-runtime/dms-error.ts +11 -0
- package/templates/vue/frontend-runtime/errors.ts +145 -0
- package/templates/vue/frontend-runtime/fetch.ts +306 -0
- package/templates/vue/frontend-runtime/module-registration.ts +247 -0
- package/templates/vue/frontend-runtime/paged-data.ts +191 -0
- package/templates/vue/frontend-runtime/payload.ts +172 -0
- package/templates/vue/frontend-runtime/route.ts +328 -0
- package/templates/vue/frontend-runtime/sdk.ts +300 -0
- package/templates/vue/frontend-runtime/state.ts +167 -0
- package/templates/vue/frontend-runtime/types.ts +361 -0
- package/templates/vue/frontend-runtime/ui.ts +247 -0
- package/templates/vue/globals.d.ts +5 -0
- package/templates/vue/head-order.mjs +11 -13
- package/templates/vue/icon-bundle.ts +31 -0
- package/templates/vue/message-subset.ts +65 -0
- package/templates/vue/network-error.ts +4 -0
- package/templates/vue/prepare-types.mjs +0 -8
- package/templates/vue/server/auth/backend.mjs +4 -2
- package/templates/vue/server/auth/establish-endpoints.mjs +14 -9
- package/templates/vue/server/auth/session.mjs +4 -3
- package/templates/vue/server/auth-hooks.mjs +6 -6
- package/templates/vue/server/backend-proxies.mjs +6 -6
- package/templates/vue/server/backend-response.mjs +15 -14
- package/templates/vue/server/built-files.mjs +10 -8
- package/templates/vue/server/cli-report.mjs +7 -5
- package/templates/vue/server/client-error.mjs +11 -8
- package/templates/vue/server/client-manifest.mjs +9 -8
- package/templates/vue/server/content.mjs +2 -1
- package/templates/vue/server/dev-private-files.mjs +20 -15
- package/templates/vue/server/dev-styles.mjs +22 -25
- package/templates/vue/server/email-bundle-watch.mjs +0 -12
- package/templates/vue/server/email.mjs +20 -15
- package/templates/vue/server/favicon.mjs +12 -11
- package/templates/vue/server/homepage.mjs +8 -10
- package/templates/vue/server/icons.mjs +18 -9
- package/templates/vue/server/in-place-render.mjs +6 -8
- package/templates/vue/server/listen-host.mjs +2 -2
- package/templates/vue/server/module-event.mjs +5 -5
- package/templates/vue/server/module-routes.mjs +11 -7
- package/templates/vue/server/module-server.mjs +8 -7
- package/templates/vue/server/module-testing.mjs +7 -7
- package/templates/vue/server/page-props.mjs +14 -14
- package/templates/vue/server/path-patterns.mjs +4 -4
- package/templates/vue/server/proxy-compression.mjs +21 -13
- package/templates/vue/server/proxy-headers.mjs +5 -3
- package/templates/vue/server/proxy-redirects.mjs +7 -6
- package/templates/vue/server/proxy-transfers.mjs +15 -11
- package/templates/vue/server/public-endpoints.mjs +27 -19
- package/templates/vue/server/public-options.mjs +9 -7
- package/templates/vue/server/request-failure.mjs +4 -6
- package/templates/vue/server/request-routing.mjs +8 -11
- package/templates/vue/server/request-target.mjs +6 -6
- package/templates/vue/server/route-rules.mjs +7 -5
- package/templates/vue/server/static-files.mjs +19 -10
- package/templates/vue/server/web-body.mjs +3 -4
- package/templates/vue/server.mjs +32 -19
- package/templates/vue/sfc-global-types.mjs +27 -24
- package/templates/vue/ssr-renderer.ts +10 -1
- package/templates/vue/ui-inertia-page.mjs +7 -7
- package/templates/vue/ui-kbd.mjs +10 -10
- package/templates/vue/ui-lazy-icons.mjs +44 -0
- package/templates/vue/ui-request-state.mjs +9 -9
- package/templates/vue/vite.config.ts +2 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { router as inertiaRouter } from "@inertiajs/vue3";
|
|
2
|
+
import {
|
|
3
|
+
type App,
|
|
4
|
+
type Component,
|
|
5
|
+
type ComponentPublicInstance,
|
|
6
|
+
type EffectScope,
|
|
7
|
+
getCurrentScope,
|
|
8
|
+
} from "vue";
|
|
9
|
+
import {
|
|
10
|
+
type DmsAsyncComponent,
|
|
11
|
+
type DmsComponentRegistration,
|
|
12
|
+
type DmsFrontendEntry,
|
|
13
|
+
type DmsFrontendRuntime,
|
|
14
|
+
} from "./module-registration";
|
|
15
|
+
import { registry, useDmsRuntime } from "./state";
|
|
16
|
+
import {
|
|
17
|
+
type DmsPageLayout,
|
|
18
|
+
type DmsPageProps,
|
|
19
|
+
type DmsSession,
|
|
20
|
+
type DmsSharedPagePayload,
|
|
21
|
+
type DmsUser,
|
|
22
|
+
} from "./types";
|
|
23
|
+
import { useDmsState } from "./payload";
|
|
24
|
+
import { updateRoute } from "./route";
|
|
25
|
+
import { dropUnusedAsyncData } from "./data-access";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The key a component name is registered and resolved under: case, a leading
|
|
29
|
+
* `lazy` and every non-alphanumeric character ignored, so PascalCase,
|
|
30
|
+
* kebab-case and lowercase spellings match. Unlike `normalizeDmsName`, a
|
|
31
|
+
* leading `Dms` is kept: it is the DMS module's prefix, and dropping it would
|
|
32
|
+
* make `DmsBanner` and another module's `Banner` the same component.
|
|
33
|
+
*/
|
|
34
|
+
function normalizeDmsComponentName(name: string): string {
|
|
35
|
+
return name
|
|
36
|
+
.replace(/^lazy/i, "")
|
|
37
|
+
.replace(/[^a-z0-9]/gi, "")
|
|
38
|
+
.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function moduleLabel(module: string | undefined): string {
|
|
42
|
+
return module ? `module "${module}"` : "an unnamed module";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A component registered under a name already taken is ignored: the first
|
|
47
|
+
* registration, from the module of higher priority, keeps the name. That is
|
|
48
|
+
* how a module replaces another's component, by registering its full name
|
|
49
|
+
* without a prefix, and only a collision neither side asked for is reported.
|
|
50
|
+
*/
|
|
51
|
+
export function registerDmsComponentEntry(
|
|
52
|
+
entries: Map<string, DmsComponentRegistration>,
|
|
53
|
+
registration: DmsComponentRegistration,
|
|
54
|
+
): void {
|
|
55
|
+
const key = normalizeDmsComponentName(registration.name);
|
|
56
|
+
const existing = entries.get(key);
|
|
57
|
+
if (!existing) {
|
|
58
|
+
entries.set(key, registration);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (existing.unprefixed || registration.unprefixed) return;
|
|
62
|
+
console.warn(
|
|
63
|
+
`[dms] Component "${registration.name}" from ${moduleLabel(registration.module)} is ignored: ` +
|
|
64
|
+
`${moduleLabel(existing.module)} already registers "${existing.name}". ` +
|
|
65
|
+
"Give the modules different component prefixes.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
/** Registers a component unless a higher-priority module already owns its name. */
|
|
69
|
+
export function registerDmsComponent(name: string, component: Component): void {
|
|
70
|
+
registerDmsComponentEntry(registry.components, { name, component });
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolves a registered component by its full name, prefix included. Case,
|
|
74
|
+
* a leading `lazy` and non-alphanumeric characters are ignored.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveDmsComponent(name: string): Component | undefined {
|
|
77
|
+
return registry.components.get(normalizeDmsComponentName(name))?.component;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Records, by registered name, every registered async component a server
|
|
82
|
+
* render reaches, so the client can resolve the same ones before hydrating.
|
|
83
|
+
* Inertia re-renders the tree once on boot; an async component still loading
|
|
84
|
+
* at that moment makes Vue drop its server-rendered markup ("Skipping lazy
|
|
85
|
+
* hydration") and leave it empty until its chunk arrives.
|
|
86
|
+
*/
|
|
87
|
+
export function trackDmsAsyncComponents(app: App): Set<string> {
|
|
88
|
+
const names = new Map<Component, string>();
|
|
89
|
+
registry.components.forEach(({ component, name }) => {
|
|
90
|
+
if ((component as DmsAsyncComponent).__asyncLoader)
|
|
91
|
+
names.set(component, name);
|
|
92
|
+
});
|
|
93
|
+
const rendered = new Set<string>();
|
|
94
|
+
app.mixin({
|
|
95
|
+
beforeCreate(this: ComponentPublicInstance) {
|
|
96
|
+
const name = names.get(this.$.type as Component);
|
|
97
|
+
if (name) rendered.add(name);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
return rendered;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Records the scope of every component a server render creates, and returns
|
|
105
|
+
* the function that stops them once the render is over.
|
|
106
|
+
*
|
|
107
|
+
* A server app is never mounted, so it is never unmounted either: Vue leaves
|
|
108
|
+
* the scope of each component it rendered running, and nothing handed to
|
|
109
|
+
* `onScopeDispose` is ever released. Nuxt UI's `useRuntimeHook` registers in
|
|
110
|
+
* a process-wide hook registry and only unregisters there, so every server
|
|
111
|
+
* render of the dashboard sidebar stayed reachable, its whole component tree
|
|
112
|
+
* and page props with it, until the server ran out of heap.
|
|
113
|
+
*
|
|
114
|
+
* The component's scope is read through `getCurrentScope`: Vue makes it the
|
|
115
|
+
* active scope before it calls `beforeCreate`, and the instance's own `scope`
|
|
116
|
+
* field is internal, absent from the typings a generated workspace checks.
|
|
117
|
+
*/
|
|
118
|
+
export function trackDmsServerScopes(app: App): () => void {
|
|
119
|
+
const scopes: EffectScope[] = [];
|
|
120
|
+
app.mixin({
|
|
121
|
+
beforeCreate() {
|
|
122
|
+
const scope = getCurrentScope();
|
|
123
|
+
if (scope) scopes.push(scope);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return () => {
|
|
127
|
+
for (const scope of scopes.splice(0)) scope.stop();
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function resolveDmsAsyncComponents(
|
|
132
|
+
names: readonly string[],
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await Promise.all(
|
|
135
|
+
names.map((name) =>
|
|
136
|
+
(
|
|
137
|
+
resolveDmsComponent(name) as DmsAsyncComponent | undefined
|
|
138
|
+
)?.__asyncLoader?.(),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
export async function preloadDmsComponents(names: string[]): Promise<void> {
|
|
143
|
+
const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
|
|
144
|
+
await Promise.all(
|
|
145
|
+
names.map((name) =>
|
|
146
|
+
(registry[name] as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
export function normalizeDmsName(name: string): string {
|
|
151
|
+
return name
|
|
152
|
+
.replace(/^lazy/i, "")
|
|
153
|
+
.replace(/^dms[-_]?/i, "")
|
|
154
|
+
.replace(/[^a-z0-9]/gi, "")
|
|
155
|
+
.toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function collectRenderedComponentNames(
|
|
159
|
+
value: unknown,
|
|
160
|
+
names: Set<string>,
|
|
161
|
+
): void {
|
|
162
|
+
if (!value || typeof value !== "object") return;
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
value.forEach((entry) => {
|
|
165
|
+
collectRenderedComponentNames(entry, names);
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const definition = value as Record<string, unknown>;
|
|
170
|
+
for (const key of ["component", "componentName"]) {
|
|
171
|
+
if (typeof definition[key] === "string") names.add(definition[key]);
|
|
172
|
+
}
|
|
173
|
+
// A child places a component definition: `{ id, component: { … } }`.
|
|
174
|
+
if (typeof definition.component === "object")
|
|
175
|
+
collectRenderedComponentNames(definition.component, names);
|
|
176
|
+
collectRenderedComponentNames(definition.children, names);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function preloadDmsLayoutComponents(
|
|
180
|
+
layout: DmsPageLayout | undefined,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const names = new Set<string>();
|
|
183
|
+
collectRenderedComponentNames(layout?.layout, names);
|
|
184
|
+
Object.values(layout?.components ?? {}).forEach((component) => {
|
|
185
|
+
collectRenderedComponentNames(component, names);
|
|
186
|
+
});
|
|
187
|
+
await Promise.all(
|
|
188
|
+
[...names].map((name) => {
|
|
189
|
+
const component = resolveDmsComponent(name) as
|
|
190
|
+
| DmsAsyncComponent
|
|
191
|
+
| undefined;
|
|
192
|
+
return component?.__asyncLoader?.();
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function normalizeDmsPageKey(name: string): string {
|
|
198
|
+
return name.split(/[?#]/, 1)[0].replace(/^\/+|\/+$/g, "");
|
|
199
|
+
}
|
|
200
|
+
const CATCH_ALL_PAGE_KEY = "[...slug]";
|
|
201
|
+
|
|
202
|
+
function findDmsPageEntry(props: DmsPageProps): DmsFrontendEntry | undefined {
|
|
203
|
+
const name = [
|
|
204
|
+
props.page.componentName,
|
|
205
|
+
props.page.route?.fullSlug,
|
|
206
|
+
props.path,
|
|
207
|
+
].find(
|
|
208
|
+
(candidate) =>
|
|
209
|
+
candidate && registry.pages.has(normalizeDmsPageKey(candidate)),
|
|
210
|
+
);
|
|
211
|
+
return name
|
|
212
|
+
? registry.pages.get(normalizeDmsPageKey(name))
|
|
213
|
+
: registry.dynamicPages.get(CATCH_ALL_PAGE_KEY);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The path a frontend module registered for a typed backend refusal. */
|
|
217
|
+
export function resolveDmsAccessRedirect(code: string): string | undefined {
|
|
218
|
+
return registry.accessRedirects.get(code);
|
|
219
|
+
}
|
|
220
|
+
export function hasDmsPage(name: string): boolean {
|
|
221
|
+
return registry.pages.has(normalizeDmsPageKey(name));
|
|
222
|
+
}
|
|
223
|
+
export function getDmsPage(props: DmsPageProps): Component | undefined {
|
|
224
|
+
if (props.error) return registry.errorPage?.component;
|
|
225
|
+
return findDmsPageEntry(props)?.component;
|
|
226
|
+
}
|
|
227
|
+
export function getDmsDynamicPage(name = "default"): Component | undefined {
|
|
228
|
+
return registry.dynamicPages.get(name)?.component;
|
|
229
|
+
}
|
|
230
|
+
export function getDmsLayout(props: DmsPageProps): Component | undefined {
|
|
231
|
+
const layout = props.page.layout;
|
|
232
|
+
const name = layout?.layout?.componentName ?? layout?.componentName;
|
|
233
|
+
return name
|
|
234
|
+
? registry.layouts.get(normalizeDmsName(name))?.component
|
|
235
|
+
: undefined;
|
|
236
|
+
}
|
|
237
|
+
export function getDmsLayoutProps(
|
|
238
|
+
props: DmsPageProps,
|
|
239
|
+
): Record<string, unknown> {
|
|
240
|
+
const definition = props.page.layout?.layout;
|
|
241
|
+
return {
|
|
242
|
+
...(definition?.options ?? {}),
|
|
243
|
+
icon: props.page.route?.icon,
|
|
244
|
+
title: props.page.route?.displayName,
|
|
245
|
+
description: props.page.route?.description,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether a page carries an older session of the same account than the one
|
|
251
|
+
* the app holds: Inertia restores a page from history, or serves it from the
|
|
252
|
+
* prefetch cache, with the props it was fetched with, and those must not undo
|
|
253
|
+
* a later `useDmsUserSession().fetch()`.
|
|
254
|
+
*/
|
|
255
|
+
function isStaleSession(
|
|
256
|
+
incoming: DmsSession | undefined,
|
|
257
|
+
current: DmsSession | null,
|
|
258
|
+
): boolean {
|
|
259
|
+
return (
|
|
260
|
+
!!incoming &&
|
|
261
|
+
!!current &&
|
|
262
|
+
incoming.accountId === current.accountId &&
|
|
263
|
+
(incoming.updatedAt ?? 0) < (current.updatedAt ?? 0)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Copies the site layout of the page props into the state. A visit carries
|
|
269
|
+
* the layout it was sent over from page to page (a once prop): the same copy
|
|
270
|
+
* is applied once, so the state keeps what a later refresh wrote into it, and
|
|
271
|
+
* `dms-lastRefreshTime` tells when the layout was last received.
|
|
272
|
+
*/
|
|
273
|
+
export function hydrateDmsSharedProps(
|
|
274
|
+
shared: DmsSharedPagePayload | undefined,
|
|
275
|
+
): void {
|
|
276
|
+
const runtime = useDmsRuntime();
|
|
277
|
+
if (!shared || shared === runtime.appliedShared) return;
|
|
278
|
+
// A page from the history carries the layout it was shown with, which the
|
|
279
|
+
// state has replaced since: see `keepSharedOverHistory`.
|
|
280
|
+
if (runtime.restoringHistory) return;
|
|
281
|
+
runtime.appliedShared = shared;
|
|
282
|
+
useDmsState<unknown>("dms-siteLayout", () => undefined).value =
|
|
283
|
+
shared.siteLayout;
|
|
284
|
+
useDmsState<unknown>("dms-pageTree", () => undefined).value =
|
|
285
|
+
shared.siteLayoutTree;
|
|
286
|
+
useDmsState<unknown>("dms-quickActions", () => undefined).value =
|
|
287
|
+
shared.quickActions;
|
|
288
|
+
useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
|
|
289
|
+
useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
|
|
290
|
+
useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Keeps the newest site layout when Back or Forward puts a page back. The
|
|
295
|
+
* page carries the layout it was shown with, which a later reload replaced
|
|
296
|
+
* (a menu change the backend announced); it is not copied into the state, and
|
|
297
|
+
* the page is given the current one, so the next visit carries that over
|
|
298
|
+
* rather than the older one.
|
|
299
|
+
*/
|
|
300
|
+
export function keepSharedOverHistory(): () => void {
|
|
301
|
+
const runtime = useDmsRuntime();
|
|
302
|
+
const onPopstate = (event: PopStateEvent) => {
|
|
303
|
+
// Inertia restores only the entries it wrote, which hold a page.
|
|
304
|
+
if ((event.state as { page?: unknown } | null)?.page)
|
|
305
|
+
runtime.restoringHistory = true;
|
|
306
|
+
};
|
|
307
|
+
window.addEventListener("popstate", onPopstate);
|
|
308
|
+
const stopListening = inertiaRouter.on("navigate", (event) => {
|
|
309
|
+
if (!runtime.restoringHistory) return;
|
|
310
|
+
runtime.restoringHistory = false;
|
|
311
|
+
const current = runtime.appliedShared;
|
|
312
|
+
const restored = (event.detail.page.props as Partial<DmsPageProps>).shared;
|
|
313
|
+
if (current && restored !== current)
|
|
314
|
+
inertiaRouter.replaceProp("shared", current);
|
|
315
|
+
});
|
|
316
|
+
return () => {
|
|
317
|
+
window.removeEventListener("popstate", onPopstate);
|
|
318
|
+
stopListening();
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
323
|
+
const runtime = useDmsRuntime();
|
|
324
|
+
if (url) updateRoute(runtime, url);
|
|
325
|
+
// Pages are keyed by path: another path mounts another page, which declares
|
|
326
|
+
// its own meta, and so does an error page shown in place of the page. A
|
|
327
|
+
// visit that only changes the query keeps the page, and so its meta.
|
|
328
|
+
const pageKey = props.error ? `error:${props.path}` : props.path;
|
|
329
|
+
if (pageKey !== runtime.pageKey) {
|
|
330
|
+
runtime.pageKey = pageKey;
|
|
331
|
+
runtime.route.meta = {};
|
|
332
|
+
}
|
|
333
|
+
dropUnusedAsyncData(runtime);
|
|
334
|
+
runtime.currentError.value = null;
|
|
335
|
+
const session = useDmsState<DmsSession | null>("dms-session", () => null);
|
|
336
|
+
if (!isStaleSession(props.session, session.value)) {
|
|
337
|
+
useDmsState<DmsUser | null>("dms-user", () => null).value =
|
|
338
|
+
props.user ?? null;
|
|
339
|
+
session.value = props.session ?? null;
|
|
340
|
+
}
|
|
341
|
+
hydrateDmsSharedProps(props.shared);
|
|
342
|
+
const layoutUrl = props.page.route?.layoutUrl ?? props.page.layoutUrl;
|
|
343
|
+
const pageLayouts = useDmsState<Record<string, DmsPageLayout>>(
|
|
344
|
+
"dms-pageLayouts",
|
|
345
|
+
() => ({}),
|
|
346
|
+
);
|
|
347
|
+
// Each navigation is a new authorization snapshot. Never retain layouts
|
|
348
|
+
// from an earlier account, tenant, or permission context.
|
|
349
|
+
pageLayouts.value =
|
|
350
|
+
layoutUrl && props.page.layout ? { [layoutUrl]: props.page.layout } : {};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Ends the hydration of the page the server rendered. The server's data no
|
|
354
|
+
* component took over is dropped: a later page must not show it as current.
|
|
355
|
+
*/
|
|
356
|
+
export function endHydration(runtime: DmsFrontendRuntime): void {
|
|
357
|
+
runtime.hydrating = false;
|
|
358
|
+
runtime.hydratedAsyncData.clear();
|
|
359
|
+
runtime.hydratedAsyncErrors.clear();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function getDmsErrorPage(): Component | undefined {
|
|
363
|
+
return registry.errorPage?.component;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
|
|
367
|
+
const layoutName =
|
|
368
|
+
props.page.layout?.layout?.componentName ??
|
|
369
|
+
props.page.layout?.componentName;
|
|
370
|
+
const page = findDmsPageEntry(props) ?? registry.dynamicPages.get("default");
|
|
371
|
+
const layout = layoutName
|
|
372
|
+
? registry.layouts.get(normalizeDmsName(layoutName))
|
|
373
|
+
: undefined;
|
|
374
|
+
const entries = props.error ? [registry.errorPage] : [page, layout];
|
|
375
|
+
// `preload` only warms the module; the async wrapper stays unresolved until
|
|
376
|
+
// its own loader runs. Hydrating an unresolved wrapper defers it, and the
|
|
377
|
+
// first parent render (Inertia's initial swap) then makes Vue drop the
|
|
378
|
+
// server-rendered markup and render the subtree from scratch.
|
|
379
|
+
await Promise.all(
|
|
380
|
+
entries.flatMap((entry) => [
|
|
381
|
+
entry?.preload?.(),
|
|
382
|
+
(entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
|
|
383
|
+
]),
|
|
384
|
+
);
|
|
385
|
+
if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
|
|
386
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { computed, type Ref, ref } from "vue";
|
|
2
|
+
import { type DmsCookieOptions } from "./types";
|
|
3
|
+
import { type DmsFrontendRuntime } from "./module-registration";
|
|
4
|
+
import { useDmsRuntime } from "./state";
|
|
5
|
+
|
|
6
|
+
const COOKIE_STATE_PREFIX = "dms-cookie:";
|
|
7
|
+
// A browser tab runs one application, so its cookie refs are shared by every
|
|
8
|
+
// caller; a server shares them within the request it renders instead.
|
|
9
|
+
const browserCookieRefs = new Map<string, Ref<unknown>>();
|
|
10
|
+
|
|
11
|
+
function readCookie(
|
|
12
|
+
source: string | undefined,
|
|
13
|
+
name: string,
|
|
14
|
+
): string | undefined {
|
|
15
|
+
const match = source
|
|
16
|
+
?.split(/;\s*/)
|
|
17
|
+
.find((entry) => entry.startsWith(`${name}=`));
|
|
18
|
+
return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseCookieValue<T>(
|
|
22
|
+
stored: string | undefined,
|
|
23
|
+
options: DmsCookieOptions<T>,
|
|
24
|
+
): T {
|
|
25
|
+
if (stored === undefined) return (options.default?.() ?? null) as T;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(stored) as T;
|
|
28
|
+
} catch {
|
|
29
|
+
return stored as T;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeCookie<T>(
|
|
34
|
+
name: string,
|
|
35
|
+
next: T,
|
|
36
|
+
options: DmsCookieOptions<T>,
|
|
37
|
+
) {
|
|
38
|
+
const attributes = [
|
|
39
|
+
`path=${options.path ?? "/"}`,
|
|
40
|
+
options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
|
|
41
|
+
options.sameSite ? `samesite=${options.sameSite}` : "",
|
|
42
|
+
options.secure ? "secure" : "",
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
// biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
|
|
45
|
+
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createCookieRef<T>(
|
|
49
|
+
name: string,
|
|
50
|
+
source: string | undefined,
|
|
51
|
+
options: DmsCookieOptions<T>,
|
|
52
|
+
isServer: boolean,
|
|
53
|
+
): Ref<T> {
|
|
54
|
+
const value = ref(
|
|
55
|
+
parseCookieValue(readCookie(source, name), options),
|
|
56
|
+
) as Ref<T>;
|
|
57
|
+
return computed({
|
|
58
|
+
get: () => value.value,
|
|
59
|
+
set: (next) => {
|
|
60
|
+
value.value = next;
|
|
61
|
+
if (!isServer) writeCookie(name, next, options);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The language a visitor picks, kept by the browser so that a reload renders
|
|
67
|
+
// in it. A signed-in user's own language still comes first.
|
|
68
|
+
export const LOCALE_COOKIE = "dms_locale";
|
|
69
|
+
export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The language this browser picked: read from `document.cookie`, or in a
|
|
73
|
+
* server render from the request. Only one of `supportedLocales` counts.
|
|
74
|
+
*/
|
|
75
|
+
export function readDmsLocaleCookie(
|
|
76
|
+
runtime: DmsFrontendRuntime,
|
|
77
|
+
supportedLocales: string[],
|
|
78
|
+
): string | undefined {
|
|
79
|
+
const source =
|
|
80
|
+
typeof document === "undefined" ? runtime.requestCookies : document.cookie;
|
|
81
|
+
try {
|
|
82
|
+
const locale = parseCookieValue<unknown>(
|
|
83
|
+
readCookie(source, LOCALE_COOKIE),
|
|
84
|
+
{},
|
|
85
|
+
);
|
|
86
|
+
return typeof locale === "string" && supportedLocales.includes(locale)
|
|
87
|
+
? locale
|
|
88
|
+
: undefined;
|
|
89
|
+
} catch {
|
|
90
|
+
// A value that is not URI-encoded: as if the browser had picked none.
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A cookie as a ref, shared by every caller of the same name — so a plugin and
|
|
97
|
+
* a page reading one preference see each other's writes — and read on the
|
|
98
|
+
* server from the request being rendered, so the server HTML matches the client.
|
|
99
|
+
*/
|
|
100
|
+
export function useDmsCookie<T = string | null>(
|
|
101
|
+
name: string,
|
|
102
|
+
options: DmsCookieOptions<T> = {},
|
|
103
|
+
): Ref<T> {
|
|
104
|
+
if (typeof document !== "undefined") {
|
|
105
|
+
if (!browserCookieRefs.has(name))
|
|
106
|
+
browserCookieRefs.set(
|
|
107
|
+
name,
|
|
108
|
+
createCookieRef(name, document.cookie, options, false) as Ref<unknown>,
|
|
109
|
+
);
|
|
110
|
+
return browserCookieRefs.get(name) as Ref<T>;
|
|
111
|
+
}
|
|
112
|
+
const runtime = useDmsRuntime();
|
|
113
|
+
const key = `${COOKIE_STATE_PREFIX}${name}`;
|
|
114
|
+
if (!runtime.sharedState.has(key))
|
|
115
|
+
runtime.sharedState.set(
|
|
116
|
+
key,
|
|
117
|
+
createCookieRef(
|
|
118
|
+
name,
|
|
119
|
+
runtime.requestCookies,
|
|
120
|
+
options,
|
|
121
|
+
true,
|
|
122
|
+
) as Ref<unknown>,
|
|
123
|
+
);
|
|
124
|
+
return runtime.sharedState.get(key) as Ref<T>;
|
|
125
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
getCurrentInstance,
|
|
4
|
+
type MaybeRefOrGetter,
|
|
5
|
+
onServerPrefetch,
|
|
6
|
+
toRaw,
|
|
7
|
+
toValue,
|
|
8
|
+
} from "vue";
|
|
9
|
+
import { type DmsAsyncDataKey, type DmsData } from "./types";
|
|
10
|
+
import { useDmsRuntime } from "./state";
|
|
11
|
+
import {
|
|
12
|
+
asyncDataEntries,
|
|
13
|
+
MAX_UNUSED_ASYNC_DATA,
|
|
14
|
+
scopedKey,
|
|
15
|
+
} from "./async-data-entry";
|
|
16
|
+
import { type DmsFrontendRuntime } from "./module-registration";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The data another caller loads under `key`, as Nuxt's `useNuxtData`: to
|
|
20
|
+
* update it in place without reading it again, from what a write answered
|
|
21
|
+
* (an optimistic update) or what a realtime event says changed. It follows
|
|
22
|
+
* the key in the runtime's scope (`setDmsAsyncDataScope`): `undefined` until
|
|
23
|
+
* a caller loads the key, its data once there, `undefined` again once the
|
|
24
|
+
* browser lets the key go (past the most recent keys it keeps) or the scope
|
|
25
|
+
* changes. It does not load the key, nor keep it.
|
|
26
|
+
*/
|
|
27
|
+
export function useDmsData<T>(
|
|
28
|
+
key: MaybeRefOrGetter<DmsAsyncDataKey>,
|
|
29
|
+
): DmsData<T> {
|
|
30
|
+
const runtime = useDmsRuntime();
|
|
31
|
+
const entry = () => {
|
|
32
|
+
const name = toValue(key);
|
|
33
|
+
const result =
|
|
34
|
+
name == null
|
|
35
|
+
? undefined
|
|
36
|
+
: runtime.asyncData.get(scopedKey(runtime, String(name)));
|
|
37
|
+
return result && asyncDataEntries.get(result);
|
|
38
|
+
};
|
|
39
|
+
// A component after the caller, in the page or in its subtree, renders the
|
|
40
|
+
// data the browser hydrates it with: the server waits for the caller's run
|
|
41
|
+
// too. One before it has none on either side. Whether the key is there as
|
|
42
|
+
// the setup runs is the same on both sides, and so are the async
|
|
43
|
+
// boundaries `useId()` counts.
|
|
44
|
+
const loaded = entry();
|
|
45
|
+
if (loaded && getCurrentInstance()) onServerPrefetch(() => loaded.settled());
|
|
46
|
+
return {
|
|
47
|
+
data: computed({
|
|
48
|
+
get() {
|
|
49
|
+
// An entry only ever holds its own key's data: a caller showing the
|
|
50
|
+
// previous key's data meanwhile keeps it in its own refs.
|
|
51
|
+
return (entry()?.result.data.value ?? undefined) as T | undefined;
|
|
52
|
+
},
|
|
53
|
+
set(value) {
|
|
54
|
+
const current = entry();
|
|
55
|
+
if (!current) return;
|
|
56
|
+
current.result.data.value = value ?? null;
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reads the data of the keys given, or of every key, again. */
|
|
63
|
+
export async function refreshDmsData(keys?: string | string[]): Promise<void> {
|
|
64
|
+
const runtime = useDmsRuntime();
|
|
65
|
+
const selected = keys
|
|
66
|
+
? (Array.isArray(keys) ? keys : [keys]).map((key) =>
|
|
67
|
+
scopedKey(runtime, key),
|
|
68
|
+
)
|
|
69
|
+
: [...runtime.asyncData.keys()];
|
|
70
|
+
await Promise.all(
|
|
71
|
+
selected.map((key) => runtime.asyncData.get(key)?.refresh()),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads again the data the components on screen show, but those that opted
|
|
77
|
+
* out (`refreshOnPageRefresh: false`) and those waiting for `execute()`:
|
|
78
|
+
* what a page calls once something changed what it shows, after a save or
|
|
79
|
+
* on a realtime event, instead of each component refreshing its own.
|
|
80
|
+
*/
|
|
81
|
+
export async function refreshDmsPageData(): Promise<void> {
|
|
82
|
+
const runtime = useDmsRuntime();
|
|
83
|
+
await Promise.all(
|
|
84
|
+
[...runtime.boundAsyncData].map((entry) => entry.pageRefresh()),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Puts the keys of the async data in a scope, such as a digest of the
|
|
90
|
+
* account and the workspace: the same key in another scope is another
|
|
91
|
+
* entry, never the data of the previous one. Moving to another scope drops
|
|
92
|
+
* the data of every key, aborting its runs; the components on screen then
|
|
93
|
+
* show their default until they load again. Call it on both sides before
|
|
94
|
+
* the page renders, with the same value, so the browser finds the data the
|
|
95
|
+
* server transferred; the scope is written in the page with each key, so a
|
|
96
|
+
* digest rather than an identifier. Call it from a plugin registered with
|
|
97
|
+
* `{ enforce: "pre" }`: a plugin of another module that reads data at setup
|
|
98
|
+
* would otherwise run first, and the new scope abort its read.
|
|
99
|
+
*
|
|
100
|
+
* @param scope The scope, or `undefined` for none
|
|
101
|
+
*/
|
|
102
|
+
export function setDmsAsyncDataScope(scope: string | undefined): void {
|
|
103
|
+
const runtime = useDmsRuntime();
|
|
104
|
+
if (runtime.asyncDataScope === scope) return;
|
|
105
|
+
runtime.asyncDataScope = scope;
|
|
106
|
+
for (const result of runtime.asyncData.values()) result.clear();
|
|
107
|
+
runtime.asyncData.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Drops the data no component shows any more, but the most recently shown:
|
|
112
|
+
* a return to a key shows its data at once, and the data does not pile up
|
|
113
|
+
* over a session.
|
|
114
|
+
*/
|
|
115
|
+
export function dropUnusedAsyncData(runtime: DmsFrontendRuntime): void {
|
|
116
|
+
const unused = [...runtime.asyncData]
|
|
117
|
+
.map(([key, result]) => ({ key, entry: asyncDataEntries.get(result) }))
|
|
118
|
+
.filter(({ entry }) => entry?.unused())
|
|
119
|
+
.sort((left, right) => right.entry!.lastShown() - left.entry!.lastShown());
|
|
120
|
+
for (const { key } of unused.slice(MAX_UNUSED_ASYNC_DATA))
|
|
121
|
+
runtime.asyncData.delete(key);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function serializeDmsAsyncData(
|
|
125
|
+
runtime: DmsFrontendRuntime,
|
|
126
|
+
): Record<string, unknown> {
|
|
127
|
+
return Object.fromEntries(
|
|
128
|
+
[...runtime.asyncData.entries()]
|
|
129
|
+
.filter(([, entry]) => entry.status.value === "success")
|
|
130
|
+
.map(([key, entry]) => [key, toRaw(entry.data.value)]),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type DmsErrorData } from "./types";
|
|
2
|
+
|
|
3
|
+
export function createDmsError(
|
|
4
|
+
input: string | DmsErrorData,
|
|
5
|
+
): Error & DmsErrorData {
|
|
6
|
+
const details = typeof input === "string" ? { message: input } : input;
|
|
7
|
+
return Object.assign(
|
|
8
|
+
new Error(details.message ?? details.statusMessage),
|
|
9
|
+
details,
|
|
10
|
+
);
|
|
11
|
+
}
|