@panphora/hyper-cms 0.5.0 → 0.6.1
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 +124 -6
- package/dist/hypercms.min.js +607 -50
- package/package.json +1 -1
- package/src/enhance.js +97 -0
- package/src/events.js +89 -9
- package/src/form-builder.js +143 -20
- package/src/form-rules.js +20 -6
- package/src/hypercms.js +31 -4
- package/src/refresh.js +10 -3
- package/src/shell.js +13 -2
- package/src/templates.js +310 -30
- package/src/theme/pixel-quiet.overrides.css +35 -1
- package/src/theme.generated.css +509 -35
- package/src/toggle.js +88 -0
- package/src/vendor/mirk.vendor.js +17 -2
package/src/refresh.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { engine } from 'hyper-html-api'
|
|
2
2
|
import { buildForm } from './form-builder.js'
|
|
3
3
|
import { morphForm } from './morph.js'
|
|
4
|
-
import { injectDefaults,
|
|
4
|
+
import { injectDefaults, injectComponents } from './templates.js'
|
|
5
5
|
import { deriveFormRules } from './form-rules.js'
|
|
6
6
|
import { coerceBooleans, applyErrorState } from './events.js'
|
|
7
|
+
import { enhanceFields, upgradeRichTextRules } from './enhance.js'
|
|
7
8
|
|
|
8
9
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
9
10
|
|
|
@@ -13,12 +14,15 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
13
14
|
// livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
|
|
14
15
|
const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
|
|
15
16
|
if (found) {
|
|
16
|
-
|
|
17
|
+
// Re-apply the rich-text upgrade open() performed: the tag holds the
|
|
18
|
+
// author's bare rules, and using them raw would rebind upgraded fields
|
|
19
|
+
// back to textContent mid-session.
|
|
20
|
+
ctx.pageRules = ctx.richText ? upgradeRichTextRules(found.rules, ctx.pageRoot) : found.rules
|
|
17
21
|
ctx.rulesTagNode = found.tagNode
|
|
18
22
|
}
|
|
19
23
|
// Re-derive formRules so schema changes (rule shape, template overrides) flow through.
|
|
20
24
|
injectDefaults(ctx.doc)
|
|
21
|
-
|
|
25
|
+
injectComponents(ctx.doc, ctx.pageRules)
|
|
22
26
|
ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
|
|
23
27
|
|
|
24
28
|
const newData = coerceBooleans(
|
|
@@ -32,6 +36,9 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
32
36
|
doc: ctx.doc,
|
|
33
37
|
})
|
|
34
38
|
morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
|
|
39
|
+
// The morph can rebuild field nodes, dropping autosize heights and richclay
|
|
40
|
+
// instances (instances are per-element); re-enhance whatever is new.
|
|
41
|
+
enhanceFields(ctx.formRoot, ctx.doc)
|
|
35
42
|
// morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
|
|
36
43
|
// the last error state so error messages survive across observer-driven refreshes
|
|
37
44
|
// (e.g., the rollback after a failed add).
|
package/src/shell.js
CHANGED
|
@@ -155,6 +155,10 @@ function ensureStyles(doc) {
|
|
|
155
155
|
const style = doc.createElement('style')
|
|
156
156
|
style.id = SHELL_STYLE_ID
|
|
157
157
|
style.setAttribute('save-remove', '')
|
|
158
|
+
// save-ignore mirrors the shell root so a full-document morph (e.g. a live-sync
|
|
159
|
+
// head-merge) preserves the stylesheet instead of stripping it; without it the
|
|
160
|
+
// style survives only via restoreChrome's reactive re-injection.
|
|
161
|
+
style.setAttribute('save-ignore', '')
|
|
158
162
|
style.textContent = cssText
|
|
159
163
|
;(doc.head || doc.documentElement).appendChild(style)
|
|
160
164
|
styledDocs.add(doc)
|
|
@@ -170,12 +174,19 @@ function ensureStyles(doc) {
|
|
|
170
174
|
link.rel = 'stylesheet'
|
|
171
175
|
link.id = SHELL_STYLE_ID
|
|
172
176
|
link.setAttribute('save-remove', '')
|
|
177
|
+
link.setAttribute('save-ignore', '')
|
|
173
178
|
link.href = href
|
|
174
179
|
;(doc.head || doc.documentElement).appendChild(link)
|
|
175
180
|
styledDocs.add(doc)
|
|
176
181
|
} catch (_) {
|
|
177
|
-
//
|
|
178
|
-
//
|
|
182
|
+
// No bundled CSS and no resolvable co-located theme (e.g. the IIFE bundle,
|
|
183
|
+
// where import.meta is undefined). Warn loudly rather than mount a silently
|
|
184
|
+
// unstyled shell — the host must call installStyles(themeText) before opening.
|
|
185
|
+
console.warn(
|
|
186
|
+
'hypercms: shell stylesheet not applied — cssText is empty and the ' +
|
|
187
|
+
'co-located theme fallback is unavailable. Call installStyles(themeText) ' +
|
|
188
|
+
'before opening the CMS.'
|
|
189
|
+
)
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|
package/src/templates.js
CHANGED
|
@@ -23,10 +23,13 @@ const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hi
|
|
|
23
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
24
|
|
|
25
25
|
const DEFAULT_TEMPLATES = {
|
|
26
|
+
// The default scalar is a one-row auto-growing textarea, not an <input>:
|
|
27
|
+
// page copy is prose and routinely wraps. Growth comes from field-sizing:
|
|
28
|
+
// content in the theme, with a scrollHeight fallback (enhance.js).
|
|
26
29
|
'@scalar': `
|
|
27
30
|
<label class="hcms-field" data-hcms-shape="scalar">
|
|
28
31
|
<span class="hcms-label" data-hcms-label></span>
|
|
29
|
-
<
|
|
32
|
+
<textarea class="mirk-textarea" rows="1" data-hcms-field></textarea>
|
|
30
33
|
<div class="hcms-error" hidden></div>
|
|
31
34
|
</label>
|
|
32
35
|
`,
|
|
@@ -116,11 +119,100 @@ const DEFAULT_TEMPLATES = {
|
|
|
116
119
|
<div class="hcms-error" hidden></div>
|
|
117
120
|
</div>
|
|
118
121
|
`,
|
|
122
|
+
// Named built-in controls, opt-in via data-hcms-component on the page element
|
|
123
|
+
// a rule points at. Promoted from the demo custom templates (mirk chrome,
|
|
124
|
+
// already themed in theme.generated.css), so the easy path needs no template
|
|
125
|
+
// authoring. @checkbox/@toggle bind the same input[type=checkbox]@checked
|
|
126
|
+
// machinery; @select/@radio get their entries from data-hcms-options (the
|
|
127
|
+
// form-builder populates the empty <select> / clones the radio prototype).
|
|
128
|
+
'@checkbox': `
|
|
129
|
+
<div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
|
|
130
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
131
|
+
<label class="mirk-checkbox">
|
|
132
|
+
<input type="checkbox" class="mirk-sr-only" data-hcms-field />
|
|
133
|
+
<span class="mirk-checkbox__box"><span class="mirk-checkbox__mark"></span></span>
|
|
134
|
+
</label>
|
|
135
|
+
<div class="hcms-error" hidden></div>
|
|
136
|
+
</div>
|
|
137
|
+
`,
|
|
138
|
+
'@toggle': `
|
|
139
|
+
<div class="hcms-field hcms-field--row" data-hcms-shape="scalar">
|
|
140
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
141
|
+
<label class="mirk-toggle">
|
|
142
|
+
<input type="checkbox" role="switch" class="mirk-sr-only" data-hcms-field />
|
|
143
|
+
<span class="mirk-toggle__track"><span class="mirk-toggle__thumb"></span></span>
|
|
144
|
+
</label>
|
|
145
|
+
<div class="hcms-error" hidden></div>
|
|
146
|
+
</div>
|
|
147
|
+
`,
|
|
148
|
+
'@select': `
|
|
149
|
+
<label class="hcms-field" data-hcms-shape="scalar">
|
|
150
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
151
|
+
<div class="mirk-select">
|
|
152
|
+
<select class="mirk-select__field" data-hcms-field></select>
|
|
153
|
+
<span aria-hidden="true" class="mirk-select__chevron">›</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="hcms-error" hidden></div>
|
|
156
|
+
</label>
|
|
157
|
+
`,
|
|
158
|
+
'@radio': `
|
|
159
|
+
<div class="hcms-field" data-hcms-shape="scalar">
|
|
160
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
161
|
+
<div class="hcms-radio-row">
|
|
162
|
+
<label class="mirk-radio">
|
|
163
|
+
<input type="radio" class="mirk-sr-only" data-hcms-field />
|
|
164
|
+
<span class="mirk-radio__ring"><span class="mirk-radio__fill"></span><span class="mirk-radio__dot"></span></span>
|
|
165
|
+
<span class="mirk-radio__label"></span>
|
|
166
|
+
</label>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="hcms-error" hidden></div>
|
|
169
|
+
</div>
|
|
170
|
+
`,
|
|
171
|
+
'@textarea': `
|
|
172
|
+
<label class="hcms-field" data-hcms-shape="scalar">
|
|
173
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
174
|
+
<textarea class="mirk-textarea" rows="3" data-hcms-field></textarea>
|
|
175
|
+
<div class="hcms-error" hidden></div>
|
|
176
|
+
</label>
|
|
177
|
+
`,
|
|
178
|
+
// Rich-text surface for @innerHTML-bound rules: a contenteditable div whose
|
|
179
|
+
// value interface is innerHTML (fieldPropertyFor), so links and inline
|
|
180
|
+
// formatting survive the round-trip. richclay mounts on it when the host
|
|
181
|
+
// page ships it (enhance.js); otherwise it stays a plain contenteditable.
|
|
182
|
+
'@richtext': `
|
|
183
|
+
<div class="hcms-field" data-hcms-shape="scalar">
|
|
184
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
185
|
+
<div class="mirk-textarea hcms-richtext" contenteditable="true" data-hcms-field></div>
|
|
186
|
+
<div class="hcms-error" hidden></div>
|
|
187
|
+
</div>
|
|
188
|
+
`,
|
|
189
|
+
'@number': `
|
|
190
|
+
<label class="hcms-field" data-hcms-shape="scalar">
|
|
191
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
192
|
+
<input class="mirk-input" type="number" data-hcms-field />
|
|
193
|
+
<div class="hcms-error" hidden></div>
|
|
194
|
+
</label>
|
|
195
|
+
`,
|
|
196
|
+
'@chips': `
|
|
197
|
+
<div class="hcms-field hcms-chips" data-hcms-shape="scalar-array">
|
|
198
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
199
|
+
<div class="mirk-tags hcms-array-items"></div>
|
|
200
|
+
<button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
|
|
201
|
+
<div class="hcms-error" hidden></div>
|
|
202
|
+
</div>
|
|
203
|
+
`,
|
|
204
|
+
'@chips-item': `
|
|
205
|
+
<span class="mirk-tags__chip" data-hcms-array-item>
|
|
206
|
+
<input class="hcms-chip-field" data-hcms-field aria-label="Item" placeholder="…" />
|
|
207
|
+
<button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
|
|
208
|
+
</span>
|
|
209
|
+
`,
|
|
119
210
|
}
|
|
120
211
|
|
|
121
|
-
// Shape templates fill-the-gaps on every managed page. The opt-in
|
|
122
|
-
//
|
|
123
|
-
// selects them (the Slice-2 component seam), so pages using
|
|
212
|
+
// Shape templates fill-the-gaps on every managed page. The opt-in components
|
|
213
|
+
// (@file/@image uploads + the named controls) are injected on demand the first
|
|
214
|
+
// time a field selects them (the Slice-2 component seam), so pages using none
|
|
215
|
+
// stay clean.
|
|
124
216
|
const ALWAYS_INJECT_KEYS = [
|
|
125
217
|
'@scalar',
|
|
126
218
|
'@object',
|
|
@@ -136,9 +228,9 @@ export function injectDefaults(doc) {
|
|
|
136
228
|
for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
|
|
137
229
|
}
|
|
138
230
|
|
|
139
|
-
// Inject one opt-in component template
|
|
140
|
-
//
|
|
141
|
-
//
|
|
231
|
+
// Inject one opt-in component template if the page hasn't already defined its
|
|
232
|
+
// own. Called by the component-selection seam the first time a field resolves
|
|
233
|
+
// to a component.
|
|
142
234
|
export function injectComponentTemplate(doc, key) {
|
|
143
235
|
if (!DEFAULT_TEMPLATES[key]) return null
|
|
144
236
|
const head = doc && (doc.head || doc.documentElement)
|
|
@@ -146,33 +238,205 @@ export function injectComponentTemplate(doc, key) {
|
|
|
146
238
|
return injectTemplate(doc, head, key)
|
|
147
239
|
}
|
|
148
240
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
241
|
+
// Named-component selection. Explicit beats inferred: a data-hcms-component
|
|
242
|
+
// attribute on the page element a rule points at wins over the @prop-suffix
|
|
243
|
+
// inference, so a checked-bound field can still opt into the @toggle look.
|
|
244
|
+
// Inference stays narrow: @src means image upload (the dominant image-CMS
|
|
245
|
+
// workflow) and @checked means checkbox (a text input showing the literal word
|
|
246
|
+
// "true" helps nobody). @href is NOT inferred: editing a link/URL is far more
|
|
247
|
+
// common than uploading a file, so an a@href rule stays a plain URL field and
|
|
248
|
+
// the file upload is opt-in. Suffix is split like the engine (lastIndexOf('@'),
|
|
249
|
+
// see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
|
|
250
|
+
const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox', innerHTML: '@richtext' }
|
|
251
|
+
// data-hcms-component values for scalar rules. "chips" is array-only (below).
|
|
252
|
+
const SCALAR_COMPONENT_BY_NAME = {
|
|
253
|
+
image: '@image',
|
|
254
|
+
file: '@file',
|
|
255
|
+
checkbox: '@checkbox',
|
|
256
|
+
toggle: '@toggle',
|
|
257
|
+
select: '@select',
|
|
258
|
+
radio: '@radio',
|
|
259
|
+
textarea: '@textarea',
|
|
260
|
+
number: '@number',
|
|
261
|
+
richtext: '@richtext',
|
|
262
|
+
}
|
|
263
|
+
// Every opt-in component injects on demand; only the 6 shape templates are
|
|
264
|
+
// always present.
|
|
265
|
+
const ON_DEMAND_COMPONENT_KEYS = new Set([
|
|
266
|
+
...Object.values(SCALAR_COMPONENT_BY_NAME),
|
|
267
|
+
'@chips',
|
|
268
|
+
'@chips-item',
|
|
269
|
+
])
|
|
270
|
+
|
|
271
|
+
export function componentForScalarRule(rule, doc, pathArr) {
|
|
161
272
|
if (typeof rule !== 'string') return '@scalar'
|
|
162
273
|
const at = rule.lastIndexOf('@')
|
|
274
|
+
const override = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
|
|
275
|
+
if (override && SCALAR_COMPONENT_BY_NAME[override]) {
|
|
276
|
+
const key = SCALAR_COMPONENT_BY_NAME[override]
|
|
277
|
+
// Value guards check exactly the elements the engine binds: under an array
|
|
278
|
+
// the same rule binds one element per item (every match votes), while a
|
|
279
|
+
// plain scalar binds only the FIRST match — a decorative second element
|
|
280
|
+
// sharing the selector must not veto the control.
|
|
281
|
+
const multi = Array.isArray(pathArr) && pathArr.some((s) => s === '*' || typeof s === 'number')
|
|
282
|
+
if (key === '@number' && !currentScalarValues(rule, at, doc, multi).every(numberInputCanHold)) {
|
|
283
|
+
return '@scalar'
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
(key === '@checkbox' || key === '@toggle') &&
|
|
287
|
+
(at < 0 || rule.slice(at + 1) !== 'checked') &&
|
|
288
|
+
!currentScalarValues(rule, at, doc, multi).every(checkboxCanHold)
|
|
289
|
+
) {
|
|
290
|
+
return '@scalar'
|
|
291
|
+
}
|
|
292
|
+
return key
|
|
293
|
+
}
|
|
163
294
|
if (at >= 0) {
|
|
164
295
|
const prop = rule.slice(at + 1)
|
|
165
296
|
if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
|
|
166
297
|
}
|
|
167
|
-
const override = readComponentOverride(rule, at, doc)
|
|
168
|
-
if (override === 'image') return '@image'
|
|
169
|
-
if (override === 'file') return '@file'
|
|
170
298
|
return '@scalar'
|
|
171
299
|
}
|
|
172
300
|
|
|
173
|
-
|
|
301
|
+
// The data-hcms-component key the page element explicitly declares for this
|
|
302
|
+
// rule, or null (absent / unknown name / value-guard fallback). Lets the
|
|
303
|
+
// builder tell "author asked for this" apart from suffix inference when
|
|
304
|
+
// logging shadowing notices.
|
|
305
|
+
export function declaredComponentKey(rule, doc) {
|
|
306
|
+
if (typeof rule !== 'string') return null
|
|
307
|
+
const at = rule.lastIndexOf('@')
|
|
308
|
+
const name = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
|
|
309
|
+
return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A number input physically rejects any value that isn't a plain number — it
|
|
313
|
+
// reads back '' — and the whole-form commit would then blank the page value on
|
|
314
|
+
// the next edit of ANY field. When any bound value isn't number-shaped, fall
|
|
315
|
+
// back to the plain text control. This runs inside componentForScalarRule so
|
|
316
|
+
// the builder and deriveFormRules stay in lockstep (both call it).
|
|
317
|
+
const NUMBER_VALUE_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
|
|
318
|
+
|
|
319
|
+
function numberInputCanHold(value) {
|
|
320
|
+
if (value == null || value === '') return true
|
|
321
|
+
return NUMBER_VALUE_RE.test(String(value))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A checkbox/toggle extracts its checked property — 'true' or 'false' — so any
|
|
325
|
+
// other bound value ("yes", "on", "1") would be clobbered to 'false' on the
|
|
326
|
+
// next whole-form commit. @checked projections are exempt in the caller: they
|
|
327
|
+
// round-trip the checked state faithfully by construction.
|
|
328
|
+
function checkboxCanHold(value) {
|
|
329
|
+
return value == null || value === '' || value === 'true' || value === 'false'
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Read the page values the rule binds, the way the engine extracts them:
|
|
333
|
+
// trimmed textContent for text projections (adapter.text trims), the live
|
|
334
|
+
// property for @value, the raw attribute otherwise. Mirrors extraction's
|
|
335
|
+
// skips: cms-template seeds and the shell are never extracted, so their
|
|
336
|
+
// values don't get a vote. `multi` mirrors the engine's binding: all matches
|
|
337
|
+
// under an array (one element per item), only the first match otherwise.
|
|
338
|
+
function currentScalarValues(rule, at, doc, multi) {
|
|
339
|
+
if (!doc || !doc.querySelectorAll) return []
|
|
340
|
+
const selector = scalarSelectorOf(rule, at)
|
|
341
|
+
if (!selector || selector === '.') return []
|
|
342
|
+
let els = null
|
|
343
|
+
try {
|
|
344
|
+
els = doc.querySelectorAll(selector)
|
|
345
|
+
} catch {
|
|
346
|
+
return []
|
|
347
|
+
}
|
|
348
|
+
const prop = at >= 0 ? rule.slice(at + 1) : null
|
|
349
|
+
const out = []
|
|
350
|
+
for (const el of els) {
|
|
351
|
+
if (el.closest && el.closest('[cms-template], [data-hcms-shell]')) continue
|
|
352
|
+
if (prop) {
|
|
353
|
+
if (prop === 'value' && 'value' in el) out.push(el.value)
|
|
354
|
+
else out.push(el.getAttribute ? el.getAttribute(prop) : null)
|
|
355
|
+
} else {
|
|
356
|
+
out.push((el.textContent || '').trim())
|
|
357
|
+
}
|
|
358
|
+
if (!multi) break
|
|
359
|
+
}
|
|
360
|
+
return out
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Array-level component selection: data-hcms-component="chips" on the list
|
|
364
|
+
// container (or any ancestor of the items) swaps the scalar-array chrome for
|
|
365
|
+
// the chip list. Resolved through the first item the rule's selector matches,
|
|
366
|
+
// then closest(), so the natural authoring spot — the <ul> — works even though
|
|
367
|
+
// the selector targets the items. Growable-from-zero lists carry a cms-template
|
|
368
|
+
// item, so the item selector always has something to match.
|
|
369
|
+
export function componentForScalarArrayRule(rule, doc) {
|
|
370
|
+
if (typeof rule !== 'string' || !rule.endsWith('[]')) return null
|
|
371
|
+
if (!doc || !doc.querySelector) return null
|
|
372
|
+
const selector = rule.slice(0, -2).trim()
|
|
373
|
+
if (!selector) return null
|
|
374
|
+
let item = null
|
|
375
|
+
try {
|
|
376
|
+
item = doc.querySelector(selector)
|
|
377
|
+
} catch {
|
|
378
|
+
return null
|
|
379
|
+
}
|
|
380
|
+
const host = item && item.closest ? item.closest('[data-hcms-component]') : null
|
|
381
|
+
const name = host && host.getAttribute ? host.getAttribute('data-hcms-component') : null
|
|
382
|
+
if (name === 'chips') return { array: '@chips', item: '@chips-item' }
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Template resolution shared by the builder and deriveFormRules: a custom
|
|
387
|
+
// template at the exact path wins, then a wildcard-path one, then the default.
|
|
388
|
+
export function resolveTemplate(pathArr, defaultKey, doc) {
|
|
389
|
+
const pathStr = pathArr.join('.')
|
|
390
|
+
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
391
|
+
return (
|
|
392
|
+
(pathStr && findTemplate(doc, pathStr)) ||
|
|
393
|
+
(wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
|
|
394
|
+
findTemplate(doc, defaultKey)
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The declared array component, but only when its array template actually
|
|
399
|
+
// wins at this path — a custom path/wildcard template that shadows it returns
|
|
400
|
+
// null. The form-builder (item chrome) and deriveFormRules (item leaf
|
|
401
|
+
// selector) MUST share this verdict: if they diverge, the derived selector
|
|
402
|
+
// stops matching the rendered item leaf, every item extracts null, and the
|
|
403
|
+
// whole-form commit writes those nulls back over real page data.
|
|
404
|
+
export function winningScalarArrayComponent(rule, pathArr, doc) {
|
|
405
|
+
const comp = componentForScalarArrayRule(rule, doc)
|
|
406
|
+
if (!comp) return null
|
|
407
|
+
const winner = resolveTemplate(pathArr, comp.array, doc)
|
|
408
|
+
if (winner && winner.getAttribute('data-hcms-tpl') === comp.array) return comp
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Option list for @select/@radio: data-hcms-options="low medium high" on the
|
|
413
|
+
// same page element as data-hcms-component. Space-separated value tokens (the
|
|
414
|
+
// data-rules-name idiom); labels derive via humanize() like every other label.
|
|
415
|
+
export function readOptionsOverride(rule, doc) {
|
|
416
|
+
if (typeof rule !== 'string') return null
|
|
417
|
+
const at = rule.lastIndexOf('@')
|
|
418
|
+
const raw = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-options')
|
|
419
|
+
if (raw == null) return null
|
|
420
|
+
const tokens = raw.trim().split(/\s+/).filter(Boolean)
|
|
421
|
+
return tokens.length ? tokens : null
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Crop opt-in for @image: data-hcms-crop="1:1" | "16:9" | "free" on the page
|
|
425
|
+
// element. The form-builder copies it onto the built field so the upload path
|
|
426
|
+
// reads it without a second page lookup.
|
|
427
|
+
export function readCropOverride(rule, doc) {
|
|
428
|
+
if (typeof rule !== 'string') return null
|
|
429
|
+
const at = rule.lastIndexOf('@')
|
|
430
|
+
return readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-crop')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function scalarSelectorOf(rule, at) {
|
|
434
|
+
return at >= 0 ? rule.slice(0, at) : rule
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The only branch that reads the page DOM during selection.
|
|
438
|
+
function readElementAttr(selector, doc, attrName) {
|
|
174
439
|
if (!doc || !doc.querySelector) return null
|
|
175
|
-
const selector = at >= 0 ? rule.slice(0, at) : rule
|
|
176
440
|
if (!selector || selector === '.') return null
|
|
177
441
|
let el = null
|
|
178
442
|
try {
|
|
@@ -180,13 +444,13 @@ function readComponentOverride(rule, at, doc) {
|
|
|
180
444
|
} catch {
|
|
181
445
|
return null
|
|
182
446
|
}
|
|
183
|
-
return el && el.getAttribute ? el.getAttribute(
|
|
447
|
+
return el && el.getAttribute ? el.getAttribute(attrName) : null
|
|
184
448
|
}
|
|
185
449
|
|
|
186
|
-
// Pre-inject the
|
|
450
|
+
// Pre-inject the component templates this page's rules actually select, so
|
|
187
451
|
// both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
|
|
188
452
|
// same rule shapes as buildTemplateMap; idempotent and author-template-safe.
|
|
189
|
-
export function
|
|
453
|
+
export function injectComponents(doc, pageRules) {
|
|
190
454
|
if (!doc || pageRules == null) return
|
|
191
455
|
walk(pageRules)
|
|
192
456
|
|
|
@@ -194,7 +458,15 @@ export function injectUploadComponents(doc, pageRules) {
|
|
|
194
458
|
const kind = shapeKindOf(rule)
|
|
195
459
|
if (kind === 'scalar') {
|
|
196
460
|
const key = componentForScalarRule(rule, doc)
|
|
197
|
-
if (
|
|
461
|
+
if (ON_DEMAND_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
if (kind === 'scalar-array') {
|
|
465
|
+
const comp = componentForScalarArrayRule(rule, doc)
|
|
466
|
+
if (comp) {
|
|
467
|
+
injectComponentTemplate(doc, comp.array)
|
|
468
|
+
injectComponentTemplate(doc, comp.item)
|
|
469
|
+
}
|
|
198
470
|
return
|
|
199
471
|
}
|
|
200
472
|
if (kind === 'object') {
|
|
@@ -275,8 +547,14 @@ export function buildTemplateMap(pageRules, doc) {
|
|
|
275
547
|
const pathStr = pathArr.join('.')
|
|
276
548
|
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
277
549
|
const shape = shapeKindOf(rule)
|
|
550
|
+
const arrayComp = shape === 'scalar-array' ? componentForScalarArrayRule(rule, doc) : null
|
|
551
|
+
// Items follow the WINNING component (a shadowed chips array renders
|
|
552
|
+
// default items), matching buildScalarArray.
|
|
553
|
+
const winningComp = shape === 'scalar-array' ? winningScalarArrayComponent(rule, pathArr, doc) : null
|
|
278
554
|
const defaultKey =
|
|
279
|
-
shape === 'scalar'
|
|
555
|
+
shape === 'scalar'
|
|
556
|
+
? componentForScalarRule(rule, doc, pathArr)
|
|
557
|
+
: (arrayComp && arrayComp.array) || SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
|
|
280
558
|
|
|
281
559
|
let tpl =
|
|
282
560
|
(pathStr && findTemplate(doc, pathStr)) ||
|
|
@@ -292,7 +570,9 @@ export function buildTemplateMap(pageRules, doc) {
|
|
|
292
570
|
const itemKey = itemPath.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
293
571
|
const itemShape = shape === 'object-array' ? 'object-array-item' : 'scalar-array-item'
|
|
294
572
|
const itemTpl =
|
|
295
|
-
findTemplate(doc, itemKey) ||
|
|
573
|
+
findTemplate(doc, itemKey) ||
|
|
574
|
+
(winningComp && findTemplate(doc, winningComp.item)) ||
|
|
575
|
+
findTemplate(doc, '@' + itemShape)
|
|
296
576
|
map.set(itemKey, itemTpl)
|
|
297
577
|
if (shape === 'object-array') {
|
|
298
578
|
const itemRule = rule[1]
|
|
@@ -227,7 +227,7 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
227
227
|
display: flex;
|
|
228
228
|
flex-direction: column;
|
|
229
229
|
gap: 26px;
|
|
230
|
-
padding:
|
|
230
|
+
padding: 12px 24px 28px;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
/* one labeled scalar field */
|
|
@@ -253,6 +253,31 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
253
253
|
padding: 9px 14px 10px;
|
|
254
254
|
}
|
|
255
255
|
.hcms-shell.pixel-quiet textarea.mirk-input { min-height: 76px; resize: vertical; }
|
|
256
|
+
|
|
257
|
+
/* default scalar fields: one-row textareas that grow with their content.
|
|
258
|
+
Browsers without field-sizing get a scrollHeight fallback (enhance.js). */
|
|
259
|
+
.hcms-shell.pixel-quiet .hcms-form textarea.mirk-textarea {
|
|
260
|
+
field-sizing: content;
|
|
261
|
+
resize: none;
|
|
262
|
+
overflow: hidden;
|
|
263
|
+
min-height: 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* rich-text surface (@richtext): a contenteditable styled like a textarea */
|
|
267
|
+
.hcms-shell.pixel-quiet .hcms-richtext {
|
|
268
|
+
min-height: 2.5em;
|
|
269
|
+
cursor: text;
|
|
270
|
+
overflow-wrap: break-word;
|
|
271
|
+
}
|
|
272
|
+
.hcms-shell.pixel-quiet .hcms-richtext a {
|
|
273
|
+
color: inherit;
|
|
274
|
+
text-decoration: underline;
|
|
275
|
+
text-underline-offset: 2px;
|
|
276
|
+
}
|
|
277
|
+
.hcms-shell.pixel-quiet .hcms-richtext:empty::before {
|
|
278
|
+
content: attr(data-hcms-placeholder);
|
|
279
|
+
color: var(--mirk-placeholder-color);
|
|
280
|
+
}
|
|
256
281
|
.hcms-shell.pixel-quiet .mirk-select__field {
|
|
257
282
|
font-size: 15px;
|
|
258
283
|
padding: 8px 40px 9px 14px;
|
|
@@ -264,6 +289,15 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
264
289
|
/* inline radio row */
|
|
265
290
|
.hcms-radio-row { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
|
|
266
291
|
|
|
292
|
+
/* chip-field (the @chips built-in): a borderless inline input that sizes to its
|
|
293
|
+
text, so chips read like static chips but stay inline-editable. */
|
|
294
|
+
.hcms-shell .mirk-tags__chip { padding-right: 6px; }
|
|
295
|
+
.hcms-shell .hcms-chip-field {
|
|
296
|
+
border: 0; background: transparent; color: inherit; font: inherit;
|
|
297
|
+
outline: none; min-width: 2ch; field-sizing: content; padding: 0;
|
|
298
|
+
}
|
|
299
|
+
.hcms-shell .hcms-chips .hcms-add { margin-top: 4px; align-self: flex-start; }
|
|
300
|
+
|
|
267
301
|
/* ---------- OBJECT GROUP — a quiet framed band, not a heavy card ---------- */
|
|
268
302
|
.hcms-object {
|
|
269
303
|
display: flex;
|