@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/README.md +131 -5
- package/dist/hypercms.min.js +1204 -220
- package/package.json +5 -3
- package/src/events.js +298 -14
- package/src/form-builder.js +171 -19
- package/src/form-rules.js +23 -9
- package/src/hypercms-bundle.js +5 -2
- package/src/hypercms.js +197 -4
- package/src/refresh.js +2 -1
- package/src/shell.js +93 -18
- package/src/templates.js +431 -25
- package/src/theme/pixel-quiet.overrides.css +512 -0
- package/src/theme.generated.css +1175 -0
- package/src/vendor/mirk.vendor.js +197 -0
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="
|
|
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
|
-
<
|
|
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"
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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,
|
|
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) ||
|
|
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]
|