@antelopejs/dms-frontend 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,6 +133,17 @@ export default frontendModule;
133
133
 
134
134
  The SDK also exposes `use` for Vue plugins. Entries execute by descending manifest priority, then stable module id. Modules without `dms.frontend.ts` are skipped by the generated loader. Registered page keys match a route's `fullSlug`, request path, or `default`; unregistered pages and components use the generic card renderer.
135
135
 
136
+ ### Components rendered inside `<svg>`
137
+
138
+ Modules usually register their components lazily, with `defineAsyncComponent`, and the page is rendered under a `<Suspense>`. Vue mounts an async component that resolves under a `<Suspense>` with the `<Suspense>`'s element namespace rather than the namespace of the place it is rendered: a component resolved by name (`resolveComponent` or a template tag) whose root is an SVG element (`<path>`, `<g>`, …) is created as an HTML element inside the `<svg>`, and the browser draws nothing ([vuejs/core#15639](https://github.com/vuejs/core/issues/15639)). Import such components directly instead, for example the node and edge types passed to Vue Flow:
139
+
140
+ ```ts
141
+ import { markRaw } from "vue";
142
+ import RelationEdge from "./RelationEdge.vue";
143
+
144
+ const edgeTypes = markRaw({ relation: RelationEdge });
145
+ ```
146
+
136
147
  ### Layer aliases outside the workspace
137
148
 
138
149
  Every layer a module ships under `layers/<name>/` answers to `#<name>` inside
package/dist/config.js CHANGED
@@ -52,6 +52,7 @@ exports.TEMPLATE_FILES = [
52
52
  ["npmrc", ".npmrc"],
53
53
  ["gitignore", ".gitignore"],
54
54
  ["pnpm-workspace.yaml", "pnpm-workspace.yaml"],
55
+ ["patches", "patches"],
55
56
  ];
56
57
  exports.LAYERS_SUBDIR = "frontend-modules";
57
58
  exports.FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
@@ -1,6 +1,6 @@
1
1
  import { type SpawnOptions } from "node:child_process";
2
2
  import { ResolvedLayer } from "./workspace";
3
- export declare function computeDepsHash(layerPaths: string[], workspacePackage?: string): string;
3
+ export declare function computeDepsHash(layerPaths: string[], workspacePackage?: string, installInputs?: readonly string[]): string;
4
4
  export declare function needsInstall(workspaceDir: string, layerPaths: string[], force: boolean): boolean;
5
5
  export declare function saveDepsHash(workspaceDir: string, layerPaths: string[]): void;
6
6
  export declare function installDeps(workspaceDir: string): void;
@@ -16,10 +16,25 @@ const manifest_1 = require("./manifest");
16
16
  const materialize_1 = require("./materialize");
17
17
  const workspace_1 = require("./workspace");
18
18
  const config_1 = require("./config");
19
- function computeDepsHash(layerPaths, workspacePackage) {
19
+ const WORKSPACE_PNPM_CONFIG = "pnpm-workspace.yaml";
20
+ const WORKSPACE_PATCHES_DIR = "patches";
21
+ function workspaceInstallInputs(workspaceDir) {
22
+ const patchesDir = (0, node_path_1.join)(workspaceDir, WORKSPACE_PATCHES_DIR);
23
+ const patches = (0, node_fs_1.existsSync)(patchesDir)
24
+ ? (0, node_fs_1.readdirSync)(patchesDir)
25
+ .sort()
26
+ .map((name) => (0, node_path_1.join)(patchesDir, name))
27
+ : [];
28
+ return [(0, node_path_1.join)(workspaceDir, WORKSPACE_PNPM_CONFIG), ...patches];
29
+ }
30
+ function computeDepsHash(layerPaths, workspacePackage, installInputs = []) {
20
31
  const hash = (0, node_crypto_1.createHash)("sha256");
21
32
  if (workspacePackage && (0, node_fs_1.existsSync)(workspacePackage))
22
33
  hash.update((0, node_fs_1.readFileSync)(workspacePackage));
34
+ for (const input of installInputs) {
35
+ if ((0, node_fs_1.existsSync)(input))
36
+ hash.update((0, node_fs_1.readFileSync)(input));
37
+ }
23
38
  for (const layerPath of [...layerPaths].sort()) {
24
39
  const pkgPath = (0, node_path_1.join)(layerPath, "package.json");
25
40
  if ((0, node_fs_1.existsSync)(pkgPath)) {
@@ -36,12 +51,12 @@ function needsInstall(workspaceDir, layerPaths, force) {
36
51
  return true;
37
52
  if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(workspaceDir, "node_modules")))
38
53
  return true;
39
- const currentHash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"));
54
+ const currentHash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"), workspaceInstallInputs(workspaceDir));
40
55
  const savedHash = (0, node_fs_1.readFileSync)(hashFile, "utf-8").trim();
41
56
  return currentHash !== savedHash;
42
57
  }
43
58
  function saveDepsHash(workspaceDir, layerPaths) {
44
- const hash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"));
59
+ const hash = computeDepsHash(layerPaths, (0, node_path_1.join)(workspaceDir, "package.json"), workspaceInstallInputs(workspaceDir));
45
60
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(workspaceDir, config_1.DEPS_HASH_FILE), hash);
46
61
  }
47
62
  function installDeps(workspaceDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -31,8 +31,10 @@ import {
31
31
  installDmsPlugins,
32
32
  preloadDmsPage,
33
33
  provideDmsFrontendRuntime,
34
+ resolveDmsAsyncComponents,
34
35
  resolveDmsComponent,
35
36
  serializeDmsAsyncData,
37
+ trackDmsAsyncComponents,
36
38
  useDmsAppConfig,
37
39
  useDmsState,
38
40
  useError,
@@ -72,6 +74,7 @@ export type DmsServerFetch = (
72
74
  ) => Promise<unknown>;
73
75
 
74
76
  const SSR_ASYNC_DATA_ID = "dms-ssr-async-data";
77
+ export const SSR_ASYNC_COMPONENTS_ID = "dms-ssr-async-components";
75
78
  const DEFAULT_LOCALE = "en";
76
79
 
77
80
  function pageLocale(props: DmsPageProps): string {
@@ -90,6 +93,15 @@ function readDmsAsyncData(): Record<string, unknown> {
90
93
  return data;
91
94
  }
92
95
 
96
+ function readDmsAsyncComponents(): string[] {
97
+ if (typeof document === "undefined") return [];
98
+ const element = document.getElementById(SSR_ASYNC_COMPONENTS_ID);
99
+ if (!element?.textContent) return [];
100
+ const names = JSON.parse(element.textContent) as string[];
101
+ element.remove();
102
+ return names;
103
+ }
104
+
93
105
  const DmsInertiaPage = defineComponent({
94
106
  name: "DmsInertiaPage",
95
107
  inheritAttrs: false,
@@ -227,6 +239,13 @@ export async function configureDmsApp(
227
239
  options.app.runWithContext(() =>
228
240
  hydrateDmsPageProps(options.initialPageProps, options.initialPageUrl),
229
241
  );
242
+ // Resolve, before the app mounts, every async component the server
243
+ // rendered: the page, its layout, and the components the render reached.
244
+ // Hydration then adopts the server-rendered markup instead of discarding it.
245
+ await Promise.all([
246
+ preloadDmsPage(options.initialPageProps),
247
+ resolveDmsAsyncComponents(readDmsAsyncComponents()),
248
+ ]);
230
249
  const mounted = await installDmsPlugins(
231
250
  options.app,
232
251
  i18n.global,
@@ -237,4 +256,4 @@ export async function configureDmsApp(
237
256
  return { mounted, runtime };
238
257
  }
239
258
 
240
- export { serializeDmsAsyncData };
259
+ export { serializeDmsAsyncData, trackDmsAsyncComponents };
@@ -13,6 +13,7 @@ import { type FetchOptions, ofetch } from "ofetch";
13
13
  import {
14
14
  type App,
15
15
  type Component,
16
+ type ComponentPublicInstance,
16
17
  type ComputedRef,
17
18
  computed,
18
19
  defineComponent,
@@ -192,15 +193,8 @@ export interface DmsCookieOptions<T> {
192
193
  sameSite?: "strict" | "lax" | "none";
193
194
  secure?: boolean;
194
195
  }
195
- export type DmsColorModePreference = "system" | "light" | "dark";
196
196
  type DmsLinkPrefetchMode = "mount" | "hover" | "click";
197
197
  type DmsLinkPrefetch = boolean | DmsLinkPrefetchMode | DmsLinkPrefetchMode[];
198
- export interface DmsColorMode {
199
- preference: DmsColorModePreference;
200
- value: "light" | "dark";
201
- unknown: boolean;
202
- forced: boolean;
203
- }
204
198
  export interface DmsAppContext {
205
199
  provide<T>(key: string, value: T): void;
206
200
  vueApp: App;
@@ -313,6 +307,8 @@ export interface DmsFrontendRuntime {
313
307
  pendingNavigation?: Promise<void>;
314
308
  hasNavigationListener: boolean;
315
309
  pageVersion: number;
310
+ /** The `Cookie` header of the request a server runtime renders. */
311
+ requestCookies?: string;
316
312
  }
317
313
 
318
314
  const components = new Map<string, DmsComponentRegistration>();
@@ -329,15 +325,6 @@ const runtimeConfig = ref<DmsRuntimeConfig>({
329
325
  public: {},
330
326
  } as DmsRuntimeConfig);
331
327
  const appConfig = ref<Record<string, unknown>>({});
332
- const COLOR_MODE_STORAGE_KEY = "dms-color-mode";
333
- const COLOR_MODE_CLASSES = ["light", "dark"];
334
- const colorMode = reactive<DmsColorMode>({
335
- preference: "system",
336
- value: "light",
337
- unknown: false,
338
- forced: false,
339
- });
340
- let isColorModeInitialized = false;
341
328
  const DMS_RUNTIME_KEY: InjectionKey<DmsFrontendRuntime> = Symbol("dms-runtime");
342
329
  type DmsRuntimeContext = DmsAppContext["runWithContext"];
343
330
  const runtimeContexts = new WeakMap<DmsFrontendRuntime, DmsRuntimeContext>();
@@ -349,8 +336,10 @@ export function createDmsFrontendRuntime(
349
336
  serverFetch?: typeof ofetch,
350
337
  hydratedAsyncData: Record<string, unknown> = {},
351
338
  isServer = false,
339
+ requestCookies?: string,
352
340
  ): DmsFrontendRuntime {
353
341
  return {
342
+ requestCookies,
354
343
  sharedState: new Map(),
355
344
  asyncData: new Map(),
356
345
  asyncDataPromises: new Map(),
@@ -519,16 +508,24 @@ async function visit(
519
508
  options: DmsNavigationOptions = {},
520
509
  ): Promise<void> {
521
510
  const url = locationToUrl(runtime, to);
522
- const result = await runMiddleware(runtime, parseRoute(runtime, url));
511
+ const target = parseRoute(runtime, url);
512
+ const result = await runMiddleware(runtime, target);
523
513
  if (result === false) return;
524
514
  if (result !== undefined) return visit(runtime, result, options);
525
515
  if (runtime.isServer) {
526
516
  runtime.serverRedirect = url;
527
517
  return;
528
518
  }
519
+ // Like Vue Router, a navigation that only changes the query or hash keeps
520
+ // the page mounted: without preserveState, Inertia remounts the page
521
+ // component on every visit and its local state (open tabs, selection,
522
+ // inputs) is lost.
523
+ const samePath = target.path === runtime.route.path;
529
524
  await new Promise<void>((resolve) =>
530
525
  inertiaRouter.visit(url, {
531
526
  ...options,
527
+ preserveState: options.preserveState ?? samePath,
528
+ preserveScroll: options.preserveScroll ?? samePath,
532
529
  onFinish: (completedVisit) => {
533
530
  options.onFinish?.(completedVisit);
534
531
  resolve();
@@ -1004,82 +1001,92 @@ export function useDmsApp(): DmsAppContext {
1004
1001
  return contextual ?? (useDmsRuntime().appContext as DmsAppContext);
1005
1002
  }
1006
1003
 
1007
- export function useDmsCookie<T = string | null>(
1004
+ const COOKIE_STATE_PREFIX = "dms-cookie:";
1005
+ // A browser tab runs one application, so its cookie refs are shared by every
1006
+ // caller; a server shares them within the request it renders instead.
1007
+ const browserCookieRefs = new Map<string, Ref<unknown>>();
1008
+
1009
+ function readCookie(
1010
+ source: string | undefined,
1008
1011
  name: string,
1009
- options: DmsCookieOptions<T> = {},
1010
- ): Ref<T> {
1011
- if (typeof document === "undefined")
1012
- return ref(options.default?.() ?? null) as Ref<T>;
1013
- const match = document.cookie
1014
- .split("; ")
1012
+ ): string | undefined {
1013
+ const match = source
1014
+ ?.split(/;\s*/)
1015
1015
  .find((entry) => entry.startsWith(`${name}=`));
1016
- const stored = match
1017
- ? decodeURIComponent(match.slice(name.length + 1))
1018
- : undefined;
1019
- let parsed: T;
1016
+ return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
1017
+ }
1018
+
1019
+ function parseCookieValue<T>(
1020
+ stored: string | undefined,
1021
+ options: DmsCookieOptions<T>,
1022
+ ): T {
1023
+ if (stored === undefined) return (options.default?.() ?? null) as T;
1020
1024
  try {
1021
- parsed =
1022
- stored === undefined
1023
- ? ((options.default?.() ?? null) as T)
1024
- : JSON.parse(stored);
1025
+ return JSON.parse(stored) as T;
1025
1026
  } catch {
1026
- parsed = stored as T;
1027
+ return stored as T;
1027
1028
  }
1028
- const value = ref(parsed) as Ref<T>;
1029
+ }
1030
+
1031
+ function writeCookie<T>(name: string, next: T, options: DmsCookieOptions<T>) {
1032
+ const attributes = [
1033
+ `path=${options.path ?? "/"}`,
1034
+ options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
1035
+ options.sameSite ? `samesite=${options.sameSite}` : "",
1036
+ options.secure ? "secure" : "",
1037
+ ].filter(Boolean);
1038
+ // biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
1039
+ document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
1040
+ }
1041
+
1042
+ function createCookieRef<T>(
1043
+ name: string,
1044
+ source: string | undefined,
1045
+ options: DmsCookieOptions<T>,
1046
+ isServer: boolean,
1047
+ ): Ref<T> {
1048
+ const value = ref(
1049
+ parseCookieValue(readCookie(source, name), options),
1050
+ ) as Ref<T>;
1029
1051
  return computed({
1030
1052
  get: () => value.value,
1031
1053
  set: (next) => {
1032
1054
  value.value = next;
1033
- const attributes = [
1034
- `path=${options.path ?? "/"}`,
1035
- options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
1036
- options.sameSite ? `samesite=${options.sameSite}` : "",
1037
- options.secure ? "secure" : "",
1038
- ].filter(Boolean);
1039
- // biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
1040
- document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
1055
+ if (!isServer) writeCookie(name, next, options);
1041
1056
  },
1042
1057
  });
1043
1058
  }
1044
1059
 
1045
- function preferredColorMode(): "light" | "dark" {
1046
- return window.matchMedia?.("(prefers-color-scheme: dark)").matches
1047
- ? "dark"
1048
- : "light";
1049
- }
1050
-
1051
- function applyColorMode(preference: DmsColorModePreference): void {
1052
- colorMode.value = preference === "system" ? preferredColorMode() : preference;
1053
- document.documentElement.classList.remove(...COLOR_MODE_CLASSES);
1054
- document.documentElement.classList.add(colorMode.value);
1055
- }
1056
-
1057
- function initializeColorMode(): void {
1058
- if (isColorModeInitialized) return;
1059
- isColorModeInitialized = true;
1060
- if (typeof window === "undefined") return;
1061
- const stored = window.localStorage.getItem(COLOR_MODE_STORAGE_KEY);
1062
- if (COLOR_MODE_CLASSES.includes(stored ?? "") || stored === "system") {
1063
- colorMode.preference = stored as DmsColorModePreference;
1060
+ /**
1061
+ * A cookie as a ref, shared by every caller of the same name — so a plugin and
1062
+ * a page reading one preference see each other's writes — and read on the
1063
+ * server from the request being rendered, so the server HTML matches the client.
1064
+ */
1065
+ export function useDmsCookie<T = string | null>(
1066
+ name: string,
1067
+ options: DmsCookieOptions<T> = {},
1068
+ ): Ref<T> {
1069
+ if (typeof document !== "undefined") {
1070
+ if (!browserCookieRefs.has(name))
1071
+ browserCookieRefs.set(
1072
+ name,
1073
+ createCookieRef(name, document.cookie, options, false) as Ref<unknown>,
1074
+ );
1075
+ return browserCookieRefs.get(name) as Ref<T>;
1064
1076
  }
1065
- watch(
1066
- () => colorMode.preference,
1067
- (preference) => {
1068
- applyColorMode(preference);
1069
- window.localStorage.setItem(COLOR_MODE_STORAGE_KEY, preference);
1070
- },
1071
- { immediate: true },
1072
- );
1073
- window
1074
- .matchMedia?.("(prefers-color-scheme: dark)")
1075
- .addEventListener("change", () => {
1076
- if (colorMode.preference === "system") applyColorMode("system");
1077
- });
1078
- }
1079
-
1080
- export function useColorMode(): DmsColorMode {
1081
- initializeColorMode();
1082
- return colorMode;
1077
+ const runtime = useDmsRuntime();
1078
+ const key = `${COOKIE_STATE_PREFIX}${name}`;
1079
+ if (!runtime.sharedState.has(key))
1080
+ runtime.sharedState.set(
1081
+ key,
1082
+ createCookieRef(
1083
+ name,
1084
+ runtime.requestCookies,
1085
+ options,
1086
+ true,
1087
+ ) as Ref<unknown>,
1088
+ );
1089
+ return runtime.sharedState.get(key) as Ref<T>;
1083
1090
  }
1084
1091
 
1085
1092
  export function createError(
@@ -1141,6 +1148,41 @@ export function resolveDmsComponent(name: string): Component | undefined {
1141
1148
  return components.get(normalizeDmsName(name))?.component;
1142
1149
  }
1143
1150
  export const getDmsComponent = resolveDmsComponent;
1151
+
1152
+ /**
1153
+ * Records, by registered name, every registered async component a server
1154
+ * render reaches, so the client can resolve the same ones before hydrating.
1155
+ * Inertia re-renders the tree once on boot; an async component still loading
1156
+ * at that moment makes Vue drop its server-rendered markup ("Skipping lazy
1157
+ * hydration") and leave it empty until its chunk arrives.
1158
+ */
1159
+ export function trackDmsAsyncComponents(app: App): Set<string> {
1160
+ const names = new Map<Component, string>();
1161
+ components.forEach(({ component, name }) => {
1162
+ if ((component as DmsAsyncComponent).__asyncLoader)
1163
+ names.set(component, name);
1164
+ });
1165
+ const rendered = new Set<string>();
1166
+ app.mixin({
1167
+ beforeCreate(this: ComponentPublicInstance) {
1168
+ const name = names.get(this.$.type as Component);
1169
+ if (name) rendered.add(name);
1170
+ },
1171
+ });
1172
+ return rendered;
1173
+ }
1174
+
1175
+ export async function resolveDmsAsyncComponents(
1176
+ names: readonly string[],
1177
+ ): Promise<void> {
1178
+ await Promise.all(
1179
+ names.map((name) =>
1180
+ (
1181
+ resolveDmsComponent(name) as DmsAsyncComponent | undefined
1182
+ )?.__asyncLoader?.(),
1183
+ ),
1184
+ );
1185
+ }
1144
1186
  export async function preloadComponents(names: string[]): Promise<void> {
1145
1187
  const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
1146
1188
  await Promise.all(
@@ -1281,8 +1323,15 @@ export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
1281
1323
  ? layouts.get(normalizeDmsName(layoutName))
1282
1324
  : undefined;
1283
1325
  const entries = props.error ? [errorPage] : [page, layout];
1326
+ // `preload` only warms the module; the async wrapper stays unresolved until
1327
+ // its own loader runs. Hydrating an unresolved wrapper defers it, and the
1328
+ // first parent render (Inertia's initial swap) then makes Vue drop the
1329
+ // server-rendered markup and render the subtree from scratch.
1284
1330
  await Promise.all(
1285
- entries.flatMap((entry) => (entry?.preload ? [entry.preload()] : [])),
1331
+ entries.flatMap((entry) => [
1332
+ entry?.preload?.(),
1333
+ (entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
1334
+ ]),
1286
1335
  );
1287
1336
  if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
1288
1337
  }
@@ -4,16 +4,6 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="data:," />
7
- <script>
8
- const preference = localStorage.getItem("dms-color-mode") || "system";
9
- const mode =
10
- preference === "system"
11
- ? matchMedia("(prefers-color-scheme: dark)").matches
12
- ? "dark"
13
- : "light"
14
- : preference;
15
- document.documentElement.classList.add(mode);
16
- </script>
17
7
  <title>Antelope DMS</title>
18
8
  </head>
19
9
  <body>
@@ -15,7 +15,7 @@
15
15
  "@unhead/vue": "^2.0.14",
16
16
  "@vitejs/plugin-vue": "^6.0.0",
17
17
  "@vue/server-renderer": "^3.5.0",
18
- "@vueuse/core": "^13.0.0",
18
+ "@vueuse/core": "^14.3.0",
19
19
  "defu": "^6.1.4",
20
20
  "ofetch": "^1.4.1",
21
21
  "reka-ui": "^2.6.0",
@@ -0,0 +1,44 @@
1
+ diff --git a/dist/runtime/vue/overrides/inertia/Link.vue b/dist/runtime/vue/overrides/inertia/Link.vue
2
+ index 5af1e3e..6cf09e1 100644
3
+ --- a/dist/runtime/vue/overrides/inertia/Link.vue
4
+ +++ b/dist/runtime/vue/overrides/inertia/Link.vue
5
+ @@ -106,20 +106,32 @@ const rel = computed(() => {
6
+ }
7
+ return null;
8
+ });
9
+ +// Patched by @antelopejs/dms-frontend until nuxt/ui#6809 is released: match on
10
+ +// path segments, so `/test` is not active on `/test-a` (nuxt/ui#6803). A `to`
11
+ +// that is not a string (a route object) has no path to match, as before.
12
+ +function normalizePath(url) {
13
+ + if (typeof url !== "string") {
14
+ + return void 0;
15
+ + }
16
+ + const path = url.split("#")[0].split("?")[0];
17
+ + if (!path) {
18
+ + return void 0;
19
+ + }
20
+ + return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
21
+ +}
22
+ +const currentPath = computed(() => normalizePath(page.url) ?? "/");
23
+ +const targetPath = computed(() => href.value ? normalizePath(href.value) : void 0);
24
+ const isLinkActive = computed(() => {
25
+ if (props.active !== void 0) {
26
+ return props.active;
27
+ }
28
+ - if (!href.value) {
29
+ + if (!targetPath.value) {
30
+ return false;
31
+ }
32
+ - if (props.exact && page.url === href.value) {
33
+ - return true;
34
+ - }
35
+ - if (!props.exact && page.url.startsWith(href.value)) {
36
+ - return true;
37
+ + if (props.exact) {
38
+ + return page.url === href.value;
39
+ }
40
+ - return false;
41
+ + return currentPath.value === targetPath.value || currentPath.value.startsWith(targetPath.value === "/" ? "/" : `${targetPath.value}/`);
42
+ });
43
+ const linkClass = computed(() => {
44
+ const active = isLinkActive.value;
@@ -2,3 +2,10 @@ packages:
2
2
  - .
3
3
  - frontend-modules/*
4
4
  ignore-workspace-root-check: true
5
+ # Fixes carried until they are released upstream. Each patch names the upstream
6
+ # issue and pull request it stands in for; drop it with the upgrade that ships
7
+ # them, since pnpm refuses a patch whose version is no longer installed.
8
+ patchedDependencies:
9
+ # Menu and breadcrumb links active on a sibling route (`/test` on `/test-a`):
10
+ # nuxt/ui#6803, fixed by nuxt/ui#6809.
11
+ "@nuxt/ui@4.10.0": patches/@nuxt__ui@4.10.0.patch
@@ -0,0 +1,15 @@
1
+ const HTML_ATTRIBUTE = /([^\s=]+)(?:="[^"]*")?/g;
2
+
3
+ /**
4
+ * The document's `<html>` tag: the template's attributes, overridden by those
5
+ * the render produced (`lang`, `data-scale`…), so none appears twice.
6
+ */
7
+ export function htmlTag(templateAttributes, renderedAttributes) {
8
+ const rendered = new Set(
9
+ [...renderedAttributes.matchAll(HTML_ATTRIBUTE)].map((match) => match[1]),
10
+ );
11
+ const kept = [...templateAttributes.matchAll(HTML_ATTRIBUTE)]
12
+ .filter((match) => !rendered.has(match[1]))
13
+ .map((match) => match[0]);
14
+ return `<html ${[...kept, renderedAttributes.trim()].filter(Boolean).join(" ")}>`;
15
+ }
@@ -22,6 +22,7 @@ import { productionHtmlTemplate } from "./server/client-manifest.mjs";
22
22
  import { documentStyleTags } from "./server/dev-styles.mjs";
23
23
  import { handleEmailRender } from "./server/email.mjs";
24
24
  import { HOMEPAGE } from "./server/homepage.mjs";
25
+ import { htmlTag } from "./server/html-tag.mjs";
25
26
  import { handleTester } from "./server/tester.mjs";
26
27
  import {
27
28
  createInertiaPage,
@@ -319,7 +320,12 @@ async function ssrRenderer(devServer) {
319
320
  return productionSsrRenderer;
320
321
  }
321
322
 
322
- export async function renderHtml(page, requestUrl, serverFetch) {
323
+ export async function renderHtml(
324
+ page,
325
+ requestUrl,
326
+ serverFetch,
327
+ requestCookies,
328
+ ) {
323
329
  const devServer = await developmentServer();
324
330
  const rawTemplate = devServer
325
331
  ? readFileSync(SOURCE_TEMPLATE_PATH, "utf8")
@@ -330,7 +336,7 @@ export async function renderHtml(page, requestUrl, serverFetch) {
330
336
  let rendered;
331
337
  try {
332
338
  const renderer = await ssrRenderer(devServer);
333
- rendered = await renderer.renderDmsPage(page, serverFetch);
339
+ rendered = await renderer.renderDmsPage(page, serverFetch, requestCookies);
334
340
  } catch (error) {
335
341
  console.error(
336
342
  "DMS SSR render failed; falling back to client rendering",
@@ -348,7 +354,9 @@ export async function renderHtml(page, requestUrl, serverFetch) {
348
354
  const preloads = await documentStyleTags(devServer, page, template);
349
355
  const html = template
350
356
  .replace("<title>Antelope DMS</title>", rendered.head.headTags)
351
- .replace("<html", `<html ${rendered.head.htmlAttrs}`)
357
+ .replace(/<html([^>]*)>/, (_, attributes) =>
358
+ htmlTag(attributes, rendered.head.htmlAttrs),
359
+ )
352
360
  .replace("<body", `<body ${rendered.head.bodyAttrs}`)
353
361
  .replace("</head>", `${preloads}</head>`)
354
362
  .replace(
@@ -359,6 +367,11 @@ export async function renderHtml(page, requestUrl, serverFetch) {
359
367
  return { html, status: rendered.error?.statusCode ?? 200 };
360
368
  }
361
369
 
370
+ function renderRequestHtml(page, request) {
371
+ const fetch = serverComponentFetch(request);
372
+ return renderHtml(page, request.url, fetch, request.headers.cookie);
373
+ }
374
+
362
375
  async function writeBackendError(error, request, response) {
363
376
  const status = Number.isInteger(error?.status) ? error.status : 502;
364
377
  const pathname = new URL(request.url, "http://frontend.local").pathname;
@@ -392,11 +405,7 @@ async function writeBackendError(error, request, response) {
392
405
  JSON.stringify(page),
393
406
  );
394
407
  }
395
- const { html } = await renderHtml(
396
- page,
397
- request.url,
398
- serverComponentFetch(request),
399
- );
408
+ const { html } = await renderRequestHtml(page, request);
400
409
  return writeContent(request, response, status, htmlHeaders(), html);
401
410
  }
402
411
  const payload =
@@ -449,11 +458,7 @@ export async function handleRequest(request, response) {
449
458
  JSON.stringify(page),
450
459
  );
451
460
  }
452
- const rendered = await renderHtml(
453
- page,
454
- request.url,
455
- serverComponentFetch(request),
456
- );
461
+ const rendered = await renderRequestHtml(page, request);
457
462
  if (rendered.redirect) {
458
463
  redirectFrontendVisit(request, response, rendered.redirect);
459
464
  return;
@@ -5,9 +5,11 @@ import { renderToString } from "@vue/server-renderer";
5
5
  import { createSSRApp, h } from "vue";
6
6
  import {
7
7
  type DmsServerFetch,
8
+ SSR_ASYNC_COMPONENTS_ID,
8
9
  configureDmsApp,
9
10
  resolveDmsInertiaPage,
10
11
  serializeDmsAsyncData,
12
+ trackDmsAsyncComponents,
11
13
  } from "./app-runtime";
12
14
  import {
13
15
  type DmsErrorData,
@@ -60,6 +62,11 @@ function asyncDataScript(runtime: DmsFrontendRuntime): string {
60
62
  return `<script id="${SSR_ASYNC_DATA_ID}" type="application/json">${value}</script>`;
61
63
  }
62
64
 
65
+ function asyncComponentsScript(names: Set<string>): string {
66
+ const value = JSON.stringify([...names]).replaceAll("<", "\\u003c");
67
+ return `<script id="${SSR_ASYNC_COMPONENTS_ID}" type="application/json">${value}</script>`;
68
+ }
69
+
63
70
  await setupFrontendModules(frontendModules);
64
71
 
65
72
  export function isDmsFrontendPage(path: string): boolean {
@@ -69,11 +76,13 @@ export function isDmsFrontendPage(path: string): boolean {
69
76
  export async function renderDmsPage(
70
77
  page: DmsSsrPage,
71
78
  serverFetch?: DmsServerFetch,
79
+ requestCookies?: string,
72
80
  ): Promise<DmsSsrResult> {
73
81
  const runtime = createDmsFrontendRuntime(
74
82
  serverFetch as typeof import("ofetch").ofetch,
75
83
  {},
76
84
  true,
85
+ requestCookies,
77
86
  );
78
87
  return runtimeStorage.run(runtime, () =>
79
88
  renderDmsPageWithRuntime(page, runtime, serverFetch),
@@ -88,12 +97,14 @@ async function renderDmsPageWithRuntime(
88
97
  await preloadDmsPage(page.props);
89
98
  const head = createHead();
90
99
  const ssrContext: DmsSsrContext = {};
100
+ let asyncComponents = new Set<string>();
91
101
  const inertiaResult = await createInertiaApp({
92
102
  page,
93
103
  render: (app) => renderToString(app, ssrContext),
94
104
  resolve: resolveDmsInertiaPage,
95
105
  async setup({ App, props, plugin }) {
96
106
  const app = createSSRApp({ render: () => h(App, props) });
107
+ asyncComponents = trackDmsAsyncComponents(app);
97
108
  const configured = await configureDmsApp({
98
109
  app,
99
110
  head,
@@ -130,12 +141,13 @@ async function renderDmsPageWithRuntime(
130
141
  return renderDmsPage(
131
142
  { ...page, props: { ...page.props, error } },
132
143
  serverFetch,
144
+ runtime.requestCookies,
133
145
  );
134
146
  }
135
147
  const renderedHead = await renderSSRHead(head);
136
148
  return {
137
149
  error: page.props.error,
138
- body: `${inertiaResult.body}${asyncDataScript(runtime)}`,
150
+ body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}`,
139
151
  head: {
140
152
  bodyAttrs: renderedHead.bodyAttrs,
141
153
  headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
@@ -158,7 +158,6 @@ export default defineConfig({
158
158
  "showError",
159
159
  "useDmsAsyncData",
160
160
  "useDmsCookie",
161
- "useColorMode",
162
161
  "useDmsFetch",
163
162
  "useHead",
164
163
  "useI18n",
@@ -184,7 +183,7 @@ export default defineConfig({
184
183
  paintBeforeHydrate(),
185
184
  ],
186
185
  resolve: {
187
- dedupe: ["vue", "reka-ui", "@nuxt/ui"],
186
+ dedupe: ["vue", "reka-ui", "@nuxt/ui", "@vueuse/core"],
188
187
  alias: {
189
188
  "#dms/frontend-module": resolve(__dirname, "frontend-module.ts"),
190
189
  "#build/nuxt-icon-client-bundle": "virtual:nuxt-ui-icons",