@panphora/hyper-cms 0.7.1 → 0.8.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 +5 -1
- package/dist/hypercms.min.js +55 -13
- package/package.json +3 -3
- package/src/events.js +149 -34
- package/src/form-builder.js +25 -20
- package/src/form-rules.js +1 -1
- package/src/hypercms.js +4 -0
- package/src/platform.js +58 -7
- package/src/templates.js +68 -18
- package/src/theme/pixel-quiet.overrides.css +42 -0
- package/src/theme.generated.css +42 -0
package/src/templates.js
CHANGED
|
@@ -269,10 +269,11 @@ const ON_DEMAND_COMPONENT_KEYS = new Set([
|
|
|
269
269
|
'@chips-item',
|
|
270
270
|
])
|
|
271
271
|
|
|
272
|
-
export function componentForScalarRule(rule, doc, pathArr) {
|
|
272
|
+
export function componentForScalarRule(rule, doc, pathArr, pageRules) {
|
|
273
273
|
if (typeof rule !== 'string') return '@scalar'
|
|
274
274
|
const at = ruleAttrIndex(rule)
|
|
275
|
-
const
|
|
275
|
+
const host = hostSelectorOf(rule, at, pathArr, pageRules)
|
|
276
|
+
const override = readElementAttr(host, doc, 'data-hcms-component')
|
|
276
277
|
if (override && SCALAR_COMPONENT_BY_NAME[override]) {
|
|
277
278
|
const key = SCALAR_COMPONENT_BY_NAME[override]
|
|
278
279
|
// Value guards check exactly the elements the engine binds: under an array
|
|
@@ -280,13 +281,13 @@ export function componentForScalarRule(rule, doc, pathArr) {
|
|
|
280
281
|
// plain scalar binds only the FIRST match — a decorative second element
|
|
281
282
|
// sharing the selector must not veto the control.
|
|
282
283
|
const multi = Array.isArray(pathArr) && pathArr.some((s) => s === '*' || typeof s === 'number')
|
|
283
|
-
if (key === '@number' && !currentScalarValues(rule, at, doc, multi).every(numberInputCanHold)) {
|
|
284
|
+
if (key === '@number' && !currentScalarValues(rule, at, doc, multi, host).every(numberInputCanHold)) {
|
|
284
285
|
return '@scalar'
|
|
285
286
|
}
|
|
286
287
|
if (
|
|
287
288
|
(key === '@checkbox' || key === '@toggle') &&
|
|
288
289
|
(at < 0 || rule.slice(at + 1) !== 'checked') &&
|
|
289
|
-
!currentScalarValues(rule, at, doc, multi).every(checkboxCanHold)
|
|
290
|
+
!currentScalarValues(rule, at, doc, multi, host).every(checkboxCanHold)
|
|
290
291
|
) {
|
|
291
292
|
return '@scalar'
|
|
292
293
|
}
|
|
@@ -303,10 +304,10 @@ export function componentForScalarRule(rule, doc, pathArr) {
|
|
|
303
304
|
// rule, or null (absent / unknown name / value-guard fallback). Lets the
|
|
304
305
|
// builder tell "author asked for this" apart from suffix inference when
|
|
305
306
|
// logging shadowing notices.
|
|
306
|
-
export function declaredComponentKey(rule, doc) {
|
|
307
|
+
export function declaredComponentKey(rule, doc, pathArr, pageRules) {
|
|
307
308
|
if (typeof rule !== 'string') return null
|
|
308
309
|
const at = ruleAttrIndex(rule)
|
|
309
|
-
const name = readElementAttr(
|
|
310
|
+
const name = readElementAttr(hostSelectorOf(rule, at, pathArr, pageRules), doc, 'data-hcms-component')
|
|
310
311
|
return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
|
|
311
312
|
}
|
|
312
313
|
|
|
@@ -336,9 +337,8 @@ function checkboxCanHold(value) {
|
|
|
336
337
|
// skips: cms-template seeds and the shell are never extracted, so their
|
|
337
338
|
// values don't get a vote. `multi` mirrors the engine's binding: all matches
|
|
338
339
|
// under an array (one element per item), only the first match otherwise.
|
|
339
|
-
function currentScalarValues(rule, at, doc, multi) {
|
|
340
|
+
function currentScalarValues(rule, at, doc, multi, selector) {
|
|
340
341
|
if (!doc || !doc.querySelectorAll) return []
|
|
341
|
-
const selector = scalarSelectorOf(rule, at)
|
|
342
342
|
if (!selector || selector === '.') return []
|
|
343
343
|
let els = null
|
|
344
344
|
try {
|
|
@@ -413,10 +413,10 @@ export function winningScalarArrayComponent(rule, pathArr, doc) {
|
|
|
413
413
|
// Option list for @select/@radio: data-hcms-options="low medium high" on the
|
|
414
414
|
// same page element as data-hcms-component. Space-separated value tokens (the
|
|
415
415
|
// data-rules-name idiom); labels derive via humanize() like every other label.
|
|
416
|
-
export function readOptionsOverride(rule, doc) {
|
|
416
|
+
export function readOptionsOverride(rule, doc, pathArr, pageRules) {
|
|
417
417
|
if (typeof rule !== 'string') return null
|
|
418
418
|
const at = ruleAttrIndex(rule)
|
|
419
|
-
const raw = readElementAttr(
|
|
419
|
+
const raw = readElementAttr(hostSelectorOf(rule, at, pathArr, pageRules), doc, 'data-hcms-options')
|
|
420
420
|
if (raw == null) return null
|
|
421
421
|
const tokens = raw.trim().split(/\s+/).filter(Boolean)
|
|
422
422
|
return tokens.length ? tokens : null
|
|
@@ -425,16 +425,61 @@ export function readOptionsOverride(rule, doc) {
|
|
|
425
425
|
// Crop opt-in for @image: data-hcms-crop="1:1" | "16:9" | "free" on the page
|
|
426
426
|
// element. The form-builder copies it onto the built field so the upload path
|
|
427
427
|
// reads it without a second page lookup.
|
|
428
|
-
export function readCropOverride(rule, doc) {
|
|
428
|
+
export function readCropOverride(rule, doc, pathArr, pageRules) {
|
|
429
429
|
if (typeof rule !== 'string') return null
|
|
430
430
|
const at = ruleAttrIndex(rule)
|
|
431
|
-
return readElementAttr(
|
|
431
|
+
return readElementAttr(hostSelectorOf(rule, at, pathArr, pageRules), doc, 'data-hcms-crop')
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
function scalarSelectorOf(rule, at) {
|
|
435
435
|
return at >= 0 ? rule.slice(0, at) : rule
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
// The page element that carries this rule's authoring attributes
|
|
439
|
+
// (data-hcms-component / -options / -crop), and whose values the guards read.
|
|
440
|
+
//
|
|
441
|
+
// A rule that binds its own context element — a lone "." or a leading "@attr" —
|
|
442
|
+
// has no selector of its own: scalarSelectorOf yields "" or ".". Inside a list
|
|
443
|
+
// that context element IS the row, so the element to read is the row, and the
|
|
444
|
+
// row selector is the enclosing array's item selector. That is the "state is
|
|
445
|
+
// content" case (data-status on the card), and without this it can never carry
|
|
446
|
+
// a named control. Outside any list the context is the document root, so there
|
|
447
|
+
// is still no element to read and we return "" — readElementAttr and
|
|
448
|
+
// currentScalarValues both treat that as "nothing declared", exactly as before.
|
|
449
|
+
//
|
|
450
|
+
// The component is chosen per RULE, not per row, so any ONE matching row
|
|
451
|
+
// answers the question; readElementAttr's querySelector taking the first match
|
|
452
|
+
// is correct rather than a shortcut.
|
|
453
|
+
function hostSelectorOf(rule, at, pathArr, pageRules) {
|
|
454
|
+
const own = scalarSelectorOf(rule, at)
|
|
455
|
+
if (own && own !== '.') return own
|
|
456
|
+
return enclosingArraySelector(pageRules, pathArr)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// The descendant-joined item selectors of every array rule crossed on the way
|
|
460
|
+
// to pathArr, or "" when the path crosses none (or does not resolve). Mirrors
|
|
461
|
+
// the scope chain hyperclay's resolveControl builds for the same path, so the
|
|
462
|
+
// generator stamps the attribute on exactly the elements read back here.
|
|
463
|
+
function enclosingArraySelector(pageRules, pathArr) {
|
|
464
|
+
if (pageRules == null || !Array.isArray(pathArr)) return ''
|
|
465
|
+
const scope = []
|
|
466
|
+
let node = pageRules
|
|
467
|
+
for (const seg of pathArr) {
|
|
468
|
+
if (node == null || typeof node === 'string') break
|
|
469
|
+
if (Array.isArray(node)) {
|
|
470
|
+
if (typeof node[0] !== 'string') return ''
|
|
471
|
+
if (seg !== '*' && typeof seg !== 'number') return ''
|
|
472
|
+
scope.push(node[0])
|
|
473
|
+
node = node[1]
|
|
474
|
+
continue
|
|
475
|
+
}
|
|
476
|
+
if (typeof node !== 'object') return ''
|
|
477
|
+
if (!Object.prototype.hasOwnProperty.call(node, seg)) return ''
|
|
478
|
+
node = node[seg]
|
|
479
|
+
}
|
|
480
|
+
return scope.join(' ')
|
|
481
|
+
}
|
|
482
|
+
|
|
438
483
|
// The only branch that reads the page DOM during selection.
|
|
439
484
|
function readElementAttr(selector, doc, attrName) {
|
|
440
485
|
if (!doc || !doc.querySelector) return null
|
|
@@ -453,12 +498,16 @@ function readElementAttr(selector, doc, attrName) {
|
|
|
453
498
|
// same rule shapes as buildTemplateMap; idempotent and author-template-safe.
|
|
454
499
|
export function injectComponents(doc, pageRules) {
|
|
455
500
|
if (!doc || pageRules == null) return
|
|
456
|
-
walk(pageRules)
|
|
501
|
+
walk(pageRules, [])
|
|
457
502
|
|
|
458
|
-
|
|
503
|
+
// The path is carried so componentForScalarRule can resolve a context leaf
|
|
504
|
+
// ("." / "@attr") against its enclosing row, the same way the builder does.
|
|
505
|
+
// Without it a row-level named control resolves to @scalar here, its template
|
|
506
|
+
// is never injected, and buildScalar then throws on the missing template.
|
|
507
|
+
function walk(rule, pathArr) {
|
|
459
508
|
const kind = shapeKindOf(rule)
|
|
460
509
|
if (kind === 'scalar') {
|
|
461
|
-
const key = componentForScalarRule(rule, doc)
|
|
510
|
+
const key = componentForScalarRule(rule, doc, pathArr, pageRules)
|
|
462
511
|
if (ON_DEMAND_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
|
|
463
512
|
return
|
|
464
513
|
}
|
|
@@ -471,15 +520,16 @@ export function injectComponents(doc, pageRules) {
|
|
|
471
520
|
return
|
|
472
521
|
}
|
|
473
522
|
if (kind === 'object') {
|
|
474
|
-
for (const child of Object.
|
|
523
|
+
for (const [k, child] of Object.entries(rule)) walk(child, [...pathArr, k])
|
|
475
524
|
return
|
|
476
525
|
}
|
|
477
526
|
if (kind === 'object-array') {
|
|
478
527
|
const itemShape = rule[1]
|
|
528
|
+
const itemPath = [...pathArr, '*']
|
|
479
529
|
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
480
|
-
for (const child of Object.
|
|
530
|
+
for (const [k, child] of Object.entries(itemShape)) walk(child, [...itemPath, k])
|
|
481
531
|
} else {
|
|
482
|
-
walk(itemShape)
|
|
532
|
+
walk(itemShape, itemPath)
|
|
483
533
|
}
|
|
484
534
|
}
|
|
485
535
|
}
|
|
@@ -451,6 +451,41 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
451
451
|
color: var(--mirk-placeholder-color);
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/* ---------- UPLOADING (spec §9) ----------
|
|
455
|
+
One attribute, [data-hcms-uploading], set on the field for as long as the host
|
|
456
|
+
has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
|
|
457
|
+
its own: the templates keep their exact shape, so a half-finished upload cannot
|
|
458
|
+
leave an orphan node behind in the form. The at-upload-time picture is painted
|
|
459
|
+
on the frame as a background, never assigned to the bound <img>, because that
|
|
460
|
+
img IS the field's value and a commit landing mid-upload would write a
|
|
461
|
+
two-megabyte data URL straight into the live page. */
|
|
462
|
+
.hcms-upload[data-hcms-uploading] .mirk-image__frame {
|
|
463
|
+
background-size: cover;
|
|
464
|
+
background-position: center;
|
|
465
|
+
}
|
|
466
|
+
/* The empty state hides the thumb, so an upload into an empty field would have
|
|
467
|
+
nowhere to show. Uploading reveals it, preview or not. */
|
|
468
|
+
.hcms-upload--image[data-hcms-uploading] .mirk-image__thumb { display: inline-block; }
|
|
469
|
+
.hcms-upload--image[data-hcms-uploading] .mirk-image__upload { display: none; }
|
|
470
|
+
/* No × mid-flight: clearing writes the leaf empty, which says nothing about the
|
|
471
|
+
request still running and reads as a cancel that is not one. */
|
|
472
|
+
.hcms-upload[data-hcms-uploading] .hcms-upload-clear { display: none; }
|
|
473
|
+
|
|
474
|
+
.hcms-upload[data-hcms-uploading] .mirk-image__frame::after,
|
|
475
|
+
.hcms-upload--file[data-hcms-uploading] .mirk-file::after {
|
|
476
|
+
content: "";
|
|
477
|
+
position: absolute;
|
|
478
|
+
left: 0;
|
|
479
|
+
bottom: 0;
|
|
480
|
+
height: 3px;
|
|
481
|
+
width: var(--hcms-upload-progress, 0%);
|
|
482
|
+
background: var(--mirk-bevel-fg);
|
|
483
|
+
transition: width 120ms linear;
|
|
484
|
+
}
|
|
485
|
+
/* The @file row has no frame to hang the bar on, so it becomes the positioning
|
|
486
|
+
context itself. */
|
|
487
|
+
.hcms-upload--file[data-hcms-uploading] .mirk-file { position: relative; }
|
|
488
|
+
|
|
454
489
|
/* clear-× (vendored-inert; data-hcms-action, never .mirk-*__remove). Bare × for
|
|
455
490
|
@file, a pinned corner badge for @image — mirroring .hcms-remove / --card. */
|
|
456
491
|
.hcms-upload-clear {
|
|
@@ -511,6 +546,13 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
511
546
|
.hcms-shell-error[hidden],
|
|
512
547
|
.hcms-error[hidden] { display: none; }
|
|
513
548
|
|
|
549
|
+
/* A note, not a refusal: the file WAS stored, in the page, and this says why it
|
|
550
|
+
is not on the host. Same slot, so there is one place a field ever speaks. */
|
|
551
|
+
.hcms-error--info {
|
|
552
|
+
color: var(--mirk-mark-fg);
|
|
553
|
+
border-color: var(--mirk-input-border);
|
|
554
|
+
}
|
|
555
|
+
|
|
514
556
|
/* ---------- SAVE (sits at the end of the scrolling form, not pinned) ---------- */
|
|
515
557
|
.hcms-shell-footer {
|
|
516
558
|
display: flex;
|
package/src/theme.generated.css
CHANGED
|
@@ -1462,6 +1462,41 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
1462
1462
|
color: var(--mirk-placeholder-color);
|
|
1463
1463
|
}
|
|
1464
1464
|
|
|
1465
|
+
/* ---------- UPLOADING (spec §9) ----------
|
|
1466
|
+
One attribute, [data-hcms-uploading], set on the field for as long as the host
|
|
1467
|
+
has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
|
|
1468
|
+
its own: the templates keep their exact shape, so a half-finished upload cannot
|
|
1469
|
+
leave an orphan node behind in the form. The at-upload-time picture is painted
|
|
1470
|
+
on the frame as a background, never assigned to the bound <img>, because that
|
|
1471
|
+
img IS the field's value and a commit landing mid-upload would write a
|
|
1472
|
+
two-megabyte data URL straight into the live page. */
|
|
1473
|
+
.hcms-upload[data-hcms-uploading] .mirk-image__frame {
|
|
1474
|
+
background-size: cover;
|
|
1475
|
+
background-position: center;
|
|
1476
|
+
}
|
|
1477
|
+
/* The empty state hides the thumb, so an upload into an empty field would have
|
|
1478
|
+
nowhere to show. Uploading reveals it, preview or not. */
|
|
1479
|
+
.hcms-upload--image[data-hcms-uploading] .mirk-image__thumb { display: inline-block; }
|
|
1480
|
+
.hcms-upload--image[data-hcms-uploading] .mirk-image__upload { display: none; }
|
|
1481
|
+
/* No × mid-flight: clearing writes the leaf empty, which says nothing about the
|
|
1482
|
+
request still running and reads as a cancel that is not one. */
|
|
1483
|
+
.hcms-upload[data-hcms-uploading] .hcms-upload-clear { display: none; }
|
|
1484
|
+
|
|
1485
|
+
.hcms-upload[data-hcms-uploading] .mirk-image__frame::after,
|
|
1486
|
+
.hcms-upload--file[data-hcms-uploading] .mirk-file::after {
|
|
1487
|
+
content: "";
|
|
1488
|
+
position: absolute;
|
|
1489
|
+
left: 0;
|
|
1490
|
+
bottom: 0;
|
|
1491
|
+
height: 3px;
|
|
1492
|
+
width: var(--hcms-upload-progress, 0%);
|
|
1493
|
+
background: var(--mirk-bevel-fg);
|
|
1494
|
+
transition: width 120ms linear;
|
|
1495
|
+
}
|
|
1496
|
+
/* The @file row has no frame to hang the bar on, so it becomes the positioning
|
|
1497
|
+
context itself. */
|
|
1498
|
+
.hcms-upload--file[data-hcms-uploading] .mirk-file { position: relative; }
|
|
1499
|
+
|
|
1465
1500
|
/* clear-× (vendored-inert; data-hcms-action, never .mirk-*__remove). Bare × for
|
|
1466
1501
|
@file, a pinned corner badge for @image — mirroring .hcms-remove / --card. */
|
|
1467
1502
|
.hcms-upload-clear {
|
|
@@ -1522,6 +1557,13 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
1522
1557
|
.hcms-shell-error[hidden],
|
|
1523
1558
|
.hcms-error[hidden] { display: none; }
|
|
1524
1559
|
|
|
1560
|
+
/* A note, not a refusal: the file WAS stored, in the page, and this says why it
|
|
1561
|
+
is not on the host. Same slot, so there is one place a field ever speaks. */
|
|
1562
|
+
.hcms-error--info {
|
|
1563
|
+
color: var(--mirk-mark-fg);
|
|
1564
|
+
border-color: var(--mirk-input-border);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1525
1567
|
/* ---------- SAVE (sits at the end of the scrolling form, not pinned) ---------- */
|
|
1526
1568
|
.hcms-shell-footer {
|
|
1527
1569
|
display: flex;
|