@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/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 override = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
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(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
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(scalarSelectorOf(rule, at), doc, 'data-hcms-options')
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(scalarSelectorOf(rule, at), doc, 'data-hcms-crop')
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
- function walk(rule) {
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.values(rule)) walk(child)
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.values(itemShape)) walk(child)
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;
@@ -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;