@panphora/hyper-cms 0.4.1 → 0.5.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,56 +44,183 @@ 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
+ `,
70
119
  }
71
120
 
72
- const DEFAULT_KEYS = Object.keys(DEFAULT_TEMPLATES)
121
+ // Shape templates fill-the-gaps on every managed page. The opt-in upload
122
+ // components (@file/@image) are injected on demand the first time a field
123
+ // selects them (the Slice-2 component seam), so pages using neither stay clean.
124
+ const ALWAYS_INJECT_KEYS = [
125
+ '@scalar',
126
+ '@object',
127
+ '@scalar-array',
128
+ '@scalar-array-item',
129
+ '@object-array',
130
+ '@object-array-item',
131
+ ]
73
132
 
74
133
  export function injectDefaults(doc) {
75
134
  const head = doc.head || doc.documentElement
76
135
  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)
136
+ for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
137
+ }
138
+
139
+ // Inject one opt-in component template (@file/@image) if the page hasn't
140
+ // already defined its own. Called by the component-selection seam the first
141
+ // time a field resolves to an upload component.
142
+ export function injectComponentTemplate(doc, key) {
143
+ if (!DEFAULT_TEMPLATES[key]) return null
144
+ const head = doc && (doc.head || doc.documentElement)
145
+ if (!head) return null
146
+ return injectTemplate(doc, head, key)
147
+ }
148
+
149
+ // Opt-in upload-component selection. A scalar field upgrades from a plain text
150
+ // input to an image-upload widget when its rule ends in @src (uploading is the
151
+ // dominant image-CMS workflow). @href is NOT inferred: editing a link/URL is far
152
+ // more common than uploading a file, so an a@href rule stays a plain URL field.
153
+ // A file upload (and an image override) is opt-in via data-hcms-component on the
154
+ // page element the rule points at — the only branch that reads the page DOM.
155
+ // Suffix is split like the engine (lastIndexOf('@'), see engine/extract.js).
156
+ // Returns a DEFAULT_TEMPLATES key.
157
+ const PROP_TO_COMPONENT = { src: '@image' }
158
+ const UPLOAD_COMPONENT_KEYS = new Set(['@image', '@file'])
159
+
160
+ export function componentForScalarRule(rule, doc) {
161
+ if (typeof rule !== 'string') return '@scalar'
162
+ const at = rule.lastIndexOf('@')
163
+ if (at >= 0) {
164
+ const prop = rule.slice(at + 1)
165
+ if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
84
166
  }
167
+ const override = readComponentOverride(rule, at, doc)
168
+ if (override === 'image') return '@image'
169
+ if (override === 'file') return '@file'
170
+ return '@scalar'
171
+ }
172
+
173
+ function readComponentOverride(rule, at, doc) {
174
+ if (!doc || !doc.querySelector) return null
175
+ const selector = at >= 0 ? rule.slice(0, at) : rule
176
+ if (!selector || selector === '.') return null
177
+ let el = null
178
+ try {
179
+ el = doc.querySelector(selector)
180
+ } catch {
181
+ return null
182
+ }
183
+ return el && el.getAttribute ? el.getAttribute('data-hcms-component') : null
184
+ }
185
+
186
+ // Pre-inject the @file/@image templates this page's rules actually select, so
187
+ // both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
188
+ // same rule shapes as buildTemplateMap; idempotent and author-template-safe.
189
+ export function injectUploadComponents(doc, pageRules) {
190
+ if (!doc || pageRules == null) return
191
+ walk(pageRules)
192
+
193
+ function walk(rule) {
194
+ const kind = shapeKindOf(rule)
195
+ if (kind === 'scalar') {
196
+ const key = componentForScalarRule(rule, doc)
197
+ if (UPLOAD_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
198
+ return
199
+ }
200
+ if (kind === 'object') {
201
+ for (const child of Object.values(rule)) walk(child)
202
+ return
203
+ }
204
+ if (kind === 'object-array') {
205
+ const itemShape = rule[1]
206
+ if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
207
+ for (const child of Object.values(itemShape)) walk(child)
208
+ } else {
209
+ walk(itemShape)
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ function injectTemplate(doc, head, key) {
216
+ const existing = findTemplate(doc, key)
217
+ if (existing) return existing
218
+ const tpl = doc.createElement('template')
219
+ tpl.setAttribute('data-hcms-tpl', key)
220
+ tpl.setAttribute('save-remove', '')
221
+ tpl.innerHTML = DEFAULT_TEMPLATES[key].trim()
222
+ head.appendChild(tpl)
223
+ return tpl
85
224
  }
86
225
 
87
226
  export function findTemplate(doc, key) {
@@ -136,11 +275,13 @@ export function buildTemplateMap(pageRules, doc) {
136
275
  const pathStr = pathArr.join('.')
137
276
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
138
277
  const shape = shapeKindOf(rule)
278
+ const defaultKey =
279
+ shape === 'scalar' ? componentForScalarRule(rule, doc) : SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
139
280
 
140
281
  let tpl =
141
282
  (pathStr && findTemplate(doc, pathStr)) ||
142
283
  (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
143
- findTemplate(doc, SHAPE_TO_DEFAULT_KEY[shape] || '@scalar')
284
+ findTemplate(doc, defaultKey)
144
285
 
145
286
  map.set(pathStr, tpl)
146
287