@antelopejs/dms 0.12.1 → 0.13.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/data/dbip-country-lite.mmdb +0 -0
  3. package/data/module-store/catalog.json +0 -6
  4. package/dist/index.js +13 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/utils/ensure-default-tenant.js +7 -2
  7. package/dist/utils/ensure-default-tenant.js.map +1 -1
  8. package/frontend-vue/dms.frontend.ts +2 -0
  9. package/frontend-vue/layers/dms-auth/app/build/components/AuthFormAlert.vue +1 -1
  10. package/frontend-vue/layers/dms-auth/app/build/composables/useAuthFormError.ts +11 -14
  11. package/frontend-vue/layers/dms-auth/app/composables/useOAuthErrorToast.ts +3 -8
  12. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +3 -2
  13. package/frontend-vue/layers/dms-core/app/build/composables/api-error.ts +145 -0
  14. package/frontend-vue/layers/dms-core/app/build/composables/auth/usePermissionPreview.ts +5 -1
  15. package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +0 -2
  16. package/frontend-vue/layers/dms-core/app/composables/page/useSiteLayout.ts +6 -5
  17. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -11
  18. package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
  19. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -29
  20. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +3 -1
  21. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +3 -3
  22. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +4 -4
  23. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +7 -9
  24. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -4
  25. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +6 -7
  26. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +2 -1
  27. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +14 -34
  28. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +3 -11
  29. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +9 -0
  30. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStoreActions.ts +13 -28
  31. package/frontend-vue/layers/dms-layout/app/custom-pages/modules-store.vue +2 -2
  32. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +5 -2
  33. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +4 -4
  34. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +0 -4
  35. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +0 -4
  36. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +2 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/actions/actionTargetRequests.ts +6 -10
  38. package/frontend-vue/layers/dms-ui/app/build/composables/chart/groupPoints.ts +13 -2
  39. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +10 -139
  40. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -10
  41. package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableConfigClipboard.ts +7 -10
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionBulkRequests.ts +11 -10
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionValues.ts +1 -4
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableViewGrouping.ts +7 -10
  46. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useExpandedRowDetails.ts +4 -1
  47. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +4 -1
  48. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -18
  49. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +3 -1
  50. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +9 -5
  51. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +4 -3
  52. package/frontend-vue/layers/dms-ui/app/components/block-preview/BlockPreview.vue +6 -1
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +3 -1
  54. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +3 -2
  55. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -19
  56. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
  57. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +6 -2
  58. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +5 -1
  59. package/frontend-vue/layers/dms-ui/app/components/form/components/ImagePresentation.vue +2 -2
  60. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -6
  61. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +2 -6
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/imageItems.ts +1 -0
  63. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +2 -1
  64. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -11
  65. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +6 -6
  66. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +14 -25
  67. package/frontend-vue/layers/dms-ui/app/composables/global/keyboardPlatform.ts +36 -9
  68. package/frontend-vue/layers/dms-ui/app/composables/useExportJob.ts +13 -59
  69. package/frontend-vue/layers/dms-ui/app/plugins/kbd-platform.ts +13 -0
  70. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  71. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  72. package/frontend-vue/package.json +1 -3
  73. package/frontend-vue/scripts/check-api-error-presentation.mjs +242 -0
  74. package/frontend-vue/scripts/check-invariants.mjs +23 -0
  75. package/frontend-vue/scripts/codemods/api-error-presenter.mjs +196 -0
  76. package/frontend-vue/scripts/test-api-error-presentation.mjs +93 -0
  77. package/package.json +10 -4
  78. package/skills/dms-module/REFERENCE.md +3 -1
  79. package/skills/dms-module/SKILL.md +7 -2
  80. package/skills/dms-project/SKILL.md +9 -3
  81. package/frontend-vue/layers/dms-core/app/composables/useReadOnlyRefusal.ts +0 -49
@@ -1,4 +1,10 @@
1
- import { computed, getCurrentInstance, onMounted, type Ref } from "vue";
1
+ import {
2
+ computed,
3
+ getCurrentInstance,
4
+ onMounted,
5
+ shallowRef,
6
+ type Ref,
7
+ } from "vue";
2
8
 
3
9
  /** How keyboard hints are printed: macOS/iOS symbols, or PC key names. */
4
10
  export type KeyboardPlatform = "mac" | "other";
@@ -10,6 +16,12 @@ export type KeyboardPlatform = "mac" | "other";
10
16
  */
11
17
  export const KEYBOARD_PLATFORM_COOKIE = "dms-keyboard-platform";
12
18
  const KEYBOARD_PLATFORM_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
19
+ /**
20
+ * The platform the server rendered the page's hints with, handed to the
21
+ * browser with the page: the cookie may change before every hint has
22
+ * hydrated.
23
+ */
24
+ const RENDERED_KEYBOARD_PLATFORM_STATE = "dms-keyboard-platform-rendered";
13
25
  /** What the server renders before the browser has said otherwise. */
14
26
  export const DEFAULT_KEYBOARD_PLATFORM: KeyboardPlatform = "other";
15
27
 
@@ -91,12 +103,17 @@ function normalizePlatform(value: KeyboardPlatform): KeyboardPlatform {
91
103
  }
92
104
 
93
105
  /**
94
- * The keyboard platform every hint renders with, shared by all callers. The
95
- * server and the hydrating browser both start from the cookie, so their HTML
96
- * matches; once mounted, the browser detects its platform and, if the cookie
97
- * was wrong or missing, corrects it, which re-renders the hints in place and
98
- * lets the next server render get them right. Only labels follow it: the key
99
- * bindings themselves stay with `defineShortcuts`.
106
+ * The keyboard platform every hint renders with, shared by all callers.
107
+ *
108
+ * The server renders the cookie's platform and hands it to the browser with
109
+ * the page. A component the browser creates while it hydrates that page
110
+ * (`isDmsHydrating()`) renders it too, until its own `onMounted`: the first
111
+ * hint to mount corrects the cookie, and a hint hydrating after it (an async
112
+ * component) must still draw what the server drew. Once mounted, the browser
113
+ * detects its platform and, if the cookie was wrong or missing, corrects it,
114
+ * which re-renders the hints in place and lets the next server render get
115
+ * them right. Only labels follow it: the key bindings themselves stay with
116
+ * `defineShortcuts`.
100
117
  */
101
118
  export function useKeyboardPlatform() {
102
119
  const stored: Ref<KeyboardPlatform> = useDmsCookie<KeyboardPlatform>(
@@ -106,13 +123,23 @@ export function useKeyboardPlatform() {
106
123
  maxAge: KEYBOARD_PLATFORM_COOKIE_MAX_AGE,
107
124
  },
108
125
  );
109
- if (getCurrentInstance()) {
126
+ const rendered = useDmsState<KeyboardPlatform>(
127
+ RENDERED_KEYBOARD_PLATFORM_STATE,
128
+ () => normalizePlatform(stored.value),
129
+ { transfer: true },
130
+ );
131
+ const inComponent = getCurrentInstance() !== null;
132
+ const hydrating = shallowRef(inComponent && isDmsHydrating());
133
+ if (inComponent) {
110
134
  onMounted(() => {
135
+ hydrating.value = false;
111
136
  const detected = detectKeyboardPlatform(window.navigator);
112
137
  if (stored.value !== detected) stored.value = detected;
113
138
  });
114
139
  }
115
- const platform = computed(() => normalizePlatform(stored.value));
140
+ const platform = computed(() =>
141
+ normalizePlatform(hydrating.value ? rendered.value : stored.value),
142
+ );
116
143
  return {
117
144
  platform,
118
145
  isMac: computed(() => platform.value === "mac"),
@@ -94,7 +94,7 @@ function createExportToasts(
94
94
  ) {
95
95
  const toast = useToast();
96
96
  const { t } = useI18n();
97
- const { processApiMessage } = useTranslation();
97
+ const { presentError } = useApiErrorPresenter();
98
98
  const { $authFetch } = useAuthFetch();
99
99
 
100
100
  function resolveLabels(
@@ -152,14 +152,13 @@ function createExportToasts(
152
152
 
153
153
  function buildRetryActions(
154
154
  opts: RunExportJobOptions,
155
- labels: UseExportJobLabels,
156
155
  getCurrentToastId: () => string | number,
157
- ): { label: string; onClick: () => void }[] | undefined {
156
+ ): ApiErrorAction[] | undefined {
158
157
  if (opts.retryable === false) return undefined;
159
158
  return [
160
159
  {
161
- label: labels.retry,
162
- onClick: () => {
160
+ labelKey: opts.labels?.retry ?? "dms.table.export.retry",
161
+ onSelect: () => {
163
162
  toast.remove(getCurrentToastId());
164
163
  void runJob(opts);
165
164
  },
@@ -170,39 +169,13 @@ function createExportToasts(
170
169
  function showFailure(
171
170
  toastId: string | number,
172
171
  opts: RunExportJobOptions,
173
- labels: UseExportJobLabels,
174
- error?: string,
175
- ): string | number {
176
- let newId: string | number = toastId;
177
- newId = replaceProgressToast(toastId, {
178
- color: Color.error,
179
- icon: "i-ph-warning",
180
- title: labels.errorTitle,
181
- description: error ? processApiMessage(error) : labels.fallbackErrorTitle,
182
- duration: EXPORT_TOAST_DURATION,
183
- progress: true,
184
- actions: buildRetryActions(opts, labels, () => newId),
172
+ error: unknown,
173
+ ): void {
174
+ presentError(error, {
175
+ fallbackKey: opts.labels?.errorTitle ?? "dms.table.export.error_title",
176
+ replaceToastId: toastId,
177
+ actions: buildRetryActions(opts, () => toastId),
185
178
  });
186
- return newId;
187
- }
188
-
189
- function showError(
190
- toastId: string | number,
191
- opts: RunExportJobOptions,
192
- labels: UseExportJobLabels,
193
- error: Error,
194
- ): string | number {
195
- let newId: string | number = toastId;
196
- newId = replaceProgressToast(toastId, {
197
- color: Color.error,
198
- icon: "i-ph-warning",
199
- title: labels.fallbackErrorTitle,
200
- description: processApiMessage(error.message),
201
- duration: EXPORT_TOAST_DURATION,
202
- progress: true,
203
- actions: buildRetryActions(opts, labels, () => newId),
204
- });
205
- return newId;
206
179
  }
207
180
 
208
181
  return {
@@ -212,7 +185,6 @@ function createExportToasts(
212
185
  updateProgressToast,
213
186
  showSuccess,
214
187
  showFailure,
215
- showError,
216
188
  };
217
189
  }
218
190
 
@@ -352,25 +324,10 @@ async function readExportStream(
352
324
  return new Blob(chunks as BlobPart[]);
353
325
  }
354
326
 
355
- function getErrorMessage(
356
- error: Parameters<typeof apiErrorText>[0],
357
- ): string | undefined {
358
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- These fields are not validated as strings; preserve optional property access, getter order, and truthy non-string forwarding for malformed errors.
359
- const err = error as { data?: string; message?: string };
360
- const data = err?.data;
361
- const message = err?.message;
362
- return data || message;
363
- }
364
-
365
327
  export const useExportJob = () => {
366
328
  const context = createExportToasts((opts) => runJob(opts));
367
- const {
368
- resolveLabels,
369
- createExportToast,
370
- showSuccess,
371
- showFailure,
372
- showError,
373
- } = context;
329
+ const { resolveLabels, createExportToast, showSuccess, showFailure } =
330
+ context;
374
331
  const {
375
332
  startJob,
376
333
  resolveStatusUrl,
@@ -404,10 +361,7 @@ export const useExportJob = () => {
404
361
  );
405
362
  showSuccess(exportToast.id, labels);
406
363
  } catch (error) {
407
- const message = getErrorMessage(error);
408
- if (message) showFailure(exportToast.id, opts, labels, message);
409
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Rejections are not checked as Error; retain showError's existing message access and failure behavior for malformed values.
410
- else showError(exportToast.id, opts, labels, error as Error);
364
+ showFailure(exportToast.id, opts, error);
411
365
  }
412
366
  }
413
367
 
@@ -0,0 +1,13 @@
1
+ import { useKeyboardPlatform } from "../composables/global/keyboardPlatform";
2
+
3
+ /**
4
+ * Nuxt UI's key hints (`<UKbd value="meta">`, the `kbds` of its menus, the
5
+ * dashboard search button) print `meta`, `ctrl` and `alt` for the keyboard
6
+ * platform the dashboard's own hints use: the cookie's, read in each hint's
7
+ * setup, so a hint hydrating the server's page keeps the platform the server
8
+ * printed until it is mounted (`useKeyboardPlatform`). Without it they print
9
+ * a blank on the server and fill it in once mounted.
10
+ */
11
+ export default defineDmsPlugin(({ vueApp }) => {
12
+ provideDmsKbdPlatform(vueApp, () => useKeyboardPlatform().platform);
13
+ });
@@ -748,6 +748,7 @@
748
748
  "not_found_hint": "What this shows doesn’t exist, or no longer does."
749
749
  },
750
750
  "action_error": {
751
+ "fields": "{summary}",
751
752
  "forbidden": "You don’t have permission to do this.",
752
753
  "generic": "Something went wrong and the action couldn’t be completed. Try again."
753
754
  },
@@ -748,6 +748,7 @@
748
748
  "not_found_hint": "Ce qui est affiché ici n’existe pas, ou plus."
749
749
  },
750
750
  "action_error": {
751
+ "fields": "{summary}",
751
752
  "forbidden": "Vous n’avez pas l’autorisation d’effectuer cette action.",
752
753
  "generic": "Une erreur est survenue et l’action n’a pas pu aboutir. Réessayez."
753
754
  },
@@ -18,9 +18,7 @@
18
18
  "test": "vitest run",
19
19
  "format": "prettier --write .",
20
20
  "lint": "node scripts/check-invariants.mjs && node scripts/check-disable-blocks.mjs && eslint .",
21
- "lint:fix": "eslint . --fix",
22
- "frontend:build": "ajs dms build -b http://127.0.0.1:5010",
23
- "frontend:start": "ajs dms start -p 3001"
21
+ "lint:fix": "eslint . --fix"
24
22
  },
25
23
  "dependencies": {
26
24
  "@codemirror/autocomplete": "^6.18.6",
@@ -0,0 +1,242 @@
1
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const PRESENTER = fileURLToPath(
7
+ new URL(
8
+ "../layers/dms-core/app/composables/useApiErrorPresenter.ts",
9
+ import.meta.url,
10
+ ),
11
+ );
12
+ const SKIP = new Set(["node_modules", "dist", "tests", "test", "fixtures"]);
13
+
14
+ function dependencies(root) {
15
+ let directory = resolve(root);
16
+ if (!statSync(directory).isDirectory()) directory = dirname(directory);
17
+ while (!existsSync(join(directory, "package.json"))) {
18
+ const parent = dirname(directory);
19
+ if (parent === directory) throw new Error(`No package.json above ${root}`);
20
+ directory = parent;
21
+ }
22
+ try {
23
+ const require = createRequire(join(directory, "package.json"));
24
+ return {
25
+ ts: require("typescript"),
26
+ parse: require("vue/compiler-sfc").parse,
27
+ };
28
+ } catch (error) {
29
+ throw new Error(
30
+ `Install typescript and vue in ${directory} to check API error presentation`,
31
+ { cause: error },
32
+ );
33
+ }
34
+ }
35
+
36
+ /** Runtime sources only. Test fixtures and generated directories are excluded. */
37
+ export function apiErrorSources(root) {
38
+ if (!statSync(root).isDirectory()) return [resolve(root)];
39
+ return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
40
+ if (entry.name.startsWith(".") || SKIP.has(entry.name)) return [];
41
+ const path = join(root, entry.name);
42
+ if (entry.isDirectory()) return apiErrorSources(path);
43
+ return /\.(?:vue|[cm]?tsx?)$/.test(entry.name) &&
44
+ !/\.(?:test|spec|d)\.ts$/.test(entry.name)
45
+ ? [resolve(path)]
46
+ : [];
47
+ });
48
+ }
49
+
50
+ /** Parses both Vue script blocks without treating template text as JavaScript. */
51
+ export function apiErrorScripts(file, source, tools) {
52
+ if (!file.endsWith(".vue")) return [{ source, offset: 0 }];
53
+ const { descriptor, errors } = tools.parse(source, { filename: file });
54
+ if (errors.length)
55
+ throw new Error(`Cannot parse ${file}: ${errors.join(", ")}`);
56
+ return [descriptor.script, descriptor.scriptSetup]
57
+ .filter(Boolean)
58
+ .map((block) => ({
59
+ source: block.content,
60
+ offset: block.loc.start.offset,
61
+ isSetup: block === descriptor.scriptSetup,
62
+ }));
63
+ }
64
+
65
+ function bindingsOf(tree, ts) {
66
+ const bindings = new Map();
67
+ const visit = (node) => {
68
+ if (ts.isVariableDeclaration(node) && node.initializer) {
69
+ if (ts.isIdentifier(node.name))
70
+ bindings.set(node.name.text, node.initializer);
71
+ if (ts.isObjectBindingPattern(node.name)) {
72
+ for (const item of node.name.elements) {
73
+ if (ts.isIdentifier(item.name))
74
+ bindings.set(item.name.text, {
75
+ binding: item.propertyName?.getText(tree) ?? item.name.text,
76
+ owner: node.initializer,
77
+ });
78
+ }
79
+ }
80
+ }
81
+ ts.forEachChild(node, visit);
82
+ };
83
+ visit(tree);
84
+ return bindings;
85
+ }
86
+
87
+ /** Analyzes local toast bindings and failure regions. Cross-file effects are not inferred. */
88
+ export function analyzeApiErrorScript(source, file, ts) {
89
+ const tree = ts.createSourceFile(
90
+ file,
91
+ source,
92
+ ts.ScriptTarget.Latest,
93
+ true,
94
+ ts.ScriptKind.TS,
95
+ );
96
+ const bindings = bindingsOf(tree, ts);
97
+ const unwrap = (node) => {
98
+ while (
99
+ node &&
100
+ (ts.isParenthesizedExpression(node) ||
101
+ ts.isAsExpression(node) ||
102
+ ts.isSatisfiesExpression(node) ||
103
+ ts.isNonNullExpression(node))
104
+ )
105
+ node = node.expression;
106
+ return node;
107
+ };
108
+ const resolveBinding = (node, seen = new Set()) => {
109
+ node = unwrap(node);
110
+ if (
111
+ node &&
112
+ ts.isIdentifier(node) &&
113
+ bindings.has(node.text) &&
114
+ !seen.has(node.text)
115
+ ) {
116
+ seen.add(node.text);
117
+ const next = bindings.get(node.text);
118
+ if (!next.binding) return resolveBinding(next, seen);
119
+ }
120
+ return node;
121
+ };
122
+ const isToast = (node) => {
123
+ node = resolveBinding(node);
124
+ return (
125
+ node &&
126
+ ((ts.isIdentifier(node) && node.text === "toast") ||
127
+ (ts.isCallExpression(node) &&
128
+ node.expression.getText(tree) === "useToast"))
129
+ );
130
+ };
131
+ const methodOf = (call) => {
132
+ const expression = unwrap(call.expression);
133
+ if (
134
+ ts.isPropertyAccessExpression(expression) &&
135
+ isToast(expression.expression) &&
136
+ ["add", "update"].includes(expression.name.text)
137
+ )
138
+ return expression.name.text;
139
+ if (ts.isIdentifier(expression)) {
140
+ const binding = bindings.get(expression.text);
141
+ if (
142
+ binding?.binding &&
143
+ isToast(binding.owner) &&
144
+ ["add", "update"].includes(binding.binding)
145
+ )
146
+ return binding.binding;
147
+ }
148
+ return null;
149
+ };
150
+ const colorOf = (payload) => {
151
+ payload = resolveBinding(payload);
152
+ if (!payload || !ts.isObjectLiteralExpression(payload)) return "dynamic";
153
+ if (payload.properties.some(ts.isSpreadAssignment)) return "dynamic";
154
+ const color = payload.properties.find(
155
+ (property) =>
156
+ property.name?.getText(tree).replace(/["']/g, "") === "color",
157
+ );
158
+ if (!color) return "neutral";
159
+ const value = resolveBinding(color.initializer);
160
+ if (!value) return "dynamic";
161
+ if (ts.isStringLiteral(value)) return value.text;
162
+ if (
163
+ ts.isPropertyAccessExpression(value) &&
164
+ value.expression.getText(tree) === "Color"
165
+ )
166
+ return value.name.text;
167
+ return "dynamic";
168
+ };
169
+ const calls = [];
170
+ const visit = (node, failure = null) => {
171
+ if (ts.isCatchClause(node)) {
172
+ visit(node.block, node.variableDeclaration?.name.getText(tree) ?? "");
173
+ return;
174
+ }
175
+ if (ts.isCallExpression(node)) {
176
+ const method = methodOf(node);
177
+ if (method) {
178
+ const payload = node.arguments[method === "update" ? 1 : 0];
179
+ const color = colorOf(payload);
180
+ if (color === "error" || (failure !== null && color !== "success"))
181
+ calls.push({ node, payload, method, failure, color, tree });
182
+ }
183
+ const expression = unwrap(node.expression);
184
+ if (
185
+ ts.isPropertyAccessExpression(expression) &&
186
+ ["catch", "then"].includes(expression.name.text)
187
+ ) {
188
+ const rejectionIndex = expression.name.text === "catch" ? 0 : 1;
189
+ visit(expression, failure);
190
+ node.arguments.forEach((argument, index) => {
191
+ const rejected =
192
+ index === rejectionIndex &&
193
+ (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
194
+ visit(
195
+ argument,
196
+ rejected
197
+ ? (argument.parameters[0]?.name.getText(tree) ?? "")
198
+ : failure,
199
+ );
200
+ });
201
+ return;
202
+ }
203
+ }
204
+ ts.forEachChild(node, (child) => visit(child, failure));
205
+ };
206
+ visit(tree);
207
+ return calls;
208
+ }
209
+
210
+ /** Returns diagnostics for local failure toasts outside the canonical presenter. */
211
+ export function checkApiErrorPresentation(roots) {
212
+ return roots.flatMap((root) => {
213
+ const tools = dependencies(root);
214
+ return apiErrorSources(resolve(root)).flatMap((file) => {
215
+ if (file === PRESENTER) return [];
216
+ const source = readFileSync(file, "utf8");
217
+ return apiErrorScripts(file, source, tools).flatMap((script) =>
218
+ analyzeApiErrorScript(script.source, file, tools.ts).map(({ node }) => {
219
+ const offset = script.offset + node.getStart();
220
+ const line = source.slice(0, offset).split("\n").length;
221
+ return `${file}:${line}: Use useApiErrorPresenter().presentError(error, { fallbackKey })`;
222
+ }),
223
+ );
224
+ });
225
+ });
226
+ }
227
+
228
+ /** Runs the checker with development dependencies resolved from each frontend root. */
229
+ export function assertApiErrorPresentation(roots) {
230
+ const diagnostics = checkApiErrorPresentation(roots);
231
+ if (diagnostics.length) throw new Error(diagnostics.join("\n"));
232
+ }
233
+
234
+ if (
235
+ process.argv[1] &&
236
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
237
+ ) {
238
+ const roots = process.argv.slice(2);
239
+ if (!roots.length)
240
+ throw new Error("Pass frontend app or layers directories to check");
241
+ assertApiErrorPresentation(roots);
242
+ }
@@ -4,6 +4,8 @@ import { dirname, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { brotliDecompressSync } from "node:zlib";
6
6
  import { z } from "zod";
7
+ import { assertApiErrorPresentation } from "./check-api-error-presentation.mjs";
8
+ import "./test-api-error-presentation.mjs";
7
9
  import "./check-autoimport-boundaries.mjs";
8
10
  import "./test-autoimport-boundaries.mjs";
9
11
  import "./check-page-invariants.mjs";
@@ -12,6 +14,25 @@ import "../../../../checks/notification-tone-contract.mjs";
12
14
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
15
  const LAYERS_DIR = join(ROOT, "layers");
14
16
 
17
+ function checkCoreErrorFallback() {
18
+ for (const locale of ["en-GB", "fr-FR"]) {
19
+ const messages = JSON.parse(
20
+ readFileSync(
21
+ join(LAYERS_DIR, `dms-core/i18n/locales/core-${locale}.json`),
22
+ "utf8",
23
+ ),
24
+ );
25
+ assert.ok(
26
+ messages.error["500"].description.trim(),
27
+ `${locale}: always load error.500.description`,
28
+ );
29
+ assert.ok(
30
+ messages.error.read_only_demo.trim(),
31
+ `${locale}: translate error.read_only_demo`,
32
+ );
33
+ }
34
+ }
35
+
15
36
  function collectSources(dir) {
16
37
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
17
38
  const file = join(dir, entry.name);
@@ -334,6 +355,8 @@ function checkGeistNarrowSpace() {
334
355
  }
335
356
 
336
357
  for (const [name, check] of [
358
+ ["API error presentation", () => assertApiErrorPresentation([LAYERS_DIR])],
359
+ ["core error fallback", checkCoreErrorFallback],
337
360
  ["translated accessible names", checkAccessibleNames],
338
361
  ["layer plugin registration", checkPluginRegistration],
339
362
  ["compare mode labels", checkCompareModeLabels],