@antelopejs/dms-frontend 0.9.2 → 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 CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <a href="https://www.npmjs.com/package/@antelopejs/dms-frontend"><img alt="npm" src="https://img.shields.io/npm/v/@antelopejs/dms-frontend?style=for-the-badge&labelColor=000000&color=000000"></a>
11
11
  <a href="./LICENSE"><img alt="License" src="https://img.shields.io/npm/l/@antelopejs/dms-frontend?style=for-the-badge&labelColor=000000&color=000000"></a>
12
- <a href="https://discord.gg/sjK28QHrA7"><img alt="Discord" src="https://img.shields.io/badge/Discord-18181B?logo=discord&style=for-the-badge&color=000000"></a>
12
+ <a href="https://discord.gg/g4pFt6eXqu"><img alt="Discord" src="https://img.shields.io/badge/Discord-18181B?logo=discord&style=for-the-badge&color=000000"></a>
13
13
  <a href="https://dms.antelopejs.com/docs/frontend"><img alt="Documentation" src="https://img.shields.io/badge/Docs-18181B?style=for-the-badge&color=000000"></a>
14
14
 
15
15
  **The frontend of the AntelopeJS DMS: the `ajs dms` CLI, the Vue renderer and the frontend-module SDK.**
package/dist/config.js CHANGED
@@ -50,6 +50,7 @@ exports.TEMPLATE_FILES = [
50
50
  ["index.html", "index.html"],
51
51
  ["main.ts", "main.ts"],
52
52
  ["app-runtime.ts", "app-runtime.ts"],
53
+ ["message-subset.ts", "message-subset.ts"],
53
54
  ["network-error.ts", "network-error.ts"],
54
55
  ["ui-app-config.ts", "ui-app-config.ts"],
55
56
  ["layer-app-config.ts", "layer-app-config.ts"],
@@ -60,6 +61,7 @@ exports.TEMPLATE_FILES = [
60
61
  ["frontend-build-loader.ts", "frontend-build-loader.ts"],
61
62
  ["server-module.ts", "server-module.ts"],
62
63
  ["icon-hydration.ts", "icon-hydration.ts"],
64
+ ["icon-bundle.ts", "icon-bundle.ts"],
63
65
  ["globals.d.ts", "globals.d.ts"],
64
66
  ["compress-assets.mjs", "compress-assets.mjs"],
65
67
  ["head-order.mjs", "head-order.mjs"],
@@ -72,6 +74,7 @@ exports.TEMPLATE_FILES = [
72
74
  ["icon-api.mjs", "icon-api.mjs"],
73
75
  ["icon-scan.mjs", "icon-scan.mjs"],
74
76
  ["ui-kbd.mjs", "ui-kbd.mjs"],
77
+ ["ui-lazy-icons.mjs", "ui-lazy-icons.mjs"],
75
78
  ["email-renderer.ts", "email-renderer.ts"],
76
79
  ["email-runtime.ts", "email-runtime.ts"],
77
80
  ["email-locales.ts", "email-locales.ts"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -4,6 +4,7 @@ import { useToast } from "@nuxt/ui/composables/useToast";
4
4
  import { useAppConfig } from "@nuxt/ui/runtime/vue/composables/useAppConfig";
5
5
  import { en as uiEn, fr as uiFr } from "@nuxt/ui/locale";
6
6
  import ui from "@nuxt/ui/vue-plugin";
7
+ import { router } from "@inertiajs/vue3";
7
8
  import { useHead } from "@unhead/vue";
8
9
  import type { VueHeadClient } from "@unhead/vue/types";
9
10
  import type { IconifyIcon } from "@iconify/vue";
@@ -64,7 +65,13 @@ import {
64
65
  supportedLocales,
65
66
  syncLocaleMessages,
66
67
  } from "./locales.generated";
67
- import { showNetworkErrors } from "./network-error";
68
+ import {
69
+ type DmsMessageSubset,
70
+ SSR_MESSAGES_ID,
71
+ recordMessageReads,
72
+ } from "./message-subset";
73
+ import { NETWORK_ERROR_KEYS, showNetworkErrors } from "./network-error";
74
+ import { loadIconBundle } from "./icon-bundle";
68
75
  import { createUiAppConfigMerger } from "./ui-app-config";
69
76
 
70
77
  export interface DmsInertiaSetupProps {
@@ -86,8 +93,16 @@ export interface DmsAppOptions {
86
93
  }
87
94
 
88
95
  export interface DmsConfiguredApp {
89
- /** The locale of the catalog the app shows its messages from now. */
96
+ /**
97
+ * The locale of the catalog the app shows its messages from now, none when
98
+ * the page is served the messages it read alone.
99
+ */
90
100
  catalogLocale: () => string | undefined;
101
+ /**
102
+ * On the server, for a page of a visitor who is not signed in: the messages
103
+ * the render read, which the page carries instead of the whole catalog.
104
+ */
105
+ messageSubset: () => DmsMessageSubset | undefined;
91
106
  mounted: () => Promise<void>;
92
107
  runtime: ReturnType<typeof createDmsFrontendRuntime>;
93
108
  }
@@ -238,8 +253,18 @@ const DmsPersistentInertiaPage = Object.assign(DmsInertiaPage, {
238
253
  layout: DmsPersistentLayout,
239
254
  });
240
255
 
241
- export function resolveDmsInertiaPage(): Component {
242
- return DmsPersistentInertiaPage;
256
+ /**
257
+ * What a visit waits for before it shows its page, set when the first one
258
+ * starts from a page that began without what the pages after it read: a
259
+ * subset of the catalog, the icon bundle. Inertia also resolves the first page
260
+ * once the app is set up, which must not wait for it.
261
+ */
262
+ let completeInitialAssets: (() => Promise<void>) | undefined;
263
+
264
+ export function resolveDmsInertiaPage(): Component | Promise<Component> {
265
+ return completeInitialAssets
266
+ ? completeInitialAssets().then(() => DmsPersistentInertiaPage)
267
+ : DmsPersistentInertiaPage;
243
268
  }
244
269
 
245
270
  function renderDmsPage(
@@ -310,13 +335,66 @@ export async function configureDmsApp(
310
335
  userLocale(options.initialPageProps.user) ??
311
336
  readDmsLocaleCookie(runtime, supportedLocales) ??
312
337
  DEFAULT_LOCALE;
313
- const messages = await loadLocaleMessages(locale);
338
+ const anonymous = !options.initialPageProps.user;
339
+ const subset = anonymous
340
+ ? readServerRendered<DmsMessageSubset | undefined>(
341
+ SSR_MESSAGES_ID,
342
+ undefined,
343
+ )
344
+ : undefined;
345
+ const started = subset?.locale === locale ? subset : undefined;
346
+ const messages = started?.messages ?? (await loadLocaleMessages(locale));
347
+ const recorder =
348
+ anonymous && typeof window === "undefined"
349
+ ? recordMessageReads(messages)
350
+ : undefined;
351
+ let loading: Promise<void> | undefined;
314
352
  const i18n = createI18n({
315
353
  legacy: false,
316
354
  locale,
317
355
  fallbackLocale: DEFAULT_LOCALE,
318
- messages: { ...localeMessages, [locale]: messages },
356
+ messages: started
357
+ ? { [locale]: messages }
358
+ : { ...localeMessages, [locale]: recorder?.messages ?? messages },
359
+ ...(started && {
360
+ missing: (_locale: string, key: string) => {
361
+ if (i18n.global.locale.value !== locale) return key;
362
+ void loadCompleteCatalog();
363
+ return "";
364
+ },
365
+ }),
319
366
  });
367
+ function loadCompleteCatalog(): Promise<void> {
368
+ if (i18n.global.locale.value !== locale) return Promise.resolve();
369
+ loading ??= loadLocaleMessages(locale).then(
370
+ (complete) => {
371
+ i18n.global.setLocaleMessage(locale, complete);
372
+ i18n.global.setMissingHandler(null);
373
+ },
374
+ () => {
375
+ loading = undefined;
376
+ },
377
+ );
378
+ return loading;
379
+ }
380
+ if (anonymous && typeof window !== "undefined") {
381
+ const complete = async (): Promise<void> => {
382
+ await Promise.all([
383
+ started ? loadCompleteCatalog() : undefined,
384
+ loadIconBundle(),
385
+ ]);
386
+ };
387
+ router.on("before", () => {
388
+ completeInitialAssets = complete;
389
+ void complete();
390
+ });
391
+ for (const type of ["pointerdown", "keydown", "focusin", "touchstart"])
392
+ addEventListener(type, () => void loadIconBundle(), {
393
+ once: true,
394
+ capture: true,
395
+ });
396
+ }
397
+ if (recorder) for (const key of NETWORK_ERROR_KEYS) i18n.global.te(key);
320
398
  // A translation changed in a layer during development reaches the page
321
399
  // without a reload. A server render imports the new catalogs instead.
322
400
  if (typeof window !== "undefined") syncLocaleMessages(i18n.global);
@@ -339,6 +417,7 @@ export async function configureDmsApp(
339
417
  // rendered: the page, its layout, and the components the render reached.
340
418
  // Hydration then adopts the server-rendered markup instead of discarding it.
341
419
  await Promise.all([
420
+ anonymous ? undefined : loadIconBundle(),
342
421
  preloadDmsPage(options.initialPageProps),
343
422
  resolveDmsAsyncComponents(
344
423
  readServerRendered<string[]>(SSR_ASYNC_COMPONENTS_ID, []),
@@ -352,7 +431,9 @@ export async function configureDmsApp(
352
431
  supportedLocales,
353
432
  );
354
433
  return {
355
- catalogLocale: () => catalogLocale(i18n.global.locale.value),
434
+ catalogLocale: () =>
435
+ recorder ? undefined : catalogLocale(i18n.global.locale.value),
436
+ messageSubset: () => recorder && { locale, messages: recorder.subset() },
356
437
  mounted,
357
438
  runtime,
358
439
  };
@@ -1 +1,6 @@
1
1
  declare const defineDmsAppConfig: typeof import("./frontend-module").defineDmsAppConfig;
2
+ declare module "virtual:nuxt-ui-icons" {
3
+ export function init(
4
+ add: (name: string, data: import("@iconify/vue").IconifyIcon) => boolean,
5
+ ): void;
6
+ }
@@ -0,0 +1,31 @@
1
+ import { addIcon, type IconifyIcon } from "@iconify/vue";
2
+
3
+ function register(name: string, data: IconifyIcon): boolean {
4
+ const colon = name.indexOf(":");
5
+ if (colon !== -1 && name.slice(0, colon).includes("-")) {
6
+ addIcon(name.replace(":", "-"), data);
7
+ }
8
+ return addIcon(name, data);
9
+ }
10
+
11
+ let loading: Promise<void> | undefined;
12
+
13
+ /**
14
+ * Loads the icons Nuxt UI bundles for the client, once: every icon the
15
+ * application's sources name. A server render has them from the start (Nuxt
16
+ * UI's own plugin, left in place by `ui-lazy-icons.mjs`), so there is nothing
17
+ * to load.
18
+ *
19
+ * A failed load is forgotten and resolves: an icon the bundle would have
20
+ * held is then fetched from the icon API, as one outside the bundle always is.
21
+ */
22
+ export function loadIconBundle(): Promise<void> {
23
+ if (import.meta.env.SSR) return Promise.resolve();
24
+ loading ??= import("virtual:nuxt-ui-icons").then(
25
+ ({ init }) => init(register),
26
+ () => {
27
+ loading = undefined;
28
+ },
29
+ );
30
+ return loading;
31
+ }
@@ -0,0 +1,65 @@
1
+ /** The `<script>` the server render leaves in the document for {@link DmsMessageSubset}. */
2
+ export const SSR_MESSAGES_ID = "dms-ssr-messages";
3
+
4
+ type Messages = Record<string, unknown>;
5
+
6
+ /**
7
+ * The messages a server render read from a locale's catalog, as a catalog of
8
+ * their own, with the locale they belong to.
9
+ */
10
+ export interface DmsMessageSubset {
11
+ locale: string;
12
+ messages: Messages;
13
+ }
14
+
15
+ export interface MessageReadRecorder {
16
+ /** The catalog to give the render: every read through it is recorded. */
17
+ messages: Messages;
18
+ /** What the render read so far. */
19
+ subset: () => Messages;
20
+ }
21
+
22
+ function isTraversable(value: unknown): value is object {
23
+ return typeof value === "object" && value !== null;
24
+ }
25
+
26
+ function isMessages(value: unknown): value is Messages {
27
+ return isTraversable(value);
28
+ }
29
+
30
+ type Slot = () => Messages;
31
+
32
+ /**
33
+ * Wraps a catalog so that the messages read through it are collected, with
34
+ * the path they sit at, into a catalog of their own.
35
+ *
36
+ * A message is recorded when a string is read from the catalog, whether by a
37
+ * lookup (`t`), an existence check (`te`) or a walk over a subtree (`tm`).
38
+ */
39
+ export function recordMessageReads(messages: Messages): MessageReadRecorder {
40
+ const used: Messages = {};
41
+ const place = (source: object, parent: Slot, key: string): Slot => {
42
+ return () => {
43
+ const holder = parent();
44
+ const existing = holder[key];
45
+ if (isMessages(existing)) return existing;
46
+ const container: Messages = Array.isArray(source) ? [] : {};
47
+ holder[key] = container;
48
+ return container;
49
+ };
50
+ };
51
+ const wrap = <T extends object>(source: T, slot: Slot): T =>
52
+ new Proxy(source, {
53
+ get(target, key, receiver) {
54
+ const value: unknown = Reflect.get(target, key, receiver);
55
+ if (typeof key !== "string" || key === "__proto__") return value;
56
+ if (typeof value === "string") {
57
+ slot()[key] = value;
58
+ return value;
59
+ }
60
+ if (isTraversable(value)) return wrap(value, place(value, slot, key));
61
+ return value;
62
+ },
63
+ });
64
+ return { messages: wrap(messages, () => used), subset: () => used };
65
+ }
@@ -15,6 +15,10 @@ export interface DmsNetworkErrorTranslator {
15
15
 
16
16
  const NETWORK_ERROR_TOAST_ID = "dms-network-error";
17
17
  const NETWORK_ERROR_KEY = "error.network";
18
+ /** The messages a module overrides the built-in texts with. */
19
+ export const NETWORK_ERROR_KEYS = ["title", "description"].map(
20
+ (name) => `${NETWORK_ERROR_KEY}.${name}`,
21
+ );
18
22
  const DEFAULT_LOCALE = "en";
19
23
  // Shipped with the renderer, since no module is guaranteed to translate the
20
24
  // case; a module overrides them with `error.network.title` and
@@ -30,6 +30,7 @@ import {
30
30
  trackDmsServerScopes,
31
31
  withPublicOptions,
32
32
  } from "./frontend-module";
33
+ import { type DmsMessageSubset, SSR_MESSAGES_ID } from "./message-subset";
33
34
  import { parsePublicOptions } from "./public-options";
34
35
  import { frontendModules } from "./frontend-modules.generated";
35
36
  import {
@@ -129,6 +130,12 @@ function asyncComponentsScript(names: Set<string>): string {
129
130
  return `<script id="${SSR_ASYNC_COMPONENTS_ID}" type="application/json">${value}</script>`;
130
131
  }
131
132
 
133
+ function messagesScript(subset: DmsMessageSubset | undefined): string {
134
+ if (!subset) return "";
135
+ const value = JSON.stringify(subset).replaceAll("<", "\\u003c");
136
+ return `<script id="${SSR_MESSAGES_ID}" type="application/json">${value}</script>`;
137
+ }
138
+
132
139
  function iconsScript(names: Set<string>): string {
133
140
  const value = JSON.stringify(serializeDmsIcons(names)).replaceAll(
134
141
  "<",
@@ -195,6 +202,7 @@ async function renderDmsPageWithRuntime(
195
202
  let iconSymbols = new Map<string, string>();
196
203
  let payload: DmsPayload = { data: {}, errors: {}, state: {} };
197
204
  let catalogLocale: (() => string | undefined) | undefined;
205
+ let messageSubset: (() => DmsMessageSubset | undefined) | undefined;
198
206
  const inertiaResult = await createInertiaApp({
199
207
  page,
200
208
  // Read as the HTML is complete: data a run settles afterwards was never
@@ -221,6 +229,7 @@ async function renderDmsPageWithRuntime(
221
229
  serverFetch,
222
230
  });
223
231
  catalogLocale = configured.catalogLocale;
232
+ messageSubset = configured.messageSubset;
224
233
  return app;
225
234
  },
226
235
  });
@@ -255,7 +264,7 @@ async function renderDmsPageWithRuntime(
255
264
  const renderedHead = await renderSSRHead(head);
256
265
  return {
257
266
  error: page.props.error,
258
- body: `${inertiaResult.body}${dmsIconSprite(iconSymbols)}${payloadScript(payload)}${asyncComponentsScript(asyncComponents)}${iconsScript(icons)}`,
267
+ body: `${inertiaResult.body}${dmsIconSprite(iconSymbols)}${payloadScript(payload)}${asyncComponentsScript(asyncComponents)}${iconsScript(icons)}${messagesScript(messageSubset?.())}`,
259
268
  head: {
260
269
  bodyAttrs: renderedHead.bodyAttrs,
261
270
  headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @typedef {object} IconsPluginContext
3
+ * @property {{ config: { consumer: string } }} environment
4
+ * @property {(message: string) => never} error
5
+ */
6
+
7
+ /** Nuxt UI's plugin that installs the client icon bundle. */
8
+ const ICONS_PLUGIN = /\/@nuxt\/ui\/dist\/runtime\/vue\/plugins\/icons\.js$/;
9
+
10
+ /**
11
+ * The client build of Nuxt UI's icons plugin, as a Vite plugin.
12
+ *
13
+ * The plugin imports the bundle of every icon the sources name, which put it
14
+ * in the entry of every page, the sign-in page's included, for icons it draws
15
+ * from the server render's own copy. The client gets an empty plugin, and
16
+ * `loadIconBundle` (`icon-bundle.ts`) loads the bundle when the page needs it. The server render keeps the plugin as it is. The
17
+ * build fails if Nuxt UI changes the form rewritten here, rather than
18
+ * letting the bundle return to the entry unnoticed.
19
+ *
20
+ * @param {(id: string) => boolean} isNuxtUiFile whether a module is Nuxt UI's
21
+ * @returns {{ name: string, enforce: "pre", transform: (this: IconsPluginContext, code: string, id: string) => { code: string, map: null } | null }}
22
+ */
23
+ export function uiLazyIcons(isNuxtUiFile) {
24
+ return {
25
+ name: "dms-ui-lazy-icons",
26
+ enforce: "pre",
27
+ transform(code, id) {
28
+ const path = id.replaceAll("\\", "/").split("?")[0];
29
+ if (
30
+ this.environment.config.consumer !== "client" ||
31
+ !isNuxtUiFile(id) ||
32
+ !ICONS_PLUGIN.test(path)
33
+ )
34
+ return null;
35
+ if (!code.includes("virtual:nuxt-ui-icons"))
36
+ this.error(
37
+ `${id} no longer installs the client icon bundle the way the DMS ` +
38
+ "defers it: the bundle would return to the entry. Update " +
39
+ "ui-lazy-icons.mjs for this Nuxt UI release.",
40
+ );
41
+ return { code: "export default { install() {} };", map: null };
42
+ },
43
+ };
44
+ }
@@ -14,6 +14,7 @@ import { recordEntryWeight } from "./entry-weight.mjs";
14
14
  import { rejectServerSourcesInClient } from "./client-server-guard.mjs";
15
15
  import { uiInertiaPage } from "./ui-inertia-page.mjs";
16
16
  import { uiKbd } from "./ui-kbd.mjs";
17
+ import { uiLazyIcons } from "./ui-lazy-icons.mjs";
17
18
  import { uiRequestState } from "./ui-request-state.mjs";
18
19
  import { autoImportDirectories, vitePlugins } from "./frontend-build-loader.ts";
19
20
  import { frontendBuilds } from "./frontend-builds.generated.ts";
@@ -184,6 +185,7 @@ export default defineConfig({
184
185
  },
185
186
  },
186
187
  uiKbd(isNuxtUiFile),
188
+ uiLazyIcons(isNuxtUiFile),
187
189
  uiInertiaPage(isNuxtUiFile),
188
190
  uiRequestState(isNuxtUiFile),
189
191
  vue({ script: { globalTypeFiles: [sfcGlobalTypesFile] } }),