@panphora/hyper-cms 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/templates.js CHANGED
@@ -10,11 +10,23 @@ export function humanize(key) {
10
10
  .replace(/^./, (c) => c.toUpperCase())
11
11
  }
12
12
 
13
+ // The default templates emit real mirk-interface markup so the generated form
14
+ // renders in the Pixel Quiet look out of the box. Every data-hcms-* hook
15
+ // (shape, label, field, action, the .hcms-array-items / .hcms-card-fields slots,
16
+ // the .hcms-error inline slots) is preserved exactly, so the engine, events,
17
+ // add/remove/reorder, and inline-error placement bind unchanged. The CSS that
18
+ // dresses these lives in src/theme.generated.css (scoped mirk + pixel-quiet).
19
+ const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hidden="true"><div class="mirk-sortable__dots"><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span></div></div>`
20
+
21
+ // Crisp-line × (square caps + crispEdges): a plain pixel-sharp close glyph that
22
+ // stays hard-edged inside the small square corner button on object-array cards.
23
+ const CLOSE_ICON = `<svg class="hcms-x" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"></path></svg>`
24
+
13
25
  const DEFAULT_TEMPLATES = {
14
26
  '@scalar': `
15
27
  <label class="hcms-field" data-hcms-shape="scalar">
16
28
  <span class="hcms-label" data-hcms-label></span>
17
- <input class="hcms-input" data-hcms-field />
29
+ <input class="mirk-input" data-hcms-field />
18
30
  <div class="hcms-error" hidden></div>
19
31
  </label>
20
32
  `,
@@ -32,58 +44,442 @@ const DEFAULT_TEMPLATES = {
32
44
  </header>
33
45
  <ul class="hcms-array-items"></ul>
34
46
  <div class="hcms-error" hidden></div>
35
- <button type="button" class="hcms-add" data-hcms-action="add">+ Add</button>
47
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
36
48
  </section>
37
49
  `,
38
50
  '@scalar-array-item': `
39
51
  <li class="hcms-array-item" draggable="true">
40
- <span class="hcms-drag-handle" aria-hidden="true">::</span>
41
- <input class="hcms-input" data-hcms-field />
52
+ <input class="mirk-input" data-hcms-field />
42
53
  <button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
43
54
  <button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
44
- <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">x</button>
55
+ <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
45
56
  <div class="hcms-error" hidden></div>
46
57
  </li>
47
58
  `,
48
59
  '@object-array': `
49
- <section class="hcms-array hcms-object-array" data-hcms-shape="object-array">
60
+ <section class="hcms-array hcms-object-array hcms-array--cards" data-hcms-shape="object-array">
50
61
  <header class="hcms-array-header">
51
62
  <h3 class="hcms-array-title" data-hcms-label></h3>
52
63
  </header>
53
64
  <div class="hcms-array-items"></div>
54
65
  <div class="hcms-error" hidden></div>
55
- <button type="button" class="hcms-add" data-hcms-action="add">+ Add</button>
66
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
56
67
  </section>
57
68
  `,
58
69
  '@object-array-item': `
59
- <article class="hcms-card" draggable="true">
60
- <header class="hcms-card-header">
61
- <span class="hcms-drag-handle" aria-hidden="true">::</span>
62
- <button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
63
- <button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
64
- <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">x</button>
65
- </header>
66
- <div class="hcms-card-fields"></div>
70
+ <article class="hcms-card mirk-sortable__item" draggable="true">
71
+ ${SORTABLE_GRIP}
72
+ <div class="hcms-card-body mirk-sortable__body">
73
+ <div class="hcms-card-fields"></div>
74
+ <div class="hcms-card-controls">
75
+ <button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
76
+ <button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
77
+ <button type="button" class="hcms-remove hcms-remove--card" data-hcms-action="remove" aria-label="Remove">${CLOSE_ICON}</button>
78
+ </div>
79
+ </div>
67
80
  <div class="hcms-error" hidden></div>
68
81
  </article>
69
82
  `,
83
+ // Opt-in upload components. Built on the kit's consolidated mirk chrome (all
84
+ // themed in theme.generated.css), with interactive hooks kept on data-hcms-*
85
+ // so the vendored mirk runtime (which keys off .mirk-*__input / .mirk-*__remove)
86
+ // never double-handles a CMS field. The bound value is the leaf's URL: the
87
+ // <a href> for @file, the <img src> for @image. Empty/filled chrome is driven
88
+ // by that attribute in CSS (.hcms-upload* in pixel-quiet.overrides.css), no JS.
89
+ '@file': `
90
+ <div class="hcms-field hcms-upload hcms-upload--file" data-hcms-shape="scalar">
91
+ <span class="hcms-label" data-hcms-label></span>
92
+ <div class="mirk-file mirk-file--compact mirk-file--round">
93
+ <label class="mirk-button mirk-button--round mirk-button--small">
94
+ <input type="file" data-hcms-upload />
95
+ <span class="mirk-button__label">Choose</span>
96
+ </label>
97
+ <a class="mirk-file__name" data-hcms-field></a>
98
+ <button type="button" class="hcms-upload-clear" data-hcms-action="clear-upload" aria-label="Remove file">${CLOSE_ICON}</button>
99
+ </div>
100
+ <div class="hcms-error" hidden></div>
101
+ </div>
102
+ `,
103
+ '@image': `
104
+ <div class="hcms-field hcms-upload hcms-upload--image" data-hcms-shape="scalar">
105
+ <span class="hcms-label" data-hcms-label></span>
106
+ <div class="mirk-image mirk-image--compact mirk-image--rounded">
107
+ <label class="mirk-button mirk-button--small mirk-image__upload">
108
+ <input type="file" accept="image/*" data-hcms-upload />
109
+ <span class="mirk-button__label">Upload image</span>
110
+ </label>
111
+ <figure class="mirk-image__thumb">
112
+ <span class="mirk-image__frame"><img class="mirk-image__preview" data-hcms-field alt="" /></span>
113
+ <button type="button" class="hcms-upload-clear hcms-upload-clear--badge" data-hcms-action="clear-upload" aria-label="Remove image">${CLOSE_ICON}</button>
114
+ </figure>
115
+ </div>
116
+ <div class="hcms-error" hidden></div>
117
+ </div>
118
+ `,
119
+ // Named built-in controls, opt-in via data-hcms-component on the page element
120
+ // a rule points at. Promoted from the demo custom templates (mirk chrome,
121
+ // already themed in theme.generated.css), so the easy path needs no template
122
+ // authoring. @checkbox/@toggle bind the same input[type=checkbox]@checked
123
+ // machinery; @select/@radio get their entries from data-hcms-options (the
124
+ // form-builder populates the empty <select> / clones the radio prototype).
125
+ '@checkbox': `
126
+ <div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
127
+ <span class="hcms-label" data-hcms-label></span>
128
+ <label class="mirk-checkbox">
129
+ <input type="checkbox" class="mirk-sr-only" data-hcms-field />
130
+ <span class="mirk-checkbox__box"><span class="mirk-checkbox__mark"></span></span>
131
+ </label>
132
+ <div class="hcms-error" hidden></div>
133
+ </div>
134
+ `,
135
+ '@toggle': `
136
+ <div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
137
+ <span class="hcms-label" data-hcms-label></span>
138
+ <label class="mirk-toggle">
139
+ <input type="checkbox" role="switch" class="mirk-sr-only" data-hcms-field />
140
+ <span class="mirk-toggle__track"><span class="mirk-toggle__thumb"></span></span>
141
+ </label>
142
+ <div class="hcms-error" hidden></div>
143
+ </div>
144
+ `,
145
+ '@select': `
146
+ <label class="hcms-field" data-hcms-shape="scalar">
147
+ <span class="hcms-label" data-hcms-label></span>
148
+ <div class="mirk-select">
149
+ <select class="mirk-select__field" data-hcms-field></select>
150
+ <span aria-hidden="true" class="mirk-select__chevron">›</span>
151
+ </div>
152
+ <div class="hcms-error" hidden></div>
153
+ </label>
154
+ `,
155
+ '@radio': `
156
+ <div class="hcms-field" data-hcms-shape="scalar">
157
+ <span class="hcms-label" data-hcms-label></span>
158
+ <div class="hcms-radio-row">
159
+ <label class="mirk-radio">
160
+ <input type="radio" class="mirk-sr-only" data-hcms-field />
161
+ <span class="mirk-radio__ring"><span class="mirk-radio__fill"></span><span class="mirk-radio__dot"></span></span>
162
+ <span class="mirk-radio__label"></span>
163
+ </label>
164
+ </div>
165
+ <div class="hcms-error" hidden></div>
166
+ </div>
167
+ `,
168
+ '@textarea': `
169
+ <label class="hcms-field" data-hcms-shape="scalar">
170
+ <span class="hcms-label" data-hcms-label></span>
171
+ <textarea class="mirk-textarea" rows="3" data-hcms-field></textarea>
172
+ <div class="hcms-error" hidden></div>
173
+ </label>
174
+ `,
175
+ '@number': `
176
+ <label class="hcms-field" data-hcms-shape="scalar">
177
+ <span class="hcms-label" data-hcms-label></span>
178
+ <input class="mirk-input" type="number" data-hcms-field />
179
+ <div class="hcms-error" hidden></div>
180
+ </label>
181
+ `,
182
+ '@chips': `
183
+ <div class="hcms-field hcms-chips" data-hcms-shape="scalar-array">
184
+ <span class="hcms-label" data-hcms-label></span>
185
+ <div class="mirk-tags hcms-array-items"></div>
186
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
187
+ <div class="hcms-error" hidden></div>
188
+ </div>
189
+ `,
190
+ '@chips-item': `
191
+ <span class="mirk-tags__chip" data-hcms-array-item>
192
+ <input class="hcms-chip-field" data-hcms-field aria-label="Item" placeholder="…" />
193
+ <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
194
+ </span>
195
+ `,
70
196
  }
71
197
 
72
- const DEFAULT_KEYS = Object.keys(DEFAULT_TEMPLATES)
198
+ // Shape templates fill-the-gaps on every managed page. The opt-in components
199
+ // (@file/@image uploads + the named controls) are injected on demand the first
200
+ // time a field selects them (the Slice-2 component seam), so pages using none
201
+ // stay clean.
202
+ const ALWAYS_INJECT_KEYS = [
203
+ '@scalar',
204
+ '@object',
205
+ '@scalar-array',
206
+ '@scalar-array-item',
207
+ '@object-array',
208
+ '@object-array-item',
209
+ ]
73
210
 
74
211
  export function injectDefaults(doc) {
75
212
  const head = doc.head || doc.documentElement
76
213
  if (!head) return
77
- for (const key of DEFAULT_KEYS) {
78
- if (findTemplate(doc, key)) continue
79
- const tpl = doc.createElement('template')
80
- tpl.setAttribute('data-hcms-tpl', key)
81
- tpl.setAttribute('save-remove', '')
82
- tpl.innerHTML = DEFAULT_TEMPLATES[key].trim()
83
- head.appendChild(tpl)
214
+ for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
215
+ }
216
+
217
+ // Inject one opt-in component template if the page hasn't already defined its
218
+ // own. Called by the component-selection seam the first time a field resolves
219
+ // to a component.
220
+ export function injectComponentTemplate(doc, key) {
221
+ if (!DEFAULT_TEMPLATES[key]) return null
222
+ const head = doc && (doc.head || doc.documentElement)
223
+ if (!head) return null
224
+ return injectTemplate(doc, head, key)
225
+ }
226
+
227
+ // Named-component selection. Explicit beats inferred: a data-hcms-component
228
+ // attribute on the page element a rule points at wins over the @prop-suffix
229
+ // inference, so a checked-bound field can still opt into the @toggle look.
230
+ // Inference stays narrow: @src means image upload (the dominant image-CMS
231
+ // workflow) and @checked means checkbox (a text input showing the literal word
232
+ // "true" helps nobody). @href is NOT inferred: editing a link/URL is far more
233
+ // common than uploading a file, so an a@href rule stays a plain URL field and
234
+ // the file upload is opt-in. Suffix is split like the engine (lastIndexOf('@'),
235
+ // see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
236
+ const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox' }
237
+ // data-hcms-component values for scalar rules. "chips" is array-only (below).
238
+ const SCALAR_COMPONENT_BY_NAME = {
239
+ image: '@image',
240
+ file: '@file',
241
+ checkbox: '@checkbox',
242
+ toggle: '@toggle',
243
+ select: '@select',
244
+ radio: '@radio',
245
+ textarea: '@textarea',
246
+ number: '@number',
247
+ }
248
+ // Every opt-in component injects on demand; only the 6 shape templates are
249
+ // always present.
250
+ const ON_DEMAND_COMPONENT_KEYS = new Set([
251
+ ...Object.values(SCALAR_COMPONENT_BY_NAME),
252
+ '@chips',
253
+ '@chips-item',
254
+ ])
255
+
256
+ export function componentForScalarRule(rule, doc, pathArr) {
257
+ if (typeof rule !== 'string') return '@scalar'
258
+ const at = rule.lastIndexOf('@')
259
+ const override = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
260
+ if (override && SCALAR_COMPONENT_BY_NAME[override]) {
261
+ const key = SCALAR_COMPONENT_BY_NAME[override]
262
+ // Value guards check exactly the elements the engine binds: under an array
263
+ // the same rule binds one element per item (every match votes), while a
264
+ // plain scalar binds only the FIRST match — a decorative second element
265
+ // sharing the selector must not veto the control.
266
+ const multi = Array.isArray(pathArr) && pathArr.some((s) => s === '*' || typeof s === 'number')
267
+ if (key === '@number' && !currentScalarValues(rule, at, doc, multi).every(numberInputCanHold)) {
268
+ return '@scalar'
269
+ }
270
+ if (
271
+ (key === '@checkbox' || key === '@toggle') &&
272
+ (at < 0 || rule.slice(at + 1) !== 'checked') &&
273
+ !currentScalarValues(rule, at, doc, multi).every(checkboxCanHold)
274
+ ) {
275
+ return '@scalar'
276
+ }
277
+ return key
278
+ }
279
+ if (at >= 0) {
280
+ const prop = rule.slice(at + 1)
281
+ if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
282
+ }
283
+ return '@scalar'
284
+ }
285
+
286
+ // The data-hcms-component key the page element explicitly declares for this
287
+ // rule, or null (absent / unknown name / value-guard fallback). Lets the
288
+ // builder tell "author asked for this" apart from suffix inference when
289
+ // logging shadowing notices.
290
+ export function declaredComponentKey(rule, doc) {
291
+ if (typeof rule !== 'string') return null
292
+ const at = rule.lastIndexOf('@')
293
+ const name = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
294
+ return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
295
+ }
296
+
297
+ // A number input physically rejects any value that isn't a plain number — it
298
+ // reads back '' — and the whole-form commit would then blank the page value on
299
+ // the next edit of ANY field. When any bound value isn't number-shaped, fall
300
+ // back to the plain text control. This runs inside componentForScalarRule so
301
+ // the builder and deriveFormRules stay in lockstep (both call it).
302
+ const NUMBER_VALUE_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
303
+
304
+ function numberInputCanHold(value) {
305
+ if (value == null || value === '') return true
306
+ return NUMBER_VALUE_RE.test(String(value))
307
+ }
308
+
309
+ // A checkbox/toggle extracts its checked property — 'true' or 'false' — so any
310
+ // other bound value ("yes", "on", "1") would be clobbered to 'false' on the
311
+ // next whole-form commit. @checked projections are exempt in the caller: they
312
+ // round-trip the checked state faithfully by construction.
313
+ function checkboxCanHold(value) {
314
+ return value == null || value === '' || value === 'true' || value === 'false'
315
+ }
316
+
317
+ // Read the page values the rule binds, the way the engine extracts them:
318
+ // trimmed textContent for text projections (adapter.text trims), the live
319
+ // property for @value, the raw attribute otherwise. Mirrors extraction's
320
+ // skips: cms-template seeds and the shell are never extracted, so their
321
+ // values don't get a vote. `multi` mirrors the engine's binding: all matches
322
+ // under an array (one element per item), only the first match otherwise.
323
+ function currentScalarValues(rule, at, doc, multi) {
324
+ if (!doc || !doc.querySelectorAll) return []
325
+ const selector = scalarSelectorOf(rule, at)
326
+ if (!selector || selector === '.') return []
327
+ let els = null
328
+ try {
329
+ els = doc.querySelectorAll(selector)
330
+ } catch {
331
+ return []
332
+ }
333
+ const prop = at >= 0 ? rule.slice(at + 1) : null
334
+ const out = []
335
+ for (const el of els) {
336
+ if (el.closest && el.closest('[cms-template], [data-hcms-shell]')) continue
337
+ if (prop) {
338
+ if (prop === 'value' && 'value' in el) out.push(el.value)
339
+ else out.push(el.getAttribute ? el.getAttribute(prop) : null)
340
+ } else {
341
+ out.push((el.textContent || '').trim())
342
+ }
343
+ if (!multi) break
344
+ }
345
+ return out
346
+ }
347
+
348
+ // Array-level component selection: data-hcms-component="chips" on the list
349
+ // container (or any ancestor of the items) swaps the scalar-array chrome for
350
+ // the chip list. Resolved through the first item the rule's selector matches,
351
+ // then closest(), so the natural authoring spot — the <ul> — works even though
352
+ // the selector targets the items. Growable-from-zero lists carry a cms-template
353
+ // item, so the item selector always has something to match.
354
+ export function componentForScalarArrayRule(rule, doc) {
355
+ if (typeof rule !== 'string' || !rule.endsWith('[]')) return null
356
+ if (!doc || !doc.querySelector) return null
357
+ const selector = rule.slice(0, -2).trim()
358
+ if (!selector) return null
359
+ let item = null
360
+ try {
361
+ item = doc.querySelector(selector)
362
+ } catch {
363
+ return null
364
+ }
365
+ const host = item && item.closest ? item.closest('[data-hcms-component]') : null
366
+ const name = host && host.getAttribute ? host.getAttribute('data-hcms-component') : null
367
+ if (name === 'chips') return { array: '@chips', item: '@chips-item' }
368
+ return null
369
+ }
370
+
371
+ // Template resolution shared by the builder and deriveFormRules: a custom
372
+ // template at the exact path wins, then a wildcard-path one, then the default.
373
+ export function resolveTemplate(pathArr, defaultKey, doc) {
374
+ const pathStr = pathArr.join('.')
375
+ const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
376
+ return (
377
+ (pathStr && findTemplate(doc, pathStr)) ||
378
+ (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
379
+ findTemplate(doc, defaultKey)
380
+ )
381
+ }
382
+
383
+ // The declared array component, but only when its array template actually
384
+ // wins at this path — a custom path/wildcard template that shadows it returns
385
+ // null. The form-builder (item chrome) and deriveFormRules (item leaf
386
+ // selector) MUST share this verdict: if they diverge, the derived selector
387
+ // stops matching the rendered item leaf, every item extracts null, and the
388
+ // whole-form commit writes those nulls back over real page data.
389
+ export function winningScalarArrayComponent(rule, pathArr, doc) {
390
+ const comp = componentForScalarArrayRule(rule, doc)
391
+ if (!comp) return null
392
+ const winner = resolveTemplate(pathArr, comp.array, doc)
393
+ if (winner && winner.getAttribute('data-hcms-tpl') === comp.array) return comp
394
+ return null
395
+ }
396
+
397
+ // Option list for @select/@radio: data-hcms-options="low medium high" on the
398
+ // same page element as data-hcms-component. Space-separated value tokens (the
399
+ // data-rules-name idiom); labels derive via humanize() like every other label.
400
+ export function readOptionsOverride(rule, doc) {
401
+ if (typeof rule !== 'string') return null
402
+ const at = rule.lastIndexOf('@')
403
+ const raw = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-options')
404
+ if (raw == null) return null
405
+ const tokens = raw.trim().split(/\s+/).filter(Boolean)
406
+ return tokens.length ? tokens : null
407
+ }
408
+
409
+ // Crop opt-in for @image: data-hcms-crop="1:1" | "16:9" | "free" on the page
410
+ // element. The form-builder copies it onto the built field so the upload path
411
+ // reads it without a second page lookup.
412
+ export function readCropOverride(rule, doc) {
413
+ if (typeof rule !== 'string') return null
414
+ const at = rule.lastIndexOf('@')
415
+ return readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-crop')
416
+ }
417
+
418
+ function scalarSelectorOf(rule, at) {
419
+ return at >= 0 ? rule.slice(0, at) : rule
420
+ }
421
+
422
+ // The only branch that reads the page DOM during selection.
423
+ function readElementAttr(selector, doc, attrName) {
424
+ if (!doc || !doc.querySelector) return null
425
+ if (!selector || selector === '.') return null
426
+ let el = null
427
+ try {
428
+ el = doc.querySelector(selector)
429
+ } catch {
430
+ return null
431
+ }
432
+ return el && el.getAttribute ? el.getAttribute(attrName) : null
433
+ }
434
+
435
+ // Pre-inject the component templates this page's rules actually select, so
436
+ // both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
437
+ // same rule shapes as buildTemplateMap; idempotent and author-template-safe.
438
+ export function injectComponents(doc, pageRules) {
439
+ if (!doc || pageRules == null) return
440
+ walk(pageRules)
441
+
442
+ function walk(rule) {
443
+ const kind = shapeKindOf(rule)
444
+ if (kind === 'scalar') {
445
+ const key = componentForScalarRule(rule, doc)
446
+ if (ON_DEMAND_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
447
+ return
448
+ }
449
+ if (kind === 'scalar-array') {
450
+ const comp = componentForScalarArrayRule(rule, doc)
451
+ if (comp) {
452
+ injectComponentTemplate(doc, comp.array)
453
+ injectComponentTemplate(doc, comp.item)
454
+ }
455
+ return
456
+ }
457
+ if (kind === 'object') {
458
+ for (const child of Object.values(rule)) walk(child)
459
+ return
460
+ }
461
+ if (kind === 'object-array') {
462
+ const itemShape = rule[1]
463
+ if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
464
+ for (const child of Object.values(itemShape)) walk(child)
465
+ } else {
466
+ walk(itemShape)
467
+ }
468
+ }
84
469
  }
85
470
  }
86
471
 
472
+ function injectTemplate(doc, head, key) {
473
+ const existing = findTemplate(doc, key)
474
+ if (existing) return existing
475
+ const tpl = doc.createElement('template')
476
+ tpl.setAttribute('data-hcms-tpl', key)
477
+ tpl.setAttribute('save-remove', '')
478
+ tpl.innerHTML = DEFAULT_TEMPLATES[key].trim()
479
+ head.appendChild(tpl)
480
+ return tpl
481
+ }
482
+
87
483
  export function findTemplate(doc, key) {
88
484
  if (!doc || !doc.querySelector) return null
89
485
  return doc.querySelector(`template[data-hcms-tpl="${cssEscape(key)}"]`)
@@ -136,11 +532,19 @@ export function buildTemplateMap(pageRules, doc) {
136
532
  const pathStr = pathArr.join('.')
137
533
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
138
534
  const shape = shapeKindOf(rule)
535
+ const arrayComp = shape === 'scalar-array' ? componentForScalarArrayRule(rule, doc) : null
536
+ // Items follow the WINNING component (a shadowed chips array renders
537
+ // default items), matching buildScalarArray.
538
+ const winningComp = shape === 'scalar-array' ? winningScalarArrayComponent(rule, pathArr, doc) : null
539
+ const defaultKey =
540
+ shape === 'scalar'
541
+ ? componentForScalarRule(rule, doc, pathArr)
542
+ : (arrayComp && arrayComp.array) || SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
139
543
 
140
544
  let tpl =
141
545
  (pathStr && findTemplate(doc, pathStr)) ||
142
546
  (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
143
- findTemplate(doc, SHAPE_TO_DEFAULT_KEY[shape] || '@scalar')
547
+ findTemplate(doc, defaultKey)
144
548
 
145
549
  map.set(pathStr, tpl)
146
550
 
@@ -151,7 +555,9 @@ export function buildTemplateMap(pageRules, doc) {
151
555
  const itemKey = itemPath.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
152
556
  const itemShape = shape === 'object-array' ? 'object-array-item' : 'scalar-array-item'
153
557
  const itemTpl =
154
- findTemplate(doc, itemKey) || findTemplate(doc, '@' + itemShape)
558
+ findTemplate(doc, itemKey) ||
559
+ (winningComp && findTemplate(doc, winningComp.item)) ||
560
+ findTemplate(doc, '@' + itemShape)
155
561
  map.set(itemKey, itemTpl)
156
562
  if (shape === 'object-array') {
157
563
  const itemRule = rule[1]