@elytracms/studio 0.0.80 → 0.0.82
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/convex/contract-parity.test.ts +1 -1
- package/convex/functions.ts +13 -3
- package/convex/http-routes.test.ts +143 -0
- package/convex/http.ts +19 -9
- package/convex/redirect-revalidate.test.ts +130 -0
- package/convex/revalidate.ts +9 -7
- package/convex/schema.ts +0 -19
- package/convex/triggers.ts +33 -3
- package/convex/users.ts +1 -1
- package/dist/components/ai/ai-chat-panel.js +26 -4
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/proposal-card.d.ts +27 -0
- package/dist/components/ai/proposal-card.js +31 -3
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/ai/proposal-issues.d.ts +55 -0
- package/dist/components/ai/proposal-issues.js +69 -0
- package/dist/components/ai/proposal-issues.js.map +1 -0
- package/dist/components/ai/translate-document-dialog.js +38 -3
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +141 -44
- package/dist/components/app-views/default-modules.js +229 -65
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +38 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.d.ts +5 -2
- package/dist/components/app-views/sidebar.js +16 -5
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +28 -1
- package/dist/components/app-views/studio-config.js +1 -1
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/builder/canvas-render-context.js +10 -2
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/clipboard-actions.js +5 -3
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/reuse-context.js +7 -1
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +94 -16
- package/dist/components/builder/reuse.js +201 -19
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/bulk-actions.js +10 -5
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +18 -3
- package/dist/components/cms/cell-edit.js +29 -7
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +54 -14
- package/dist/components/cms/cms-store.js +142 -45
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +2 -2
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +15 -14
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-editor-context.d.ts +12 -1
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/field-control.js +22 -8
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/field-renderer.js +8 -1
- package/dist/components/cms/field-renderer.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +36 -0
- package/dist/components/cms/gate-validation.js +17 -0
- package/dist/components/cms/gate-validation.js.map +1 -0
- package/dist/components/cms/import-export.js +2 -1
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/inline-field-errors.d.ts +8 -0
- package/dist/components/cms/inline-field-errors.js +52 -0
- package/dist/components/cms/inline-field-errors.js.map +1 -0
- package/dist/components/cms/issues-pane.d.ts +4 -2
- package/dist/components/cms/issues-pane.js +13 -7
- package/dist/components/cms/issues-pane.js.map +1 -1
- package/dist/components/cms/slug-collision-report.d.ts +41 -0
- package/dist/components/cms/slug-collision-report.js +52 -0
- package/dist/components/cms/slug-collision-report.js.map +1 -0
- package/dist/components/components/transfer-dialog.js +10 -12
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/deployment-setup-gate.d.ts +24 -0
- package/dist/components/deployment-setup-gate.js +29 -4
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/issue-severity.d.ts +73 -0
- package/dist/components/issue-severity.js +56 -0
- package/dist/components/issue-severity.js.map +1 -0
- package/dist/components/nav-issues.d.ts +68 -0
- package/dist/components/nav-issues.js +78 -0
- package/dist/components/nav-issues.js.map +1 -0
- package/dist/components/operation-error.js +3 -3
- package/dist/components/operation-error.js.map +1 -1
- package/dist/components/project-list.d.ts +9 -3
- package/dist/components/project-list.js +11 -5
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-switcher.d.ts +3 -1
- package/dist/components/project-switcher.js +4 -2
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/redirects/redirects-store.d.ts +21 -0
- package/dist/components/redirects/redirects-store.js +17 -4
- package/dist/components/redirects/redirects-store.js.map +1 -1
- package/dist/components/section-nav.js +75 -10
- package/dist/components/section-nav.js.map +1 -1
- package/dist/customization.d.ts +7 -0
- package/dist/customization.js.map +1 -1
- package/dist/lib/ai/apply-content.js +6 -7
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +32 -26
- package/dist/lib/ai/chat-context.js +81 -9
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +42 -0
- package/dist/lib/ai/constraints.js +259 -0
- package/dist/lib/ai/constraints.js.map +1 -0
- package/dist/lib/ai/index.d.ts +2 -0
- package/dist/lib/ai/index.js +2 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/proposal-validation.d.ts +26 -0
- package/dist/lib/ai/proposal-validation.js +17 -0
- package/dist/lib/ai/proposal-validation.js.map +1 -0
- package/dist/lib/ai/shape.js +60 -13
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -4
- package/dist/lib/ai/tools.js +43 -14
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.d.ts +35 -1
- package/dist/lib/ai/translate-document.js +16 -1
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +11 -6
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +8 -6
- package/dist/lib/ai/update-fields.js +9 -22
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/clipboard/paste-issues.d.ts +5 -0
- package/dist/lib/clipboard/paste-issues.js +16 -6
- package/dist/lib/clipboard/paste-issues.js.map +1 -1
- package/dist/lib/cms/document-gate.d.ts +37 -0
- package/dist/lib/cms/document-gate.js +40 -0
- package/dist/lib/cms/document-gate.js.map +1 -0
- package/dist/lib/cms-views/view-model.d.ts +5 -0
- package/dist/lib/cms-views/view-model.js +10 -4
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +6 -1
- package/dist/lib/data/collection-content.js +51 -15
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/i18n/i18n.d.ts +51 -16
- package/dist/lib/i18n/i18n.js +119 -43
- package/dist/lib/i18n/i18n.js.map +1 -1
- package/dist/lib/i18n/index.d.ts +8 -1
- package/dist/lib/i18n/index.js +1 -1
- package/dist/lib/i18n/index.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +200 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +162 -0
- package/dist/lib/i18n/messages.en.js +226 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/i18n/types.d.ts +15 -9
- package/dist/lib/i18n/types.js.map +1 -1
- package/dist/lib/locale/binding-fallback.d.ts +8 -1
- package/dist/lib/locale/binding-fallback.js +8 -1
- package/dist/lib/locale/binding-fallback.js.map +1 -1
- package/dist/lib/locale/locale-store.d.ts +41 -9
- package/dist/lib/locale/locale-store.js +34 -15
- package/dist/lib/locale/locale-store.js.map +1 -1
- package/dist/lib/locale/locale-switcher.d.ts +3 -1
- package/dist/lib/locale/locale-switcher.js +4 -2
- package/dist/lib/locale/locale-switcher.js.map +1 -1
- package/dist/lib/locale/use-studio-locale.d.ts +9 -4
- package/dist/lib/locale/use-studio-locale.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +19 -2
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +1 -3
- package/dist/lib/persistence/convex-adapter.js +0 -55
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.js +10 -0
- package/dist/lib/persistence/deployment-readiness.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +85 -9
- package/dist/lib/persona/labels.js +198 -58
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/routes/route-issues.d.ts +9 -0
- package/dist/lib/routes/route-issues.js +117 -0
- package/dist/lib/routes/route-issues.js.map +1 -0
- package/dist/lib/webhooks/dispatcher.d.ts +5 -2
- package/dist/lib/webhooks/dispatcher.js.map +1 -1
- package/dist/lib/webhooks/tags.js +150 -17
- package/dist/lib/webhooks/tags.js.map +1 -1
- package/dist/lib/workspace/config-issues.d.ts +56 -0
- package/dist/lib/workspace/config-issues.js +72 -0
- package/dist/lib/workspace/config-issues.js.map +1 -0
- package/dist/lib/workspace/index.d.ts +24 -8
- package/dist/lib/workspace/index.js +28 -18
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/lib/workspace/switcher-source.d.ts +20 -3
- package/dist/lib/workspace/switcher-source.js.map +1 -1
- package/dist/lib/workspace/workspace-source.js +27 -0
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/dist/previews/manifest-format.d.ts +13 -6
- package/dist/previews/manifest-format.js +9 -2
- package/dist/previews/manifest-format.js.map +1 -1
- package/dist/router/root.d.ts +36 -1
- package/dist/router/root.js +54 -7
- package/dist/router/root.js.map +1 -1
- package/package.json +2 -2
- package/convex/cliTokens.ts +0 -63
- package/convex/sync.ts +0 -297
|
@@ -6,35 +6,49 @@ import { humanizeName } from '../utils.js';
|
|
|
6
6
|
*
|
|
7
7
|
* A non-technical editor must never see a manifest/binding/path-token/validation
|
|
8
8
|
* identifier on — or one click off — the editor path. The engine speaks in stable
|
|
9
|
-
* machine codes (`
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* machine codes (`cmsIssueCodeSchema`, `operationErrorCodeSchema`) and machine ids
|
|
10
|
+
* (`base.Heading`, `r-blog-slug-en`); this module is the single translation layer the
|
|
11
|
+
* editor-facing surfaces route through.
|
|
12
12
|
*
|
|
13
13
|
* The maps are exhaustive `Record<…Code, string>` over the Zod enums, so adding a
|
|
14
|
-
* code in `@elytracms/core/
|
|
14
|
+
* code in `@elytracms/core/cms-core` or `@elytracms/core/operations`
|
|
15
15
|
* fails typecheck here until it is given an editor-facing label — the persona
|
|
16
|
-
* boundary cannot silently regress.
|
|
16
|
+
* boundary cannot silently regress. A table is only worth that guard while something
|
|
17
|
+
* renders it: EC-509 deleted two that nothing did (see below). Engineers keep the raw
|
|
18
|
+
* code: every surface that
|
|
17
19
|
* shows a label also carries the code in a `title=`/`data-*` attribute (inspectable
|
|
18
20
|
* via hover/devtools, never rendered as visible copy).
|
|
19
21
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
/**
|
|
23
|
+
* EC-509 — two label tables were DELETED here rather than localized, because measuring
|
|
24
|
+
* first showed nothing rendered them.
|
|
25
|
+
*
|
|
26
|
+
* `GRAPH_ISSUE_LABELS`/`graphIssueLabel` (16 entries, project-graph `IssueCode`) and
|
|
27
|
+
* `CONDITION_OPERATOR_LABELS`/`conditionOperatorLabel` (8, `ConditionOperator`) had
|
|
28
|
+
* **zero call sites** outside this file and its own test — measured by grep over
|
|
29
|
+
* `packages/` + `apps/`, with `cmsIssueLabel` (4 sites) and `operationErrorLabel` (1)
|
|
30
|
+
* as the known-positive control that the search finds a render site when one exists.
|
|
31
|
+
*
|
|
32
|
+
* The graph table was unreachable structurally, not just unused: the ONE live path from
|
|
33
|
+
* graph validation into the studio is `cms-composition-validators.ts`, which maps a
|
|
34
|
+
* graph issue to `CompositionIssue` — a type with `message`/`severity`/`nodePath` and
|
|
35
|
+
* **no `code` field at all** (`cms-core/validate-document.ts:809`). The code an
|
|
36
|
+
* exhaustiveness guard exists to force a label for cannot reach an editor surface.
|
|
37
|
+
* `ConditionOperator` is live in the runtime renderer's condition gate
|
|
38
|
+
* (`runtime-renderer/render.tsx:48`) but has no studio surface: v1 canvas is static
|
|
39
|
+
* props, editor bindings parked, so no inspector ever shows an operator to name.
|
|
40
|
+
*
|
|
41
|
+
* **This overturns an EC-210 decision** (2026-06-17), which kept `graphIssueLabel` when
|
|
42
|
+
* the validation panel was removed, on the rationale that it was "the EC-192 boundary's
|
|
43
|
+
* exhaustiveness guard, not panel code". That was right then and its premise is false
|
|
44
|
+
* now — the guard guards a boundary with no traffic, so it only forced editor copy for
|
|
45
|
+
* strings no editor can see. Localizing them would have added 24 keys to the same
|
|
46
|
+
* effect. If a surface ever renders these codes it needs labels written for it, and
|
|
47
|
+
* will write them localized from the start.
|
|
48
|
+
*
|
|
49
|
+
* Neither symbol is referenced by either consumer repo (measured read-only) and neither
|
|
50
|
+
* appears in docs.
|
|
51
|
+
*/
|
|
38
52
|
/**
|
|
39
53
|
* EC-443: the CMS issue titles live in the EC-241 message catalog
|
|
40
54
|
* (`cms.issue.<code>`, EN source + shipped de), so the Issues pane / for-review
|
|
@@ -69,60 +83,186 @@ const CMS_ISSUE_LABEL_KEYS = {
|
|
|
69
83
|
'unknown-alias': 'cms.issue.unknown-alias',
|
|
70
84
|
'unfilled-slot': 'cms.issue.unfilled-slot',
|
|
71
85
|
'custom-validation': 'cms.issue.custom-validation',
|
|
86
|
+
'unaddressable-url': 'cms.issue.unaddressable-url',
|
|
72
87
|
'unknown-version': 'cms.issue.unknown-version',
|
|
73
88
|
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
/**
|
|
90
|
+
* EC-507 rider — the few codes whose TITLE overstates at `warning` severity.
|
|
91
|
+
*
|
|
92
|
+
* `invalid-field-value` reads "Invalid value", which is right when the value is
|
|
93
|
+
* rejected (wrong type, not among its options) and wrong when it is not: EC-472
|
|
94
|
+
* deliberately STORES `0202-09-18` as a legal ISO date and merely flags the year as
|
|
95
|
+
* probably unintended. A title calling that invalid contradicts the value the editor
|
|
96
|
+
* can plainly see was accepted, on all three surfaces at once.
|
|
97
|
+
*
|
|
98
|
+
* Keyed on SEVERITY rather than on the implausible-year case specifically, because
|
|
99
|
+
* the overstatement belongs to the severity: any future warning under this code has
|
|
100
|
+
* the same property (the value was kept), and the fix then needs no new entry. A
|
|
101
|
+
* sparse `Partial` on purpose — a code with no entry keeps one title for both
|
|
102
|
+
* severities, which is the correct default and keeps
|
|
103
|
+
* {@link CMS_ISSUE_LABEL_KEYS} the single exhaustive map.
|
|
104
|
+
*/
|
|
105
|
+
const CMS_ISSUE_WARNING_LABEL_KEYS = {
|
|
106
|
+
'invalid-field-value': 'cms.issue.invalid-field-value.warning',
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* EC-494: the badge titles live in the EC-241 catalog (`operations.errorLabel.<code>`),
|
|
110
|
+
* exactly as EC-443 moved the CMS issue titles. **Measured before changing it:** this
|
|
111
|
+
* map was a hard-coded English `Record`, so once the EC-494 bodies localized the banner
|
|
112
|
+
* rendered a German sentence under an English badge — the inverse of the bug this
|
|
113
|
+
* ticket exists for, and not what the ticket said was there. Still the exhaustive
|
|
114
|
+
* `Record` over the Zod enum, so a new code fails typecheck until it has a key.
|
|
115
|
+
*/
|
|
116
|
+
const OPERATION_ERROR_LABEL_KEYS = {
|
|
117
|
+
validation: 'operations.errorLabel.validation',
|
|
118
|
+
'not-found': 'operations.errorLabel.not-found',
|
|
119
|
+
conflict: 'operations.errorLabel.conflict',
|
|
120
|
+
'backend-unavailable': 'operations.errorLabel.backend-unavailable',
|
|
121
|
+
unauthorized: 'operations.errorLabel.unauthorized',
|
|
122
|
+
unknown: 'operations.errorLabel.unknown',
|
|
81
123
|
};
|
|
82
124
|
const EXPORT_TARGET_LABELS = {
|
|
83
125
|
nextjs: 'Next.js',
|
|
84
126
|
};
|
|
85
|
-
/**
|
|
86
|
-
* Plain-language labels for condition operators. Exhaustive over
|
|
87
|
-
* `conditionOperatorSchema` — a new operator fails typecheck here until it has an
|
|
88
|
-
* editor-facing label. The raw operator (`eq`, `gte`, …) is a machine token an
|
|
89
|
-
* editor must never read in the inspector.
|
|
90
|
-
*/
|
|
91
|
-
const CONDITION_OPERATOR_LABELS = {
|
|
92
|
-
truthy: 'Is set',
|
|
93
|
-
exists: 'Exists',
|
|
94
|
-
eq: 'Equals',
|
|
95
|
-
neq: 'Does not equal',
|
|
96
|
-
gt: 'Greater than',
|
|
97
|
-
gte: 'At least',
|
|
98
|
-
lt: 'Less than',
|
|
99
|
-
lte: 'At most',
|
|
100
|
-
};
|
|
101
|
-
/** Plain-language title for a graph validation issue code. */
|
|
102
|
-
export function graphIssueLabel(code) {
|
|
103
|
-
return GRAPH_ISSUE_LABELS[code];
|
|
104
|
-
}
|
|
105
127
|
/**
|
|
106
128
|
* Plain-language title for a CMS validation issue code — localized against the
|
|
107
129
|
* active studio-UI locale (EC-443). A runtime-unknown code (impossible typewise;
|
|
108
130
|
* a cast could smuggle one) degrades to its humanized form, never the raw enum.
|
|
131
|
+
*
|
|
132
|
+
* `severity` is optional and additive (EC-507): pass the issue's own severity and a
|
|
133
|
+
* code with a softer warning title (see {@link CMS_ISSUE_WARNING_LABEL_KEYS}) uses
|
|
134
|
+
* it; omit it, or pass `'error'`, and nothing changes. Every render site passes it —
|
|
135
|
+
* the parameter is optional so that a pure/unit caller naming only a code still
|
|
136
|
+
* compiles, not because a surface may skip it.
|
|
109
137
|
*/
|
|
110
|
-
export function cmsIssueLabel(code) {
|
|
111
|
-
const key =
|
|
138
|
+
export function cmsIssueLabel(code, severity) {
|
|
139
|
+
const key = (severity === 'warning' ? CMS_ISSUE_WARNING_LABEL_KEYS[code] : undefined) ??
|
|
140
|
+
CMS_ISSUE_LABEL_KEYS[code];
|
|
112
141
|
return key !== undefined ? translate(key) : humanizeName(code);
|
|
113
142
|
}
|
|
114
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* The localized text of a {@link CustomizationIssue} (EC-478) — the ONE place the
|
|
145
|
+
* studio turns a config-level finding into rendered prose.
|
|
146
|
+
*
|
|
147
|
+
* The producing layer (`studio-core/customization.ts`, `app-views/default-modules.tsx`,
|
|
148
|
+
* `builder/reuse.ts`) cannot own the wording: the EC-236 seam lives in `packages/core`,
|
|
149
|
+
* a leaf of the DAG that may not import this catalog. So it emits a
|
|
150
|
+
* `CustomizationMessageRef` — a stable key plus its interpolation values, all of
|
|
151
|
+
* which are IDENTIFIERS (entry ids, extension points, component ids) and therefore the
|
|
152
|
+
* same in every language — and this resolves it against the active UI locale. Same
|
|
153
|
+
* shape as {@link cmsIssueLabel}, one layer down: the engine speaks codes and keys, the
|
|
154
|
+
* studio speaks the editor's language.
|
|
155
|
+
*
|
|
156
|
+
* `messageRef.reason` fills the `{reason}` slot of `invalid-override`, whose detail is
|
|
157
|
+
* itself prose (a `validate` rejection) and so carries its own key. A ref-less issue —
|
|
158
|
+
* a third-party producer of this published type — degrades to its English
|
|
159
|
+
* `issue.message`, which is honest: a visible English sentence beats a missing key.
|
|
160
|
+
*/
|
|
161
|
+
export function customizationIssueMessage(issue) {
|
|
162
|
+
const ref = issue.messageRef;
|
|
163
|
+
if (!ref)
|
|
164
|
+
return issue.message;
|
|
165
|
+
const vars = { ...ref.vars };
|
|
166
|
+
if (ref.reason)
|
|
167
|
+
vars.reason = translate(ref.reason.key, ref.reason.vars);
|
|
168
|
+
return translate(ref.key, vars);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The localized BODY of a {@link CmsValidationIssue} (EC-487) — the ONE place the
|
|
172
|
+
* studio turns a CMS validation finding into rendered prose, exactly as
|
|
173
|
+
* {@link customizationIssueMessage} does one layer up.
|
|
174
|
+
*
|
|
175
|
+
* EC-443 localized the issue TITLES ({@link cmsIssueLabel}); the bodies stayed English
|
|
176
|
+
* because their producers live in `packages/core`, below this catalog in the DAG. So
|
|
177
|
+
* the producer emits a `CmsMessageRef` — a stable key plus its interpolation values,
|
|
178
|
+
* all of which are IDENTIFIERS (field/collection/route ids, locales, patterns, counts)
|
|
179
|
+
* and therefore the same in every language — and this resolves it against the active
|
|
180
|
+
* UI locale.
|
|
181
|
+
*
|
|
182
|
+
* `messageRef.details` fill placeholders whose text is itself prose, keyed by
|
|
183
|
+
* placeholder name. A placeholder with no entry keeps whatever the producer put in
|
|
184
|
+
* `vars` — the honest outcome for prose the engine did not author (a config-as-code
|
|
185
|
+
* `validate` message, a composition finding), which then stays in its original
|
|
186
|
+
* language rather than vanishing. A ref-less issue — a third-party producer of this
|
|
187
|
+
* published type — degrades to its English `issue.message`: a visible English sentence
|
|
188
|
+
* beats a missing key.
|
|
189
|
+
*/
|
|
190
|
+
export function cmsValidationIssueMessage(issue) {
|
|
191
|
+
return resolveMessageRef(issue.messageRef, issue.message);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* EC-494 — the ONE resolver every carried message ref goes through.
|
|
195
|
+
*
|
|
196
|
+
* Four published types now carry the same `{ key, vars?, details? }` reference —
|
|
197
|
+
* `CmsValidationIssue` (EC-487), `CustomizationIssue` (EC-478, via its own `reason`
|
|
198
|
+
* slot), and now `OperationIssue`/`OperationError` and the clipboard + content-query
|
|
199
|
+
* errors. They are separate declarations only because the EC-265 DAG forbids the
|
|
200
|
+
* lower subpaths from importing `cms-core`; the RESOLUTION is one rule, so it lives
|
|
201
|
+
* in one function.
|
|
202
|
+
*
|
|
203
|
+
* `fallback` is the producer's prebuilt English, rendered whenever there is no ref —
|
|
204
|
+
* a third-party producer, a Zod diagnostic, a persistence-layer message, a
|
|
205
|
+
* config-authored `validate` string. A visible English sentence beats a missing key
|
|
206
|
+
* (EC-443).
|
|
207
|
+
*/
|
|
208
|
+
function resolveMessageRef(ref, fallback) {
|
|
209
|
+
if (!ref)
|
|
210
|
+
return fallback;
|
|
211
|
+
const vars = { ...ref.vars };
|
|
212
|
+
for (const [placeholder, nested] of Object.entries(ref.details ?? {})) {
|
|
213
|
+
vars[placeholder] = translate(nested.key, nested.vars);
|
|
214
|
+
}
|
|
215
|
+
return translate(ref.key, vars);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The localized text of ONE {@link OperationIssue} line in the error banner (EC-494).
|
|
219
|
+
*
|
|
220
|
+
* These issues cross a process boundary: `operations/documents.ts` rebuilds a
|
|
221
|
+
* `CmsValidationIssue` as an `OperationIssue` **server-side, with no locale**, so the
|
|
222
|
+
* producer cannot translate and the catalog key rides on the wire instead. A ref-less
|
|
223
|
+
* issue — a Zod input diagnostic, a persistence-layer message, a third-party producer
|
|
224
|
+
* of this published type — keeps its English.
|
|
225
|
+
*/
|
|
226
|
+
export function operationIssueMessage(issue) {
|
|
227
|
+
return resolveMessageRef(issue.messageRef, issue.message);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The localized ENVELOPE sentence of an {@link OperationError} (EC-494) — the line
|
|
231
|
+
* beside the localized badge in `OperationErrorBanner`. Localizing only the issue
|
|
232
|
+
* lines would have left exactly one English sentence under a German badge.
|
|
233
|
+
*/
|
|
234
|
+
export function operationErrorMessage(error) {
|
|
235
|
+
return resolveMessageRef(error.messageRef, error.message);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The localized text of a clipboard fragment failure (EC-494) — parse (`corrupt-json`,
|
|
239
|
+
* `foreign-format`, `unsupported-version`, `invalid-payload`) or build
|
|
240
|
+
* (`node-not-found`, `fixture-not-found`), rendered by the builder's paste notice.
|
|
241
|
+
*/
|
|
242
|
+
export function clipboardErrorMessage(error) {
|
|
243
|
+
return resolveMessageRef(error.messageRef, error.message);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The localized text of a {@link ContentQueryError} (EC-494) — the filter builder's
|
|
247
|
+
* per-clause line and the canvas listing notice, both of which render errors built by
|
|
248
|
+
* `core/content`'s `validateWhere`/`validateSort`/`validateLimit`.
|
|
249
|
+
*/
|
|
250
|
+
export function contentQueryErrorMessage(error) {
|
|
251
|
+
return resolveMessageRef(error.messageRef, error.message);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Plain-language title for an operation failure code — localized against the active
|
|
255
|
+
* studio-UI locale. A runtime-unknown code (impossible typewise; a cast could smuggle
|
|
256
|
+
* one) degrades to the generic title, never the raw enum.
|
|
257
|
+
*/
|
|
115
258
|
export function operationErrorLabel(code) {
|
|
116
|
-
|
|
259
|
+
const key = OPERATION_ERROR_LABEL_KEYS[code];
|
|
260
|
+
return translate(key ?? OPERATION_ERROR_LABEL_KEYS.unknown);
|
|
117
261
|
}
|
|
118
262
|
/** Friendly name for a project export target (`nextjs` → `Next.js`). */
|
|
119
263
|
export function exportTargetLabel(target) {
|
|
120
264
|
return EXPORT_TARGET_LABELS[target] ?? humanizeName(target);
|
|
121
265
|
}
|
|
122
|
-
/** Plain-language label for a condition operator (`gte` → `At least`). */
|
|
123
|
-
export function conditionOperatorLabel(operator) {
|
|
124
|
-
return CONDITION_OPERATOR_LABELS[operator];
|
|
125
|
-
}
|
|
126
266
|
/** The leaf of a component id, namespace stripped (`base.Heading` → `Heading`). */
|
|
127
267
|
export function componentLeafId(componentId) {
|
|
128
268
|
const dot = componentId.lastIndexOf('.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"labels.js","sourceRoot":"","sources":["../../../src/lib/persona/labels.ts"],"names":[],"mappings":"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","sourcesContent":["import type { IssueCode, ConditionOperator } from '@elytracms/core/project-graph'\nimport type { CmsIssueCode } from '@elytracms/core/cms-core'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport type { OperationErrorCode } from '@elytracms/core/operations'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { localizedLabel, translate } from '@elytracms/studio/lib/i18n'\nimport { humanizeName } from '../utils'\n\n/**\n * Persona-aware labels (EC-192 item 2, Milestone B promise).\n *\n * A non-technical editor must never see a manifest/binding/path-token/validation\n * identifier on — or one click off — the editor path. The engine speaks in stable\n * machine codes (`issueCodeSchema`, `cmsIssueCodeSchema`, `operationErrorCodeSchema`)\n * and machine ids (`base.Heading`, `r-blog-slug-en`); this module is the\n * single translation layer the editor-facing surfaces route through.\n *\n * The maps are exhaustive `Record<…Code, string>` over the Zod enums, so adding a\n * code in `@elytracms/core/project-graph`, `@elytracms/core/cms-core`, or `@elytracms/core/operations`\n * fails typecheck here until it is given an editor-facing label — the persona\n * boundary cannot silently regress. Engineers keep the raw code: every surface that\n * shows a label also carries the code in a `title=`/`data-*` attribute (inspectable\n * via hover/devtools, never rendered as visible copy).\n */\n\nconst GRAPH_ISSUE_LABELS: Record<IssueCode, string> = {\n 'invalid-component-name': 'Invalid component',\n 'unknown-component': 'Unknown component',\n 'duplicate-node-id': 'Duplicate item',\n 'unknown-prop': 'Unknown setting',\n 'invalid-prop': 'Invalid setting',\n 'binding-not-allowed': 'Data not allowed here',\n 'unresolved-binding': 'Missing data',\n 'missing-required-slot': 'Missing content',\n 'unknown-slot': 'Unknown content area',\n 'forbidden-component': 'Not allowed here',\n 'server-only-component': 'Not available in the editor',\n 'invalid-container-direction': 'Invalid layout',\n 'invalid-container-alignment': 'Invalid alignment',\n 'invalid-container-gap': 'Invalid spacing',\n 'invalid-container-columns': 'Invalid columns',\n 'invalid-container-responsive': 'Invalid responsive setting',\n}\n\n/**\n * EC-443: the CMS issue titles live in the EC-241 message catalog\n * (`cms.issue.<code>`, EN source + shipped de), so the Issues pane / for-review\n * lane localize with the rest of the chrome. This map stays the exhaustive\n * `Record` over the Zod enum — a new code still fails typecheck here until it\n * gets an editor-facing catalog key (the persona boundary cannot silently\n * regress); {@link cmsIssueLabel} resolves it through `translate`.\n */\nconst CMS_ISSUE_LABEL_KEYS: Record<CmsIssueCode, string> = {\n 'duplicate-collection': 'cms.issue.duplicate-collection',\n 'duplicate-field': 'cms.issue.duplicate-field',\n 'unknown-collection': 'cms.issue.unknown-collection',\n 'invalid-collection-config': 'cms.issue.invalid-collection-config',\n 'invalid-field-config': 'cms.issue.invalid-field-config',\n 'reserved-field-name': 'cms.issue.reserved-field-name',\n 'missing-required-field': 'cms.issue.missing-required-field',\n 'invalid-field-value': 'cms.issue.invalid-field-value',\n 'unknown-relation-target': 'cms.issue.unknown-relation-target',\n 'unpublished-relation-target': 'cms.issue.unpublished-relation-target',\n 'cardinality-violation': 'cms.issue.cardinality-violation',\n 'unknown-asset': 'cms.issue.unknown-asset',\n 'duplicate-document': 'cms.issue.duplicate-document',\n 'unknown-locale': 'cms.issue.unknown-locale',\n 'missing-localized-value': 'cms.issue.missing-localized-value',\n 'route-conflict': 'cms.issue.route-conflict',\n 'redirect-loop': 'cms.issue.redirect-loop',\n 'unknown-route-target': 'cms.issue.unknown-route-target',\n 'hierarchy-cycle': 'cms.issue.hierarchy-cycle',\n 'document-nesting-depth': 'cms.issue.document-nesting-depth',\n 'singleton-document-drift': 'cms.issue.singleton-document-drift',\n 'duplicate-alias': 'cms.issue.duplicate-alias',\n 'unknown-alias': 'cms.issue.unknown-alias',\n 'unfilled-slot': 'cms.issue.unfilled-slot',\n 'custom-validation': 'cms.issue.custom-validation',\n 'unknown-version': 'cms.issue.unknown-version',\n}\n\nconst OPERATION_ERROR_LABELS: Record<OperationErrorCode, string> = {\n validation: \"Couldn't save\",\n 'not-found': 'Not found',\n conflict: 'Already exists',\n 'backend-unavailable': 'Connection problem',\n unauthorized: 'Not allowed',\n unknown: 'Something went wrong',\n}\n\nconst EXPORT_TARGET_LABELS: Record<string, string> = {\n nextjs: 'Next.js',\n}\n\n/**\n * Plain-language labels for condition operators. Exhaustive over\n * `conditionOperatorSchema` — a new operator fails typecheck here until it has an\n * editor-facing label. The raw operator (`eq`, `gte`, …) is a machine token an\n * editor must never read in the inspector.\n */\nconst CONDITION_OPERATOR_LABELS: Record<ConditionOperator, string> = {\n truthy: 'Is set',\n exists: 'Exists',\n eq: 'Equals',\n neq: 'Does not equal',\n gt: 'Greater than',\n gte: 'At least',\n lt: 'Less than',\n lte: 'At most',\n}\n\n/** Plain-language title for a graph validation issue code. */\nexport function graphIssueLabel(code: IssueCode): string {\n return GRAPH_ISSUE_LABELS[code]\n}\n\n/**\n * Plain-language title for a CMS validation issue code — localized against the\n * active studio-UI locale (EC-443). A runtime-unknown code (impossible typewise;\n * a cast could smuggle one) degrades to its humanized form, never the raw enum.\n */\nexport function cmsIssueLabel(code: CmsIssueCode): string {\n const key = CMS_ISSUE_LABEL_KEYS[code] as string | undefined\n return key !== undefined ? translate(key) : humanizeName(code)\n}\n\n/** Plain-language title for an operation failure code. */\nexport function operationErrorLabel(code: OperationErrorCode): string {\n return OPERATION_ERROR_LABELS[code] ?? OPERATION_ERROR_LABELS.unknown\n}\n\n/** Friendly name for a project export target (`nextjs` → `Next.js`). */\nexport function exportTargetLabel(target: string): string {\n return EXPORT_TARGET_LABELS[target] ?? humanizeName(target)\n}\n\n/** Plain-language label for a condition operator (`gte` → `At least`). */\nexport function conditionOperatorLabel(operator: ConditionOperator): string {\n return CONDITION_OPERATOR_LABELS[operator]\n}\n\n/** The leaf of a component id, namespace stripped (`base.Heading` → `Heading`). */\nexport function componentLeafId(componentId: string): string {\n const dot = componentId.lastIndexOf('.')\n return dot === -1 ? componentId : componentId.slice(dot + 1)\n}\n\n/**\n * Strip the `base.`/`project.` namespace prefix from a component id for compact,\n * editor-facing labels (`base.Heading` → `Heading`). Unlike {@link componentLeafId}\n * it removes only those two known prefixes, so a dotted custom id keeps its shape.\n */\nexport function shortId(componentId: string): string {\n return componentId.replace('base.', '').replace('project.', '')\n}\n\n/**\n * The human display name of a component TYPE for editor-facing surfaces: the\n * manifest's authored `title` (EC-407: resolved through {@link resolveLabel}, so a\n * `{ en, de }` localized title renders in the active studio-UI locale) when present,\n * else the humanized leaf of its id (`base.Heading` → `Heading`,\n * `project.MarketingHero` → `Marketing hero`). Never the raw namespaced id.\n *\n * `locale` is optional: omit it (the render-site default) to resolve against the\n * active studio-UI locale non-reactively; pass it explicitly for pure/unit callers.\n */\nexport function componentDisplayName(\n componentId: string,\n manifest?: ComponentManifest | undefined,\n locale?: string,\n): string {\n const resolved =\n locale === undefined ? localizedLabel(manifest?.title) : (resolveLabel(manifest?.title, locale) ?? '')\n return resolved || humanizeName(componentLeafId(componentId))\n}\n"]}
|
|
1
|
+
{"version":3,"file":"labels.js","sourceRoot":"","sources":["../../../src/lib/persona/labels.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsIssueCode, CmsIssueSeverity, CmsValidationIssue } from '@elytracms/core/cms-core'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport type { OperationError, OperationErrorCode, OperationIssue } from '@elytracms/core/operations'\nimport type { BuildFragmentError, FragmentParseError } from '@elytracms/core/clipboard'\nimport type { ContentQueryError } from '@elytracms/core/content'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { localizedLabel, translate } from '@elytracms/studio/lib/i18n'\nimport { humanizeName } from '../utils'\n\n/**\n * Persona-aware labels (EC-192 item 2, Milestone B promise).\n *\n * A non-technical editor must never see a manifest/binding/path-token/validation\n * identifier on — or one click off — the editor path. The engine speaks in stable\n * machine codes (`cmsIssueCodeSchema`, `operationErrorCodeSchema`) and machine ids\n * (`base.Heading`, `r-blog-slug-en`); this module is the single translation layer the\n * editor-facing surfaces route through.\n *\n * The maps are exhaustive `Record<…Code, string>` over the Zod enums, so adding a\n * code in `@elytracms/core/cms-core` or `@elytracms/core/operations`\n * fails typecheck here until it is given an editor-facing label — the persona\n * boundary cannot silently regress. A table is only worth that guard while something\n * renders it: EC-509 deleted two that nothing did (see below). Engineers keep the raw\n * code: every surface that\n * shows a label also carries the code in a `title=`/`data-*` attribute (inspectable\n * via hover/devtools, never rendered as visible copy).\n */\n\n/**\n * EC-509 — two label tables were DELETED here rather than localized, because measuring\n * first showed nothing rendered them.\n *\n * `GRAPH_ISSUE_LABELS`/`graphIssueLabel` (16 entries, project-graph `IssueCode`) and\n * `CONDITION_OPERATOR_LABELS`/`conditionOperatorLabel` (8, `ConditionOperator`) had\n * **zero call sites** outside this file and its own test — measured by grep over\n * `packages/` + `apps/`, with `cmsIssueLabel` (4 sites) and `operationErrorLabel` (1)\n * as the known-positive control that the search finds a render site when one exists.\n *\n * The graph table was unreachable structurally, not just unused: the ONE live path from\n * graph validation into the studio is `cms-composition-validators.ts`, which maps a\n * graph issue to `CompositionIssue` — a type with `message`/`severity`/`nodePath` and\n * **no `code` field at all** (`cms-core/validate-document.ts:809`). The code an\n * exhaustiveness guard exists to force a label for cannot reach an editor surface.\n * `ConditionOperator` is live in the runtime renderer's condition gate\n * (`runtime-renderer/render.tsx:48`) but has no studio surface: v1 canvas is static\n * props, editor bindings parked, so no inspector ever shows an operator to name.\n *\n * **This overturns an EC-210 decision** (2026-06-17), which kept `graphIssueLabel` when\n * the validation panel was removed, on the rationale that it was \"the EC-192 boundary's\n * exhaustiveness guard, not panel code\". That was right then and its premise is false\n * now — the guard guards a boundary with no traffic, so it only forced editor copy for\n * strings no editor can see. Localizing them would have added 24 keys to the same\n * effect. If a surface ever renders these codes it needs labels written for it, and\n * will write them localized from the start.\n *\n * Neither symbol is referenced by either consumer repo (measured read-only) and neither\n * appears in docs.\n */\n\n/**\n * EC-443: the CMS issue titles live in the EC-241 message catalog\n * (`cms.issue.<code>`, EN source + shipped de), so the Issues pane / for-review\n * lane localize with the rest of the chrome. This map stays the exhaustive\n * `Record` over the Zod enum — a new code still fails typecheck here until it\n * gets an editor-facing catalog key (the persona boundary cannot silently\n * regress); {@link cmsIssueLabel} resolves it through `translate`.\n */\nconst CMS_ISSUE_LABEL_KEYS: Record<CmsIssueCode, string> = {\n 'duplicate-collection': 'cms.issue.duplicate-collection',\n 'duplicate-field': 'cms.issue.duplicate-field',\n 'unknown-collection': 'cms.issue.unknown-collection',\n 'invalid-collection-config': 'cms.issue.invalid-collection-config',\n 'invalid-field-config': 'cms.issue.invalid-field-config',\n 'reserved-field-name': 'cms.issue.reserved-field-name',\n 'missing-required-field': 'cms.issue.missing-required-field',\n 'invalid-field-value': 'cms.issue.invalid-field-value',\n 'unknown-relation-target': 'cms.issue.unknown-relation-target',\n 'unpublished-relation-target': 'cms.issue.unpublished-relation-target',\n 'cardinality-violation': 'cms.issue.cardinality-violation',\n 'unknown-asset': 'cms.issue.unknown-asset',\n 'duplicate-document': 'cms.issue.duplicate-document',\n 'unknown-locale': 'cms.issue.unknown-locale',\n 'missing-localized-value': 'cms.issue.missing-localized-value',\n 'route-conflict': 'cms.issue.route-conflict',\n 'redirect-loop': 'cms.issue.redirect-loop',\n 'unknown-route-target': 'cms.issue.unknown-route-target',\n 'hierarchy-cycle': 'cms.issue.hierarchy-cycle',\n 'document-nesting-depth': 'cms.issue.document-nesting-depth',\n 'singleton-document-drift': 'cms.issue.singleton-document-drift',\n 'duplicate-alias': 'cms.issue.duplicate-alias',\n 'unknown-alias': 'cms.issue.unknown-alias',\n 'unfilled-slot': 'cms.issue.unfilled-slot',\n 'custom-validation': 'cms.issue.custom-validation',\n 'unaddressable-url': 'cms.issue.unaddressable-url',\n 'unknown-version': 'cms.issue.unknown-version',\n}\n\n/**\n * EC-507 rider — the few codes whose TITLE overstates at `warning` severity.\n *\n * `invalid-field-value` reads \"Invalid value\", which is right when the value is\n * rejected (wrong type, not among its options) and wrong when it is not: EC-472\n * deliberately STORES `0202-09-18` as a legal ISO date and merely flags the year as\n * probably unintended. A title calling that invalid contradicts the value the editor\n * can plainly see was accepted, on all three surfaces at once.\n *\n * Keyed on SEVERITY rather than on the implausible-year case specifically, because\n * the overstatement belongs to the severity: any future warning under this code has\n * the same property (the value was kept), and the fix then needs no new entry. A\n * sparse `Partial` on purpose — a code with no entry keeps one title for both\n * severities, which is the correct default and keeps\n * {@link CMS_ISSUE_LABEL_KEYS} the single exhaustive map.\n */\nconst CMS_ISSUE_WARNING_LABEL_KEYS: Partial<Record<CmsIssueCode, string>> = {\n 'invalid-field-value': 'cms.issue.invalid-field-value.warning',\n}\n\n/**\n * EC-494: the badge titles live in the EC-241 catalog (`operations.errorLabel.<code>`),\n * exactly as EC-443 moved the CMS issue titles. **Measured before changing it:** this\n * map was a hard-coded English `Record`, so once the EC-494 bodies localized the banner\n * rendered a German sentence under an English badge — the inverse of the bug this\n * ticket exists for, and not what the ticket said was there. Still the exhaustive\n * `Record` over the Zod enum, so a new code fails typecheck until it has a key.\n */\nconst OPERATION_ERROR_LABEL_KEYS: Record<OperationErrorCode, string> = {\n validation: 'operations.errorLabel.validation',\n 'not-found': 'operations.errorLabel.not-found',\n conflict: 'operations.errorLabel.conflict',\n 'backend-unavailable': 'operations.errorLabel.backend-unavailable',\n unauthorized: 'operations.errorLabel.unauthorized',\n unknown: 'operations.errorLabel.unknown',\n}\n\nconst EXPORT_TARGET_LABELS: Record<string, string> = {\n nextjs: 'Next.js',\n}\n\n/**\n * Plain-language title for a CMS validation issue code — localized against the\n * active studio-UI locale (EC-443). A runtime-unknown code (impossible typewise;\n * a cast could smuggle one) degrades to its humanized form, never the raw enum.\n *\n * `severity` is optional and additive (EC-507): pass the issue's own severity and a\n * code with a softer warning title (see {@link CMS_ISSUE_WARNING_LABEL_KEYS}) uses\n * it; omit it, or pass `'error'`, and nothing changes. Every render site passes it —\n * the parameter is optional so that a pure/unit caller naming only a code still\n * compiles, not because a surface may skip it.\n */\nexport function cmsIssueLabel(code: CmsIssueCode, severity?: CmsIssueSeverity): string {\n const key =\n (severity === 'warning' ? CMS_ISSUE_WARNING_LABEL_KEYS[code] : undefined) ??\n (CMS_ISSUE_LABEL_KEYS[code] as string | undefined)\n return key !== undefined ? translate(key) : humanizeName(code)\n}\n\n/**\n * The localized text of a {@link CustomizationIssue} (EC-478) — the ONE place the\n * studio turns a config-level finding into rendered prose.\n *\n * The producing layer (`studio-core/customization.ts`, `app-views/default-modules.tsx`,\n * `builder/reuse.ts`) cannot own the wording: the EC-236 seam lives in `packages/core`,\n * a leaf of the DAG that may not import this catalog. So it emits a\n * `CustomizationMessageRef` — a stable key plus its interpolation values, all of\n * which are IDENTIFIERS (entry ids, extension points, component ids) and therefore the\n * same in every language — and this resolves it against the active UI locale. Same\n * shape as {@link cmsIssueLabel}, one layer down: the engine speaks codes and keys, the\n * studio speaks the editor's language.\n *\n * `messageRef.reason` fills the `{reason}` slot of `invalid-override`, whose detail is\n * itself prose (a `validate` rejection) and so carries its own key. A ref-less issue —\n * a third-party producer of this published type — degrades to its English\n * `issue.message`, which is honest: a visible English sentence beats a missing key.\n */\nexport function customizationIssueMessage(issue: CustomizationIssue): string {\n const ref = issue.messageRef\n if (!ref) return issue.message\n const vars = { ...ref.vars }\n if (ref.reason) vars.reason = translate(ref.reason.key, ref.reason.vars)\n return translate(ref.key, vars)\n}\n\n/**\n * The localized BODY of a {@link CmsValidationIssue} (EC-487) — the ONE place the\n * studio turns a CMS validation finding into rendered prose, exactly as\n * {@link customizationIssueMessage} does one layer up.\n *\n * EC-443 localized the issue TITLES ({@link cmsIssueLabel}); the bodies stayed English\n * because their producers live in `packages/core`, below this catalog in the DAG. So\n * the producer emits a `CmsMessageRef` — a stable key plus its interpolation values,\n * all of which are IDENTIFIERS (field/collection/route ids, locales, patterns, counts)\n * and therefore the same in every language — and this resolves it against the active\n * UI locale.\n *\n * `messageRef.details` fill placeholders whose text is itself prose, keyed by\n * placeholder name. A placeholder with no entry keeps whatever the producer put in\n * `vars` — the honest outcome for prose the engine did not author (a config-as-code\n * `validate` message, a composition finding), which then stays in its original\n * language rather than vanishing. A ref-less issue — a third-party producer of this\n * published type — degrades to its English `issue.message`: a visible English sentence\n * beats a missing key.\n */\nexport function cmsValidationIssueMessage(issue: CmsValidationIssue): string {\n return resolveMessageRef(issue.messageRef, issue.message)\n}\n\n/**\n * EC-494 — the ONE resolver every carried message ref goes through.\n *\n * Four published types now carry the same `{ key, vars?, details? }` reference —\n * `CmsValidationIssue` (EC-487), `CustomizationIssue` (EC-478, via its own `reason`\n * slot), and now `OperationIssue`/`OperationError` and the clipboard + content-query\n * errors. They are separate declarations only because the EC-265 DAG forbids the\n * lower subpaths from importing `cms-core`; the RESOLUTION is one rule, so it lives\n * in one function.\n *\n * `fallback` is the producer's prebuilt English, rendered whenever there is no ref —\n * a third-party producer, a Zod diagnostic, a persistence-layer message, a\n * config-authored `validate` string. A visible English sentence beats a missing key\n * (EC-443).\n */\nfunction resolveMessageRef(\n ref:\n | {\n readonly key: string\n readonly vars?: Readonly<Record<string, string | number>>\n readonly details?: Readonly<\n Record<string, { readonly key: string; readonly vars?: Readonly<Record<string, string | number>> }>\n >\n }\n | undefined,\n fallback: string,\n): string {\n if (!ref) return fallback\n const vars: Record<string, string | number> = { ...ref.vars }\n for (const [placeholder, nested] of Object.entries(ref.details ?? {})) {\n vars[placeholder] = translate(nested.key, nested.vars)\n }\n return translate(ref.key, vars)\n}\n\n/**\n * The localized text of ONE {@link OperationIssue} line in the error banner (EC-494).\n *\n * These issues cross a process boundary: `operations/documents.ts` rebuilds a\n * `CmsValidationIssue` as an `OperationIssue` **server-side, with no locale**, so the\n * producer cannot translate and the catalog key rides on the wire instead. A ref-less\n * issue — a Zod input diagnostic, a persistence-layer message, a third-party producer\n * of this published type — keeps its English.\n */\nexport function operationIssueMessage(issue: OperationIssue): string {\n return resolveMessageRef(issue.messageRef, issue.message)\n}\n\n/**\n * The localized ENVELOPE sentence of an {@link OperationError} (EC-494) — the line\n * beside the localized badge in `OperationErrorBanner`. Localizing only the issue\n * lines would have left exactly one English sentence under a German badge.\n */\nexport function operationErrorMessage(error: OperationError): string {\n return resolveMessageRef(error.messageRef, error.message)\n}\n\n/**\n * The localized text of a clipboard fragment failure (EC-494) — parse (`corrupt-json`,\n * `foreign-format`, `unsupported-version`, `invalid-payload`) or build\n * (`node-not-found`, `fixture-not-found`), rendered by the builder's paste notice.\n */\nexport function clipboardErrorMessage(error: FragmentParseError | BuildFragmentError): string {\n return resolveMessageRef(error.messageRef, error.message)\n}\n\n/**\n * The localized text of a {@link ContentQueryError} (EC-494) — the filter builder's\n * per-clause line and the canvas listing notice, both of which render errors built by\n * `core/content`'s `validateWhere`/`validateSort`/`validateLimit`.\n */\nexport function contentQueryErrorMessage(error: ContentQueryError): string {\n return resolveMessageRef(error.messageRef, error.message)\n}\n\n/**\n * Plain-language title for an operation failure code — localized against the active\n * studio-UI locale. A runtime-unknown code (impossible typewise; a cast could smuggle\n * one) degrades to the generic title, never the raw enum.\n */\nexport function operationErrorLabel(code: OperationErrorCode): string {\n const key = OPERATION_ERROR_LABEL_KEYS[code] as string | undefined\n return translate(key ?? OPERATION_ERROR_LABEL_KEYS.unknown)\n}\n\n/** Friendly name for a project export target (`nextjs` → `Next.js`). */\nexport function exportTargetLabel(target: string): string {\n return EXPORT_TARGET_LABELS[target] ?? humanizeName(target)\n}\n\n/** The leaf of a component id, namespace stripped (`base.Heading` → `Heading`). */\nexport function componentLeafId(componentId: string): string {\n const dot = componentId.lastIndexOf('.')\n return dot === -1 ? componentId : componentId.slice(dot + 1)\n}\n\n/**\n * Strip the `base.`/`project.` namespace prefix from a component id for compact,\n * editor-facing labels (`base.Heading` → `Heading`). Unlike {@link componentLeafId}\n * it removes only those two known prefixes, so a dotted custom id keeps its shape.\n */\nexport function shortId(componentId: string): string {\n return componentId.replace('base.', '').replace('project.', '')\n}\n\n/**\n * The human display name of a component TYPE for editor-facing surfaces: the\n * manifest's authored `title` (EC-407: resolved through {@link resolveLabel}, so a\n * `{ en, de }` localized title renders in the active studio-UI locale) when present,\n * else the humanized leaf of its id (`base.Heading` → `Heading`,\n * `project.MarketingHero` → `Marketing hero`). Never the raw namespaced id.\n *\n * `locale` is optional: omit it (the render-site default) to resolve against the\n * active studio-UI locale non-reactively; pass it explicitly for pure/unit callers.\n */\nexport function componentDisplayName(\n componentId: string,\n manifest?: ComponentManifest | undefined,\n locale?: string,\n): string {\n const resolved =\n locale === undefined ? localizedLabel(manifest?.title) : (resolveLabel(manifest?.title, locale) ?? '')\n return resolved || humanizeName(componentLeafId(componentId))\n}\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CmsValidationIssue, RouteRecord } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { RedirectEntry } from '@elytracms/core/persistence';
|
|
3
|
+
/**
|
|
4
|
+
* The project's structural URL-map issues: route conflicts (from config routes),
|
|
5
|
+
* redirect loops (from the enabled editorial redirects), and declarations that cannot
|
|
6
|
+
* serve content at all (EC-484). Pure and total — it never throws, and an empty result
|
|
7
|
+
* means "no structural problem", not "not checked".
|
|
8
|
+
*/
|
|
9
|
+
export declare function routeValidationIssues(routes: readonly RouteRecord[], redirects?: readonly RedirectEntry[]): readonly CmsValidationIssue[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createRouter, isHierarchyTemplate, routeReachabilityIssues } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* The studio's URL-map validation (EC-469) — the pure core.
|
|
4
|
+
*
|
|
5
|
+
* `createRouter` has always computed structural issues (**route conflicts** and
|
|
6
|
+
* **redirect loops**) and, before this, no studio surface showed them. The delivery
|
|
7
|
+
* content client exposes them as `routerIssues`, but that path never reaches the
|
|
8
|
+
* studio: its only producer, the Convex `createContent` kit, has no studio caller —
|
|
9
|
+
* it serves the delivery host. So the studio computes them from the two inputs it
|
|
10
|
+
* already holds, which is also the only way it can: routes come from config
|
|
11
|
+
* (`resolveDeploymentRoutes`), redirects come from the editorial rail.
|
|
12
|
+
*
|
|
13
|
+
* This is NOT a revived Routes view (EC-303 retired that, correctly — routes are
|
|
14
|
+
* code). It is validation of the code + data the studio already reads.
|
|
15
|
+
*
|
|
16
|
+
* **Fidelity is the whole point**, so both of delivery's input rules are applied
|
|
17
|
+
* here, not approximated:
|
|
18
|
+
*
|
|
19
|
+
* - **Hierarchy mounts are filtered out** (`content/client.ts` does exactly this
|
|
20
|
+
* before constructing its router). A mount (`/:path*`, EC-218) is resolved
|
|
21
|
+
* per-request against the parent chain, not by the router. Counting it as a route
|
|
22
|
+
* makes `/:slug` + `/:path*` look like a conflict — measured — which the runtime
|
|
23
|
+
* does not consider one. A false alarm here would be worse than no alarm: it would
|
|
24
|
+
* teach developers to ignore the surface.
|
|
25
|
+
* - **Only ENABLED redirects count** (the Convex content kit filters `row.enabled`).
|
|
26
|
+
* `enabled` is the switch for *pausing* a redirect, so a paused chain is not live
|
|
27
|
+
* and must not be reported as a loop.
|
|
28
|
+
*
|
|
29
|
+
* `unknown-route-target` is deliberately NOT produced by the ROUTER here. Despite the
|
|
30
|
+
* ticket's framing it is not a router issue: the *resolver* raises it per resolved URL
|
|
31
|
+
* against the documents that exist (`content/client.ts`), so it needs a URL and a
|
|
32
|
+
* document pool, neither of which a project-level check has.
|
|
33
|
+
*
|
|
34
|
+
* ## Unreachable declarations (EC-484)
|
|
35
|
+
*
|
|
36
|
+
* Beside the two router codes, this reports declarations that serve NOTHING —
|
|
37
|
+
* measured, previously silent everywhere: an all-dynamic route (`/:slug`) matching
|
|
38
|
+
* every URL at a depth a hierarchy mount serves; and a catch-all with a dynamic prefix
|
|
39
|
+
* (`/:lang/:path*`), which every router drops and no mount is built from.
|
|
40
|
+
*
|
|
41
|
+
* **EC-488 narrowed the first one.** It used to mean the mount's root pages 404 though
|
|
42
|
+
* they exist in the studio; the resolver now falls through to the mounts when a matched
|
|
43
|
+
* route resolves no document, so those pages serve and the finding is a `warning` about
|
|
44
|
+
* content-decided precedence (a page whose composed path collides with the route
|
|
45
|
+
* collection's slug is still silently shadowed). The severity comes from the shared
|
|
46
|
+
* `routeReachabilityIssues`, so this surface tracks it without a change here.
|
|
47
|
+
*
|
|
48
|
+
* This one belongs in the studio, unlike the mount-vs-mount conflicts EC-482 left to
|
|
49
|
+
* `elytra doctor`, because of who meets the symptom: a 404 on a page the editor can
|
|
50
|
+
* see in the studio is what an editor reports and a developer cannot reproduce from a
|
|
51
|
+
* document. Naming it at the rail foot turns "the About page is broken" into a route
|
|
52
|
+
* id. It is also not a new precedent — this function has surfaced the code-side
|
|
53
|
+
* `route-conflict` since EC-469.
|
|
54
|
+
*
|
|
55
|
+
* The COLLECTION-dependent half of EC-484 (a mount whose collection has no `parent`
|
|
56
|
+
* self-relation, so it can never compose a path) is not here: this core is given
|
|
57
|
+
* routes and redirects, and `elytra doctor` — which loads the collections anyway —
|
|
58
|
+
* reports it at build time, where a developer fixes it.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* Collapse the several reports of ONE redirect cycle into one finding.
|
|
62
|
+
*
|
|
63
|
+
* `createRouter` walks every redirect and reports a loop per distinct loop *entry
|
|
64
|
+
* point* (`routes.ts`, EC-020) — correct for a machine, but a 2-hop cycle then reads
|
|
65
|
+
* as two separate bugs at the rail foot, and fixing one makes both vanish. Every
|
|
66
|
+
* rotation of a cycle visits the same set of paths, so the sorted member set is a
|
|
67
|
+
* stable identity for the cycle: dedupe on it, keep the first, and two genuinely
|
|
68
|
+
* different cycles still report separately. Presentation only — nothing is hidden, and
|
|
69
|
+
* `createRouter`'s shared behaviour is untouched.
|
|
70
|
+
*/
|
|
71
|
+
function collapseLoops(issues) {
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const issue of issues) {
|
|
75
|
+
if (issue.code !== 'redirect-loop') {
|
|
76
|
+
out.push(issue);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const chain = issue.meta?.chain;
|
|
80
|
+
const key = Array.isArray(chain)
|
|
81
|
+
? [...new Set(chain.map(String))].sort().join('|')
|
|
82
|
+
: issue.path.join('/');
|
|
83
|
+
if (seen.has(key))
|
|
84
|
+
continue;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
out.push(issue);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** Project the stored editorial records onto the routing shape delivery resolves. */
|
|
91
|
+
function liveRedirects(redirects) {
|
|
92
|
+
return redirects
|
|
93
|
+
.filter((entry) => entry.enabled)
|
|
94
|
+
.map((entry) => ({
|
|
95
|
+
id: entry.id,
|
|
96
|
+
from: entry.from,
|
|
97
|
+
to: entry.to,
|
|
98
|
+
permanent: entry.permanent,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The project's structural URL-map issues: route conflicts (from config routes),
|
|
103
|
+
* redirect loops (from the enabled editorial redirects), and declarations that cannot
|
|
104
|
+
* serve content at all (EC-484). Pure and total — it never throws, and an empty result
|
|
105
|
+
* means "no structural problem", not "not checked".
|
|
106
|
+
*/
|
|
107
|
+
export function routeValidationIssues(routes, redirects = []) {
|
|
108
|
+
const routable = routes.filter((route) => !isHierarchyTemplate(route));
|
|
109
|
+
return [
|
|
110
|
+
...collapseLoops(createRouter(routable, liveRedirects(redirects)).issues),
|
|
111
|
+
// Routes only: the reachability checks that need the collection set stay with
|
|
112
|
+
// `elytra doctor`, which has it. Passing no collections is the documented way to
|
|
113
|
+
// ask for exactly the route-side half.
|
|
114
|
+
...routeReachabilityIssues(routes),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=route-issues.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-issues.js","sourceRoot":"","sources":["../../../src/lib/routes/route-issues.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAIrG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH;;;;;;;;;;GAUG;AACH,SAAS,aAAa,CAAC,MAAqC;IAC1D,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;IAC9B,MAAM,GAAG,GAAyB,EAAE,CAAA;IACpC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,IAAI,KAAK,eAAe,EAAE,CAAC;YACnC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YACf,SAAQ;QACV,CAAC;QACD,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,EAAE,KAAK,CAAA;QAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAC9B,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;YAClD,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACxB,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAQ;QAC3B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACb,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACjB,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,qFAAqF;AACrF,SAAS,aAAa,CAAC,SAAmC;IACxD,OAAO,SAAS;SACb,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC;SAChC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACf,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,SAAS,EAAE,KAAK,CAAC,SAAS;KAC3B,CAAC,CAAC,CAAA;AACP,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,MAA8B,EAC9B,YAAsC,EAAE;IAExC,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,mBAAmB,CAAC,KAAK,CAAC,CAAC,CAAA;IACtE,OAAO;QACL,GAAG,aAAa,CAAC,YAAY,CAAC,QAAQ,EAAE,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC;QACzE,8EAA8E;QAC9E,iFAAiF;QACjF,uCAAuC;QACvC,GAAG,uBAAuB,CAAC,MAAM,CAAC;KACnC,CAAA;AACH,CAAC","sourcesContent":["import { createRouter, isHierarchyTemplate, routeReachabilityIssues } from '@elytracms/core/cms-core'\nimport type { CmsValidationIssue, RedirectRecord, RouteRecord } from '@elytracms/core/cms-core'\nimport type { RedirectEntry } from '@elytracms/core/persistence'\n\n/**\n * The studio's URL-map validation (EC-469) — the pure core.\n *\n * `createRouter` has always computed structural issues (**route conflicts** and\n * **redirect loops**) and, before this, no studio surface showed them. The delivery\n * content client exposes them as `routerIssues`, but that path never reaches the\n * studio: its only producer, the Convex `createContent` kit, has no studio caller —\n * it serves the delivery host. So the studio computes them from the two inputs it\n * already holds, which is also the only way it can: routes come from config\n * (`resolveDeploymentRoutes`), redirects come from the editorial rail.\n *\n * This is NOT a revived Routes view (EC-303 retired that, correctly — routes are\n * code). It is validation of the code + data the studio already reads.\n *\n * **Fidelity is the whole point**, so both of delivery's input rules are applied\n * here, not approximated:\n *\n * - **Hierarchy mounts are filtered out** (`content/client.ts` does exactly this\n * before constructing its router). A mount (`/:path*`, EC-218) is resolved\n * per-request against the parent chain, not by the router. Counting it as a route\n * makes `/:slug` + `/:path*` look like a conflict — measured — which the runtime\n * does not consider one. A false alarm here would be worse than no alarm: it would\n * teach developers to ignore the surface.\n * - **Only ENABLED redirects count** (the Convex content kit filters `row.enabled`).\n * `enabled` is the switch for *pausing* a redirect, so a paused chain is not live\n * and must not be reported as a loop.\n *\n * `unknown-route-target` is deliberately NOT produced by the ROUTER here. Despite the\n * ticket's framing it is not a router issue: the *resolver* raises it per resolved URL\n * against the documents that exist (`content/client.ts`), so it needs a URL and a\n * document pool, neither of which a project-level check has.\n *\n * ## Unreachable declarations (EC-484)\n *\n * Beside the two router codes, this reports declarations that serve NOTHING —\n * measured, previously silent everywhere: an all-dynamic route (`/:slug`) matching\n * every URL at a depth a hierarchy mount serves; and a catch-all with a dynamic prefix\n * (`/:lang/:path*`), which every router drops and no mount is built from.\n *\n * **EC-488 narrowed the first one.** It used to mean the mount's root pages 404 though\n * they exist in the studio; the resolver now falls through to the mounts when a matched\n * route resolves no document, so those pages serve and the finding is a `warning` about\n * content-decided precedence (a page whose composed path collides with the route\n * collection's slug is still silently shadowed). The severity comes from the shared\n * `routeReachabilityIssues`, so this surface tracks it without a change here.\n *\n * This one belongs in the studio, unlike the mount-vs-mount conflicts EC-482 left to\n * `elytra doctor`, because of who meets the symptom: a 404 on a page the editor can\n * see in the studio is what an editor reports and a developer cannot reproduce from a\n * document. Naming it at the rail foot turns \"the About page is broken\" into a route\n * id. It is also not a new precedent — this function has surfaced the code-side\n * `route-conflict` since EC-469.\n *\n * The COLLECTION-dependent half of EC-484 (a mount whose collection has no `parent`\n * self-relation, so it can never compose a path) is not here: this core is given\n * routes and redirects, and `elytra doctor` — which loads the collections anyway —\n * reports it at build time, where a developer fixes it.\n */\n\n/**\n * Collapse the several reports of ONE redirect cycle into one finding.\n *\n * `createRouter` walks every redirect and reports a loop per distinct loop *entry\n * point* (`routes.ts`, EC-020) — correct for a machine, but a 2-hop cycle then reads\n * as two separate bugs at the rail foot, and fixing one makes both vanish. Every\n * rotation of a cycle visits the same set of paths, so the sorted member set is a\n * stable identity for the cycle: dedupe on it, keep the first, and two genuinely\n * different cycles still report separately. Presentation only — nothing is hidden, and\n * `createRouter`'s shared behaviour is untouched.\n */\nfunction collapseLoops(issues: readonly CmsValidationIssue[]): CmsValidationIssue[] {\n const seen = new Set<string>()\n const out: CmsValidationIssue[] = []\n for (const issue of issues) {\n if (issue.code !== 'redirect-loop') {\n out.push(issue)\n continue\n }\n const chain = issue.meta?.chain\n const key = Array.isArray(chain)\n ? [...new Set(chain.map(String))].sort().join('|')\n : issue.path.join('/')\n if (seen.has(key)) continue\n seen.add(key)\n out.push(issue)\n }\n return out\n}\n\n/** Project the stored editorial records onto the routing shape delivery resolves. */\nfunction liveRedirects(redirects: readonly RedirectEntry[]): RedirectRecord[] {\n return redirects\n .filter((entry) => entry.enabled)\n .map((entry) => ({\n id: entry.id,\n from: entry.from,\n to: entry.to,\n permanent: entry.permanent,\n }))\n}\n\n/**\n * The project's structural URL-map issues: route conflicts (from config routes),\n * redirect loops (from the enabled editorial redirects), and declarations that cannot\n * serve content at all (EC-484). Pure and total — it never throws, and an empty result\n * means \"no structural problem\", not \"not checked\".\n */\nexport function routeValidationIssues(\n routes: readonly RouteRecord[],\n redirects: readonly RedirectEntry[] = [],\n): readonly CmsValidationIssue[] {\n const routable = routes.filter((route) => !isHierarchyTemplate(route))\n return [\n ...collapseLoops(createRouter(routable, liveRedirects(redirects)).issues),\n // Routes only: the reachability checks that need the collection set stay with\n // `elytra doctor`, which has it. Passing no collections is the documented way to\n // ask for exactly the route-side half.\n ...routeReachabilityIssues(routes),\n ]\n}\n"]}
|
|
@@ -3,8 +3,11 @@ import type { OperationEvent, OperationEventBus } from '@elytracms/core/operatio
|
|
|
3
3
|
* Revalidation webhook dispatcher (EC-146): subscribes to the EC-139
|
|
4
4
|
* operations event bus and POSTs a signed `{projectId, event, tags,
|
|
5
5
|
* timestamp}` payload to every configured host revalidate endpoint whenever a
|
|
6
|
-
*
|
|
7
|
-
* publish/unpublish
|
|
6
|
+
* command that can change **published** output succeeds (document
|
|
7
|
+
* publish/unpublish/pinVersion/delete, editorial redirect save/remove, plus
|
|
8
|
+
* asset edits — EC-501 narrowed this from "any document write": a draft save
|
|
9
|
+
* cannot move published state, so it fires nothing; EC-510 added the redirect
|
|
10
|
+
* writes, which plainly can). The host's `createRevalidateRoute` (`@elytracms/next`)
|
|
8
11
|
* verifies the HMAC and maps the tags onto `revalidateTag`.
|
|
9
12
|
*
|
|
10
13
|
* This runs **wherever the operations client runs** — in the v1 studio that
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../src/lib/webhooks/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AACrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;
|
|
1
|
+
{"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../src/lib/webhooks/dispatcher.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AACrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;AAqE5C,KAAK,UAAU,OAAO,CACpB,QAAyB,EACzB,MAAqB,EACrB,SAAiB,EACjB,OAAqB;IAErB,MAAM,OAAO,GAA6B;QACxC,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,SAAS;KACV,CAAA;IACD,+CAA+C;IAC/C,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvC,MAAM,SAAS,GAAG,MAAM,eAAe,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,CAAC,wBAAwB,CAAC,EAAE,SAAS;SACtC;QACD,IAAI,EAAE,OAAO;KACd,CAAC,CAAA;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,2BAA2B,QAAQ,CAAC,GAAG,aAAa,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;IACzF,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAyB,EACzB,OAAiC;IAEjC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,KAAK,CAAA;IACxC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAE7C,IAAI,OAAO,GAAkB,OAAO,CAAC,OAAO,EAAE,CAAA;IAE9C,MAAM,QAAQ,GAAG,CAAC,KAAqB,EAAQ,EAAE;QAC/C,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;YAAE,OAAM;QAC1C,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;QACvE,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,WAAW,EAAE,CAAA;QACrC,KAAK,MAAM,QAAQ,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACzC,OAAO,GAAG,OAAO;iBACd,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;iBACzD,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;gBACxB,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAA;YACxD,CAAC,CAAC,CAAA;QACN,CAAC;IACH,CAAC,CAAA;IAED,MAAM,WAAW,GAAG,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAA;IAC9C,OAAO;QACL,MAAM,EAAE,WAAW;QACnB,KAAK,EAAE,KAAK,IAAI,EAAE;YAChB,4DAA4D;YAC5D,IAAI,OAAsB,CAAA;YAC1B,GAAG,CAAC;gBACF,OAAO,GAAG,OAAO,CAAA;gBACjB,MAAM,OAAO,CAAA;YACf,CAAC,QAAQ,OAAO,KAAK,OAAO,EAAC;QAC/B,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { OperationEvent, OperationEventBus } from '@elytracms/core/operations'\nimport { WEBHOOK_SIGNATURE_HEADER, signWebhookBody } from './signing'\nimport { webhookTagsForEvent } from './tags'\nimport type { WebhookTagSet } from './tags'\n\n/**\n * Revalidation webhook dispatcher (EC-146): subscribes to the EC-139\n * operations event bus and POSTs a signed `{projectId, event, tags,\n * timestamp}` payload to every configured host revalidate endpoint whenever a\n * command that can change **published** output succeeds (document\n * publish/unpublish/pinVersion/delete, editorial redirect save/remove, plus\n * asset edits — EC-501 narrowed this from \"any document write\": a draft save\n * cannot move published state, so it fires nothing; EC-510 added the redirect\n * writes, which plainly can). The host's `createRevalidateRoute` (`@elytracms/next`)\n * verifies the HMAC and maps the tags onto `revalidateTag`.\n *\n * This runs **wherever the operations client runs** — in the v1 studio that\n * is the browser, so deliveries are cross-origin `fetch` calls. CORS\n * implications (documented honestly):\n *\n * - the custom signature header always triggers a preflight, so the host\n * endpoint must answer `OPTIONS` and allow the studio origin —\n * `createRevalidateRoute({ allowOrigin })` does both;\n * - the shared secret necessarily ships in the studio bundle in this mode.\n * Acceptable for v1 (the secret only gates cache invalidation, never\n * content access); the server-side Convex action variant\n * (`apps/builder/convex/webhooks.ts`) keeps the secret out of the browser\n * for Convex-backed deployments.\n *\n * Deliveries are fire-and-forget on a serial queue (same pattern as the\n * EC-155 reference index): a slow or unreachable host never blocks the\n * command pipeline, failures surface through `onError` only, and tests await\n * `flush()` for determinism.\n */\n\nexport interface WebhookEndpoint {\n /** Absolute URL of the host's revalidate route, e.g. `https://site/api/revalidate`. */\n url: string\n /** Shared HMAC secret for this endpoint. */\n secret: string\n}\n\nexport interface WebhookDispatcherOptions {\n endpoints: readonly WebhookEndpoint[]\n /**\n * All locales the project serves. Document events sweep the collection\n * membership tag once per locale (`collection:<name>:<locale>`).\n */\n locales: readonly string[]\n /** Injectable transport (tests); defaults to global `fetch`. */\n fetchFn?: typeof fetch\n /** Injectable clock (tests); payload `timestamp` is `now().toISOString()`. */\n now?: () => Date\n /** Delivery failures land here (never thrown into the command pipeline). */\n onError?: (error: unknown, context: { url: string; event: OperationEvent }) => void\n}\n\nexport interface WebhookDispatcherAttachment {\n detach(): void\n /** Await all queued deliveries (tests / shutdown). */\n flush(): Promise<void>\n}\n\n/** The signed payload shape (mirror of `revalidatePayloadSchema` in @elytracms/next). */\nexport interface RevalidateWebhookPayload {\n projectId: string\n event: string\n tags: string[]\n timestamp: string\n}\n\nasync function deliver(\n endpoint: WebhookEndpoint,\n tagSet: WebhookTagSet,\n timestamp: string,\n fetchFn: typeof fetch,\n): Promise<void> {\n const payload: RevalidateWebhookPayload = {\n projectId: tagSet.projectId,\n event: tagSet.event,\n tags: tagSet.tags,\n timestamp,\n }\n // Sign the exact string that goes on the wire.\n const rawBody = JSON.stringify(payload)\n const signature = await signWebhookBody(rawBody, endpoint.secret)\n const response = await fetchFn(endpoint.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n [WEBHOOK_SIGNATURE_HEADER]: signature,\n },\n body: rawBody,\n })\n if (!response.ok) {\n throw new Error(`Revalidation webhook to ${endpoint.url} answered ${response.status}.`)\n }\n}\n\n/**\n * Attach the dispatcher to an event bus; returns a detach/flush handle.\n * With no endpoints configured this still subscribes but never queues work.\n */\nexport function attachRevalidationWebhooks(\n events: OperationEventBus,\n options: WebhookDispatcherOptions,\n): WebhookDispatcherAttachment {\n const fetchFn = options.fetchFn ?? fetch\n const now = options.now ?? (() => new Date())\n\n let pending: Promise<void> = Promise.resolve()\n\n const listener = (event: OperationEvent): void => {\n if (options.endpoints.length === 0) return\n const tagSet = webhookTagsForEvent(event, { locales: options.locales })\n if (!tagSet) return\n const timestamp = now().toISOString()\n for (const endpoint of options.endpoints) {\n pending = pending\n .then(() => deliver(endpoint, tagSet, timestamp, fetchFn))\n .catch((error: unknown) => {\n options.onError?.(error, { url: endpoint.url, event })\n })\n }\n }\n\n const unsubscribe = events.subscribe(listener)\n return {\n detach: unsubscribe,\n flush: async () => {\n // Drain until no new deliveries were queued while awaiting.\n let current: Promise<void>\n do {\n current = pending\n await current\n } while (current !== pending)\n },\n }\n}\n"]}
|