@panphora/hyper-cms 0.11.5 → 0.12.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.
@@ -0,0 +1,196 @@
1
+ import { checkCustomPropertyValue } from 'hyper-html-api/engine'
2
+ import { COLORS, HUES, SHADES } from './catalogs/colors.js'
3
+
4
+ // The @color component: swatches from the Tailwind palette plus an optional
5
+ // custom picker, all writing one hidden field that the form commits like any
6
+ // other scalar. Built here rather than in form-builder.js because the events
7
+ // side (pick, clear, panel, custom entry) needs the same helpers.
8
+
9
+ const INLINE_LIMIT = 12
10
+ const BY_ID = new Map(COLORS.map((c) => [c.id, c]))
11
+ const BY_VALUE = new Map(COLORS.map((c) => [c.value, c]))
12
+ const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
13
+ const COLOR_FUNCTION = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\(.*\)$/i
14
+ const UNSAFE = /[;{}<>\\]|url\s*\(/i
15
+
16
+ // The form is rebuilt after every page change, so which panels are open is
17
+ // kept outside it: per document, by field path.
18
+ const OPEN = new WeakMap()
19
+
20
+ function openPanels(doc) {
21
+ let set = OPEN.get(doc)
22
+ if (!set) OPEN.set(doc, (set = new Set()))
23
+ return set
24
+ }
25
+
26
+ // A value the custom picker may write. The browser's own parser decides when it
27
+ // is there; without one (node tests) a conservative shape check stands in.
28
+ export function isColorValue(value, doc) {
29
+ const text = String(value ?? '').trim()
30
+ if (!text || UNSAFE.test(text) || checkCustomPropertyValue(text)) return false
31
+ const css = doc?.defaultView?.CSS
32
+ if (css && typeof css.supports === 'function') return css.supports('color', text)
33
+ return HEX.test(text) || COLOR_FUNCTION.test(text) || /^[a-z]+$/i.test(text)
34
+ }
35
+
36
+ function humanColor(c) {
37
+ return `${c.hue[0].toUpperCase()}${c.hue.slice(1)} ${c.shade}`
38
+ }
39
+
40
+ function toSwatch(c) {
41
+ return { id: c.id, label: humanColor(c), value: c.value }
42
+ }
43
+
44
+ export function colorLabel(value) {
45
+ const text = String(value ?? '').trim()
46
+ const hit = BY_VALUE.get(text)
47
+ return hit ? humanColor(hit) : text
48
+ }
49
+
50
+ export function parseColorOptions(tokens, doc) {
51
+ if (!tokens || tokens.length === 0) {
52
+ return { swatches: COLORS.map(toSwatch), custom: true, full: true }
53
+ }
54
+ const swatches = []
55
+ const seen = new Set()
56
+ let custom = false
57
+ const add = (s) => {
58
+ if (seen.has(s.value)) return
59
+ seen.add(s.value)
60
+ swatches.push(s)
61
+ }
62
+ for (const token of tokens) {
63
+ if (token === 'custom') custom = true
64
+ else if (BY_ID.has(token)) add(toSwatch(BY_ID.get(token)))
65
+ else if (HUES.includes(token)) COLORS.filter((c) => c.hue === token).forEach((c) => add(toSwatch(c)))
66
+ else if (SHADES.includes(token)) COLORS.filter((c) => c.shade === token).forEach((c) => add(toSwatch(c)))
67
+ else if (isColorValue(token, doc)) add({ id: null, label: token, value: token })
68
+ else console.warn(`[hypercms] color option "${token}" is not a Tailwind color, hue, shade, CSS color or "custom"; ignoring it`)
69
+ }
70
+ return { swatches, custom, full: false }
71
+ }
72
+
73
+ export function setupColorControl(node, { value, tokens, path, doc }) {
74
+ const { swatches, custom, full } = parseColorOptions(tokens, doc)
75
+ const current = value == null ? '' : String(value).trim()
76
+ if (current && !swatches.some((s) => s.value === current)) {
77
+ swatches.unshift({ id: null, label: colorLabel(current), value: current })
78
+ }
79
+ const inline = swatches.length <= INLINE_LIMIT
80
+ node.setAttribute('data-hcms-color-path', path)
81
+ node.classList.toggle('hcms-color--inline', inline)
82
+ const grid = node.querySelector('.hcms-color-grid')
83
+ if (grid) {
84
+ grid.classList.toggle('hcms-color-grid--full', full)
85
+ for (const s of swatches) {
86
+ const b = doc.createElement('button')
87
+ b.type = 'button'
88
+ b.className = 'hcms-color-swatch'
89
+ b.setAttribute('data-hcms-action', 'color-pick')
90
+ b.setAttribute('data-hcms-color', s.value)
91
+ b.setAttribute('aria-label', s.label)
92
+ b.setAttribute('title', s.id || s.label)
93
+ b.setAttribute('style', `--hcms-swatch: ${s.value}`)
94
+ grid.appendChild(b)
95
+ }
96
+ }
97
+ if (!custom) node.querySelector('.hcms-color-custom')?.remove()
98
+ const toggle = node.querySelector('.hcms-color-current')
99
+ if (inline && toggle) {
100
+ toggle.removeAttribute('data-hcms-action')
101
+ toggle.removeAttribute('aria-expanded')
102
+ }
103
+ setPanelOpen(node, inline || openPanels(doc).has(path))
104
+ paintColorControl(node, current)
105
+ }
106
+
107
+ // Reflect a value in the control's own chrome: chip, name, pressed swatch,
108
+ // custom inputs. The hidden field is the value; this is only its picture.
109
+ export function paintColorControl(node, value) {
110
+ const current = value == null ? '' : String(value).trim()
111
+ node.toggleAttribute('data-hcms-color-empty', current === '')
112
+ const chip = node.querySelector('.hcms-color-chip')
113
+ if (chip) {
114
+ if (current) chip.setAttribute('style', `--hcms-swatch: ${current}`)
115
+ else chip.removeAttribute('style')
116
+ }
117
+ const name = node.querySelector('.hcms-color-name')
118
+ if (name) name.textContent = current ? colorLabel(current) : 'Not set'
119
+ node.querySelectorAll('.hcms-color-swatch').forEach((b) => {
120
+ b.setAttribute('aria-pressed', String(b.getAttribute('data-hcms-color') === current))
121
+ })
122
+ const text = node.querySelector('[data-hcms-color-input="text"]')
123
+ if (text && text.ownerDocument?.activeElement !== text) text.value = current
124
+ const native = node.querySelector('[data-hcms-color-input="native"]')
125
+ if (native && /^#[0-9a-f]{6}$/i.test(current)) native.value = current.toLowerCase()
126
+ }
127
+
128
+ function setPanelOpen(node, open) {
129
+ const panel = node.querySelector('.hcms-color-panel')
130
+ if (panel) panel.hidden = !open
131
+ const toggle = node.querySelector('.hcms-color-current[data-hcms-action]')
132
+ if (toggle) toggle.setAttribute('aria-expanded', String(open))
133
+ }
134
+
135
+ function controlOf(el) {
136
+ return el?.closest ? el.closest('[data-hcms-color-path]') : null
137
+ }
138
+
139
+ function hiddenField(node) {
140
+ return node.querySelector('input[type="hidden"][data-hcms-field]')
141
+ }
142
+
143
+ function showColorError(node, message) {
144
+ const slot = node.querySelector(':scope > .hcms-error')
145
+ if (!slot) return
146
+ slot.textContent = message || ''
147
+ slot.hidden = !message
148
+ }
149
+
150
+ // Each of these returns the hidden field to commit, or null when nothing changed.
151
+ function setColor(node, value) {
152
+ const field = hiddenField(node)
153
+ if (!field) return null
154
+ showColorError(node, null)
155
+ paintColorControl(node, value)
156
+ if (field.value === value) return null
157
+ field.value = value
158
+ return field
159
+ }
160
+
161
+ export function pickColor(actionEl) {
162
+ const node = controlOf(actionEl)
163
+ if (!node) return null
164
+ if (!node.classList.contains('hcms-color--inline')) {
165
+ openPanels(node.ownerDocument).delete(node.getAttribute('data-hcms-color-path'))
166
+ setPanelOpen(node, false)
167
+ }
168
+ return setColor(node, actionEl.getAttribute('data-hcms-color') || '')
169
+ }
170
+
171
+ export function clearColor(actionEl) {
172
+ const node = controlOf(actionEl)
173
+ return node ? setColor(node, '') : null
174
+ }
175
+
176
+ export function toggleColorPanel(actionEl) {
177
+ const node = controlOf(actionEl)
178
+ if (!node) return
179
+ const path = node.getAttribute('data-hcms-color-path')
180
+ const panels = openPanels(node.ownerDocument)
181
+ const open = !panels.has(path)
182
+ if (open) panels.add(path)
183
+ else panels.delete(path)
184
+ setPanelOpen(node, open)
185
+ }
186
+
187
+ export function customColor(inputEl) {
188
+ const node = controlOf(inputEl)
189
+ if (!node) return null
190
+ const value = String(inputEl.value || '').trim()
191
+ if (value !== '' && !isColorValue(value, node.ownerDocument)) {
192
+ showColorError(node, `"${value}" is not a CSS color`)
193
+ return null
194
+ }
195
+ return setColor(node, value)
196
+ }
package/src/events.js CHANGED
@@ -5,6 +5,10 @@ import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
5
5
  import { scaffold } from './scaffold.js'
6
6
  import { autosizeTextarea, enhanceFields } from './enhance.js'
7
7
  import { platform } from './platform.js'
8
+ import { contentRoot } from './page-root.js'
9
+ import { pickColor, clearColor, toggleColorPanel, customColor } from './color-control.js'
10
+ import { pickFont, toggleFontPanel } from './font-control.js'
11
+ import { reconcileFontsLink } from './fonts-link.js'
8
12
 
9
13
  const BOUND = new WeakSet()
10
14
 
@@ -57,6 +61,9 @@ export function bindEvents(ctx) {
57
61
  const target = e.target
58
62
  if (!target || !target.closest) return
59
63
  if (!target.closest('[data-hcms-form-root]')) return
64
+ // The color control's custom inputs commit on change, not per keystroke or
65
+ // per drag step: a commit rebuilds the form under the cursor.
66
+ if (target.matches('[data-hcms-color-input]')) return
60
67
  if (!target.matches('input, textarea, select, [contenteditable][data-hcms-field]')) return
61
68
  if (target.tagName === 'TEXTAREA') autosizeTextarea(target)
62
69
  // The upload picker fires `input` too, but its .value is the OS fake path
@@ -72,6 +79,11 @@ export function bindEvents(ctx) {
72
79
  const target = e.target
73
80
  if (!target || !target.closest) return
74
81
  if (!target.closest('[data-hcms-form-root]')) return
82
+ if (target.matches('[data-hcms-color-input]')) {
83
+ const field = customColor(target)
84
+ if (field) onScalarChange(field, ctx)
85
+ return
86
+ }
75
87
  if (target.matches('input[type="file"][data-hcms-upload]')) {
76
88
  onUploadChange(target, ctx)
77
89
  return
@@ -89,7 +101,7 @@ export function bindEvents(ctx) {
89
101
  const action = actionEl.getAttribute('data-hcms-action')
90
102
  // add/remove/move must live inside the form root; close inside the shell.
91
103
  // Ignore stray data-hcms-action attributes elsewhere on the page.
92
- if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload') {
104
+ if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload' || action === 'color-pick' || action === 'color-clear' || action === 'color-panel' || action === 'font-pick' || action === 'font-panel') {
93
105
  if (!actionEl.closest('[data-hcms-form-root]')) return
94
106
  } else if (action === 'close') {
95
107
  if (!actionEl.closest('[data-hcms-shell]')) return
@@ -109,6 +121,16 @@ export function bindEvents(ctx) {
109
121
  onMove(itemEl, action === 'move-up' ? -1 : 1, ctx)
110
122
  } else if (action === 'clear-upload') {
111
123
  onClearUpload(actionEl, ctx)
124
+ } else if (action === 'color-pick' || action === 'color-clear') {
125
+ const field = action === 'color-pick' ? pickColor(actionEl) : clearColor(actionEl)
126
+ if (field) onScalarChange(field, ctx)
127
+ } else if (action === 'color-panel') {
128
+ toggleColorPanel(actionEl)
129
+ } else if (action === 'font-pick') {
130
+ const field = pickFont(actionEl)
131
+ if (field) onScalarChange(field, ctx)
132
+ } else if (action === 'font-panel') {
133
+ toggleFontPanel(actionEl)
112
134
  } else if (action === 'close') {
113
135
  ctx.onCloseRequested?.()
114
136
  }
@@ -154,7 +176,7 @@ function resolveUnobservedProjection(ctx, pathStr) {
154
176
  const prop = rule.slice(at + 1)
155
177
  if (!UNOBSERVED_FIELD_PROPS.has(prop)) return null
156
178
  const selector = rule.slice(0, at)
157
- const el = selector ? ctx.pageRoot.querySelector(selector) : ctx.pageRoot
179
+ const el = selector ? ctx.pageRoot.querySelector(selector) : contentRoot(ctx.pageRoot)
158
180
  if (!el) return null
159
181
  return { el, prop, oldValue: el[prop] }
160
182
  }
@@ -631,6 +653,7 @@ export function commit(newData, info, ctx) {
631
653
  if (result.ok) {
632
654
  ctx.lastFingerprint = fingerprint
633
655
  ctx.lastData = newData
656
+ reconcileFontsLink(ctx, newData)
634
657
  setError(ctx, null)
635
658
  ctx.dispatch?.('hcms:change', { data: newData, path: info.path, structural: !!info.structural })
636
659
  ctx.onChange?.(newData, info)
@@ -0,0 +1,142 @@
1
+ import { FONTS, FONT_GROUPS, SYSTEM_FONT, fontStack, fontFromStack, fontsHref } from './catalogs/fonts.js'
2
+
3
+ // The @font component: a menu of catalog families, each shown in its own face,
4
+ // writing one hidden field that the form commits like any other scalar.
5
+
6
+ const GROUP_LABELS = { sans: 'Sans', serif: 'Serif', display: 'Display', mono: 'Mono' }
7
+ const PREVIEW = 'link[data-hcms-font-preview]'
8
+
9
+ const OPEN = new WeakMap()
10
+
11
+ function openPanels(doc) {
12
+ let set = OPEN.get(doc)
13
+ if (!set) OPEN.set(doc, (set = new Set()))
14
+ return set
15
+ }
16
+
17
+ function isSystemStack(stack) {
18
+ return String(stack ?? '').trim() === SYSTEM_FONT.fallback
19
+ }
20
+
21
+ export function fontLabel(stack) {
22
+ const text = String(stack ?? '').trim()
23
+ if (!text) return 'Not set'
24
+ if (isSystemStack(text)) return 'System'
25
+ const font = fontFromStack(text)
26
+ return font ? font.family : 'Current font'
27
+ }
28
+
29
+ export function parseFontOptions(tokens) {
30
+ if (!tokens || tokens.length === 0) return { system: true, fonts: [...FONTS] }
31
+ let system = false
32
+ const picked = new Set()
33
+ for (const token of tokens) {
34
+ if (token === 'system') system = true
35
+ else if (FONT_GROUPS.includes(token)) FONTS.filter((f) => f.group === token).forEach((f) => picked.add(f))
36
+ else if (FONTS.some((f) => f.slug === token)) picked.add(FONTS.find((f) => f.slug === token))
37
+ else console.warn(`[hypercms] font option "${token}" is not a catalog font, a group or "system"; ignoring it`)
38
+ }
39
+ return { system, fonts: FONTS.filter((f) => picked.has(f)) }
40
+ }
41
+
42
+ function option(doc, { value, label, family }) {
43
+ const b = doc.createElement('button')
44
+ b.type = 'button'
45
+ b.className = 'hcms-font-option'
46
+ b.setAttribute('role', 'option')
47
+ b.setAttribute('data-hcms-action', 'font-pick')
48
+ b.setAttribute('data-hcms-font', value)
49
+ b.setAttribute('style', `font-family: ${family}`)
50
+ b.textContent = label
51
+ return b
52
+ }
53
+
54
+ export function setupFontControl(node, { value, tokens, path, doc }) {
55
+ const { system, fonts } = parseFontOptions(tokens)
56
+ const current = value == null ? '' : String(value).trim()
57
+ node.setAttribute('data-hcms-font-path', path)
58
+ const list = node.querySelector('.hcms-font-list')
59
+ if (list) {
60
+ const offered = new Set([...(system ? [SYSTEM_FONT.fallback] : []), ...fonts.map(fontStack)])
61
+ if (current && !offered.has(current)) {
62
+ list.appendChild(option(doc, { value: current, label: fontLabel(current), family: current }))
63
+ }
64
+ if (system) list.appendChild(option(doc, { value: SYSTEM_FONT.fallback, label: 'System', family: SYSTEM_FONT.fallback }))
65
+ for (const group of FONT_GROUPS) {
66
+ const members = fonts.filter((f) => f.group === group)
67
+ if (!members.length) continue
68
+ const heading = doc.createElement('div')
69
+ heading.className = 'hcms-font-group'
70
+ heading.textContent = GROUP_LABELS[group]
71
+ list.appendChild(heading)
72
+ for (const f of members) list.appendChild(option(doc, { value: fontStack(f), label: f.family, family: fontStack(f) }))
73
+ }
74
+ }
75
+ setPanelOpen(node, openPanels(doc).has(path))
76
+ paintFontControl(node, current)
77
+ }
78
+
79
+ export function paintFontControl(node, value) {
80
+ const current = value == null ? '' : String(value).trim()
81
+ const name = node.querySelector('.hcms-font-name')
82
+ if (name) {
83
+ name.textContent = fontLabel(current)
84
+ if (current) name.setAttribute('style', `font-family: ${current}`)
85
+ else name.removeAttribute('style')
86
+ }
87
+ node.querySelectorAll('.hcms-font-option').forEach((b) => {
88
+ b.setAttribute('aria-selected', String(b.getAttribute('data-hcms-font') === current))
89
+ })
90
+ }
91
+
92
+ function setPanelOpen(node, open) {
93
+ const panel = node.querySelector('.hcms-font-panel')
94
+ if (panel) panel.hidden = !open
95
+ node.querySelector('.hcms-font-current')?.setAttribute('aria-expanded', String(open))
96
+ if (open) ensurePreviewLink(node.ownerDocument)
97
+ }
98
+
99
+ // One editor-only stylesheet for every family name in its own face. It asks
100
+ // Google for just the letters of the names, so it stays a few kilobytes.
101
+ function ensurePreviewLink(doc) {
102
+ const head = doc?.head
103
+ if (!head || head.querySelector(PREVIEW)) return
104
+ const href = fontsHref(FONTS.map(fontStack))
105
+ const letters = [...new Set(FONTS.map((f) => f.family).join(''))].join('')
106
+ const link = doc.createElement('link')
107
+ link.rel = 'stylesheet'
108
+ link.href = `${href}&text=${encodeURIComponent(letters)}`
109
+ link.setAttribute('data-hcms-font-preview', '')
110
+ for (const attr of ['editor-ui', 'no-save', 'save-remove', 'snapshot-remove']) link.setAttribute(attr, '')
111
+ head.appendChild(link)
112
+ }
113
+
114
+ function controlOf(el) {
115
+ return el?.closest ? el.closest('[data-hcms-font-path]') : null
116
+ }
117
+
118
+ export function toggleFontPanel(actionEl) {
119
+ const node = controlOf(actionEl)
120
+ if (!node) return
121
+ const path = node.getAttribute('data-hcms-font-path')
122
+ const panels = openPanels(node.ownerDocument)
123
+ const open = !panels.has(path)
124
+ if (open) panels.add(path)
125
+ else panels.delete(path)
126
+ setPanelOpen(node, open)
127
+ }
128
+
129
+ // Returns the hidden field to commit, or null when nothing changed.
130
+ export function pickFont(actionEl) {
131
+ const node = controlOf(actionEl)
132
+ if (!node) return null
133
+ const field = node.querySelector('input[type="hidden"][data-hcms-field]')
134
+ if (!field) return null
135
+ openPanels(node.ownerDocument).delete(node.getAttribute('data-hcms-font-path'))
136
+ setPanelOpen(node, false)
137
+ const value = actionEl.getAttribute('data-hcms-font') || ''
138
+ paintFontControl(node, value)
139
+ if (field.value === value) return null
140
+ field.value = value
141
+ return field
142
+ }
@@ -0,0 +1,83 @@
1
+ import { componentForScalarRule } from './templates.js'
2
+ import { fontsHref, sameFamilies } from './catalogs/fonts.js'
3
+
4
+ // The page's one Google Fonts stylesheet is derived from its font fields: the
5
+ // families they hold, nothing else. Recomputed after commits and refreshes, so
6
+ // undo, live sync and outside edits all end in the right link without each
7
+ // path having to know about fonts.
8
+
9
+ const LINK = 'link[data-hcms-fonts]'
10
+ const PRECONNECT = 'link[data-hcms-fonts-preconnect]'
11
+
12
+ // Every font field's value, walking the rules and the extracted data together.
13
+ // `found` says whether these rules have any font field at all.
14
+ export function fontStacksIn(pageRules, data, doc) {
15
+ const stacks = []
16
+ let found = false
17
+ walk(pageRules, data, [])
18
+ return { stacks, found }
19
+
20
+ function walk(rule, value, pathArr) {
21
+ if (typeof rule === 'string') {
22
+ if (rule.endsWith('[]')) return
23
+ if (componentForScalarRule(rule, doc, pathArr, pageRules) !== '@font') return
24
+ found = true
25
+ if (value != null && value !== '') stacks.push(String(value))
26
+ return
27
+ }
28
+ if (Array.isArray(rule)) {
29
+ const items = Array.isArray(value) ? value : []
30
+ items.forEach((item) => walk(rule[1], item, [...pathArr, '*']))
31
+ if (!items.length) walk(rule[1], undefined, [...pathArr, '*'])
32
+ return
33
+ }
34
+ if (rule && typeof rule === 'object') {
35
+ for (const [key, child] of Object.entries(rule)) walk(child, value?.[key], [...pathArr, key])
36
+ }
37
+ }
38
+ }
39
+
40
+ export function syncFontsLink(doc, href) {
41
+ const head = doc?.head
42
+ if (!head) return
43
+ let link = head.querySelector(LINK)
44
+ let pre = head.querySelector(PRECONNECT)
45
+ if (!href) {
46
+ link?.remove()
47
+ pre?.remove()
48
+ return
49
+ }
50
+ if (!pre) {
51
+ pre = doc.createElement('link')
52
+ pre.setAttribute('rel', 'preconnect')
53
+ pre.setAttribute('href', 'https://fonts.gstatic.com')
54
+ pre.setAttribute('crossorigin', '')
55
+ pre.setAttribute('data-hcms-fonts-preconnect', '')
56
+ pre.setAttribute('no-undo', '')
57
+ head.appendChild(pre)
58
+ }
59
+ if (!link) {
60
+ link = doc.createElement('link')
61
+ link.setAttribute('rel', 'stylesheet')
62
+ link.setAttribute('data-hcms-fonts', '')
63
+ link.setAttribute('no-undo', '')
64
+ pre.after(link)
65
+ }
66
+ // The CMS derives this link from the font fields, so its changes are never
67
+ // undo steps of their own, including on a link the author wrote by hand.
68
+ if (!link.hasAttribute('no-undo')) link.setAttribute('no-undo', '')
69
+ if (!pre.hasAttribute('no-undo')) pre.setAttribute('no-undo', '')
70
+ if (link.getAttribute('href') !== href) link.setAttribute('href', href)
71
+ }
72
+
73
+ export function reconcileFontsLink(ctx, data) {
74
+ if (!ctx?.doc || ctx.pageRules == null) return
75
+ const { stacks, found } = fontStacksIn(ctx.pageRules, data, ctx.doc)
76
+ if (!found) return
77
+ const current = ctx.doc.head?.querySelector(LINK)?.getAttribute('href') || null
78
+ const href = fontsHref(stacks, current)
79
+ // A link that already loads exactly these families stays as it is, so
80
+ // opening the editor or an undo never rewrites the author's own URL.
81
+ if (href && current && sameFamilies(current, href)) return
82
+ syncFontsLink(ctx.doc, href)
83
+ }
@@ -10,8 +10,12 @@ import {
10
10
  declaredComponentKey,
11
11
  readOptionsOverride,
12
12
  readCropOverride,
13
+ styleRole,
13
14
  } from './templates.js'
14
- import { fieldPropertyFor } from './form-rules.js'
15
+ import { fieldPropertyFor, shapeOf } from './form-rules.js'
16
+ import { setupColorControl } from './color-control.js'
17
+ import { setupFontControl } from './font-control.js'
18
+ import { populateVariant } from './variant-control.js'
15
19
 
16
20
  // The visible label for a file field is the basename of its URL value; the
17
21
  // bound leaf still carries the full URL in href so it round-trips byte-stable.
@@ -28,11 +32,59 @@ export function fileNameFromUrl(url) {
28
32
 
29
33
  export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
30
34
  const fragment = doc.createDocumentFragment()
35
+ const pageStyle = buildPageStyle(pageRules, data, doc)
36
+ if (pageStyle) fragment.appendChild(pageStyle)
31
37
  const root = buildNode(pageRules, [], data, doc, pageRules)
32
38
  if (root) fragment.appendChild(root)
33
39
  return fragment
34
40
  }
35
41
 
42
+ // Page style fields, gathered from wherever their rules sit (outside lists)
43
+ // into one group that renders first. Each field keeps its own data-hcms-path.
44
+ function buildPageStyle(pageRules, data, doc) {
45
+ const fields = []
46
+ collect(pageRules, data, [])
47
+ if (!fields.length) return null
48
+ const tpl = findTemplate(doc, '@object')
49
+ if (!tpl) return null
50
+ const node = cloneTemplate(tpl, doc)
51
+ node.setAttribute('data-hcms-page-style', '')
52
+ const label = node.querySelector('[data-hcms-label]')
53
+ if (label) label.textContent = 'Page style'
54
+ const slot = node.querySelector('.hcms-object-fields') || node
55
+ for (const [rule, pathArr, value] of fields) {
56
+ const field = buildNode(rule, pathArr, value, doc, pageRules)
57
+ if (field) slot.appendChild(field)
58
+ }
59
+ return node
60
+
61
+ function collect(rule, value, pathArr) {
62
+ if (typeof rule === 'string') {
63
+ if (pathArr.length && styleRole(rule, doc, pathArr, pageRules) === 'page') fields.push([rule, pathArr, value])
64
+ return
65
+ }
66
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return
67
+ if (pathArr.length) {
68
+ const tpl = resolveTemplate(pathArr, '@object', doc)
69
+ if (tpl && isInlineTemplate(tpl)) return
70
+ }
71
+ for (const [k, child] of Object.entries(rule)) collect(child, value == null ? null : value[k], [...pathArr, k])
72
+ }
73
+ }
74
+
75
+ // Style fields first, each group keeping its own order; page style fields are
76
+ // left out here because buildPageStyle renders them.
77
+ function orderedEntries(shape, pathArr, doc, pageRules) {
78
+ const style = []
79
+ const content = []
80
+ for (const [k, child] of Object.entries(shape)) {
81
+ const role = styleRole(child, doc, [...pathArr, k], pageRules)
82
+ if (role === 'page') continue
83
+ ;(role ? style : content).push([k, child])
84
+ }
85
+ return [...style, ...content]
86
+ }
87
+
36
88
  // pageRules rides alongside doc so a scalar bound to its own context element
37
89
  // ("." / "@attr") can resolve the row that carries its named control; doc alone
38
90
  // cannot answer that, because the row is only knowable from the enclosing array
@@ -88,6 +140,17 @@ function buildScalar(rule, pathArr, data, doc, pageRules) {
88
140
  if ((componentKey === '@select' || componentKey === '@radio') && winnerKey === componentKey) {
89
141
  populateOptions(node, rule, pathArr, data, doc, componentKey, pageRules)
90
142
  }
143
+ if (componentKey === '@variant' && winnerKey === '@variant') {
144
+ const tokens = readOptionsOverride(rule, doc, pathArr, pageRules)
145
+ if (!tokens) setFieldError(node, 'data-hcms-options required (value:icon tokens)')
146
+ const count = populateVariant(node, {
147
+ tokens,
148
+ value: data,
149
+ name: radioGroupName(pathArr.join('.')),
150
+ doc,
151
+ })
152
+ if (count === 0) node.querySelector('.hcms-variant-option')?.remove()
153
+ }
91
154
  if (componentKey === '@image' && winnerKey === '@image') {
92
155
  const crop = readCropOverride(rule, doc, pathArr, pageRules)
93
156
  if (crop != null && !node.hasAttribute('data-hcms-crop')) node.setAttribute('data-hcms-crop', crop)
@@ -100,6 +163,22 @@ function buildScalar(rule, pathArr, data, doc, pageRules) {
100
163
  stampContainerField(node, lastKey(pathArr))
101
164
  setLabel(node, lastKey(pathArr))
102
165
  populateScalarValue(node, data)
166
+ if (componentKey === '@color' && winnerKey === '@color') {
167
+ setupColorControl(node, {
168
+ value: data,
169
+ tokens: readOptionsOverride(rule, doc, pathArr, pageRules),
170
+ path: pathArr.join('.'),
171
+ doc,
172
+ })
173
+ }
174
+ if (componentKey === '@font' && winnerKey === '@font') {
175
+ setupFontControl(node, {
176
+ value: data,
177
+ tokens: readOptionsOverride(rule, doc, pathArr, pageRules),
178
+ path: pathArr.join('.'),
179
+ doc,
180
+ })
181
+ }
103
182
  if (componentKey === '@file') syncFileLeafText(node)
104
183
  return node
105
184
  }
@@ -212,11 +291,12 @@ function buildObject(rule, pathArr, data, doc, pageRules) {
212
291
  }
213
292
 
214
293
  const slot = requireSlot(node, '.hcms-object-fields', tpl, pathArr)
215
- for (const [k, child] of Object.entries(rule)) {
294
+ for (const [k, child] of orderedEntries(rule, pathArr, doc, pageRules)) {
216
295
  const childData = data == null ? null : data[k]
217
296
  const childNode = buildNode(child, [...pathArr, k], childData, doc, pageRules)
218
297
  if (childNode) slot.appendChild(childNode)
219
298
  }
299
+ if (pathArr.length && !slot.children.length && Object.keys(rule).length) return null
220
300
  return node
221
301
  }
222
302
 
@@ -259,7 +339,7 @@ function buildObjectArrayItem(itemShape, pathArr, data, doc, pageRules) {
259
339
 
260
340
  const slot = requireSlot(node, '.hcms-card-fields', tpl, pathArr)
261
341
  if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
262
- for (const [k, child] of Object.entries(itemShape)) {
342
+ for (const [k, child] of orderedEntries(itemShape, pathArr, doc, pageRules)) {
263
343
  const childData = data == null ? null : data[k]
264
344
  const childNode = buildNode(child, [...pathArr, k], childData, doc, pageRules)
265
345
  if (childNode) slot.appendChild(childNode)
@@ -332,6 +412,7 @@ function cloneTemplate(tpl, doc) {
332
412
 
333
413
  function stampPath(node, pathArr) {
334
414
  node.setAttribute('data-hcms-path', pathArr.join('.'))
415
+ node.setAttribute('data-hcms-path-shape', shapeOf(pathArr))
335
416
  }
336
417
 
337
418
  function stampScalarField(node, key) {
@@ -481,6 +562,7 @@ function stampInlineFieldPaths(node, shape, parentPath) {
481
562
  if (shape && typeof shape === 'object' && !(key in shape)) return
482
563
  const childPath = [...parentPath, key].join('.')
483
564
  el.setAttribute('data-hcms-path', childPath)
565
+ el.setAttribute('data-hcms-path-shape', shapeOf([...parentPath, key]))
484
566
  })
485
567
  }
486
568