@elytracms/studio 0.0.70 → 0.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/components/account/self-account-view-source.js +1 -0
  2. package/dist/components/account/self-account-view-source.js.map +1 -1
  3. package/dist/components/assets/assets-collection-view-source.js +1 -0
  4. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  5. package/dist/components/cms/collection-table.js +19 -2
  6. package/dist/components/cms/collection-table.js.map +1 -1
  7. package/dist/components/cms/detail-editor.js +73 -51
  8. package/dist/components/cms/detail-editor.js.map +1 -1
  9. package/dist/components/cms/duplicate-document.d.ts +45 -0
  10. package/dist/components/cms/duplicate-document.js +141 -0
  11. package/dist/components/cms/duplicate-document.js.map +1 -0
  12. package/dist/components/cms/field-anchors.d.ts +67 -0
  13. package/dist/components/cms/field-anchors.js +91 -0
  14. package/dist/components/cms/field-anchors.js.map +1 -0
  15. package/dist/components/cms/issue-copy.d.ts +45 -0
  16. package/dist/components/cms/issue-copy.js +89 -0
  17. package/dist/components/cms/issue-copy.js.map +1 -0
  18. package/dist/components/cms/issues-pane.d.ts +11 -2
  19. package/dist/components/cms/issues-pane.js +22 -4
  20. package/dist/components/cms/issues-pane.js.map +1 -1
  21. package/dist/components/cms/object-array-field.d.ts +2 -0
  22. package/dist/components/cms/object-array-field.js +75 -21
  23. package/dist/components/cms/object-array-field.js.map +1 -1
  24. package/dist/components/cms/row-actions.d.ts +10 -3
  25. package/dist/components/cms/row-actions.js +8 -0
  26. package/dist/components/cms/row-actions.js.map +1 -1
  27. package/dist/components/cms/use-field-focus.d.ts +25 -9
  28. package/dist/components/cms/use-field-focus.js +80 -15
  29. package/dist/components/cms/use-field-focus.js.map +1 -1
  30. package/dist/components/collection-view/cms-collection-view-source.js +1 -0
  31. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  32. package/dist/components/redirects/redirects-collection-view-source.js +1 -0
  33. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  34. package/dist/components/users/users-collection-view-source.js +1 -0
  35. package/dist/components/users/users-collection-view-source.js.map +1 -1
  36. package/dist/customization.d.ts +9 -0
  37. package/dist/customization.js +8 -0
  38. package/dist/customization.js.map +1 -1
  39. package/dist/lib/collection-view/collection-view-source.d.ts +8 -0
  40. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  41. package/dist/lib/i18n/messages.de.js +38 -1
  42. package/dist/lib/i18n/messages.de.js.map +1 -1
  43. package/dist/lib/i18n/messages.en.d.ts +34 -0
  44. package/dist/lib/i18n/messages.en.js +41 -1
  45. package/dist/lib/i18n/messages.en.js.map +1 -1
  46. package/dist/lib/persona/labels.d.ts +5 -1
  47. package/dist/lib/persona/labels.js +43 -30
  48. package/dist/lib/persona/labels.js.map +1 -1
  49. package/dist/previews/enumerate.d.ts +68 -0
  50. package/dist/previews/enumerate.js +78 -0
  51. package/dist/previews/enumerate.js.map +1 -0
  52. package/dist/previews/harness.d.ts +50 -0
  53. package/dist/previews/harness.js +123 -0
  54. package/dist/previews/harness.js.map +1 -0
  55. package/dist/previews/manifest-format.d.ts +57 -0
  56. package/dist/previews/manifest-format.js +59 -0
  57. package/dist/previews/manifest-format.js.map +1 -0
  58. package/dist/ui/field.d.ts +24 -1
  59. package/dist/ui/field.js +23 -2
  60. package/dist/ui/field.js.map +1 -1
  61. package/package.json +10 -6
@@ -0,0 +1,141 @@
1
+ /**
2
+ * EC-442 — the duplicate-document derivation (pure core) + the source-composed
3
+ * dispatch behind the list row `⋮` menu's and the detail-editor header's
4
+ * **Duplicate** action.
5
+ *
6
+ * Semantics (ratified 2026-07-17):
7
+ * - The duplicate copies the current DRAFT field values — `values` plus
8
+ * `localized` across ALL locales plus `defaultLocale` — into a fresh document
9
+ * id. It is created as a **draft, never auto-published** ("live is
10
+ * published-only"): published-ness is the version pointer (EC-224), and
11
+ * nothing here touches it.
12
+ * - `alias` is deliberately NOT copied: an alias is a unique-within-collection
13
+ * slot handle (EC-284) — code addresses THE document holding it, and a copied
14
+ * alias would immediately surface as uniqueness drift. The duplicate is new
15
+ * content, not the slot holder.
16
+ * - **Slug strategy: uniquify, never clear.** Every slug field (a `text` field
17
+ * named `slug` or opted in via `form.control: 'slug'` — the EC-315
18
+ * convention) gets a deterministic `-copy` / `-copy-2` / … suffix walked past
19
+ * every slug string the collection already holds (base values AND every
20
+ * locale variant of every sibling). Rationale: a cleared slug lands the new
21
+ * draft in an error-looking validation state, while a uniquified one reads as
22
+ * what it is ("about-copy") and keeps the draft one review away from
23
+ * publishable. A missing/duplicate slug stays publish-gating only — this
24
+ * derivation never blocks a create.
25
+ * - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids
26
+ * only need uniqueness within one composition value (`validateCompositionValue`
27
+ * checks duplicates per value; the builder's fresh-id counter re-derives from
28
+ * the open value via `maxNodeIdSeq`), so identical node ids across two
29
+ * documents collide nowhere.
30
+ * - References/relations copy as-is (the duplicate points at the same targets);
31
+ * the EC-277 in-transaction reference derivation indexes the new document on
32
+ * its first persisted write because it catches every writer.
33
+ */
34
+ /** The conventional slug field name (mirrors `field-control`'s slug auto-upgrade). */
35
+ const SLUG_FIELD_NAME = 'slug';
36
+ /** The built-in control name that opts any `text` field into slug behaviour (EC-315). */
37
+ const SLUG_CONTROL = 'slug';
38
+ /** The EC-315 slug-field convention: a `text` field named `slug`, or any `text`
39
+ * field with `form.control: 'slug'` (same predicate as the studio slug control). */
40
+ function isSlugField(field) {
41
+ return (field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME));
42
+ }
43
+ /**
44
+ * Every slug string the collection already holds for `fieldName` — the base
45
+ * `values` slot AND every per-locale slot of every sibling (the original
46
+ * included). A union pool across locales is deliberately conservative: a
47
+ * candidate free in the pool can never collide with any sibling in any locale,
48
+ * including through the EC-018 base-value fallback.
49
+ */
50
+ function slugPool(fieldName, siblings) {
51
+ const pool = new Set();
52
+ const add = (value) => {
53
+ if (typeof value === 'string' && value.trim() !== '')
54
+ pool.add(value.trim());
55
+ };
56
+ for (const doc of siblings) {
57
+ add(doc.values?.[fieldName]);
58
+ for (const localeValues of Object.values(doc.localized ?? {}))
59
+ add(localeValues[fieldName]);
60
+ }
61
+ return pool;
62
+ }
63
+ /**
64
+ * The deterministic duplicate slug: `<slug>-copy`, then `<slug>-copy-2`,
65
+ * `<slug>-copy-3`, … — the first candidate not in `taken`. Pure function of
66
+ * `(slug, taken)`, so the SAME original string in the base slot and a locale
67
+ * slot maps to the SAME uniquified string (the duplicate keeps the original's
68
+ * internal base/locale relationship).
69
+ */
70
+ export function uniquifySlug(slug, taken) {
71
+ const base = `${slug}-copy`;
72
+ if (!taken.has(base))
73
+ return base;
74
+ let n = 2;
75
+ while (taken.has(`${base}-${n}`))
76
+ n += 1;
77
+ return `${base}-${n}`;
78
+ }
79
+ /** Uniquify one stored slug slot in place; empty/non-string slots stay untouched
80
+ * (a missing slug remains a publish-gating issue, never a create blocker). */
81
+ function uniquifySlot(values, fieldName, pool) {
82
+ if (!values)
83
+ return;
84
+ const raw = values[fieldName];
85
+ if (typeof raw !== 'string' || raw.trim() === '')
86
+ return;
87
+ values[fieldName] = uniquifySlug(raw.trim(), pool);
88
+ }
89
+ /**
90
+ * Derive the duplicate document (pure): deep-copied `values` + `localized` (all
91
+ * locales) + `defaultLocale` under the fresh id, `alias` dropped, every slug
92
+ * field's stored slots uniquified against the collection's slug pool. See the
93
+ * module doc for the ratified semantics each rule pins.
94
+ */
95
+ export function deriveDuplicateDocument({ original, newId, schema, siblings, }) {
96
+ const duplicate = {
97
+ collection: original.collection,
98
+ id: newId,
99
+ // Deep clones: composition trees, rich text, nested objects/arrays must be
100
+ // independent of the original (editing one must never mutate the other).
101
+ values: structuredClone(original.values ?? {}),
102
+ ...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),
103
+ ...(original.defaultLocale !== undefined ? { defaultLocale: original.defaultLocale } : {}),
104
+ // `alias` deliberately dropped (unique slot handle — see module doc).
105
+ };
106
+ for (const field of schema.fields) {
107
+ if (!isSlugField(field))
108
+ continue;
109
+ const pool = slugPool(field.name, siblings);
110
+ uniquifySlot(duplicate.values, field.name, pool);
111
+ for (const localeValues of Object.values(duplicate.localized ?? {})) {
112
+ uniquifySlot(localeValues, field.name, pool);
113
+ }
114
+ }
115
+ return duplicate;
116
+ }
117
+ /**
118
+ * Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch
119
+ * both surfaces (list row menu, detail-editor header) share. Composes the seam's
120
+ * EXISTING write paths, never a parallel one (AD-17):
121
+ *
122
+ * 1. `source.create()` mints the fresh id + inserts the rail's blank record
123
+ * (the same collision-free mint the New button uses);
124
+ * 2. `source.save(derived)` lands the copied values under that id (the CMS
125
+ * save is a replace-or-append upsert; a rail save persists its draft).
126
+ *
127
+ * The session store applies both optimistically, so the new row is visible
128
+ * without a reload, and the persister mirrors them onto the operations facade
129
+ * with the user's session — no new trust boundary. Callers gate on the
130
+ * explicit `capabilities.duplicate` flag (the row-menu vocabulary / the header
131
+ * gate) before dispatching — CMS collections only (owner call 2026-07-17;
132
+ * every primitive rail declares `duplicate: false`).
133
+ */
134
+ export function duplicateFromSource(source, original) {
135
+ // Snapshot the slug pool BEFORE create() inserts the blank record.
136
+ const siblings = source.documents();
137
+ const ref = source.create();
138
+ source.save(deriveDuplicateDocument({ original, newId: ref.id, schema: source.schema, siblings }));
139
+ return ref;
140
+ }
141
+ //# sourceMappingURL=duplicate-document.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplicate-document.js","sourceRoot":"","sources":["../../../src/components/cms/duplicate-document.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsDocument, CollectionDef, DocumentRef, FieldDef } from '@elytracms/core/cms-core'\nimport type { CollectionViewSource } from '@elytracms/studio/lib/collection-view/collection-view-source'\n\n/**\n * EC-442 — the duplicate-document derivation (pure core) + the source-composed\n * dispatch behind the list row `⋮` menu's and the detail-editor header's\n * **Duplicate** action.\n *\n * Semantics (ratified 2026-07-17):\n * - The duplicate copies the current DRAFT field values — `values` plus\n * `localized` across ALL locales plus `defaultLocale` — into a fresh document\n * id. It is created as a **draft, never auto-published** (\"live is\n * published-only\"): published-ness is the version pointer (EC-224), and\n * nothing here touches it.\n * - `alias` is deliberately NOT copied: an alias is a unique-within-collection\n * slot handle (EC-284) — code addresses THE document holding it, and a copied\n * alias would immediately surface as uniqueness drift. The duplicate is new\n * content, not the slot holder.\n * - **Slug strategy: uniquify, never clear.** Every slug field (a `text` field\n * named `slug` or opted in via `form.control: 'slug'` — the EC-315\n * convention) gets a deterministic `-copy` / `-copy-2` / … suffix walked past\n * every slug string the collection already holds (base values AND every\n * locale variant of every sibling). Rationale: a cleared slug lands the new\n * draft in an error-looking validation state, while a uniquified one reads as\n * what it is (\"about-copy\") and keeps the draft one review away from\n * publishable. A missing/duplicate slug stays publish-gating only — this\n * derivation never blocks a create.\n * - Composition (`blocks`) field values copy VERBATIM (deep clone): node ids\n * only need uniqueness within one composition value (`validateCompositionValue`\n * checks duplicates per value; the builder's fresh-id counter re-derives from\n * the open value via `maxNodeIdSeq`), so identical node ids across two\n * documents collide nowhere.\n * - References/relations copy as-is (the duplicate points at the same targets);\n * the EC-277 in-transaction reference derivation indexes the new document on\n * its first persisted write because it catches every writer.\n */\n\n/** The conventional slug field name (mirrors `field-control`'s slug auto-upgrade). */\nconst SLUG_FIELD_NAME = 'slug'\n/** The built-in control name that opts any `text` field into slug behaviour (EC-315). */\nconst SLUG_CONTROL = 'slug'\n\n/** The EC-315 slug-field convention: a `text` field named `slug`, or any `text`\n * field with `form.control: 'slug'` (same predicate as the studio slug control). */\nfunction isSlugField(field: FieldDef): boolean {\n return (\n field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME)\n )\n}\n\n/**\n * Every slug string the collection already holds for `fieldName` — the base\n * `values` slot AND every per-locale slot of every sibling (the original\n * included). A union pool across locales is deliberately conservative: a\n * candidate free in the pool can never collide with any sibling in any locale,\n * including through the EC-018 base-value fallback.\n */\nfunction slugPool(fieldName: string, siblings: readonly CmsDocument[]): Set<string> {\n const pool = new Set<string>()\n const add = (value: unknown) => {\n if (typeof value === 'string' && value.trim() !== '') pool.add(value.trim())\n }\n for (const doc of siblings) {\n add(doc.values?.[fieldName])\n for (const localeValues of Object.values(doc.localized ?? {})) add(localeValues[fieldName])\n }\n return pool\n}\n\n/**\n * The deterministic duplicate slug: `<slug>-copy`, then `<slug>-copy-2`,\n * `<slug>-copy-3`, … — the first candidate not in `taken`. Pure function of\n * `(slug, taken)`, so the SAME original string in the base slot and a locale\n * slot maps to the SAME uniquified string (the duplicate keeps the original's\n * internal base/locale relationship).\n */\nexport function uniquifySlug(slug: string, taken: ReadonlySet<string>): string {\n const base = `${slug}-copy`\n if (!taken.has(base)) return base\n let n = 2\n while (taken.has(`${base}-${n}`)) n += 1\n return `${base}-${n}`\n}\n\n/** Uniquify one stored slug slot in place; empty/non-string slots stay untouched\n * (a missing slug remains a publish-gating issue, never a create blocker). */\nfunction uniquifySlot(\n values: Record<string, unknown> | undefined,\n fieldName: string,\n pool: ReadonlySet<string>,\n): void {\n if (!values) return\n const raw = values[fieldName]\n if (typeof raw !== 'string' || raw.trim() === '') return\n values[fieldName] = uniquifySlug(raw.trim(), pool)\n}\n\nexport interface DeriveDuplicateInput {\n /** The document being duplicated — the CURRENT draft values (list row / editor buffer). */\n original: CmsDocument\n /** The freshly minted id for the duplicate (source-owned, via `source.create()`). */\n newId: string\n /** The collection schema — identifies the slug fields to uniquify. */\n schema: CollectionDef\n /** The collection's current rows (the original included) — the slug pool. */\n siblings: readonly CmsDocument[]\n}\n\n/**\n * Derive the duplicate document (pure): deep-copied `values` + `localized` (all\n * locales) + `defaultLocale` under the fresh id, `alias` dropped, every slug\n * field's stored slots uniquified against the collection's slug pool. See the\n * module doc for the ratified semantics each rule pins.\n */\nexport function deriveDuplicateDocument({\n original,\n newId,\n schema,\n siblings,\n}: DeriveDuplicateInput): CmsDocument {\n const duplicate: CmsDocument = {\n collection: original.collection,\n id: newId,\n // Deep clones: composition trees, rich text, nested objects/arrays must be\n // independent of the original (editing one must never mutate the other).\n values: structuredClone(original.values ?? {}),\n ...(original.localized !== undefined ? { localized: structuredClone(original.localized) } : {}),\n ...(original.defaultLocale !== undefined ? { defaultLocale: original.defaultLocale } : {}),\n // `alias` deliberately dropped (unique slot handle — see module doc).\n }\n for (const field of schema.fields) {\n if (!isSlugField(field)) continue\n const pool = slugPool(field.name, siblings)\n uniquifySlot(duplicate.values, field.name, pool)\n for (const localeValues of Object.values(duplicate.localized ?? {})) {\n uniquifySlot(localeValues, field.name, pool)\n }\n }\n return duplicate\n}\n\n/**\n * Duplicate `original` through a {@link CollectionViewSource} — the ONE dispatch\n * both surfaces (list row menu, detail-editor header) share. Composes the seam's\n * EXISTING write paths, never a parallel one (AD-17):\n *\n * 1. `source.create()` mints the fresh id + inserts the rail's blank record\n * (the same collision-free mint the New button uses);\n * 2. `source.save(derived)` lands the copied values under that id (the CMS\n * save is a replace-or-append upsert; a rail save persists its draft).\n *\n * The session store applies both optimistically, so the new row is visible\n * without a reload, and the persister mirrors them onto the operations facade\n * with the user's session — no new trust boundary. Callers gate on the\n * explicit `capabilities.duplicate` flag (the row-menu vocabulary / the header\n * gate) before dispatching — CMS collections only (owner call 2026-07-17;\n * every primitive rail declares `duplicate: false`).\n */\nexport function duplicateFromSource(\n source: CollectionViewSource,\n original: CmsDocument,\n): DocumentRef {\n // Snapshot the slug pool BEFORE create() inserts the blank record.\n const siblings = source.documents()\n const ref = source.create()\n source.save(deriveDuplicateDocument({ original, newId: ref.id, schema: source.schema, siblings }))\n return ref\n}\n"]}
@@ -0,0 +1,67 @@
1
+ import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core';
2
+ /**
3
+ * EC-443 — the pure core of path-aware jump-to-field.
4
+ *
5
+ * The anchor convention itself (`data-ec-field-path="<field>(/<token>)*"`) is
6
+ * defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc
7
+ * comment before touching anything here. This module carries the shared
8
+ * plumbing around it:
9
+ *
10
+ * - {@link FieldPathContext}: the current anchor-path prefix, threaded through
11
+ * the recursive object/repeater controls so each nested row knows its own
12
+ * full path without prop-drilling. `null` outside a stamped surface (e.g.
13
+ * the canvas prop inspector), where no anchors are stamped.
14
+ * - {@link FieldFocusRequestContext}: the active focus request (set by
15
+ * `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en
16
+ * route and the ring highlight on the exact nested control.
17
+ * - The pure focus-plan decisions (`anchorCandidates`,
18
+ * `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no
19
+ * DOM, directly pinned in `use-field-focus.test.ts`.
20
+ */
21
+ /** The anchor-path prefix for the controls below this point (see module doc). */
22
+ export declare const FieldPathContext: import("react").Context<string | null>;
23
+ export declare const useFieldPath: () => string | null;
24
+ /**
25
+ * The in-flight jump-to-field request. `path` is a full anchor path
26
+ * (`links/3/badge/text`); `nonce` distinguishes back-to-back jumps to the same
27
+ * path (a re-click must re-expand a manually collapsed item). Cleared when the
28
+ * highlight window ends.
29
+ */
30
+ export interface FieldFocusRequest {
31
+ path: string;
32
+ nonce: number;
33
+ }
34
+ export declare const FieldFocusRequestContext: import("react").Context<FieldFocusRequest | null>;
35
+ export declare const useFieldFocusRequest: () => FieldFocusRequest | null;
36
+ /** CSS selector for one stamped anchor path. */
37
+ export declare function fieldAnchorSelector(path: string): string;
38
+ /**
39
+ * The anchor lookup order for a parsed field path: every prefix, deepest
40
+ * first, down to the top-level field itself — the graceful-degradation ladder
41
+ * (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The
42
+ * top-level field is additionally always reachable via its `#f-<name>`
43
+ * control id, which is the ultimate floor.
44
+ */
45
+ export declare function anchorCandidates(field: string, rest: readonly string[]): string[];
46
+ /**
47
+ * The deepest candidate that exists right now (`exists` = a DOM probe in
48
+ * production, a fake in tests), or `null` when none does. Combined with
49
+ * {@link anchorCandidates} this IS the degradation decision: deepest existing
50
+ * anchor wins, the top-level field is the floor, never a no-op.
51
+ */
52
+ export declare function deepestExistingAnchor(candidates: readonly string[], exists: (path: string) => boolean): string | null;
53
+ /**
54
+ * Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`
55
+ * contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a
56
+ * collapsed array item uses to auto-open on the way to a nested target.
57
+ */
58
+ export declare function isPathWithin(itemPath: string, focusPath: string): boolean;
59
+ /**
60
+ * Walk a parsed field path down the schema and return the DEEPEST resolvable
61
+ * field def: numeric tokens step into an array item (same def), name tokens
62
+ * descend into an `object` field's subfields. An unknown token stops the walk
63
+ * at the last resolved def (label degradation mirrors anchor degradation);
64
+ * an unknown top-level field is `null`. Rich-text node paths therefore resolve
65
+ * to the rich-text field itself.
66
+ */
67
+ export declare function resolveFieldDefAtPath(collection: CollectionDef, field: string, rest: readonly string[]): FieldDef | null;
@@ -0,0 +1,91 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { FIELD_PATH_ATTR } from '@elytracms/studio/ui';
3
+ /**
4
+ * EC-443 — the pure core of path-aware jump-to-field.
5
+ *
6
+ * The anchor convention itself (`data-ec-field-path="<field>(/<token>)*"`) is
7
+ * defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc
8
+ * comment before touching anything here. This module carries the shared
9
+ * plumbing around it:
10
+ *
11
+ * - {@link FieldPathContext}: the current anchor-path prefix, threaded through
12
+ * the recursive object/repeater controls so each nested row knows its own
13
+ * full path without prop-drilling. `null` outside a stamped surface (e.g.
14
+ * the canvas prop inspector), where no anchors are stamped.
15
+ * - {@link FieldFocusRequestContext}: the active focus request (set by
16
+ * `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en
17
+ * route and the ring highlight on the exact nested control.
18
+ * - The pure focus-plan decisions (`anchorCandidates`,
19
+ * `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no
20
+ * DOM, directly pinned in `use-field-focus.test.ts`.
21
+ */
22
+ // --- Contexts -----------------------------------------------------------------
23
+ /** The anchor-path prefix for the controls below this point (see module doc). */
24
+ export const FieldPathContext = createContext(null);
25
+ export const useFieldPath = () => useContext(FieldPathContext);
26
+ export const FieldFocusRequestContext = createContext(null);
27
+ export const useFieldFocusRequest = () => useContext(FieldFocusRequestContext);
28
+ // --- Pure focus-plan decisions ------------------------------------------------
29
+ /** CSS selector for one stamped anchor path. */
30
+ export function fieldAnchorSelector(path) {
31
+ return `[${FIELD_PATH_ATTR}="${path.replace(/"/g, '\\"')}"]`;
32
+ }
33
+ /**
34
+ * The anchor lookup order for a parsed field path: every prefix, deepest
35
+ * first, down to the top-level field itself — the graceful-degradation ladder
36
+ * (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The
37
+ * top-level field is additionally always reachable via its `#f-<name>`
38
+ * control id, which is the ultimate floor.
39
+ */
40
+ export function anchorCandidates(field, rest) {
41
+ const out = [];
42
+ for (let depth = rest.length; depth >= 0; depth--) {
43
+ out.push([field, ...rest.slice(0, depth)].join('/'));
44
+ }
45
+ return out;
46
+ }
47
+ /**
48
+ * The deepest candidate that exists right now (`exists` = a DOM probe in
49
+ * production, a fake in tests), or `null` when none does. Combined with
50
+ * {@link anchorCandidates} this IS the degradation decision: deepest existing
51
+ * anchor wins, the top-level field is the floor, never a no-op.
52
+ */
53
+ export function deepestExistingAnchor(candidates, exists) {
54
+ for (const candidate of candidates)
55
+ if (exists(candidate))
56
+ return candidate;
57
+ return null;
58
+ }
59
+ /**
60
+ * Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`
61
+ * contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a
62
+ * collapsed array item uses to auto-open on the way to a nested target.
63
+ */
64
+ export function isPathWithin(itemPath, focusPath) {
65
+ return focusPath === itemPath || focusPath.startsWith(`${itemPath}/`);
66
+ }
67
+ /**
68
+ * Walk a parsed field path down the schema and return the DEEPEST resolvable
69
+ * field def: numeric tokens step into an array item (same def), name tokens
70
+ * descend into an `object` field's subfields. An unknown token stops the walk
71
+ * at the last resolved def (label degradation mirrors anchor degradation);
72
+ * an unknown top-level field is `null`. Rich-text node paths therefore resolve
73
+ * to the rich-text field itself.
74
+ */
75
+ export function resolveFieldDefAtPath(collection, field, rest) {
76
+ let current = collection.fields.find((f) => f.name === field);
77
+ if (current === undefined)
78
+ return null;
79
+ for (const token of rest) {
80
+ if (/^\d+$/.test(token))
81
+ continue;
82
+ if (current.type !== 'object')
83
+ break;
84
+ const sub = current.fields.find((f) => f.name === token);
85
+ if (sub === undefined)
86
+ break;
87
+ current = sub;
88
+ }
89
+ return current;
90
+ }
91
+ //# sourceMappingURL=field-anchors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-anchors.js","sourceRoot":"","sources":["../../../src/components/cms/field-anchors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AAEjD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAEtD;;;;;;;;;;;;;;;;;;GAkBG;AAEH,iFAAiF;AAEjF,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAgB,IAAI,CAAC,CAAA;AAClE,MAAM,CAAC,MAAM,YAAY,GAAG,GAAkB,EAAE,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAA;AAY7E,MAAM,CAAC,MAAM,wBAAwB,GAAG,aAAa,CAA2B,IAAI,CAAC,CAAA;AACrF,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAA6B,EAAE,CACjE,UAAU,CAAC,wBAAwB,CAAC,CAAA;AAEtC,iFAAiF;AAEjF,gDAAgD;AAChD,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,eAAe,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAA;AAC9D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,IAAuB;IACrE,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,IAAI,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAA6B,EAC7B,MAAiC;IAEjC,KAAK,MAAM,SAAS,IAAI,UAAU;QAAE,IAAI,MAAM,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAA;IAC3E,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,SAAiB;IAC9D,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAA;AACvE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAAyB,EACzB,KAAa,EACb,IAAuB;IAEvB,IAAI,OAAO,GAAyB,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;IACnF,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,IAAI,CAAA;IACtC,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;QACzB,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,SAAQ;QACjC,IAAI,OAAO,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAK;QACpC,MAAM,GAAG,GAAyB,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAA;QAC9E,IAAI,GAAG,KAAK,SAAS;YAAE,MAAK;QAC5B,OAAO,GAAG,GAAG,CAAA;IACf,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["import { createContext, useContext } from 'react'\nimport type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { FIELD_PATH_ATTR } from '@elytracms/studio/ui'\n\n/**\n * EC-443 — the pure core of path-aware jump-to-field.\n *\n * The anchor convention itself (`data-ec-field-path=\"<field>(/<token>)*\"`) is\n * defined ONCE, on {@link FIELD_PATH_ATTR} in `ui/field.tsx` — read its doc\n * comment before touching anything here. This module carries the shared\n * plumbing around it:\n *\n * - {@link FieldPathContext}: the current anchor-path prefix, threaded through\n * the recursive object/repeater controls so each nested row knows its own\n * full path without prop-drilling. `null` outside a stamped surface (e.g.\n * the canvas prop inspector), where no anchors are stamped.\n * - {@link FieldFocusRequestContext}: the active focus request (set by\n * `useFieldFocus`), which drives BOTH collapsed-array auto-expansion en\n * route and the ring highlight on the exact nested control.\n * - The pure focus-plan decisions (`anchorCandidates`,\n * `deepestExistingAnchor`, `isPathWithin`, `resolveFieldDefAtPath`) — no\n * DOM, directly pinned in `use-field-focus.test.ts`.\n */\n\n// --- Contexts -----------------------------------------------------------------\n\n/** The anchor-path prefix for the controls below this point (see module doc). */\nexport const FieldPathContext = createContext<string | null>(null)\nexport const useFieldPath = (): string | null => useContext(FieldPathContext)\n\n/**\n * The in-flight jump-to-field request. `path` is a full anchor path\n * (`links/3/badge/text`); `nonce` distinguishes back-to-back jumps to the same\n * path (a re-click must re-expand a manually collapsed item). Cleared when the\n * highlight window ends.\n */\nexport interface FieldFocusRequest {\n path: string\n nonce: number\n}\nexport const FieldFocusRequestContext = createContext<FieldFocusRequest | null>(null)\nexport const useFieldFocusRequest = (): FieldFocusRequest | null =>\n useContext(FieldFocusRequestContext)\n\n// --- Pure focus-plan decisions ------------------------------------------------\n\n/** CSS selector for one stamped anchor path. */\nexport function fieldAnchorSelector(path: string): string {\n return `[${FIELD_PATH_ATTR}=\"${path.replace(/\"/g, '\\\\\"')}\"]`\n}\n\n/**\n * The anchor lookup order for a parsed field path: every prefix, deepest\n * first, down to the top-level field itself — the graceful-degradation ladder\n * (`links/3/badge/text` → `links/3/badge` → `links/3` → `links`). The\n * top-level field is additionally always reachable via its `#f-<name>`\n * control id, which is the ultimate floor.\n */\nexport function anchorCandidates(field: string, rest: readonly string[]): string[] {\n const out: string[] = []\n for (let depth = rest.length; depth >= 0; depth--) {\n out.push([field, ...rest.slice(0, depth)].join('/'))\n }\n return out\n}\n\n/**\n * The deepest candidate that exists right now (`exists` = a DOM probe in\n * production, a fake in tests), or `null` when none does. Combined with\n * {@link anchorCandidates} this IS the degradation decision: deepest existing\n * anchor wins, the top-level field is the floor, never a no-op.\n */\nexport function deepestExistingAnchor(\n candidates: readonly string[],\n exists: (path: string) => boolean,\n): string | null {\n for (const candidate of candidates) if (exists(candidate)) return candidate\n return null\n}\n\n/**\n * Is `focusPath` at or below `itemPath`? Token-prefix semantics (`links/3`\n * contains `links/3/badge/text` but NOT `links/31`) — the expansion decision a\n * collapsed array item uses to auto-open on the way to a nested target.\n */\nexport function isPathWithin(itemPath: string, focusPath: string): boolean {\n return focusPath === itemPath || focusPath.startsWith(`${itemPath}/`)\n}\n\n/**\n * Walk a parsed field path down the schema and return the DEEPEST resolvable\n * field def: numeric tokens step into an array item (same def), name tokens\n * descend into an `object` field's subfields. An unknown token stops the walk\n * at the last resolved def (label degradation mirrors anchor degradation);\n * an unknown top-level field is `null`. Rich-text node paths therefore resolve\n * to the rich-text field itself.\n */\nexport function resolveFieldDefAtPath(\n collection: CollectionDef,\n field: string,\n rest: readonly string[],\n): FieldDef | null {\n let current: FieldDef | undefined = collection.fields.find((f) => f.name === field)\n if (current === undefined) return null\n for (const token of rest) {\n if (/^\\d+$/.test(token)) continue\n if (current.type !== 'object') break\n const sub: FieldDef | undefined = current.fields.find((f) => f.name === token)\n if (sub === undefined) break\n current = sub\n }\n return current\n}\n"]}
@@ -0,0 +1,45 @@
1
+ import type { CmsValidationIssue, CollectionDef, FieldDef } from '@elytracms/core/cms-core';
2
+ /**
3
+ * EC-443 — the pure copy/jump decisions behind the Issues pane. No React, no
4
+ * catalog access: {@link issueJumpTarget} turns a structured issue into the
5
+ * anchor path + deepest resolvable field def the jump link needs, and
6
+ * {@link issueBodyDescriptor} maps an issue code to a localized body template
7
+ * (catalog key + params) where the full body is derivable from `code` +
8
+ * `path`/`meta` alone. Codes whose core message carries underivable detail
9
+ * (e.g. `invalid-field-value`'s per-check phrasing, `custom-validation`'s
10
+ * config-authored text) return `null` — the pane then shows the raw English
11
+ * core message, an HONEST fallback, never a blank.
12
+ */
13
+ /** Where an issue's jump link lands + what it is called. */
14
+ export interface IssueJumpTarget {
15
+ /** The `FIELD_PATH_ATTR` anchor path (`links/3/badge/text`), locale stripped. */
16
+ path: string;
17
+ /** The content locale the issue belongs to (`localized/<locale>/…` paths). */
18
+ locale?: string;
19
+ /** The deepest field def resolvable along the path (label source), if any. */
20
+ fieldDef: FieldDef | null;
21
+ /** The last named path token — the label fallback when no def resolves. */
22
+ leaf: string;
23
+ }
24
+ /**
25
+ * Derive the jump target from an issue's structured `path` (the full document
26
+ * index path — `['documents', <key>, 'values'|'localized', …]` from
27
+ * `validateDocument`, or a bare `[<field>]` from studio-built issues like the
28
+ * EC-300 embed errors). The FULL path wins (so a nested issue lands on the
29
+ * exact control — `meta.field` carries only the LEAF name and must never be
30
+ * mistaken for a top-level field); `meta.field` is the fallback for bare
31
+ * paths, and only when it names a real top-level field. `null` → no jump link
32
+ * (document-scoped issues).
33
+ */
34
+ export declare function issueJumpTarget(collection: CollectionDef, issue: CmsValidationIssue): IssueJumpTarget | null;
35
+ /** A localized issue body: the catalog key + its substitution params. */
36
+ export interface IssueBodyDescriptor {
37
+ key: string;
38
+ vars: Record<string, string | number>;
39
+ }
40
+ /**
41
+ * The code → body-template mapping (see module doc for the fallback contract).
42
+ * `fieldLabelText` is the resolved (localized) label of the deepest field along
43
+ * the issue path — supplied by the caller so this stays catalog-free and pure.
44
+ */
45
+ export declare function issueBodyDescriptor(issue: CmsValidationIssue, fieldLabelText: string): IssueBodyDescriptor | null;
@@ -0,0 +1,89 @@
1
+ import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
2
+ import { resolveFieldDefAtPath } from './field-anchors.js';
3
+ /** The last non-index token of a path (`links/3/badge/text` → `text`). */
4
+ function lastNameToken(tokens) {
5
+ for (let i = tokens.length - 1; i >= 0; i--) {
6
+ const token = tokens[i];
7
+ if (!/^\d+$/.test(token))
8
+ return token;
9
+ }
10
+ return tokens[tokens.length - 1] ?? '';
11
+ }
12
+ /**
13
+ * Derive the jump target from an issue's structured `path` (the full document
14
+ * index path — `['documents', <key>, 'values'|'localized', …]` from
15
+ * `validateDocument`, or a bare `[<field>]` from studio-built issues like the
16
+ * EC-300 embed errors). The FULL path wins (so a nested issue lands on the
17
+ * exact control — `meta.field` carries only the LEAF name and must never be
18
+ * mistaken for a top-level field); `meta.field` is the fallback for bare
19
+ * paths, and only when it names a real top-level field. `null` → no jump link
20
+ * (document-scoped issues).
21
+ */
22
+ export function issueJumpTarget(collection, issue) {
23
+ const tokens = issue.path.map(String);
24
+ const relative = tokens[0] === 'documents' ? tokens.slice(2) : tokens;
25
+ const parsed = parseDocumentFieldPath(relative.join('/'));
26
+ if (parsed && collection.fields.some((f) => f.name === parsed.field)) {
27
+ const pathTokens = [parsed.field, ...parsed.rest];
28
+ return {
29
+ path: pathTokens.join('/'),
30
+ ...(parsed.locale !== undefined ? { locale: parsed.locale } : {}),
31
+ fieldDef: resolveFieldDefAtPath(collection, parsed.field, parsed.rest),
32
+ leaf: lastNameToken(pathTokens),
33
+ };
34
+ }
35
+ const metaField = typeof issue.meta?.field === 'string' ? issue.meta.field : undefined;
36
+ const topLevel = metaField ? collection.fields.find((f) => f.name === metaField) : undefined;
37
+ if (topLevel) {
38
+ return { path: topLevel.name, fieldDef: topLevel, leaf: topLevel.name };
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * The code → body-template mapping (see module doc for the fallback contract).
44
+ * `fieldLabelText` is the resolved (localized) label of the deepest field along
45
+ * the issue path — supplied by the caller so this stays catalog-free and pure.
46
+ */
47
+ export function issueBodyDescriptor(issue, fieldLabelText) {
48
+ switch (issue.code) {
49
+ case 'missing-required-field':
50
+ return { key: 'cms.issueBody.missing-required-field', vars: { field: fieldLabelText } };
51
+ case 'missing-localized-value': {
52
+ const locale = typeof issue.meta?.locale === 'string' ? issue.meta.locale : undefined;
53
+ if (locale === undefined)
54
+ return null;
55
+ return issue.severity === 'error'
56
+ ? {
57
+ key: 'cms.issueBody.missing-localized-value.default',
58
+ vars: { field: fieldLabelText, locale },
59
+ }
60
+ : {
61
+ key: 'cms.issueBody.missing-localized-value.fallback',
62
+ vars: { field: fieldLabelText, locale },
63
+ };
64
+ }
65
+ case 'unknown-asset': {
66
+ const asset = typeof issue.meta?.asset === 'string' ? issue.meta.asset : undefined;
67
+ if (asset === undefined)
68
+ return null;
69
+ return { key: 'cms.issueBody.unknown-asset', vars: { field: fieldLabelText, asset } };
70
+ }
71
+ case 'document-nesting-depth': {
72
+ const depth = typeof issue.meta?.depth === 'number' ? issue.meta.depth : undefined;
73
+ const limit = typeof issue.meta?.limit === 'number' ? issue.meta.limit : undefined;
74
+ if (depth === undefined || limit === undefined)
75
+ return null;
76
+ return {
77
+ key: issue.severity === 'error'
78
+ ? 'cms.issueBody.document-nesting-depth.error'
79
+ : 'cms.issueBody.document-nesting-depth.warning',
80
+ vars: { depth, limit },
81
+ };
82
+ }
83
+ case 'hierarchy-cycle':
84
+ return { key: 'cms.issueBody.hierarchy-cycle', vars: {} };
85
+ default:
86
+ return null;
87
+ }
88
+ }
89
+ //# sourceMappingURL=issue-copy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issue-copy.js","sourceRoot":"","sources":["../../../src/components/cms/issue-copy.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsValidationIssue, CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { parseDocumentFieldPath } from '@elytracms/studio/lib/references'\nimport { resolveFieldDefAtPath } from './field-anchors'\n\n/**\n * EC-443 — the pure copy/jump decisions behind the Issues pane. No React, no\n * catalog access: {@link issueJumpTarget} turns a structured issue into the\n * anchor path + deepest resolvable field def the jump link needs, and\n * {@link issueBodyDescriptor} maps an issue code to a localized body template\n * (catalog key + params) where the full body is derivable from `code` +\n * `path`/`meta` alone. Codes whose core message carries underivable detail\n * (e.g. `invalid-field-value`'s per-check phrasing, `custom-validation`'s\n * config-authored text) return `null` — the pane then shows the raw English\n * core message, an HONEST fallback, never a blank.\n */\n\n/** Where an issue's jump link lands + what it is called. */\nexport interface IssueJumpTarget {\n /** The `FIELD_PATH_ATTR` anchor path (`links/3/badge/text`), locale stripped. */\n path: string\n /** The content locale the issue belongs to (`localized/<locale>/…` paths). */\n locale?: string\n /** The deepest field def resolvable along the path (label source), if any. */\n fieldDef: FieldDef | null\n /** The last named path token — the label fallback when no def resolves. */\n leaf: string\n}\n\n/** The last non-index token of a path (`links/3/badge/text` → `text`). */\nfunction lastNameToken(tokens: readonly string[]): string {\n for (let i = tokens.length - 1; i >= 0; i--) {\n const token = tokens[i]!\n if (!/^\\d+$/.test(token)) return token\n }\n return tokens[tokens.length - 1] ?? ''\n}\n\n/**\n * Derive the jump target from an issue's structured `path` (the full document\n * index path — `['documents', <key>, 'values'|'localized', …]` from\n * `validateDocument`, or a bare `[<field>]` from studio-built issues like the\n * EC-300 embed errors). The FULL path wins (so a nested issue lands on the\n * exact control — `meta.field` carries only the LEAF name and must never be\n * mistaken for a top-level field); `meta.field` is the fallback for bare\n * paths, and only when it names a real top-level field. `null` → no jump link\n * (document-scoped issues).\n */\nexport function issueJumpTarget(\n collection: CollectionDef,\n issue: CmsValidationIssue,\n): IssueJumpTarget | null {\n const tokens = issue.path.map(String)\n const relative = tokens[0] === 'documents' ? tokens.slice(2) : tokens\n const parsed = parseDocumentFieldPath(relative.join('/'))\n if (parsed && collection.fields.some((f) => f.name === parsed.field)) {\n const pathTokens = [parsed.field, ...parsed.rest]\n return {\n path: pathTokens.join('/'),\n ...(parsed.locale !== undefined ? { locale: parsed.locale } : {}),\n fieldDef: resolveFieldDefAtPath(collection, parsed.field, parsed.rest),\n leaf: lastNameToken(pathTokens),\n }\n }\n const metaField = typeof issue.meta?.field === 'string' ? issue.meta.field : undefined\n const topLevel = metaField ? collection.fields.find((f) => f.name === metaField) : undefined\n if (topLevel) {\n return { path: topLevel.name, fieldDef: topLevel, leaf: topLevel.name }\n }\n return null\n}\n\n/** A localized issue body: the catalog key + its substitution params. */\nexport interface IssueBodyDescriptor {\n key: string\n vars: Record<string, string | number>\n}\n\n/**\n * The code → body-template mapping (see module doc for the fallback contract).\n * `fieldLabelText` is the resolved (localized) label of the deepest field along\n * the issue path — supplied by the caller so this stays catalog-free and pure.\n */\nexport function issueBodyDescriptor(\n issue: CmsValidationIssue,\n fieldLabelText: string,\n): IssueBodyDescriptor | null {\n switch (issue.code) {\n case 'missing-required-field':\n return { key: 'cms.issueBody.missing-required-field', vars: { field: fieldLabelText } }\n case 'missing-localized-value': {\n const locale = typeof issue.meta?.locale === 'string' ? issue.meta.locale : undefined\n if (locale === undefined) return null\n return issue.severity === 'error'\n ? {\n key: 'cms.issueBody.missing-localized-value.default',\n vars: { field: fieldLabelText, locale },\n }\n : {\n key: 'cms.issueBody.missing-localized-value.fallback',\n vars: { field: fieldLabelText, locale },\n }\n }\n case 'unknown-asset': {\n const asset = typeof issue.meta?.asset === 'string' ? issue.meta.asset : undefined\n if (asset === undefined) return null\n return { key: 'cms.issueBody.unknown-asset', vars: { field: fieldLabelText, asset } }\n }\n case 'document-nesting-depth': {\n const depth = typeof issue.meta?.depth === 'number' ? issue.meta.depth : undefined\n const limit = typeof issue.meta?.limit === 'number' ? issue.meta.limit : undefined\n if (depth === undefined || limit === undefined) return null\n return {\n key:\n issue.severity === 'error'\n ? 'cms.issueBody.document-nesting-depth.error'\n : 'cms.issueBody.document-nesting-depth.warning',\n vars: { depth, limit },\n }\n }\n case 'hierarchy-cycle':\n return { key: 'cms.issueBody.hierarchy-cycle', vars: {} }\n default:\n return null\n }\n}\n"]}
@@ -6,14 +6,23 @@ import type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core
6
6
  * `InspectorSidebar` (the document-scoped equivalent of the amputated builder's
7
7
  * validation sidebar, EC-069). Field-level issues stay inline on the field too;
8
8
  * here they additionally list with a jump-to-field action. Document-level issues
9
- * that carry no `meta.field` (e.g. the EC-260 nesting-depth warning) have no
9
+ * that carry no field path (e.g. the EC-260 nesting-depth warning) have no
10
10
  * other home — this is theirs.
11
11
  *
12
+ * EC-443: the jump carries the issue's FULL parsed path (`issueJumpTarget`), so
13
+ * a nested finding (`links/3/badge/text`) lands on the exact control — the link
14
+ * names the deepest resolvable field's localized label, never a raw leaf token
15
+ * when a def resolves. Card copy is localized studio-side: the title via
16
+ * `cmsIssueLabel` (catalog-routed), the body via the `issueBodyDescriptor`
17
+ * template where one exists, else the raw (English) core message — an honest
18
+ * fallback, never a blank. Core validation messages stay English.
19
+ *
12
20
  * Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw
13
21
  * `code` rides along in `title=` for engineers, never as visible copy.
14
22
  */
15
23
  export declare function IssuesPane({ issues, collection, onJumpToField, }: {
16
24
  issues: readonly CmsValidationIssue[];
17
25
  collection: CollectionDef;
18
- onJumpToField: (field: string) => void;
26
+ /** Receives the EC-443 anchor path (`links/3/badge/text`) + the issue's locale. */
27
+ onJumpToField: (fieldPath: string, locale?: string) => void;
19
28
  }): import("react").JSX.Element;
@@ -5,6 +5,7 @@ import { cn } from '@elytracms/studio/lib/utils';
5
5
  import { cmsIssueLabel } from '@elytracms/studio/lib/persona/labels';
6
6
  import { translate } from '@elytracms/studio/lib/i18n';
7
7
  import { fieldLabel } from './cms-labels.js';
8
+ import { issueBodyDescriptor, issueJumpTarget } from './issue-copy.js';
8
9
  /**
9
10
  * EC-283 — the document-scoped validation surface.
10
11
  *
@@ -12,9 +13,17 @@ import { fieldLabel } from './cms-labels.js';
12
13
  * `InspectorSidebar` (the document-scoped equivalent of the amputated builder's
13
14
  * validation sidebar, EC-069). Field-level issues stay inline on the field too;
14
15
  * here they additionally list with a jump-to-field action. Document-level issues
15
- * that carry no `meta.field` (e.g. the EC-260 nesting-depth warning) have no
16
+ * that carry no field path (e.g. the EC-260 nesting-depth warning) have no
16
17
  * other home — this is theirs.
17
18
  *
19
+ * EC-443: the jump carries the issue's FULL parsed path (`issueJumpTarget`), so
20
+ * a nested finding (`links/3/badge/text`) lands on the exact control — the link
21
+ * names the deepest resolvable field's localized label, never a raw leaf token
22
+ * when a def resolves. Card copy is localized studio-side: the title via
23
+ * `cmsIssueLabel` (catalog-routed), the body via the `issueBodyDescriptor`
24
+ * template where one exists, else the raw (English) core message — an honest
25
+ * fallback, never a blank. Core validation messages stay English.
26
+ *
18
27
  * Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw
19
28
  * `code` rides along in `title=` for engineers, never as visible copy.
20
29
  */
@@ -26,9 +35,18 @@ export function IssuesPane({ issues, collection, onJumpToField, }) {
26
35
  }
27
36
  function IssueRow({ issue, collection, onJumpToField, }) {
28
37
  const isError = issue.severity === 'error';
29
- const fieldName = typeof issue.meta?.field === 'string' ? issue.meta.field : undefined;
30
- const fieldDef = fieldName ? collection.fields.find((f) => f.name === fieldName) : undefined;
38
+ const jump = issueJumpTarget(collection, issue);
39
+ // The (localized) name of the offending field: the deepest resolvable def's
40
+ // `form.label`, degrading to the leaf path token / `meta.field` raw name.
41
+ const fieldName = jump !== null
42
+ ? jump.fieldDef !== null
43
+ ? fieldLabel(jump.fieldDef)
44
+ : jump.leaf
45
+ : typeof issue.meta?.field === 'string'
46
+ ? issue.meta.field
47
+ : '';
48
+ const body = issueBodyDescriptor(issue, fieldName);
31
49
  const Icon = isError ? CircleAlert : TriangleAlert;
32
- return (_jsxs(Stack, { gap: "1", className: cn('rounded-md border px-2.5 py-2', isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5'), children: [_jsxs(Row, { gap: "2", align: "start", children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning'), "aria-hidden": true }), _jsxs(Stack, { gap: "1", className: "min-w-0", children: [_jsx(Text, { size: "xs", weight: "semibold", title: issue.code, children: cmsIssueLabel(issue.code) }), _jsx(Text, { size: "xs", tone: "muted", children: issue.message })] })] }), fieldName ? (_jsx(Row, { className: "pl-[1.375rem]", children: _jsx(Button, { size: "sm", variant: "link", className: "h-auto p-0 text-xs", onClick: () => onJumpToField(fieldName), children: translate('cms.issues.goToField', { field: fieldDef ? fieldLabel(fieldDef) : fieldName }) }) })) : null] }));
50
+ return (_jsxs(Stack, { gap: "1", className: cn('rounded-md border px-2.5 py-2', isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5'), children: [_jsxs(Row, { gap: "2", align: "start", children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning'), "aria-hidden": true }), _jsxs(Stack, { gap: "1", className: "min-w-0", children: [_jsx(Text, { size: "xs", weight: "semibold", title: issue.code, children: cmsIssueLabel(issue.code) }), _jsx(Text, { size: "xs", tone: "muted", children: body !== null ? translate(body.key, body.vars) : issue.message })] })] }), jump !== null ? (_jsx(Row, { className: "pl-[1.375rem]", children: _jsx(Button, { size: "sm", variant: "link", className: "h-auto p-0 text-xs", onClick: () => onJumpToField(jump.path, jump.locale), children: translate('cms.issues.goToField', { field: fieldName }) }) })) : null] }));
33
51
  }
34
52
  //# sourceMappingURL=issues-pane.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"issues-pane.js","sourceRoot":"","sources":["../../../src/components/cms/issues-pane.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEtE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC/D,OAAO,EAAE,EAAE,EAAE,MAAM,6BAA6B,CAAA;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,sCAAsC,CAAA;AACpE,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAEzC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,UAAU,CAAC,EACzB,MAAM,EACN,UAAU,EACV,aAAa,GAKd;IACC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CACL,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,KAAK,EAAC,QAAQ,EAAC,SAAS,EAAC,wBAAwB,aAC9D,KAAC,WAAW,IAAC,SAAS,EAAC,qBAAqB,wBAAe,EAC3D,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,MAAM,EAAC,QAAQ,YAC5B,SAAS,CAAC,iBAAiB,CAAC,GACxB,EACP,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,sBAAsB,CAAC,GAC7B,IACD,CACT,CAAA;IACH,CAAC;IAED,OAAO,CACL,KAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,SAAS,EAAC,KAAK,YAC3B,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CACxB,KAAC,QAAQ,IAEP,KAAK,EAAE,KAAK,EACZ,UAAU,EAAE,UAAU,EACtB,aAAa,EAAE,aAAa,IAHvB,GAAG,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAIjD,CACH,CAAC,GACI,CACT,CAAA;AACH,CAAC;AAED,SAAS,QAAQ,CAAC,EAChB,KAAK,EACL,UAAU,EACV,aAAa,GAKd;IACC,MAAM,OAAO,GAAG,KAAK,CAAC,QAAQ,KAAK,OAAO,CAAA;IAC1C,MAAM,SAAS,GAAG,OAAO,KAAK,CAAC,IAAI,EAAE,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAA;IACtF,MAAM,QAAQ,GAAG,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IAC5F,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,aAAa,CAAA;IAElD,OAAO,CACL,MAAC,KAAK,IACJ,GAAG,EAAC,GAAG,EACP,SAAS,EAAE,EAAE,CACX,+BAA+B,EAC/B,OAAO,CAAC,CAAC,CAAC,wCAAwC,CAAC,CAAC,CAAC,gCAAgC,CACtF,aAED,MAAC,GAAG,IAAC,GAAG,EAAC,GAAG,EAAC,KAAK,EAAC,OAAO,aACxB,KAAC,IAAI,IACH,SAAS,EAAE,EAAE,CAAC,0BAA0B,EAAE,OAAO,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,cAAc,CAAC,wBAExF,EACF,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,SAAS,EAAC,SAAS,aAChC,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,MAAM,EAAC,UAAU,EAAC,KAAK,EAAE,KAAK,CAAC,IAAI,YAChD,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,GACrB,EACP,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,KAAK,CAAC,OAAO,GACT,IACD,IACJ,EACL,SAAS,CAAC,CAAC,CAAC,CACX,KAAC,GAAG,IAAC,SAAS,EAAC,eAAe,YAC5B,KAAC,MAAM,IACL,IAAI,EAAC,IAAI,EACT,OAAO,EAAC,MAAM,EACd,SAAS,EAAC,oBAAoB,EAC9B,OAAO,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,SAAS,CAAC,YAEtC,SAAS,CAAC,sBAAsB,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,GACnF,GACL,CACP,CAAC,CAAC,CAAC,IAAI,IACF,CACT,CAAA;AACH,CAAC","sourcesContent":["import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react'\nimport type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core'\nimport { Button, Row, Stack, Text } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { cmsIssueLabel } from '@elytracms/studio/lib/persona/labels'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { fieldLabel } from './cms-labels'\n\n/**\n * EC-283 — the document-scoped validation surface.\n *\n * Lists every {@link CmsValidationIssue} for the open draft in the right\n * `InspectorSidebar` (the document-scoped equivalent of the amputated builder's\n * validation sidebar, EC-069). Field-level issues stay inline on the field too;\n * here they additionally list with a jump-to-field action. Document-level issues\n * that carry no `meta.field` (e.g. the EC-260 nesting-depth warning) have no\n * other home — this is theirs.\n *\n * Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw\n * `code` rides along in `title=` for engineers, never as visible copy.\n */\nexport function IssuesPane({\n issues,\n collection,\n onJumpToField,\n}: {\n issues: readonly CmsValidationIssue[]\n collection: CollectionDef\n onJumpToField: (field: string) => void\n}) {\n if (issues.length === 0) {\n return (\n <Stack gap=\"2\" align=\"center\" className=\"px-4 py-10 text-center\">\n <CircleCheck className=\"size-5 text-success\" aria-hidden />\n <Text size=\"sm\" weight=\"medium\">\n {translate('cms.issues.none')}\n </Text>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.issues.satisfied')}\n </Text>\n </Stack>\n )\n }\n\n return (\n <Stack gap=\"1\" className=\"p-2\">\n {issues.map((issue, i) => (\n <IssueRow\n key={`${issue.code}:${issue.path.join('/')}:${i}`}\n issue={issue}\n collection={collection}\n onJumpToField={onJumpToField}\n />\n ))}\n </Stack>\n )\n}\n\nfunction IssueRow({\n issue,\n collection,\n onJumpToField,\n}: {\n issue: CmsValidationIssue\n collection: CollectionDef\n onJumpToField: (field: string) => void\n}) {\n const isError = issue.severity === 'error'\n const fieldName = typeof issue.meta?.field === 'string' ? issue.meta.field : undefined\n const fieldDef = fieldName ? collection.fields.find((f) => f.name === fieldName) : undefined\n const Icon = isError ? CircleAlert : TriangleAlert\n\n return (\n <Stack\n gap=\"1\"\n className={cn(\n 'rounded-md border px-2.5 py-2',\n isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5',\n )}\n >\n <Row gap=\"2\" align=\"start\">\n <Icon\n className={cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning')}\n aria-hidden\n />\n <Stack gap=\"1\" className=\"min-w-0\">\n <Text size=\"xs\" weight=\"semibold\" title={issue.code}>\n {cmsIssueLabel(issue.code)}\n </Text>\n <Text size=\"xs\" tone=\"muted\">\n {issue.message}\n </Text>\n </Stack>\n </Row>\n {fieldName ? (\n <Row className=\"pl-[1.375rem]\">\n <Button\n size=\"sm\"\n variant=\"link\"\n className=\"h-auto p-0 text-xs\"\n onClick={() => onJumpToField(fieldName)}\n >\n {translate('cms.issues.goToField', { field: fieldDef ? fieldLabel(fieldDef) : fieldName })}\n </Button>\n </Row>\n ) : null}\n </Stack>\n )\n}\n"]}
1
+ {"version":3,"file":"issues-pane.js","sourceRoot":"","sources":["../../../src/components/cms/issues-pane.tsx"],"names":[],"mappings":"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","sourcesContent":["import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react'\nimport type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core'\nimport { Button, Row, Stack, Text } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { cmsIssueLabel } from '@elytracms/studio/lib/persona/labels'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { fieldLabel } from './cms-labels'\nimport { issueBodyDescriptor, issueJumpTarget } from './issue-copy'\n\n/**\n * EC-283 — the document-scoped validation surface.\n *\n * Lists every {@link CmsValidationIssue} for the open draft in the right\n * `InspectorSidebar` (the document-scoped equivalent of the amputated builder's\n * validation sidebar, EC-069). Field-level issues stay inline on the field too;\n * here they additionally list with a jump-to-field action. Document-level issues\n * that carry no field path (e.g. the EC-260 nesting-depth warning) have no\n * other home — this is theirs.\n *\n * EC-443: the jump carries the issue's FULL parsed path (`issueJumpTarget`), so\n * a nested finding (`links/3/badge/text`) lands on the exact control — the link\n * names the deepest resolvable field's localized label, never a raw leaf token\n * when a def resolves. Card copy is localized studio-side: the title via\n * `cmsIssueLabel` (catalog-routed), the body via the `issueBodyDescriptor`\n * template where one exists, else the raw (English) core message — an honest\n * fallback, never a blank. Core validation messages stay English.\n *\n * Persona boundary (EC-192): rows show the plain-language `cmsIssueLabel`; the raw\n * `code` rides along in `title=` for engineers, never as visible copy.\n */\nexport function IssuesPane({\n issues,\n collection,\n onJumpToField,\n}: {\n issues: readonly CmsValidationIssue[]\n collection: CollectionDef\n /** Receives the EC-443 anchor path (`links/3/badge/text`) + the issue's locale. */\n onJumpToField: (fieldPath: string, locale?: string) => void\n}) {\n if (issues.length === 0) {\n return (\n <Stack gap=\"2\" align=\"center\" className=\"px-4 py-10 text-center\">\n <CircleCheck className=\"size-5 text-success\" aria-hidden />\n <Text size=\"sm\" weight=\"medium\">\n {translate('cms.issues.none')}\n </Text>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.issues.satisfied')}\n </Text>\n </Stack>\n )\n }\n\n return (\n <Stack gap=\"1\" className=\"p-2\">\n {issues.map((issue, i) => (\n <IssueRow\n key={`${issue.code}:${issue.path.join('/')}:${i}`}\n issue={issue}\n collection={collection}\n onJumpToField={onJumpToField}\n />\n ))}\n </Stack>\n )\n}\n\nfunction IssueRow({\n issue,\n collection,\n onJumpToField,\n}: {\n issue: CmsValidationIssue\n collection: CollectionDef\n onJumpToField: (fieldPath: string, locale?: string) => void\n}) {\n const isError = issue.severity === 'error'\n const jump = issueJumpTarget(collection, issue)\n // The (localized) name of the offending field: the deepest resolvable def's\n // `form.label`, degrading to the leaf path token / `meta.field` raw name.\n const fieldName =\n jump !== null\n ? jump.fieldDef !== null\n ? fieldLabel(jump.fieldDef)\n : jump.leaf\n : typeof issue.meta?.field === 'string'\n ? issue.meta.field\n : ''\n const body = issueBodyDescriptor(issue, fieldName)\n const Icon = isError ? CircleAlert : TriangleAlert\n\n return (\n <Stack\n gap=\"1\"\n className={cn(\n 'rounded-md border px-2.5 py-2',\n isError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5',\n )}\n >\n <Row gap=\"2\" align=\"start\">\n <Icon\n className={cn('mt-0.5 size-3.5 shrink-0', isError ? 'text-destructive' : 'text-warning')}\n aria-hidden\n />\n <Stack gap=\"1\" className=\"min-w-0\">\n <Text size=\"xs\" weight=\"semibold\" title={issue.code}>\n {cmsIssueLabel(issue.code)}\n </Text>\n <Text size=\"xs\" tone=\"muted\">\n {body !== null ? translate(body.key, body.vars) : issue.message}\n </Text>\n </Stack>\n </Row>\n {jump !== null ? (\n <Row className=\"pl-[1.375rem]\">\n <Button\n size=\"sm\"\n variant=\"link\"\n className=\"h-auto p-0 text-xs\"\n onClick={() => onJumpToField(jump.path, jump.locale)}\n >\n {translate('cms.issues.goToField', { field: fieldName })}\n </Button>\n </Row>\n ) : null}\n </Stack>\n )\n}\n"]}
@@ -3,6 +3,8 @@ import type { FieldDef, ObjectFieldDef } from '@elytracms/core/cms-core';
3
3
  * The nested subfield form for one object item (EC-253). Reuses {@link FieldControl}
4
4
  * recursively, so any subfield type — including a nested object/repeater — works for
5
5
  * free. Writing a subfield merges it back into the item and bubbles the new item up.
6
+ * EC-443: each subfield row stamps its path anchor (`<itemPath>/<sub>`), rings when
7
+ * it is the exact focus target, and provides its path down for deeper nesting.
6
8
  */
7
9
  export declare function ObjectItemForm({ fields, value, onChange, readOnly, idPrefix, }: {
8
10
  fields: readonly FieldDef[];