@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
|
@@ -16,23 +16,41 @@
|
|
|
16
16
|
*
|
|
17
17
|
* ## Which events produce which tags
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* The gate is **"can this operation change PUBLISHED output?"** — not "did this
|
|
20
|
+
* operation touch a document" (EC-501). Live delivery is published-only, so an
|
|
21
|
+
* operation that cannot move published state has no cache entry to drop and
|
|
22
|
+
* every tag it emits is waste by construction.
|
|
23
|
+
*
|
|
24
|
+
* - **Document publish/unpublish/pin/delete** (`documents.publish`,
|
|
25
|
+
* `documents.unpublish`, `documents.pinVersion`, `documents.delete`): the
|
|
26
|
+
* document's own
|
|
22
27
|
* `doc:<collection>:<id>` tag — dropping its detail page *and every cached
|
|
23
28
|
* list that actually contained it* (fetch-time tagging) — plus the coarse
|
|
24
29
|
* `collection:<name>:<locale>` membership tag for **all** configured
|
|
25
30
|
* locales, sweeping cached lists whose membership may have changed
|
|
26
31
|
* (new/removed/newly-matching documents).
|
|
32
|
+
* - **Document SAVE** (`documents.upsert`) fires **nothing** — see
|
|
33
|
+
* {@link PUBLISHED_STATE_OPERATIONS}.
|
|
34
|
+
* - **Editorial redirect write** (`redirects.save`, `redirects.remove`,
|
|
35
|
+
* EC-510): the whole-project sweep tag — deliberately coarse, see
|
|
36
|
+
* {@link ROUTING_OPERATIONS}.
|
|
27
37
|
* - **Asset edit** (`assets.upsert` of an existing asset, `assets.remove`,
|
|
28
38
|
* EC-227): the `asset:<id>` tag, dropping exactly the pages that *baked* that
|
|
29
39
|
* asset's delivery shape (alt/dimensions/url). An `assets.upsert` CREATE (no
|
|
30
40
|
* id in the summary — minted server-side) fires nothing: a brand-new asset is
|
|
31
41
|
* in no cached page yet.
|
|
32
42
|
* - **Fallback**: when a `documents` (or `assets.remove`) event's input summary
|
|
33
|
-
* was truncated past the identity (EC-139 caps summaries at 160 chars
|
|
34
|
-
*
|
|
35
|
-
*
|
|
43
|
+
* was truncated past the identity (EC-139 caps summaries at 160 chars), the
|
|
44
|
+
* project sweep tag is emitted — coarse, always correct, mirroring the EC-155
|
|
45
|
+
* reference-index fallback. Since EC-501 the surviving document operations
|
|
46
|
+
* all carry a small `{ projectId, ref }` input, so this is a defensive path
|
|
47
|
+
* rather than a routine one — the routine truncation was the document-bearing
|
|
48
|
+
* `documents.upsert`, which no longer reaches here at all.
|
|
49
|
+
*
|
|
50
|
+
* Every branch needs only the `projectId`, which serializes FIRST in every
|
|
51
|
+
* operation input (measured 2026-08-01 across the real `redirects.save`
|
|
52
|
+
* shapes), so {@link projectIdOfSummary}'s regex survives truncation even when
|
|
53
|
+
* the rest of the summary is cut mid-string.
|
|
36
54
|
*/
|
|
37
55
|
function scoped(projectId, tag) {
|
|
38
56
|
return `p:${projectId}:${tag}`;
|
|
@@ -53,17 +71,125 @@ export function assetWebhookTag(projectId, assetId) {
|
|
|
53
71
|
export function projectSweepWebhookTag(projectId) {
|
|
54
72
|
return scoped(projectId, 'project');
|
|
55
73
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The document operations that can change **published** output, and therefore
|
|
76
|
+
* the ones whose success must reach the host's revalidate endpoint.
|
|
77
|
+
*
|
|
78
|
+
* EC-224: publish/unpublish/pinVersion move the version pointer; their
|
|
79
|
+
* `{ projectId, ref }` input drives the doc + per-locale collection tag set
|
|
80
|
+
* (the flat `documents.setState` op is gone). `documents.delete` removes the
|
|
81
|
+
* row outright, taking any pinned version with it.
|
|
82
|
+
*
|
|
83
|
+
* ## Why `documents.upsert` (a draft SAVE) is NOT here — EC-501
|
|
84
|
+
*
|
|
85
|
+
* A save writes the **draft row only**; published readers serve the *pinned
|
|
86
|
+
* version snapshot*, never the draft. That is structural, not a heuristic:
|
|
87
|
+
*
|
|
88
|
+
* - the operation hands the document straight to `cms.upsertDocument`
|
|
89
|
+
* (`core/operations/documents.ts`), whose Convex arg list has no
|
|
90
|
+
* `publishedVersion` field at all (`studio/convex/cms.ts` `upsertDocument`),
|
|
91
|
+
* and whose in-memory twin re-attaches the previous pin verbatim with the
|
|
92
|
+
* comment "only publish/unpublish move the pointer"
|
|
93
|
+
* (`core/persistence/cms.ts`);
|
|
94
|
+
* - the published delivery snapshot skips every row without a pin and serves
|
|
95
|
+
* the pinned snapshot, "never falling back to the draft"
|
|
96
|
+
* (`studio/convex/delivery.ts`);
|
|
97
|
+
* - the Convex write-chokepoint trigger already encodes exactly this verdict
|
|
98
|
+
* server-side — it schedules a revalidation only when `publishedVersion`
|
|
99
|
+
* actually changed, "a plain draft save must not invalidate the published
|
|
100
|
+
* host cache" (`studio/convex/triggers.ts`, EC-286/EC-146). This emitter was
|
|
101
|
+
* simply the one path that never asked.
|
|
102
|
+
*
|
|
103
|
+
* Measured before the change (connected studio → capture endpoint, one short
|
|
104
|
+
* editing session): 12 `documents.upsert` deliveries to 1 `documents.publish`,
|
|
105
|
+
* and 7 of those 12 carried the **whole-project sweep** tag rather than the
|
|
106
|
+
* document's own — because a real document's `inputSummary` exceeds the EC-139
|
|
107
|
+
* 160-char cap, so the identity parse below fails and falls back to the sweep.
|
|
108
|
+
* A single keystroke-batch on a live page was dropping every cached page *and*
|
|
109
|
+
* every cached 404 on the site (EC-489 widened 404s to carry collection tags).
|
|
110
|
+
*
|
|
111
|
+
* ## The one documented exception (deliberate, not an oversight)
|
|
112
|
+
*
|
|
113
|
+
* `cms-data-sources` rows are the sole document class the published delivery
|
|
114
|
+
* snapshot reads from the **draft** side, on purpose — data sources are project
|
|
115
|
+
* config, so published bindings must resolve even when the defining row is not
|
|
116
|
+
* itself published (`studio/convex/delivery.ts`). A save to that system
|
|
117
|
+
* collection therefore *can* change published output and is now silent.
|
|
118
|
+
* Accepted because: (a) it is a retired-builder remnant (EC-166/167) with
|
|
119
|
+
* **zero writers** left in the studio — `provisionSource`/`removeSource` have
|
|
120
|
+
* no call sites; (b) it only ever invalidated *by accident*, through the
|
|
121
|
+
* truncated-summary sweep fallback, never through a precise tag; and (c) a
|
|
122
|
+
* collection-scoped carve-out cannot work here — the collection name is exactly
|
|
123
|
+
* what the truncated summary loses. If that data layer is ever revived, the fix
|
|
124
|
+
* belongs at the delivery trust boundary (serve sources published-only), not by
|
|
125
|
+
* re-broadening this set.
|
|
126
|
+
*/
|
|
127
|
+
const PUBLISHED_STATE_OPERATIONS = new Set([
|
|
59
128
|
'documents.delete',
|
|
60
|
-
// EC-224: publish/unpublish/pinVersion move the version pointer; their
|
|
61
|
-
// `{ projectId, ref }` input drives the same doc + per-locale collection tag
|
|
62
|
-
// set (the flat `documents.setState` op is gone).
|
|
63
129
|
'documents.publish',
|
|
64
130
|
'documents.unpublish',
|
|
65
131
|
'documents.pinVersion',
|
|
66
132
|
]);
|
|
133
|
+
/**
|
|
134
|
+
* The **routing** operations (EC-510): editorial redirects, which EC-303 moved
|
|
135
|
+
* out of config and into the database. Every *enabled* redirect row is
|
|
136
|
+
* projected into the delivery snapshot the router and `resolvePage` consume
|
|
137
|
+
* (`studio/convex/delivery.ts`), in BOTH perspectives — so saving, retargeting,
|
|
138
|
+
* disabling or deleting one changes published output, exactly the question
|
|
139
|
+
* {@link PUBLISHED_STATE_OPERATIONS} asks. They belong to that question, not a
|
|
140
|
+
* parallel one; they are a separate set only because their tag is different.
|
|
141
|
+
*
|
|
142
|
+
* ## Why the coarse project sweep and NOT a targeted `route:<from>:<locale>`
|
|
143
|
+
*
|
|
144
|
+
* The tag vocabulary has `route:<pattern>:<locale>` and a redirect's `from` IS
|
|
145
|
+
* a route path, so a targeted tag looks available. It is not — it would be
|
|
146
|
+
* silently wrong in four independent ways, and a silent wrong invalidation is
|
|
147
|
+
* the exact failure family EC-489/EC-501/this ticket exist to remove:
|
|
148
|
+
*
|
|
149
|
+
* 1. **`redirects.remove` has no `from` at all.** Its input is
|
|
150
|
+
* `{ projectId, id }` (`core/operations/redirects.ts`), so the path whose
|
|
151
|
+
* cache entry must drop is not in the event. Precision is unobtainable, and
|
|
152
|
+
* a delete is the single most important case to get right.
|
|
153
|
+
* 2. **A save can RENAME `from`.** The event carries only the NEW path; the OLD
|
|
154
|
+
* one keeps serving a redirect that no longer exists. `OperationEvent` has
|
|
155
|
+
* `operation`/`kind`/`status`/`inputSummary` and nothing else — no result,
|
|
156
|
+
* no previous row (EC-501 rejected widening it for the same reason) — so the
|
|
157
|
+
* emitter cannot even detect that a rename happened.
|
|
158
|
+
* 3. **Normalization would have to be mirrored.** The cached entry is tagged
|
|
159
|
+
* with the resolver's `normalizePath(url)`; `from` is editor-typed free text
|
|
160
|
+
* (`z.string().min(1)`), stored verbatim and normalized only at match time.
|
|
161
|
+
* A trailing slash or a query string would produce a tag that matches
|
|
162
|
+
* nothing — no error, no invalidation.
|
|
163
|
+
* 4. **The identity truncates.** MEASURED 2026-08-01 on the real schema: a
|
|
164
|
+
* Konditorei-shaped migration redirect (legacy permalink → new path)
|
|
165
|
+
* serializes to 226 chars and an EC-311 `meta` bag to 216, both past the
|
|
166
|
+
* EC-139 160-char cap. A `from`-based tag would work on a demo-sized
|
|
167
|
+
* redirect and degrade on the real one — EC-501's pathology in reverse.
|
|
168
|
+
*
|
|
169
|
+
* The sweep has none of those failure modes: it needs only `projectId`, which
|
|
170
|
+
* serializes first and therefore survives truncation, so **the truncation
|
|
171
|
+
* fallback and the primary answer are the same tag** — correct by design rather
|
|
172
|
+
* than by accident. `nextCacheTags` stamps `project` on every cached entry, so
|
|
173
|
+
* it is guaranteed to reach the affected URL whatever the redirect changed.
|
|
174
|
+
*
|
|
175
|
+
* ## The cost, and why it is affordable here (unlike EC-501)
|
|
176
|
+
*
|
|
177
|
+
* A sweep drops every cached entry, lazily on next read. That was intolerable
|
|
178
|
+
* for `documents.upsert` because it fired on every ~1s keystroke batch and
|
|
179
|
+
* *none* of those invalidations were necessary. A redirect write is an
|
|
180
|
+
* explicit form save on a rail with `inlineEdit: false` and no autosave
|
|
181
|
+
* (`redirects-collection-view-source.ts`), and every one of them IS a routing
|
|
182
|
+
* change. Measured on a connected studio: a session that deliberately exercised
|
|
183
|
+
* every mutation kind — create, retarget, disable, rename, delete — produced 5
|
|
184
|
+
* writes. The Convex-side path already sweeps the project on every document
|
|
185
|
+
* publish (`convex/revalidate.ts`, "the honest, reliable v1"), so this is
|
|
186
|
+
* strictly rarer than a cost the engine already pays.
|
|
187
|
+
*
|
|
188
|
+
* If a precise redirect tag is ever wanted, the place to get it is the Convex
|
|
189
|
+
* write chokepoint, where `change.oldDoc` holds the previous `from` — not here,
|
|
190
|
+
* where the information does not exist.
|
|
191
|
+
*/
|
|
192
|
+
const ROUTING_OPERATIONS = new Set(['redirects.save', 'redirects.remove']);
|
|
67
193
|
/**
|
|
68
194
|
* Asset-record mutations (EC-227): editing an asset (alt/dimensions/re-upload)
|
|
69
195
|
* must drop the published pages that *baked* its delivery shape, which carry the
|
|
@@ -107,13 +233,19 @@ function documentRefOf(value) {
|
|
|
107
233
|
export function webhookTagsForEvent(event, options) {
|
|
108
234
|
if (event.status !== 'succeeded' || event.kind !== 'command')
|
|
109
235
|
return null;
|
|
110
|
-
const isDocument =
|
|
236
|
+
const isDocument = PUBLISHED_STATE_OPERATIONS.has(event.operation);
|
|
111
237
|
const isAsset = ASSET_OPERATIONS.has(event.operation);
|
|
112
|
-
|
|
238
|
+
const isRouting = ROUTING_OPERATIONS.has(event.operation);
|
|
239
|
+
if (!isDocument && !isAsset && !isRouting)
|
|
113
240
|
return null;
|
|
114
241
|
const projectId = projectIdOfSummary(event.inputSummary);
|
|
115
242
|
if (projectId === undefined)
|
|
116
243
|
return null;
|
|
244
|
+
// EC-510: one answer for every redirect write — see `ROUTING_OPERATIONS` for
|
|
245
|
+
// why the targeted `route:` tag is unobtainable and the sweep is the honest one.
|
|
246
|
+
if (isRouting) {
|
|
247
|
+
return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] };
|
|
248
|
+
}
|
|
117
249
|
if (isAsset)
|
|
118
250
|
return assetTagsForEvent(projectId, event);
|
|
119
251
|
const parsed = parseSummary(event.inputSummary);
|
|
@@ -126,9 +258,10 @@ export function webhookTagsForEvent(event, options) {
|
|
|
126
258
|
for (const locale of options.locales) {
|
|
127
259
|
tags.add(collectionWebhookTag(projectId, ref.collection, locale));
|
|
128
260
|
}
|
|
129
|
-
// EC-207:
|
|
130
|
-
//
|
|
131
|
-
//
|
|
261
|
+
// EC-207: ROUTES are config-owned (a code deploy, which rebuilds the host), so
|
|
262
|
+
// a document event never needs a `route:` tag. Redirects are NOT — EC-303 made
|
|
263
|
+
// them editorial data and EC-510 gives their own operations the project sweep
|
|
264
|
+
// (see `ROUTING_OPERATIONS`); a *document* write still cannot change them.
|
|
132
265
|
return { projectId, event: event.operation, tags: [...tags].sort() };
|
|
133
266
|
}
|
|
134
267
|
/** A non-empty string, else `undefined` (the asset id out of a summary). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tags.js","sourceRoot":"","sources":["../../../src/lib/webhooks/tags.ts"],"names":[],"mappings":"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","sourcesContent":["import type { OperationEvent } from '@elytracms/core/operations'\n\n/**\n * Pure tag computation for the EC-146 revalidation webhooks: map a succeeded\n * publish-shaped operation event onto the Next cache tags it invalidates.\n *\n * ## Tag format (mirror of `packages/next/src/cache.ts`)\n *\n * The canonical tag format lives in `@elytracms/next` (`src/cache.ts`). The\n * builder cannot depend on that package — it would drag the `next` peer\n * dependency into the studio bundle — so the three formatters are mirrored\n * here. Both sides pin the exact literal strings in their tests\n * (`tags.test.ts` here, `cache.test.ts` there) to keep them in lock-step.\n *\n * Every emitted tag is scoped `p:<projectId>:…` — the dispatcher always knows\n * the project id, and the scope keeps multi-project hosts from\n * cross-invalidating. Hosts must configure the same id as `cache.scope`.\n *\n * ## Which events produce which tags\n *\n * - **Document publish/unpublish/delete/save** (`documents.publish`,\n * `documents.unpublish`, `documents.pinVersion`, `documents.upsert`,\n * `documents.delete`): the document's own\n * `doc:<collection>:<id>` tag — dropping its detail page *and every cached\n * list that actually contained it* (fetch-time tagging) — plus the coarse\n * `collection:<name>:<locale>` membership tag for **all** configured\n * locales, sweeping cached lists whose membership may have changed\n * (new/removed/newly-matching documents).\n * - **Asset edit** (`assets.upsert` of an existing asset, `assets.remove`,\n * EC-227): the `asset:<id>` tag, dropping exactly the pages that *baked* that\n * asset's delivery shape (alt/dimensions/url). An `assets.upsert` CREATE (no\n * id in the summary — minted server-side) fires nothing: a brand-new asset is\n * in no cached page yet.\n * - **Fallback**: when a `documents` (or `assets.remove`) event's input summary\n * was truncated past the identity (EC-139 caps summaries at 160 chars; only\n * very large upserts hit this), the project sweep tag is emitted — coarse,\n * always correct, mirroring the EC-155 reference-index fallback.\n */\n\nfunction scoped(projectId: string, tag: string): string {\n return `p:${projectId}:${tag}`\n}\n\n/** `p:<projectId>:doc:<collection>:<id>` — mirror of `docCacheTag`. */\nexport function docWebhookTag(projectId: string, collection: string, id: string): string {\n return scoped(projectId, `doc:${collection}:${id}`)\n}\n\n/** `p:<projectId>:collection:<name>:<locale>` — mirror of `collectionCacheTag`. */\nexport function collectionWebhookTag(\n projectId: string,\n collection: string,\n locale: string,\n): string {\n return scoped(projectId, `collection:${collection}:${locale}`)\n}\n\n/** `p:<projectId>:asset:<id>` — mirror of `assetCacheTag` (EC-227). */\nexport function assetWebhookTag(projectId: string, assetId: string): string {\n return scoped(projectId, `asset:${assetId}`)\n}\n\n/** `p:<projectId>:project` — mirror of `projectSweepTag`. */\nexport function projectSweepWebhookTag(projectId: string): string {\n return scoped(projectId, 'project')\n}\n\n/** The operations whose success must reach the host's revalidate endpoint. */\nconst DOCUMENT_OPERATIONS = new Set([\n 'documents.upsert',\n 'documents.delete',\n // EC-224: publish/unpublish/pinVersion move the version pointer; their\n // `{ projectId, ref }` input drives the same doc + per-locale collection tag\n // set (the flat `documents.setState` op is gone).\n 'documents.publish',\n 'documents.unpublish',\n 'documents.pinVersion',\n])\n/**\n * Asset-record mutations (EC-227): editing an asset (alt/dimensions/re-upload)\n * must drop the published pages that *baked* its delivery shape, which carry the\n * `asset:<id>` tag. `assets.remove` always carries `assetId`; `assets.upsert`\n * carries `asset.id` only on an EDIT (a CREATE mints the id server-side).\n */\nconst ASSET_OPERATIONS = new Set(['assets.upsert', 'assets.remove'])\n\n/** One computed webhook emission: which project, why, and which tags. */\nexport interface WebhookTagSet {\n projectId: string\n /** The operation name, carried verbatim as the webhook `event`. */\n event: string\n /** Sorted, deduplicated, fully scoped Next tag strings. */\n tags: string[]\n}\n\n/** Tolerant parse of an EC-139 input summary (may be truncated past 160 chars). */\nfunction parseSummary(summary: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(summary)\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : undefined\n } catch {\n return undefined\n }\n}\n\n/** Extract the projectId even from a truncated summary (it serializes first). */\nfunction projectIdOfSummary(summary: string): string | undefined {\n const match = /\"projectId\":\"([^\"]+)\"/.exec(summary)\n return match?.[1]\n}\n\nfunction documentRefOf(value: unknown): { collection: string; id: string } | undefined {\n if (typeof value !== 'object' || value === null) return undefined\n const candidate = value as { collection?: unknown; id?: unknown }\n return typeof candidate.collection === 'string' &&\n candidate.collection.length > 0 &&\n typeof candidate.id === 'string' &&\n candidate.id.length > 0\n ? { collection: candidate.collection, id: candidate.id }\n : undefined\n}\n\n/**\n * Compute the webhook tag set for one lifecycle event, or `null` when the\n * event is not a successful publish-shaped command (queries, failures, and\n * unrelated domains never fire webhooks).\n */\nexport function webhookTagsForEvent(\n event: Pick<OperationEvent, 'operation' | 'kind' | 'status' | 'inputSummary'>,\n options: { locales: readonly string[] },\n): WebhookTagSet | null {\n if (event.status !== 'succeeded' || event.kind !== 'command') return null\n\n const isDocument = DOCUMENT_OPERATIONS.has(event.operation)\n const isAsset = ASSET_OPERATIONS.has(event.operation)\n if (!isDocument && !isAsset) return null\n\n const projectId = projectIdOfSummary(event.inputSummary)\n if (projectId === undefined) return null\n\n if (isAsset) return assetTagsForEvent(projectId, event)\n\n const parsed = parseSummary(event.inputSummary)\n const ref = documentRefOf(parsed?.['ref']) ?? documentRefOf(parsed?.['document'])\n if (!ref) {\n // Truncated summary (large upsert payload): honest coarse fallback.\n return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n }\n\n const tags = new Set<string>([docWebhookTag(projectId, ref.collection, ref.id)])\n for (const locale of options.locales) {\n tags.add(collectionWebhookTag(projectId, ref.collection, locale))\n }\n // EC-207: routes/redirects are config-owned (a code deploy, which rebuilds the\n // host) — they are no longer DB documents, so there is no route-document event\n // to sweep the project for.\n return { projectId, event: event.operation, tags: [...tags].sort() }\n}\n\n/** A non-empty string, else `undefined` (the asset id out of a summary). */\nfunction stringOf(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined\n}\n\n/**\n * Tags for an asset mutation (EC-227). The edit cases — `assets.remove` (carries\n * `assetId`) and `assets.upsert` of an existing asset (carries `asset.id`) —\n * emit exactly `asset:<id>`, dropping the pages that baked that asset. An\n * `assets.upsert` with **no** id is a CREATE: the id is minted server-side and a\n * brand-new asset cannot be baked into any existing page, so it invalidates\n * nothing (`null`, no webhook). A `remove` missing its id means a truncated\n * summary — sweep the project (coarse, always correct — the document fallback).\n */\nfunction assetTagsForEvent(\n projectId: string,\n event: Pick<OperationEvent, 'operation' | 'inputSummary'>,\n): WebhookTagSet | null {\n const parsed = parseSummary(event.inputSummary)\n const assetId =\n event.operation === 'assets.remove'\n ? stringOf(parsed?.['assetId'])\n : stringOf((parsed?.['asset'] as { id?: unknown } | undefined)?.id)\n if (assetId) {\n return { projectId, event: event.operation, tags: [assetWebhookTag(projectId, assetId)] }\n }\n return event.operation === 'assets.remove'\n ? { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n : null\n}\n"]}
|
|
1
|
+
{"version":3,"file":"tags.js","sourceRoot":"","sources":["../../../src/lib/webhooks/tags.ts"],"names":[],"mappings":"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","sourcesContent":["import type { OperationEvent } from '@elytracms/core/operations'\n\n/**\n * Pure tag computation for the EC-146 revalidation webhooks: map a succeeded\n * publish-shaped operation event onto the Next cache tags it invalidates.\n *\n * ## Tag format (mirror of `packages/next/src/cache.ts`)\n *\n * The canonical tag format lives in `@elytracms/next` (`src/cache.ts`). The\n * builder cannot depend on that package — it would drag the `next` peer\n * dependency into the studio bundle — so the three formatters are mirrored\n * here. Both sides pin the exact literal strings in their tests\n * (`tags.test.ts` here, `cache.test.ts` there) to keep them in lock-step.\n *\n * Every emitted tag is scoped `p:<projectId>:…` — the dispatcher always knows\n * the project id, and the scope keeps multi-project hosts from\n * cross-invalidating. Hosts must configure the same id as `cache.scope`.\n *\n * ## Which events produce which tags\n *\n * The gate is **\"can this operation change PUBLISHED output?\"** — not \"did this\n * operation touch a document\" (EC-501). Live delivery is published-only, so an\n * operation that cannot move published state has no cache entry to drop and\n * every tag it emits is waste by construction.\n *\n * - **Document publish/unpublish/pin/delete** (`documents.publish`,\n * `documents.unpublish`, `documents.pinVersion`, `documents.delete`): the\n * document's own\n * `doc:<collection>:<id>` tag — dropping its detail page *and every cached\n * list that actually contained it* (fetch-time tagging) — plus the coarse\n * `collection:<name>:<locale>` membership tag for **all** configured\n * locales, sweeping cached lists whose membership may have changed\n * (new/removed/newly-matching documents).\n * - **Document SAVE** (`documents.upsert`) fires **nothing** — see\n * {@link PUBLISHED_STATE_OPERATIONS}.\n * - **Editorial redirect write** (`redirects.save`, `redirects.remove`,\n * EC-510): the whole-project sweep tag — deliberately coarse, see\n * {@link ROUTING_OPERATIONS}.\n * - **Asset edit** (`assets.upsert` of an existing asset, `assets.remove`,\n * EC-227): the `asset:<id>` tag, dropping exactly the pages that *baked* that\n * asset's delivery shape (alt/dimensions/url). An `assets.upsert` CREATE (no\n * id in the summary — minted server-side) fires nothing: a brand-new asset is\n * in no cached page yet.\n * - **Fallback**: when a `documents` (or `assets.remove`) event's input summary\n * was truncated past the identity (EC-139 caps summaries at 160 chars), the\n * project sweep tag is emitted — coarse, always correct, mirroring the EC-155\n * reference-index fallback. Since EC-501 the surviving document operations\n * all carry a small `{ projectId, ref }` input, so this is a defensive path\n * rather than a routine one — the routine truncation was the document-bearing\n * `documents.upsert`, which no longer reaches here at all.\n *\n * Every branch needs only the `projectId`, which serializes FIRST in every\n * operation input (measured 2026-08-01 across the real `redirects.save`\n * shapes), so {@link projectIdOfSummary}'s regex survives truncation even when\n * the rest of the summary is cut mid-string.\n */\n\nfunction scoped(projectId: string, tag: string): string {\n return `p:${projectId}:${tag}`\n}\n\n/** `p:<projectId>:doc:<collection>:<id>` — mirror of `docCacheTag`. */\nexport function docWebhookTag(projectId: string, collection: string, id: string): string {\n return scoped(projectId, `doc:${collection}:${id}`)\n}\n\n/** `p:<projectId>:collection:<name>:<locale>` — mirror of `collectionCacheTag`. */\nexport function collectionWebhookTag(\n projectId: string,\n collection: string,\n locale: string,\n): string {\n return scoped(projectId, `collection:${collection}:${locale}`)\n}\n\n/** `p:<projectId>:asset:<id>` — mirror of `assetCacheTag` (EC-227). */\nexport function assetWebhookTag(projectId: string, assetId: string): string {\n return scoped(projectId, `asset:${assetId}`)\n}\n\n/** `p:<projectId>:project` — mirror of `projectSweepTag`. */\nexport function projectSweepWebhookTag(projectId: string): string {\n return scoped(projectId, 'project')\n}\n\n/**\n * The document operations that can change **published** output, and therefore\n * the ones whose success must reach the host's revalidate endpoint.\n *\n * EC-224: publish/unpublish/pinVersion move the version pointer; their\n * `{ projectId, ref }` input drives the doc + per-locale collection tag set\n * (the flat `documents.setState` op is gone). `documents.delete` removes the\n * row outright, taking any pinned version with it.\n *\n * ## Why `documents.upsert` (a draft SAVE) is NOT here — EC-501\n *\n * A save writes the **draft row only**; published readers serve the *pinned\n * version snapshot*, never the draft. That is structural, not a heuristic:\n *\n * - the operation hands the document straight to `cms.upsertDocument`\n * (`core/operations/documents.ts`), whose Convex arg list has no\n * `publishedVersion` field at all (`studio/convex/cms.ts` `upsertDocument`),\n * and whose in-memory twin re-attaches the previous pin verbatim with the\n * comment \"only publish/unpublish move the pointer\"\n * (`core/persistence/cms.ts`);\n * - the published delivery snapshot skips every row without a pin and serves\n * the pinned snapshot, \"never falling back to the draft\"\n * (`studio/convex/delivery.ts`);\n * - the Convex write-chokepoint trigger already encodes exactly this verdict\n * server-side — it schedules a revalidation only when `publishedVersion`\n * actually changed, \"a plain draft save must not invalidate the published\n * host cache\" (`studio/convex/triggers.ts`, EC-286/EC-146). This emitter was\n * simply the one path that never asked.\n *\n * Measured before the change (connected studio → capture endpoint, one short\n * editing session): 12 `documents.upsert` deliveries to 1 `documents.publish`,\n * and 7 of those 12 carried the **whole-project sweep** tag rather than the\n * document's own — because a real document's `inputSummary` exceeds the EC-139\n * 160-char cap, so the identity parse below fails and falls back to the sweep.\n * A single keystroke-batch on a live page was dropping every cached page *and*\n * every cached 404 on the site (EC-489 widened 404s to carry collection tags).\n *\n * ## The one documented exception (deliberate, not an oversight)\n *\n * `cms-data-sources` rows are the sole document class the published delivery\n * snapshot reads from the **draft** side, on purpose — data sources are project\n * config, so published bindings must resolve even when the defining row is not\n * itself published (`studio/convex/delivery.ts`). A save to that system\n * collection therefore *can* change published output and is now silent.\n * Accepted because: (a) it is a retired-builder remnant (EC-166/167) with\n * **zero writers** left in the studio — `provisionSource`/`removeSource` have\n * no call sites; (b) it only ever invalidated *by accident*, through the\n * truncated-summary sweep fallback, never through a precise tag; and (c) a\n * collection-scoped carve-out cannot work here — the collection name is exactly\n * what the truncated summary loses. If that data layer is ever revived, the fix\n * belongs at the delivery trust boundary (serve sources published-only), not by\n * re-broadening this set.\n */\nconst PUBLISHED_STATE_OPERATIONS = new Set([\n 'documents.delete',\n 'documents.publish',\n 'documents.unpublish',\n 'documents.pinVersion',\n])\n/**\n * The **routing** operations (EC-510): editorial redirects, which EC-303 moved\n * out of config and into the database. Every *enabled* redirect row is\n * projected into the delivery snapshot the router and `resolvePage` consume\n * (`studio/convex/delivery.ts`), in BOTH perspectives — so saving, retargeting,\n * disabling or deleting one changes published output, exactly the question\n * {@link PUBLISHED_STATE_OPERATIONS} asks. They belong to that question, not a\n * parallel one; they are a separate set only because their tag is different.\n *\n * ## Why the coarse project sweep and NOT a targeted `route:<from>:<locale>`\n *\n * The tag vocabulary has `route:<pattern>:<locale>` and a redirect's `from` IS\n * a route path, so a targeted tag looks available. It is not — it would be\n * silently wrong in four independent ways, and a silent wrong invalidation is\n * the exact failure family EC-489/EC-501/this ticket exist to remove:\n *\n * 1. **`redirects.remove` has no `from` at all.** Its input is\n * `{ projectId, id }` (`core/operations/redirects.ts`), so the path whose\n * cache entry must drop is not in the event. Precision is unobtainable, and\n * a delete is the single most important case to get right.\n * 2. **A save can RENAME `from`.** The event carries only the NEW path; the OLD\n * one keeps serving a redirect that no longer exists. `OperationEvent` has\n * `operation`/`kind`/`status`/`inputSummary` and nothing else — no result,\n * no previous row (EC-501 rejected widening it for the same reason) — so the\n * emitter cannot even detect that a rename happened.\n * 3. **Normalization would have to be mirrored.** The cached entry is tagged\n * with the resolver's `normalizePath(url)`; `from` is editor-typed free text\n * (`z.string().min(1)`), stored verbatim and normalized only at match time.\n * A trailing slash or a query string would produce a tag that matches\n * nothing — no error, no invalidation.\n * 4. **The identity truncates.** MEASURED 2026-08-01 on the real schema: a\n * Konditorei-shaped migration redirect (legacy permalink → new path)\n * serializes to 226 chars and an EC-311 `meta` bag to 216, both past the\n * EC-139 160-char cap. A `from`-based tag would work on a demo-sized\n * redirect and degrade on the real one — EC-501's pathology in reverse.\n *\n * The sweep has none of those failure modes: it needs only `projectId`, which\n * serializes first and therefore survives truncation, so **the truncation\n * fallback and the primary answer are the same tag** — correct by design rather\n * than by accident. `nextCacheTags` stamps `project` on every cached entry, so\n * it is guaranteed to reach the affected URL whatever the redirect changed.\n *\n * ## The cost, and why it is affordable here (unlike EC-501)\n *\n * A sweep drops every cached entry, lazily on next read. That was intolerable\n * for `documents.upsert` because it fired on every ~1s keystroke batch and\n * *none* of those invalidations were necessary. A redirect write is an\n * explicit form save on a rail with `inlineEdit: false` and no autosave\n * (`redirects-collection-view-source.ts`), and every one of them IS a routing\n * change. Measured on a connected studio: a session that deliberately exercised\n * every mutation kind — create, retarget, disable, rename, delete — produced 5\n * writes. The Convex-side path already sweeps the project on every document\n * publish (`convex/revalidate.ts`, \"the honest, reliable v1\"), so this is\n * strictly rarer than a cost the engine already pays.\n *\n * If a precise redirect tag is ever wanted, the place to get it is the Convex\n * write chokepoint, where `change.oldDoc` holds the previous `from` — not here,\n * where the information does not exist.\n */\nconst ROUTING_OPERATIONS = new Set(['redirects.save', 'redirects.remove'])\n\n/**\n * Asset-record mutations (EC-227): editing an asset (alt/dimensions/re-upload)\n * must drop the published pages that *baked* its delivery shape, which carry the\n * `asset:<id>` tag. `assets.remove` always carries `assetId`; `assets.upsert`\n * carries `asset.id` only on an EDIT (a CREATE mints the id server-side).\n */\nconst ASSET_OPERATIONS = new Set(['assets.upsert', 'assets.remove'])\n\n/** One computed webhook emission: which project, why, and which tags. */\nexport interface WebhookTagSet {\n projectId: string\n /** The operation name, carried verbatim as the webhook `event`. */\n event: string\n /** Sorted, deduplicated, fully scoped Next tag strings. */\n tags: string[]\n}\n\n/** Tolerant parse of an EC-139 input summary (may be truncated past 160 chars). */\nfunction parseSummary(summary: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(summary)\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : undefined\n } catch {\n return undefined\n }\n}\n\n/** Extract the projectId even from a truncated summary (it serializes first). */\nfunction projectIdOfSummary(summary: string): string | undefined {\n const match = /\"projectId\":\"([^\"]+)\"/.exec(summary)\n return match?.[1]\n}\n\nfunction documentRefOf(value: unknown): { collection: string; id: string } | undefined {\n if (typeof value !== 'object' || value === null) return undefined\n const candidate = value as { collection?: unknown; id?: unknown }\n return typeof candidate.collection === 'string' &&\n candidate.collection.length > 0 &&\n typeof candidate.id === 'string' &&\n candidate.id.length > 0\n ? { collection: candidate.collection, id: candidate.id }\n : undefined\n}\n\n/**\n * Compute the webhook tag set for one lifecycle event, or `null` when the\n * event is not a successful publish-shaped command (queries, failures, and\n * unrelated domains never fire webhooks).\n */\nexport function webhookTagsForEvent(\n event: Pick<OperationEvent, 'operation' | 'kind' | 'status' | 'inputSummary'>,\n options: { locales: readonly string[] },\n): WebhookTagSet | null {\n if (event.status !== 'succeeded' || event.kind !== 'command') return null\n\n const isDocument = PUBLISHED_STATE_OPERATIONS.has(event.operation)\n const isAsset = ASSET_OPERATIONS.has(event.operation)\n const isRouting = ROUTING_OPERATIONS.has(event.operation)\n if (!isDocument && !isAsset && !isRouting) return null\n\n const projectId = projectIdOfSummary(event.inputSummary)\n if (projectId === undefined) return null\n\n // EC-510: one answer for every redirect write — see `ROUTING_OPERATIONS` for\n // why the targeted `route:` tag is unobtainable and the sweep is the honest one.\n if (isRouting) {\n return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n }\n\n if (isAsset) return assetTagsForEvent(projectId, event)\n\n const parsed = parseSummary(event.inputSummary)\n const ref = documentRefOf(parsed?.['ref']) ?? documentRefOf(parsed?.['document'])\n if (!ref) {\n // Truncated summary (large upsert payload): honest coarse fallback.\n return { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n }\n\n const tags = new Set<string>([docWebhookTag(projectId, ref.collection, ref.id)])\n for (const locale of options.locales) {\n tags.add(collectionWebhookTag(projectId, ref.collection, locale))\n }\n // EC-207: ROUTES are config-owned (a code deploy, which rebuilds the host), so\n // a document event never needs a `route:` tag. Redirects are NOT — EC-303 made\n // them editorial data and EC-510 gives their own operations the project sweep\n // (see `ROUTING_OPERATIONS`); a *document* write still cannot change them.\n return { projectId, event: event.operation, tags: [...tags].sort() }\n}\n\n/** A non-empty string, else `undefined` (the asset id out of a summary). */\nfunction stringOf(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined\n}\n\n/**\n * Tags for an asset mutation (EC-227). The edit cases — `assets.remove` (carries\n * `assetId`) and `assets.upsert` of an existing asset (carries `asset.id`) —\n * emit exactly `asset:<id>`, dropping the pages that baked that asset. An\n * `assets.upsert` with **no** id is a CREATE: the id is minted server-side and a\n * brand-new asset cannot be baked into any existing page, so it invalidates\n * nothing (`null`, no webhook). A `remove` missing its id means a truncated\n * summary — sweep the project (coarse, always correct — the document fallback).\n */\nfunction assetTagsForEvent(\n projectId: string,\n event: Pick<OperationEvent, 'operation' | 'inputSummary'>,\n): WebhookTagSet | null {\n const parsed = parseSummary(event.inputSummary)\n const assetId =\n event.operation === 'assets.remove'\n ? stringOf(parsed?.['assetId'])\n : stringOf((parsed?.['asset'] as { id?: unknown } | undefined)?.id)\n if (assetId) {\n return { projectId, event: event.operation, tags: [assetWebhookTag(projectId, assetId)] }\n }\n return event.operation === 'assets.remove'\n ? { projectId, event: event.operation, tags: [projectSweepWebhookTag(projectId)] }\n : null\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CollectionDef } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { CustomizationIssue } from '@elytracms/core/studio-core';
|
|
3
|
+
/**
|
|
4
|
+
* EC-479 — project-level configuration findings for the section-rail foot.
|
|
5
|
+
*
|
|
6
|
+
* The engine used to fill a missing declaration with its own `cms-core` fixtures:
|
|
7
|
+
* `?? sampleFilterableCollections` in the CMS store, `?? sampleDocuments` beside
|
|
8
|
+
* it, `?? sampleLocaleConfig` in the locale store. A studio whose config never
|
|
9
|
+
* arrived therefore looked *configured* — it showed six documents about Ada
|
|
10
|
+
* Lovelace under a four-collection schema, in `en/de`, none of which the project
|
|
11
|
+
* had declared. The owner's ruling (2026-07-30): "fixtures should only show in the
|
|
12
|
+
* playground, but not be hard-wired in the engine".
|
|
13
|
+
*
|
|
14
|
+
* With the fallbacks retired the same state renders as an empty CMS, which on its
|
|
15
|
+
* own is the OTHER silent failure — the house rule bans both ("if you are writing
|
|
16
|
+
* a fallback that hides a broken state, that *is* the bug"). This module is the
|
|
17
|
+
* third option: say so, at the rail foot, where EC-467's reuse issues and EC-469's
|
|
18
|
+
* URL-map issues already report project-level problems, on every studio surface.
|
|
19
|
+
*
|
|
20
|
+
* Pure and deterministic — no config-holder reads, no React. The caller supplies
|
|
21
|
+
* the resolved config (`resolveDeploymentCollections()`), which is also what makes
|
|
22
|
+
* this testable against a synthetic project instead of the live one.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The extension-point name stamped on these issues. Not a customization registry
|
|
26
|
+
* like `'sidebar'`/`'fieldRenderers'` — the finding is about the project's own
|
|
27
|
+
* declarations, so it locates the config, not an override list.
|
|
28
|
+
*/
|
|
29
|
+
export declare const PROJECT_CONFIG_POINT = "project";
|
|
30
|
+
/**
|
|
31
|
+
* Catalog keys for the issues below (EC-478). Named here, next to the producer, so
|
|
32
|
+
* the studio's i18n parity test asserts coverage against the producer rather than a
|
|
33
|
+
* hand-copied list — the same shape as `default-modules.tsx`'s `KEYSPACE_KEYS`.
|
|
34
|
+
*/
|
|
35
|
+
export declare const PROJECT_CONFIG_ISSUE_KEYS: {
|
|
36
|
+
readonly noCollections: "customization.issue.noCollections";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Report what the project has NOT declared but the studio cannot work without.
|
|
40
|
+
*
|
|
41
|
+
* Today that is exactly one thing: collections. A studio with none has no content
|
|
42
|
+
* model at all — the CMS rail expands to nothing, every list is empty, and before
|
|
43
|
+
* EC-479 the store quietly substituted the engine's sample schema so none of that
|
|
44
|
+
* was visible. The remedy is one line in `elytra.config.ts`, which is why the
|
|
45
|
+
* message names the file.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately NOT reported here: an absent locale config. A `Project` only exists
|
|
48
|
+
* for a config-declared project and `resolveWorkspaceConfig` gives every one of
|
|
49
|
+
* them a `locales` array, so "no locales" is unreachable from the rail's vantage
|
|
50
|
+
* point; the CMS session guards it directly instead (`CmsProvider`), where it is
|
|
51
|
+
* both reachable and actionable. Reporting an unreachable state would be copy
|
|
52
|
+
* nobody can trigger. Since EC-490 (owner-ratified 2026-08-01) that unreachability
|
|
53
|
+
* is STRUCTURAL rather than incidental: a config declaring no `locales` is rejected
|
|
54
|
+
* outright, so there is no resolved project behind which the question could hide.
|
|
55
|
+
*/
|
|
56
|
+
export declare function projectConfigIssues(collections: readonly CollectionDef[]): CustomizationIssue[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EC-479 — project-level configuration findings for the section-rail foot.
|
|
3
|
+
*
|
|
4
|
+
* The engine used to fill a missing declaration with its own `cms-core` fixtures:
|
|
5
|
+
* `?? sampleFilterableCollections` in the CMS store, `?? sampleDocuments` beside
|
|
6
|
+
* it, `?? sampleLocaleConfig` in the locale store. A studio whose config never
|
|
7
|
+
* arrived therefore looked *configured* — it showed six documents about Ada
|
|
8
|
+
* Lovelace under a four-collection schema, in `en/de`, none of which the project
|
|
9
|
+
* had declared. The owner's ruling (2026-07-30): "fixtures should only show in the
|
|
10
|
+
* playground, but not be hard-wired in the engine".
|
|
11
|
+
*
|
|
12
|
+
* With the fallbacks retired the same state renders as an empty CMS, which on its
|
|
13
|
+
* own is the OTHER silent failure — the house rule bans both ("if you are writing
|
|
14
|
+
* a fallback that hides a broken state, that *is* the bug"). This module is the
|
|
15
|
+
* third option: say so, at the rail foot, where EC-467's reuse issues and EC-469's
|
|
16
|
+
* URL-map issues already report project-level problems, on every studio surface.
|
|
17
|
+
*
|
|
18
|
+
* Pure and deterministic — no config-holder reads, no React. The caller supplies
|
|
19
|
+
* the resolved config (`resolveDeploymentCollections()`), which is also what makes
|
|
20
|
+
* this testable against a synthetic project instead of the live one.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The extension-point name stamped on these issues. Not a customization registry
|
|
24
|
+
* like `'sidebar'`/`'fieldRenderers'` — the finding is about the project's own
|
|
25
|
+
* declarations, so it locates the config, not an override list.
|
|
26
|
+
*/
|
|
27
|
+
export const PROJECT_CONFIG_POINT = 'project';
|
|
28
|
+
/** The file a developer fixes these findings in — interpolated, never localized. */
|
|
29
|
+
const CONFIG_FILE = 'elytra.config.ts';
|
|
30
|
+
/**
|
|
31
|
+
* Catalog keys for the issues below (EC-478). Named here, next to the producer, so
|
|
32
|
+
* the studio's i18n parity test asserts coverage against the producer rather than a
|
|
33
|
+
* hand-copied list — the same shape as `default-modules.tsx`'s `KEYSPACE_KEYS`.
|
|
34
|
+
*/
|
|
35
|
+
export const PROJECT_CONFIG_ISSUE_KEYS = {
|
|
36
|
+
noCollections: 'customization.issue.noCollections',
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Report what the project has NOT declared but the studio cannot work without.
|
|
40
|
+
*
|
|
41
|
+
* Today that is exactly one thing: collections. A studio with none has no content
|
|
42
|
+
* model at all — the CMS rail expands to nothing, every list is empty, and before
|
|
43
|
+
* EC-479 the store quietly substituted the engine's sample schema so none of that
|
|
44
|
+
* was visible. The remedy is one line in `elytra.config.ts`, which is why the
|
|
45
|
+
* message names the file.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately NOT reported here: an absent locale config. A `Project` only exists
|
|
48
|
+
* for a config-declared project and `resolveWorkspaceConfig` gives every one of
|
|
49
|
+
* them a `locales` array, so "no locales" is unreachable from the rail's vantage
|
|
50
|
+
* point; the CMS session guards it directly instead (`CmsProvider`), where it is
|
|
51
|
+
* both reachable and actionable. Reporting an unreachable state would be copy
|
|
52
|
+
* nobody can trigger. Since EC-490 (owner-ratified 2026-08-01) that unreachability
|
|
53
|
+
* is STRUCTURAL rather than incidental: a config declaring no `locales` is rejected
|
|
54
|
+
* outright, so there is no resolved project behind which the question could hide.
|
|
55
|
+
*/
|
|
56
|
+
export function projectConfigIssues(collections) {
|
|
57
|
+
if (collections.length > 0)
|
|
58
|
+
return [];
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
code: 'missing-configuration',
|
|
62
|
+
severity: 'error',
|
|
63
|
+
message: `This project declares no content collections. Add them to "${CONFIG_FILE}" — the studio shows your schema or none, never sample content.`,
|
|
64
|
+
messageRef: {
|
|
65
|
+
key: PROJECT_CONFIG_ISSUE_KEYS.noCollections,
|
|
66
|
+
vars: { file: CONFIG_FILE },
|
|
67
|
+
},
|
|
68
|
+
point: PROJECT_CONFIG_POINT,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=config-issues.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-issues.js","sourceRoot":"","sources":["../../../src/lib/workspace/config-issues.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAE7C,oFAAoF;AACpF,MAAM,WAAW,GAAG,kBAAkB,CAAA;AAEtC;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG;IACvC,aAAa,EAAE,mCAAmC;CAC1C,CAAA;AAEV;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,mBAAmB,CACjC,WAAqC;IAErC,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,EAAE,CAAA;IACrC,OAAO;QACL;YACE,IAAI,EAAE,uBAAuB;YAC7B,QAAQ,EAAE,OAAO;YACjB,OAAO,EAAE,8DAA8D,WAAW,iEAAiE;YACnJ,UAAU,EAAE;gBACV,GAAG,EAAE,yBAAyB,CAAC,aAAa;gBAC5C,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE;aAC5B;YACD,KAAK,EAAE,oBAAoB;SAC5B;KACF,CAAA;AACH,CAAC","sourcesContent":["import type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\n\n/**\n * EC-479 — project-level configuration findings for the section-rail foot.\n *\n * The engine used to fill a missing declaration with its own `cms-core` fixtures:\n * `?? sampleFilterableCollections` in the CMS store, `?? sampleDocuments` beside\n * it, `?? sampleLocaleConfig` in the locale store. A studio whose config never\n * arrived therefore looked *configured* — it showed six documents about Ada\n * Lovelace under a four-collection schema, in `en/de`, none of which the project\n * had declared. The owner's ruling (2026-07-30): \"fixtures should only show in the\n * playground, but not be hard-wired in the engine\".\n *\n * With the fallbacks retired the same state renders as an empty CMS, which on its\n * own is the OTHER silent failure — the house rule bans both (\"if you are writing\n * a fallback that hides a broken state, that *is* the bug\"). This module is the\n * third option: say so, at the rail foot, where EC-467's reuse issues and EC-469's\n * URL-map issues already report project-level problems, on every studio surface.\n *\n * Pure and deterministic — no config-holder reads, no React. The caller supplies\n * the resolved config (`resolveDeploymentCollections()`), which is also what makes\n * this testable against a synthetic project instead of the live one.\n */\n\n/**\n * The extension-point name stamped on these issues. Not a customization registry\n * like `'sidebar'`/`'fieldRenderers'` — the finding is about the project's own\n * declarations, so it locates the config, not an override list.\n */\nexport const PROJECT_CONFIG_POINT = 'project'\n\n/** The file a developer fixes these findings in — interpolated, never localized. */\nconst CONFIG_FILE = 'elytra.config.ts'\n\n/**\n * Catalog keys for the issues below (EC-478). Named here, next to the producer, so\n * the studio's i18n parity test asserts coverage against the producer rather than a\n * hand-copied list — the same shape as `default-modules.tsx`'s `KEYSPACE_KEYS`.\n */\nexport const PROJECT_CONFIG_ISSUE_KEYS = {\n noCollections: 'customization.issue.noCollections',\n} as const\n\n/**\n * Report what the project has NOT declared but the studio cannot work without.\n *\n * Today that is exactly one thing: collections. A studio with none has no content\n * model at all — the CMS rail expands to nothing, every list is empty, and before\n * EC-479 the store quietly substituted the engine's sample schema so none of that\n * was visible. The remedy is one line in `elytra.config.ts`, which is why the\n * message names the file.\n *\n * Deliberately NOT reported here: an absent locale config. A `Project` only exists\n * for a config-declared project and `resolveWorkspaceConfig` gives every one of\n * them a `locales` array, so \"no locales\" is unreachable from the rail's vantage\n * point; the CMS session guards it directly instead (`CmsProvider`), where it is\n * both reachable and actionable. Reporting an unreachable state would be copy\n * nobody can trigger. Since EC-490 (owner-ratified 2026-08-01) that unreachability\n * is STRUCTURAL rather than incidental: a config declaring no `locales` is rejected\n * outright, so there is no resolved project behind which the question could hide.\n */\nexport function projectConfigIssues(\n collections: readonly CollectionDef[],\n): CustomizationIssue[] {\n if (collections.length > 0) return []\n return [\n {\n code: 'missing-configuration',\n severity: 'error',\n message: `This project declares no content collections. Add them to \"${CONFIG_FILE}\" — the studio shows your schema or none, never sample content.`,\n messageRef: {\n key: PROJECT_CONFIG_ISSUE_KEYS.noCollections,\n vars: { file: CONFIG_FILE },\n },\n point: PROJECT_CONFIG_POINT,\n },\n ]\n}\n"]}
|
|
@@ -4,6 +4,7 @@ import type { SidebarNode } from '../../components/app-views/module.js';
|
|
|
4
4
|
import type { CustomFieldRenderer, StudioConfig } from '../../components/app-views/studio-config.js';
|
|
5
5
|
import type { GeneratedPreviewLookup } from '../../components/builder/blocks-model.js';
|
|
6
6
|
import type { WorkspaceState } from './workspace-source.js';
|
|
7
|
+
export { PROJECT_CONFIG_ISSUE_KEYS, PROJECT_CONFIG_POINT, projectConfigIssues, } from './config-issues.js';
|
|
7
8
|
export { resolveWorkspaceState } from './workspace-source.js';
|
|
8
9
|
export type { WorkspaceSources, WorkspaceState } from './workspace-source.js';
|
|
9
10
|
export { isSingleProjectWorkspace, launcherAutoForwardTarget, persistedProjects, selectSwitcherProjects, soleWorkspaceProject, unprovisionedProjects, } from './switcher-source.js';
|
|
@@ -145,6 +146,14 @@ export declare function resolveDeploymentSidebar(): SidebarNode[] | undefined;
|
|
|
145
146
|
* body, kit `tokensCss` only). Studio-wide, like the rail and field renderers.
|
|
146
147
|
*/
|
|
147
148
|
export declare function resolveDeploymentCanvasTheme(): StudioConfig['canvas'];
|
|
149
|
+
/**
|
|
150
|
+
* The reusable-elements declaration from the studio config (EC-467): WHICH
|
|
151
|
+
* component is the project's reuse embed. Studio-only (from `defineStudio`, never
|
|
152
|
+
* the delivery bundle); `undefined` when none — the studio then INFERS the embed
|
|
153
|
+
* from the manifests + collections exactly as it always has (absence means infer,
|
|
154
|
+
* not off). Studio-wide, like the rail and field renderers.
|
|
155
|
+
*/
|
|
156
|
+
export declare function resolveDeploymentReuse(): StudioConfig['reuse'];
|
|
148
157
|
/**
|
|
149
158
|
* The resolved URL routes of the active config project (EC-207, "routes as
|
|
150
159
|
* code"). Routes are config-sourced: the studio reads the materialized
|
|
@@ -162,14 +171,21 @@ export declare function resolveDeploymentRoutes(): RouteRecord[];
|
|
|
162
171
|
*/
|
|
163
172
|
export declare function resolveDeploymentLocales(): string[];
|
|
164
173
|
/**
|
|
165
|
-
* The active config project's
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
174
|
+
* The active config project's `LocaleConfig` (EC-357 finding #3). Locales are
|
|
175
|
+
* structure and structure is code: when `elytra.config.ts` declares them, they
|
|
176
|
+
* drive the studio's ambient locale switcher in EVERY mode — the CMS surface must
|
|
177
|
+
* not fall back to the sample `en/de` fixture (playground, or a connected first
|
|
178
|
+
* run with nothing persisted) when the config says otherwise. `undefined` when no
|
|
179
|
+
* config project is active (env-only wiring keeps the existing kit/sample
|
|
180
|
+
* fallbacks).
|
|
181
|
+
*
|
|
182
|
+
* EC-519: this used to REASSEMBLE the config's flat `locales` + `defaultLocale`
|
|
183
|
+
* pair into a `LocaleConfig`, and had grown its own repair rule — an absent or
|
|
184
|
+
* out-of-set default fell back to `locales[0]`. That is a fourth place a locale
|
|
185
|
+
* could be invented, in the surface furthest from the author. The resolver now
|
|
186
|
+
* hands over the same `LocaleConfig` the delivery host takes, so there is nothing
|
|
187
|
+
* to reassemble and nothing to repair: a default outside the set is a config
|
|
188
|
+
* ERROR (`default-locale-not-in-locales`) and the project never resolves.
|
|
173
189
|
*/
|
|
174
190
|
export declare function resolveDeploymentLocaleConfig(): LocaleConfig | undefined;
|
|
175
191
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { frontendUrlForEnvironment } from '@elytracms/core/studio-core';
|
|
2
2
|
import { provideStudioEnv } from '../persistence/backend-mode.js';
|
|
3
3
|
import { resolveWorkspaceState } from './workspace-source.js';
|
|
4
|
+
export { PROJECT_CONFIG_ISSUE_KEYS, PROJECT_CONFIG_POINT, projectConfigIssues, } from './config-issues.js';
|
|
4
5
|
export { resolveWorkspaceState } from './workspace-source.js';
|
|
5
6
|
export { isSingleProjectWorkspace, launcherAutoForwardTarget, persistedProjects, selectSwitcherProjects, soleWorkspaceProject, unprovisionedProjects, } from './switcher-source.js';
|
|
6
7
|
let cached = null;
|
|
@@ -217,6 +218,16 @@ export function resolveDeploymentSidebar() {
|
|
|
217
218
|
export function resolveDeploymentCanvasTheme() {
|
|
218
219
|
return studioConfig?.canvas;
|
|
219
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* The reusable-elements declaration from the studio config (EC-467): WHICH
|
|
223
|
+
* component is the project's reuse embed. Studio-only (from `defineStudio`, never
|
|
224
|
+
* the delivery bundle); `undefined` when none — the studio then INFERS the embed
|
|
225
|
+
* from the manifests + collections exactly as it always has (absence means infer,
|
|
226
|
+
* not off). Studio-wide, like the rail and field renderers.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveDeploymentReuse() {
|
|
229
|
+
return studioConfig?.reuse;
|
|
230
|
+
}
|
|
220
231
|
/**
|
|
221
232
|
* The resolved URL routes of the active config project (EC-207, "routes as
|
|
222
233
|
* code"). Routes are config-sourced: the studio reads the materialized
|
|
@@ -238,28 +249,27 @@ export function resolveDeploymentRoutes() {
|
|
|
238
249
|
* (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.
|
|
239
250
|
*/
|
|
240
251
|
export function resolveDeploymentLocales() {
|
|
241
|
-
return activeConfigProject()?.locales ?? [];
|
|
252
|
+
return activeConfigProject()?.locales.locales ?? [];
|
|
242
253
|
}
|
|
243
254
|
/**
|
|
244
|
-
* The active config project's
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
255
|
+
* The active config project's `LocaleConfig` (EC-357 finding #3). Locales are
|
|
256
|
+
* structure and structure is code: when `elytra.config.ts` declares them, they
|
|
257
|
+
* drive the studio's ambient locale switcher in EVERY mode — the CMS surface must
|
|
258
|
+
* not fall back to the sample `en/de` fixture (playground, or a connected first
|
|
259
|
+
* run with nothing persisted) when the config says otherwise. `undefined` when no
|
|
260
|
+
* config project is active (env-only wiring keeps the existing kit/sample
|
|
261
|
+
* fallbacks).
|
|
262
|
+
*
|
|
263
|
+
* EC-519: this used to REASSEMBLE the config's flat `locales` + `defaultLocale`
|
|
264
|
+
* pair into a `LocaleConfig`, and had grown its own repair rule — an absent or
|
|
265
|
+
* out-of-set default fell back to `locales[0]`. That is a fourth place a locale
|
|
266
|
+
* could be invented, in the surface furthest from the author. The resolver now
|
|
267
|
+
* hands over the same `LocaleConfig` the delivery host takes, so there is nothing
|
|
268
|
+
* to reassemble and nothing to repair: a default outside the set is a config
|
|
269
|
+
* ERROR (`default-locale-not-in-locales`) and the project never resolves.
|
|
252
270
|
*/
|
|
253
271
|
export function resolveDeploymentLocaleConfig() {
|
|
254
|
-
|
|
255
|
-
if (!project || project.locales.length === 0)
|
|
256
|
-
return undefined;
|
|
257
|
-
const fallback = project.locales[0];
|
|
258
|
-
const explicit = project.defaultLocale;
|
|
259
|
-
return {
|
|
260
|
-
default: explicit && project.locales.includes(explicit) ? explicit : fallback,
|
|
261
|
-
locales: [...project.locales],
|
|
262
|
-
};
|
|
272
|
+
return activeConfigProject()?.locales;
|
|
263
273
|
}
|
|
264
274
|
/**
|
|
265
275
|
* The active project's components reference (EC-194, AD-11): where the consuming
|