@optionfactory/fml 9.0.0-rc15 → 9.0.0-rc16

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.
Files changed (43) hide show
  1. package/README.md +16 -88
  2. package/dist/client-errors.iife.js +17 -26
  3. package/dist/client-errors.iife.js.map +1 -1
  4. package/dist/client-errors.iife.min.js.map +1 -1
  5. package/dist/custom-elements.json +44 -30
  6. package/dist/fml.css +20 -17
  7. package/dist/fml.css.map +1 -1
  8. package/dist/fml.iife.js +3386 -2037
  9. package/dist/fml.iife.js.map +1 -1
  10. package/dist/fml.iife.min.js +1 -1
  11. package/dist/fml.iife.min.js.map +1 -1
  12. package/dist/ftl.d.mts +395 -224
  13. package/dist/ftl.iife.js +400 -330
  14. package/dist/ftl.iife.js.map +1 -1
  15. package/dist/ftl.iife.min.js +1 -1
  16. package/dist/ftl.iife.min.js.map +1 -1
  17. package/dist/ftl.min.mjs +1 -1
  18. package/dist/ftl.min.mjs.map +1 -1
  19. package/dist/ftl.mjs +400 -330
  20. package/dist/ftl.mjs.map +1 -1
  21. package/dist/ful.css +20 -17
  22. package/dist/ful.css.map +1 -1
  23. package/dist/ful.d.mts +2583 -690
  24. package/dist/ful.iife.js +2850 -1542
  25. package/dist/ful.iife.js.map +1 -1
  26. package/dist/ful.iife.min.js +1 -1
  27. package/dist/ful.iife.min.js.map +1 -1
  28. package/dist/ful.min.mjs +1 -1
  29. package/dist/ful.min.mjs.map +1 -1
  30. package/dist/ful.mjs +2850 -1542
  31. package/dist/ful.mjs.map +1 -1
  32. package/dist/httpc.d.mts +139 -121
  33. package/dist/httpc.iife.js +137 -166
  34. package/dist/httpc.iife.js.map +1 -1
  35. package/dist/httpc.iife.min.js +1 -1
  36. package/dist/httpc.iife.min.js.map +1 -1
  37. package/dist/httpc.min.mjs +1 -1
  38. package/dist/httpc.min.mjs.map +1 -1
  39. package/dist/httpc.mjs +137 -166
  40. package/dist/httpc.mjs.map +1 -1
  41. package/dist/vscode.html-custom-data.json +26 -18
  42. package/dist/web-types.json +51 -35
  43. package/package.json +1 -1
package/dist/ftl.mjs CHANGED
@@ -1,10 +1,6 @@
1
1
  /**
2
- * A Map bounded by entry count, evicting in insertion order: not an LRU (a hit
3
- * does not refresh an entry), just a cap keeping unbounded key spaces (parsed
4
- * expressions, compiled masks, formatter instances) from growing forever. The
5
- * capacity is sized so eviction never happens on a sane page: hitting it means
6
- * dynamically generated keys, where FIFO's worst case (evicting a hot entry)
7
- * costs one recomputation.
2
+ * A Map bounded by entry count, evicting the oldest entry when full. A hit does
3
+ * not refresh an entry: eviction follows insertion order, not use.
8
4
  */
9
5
  class BoundedCache {
10
6
  #max;
@@ -33,6 +29,7 @@ class BoundedCache {
33
29
  this.#entries.set(key, value);
34
30
  return value;
35
31
  }
32
+ /** @returns {number} the number of entries held */
36
33
  get size() {
37
34
  return this.#entries.size;
38
35
  }
@@ -75,10 +72,6 @@ const nodes = {
75
72
  //
76
73
  // https://peggyjs.org/
77
74
 
78
- //keyword classification lives here, not in the grammar's ordered choice: the
79
- //identifier rule consumes the whole word and the action decides, so no keyword
80
- //can grab a longer identifier's prefix. A Map, not a plain object: `in` on the
81
- //latter would classify inherited names such as `constructor` as keywords
82
75
  const keywords = new Map([
83
76
  ['true', true],
84
77
  ['false', false],
@@ -3250,10 +3243,10 @@ function peg$parse(input, options) {
3250
3243
  /**
3251
3244
  * The one lookup against a data stack: the innermost overlay carrying the name
3252
3245
  * wins, `self` is the innermost overlay itself, and a function overlay counts
3253
- * like an object one. Every lookup that resolves a name goes through this, so the
3254
- * imperative facades cannot drift from what a template sees.
3246
+ * like an object one.
3255
3247
  * @param {any[]} dataStack
3256
3248
  * @param {string|symbol} prop
3249
+ * @returns {any} undefined when no overlay carries the name
3257
3250
  */
3258
3251
  const resolveInStack = (dataStack, prop) => {
3259
3252
  if (prop === 'self') {
@@ -3317,8 +3310,7 @@ class EvaluatingVisitor {
3317
3310
  return this.visit(node.lhs) || this.visit(node.rhs);
3318
3311
  }
3319
3312
  [nodes.nullc](node) {
3320
- const lhs = this.visit(node.lhs);
3321
- return lhs !== null && lhs !== undefined ? lhs : this.visit(node.rhs);
3313
+ return this.visit(node.lhs) ?? this.visit(node.rhs);
3322
3314
  }
3323
3315
  [nodes.not](node) {
3324
3316
  return !this.visit(node.expr);
@@ -3385,8 +3377,7 @@ class EvaluatingVisitor {
3385
3377
  return this.visit(node.cond) ? this.visit(node.ifTrue) : this.visit(node.ifFalse);
3386
3378
  }
3387
3379
  [nodes.elv](node) {
3388
- const cond = this.visit(node.cond);
3389
- return cond ? cond : this.visit(node.ifFalse);
3380
+ return this.visit(node.cond) || this.visit(node.ifFalse);
3390
3381
  }
3391
3382
  [nodes.access](node) {
3392
3383
  let prev;
@@ -3452,16 +3443,23 @@ class EvaluatingVisitor {
3452
3443
  * spliced into the expression text.
3453
3444
  */
3454
3445
  class Expressions {
3446
+ /** Parses the whole text as one expression: `user.name`. */
3455
3447
  static MODE_EXPRESSION = Symbol('MODE_EXPRESSION');
3448
+ /**
3449
+ * Parses text with interpolations, `Hello {{ user.name }}`, evaluating to
3450
+ * its parts: `{ type, value }` for each literal run and each `{{ }}` (text),
3451
+ * `{{{ }}}` (html) or `{{{{ }}}}` (node) interpolation, in order.
3452
+ */
3456
3453
  static MODE_TEMPLATED = Symbol('MODE_TEMPLATED');
3457
3454
 
3458
3455
  static #astCache = new BoundedCache(1000);
3459
3456
 
3460
3457
  /**
3461
- * Parses an expression.
3458
+ * Parses an expression, caching the ast by mode and text.
3462
3459
  * @param {string} expression
3463
- * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode]
3464
- * @returns the ast
3460
+ * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode] MODE_EXPRESSION when omitted
3461
+ * @returns {any} the ast
3462
+ * @throws a peggy syntax error when the text does not parse
3465
3463
  */
3466
3464
  static parse(expression, mode) {
3467
3465
  const key = mode?.toString() + expression;
@@ -3472,12 +3470,12 @@ class Expressions {
3472
3470
  );
3473
3471
  }
3474
3472
  /**
3475
- * Evaluates an expression.
3473
+ * Evaluates a parsed ast against the modules and the data stack.
3476
3474
  * @param {{[k: string]: any } | null | undefined } modules
3477
- * @param {any[]} dataStack
3478
- * @param {any} ast
3479
- * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode]
3480
- * @returns the result
3475
+ * @param {any[]} dataStack innermost overlay last
3476
+ * @param {any} ast what `parse` answered
3477
+ * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode] the mode it was parsed in
3478
+ * @returns {any} the value, or the list of parts in MODE_TEMPLATED
3481
3479
  */
3482
3480
  static evaluate(modules, dataStack, ast, mode) {
3483
3481
  return new EvaluatingVisitor(modules, dataStack).visitRoot(ast, mode === Expressions.MODE_TEMPLATED);
@@ -3485,10 +3483,10 @@ class Expressions {
3485
3483
  /**
3486
3484
  * Parses and evaluates an expression.
3487
3485
  * @param {{ [x: string]: any; } | null | undefined} modules
3488
- * @param {any[]} dataStack
3486
+ * @param {any[]} dataStack innermost overlay last
3489
3487
  * @param {string} expression
3490
- * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode]
3491
- * @returns the result
3488
+ * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode] MODE_EXPRESSION when omitted
3489
+ * @returns {any} the value, or the list of parts in MODE_TEMPLATED
3492
3490
  */
3493
3491
  static interpret(modules, dataStack, expression, mode) {
3494
3492
  return Expressions.evaluate(modules, dataStack, Expressions.parse(expression, mode), mode);
@@ -3504,14 +3502,31 @@ class Expressions {
3504
3502
  class ExpressionEvaluator {
3505
3503
  #modules;
3506
3504
  #dataStack;
3505
+ /**
3506
+ * @param {{ [k: string]: any } | null | undefined} modules
3507
+ * @param {any[]} dataStack innermost overlay last
3508
+ */
3507
3509
  constructor(modules, dataStack) {
3508
3510
  this.#modules = modules;
3509
3511
  this.#dataStack = dataStack;
3510
3512
  }
3513
+ /**
3514
+ * A new evaluator with one more module: `#name:fn()` resolves into it, and an
3515
+ * empty name merges a whole map whose functions resolve bare, as `#fn()`.
3516
+ * @param {string} name
3517
+ * @param {object} value
3518
+ * @returns {ExpressionEvaluator}
3519
+ */
3511
3520
  withModule(name, value) {
3512
3521
  const module = name ? { [name]: value } : value;
3513
3522
  return new ExpressionEvaluator({ ...this.#modules, ...module }, this.#dataStack);
3514
3523
  }
3524
+ /**
3525
+ * A new evaluator with the data pushed as the innermost overlays, the last
3526
+ * one winning a shared name.
3527
+ * @param {...any} data
3528
+ * @returns {ExpressionEvaluator}
3529
+ */
3515
3530
  withOverlay(...data) {
3516
3531
  return new ExpressionEvaluator(
3517
3532
  this.#modules,
@@ -3522,26 +3537,36 @@ class ExpressionEvaluator {
3522
3537
  * Resolves a name against the data stack, with no parse round trip: the
3523
3538
  * lookup a template's bare identifier makes, for an imperative caller.
3524
3539
  * @param {string} name
3540
+ * @returns {any} undefined when no overlay carries the name
3525
3541
  */
3526
3542
  resolve(name) {
3527
3543
  return resolveInStack(this.#dataStack, name);
3528
3544
  }
3529
- /** Evaluates an expression against this scope. */
3545
+ /**
3546
+ * Evaluates an expression against this scope.
3547
+ * @param {string} expression
3548
+ * @returns {any}
3549
+ */
3530
3550
  evaluateExpression(expression) {
3531
3551
  return Expressions.interpret(this.#modules, this.#dataStack, expression, Expressions.MODE_EXPRESSION);
3532
3552
  }
3533
- /** Evaluates a templated text, the `{{ }}` form, against this scope. */
3553
+ /**
3554
+ * Evaluates a templated text, the `{{ }}` form, against this scope.
3555
+ * @param {string} text
3556
+ * @returns {{ type: symbol, value: any }[]} its parts, as MODE_TEMPLATED describes
3557
+ */
3534
3558
  evaluateTemplated(text) {
3535
3559
  return Expressions.interpret(this.#modules, this.#dataStack, text, Expressions.MODE_TEMPLATED);
3536
3560
  }
3537
3561
  }
3538
3562
 
3539
- /** Creates and inspects the DocumentFragments a Template renders into. */
3563
+ /** Creates, serializes and inspects DocumentFragments. */
3540
3564
  class Fragments {
3541
3565
  /**
3542
- * Creates a DocumentFragment from an string.
3543
- * @param {...string} html
3544
- * @returns {DocumentFragment} the fragment
3566
+ * Parses html into a DocumentFragment adopted by the document, each piece
3567
+ * trimmed and the pieces joined.
3568
+ * @param {...string} html
3569
+ * @returns {DocumentFragment}
3545
3570
  */
3546
3571
  static fromHtml(...html) {
3547
3572
  const el = document.createElement('template');
@@ -3549,11 +3574,11 @@ class Fragments {
3549
3574
  return document.adoptNode(el.content);
3550
3575
  }
3551
3576
  /**
3552
- * Creates a string representation (HTML) of a DocumentFragment, consuming it:
3553
- * the nodes are moved out, not copied, and the fragment is left empty. Pass
3577
+ * Serializes a fragment, or a single node, to html, consuming it: the nodes
3578
+ * are moved out, not copied, and a fragment is left empty. Pass
3554
3579
  * `fragment.cloneNode(true)` to keep the original usable.
3555
- * @param {DocumentFragment} fragment
3556
- * @returns {string} the html
3580
+ * @param {DocumentFragment|Node} fragment
3581
+ * @returns {string}
3557
3582
  */
3558
3583
  static toHtml(fragment) {
3559
3584
  var el = document.createElement('template');
@@ -3561,17 +3586,17 @@ class Fragments {
3561
3586
  return el.innerHTML;
3562
3587
  }
3563
3588
  /**
3564
- * Checks if a fragment contains only blank text nodes
3589
+ * Whether a fragment holds no element and no text but whitespace.
3565
3590
  * @param {DocumentFragment} fragment
3566
- * @returns {boolean} true if the fragment is blank
3591
+ * @returns {boolean}
3567
3592
  */
3568
3593
  static isBlank(fragment) {
3569
3594
  return fragment.childElementCount === 0 && fragment.textContent.trim() === '';
3570
3595
  }
3571
3596
  /**
3572
- * Creates a DocumentFragment from nodes.
3573
- * @param {...Node} nodes
3574
- * @returns {DocumentFragment} the fragment
3597
+ * Moves the nodes, in order, into a new DocumentFragment.
3598
+ * @param {...Node} nodes
3599
+ * @returns {DocumentFragment}
3575
3600
  */
3576
3601
  static from(...nodes) {
3577
3602
  const fragment = new DocumentFragment();
@@ -3581,9 +3606,10 @@ class Fragments {
3581
3606
  return fragment;
3582
3607
  }
3583
3608
  /**
3584
- * Creates a DocumentFragment from childNodes of an element.
3609
+ * Moves the children of a node into a new DocumentFragment, leaving the
3610
+ * node empty.
3585
3611
  * @param {Node} el
3586
- * @returns {DocumentFragment} the fragment
3612
+ * @returns {DocumentFragment}
3587
3613
  */
3588
3614
  static fromChildNodes(el) {
3589
3615
  const fragment = new DocumentFragment();
@@ -3594,23 +3620,23 @@ class Fragments {
3594
3620
  }
3595
3621
  }
3596
3622
 
3597
- /** Attribute reads and writes where a nullish value removes the attribute instead of setting it to the string 'null'. */
3623
+ /** Attribute helpers: unique ids, defaults, prefixed forwarding, and writes where a nullish value removes the attribute. */
3598
3624
  class Attributes {
3599
3625
  static id = 0;
3600
3626
  /**
3601
- * Creates a unique id with the given prefix.
3627
+ * A page-unique id under the prefix, as `prefix-n`.
3602
3628
  * @param {string} prefix
3603
- * @returns
3629
+ * @returns {string}
3604
3630
  */
3605
3631
  static uid(prefix) {
3606
3632
  return `${prefix}-${++Attributes.id}`;
3607
3633
  }
3608
3634
  /**
3609
- * Sets an attribute if not present.
3635
+ * Sets an attribute when the element does not carry it.
3610
3636
  * @param {Element} el
3611
3637
  * @param {string} k
3612
3638
  * @param {string} v
3613
- * @returns
3639
+ * @returns {string|null} the attribute's value afterwards
3614
3640
  */
3615
3641
  static defaultValue(el, k, v) {
3616
3642
  if (!el.hasAttribute(k)) {
@@ -3619,7 +3645,9 @@ class Attributes {
3619
3645
  return el.getAttribute(k);
3620
3646
  }
3621
3647
  /**
3622
- * Forwards prefixed attributes from an element to another (removing the prefix).
3648
+ * Copies every attribute whose name starts with the prefix onto another
3649
+ * element, under the name without the prefix. `<prefix>class` adds its
3650
+ * classes to the target's instead of replacing them.
3623
3651
  * @param {string} prefix
3624
3652
  * @param {Element} from
3625
3653
  * @param {Element} to
@@ -3642,7 +3670,7 @@ class Attributes {
3642
3670
  });
3643
3671
  }
3644
3672
  /**
3645
- * Sets the value of an attribute. nullish values remove the attribute.
3673
+ * Sets an attribute, or removes it when the value is nullish.
3646
3674
  * @param {Element} el
3647
3675
  * @param {string} attr
3648
3676
  * @param {string | null | undefined} value
@@ -3664,13 +3692,14 @@ class Attributes {
3664
3692
  */
3665
3693
  class LightSlots {
3666
3694
  /**
3667
- * Extracts light slots from an element. For non default slots in a template tag, the content is extracted.
3695
+ * Takes an element's children apart into slots, leaving the element empty.
3696
+ * A named child is removed and contributes what `slotFromNode` answers;
3697
+ * children sharing a name are gathered in order. A child with an empty
3698
+ * `slot` belongs to the default slot, in document order among the rest.
3668
3699
  * @param {Element} el
3669
- * @returns the slots
3700
+ * @returns {Record<string, DocumentFragment>} the slots by name, `default` always present
3670
3701
  */
3671
3702
  static from(el) {
3672
- //the platform reads slot="" as the default slot: such children stay in
3673
- //the document order of the default content, their claim stripped like any named one
3674
3703
  for (const child of el.children) {
3675
3704
  if (child.matches('[slot=""]')) {
3676
3705
  child.removeAttribute('slot');
@@ -3696,6 +3725,12 @@ class LightSlots {
3696
3725
  }
3697
3726
  return slots;
3698
3727
  }
3728
+ /**
3729
+ * What a slotted node contributes: a template's content, the parsed markup
3730
+ * of a `<script type="text/html">`, or the node itself.
3731
+ * @param {Element} el
3732
+ * @returns {Element|DocumentFragment}
3733
+ */
3699
3734
  static slotFromNode(el) {
3700
3735
  if (el instanceof HTMLTemplateElement) {
3701
3736
  return document.adoptNode(el.content);
@@ -3707,12 +3742,13 @@ class LightSlots {
3707
3742
  }
3708
3743
  }
3709
3744
 
3710
- /** Waits for the parser and for the document, for elements that upgrade before their own markup is complete. */
3745
+ /** Waits on the parser and the document, and queries an element's own children. */
3711
3746
  class Nodes {
3712
3747
  /**
3713
- * Checks if an element is already parsed.
3748
+ * Whether the parser has moved past the element: the element, or one of
3749
+ * its ancestors, has a next sibling.
3714
3750
  * @param {Element} el
3715
- * @returns
3751
+ * @returns {boolean}
3716
3752
  */
3717
3753
  static isParsed(el) {
3718
3754
  for (let c = /** @type {Node | null} */ (el); c; c = c.parentNode) {
@@ -3724,13 +3760,12 @@ class Nodes {
3724
3760
  }
3725
3761
 
3726
3762
  /**
3727
- * Waits for the document's DOMContentLoaded, resolving immediately when
3728
- * that moment already passed. 'interactive' alone cannot tell a document
3729
- * still waiting for deferred scripts (the event comes, however late) from
3730
- * a module imported past it (the event is gone): the two events order
3731
- * themselves, DOMContentLoaded always precedes load, so racing the two
3732
- * resolves with DCL whenever it is still coming and with load otherwise,
3733
- * never early.
3763
+ * Waits for the document's DOMContentLoaded. Resolves at once when that
3764
+ * moment already passed, and for a document with no view, which receives
3765
+ * no events. Never resolves early for a document still running its
3766
+ * deferred scripts.
3767
+ * @param {Document} doc
3768
+ * @returns {Promise<void>}
3734
3769
  */
3735
3770
  static waitDomContentLoaded(doc) {
3736
3771
  if (doc.readyState === 'loading') {
@@ -3743,7 +3778,6 @@ class Nodes {
3743
3778
  }
3744
3779
  const win = doc.defaultView;
3745
3780
  if (win === null) {
3746
- //a viewless document can receive no event at all
3747
3781
  return Promise.resolve();
3748
3782
  }
3749
3783
  return new Promise((resolve) => {
@@ -3763,18 +3797,8 @@ class Nodes {
3763
3797
  * which is the parser having moved past this subtree. The document's
3764
3798
  * DOMContentLoaded is the deadline. Resolves immediately for an element
3765
3799
  * that is already parsed.
3766
- *
3767
- * Every ancestor is watched, not only the parent, because that is what the
3768
- * predicate reads: an element last among its siblings becomes parsed when
3769
- * an ancestor gains one, and a childList observer on the parent never sees
3770
- * that. Formatted markup usually hides the difference, the whitespace
3771
- * before a closing tag being a text node the parent does gain, so the gap
3772
- * shows on whitespace-free markup, where the wait fell back to the
3773
- * deadline. One observer takes many targets, so the cost is one observe()
3774
- * per level, and it is disconnected at the first checkpoint past the
3775
- * element either way.
3776
- * @param {any} el
3777
- * @returns {Promise<any>}
3800
+ * @param {Element} el
3801
+ * @returns {Promise<Element>} the element
3778
3802
  */
3779
3803
  static waitParsed(el) {
3780
3804
  if (Nodes.isParsed(el)) {
@@ -3799,10 +3823,10 @@ class Nodes {
3799
3823
  }
3800
3824
 
3801
3825
  /**
3802
- * Returns the first child of the element element (if exists) matching the selector.
3803
- * @param {Element} el
3826
+ * The first child element matching the selector, descendants excluded.
3827
+ * @param {Element|DocumentFragment} el
3804
3828
  * @param {string} selector
3805
- * @returns
3829
+ * @returns {Element|null}
3806
3830
  */
3807
3831
  static queryChildren(el, selector) {
3808
3832
  for (const c of el.children) {
@@ -3813,10 +3837,10 @@ class Nodes {
3813
3837
  return null;
3814
3838
  }
3815
3839
  /**
3816
- * Returns all children of the element matching the selector.
3817
- * @param {Element} el
3840
+ * Every child element matching the selector, descendants excluded.
3841
+ * @param {Element|DocumentFragment} el
3818
3842
  * @param {string} selector
3819
- * @returns
3843
+ * @returns {Element[]}
3820
3844
  */
3821
3845
  static queryChildrenAll(el, selector) {
3822
3846
  const r = [];
@@ -3873,9 +3897,11 @@ class NodeOperations {
3873
3897
 
3874
3898
  /** The `data-tpl-*` commands, each taking the node it is written on and the scope it evaluates in. */
3875
3899
  class CommandsHandler {
3876
- //the order is the semantics: each command sees the node as the ones before
3877
- //it left it, so tplIf gates before tplWith/tplEach push their overlay and
3878
- //tplWhen gates after (inside the sub-render, where the overlay is in scope)
3900
+ /**
3901
+ * The order the commands run in on one element, each seeing the node as the
3902
+ * ones before left it: tplIf gates before tplWith and tplEach push their
3903
+ * overlay, tplWhen gates after, inside the scope that overlay opened.
3904
+ */
3879
3905
  static ORDERED_COMMANDS = [
3880
3906
  'tplIf',
3881
3907
  'tplWith',
@@ -3886,8 +3912,7 @@ class CommandsHandler {
3886
3912
  'tplRemove',
3887
3913
  'tplVerbatim',
3888
3914
  ];
3889
- //same body as tplWhen: the two differ only in ORDERED_COMMANDS position,
3890
- //if evaluates in the outer scope, before with/each
3915
+ /** Removes the node when the expression is falsy, evaluated before tplWith and tplEach open their overlay. */
3891
3916
  static tplIf(node, expression, ops, evaluator) {
3892
3917
  const accept = evaluator.evaluateExpression(expression);
3893
3918
  if (!accept) {
@@ -3902,49 +3927,36 @@ class CommandsHandler {
3902
3927
  .render();
3903
3928
  ops.replace(node, newNode);
3904
3929
  }
3930
+ /**
3931
+ * Renders the node once per entry, in place of the node. A Map iterates as
3932
+ * `{ key, value }` entries in its own order, a plain object as `{ key, value }`
3933
+ * entries in `Object.entries` order, and any other iterable as itself; a
3934
+ * value that is none of these throws. Each render sees its entry under
3935
+ * `data-tpl-var`, or as the whole overlay without one. `data-tpl-stat` names
3936
+ * an overlay beneath the entry carrying `index`, `count`, `size`, `first`,
3937
+ * `last`, `even` and `odd`, so an entry property of the same name wins; the
3938
+ * size, and so `last`, is null for a collection that knows neither its
3939
+ * `length` nor its `size`, which is never consumed to learn it.
3940
+ */
3905
3941
  static tplEach(node, expression, ops, evaluator) {
3906
3942
  const varName = ops.popData(node, 'tplVar');
3907
3943
  const statName = ops.popData(node, 'tplStat');
3908
3944
  const template = new Template(node, evaluator);
3909
3945
  const evaluated = evaluator.evaluateExpression(expression);
3910
- //keyed collections iterate as {key, value} entries: a Map in its own
3911
- //order, a non-iterable plain dict in Object.entries order. Plain alone:
3912
- //a class instance or a response wrapper standing where an array was
3913
- //expected still fails loudly below, and any other iterable (a Set, a
3914
- //generator, an entries() iterator) iterates as itself
3915
3946
  const proto = evaluated === null || typeof evaluated !== 'object' ? undefined : Object.getPrototypeOf(evaluated);
3916
- const keyed =
3917
- evaluated instanceof Map
3918
- ? [...evaluated]
3919
- : !evaluated?.[Symbol.iterator] && (proto === Object.prototype || proto === null)
3920
- ? Object.entries(evaluated)
3921
- : null;
3947
+ const plain = !evaluated?.[Symbol.iterator] && (proto === Object.prototype || proto === null);
3948
+ const dict = plain ? Object.entries(evaluated) : null;
3949
+ const keyed = evaluated instanceof Map ? [...evaluated] : dict;
3922
3950
  const entries = keyed === null ? evaluated : keyed.map(([key, value]) => ({ key, value }));
3923
3951
  if (!entries?.[Symbol.iterator]) {
3924
3952
  throw new Error(`Expected an iterable got '${evaluated}'`);
3925
3953
  }
3926
- if (!statName) {
3927
- for (const v of entries) {
3928
- ops.prepend(node, template.withOverlay(varName ? { [varName]: v } : v).render());
3929
- }
3930
- ops.remove(node);
3931
- return;
3932
- }
3933
- //the stat rides grouped under its declared name, overlaid below the
3934
- //item, so data can never collide with its fields and an item property
3935
- //sharing the stat's very name wins, as data always does. The size is
3936
- //read where the collection already knows it (arrays and keyed entries
3937
- //by length, sets and the like by size): an arbitrary iterator is never
3938
- //consumed to learn it, so its size and last read null, the unknown
3939
- const size = Array.isArray(entries)
3940
- ? entries.length
3941
- : typeof entries.length === 'number'
3942
- ? entries.length
3943
- : typeof entries.size === 'number'
3944
- ? entries.size
3945
- : null;
3954
+ const measured = statName ? entries : null;
3955
+ const counted = typeof measured?.length === 'number' ? measured.length : measured?.size;
3956
+ const size = typeof counted === 'number' ? counted : null;
3946
3957
  let index = 0;
3947
3958
  for (const v of entries) {
3959
+ const item = varName ? { [varName]: v } : v;
3948
3960
  const stat = {
3949
3961
  index,
3950
3962
  count: index + 1,
@@ -3954,16 +3966,13 @@ class CommandsHandler {
3954
3966
  even: index % 2 === 0,
3955
3967
  odd: index % 2 === 1,
3956
3968
  };
3957
- ops.prepend(
3958
- node,
3959
- template.withOverlay({ [statName]: stat }, varName ? { [varName]: v } : v).render(),
3960
- );
3969
+ const layers = statName ? [{ [statName]: stat }, item] : [item];
3970
+ ops.prepend(node, template.withOverlay(...layers).render());
3961
3971
  ++index;
3962
3972
  }
3963
3973
  ops.remove(node);
3964
3974
  }
3965
- //same body as tplIf: the two differ only in ORDERED_COMMANDS position,
3966
- //when evaluates after with/each, in the scope their overlay opened
3975
+ /** Removes the node when the expression is falsy, evaluated in the overlay tplWith or tplEach opened. */
3967
3976
  static tplWhen(node, expression, ops, evaluator) {
3968
3977
  const accept = evaluator.evaluateExpression(expression);
3969
3978
  if (!accept) {
@@ -3977,12 +3986,8 @@ class CommandsHandler {
3977
3986
  static tplRemove(node, value, ops, evaluator) {
3978
3987
  switch (value.toLowerCase()) {
3979
3988
  case 'tag': {
3980
- const fragment = new DocumentFragment();
3981
- while (node.firstChild) {
3982
- fragment.appendChild(node.firstChild);
3983
- }
3989
+ const fragment = Fragments.fromChildNodes(node);
3984
3990
  if ('tplVerbatim' in node.dataset) {
3985
- //we are removing the parent element so we have to handle the lower priority commands
3986
3991
  ops.popData(node, 'tplVerbatim');
3987
3992
  ops.replace(node, fragment);
3988
3993
  } else {
@@ -4045,12 +4050,10 @@ class CommandsHandler {
4045
4050
  }
4046
4051
  }
4047
4052
 
4048
- // Module-isolated string cache for dataset-to-attribute conversions
4049
4053
  const attributeCache = new BoundedCache(1000);
4050
4054
 
4051
4055
  /**
4052
- * Converts a tpl camelCase dataset key into a kebab-case attribute name.
4053
- * Uses a cache lookup to avoid repetitive regex/string splitting.
4056
+ * The attribute a `tpl` dataset key targets: `tplAriaLabel` is `aria-label`.
4054
4057
  * @param {string} dataSetKey
4055
4058
  * @returns {string}
4056
4059
  */
@@ -4072,38 +4075,40 @@ function toAttr(dataSetKey) {
4072
4075
  */
4073
4076
  class Template {
4074
4077
  /**
4075
- * Creates a template from a string.
4078
+ * Creates a template from html.
4076
4079
  * @param {string} html
4077
4080
  * @param {{ [k: string] : any }?} [modules]
4078
- * @param {...*} data
4079
- * @returns the template
4081
+ * @param {...*} data the data stack, innermost last
4082
+ * @returns {Template}
4080
4083
  */
4081
4084
  static fromHtml(html, modules, ...data) {
4082
4085
  return new Template(Fragments.fromHtml(html), new ExpressionEvaluator(modules, data));
4083
4086
  }
4084
4087
 
4085
4088
  /**
4086
- * Creates a template from the content of the first template element matching the selector.
4089
+ * Creates a template from the content of the first element matching the
4090
+ * selector, which is adopted: the template element is left empty.
4087
4091
  * @param {string} selector for an HTMLTemplateElement
4088
4092
  * @param {{ [k: string] : any }?} [modules]
4089
- * @param {...*} data
4090
- * @returns the template
4093
+ * @param {...*} data the data stack, innermost last
4094
+ * @returns {Template}
4095
+ * @throws when the selector matches no template element
4091
4096
  */
4092
4097
  static fromSelector(selector, modules, ...data) {
4093
4098
  const templateEl = document.querySelector(selector);
4094
4099
  if (!(templateEl instanceof HTMLTemplateElement)) {
4095
4100
  throw new Error('template selector does not match any template tag');
4096
4101
  }
4097
- const fragment = document.adoptNode(templateEl.content);
4098
- return new Template(fragment, new ExpressionEvaluator(modules, data));
4102
+ return Template.fromTemplate(templateEl, modules, ...data);
4099
4103
  }
4100
4104
 
4101
4105
  /**
4102
- * Creates a template from the content of an HTMLTemplateElement.
4106
+ * Creates a template from the content of a template element, which is
4107
+ * adopted: the template element is left empty.
4103
4108
  * @param {HTMLTemplateElement} templateEl
4104
4109
  * @param {{ [k: string] : any }?} [modules]
4105
- * @param {...*} data
4106
- * @returns the template
4110
+ * @param {...*} data the data stack, innermost last
4111
+ * @returns {Template}
4107
4112
  */
4108
4113
  static fromTemplate(templateEl, modules, ...data) {
4109
4114
  const fragment = document.adoptNode(templateEl.content);
@@ -4111,11 +4116,12 @@ class Template {
4111
4116
  }
4112
4117
 
4113
4118
  /**
4114
- * Creates a template from a DocumentFragment.
4119
+ * Creates a template over a fragment, which is imported afresh on every
4120
+ * render and never modified.
4115
4121
  * @param {DocumentFragment} fragment
4116
4122
  * @param { { [k: string] : any }? } [modules]
4117
- * @param {...*} data
4118
- * @returns the template
4123
+ * @param {...*} data the data stack, innermost last
4124
+ * @returns {Template}
4119
4125
  */
4120
4126
  static fromFragment(fragment, modules, ...data) {
4121
4127
  return new Template(fragment, new ExpressionEvaluator(modules, data));
@@ -4135,28 +4141,32 @@ class Template {
4135
4141
  * Creates a new Template rendering in another scope: the one way to rebind
4136
4142
  * a compiled template to a registry's modules and data.
4137
4143
  * @param {ExpressionEvaluator} evaluator
4144
+ * @returns {Template}
4138
4145
  */
4139
4146
  withEvaluator(evaluator) {
4140
4147
  return new Template(this.#fragment, evaluator);
4141
4148
  }
4142
4149
  /**
4143
- * Creates a new Template replacing the fragment.
4150
+ * Creates a new Template over another fragment, in the same scope.
4144
4151
  * @param {DocumentFragment} fragment
4152
+ * @returns {Template}
4145
4153
  */
4146
4154
  withFragment(fragment) {
4147
4155
  return new Template(fragment, this.#evaluator);
4148
4156
  }
4149
4157
  /**
4150
- * Creates a new Template with a new module added.
4158
+ * Creates a new Template with a module added, as `ExpressionEvaluator.withModule` does.
4151
4159
  * @param {string?} name
4152
4160
  * @param {{[k: string]: any}} value
4161
+ * @returns {Template}
4153
4162
  */
4154
4163
  withModule(name, value) {
4155
4164
  return new Template(this.#fragment, this.#evaluator.withModule(name, value));
4156
4165
  }
4157
4166
  /**
4158
- * Creates a new Template with new a data overlay added to the stack.
4167
+ * Creates a new Template with the data pushed as the innermost overlays.
4159
4168
  * @param {...*} data
4169
+ * @returns {Template}
4160
4170
  */
4161
4171
  withOverlay(...data) {
4162
4172
  return new Template(this.#fragment, this.#evaluator.withOverlay(...data));
@@ -4165,7 +4175,7 @@ class Template {
4165
4175
  * Evaluates an expression in this template's scope, widened by the overlays.
4166
4176
  * @param {string} expression
4167
4177
  * @param {...*} data
4168
- * @returns the evaluated expression result
4178
+ * @returns {any}
4169
4179
  */
4170
4180
  evaluateExpression(expression, ...data) {
4171
4181
  return this.#evaluator.withOverlay(...data).evaluateExpression(expression);
@@ -4174,33 +4184,29 @@ class Template {
4174
4184
  * Evaluates a templated text, the `{{ }}` form, in this template's scope.
4175
4185
  * @param {string} text
4176
4186
  * @param {...*} data
4177
- * @returns the parts the text evaluates to
4187
+ * @returns {{ type: symbol, value: any }[]} the parts, as `Expressions.MODE_TEMPLATED` describes
4178
4188
  */
4179
4189
  evaluateTemplated(text, ...data) {
4180
4190
  return this.#evaluator.withOverlay(...data).evaluateTemplated(text);
4181
4191
  }
4182
4192
  /**
4183
- * Returns an expression evaluator with bound modules and dataStack.
4193
+ * The scope this template renders in.
4194
+ * @returns {ExpressionEvaluator}
4184
4195
  */
4185
4196
  evaluator() {
4186
4197
  return this.#evaluator;
4187
4198
  }
4188
4199
  /**
4189
- * Renders the template.
4190
- * @returns a DocumentFragment
4200
+ * Renders the template into a new fragment, applying the `data-tpl-*`
4201
+ * commands and bindings and the `{{ }}` interpolations.
4202
+ * @returns {DocumentFragment}
4203
+ * @throws {RenderError} naming the node and the directive that failed
4191
4204
  */
4192
4205
  render() {
4193
4206
  try {
4194
4207
  const ops = new NodeOperations();
4195
4208
  const imported = document.importNode(this.#fragment, true);
4196
- const fragment =
4197
- imported.nodeType === Node.DOCUMENT_FRAGMENT_NODE
4198
- ? imported
4199
- : (() => {
4200
- const d = new DocumentFragment();
4201
- d.appendChild(imported);
4202
- return d;
4203
- })();
4209
+ const fragment = imported.nodeType === Node.DOCUMENT_FRAGMENT_NODE ? imported : Fragments.from(imported);
4204
4210
  const iterator = document.createNodeIterator(
4205
4211
  fragment,
4206
4212
  NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
@@ -4226,8 +4232,6 @@ class Template {
4226
4232
  try {
4227
4233
  CommandsHandler[command](el, value, ops, this.#evaluator);
4228
4234
  } catch (ex) {
4229
- //the directive is popped before it runs, so the open tag no
4230
- //longer carries it: the frame names it and its expression
4231
4235
  throw RenderError.wrap(`Error evaluating data-tpl-${toAttr(command)}="${value}"`, el, ex);
4232
4236
  }
4233
4237
  if (ops.removed(el)) {
@@ -4250,17 +4254,13 @@ class Template {
4250
4254
  el.setAttribute(attributeName, evaluated);
4251
4255
  }
4252
4256
  } catch (ex) {
4253
- throw RenderError.wrap(`Error evaluating data-tpl-${toAttr(dataSetKey)}="${expression}"`, el, ex);
4257
+ throw RenderError.wrap(`Error evaluating data-tpl-${attributeName}="${expression}"`, el, ex);
4254
4258
  }
4255
4259
  }
4256
4260
  }
4257
4261
  ops.cleanup();
4258
4262
  return fragment;
4259
4263
  } catch (ex) {
4260
- //a command, a text node or a nested render already named the node it
4261
- //failed on: wrapping again would add a frame for the fragment that
4262
- //contains it, one per level, serializing the whole template into the
4263
- //message that survives. Only a failure outside those is framed here
4264
4264
  if (ex instanceof RenderError) {
4265
4265
  throw ex;
4266
4266
  }
@@ -4268,22 +4268,26 @@ class Template {
4268
4268
  }
4269
4269
  }
4270
4270
  /**
4271
- * Renders this template on the Element (replacing children).
4271
+ * Renders this template into the element, replacing its children.
4272
4272
  * @param {Element} el
4273
+ * @throws {RenderError}
4273
4274
  */
4274
4275
  renderTo(el) {
4275
4276
  el.replaceChildren(this.render());
4276
4277
  }
4277
4278
  /**
4278
- * Renders this template appending the resulting fragment to the Element.
4279
+ * Renders this template and appends the result to the element.
4279
4280
  * @param {Element} el
4281
+ * @throws {RenderError}
4280
4282
  */
4281
4283
  appendTo(el) {
4282
4284
  el.appendChild(this.render());
4283
4285
  }
4284
4286
  /**
4285
- * Renders this template on the first Element matching the selector (replacing children), if exists.
4287
+ * Renders this template into the first element matching the selector,
4288
+ * replacing its children; does nothing when none matches.
4286
4289
  * @param {string} selector
4290
+ * @throws {RenderError}
4287
4291
  */
4288
4292
  renderToSelector(selector) {
4289
4293
  const el = document.querySelector(selector);
@@ -4292,8 +4296,10 @@ class Template {
4292
4296
  }
4293
4297
  }
4294
4298
  /**
4295
- * Renders this template appending the resulting fragment to the Element matching the selector, if exists.
4299
+ * Renders this template and appends the result to the first element
4300
+ * matching the selector; does nothing when none matches.
4296
4301
  * @param {string} selector
4302
+ * @throws {RenderError}
4297
4303
  */
4298
4304
  appendToSelector(selector) {
4299
4305
  const el = document.querySelector(selector);
@@ -4317,30 +4323,26 @@ class Template {
4317
4323
  }
4318
4324
 
4319
4325
  /**
4320
- * A render failure, one frame per nesting level. Each frame names the node it
4321
- * failed on and what was being evaluated there, so the chain reads as the path
4322
- * from the template's root down to the offending expression. The frame carries
4323
- * the node's identification only, an open tag rather than its whole subtree:
4324
- * the markup is serialized on demand through `html`, and the live node stays on
4325
- * `node`, so a failure costs no clone and a nested failure does not embed the
4326
- * page in its own message.
4326
+ * A render failure, one frame per nesting level, chained through `cause` from
4327
+ * the outermost frame down to the error that failed the expression. A frame's
4328
+ * message names what was being evaluated and the node's open tag, with the
4329
+ * attributes it still carries: the directive being evaluated is already off
4330
+ * the tag. The subtree is not in the message; `html` serializes it on demand.
4327
4331
  */
4328
4332
  class RenderError extends Error {
4329
- /**
4330
- * How many frames a chain keeps. The innermost are the specific ones, so a
4331
- * deeper nesting drops the outer context rather than the failure site: three
4332
- * frames name the offending node and the two levels that hold it, which is
4333
- * the path a reader follows without the page arriving with it.
4334
- */
4333
+ /** How many frames a chain keeps: a deeper nesting drops the outermost ones and marks the chain `truncated`. */
4335
4334
  static FRAMES = 3;
4336
4335
  #node;
4337
4336
  #depth;
4338
- /** true when the budget dropped the outer frames of this chain */
4337
+ /** Whether the chain dropped outer frames to stay within FRAMES. */
4339
4338
  truncated = false;
4340
4339
  /**
4341
- * Frames a failure, unless the chain already spent its budget: then the
4342
- * cause travels on, marked so a report can say the outer context was
4343
- * dropped.
4340
+ * Frames a failure, unless the chain already holds FRAMES frames: then the
4341
+ * cause is returned as it is, marked `truncated`.
4342
+ * @param {string} message
4343
+ * @param {Node} nodeOrFragment the node that failed
4344
+ * @param {any} cause
4345
+ * @returns {RenderError}
4344
4346
  */
4345
4347
  static wrap(message, nodeOrFragment, cause) {
4346
4348
  if (cause instanceof RenderError && cause.depth >= RenderError.FRAMES) {
@@ -4349,27 +4351,44 @@ class RenderError extends Error {
4349
4351
  }
4350
4352
  return new RenderError(message, nodeOrFragment, cause);
4351
4353
  }
4354
+ /**
4355
+ * @param {string} message
4356
+ * @param {Node} nodeOrFragment the node that failed
4357
+ * @param {any} cause
4358
+ */
4352
4359
  constructor(message, nodeOrFragment, cause) {
4353
4360
  super(`${message} in \`${RenderError.describe(nodeOrFragment)}\``, { cause });
4354
4361
  this.name = 'RenderError';
4355
4362
  this.#node = nodeOrFragment;
4356
4363
  this.#depth = (cause instanceof RenderError ? cause.depth : 0) + 1;
4357
4364
  }
4358
- /** How many frames this chain carries, this one included. */
4365
+ /**
4366
+ * How many frames this chain carries, this one included.
4367
+ * @returns {number}
4368
+ */
4359
4369
  get depth() {
4360
4370
  return this.#depth;
4361
4371
  }
4362
- /** The node the render failed on, live: it keeps its place in the fragment being built. */
4372
+ /**
4373
+ * The node the render failed on, live in the fragment being built.
4374
+ * @returns {Node}
4375
+ */
4363
4376
  get node() {
4364
4377
  return this.#node;
4365
4378
  }
4366
- /** The node's markup, serialized when asked for rather than on every failure. */
4379
+ /**
4380
+ * The failed node's markup, whitespace trimmed, serialized on each read.
4381
+ * @returns {string}
4382
+ */
4367
4383
  get html() {
4368
4384
  return RenderError.stringify(this.#node);
4369
4385
  }
4370
4386
  /**
4371
4387
  * What identifies a node in a frame: an element by its open tag, a text node
4372
- * by its source, a fragment by the open tags of the elements it holds.
4388
+ * by its source, a fragment by the open tags of the elements it holds, cut
4389
+ * at 120 characters.
4390
+ * @param {Node} nodeOrFragment
4391
+ * @returns {string}
4373
4392
  */
4374
4393
  static describe(nodeOrFragment) {
4375
4394
  if (nodeOrFragment.nodeType === Node.TEXT_NODE) {
@@ -4388,10 +4407,13 @@ class RenderError extends Error {
4388
4407
  static #ellipsize(text) {
4389
4408
  return text.length > 120 ? `${text.slice(0, 120)}…` : text;
4390
4409
  }
4410
+ /**
4411
+ * A node's markup with its whitespace-only text dropped and the rest trimmed.
4412
+ * @param {Node} nodeOrFragment left untouched: a clone is serialized
4413
+ * @returns {string}
4414
+ */
4391
4415
  static stringify(nodeOrFragment) {
4392
- const t = document.createElement('template');
4393
- t.content.appendChild(RenderError.#cleanup(nodeOrFragment.cloneNode(true)));
4394
- return t.innerHTML;
4416
+ return Fragments.toHtml(RenderError.#cleanup(nodeOrFragment.cloneNode(true)));
4395
4417
  }
4396
4418
  static #cleanup(node) {
4397
4419
  if (node.nodeType === Node.TEXT_NODE) {
@@ -4413,11 +4435,6 @@ class RenderError extends Error {
4413
4435
  }
4414
4436
  }
4415
4437
 
4416
- /**
4417
- * Tracks the elements waiting to render. `ready` resolves once the queue has
4418
- * drained and never rejects; the promise kept per element resolves when that
4419
- * element has rendered and rejects with whatever its upgrade threw.
4420
- */
4421
4438
  class UpgradeQueue {
4422
4439
  #q = new Map();
4423
4440
  #readyResolve;
@@ -4429,12 +4446,6 @@ class UpgradeQueue {
4429
4446
  this.#start();
4430
4447
  });
4431
4448
  }
4432
- /**
4433
- * Waits for the page's readiness: the promise resolves right after the
4434
- * ftl:ready event is dispatched, and immediately when that moment already
4435
- * passed.
4436
- * @returns {Promise<void>}
4437
- */
4438
4449
  ready() {
4439
4450
  return this.#ready;
4440
4451
  }
@@ -4450,13 +4461,8 @@ class UpgradeQueue {
4450
4461
  }
4451
4462
  enqueue(el) {
4452
4463
  if (this.#q.has(el)) {
4453
- //already upgrading, can happen when disconnecting an element
4454
- //while it's already queued for upgrade (e.g.: ful-form)
4455
4464
  return;
4456
4465
  }
4457
- //one entry, two signals: readiness waits on a signal that only ever
4458
- //resolves, so nothing here attaches a rejection handler to the upgrade
4459
- //itself and a component that fails is still reported the way it always was
4460
4466
  const { promise: finished, resolve: markFinished } = /** @type {PromiseWithResolvers<void>} */ (
4461
4467
  Promise.withResolvers()
4462
4468
  );
@@ -4468,11 +4474,6 @@ class UpgradeQueue {
4468
4474
  });
4469
4475
  this.#q.set(el, { upgrade, finished });
4470
4476
  }
4471
- /**
4472
- * The one fixed-point loop: drains the accepted entries, including the ones
4473
- * enqueued while waiting, since a component is only queued once its parent
4474
- * connects it and a single pass would miss everything nested.
4475
- */
4476
4477
  async #drain(accept, pick) {
4477
4478
  for (;;) {
4478
4479
  const pending = Array.from(this.#q)
@@ -4485,22 +4486,25 @@ class UpgradeQueue {
4485
4486
  }
4486
4487
  }
4487
4488
  /**
4488
- * Waits for the whole queue to drain. Never rejects: a component that fails
4489
- * does not hold the others back, and readiness means the queue drained rather
4490
- * than that everything worked.
4489
+ * Waits for the whole queue to drain, the upgrades enqueued meanwhile included.
4490
+ * Never rejects: a failed upgrade counts as drained.
4491
+ * @returns {Promise<void>}
4491
4492
  */
4492
4493
  settled() {
4493
4494
  return this.#drain(() => true, (entry) => entry.finished);
4494
4495
  }
4495
- /** Waits for the accepted upgrades, rejecting with the first that failed. */
4496
+ /**
4497
+ * Waits for the accepted upgrades, the ones enqueued meanwhile included,
4498
+ * rejecting with the first that failed.
4499
+ * @param {(el: Element) => boolean} accept
4500
+ * @returns {Promise<void>}
4501
+ */
4496
4502
  upgraded(accept) {
4497
4503
  return this.#drain(accept, (entry) => entry.upgrade);
4498
4504
  }
4499
- /** The pending upgrade of one element, undefined when it is not queued. */
4500
4505
  whenUpgraded(el) {
4501
4506
  return this.#q.get(el)?.upgrade;
4502
4507
  }
4503
- /** The elements whose upgrade is still pending, in queue order. */
4504
4508
  pending() {
4505
4509
  return Array.from(this.#q.keys());
4506
4510
  }
@@ -4511,8 +4515,8 @@ class UpgradeQueue {
4511
4515
  * and attribute mappers. Elements defined before configure() are deferred and
4512
4516
  * defined by it; from then on every defineElement takes effect immediately.
4513
4517
  * The exported `registry` singleton is the page's own; a `new Registry()` is a
4514
- * separate instance, and Rendering and the ftl:ready machinery wait on the
4515
- * singleton alone.
4518
+ * separate instance with its own upgrade queue, which dispatches its own
4519
+ * ftl:ready on the document, while Rendering waits on the singleton alone.
4516
4520
  */
4517
4521
  class Registry {
4518
4522
  #tagToClass = {};
@@ -4581,9 +4585,12 @@ class Registry {
4581
4585
  * Registers a custom element under its tag: the class is augmented with its
4582
4586
  * BITS (observed attributes, mappers, templates) and handed to the platform.
4583
4587
  * Before configure() the definition is deferred, so import order never
4584
- * matters.
4588
+ * matters. The element resolves its templates and components through this
4589
+ * registry, whichever one a module imports, and its `static config` is read
4590
+ * here, so a page replacing it does so before the definition.
4585
4591
  * @param {string} tag
4586
4592
  * @param {*} klass a ParsedElement subclass
4593
+ * @returns {Registry} this registry
4587
4594
  */
4588
4595
  defineElement(tag, klass) {
4589
4596
  if (!this.#configured) {
@@ -4601,13 +4608,8 @@ class Registry {
4601
4608
  * ancestors', so a base class declares what every subclass keeps observing
4602
4609
  * (a protocol attribute such as Field's disabled claim) and a leaf refines a
4603
4610
  * mapping, or moves a name's position, without repeating the whole list.
4604
- *
4605
- * The walk stops where the platform's own class hierarchy begins: no earlier
4606
- * stop can work, since a registered ancestor carries an own BITS of its own,
4607
- * and nothing above the elements declares anything.
4608
- *
4609
- * Everything deriving a component's attribute vocabulary reads it here, so
4610
- * the runtime and whatever documents it cannot walk the chain differently.
4611
+ * The observed attributes are applied in the composed order, a re-declared
4612
+ * name taking the position of its last declaration.
4611
4613
  * @param {*} klass a ParsedElement subclass
4612
4614
  * @returns {{ observed: string[], attributes: string[] }}
4613
4615
  */
@@ -4623,14 +4625,7 @@ class Registry {
4623
4625
  * The property an observed attribute drives, by the platform's own
4624
4626
  * dash-to-camel rule: the one `dataset` applies, so `clear-invalid-on-change`
4625
4627
  * would reach `clearInvalidOnChange` the way `data-clear-invalid-on-change`
4626
- * reaches `dataset.clearInvalidOnChange`. A single-word name is returned
4627
- * unchanged, which is what every observed attribute in the library is.
4628
- *
4629
- * It exists because the observed tier is the one that becomes properties:
4630
- * without it an attribute could only be observed if its name happened to be
4631
- * a usable identifier, which is why every multiword observed attribute here
4632
- * used to be squashed into one word while the configuration tier, which
4633
- * never becomes a property, spelled the same idea with a dash.
4628
+ * reaches `dataset.clearInvalidOnChange`.
4634
4629
  *
4635
4630
  * Only this direction is mapped. A property never derives its attribute: a
4636
4631
  * setter reflects through `reflectTo`, naming the attribute it writes.
@@ -4643,10 +4638,6 @@ class Registry {
4643
4638
  #augmentAndDefineElement(tag, klass) {
4644
4639
  const { observed, attributes } = Registry.declarationsOf(klass);
4645
4640
  const { template, templates, slots, mappers, config } = klass;
4646
- //a name a subclass re-declares takes the subclass's position, which is
4647
- //how a field whose value setter reads its own shape attributes declares
4648
- //that its value lands after them: the order the declarations compose in
4649
- //is the order the base applies them
4650
4641
  const declaredNames = observed.map((a) => a.split(':')[0]);
4651
4642
  const observedNames = [...new Set(declaredNames.reverse())].reverse();
4652
4643
  const attrToMapper = [...attributes, ...observed].reduce((acc, a) => {
@@ -4665,20 +4656,13 @@ class Registry {
4665
4656
  const nameToTemplate = Object.fromEntries(namesAndTemplates);
4666
4657
 
4667
4658
  klass.BITS = {
4668
- //the defining registry travels with the definition: an element resolves
4669
- //its templates and its components through the registry that defined it,
4670
- //not through whichever one a module happened to import
4671
4659
  registry: this,
4672
4660
  enqueue: (el) => this.#upgradeQueue.enqueue(el),
4673
4661
  SLOTS: slots,
4674
- //the class's own constants, overlaid on its templates as `config`:
4675
- //read here with the rest of the declaration, so a page replacing them
4676
- //does it before configure() like every other definition-time choice
4677
4662
  CONFIG: config,
4678
4663
  OBSERVED: observedNames,
4679
4664
  DECLARED: [...new Set([...observedNames, ...attributes.map((a) => a.split(':')[0])])],
4680
4665
  ATTR_TO_MAPPER: attrToMapper,
4681
- //resolved once here rather than at every attribute write
4682
4666
  ATTR_TO_PROPERTY: Object.fromEntries(observedNames.map((a) => [a, Registry.propertyOf(a)])),
4683
4667
  TEMPLATES: nameToTemplate,
4684
4668
  };
@@ -4690,6 +4674,7 @@ class Registry {
4690
4674
  * as `#fn`.
4691
4675
  * @param {string} name
4692
4676
  * @param {object} value
4677
+ * @returns {Registry} this registry
4693
4678
  */
4694
4679
  defineModule(name, value) {
4695
4680
  const module = name ? { [name]: value } : value;
@@ -4700,6 +4685,7 @@ class Registry {
4700
4685
  /**
4701
4686
  * Replaces the whole module map.
4702
4687
  * @param {object} ms
4688
+ * @returns {Registry} this registry
4703
4689
  */
4704
4690
  defineModules(ms) {
4705
4691
  this.#modules = ms;
@@ -4711,6 +4697,7 @@ class Registry {
4711
4697
  * through component().
4712
4698
  * @param {string} name
4713
4699
  * @param {*} value
4700
+ * @returns {Registry} this registry
4714
4701
  */
4715
4702
  defineComponent(name, value) {
4716
4703
  this.#components[name] = value;
@@ -4719,6 +4706,7 @@ class Registry {
4719
4706
  /**
4720
4707
  * Replaces the data stack the templates evaluate over.
4721
4708
  * @param {...any} data
4709
+ * @returns {Registry} this registry
4722
4710
  */
4723
4711
  defineData(...data) {
4724
4712
  this.#data = data;
@@ -4728,6 +4716,7 @@ class Registry {
4728
4716
  /**
4729
4717
  * Appends to the data stack, the later entry winning a shared name.
4730
4718
  * @param {...any} data
4719
+ * @returns {Registry} this registry
4731
4720
  */
4732
4721
  defineOverlay(...data) {
4733
4722
  this.#data = [...this.#data, ...data];
@@ -4739,6 +4728,7 @@ class Registry {
4739
4728
  * `name:type` declaration.
4740
4729
  * @param {string} k
4741
4730
  * @param {{ unmarshal(str: string|null, name: string, el: Element): any, marshal(value: any, name: string, el: Element): string|null }} v
4731
+ * @returns {Registry} this registry
4742
4732
  */
4743
4733
  defineMapper(k, v) {
4744
4734
  this.#mappers[k] = v;
@@ -4747,6 +4737,7 @@ class Registry {
4747
4737
  /**
4748
4738
  * Hands the registry over to the plugin's configure.
4749
4739
  * @param {{ configure(registry: Registry): void }} p
4740
+ * @returns {Registry} this registry
4750
4741
  */
4751
4742
  plugin(p) {
4752
4743
  p.configure(this);
@@ -4755,6 +4746,7 @@ class Registry {
4755
4746
  /**
4756
4747
  * Defines every element deferred so far; from then on, defineElement takes
4757
4748
  * effect immediately.
4749
+ * @returns {Registry} this registry
4758
4750
  */
4759
4751
  configure() {
4760
4752
  for (const [tag, klass] of Object.entries(this.#tagToClass)) {
@@ -4766,23 +4758,36 @@ class Registry {
4766
4758
  }
4767
4759
  /**
4768
4760
  * Waits for the queued upgrades the filter accepts, rejecting with the first
4769
- * that failed: the rejecting barrier Rendering is a facade over.
4761
+ * that failed, the upgrades enqueued meanwhile included: the rejecting
4762
+ * barrier Rendering is a facade over.
4770
4763
  * @param {(el: Element) => boolean} accept
4764
+ * @returns {Promise<void>}
4771
4765
  */
4772
4766
  settle(accept) {
4773
4767
  return this.#upgradeQueue.upgraded(accept);
4774
4768
  }
4775
- /** The pending upgrade of one element, undefined when it is not queued. */
4769
+ /**
4770
+ * The pending upgrade of one element: resolving when it has rendered,
4771
+ * rejecting with what its upgrade threw.
4772
+ * @param {Element} el
4773
+ * @returns {Promise<void>|undefined} undefined when the element is not queued
4774
+ */
4776
4775
  whenUpgraded(el) {
4777
4776
  return this.#upgradeQueue.whenUpgraded(el);
4778
4777
  }
4779
- /** The elements whose upgrade is still pending, in queue order. */
4778
+ /**
4779
+ * The elements whose upgrade is still pending, in queue order.
4780
+ * @returns {Element[]}
4781
+ */
4780
4782
  pending() {
4781
4783
  return this.#upgradeQueue.pending();
4782
4784
  }
4783
4785
  /**
4784
- * Waits for the page's readiness: the same moment the ftl:ready event is
4785
- * dispatched at, resolving immediately when that moment already passed.
4786
+ * Waits for this registry's readiness: resolves right after its ftl:ready
4787
+ * event is dispatched on the document, and immediately when that already
4788
+ * happened. Never rejects: a failed upgrade counts as settled, and its
4789
+ * rejection is left unhandled so it still reaches the console and the
4790
+ * page's error reporting.
4786
4791
  * @returns {Promise<void>}
4787
4792
  */
4788
4793
  ready() {
@@ -4795,11 +4800,16 @@ class Registry {
4795
4800
  * The scope every template on this registry renders in: the modules and the
4796
4801
  * data stack, as one value. Replaced whenever either is defined, so a holder
4797
4802
  * of an older one keeps rendering against what it was handed.
4803
+ * @returns {ExpressionEvaluator}
4798
4804
  */
4799
4805
  evaluator() {
4800
4806
  return this.#evaluator;
4801
4807
  }
4802
- /** The component registered under the name, undefined when none is. */
4808
+ /**
4809
+ * The component registered under the name.
4810
+ * @param {string} name
4811
+ * @returns {any} undefined when none is
4812
+ */
4803
4813
  component(name) {
4804
4814
  return this.#components[name];
4805
4815
  }
@@ -4807,33 +4817,59 @@ class Registry {
4807
4817
 
4808
4818
  const registry = new Registry();
4809
4819
 
4810
- /** The Template factories, each bound to the page registry's scope. */
4811
4820
  /** The Template factories bound to the page's own registry: the same four sources, with its modules and data already applied. */
4812
4821
  class Templates {
4822
+ /**
4823
+ * @param {string} html
4824
+ * @returns {Template}
4825
+ */
4813
4826
  static fromHtml(html) {
4814
4827
  return Template.fromHtml(html).withEvaluator(registry.evaluator());
4815
4828
  }
4829
+ /**
4830
+ * @param {string} selector for an HTMLTemplateElement
4831
+ * @returns {Template}
4832
+ */
4816
4833
  static fromSelector(selector) {
4817
4834
  return Template.fromSelector(selector).withEvaluator(registry.evaluator());
4818
4835
  }
4836
+ /**
4837
+ * @param {HTMLTemplateElement} templateEl consumed: its content is adopted
4838
+ * @returns {Template}
4839
+ */
4819
4840
  static fromTemplate(templateEl) {
4820
4841
  return Template.fromTemplate(templateEl).withEvaluator(registry.evaluator());
4821
4842
  }
4843
+ /**
4844
+ * @param {DocumentFragment} fragment
4845
+ * @returns {Template}
4846
+ */
4822
4847
  static fromFragment(fragment) {
4823
4848
  return Template.fromFragment(fragment).withEvaluator(registry.evaluator());
4824
4849
  }
4825
4850
  }
4826
4851
 
4827
4852
  /**
4828
- * Awaitable rendering barriers over the registry's upgrade queue. These are
4829
- * the rejecting waits: a failed upgrade among the awaited components rejects
4830
- * the wait, where registry.ready() only ever means the queue drained and
4831
- * leaves a failed component to its own unhandled-rejection report.
4853
+ * Waits for elements queued on the page's `registry` to render. Unlike
4854
+ * `registry.ready()`, these reject when an awaited upgrade fails. An element
4855
+ * defined by another Registry is awaited through that registry instead.
4832
4856
  */
4833
4857
  class Rendering {
4858
+ /**
4859
+ * Waits for the element and its descendants, the ones their own renders
4860
+ * enqueue included.
4861
+ * @param {Element} el
4862
+ * @returns {Promise<void>} rejecting with the first upgrade that failed
4863
+ */
4834
4864
  static waitFor(el) {
4835
4865
  return registry.settle((child) => el.contains(child));
4836
4866
  }
4867
+ /**
4868
+ * Waits for the element's descendants, the element itself excluded: what a
4869
+ * component awaits from inside its own render.
4870
+ * @param {Element} el
4871
+ * @returns {Promise<void>} rejecting with the first upgrade that failed
4872
+ */
4837
4873
  static waitForChildren(el) {
4838
4874
  return registry.settle((child) => el !== child && el.contains(child));
4839
4875
  }
@@ -4847,9 +4883,15 @@ class Rendering {
4847
4883
  * @property {(val: any, name: string, el: Element) => string|null} marshal
4848
4884
  */
4849
4885
 
4886
+ /**
4887
+ * The base of every fml element. A subclass declares its attributes, slots and
4888
+ * templates in statics and builds its dom in `render`; the base reads the light
4889
+ * slots, reads the declared attributes through their mappers, renders once, and
4890
+ * then forwards observed attribute writes to the matching properties. An element
4891
+ * resolves templates and components through the registry that defined it.
4892
+ */
4850
4893
  class ParsedElement extends HTMLElement {
4851
4894
  static BITS = {
4852
- //an element the registry never defined still answers the page's own
4853
4895
  registry,
4854
4896
  enqueue: (el) => {},
4855
4897
  SLOTS: false,
@@ -4873,14 +4915,12 @@ class ParsedElement extends HTMLElement {
4873
4915
  this.internals = this.attachInternals();
4874
4916
  }
4875
4917
  /**
4876
- * The platform's window into the element's own state. The base attaches it
4877
- * for every element, so a subclass never calls attachInternals itself: the
4878
- * platform allows it once, and a second call throws. Form association is a
4879
- * property of the definition rather than of who attached, so a subclass
4880
- * declaring `static formAssociated` still gets the form apis here.
4881
- *
4882
- * A subclass must not redeclare it: a class field with no initializer
4883
- * assigns undefined after super() returns, which would wipe it.
4918
+ * The element's ElementInternals, attached by the base for every element: a
4919
+ * subclass never calls attachInternals itself, since a second call throws.
4920
+ * A subclass declaring `static formAssociated` gets the form apis here.
4921
+ * A subclass must not redeclare the field: a class field without an
4922
+ * initializer assigns undefined after super() returns, wiping it.
4923
+ * @type {ElementInternals}
4884
4924
  */
4885
4925
  internals;
4886
4926
  #parsed = false;
@@ -4897,26 +4937,36 @@ class ParsedElement extends HTMLElement {
4897
4937
  #mapper(attr) {
4898
4938
  const mapper = this.#bits().ATTR_TO_MAPPER[attr];
4899
4939
  if (!mapper) {
4900
- //an attribute the class never declared has no type to read it as:
4901
- //say so rather than dying on the missing mapper. Content this
4902
- //element does not own, such as a custom loader's own configuration,
4903
- //is read with getAttribute, the platform's own answer
4904
4940
  throw new Error(
4905
4941
  `${this.constructor.name} declares no attribute '${attr}': declare it in static observed or static attributes, or read it with getAttribute`,
4906
4942
  );
4907
4943
  }
4908
4944
  return mapper;
4909
4945
  }
4946
+ /**
4947
+ * Reads an attribute value through the mapper it was declared with.
4948
+ * @param {string} attr
4949
+ * @param {string|null} str
4950
+ * @returns {any}
4951
+ * @throws when the class declares no such attribute; read undeclared ones with getAttribute
4952
+ */
4910
4953
  unmarshal(attr, str) {
4911
4954
  return this.#mapper(attr).unmarshal(str, attr, this);
4912
4955
  }
4956
+ /**
4957
+ * Writes a value as the attribute string its declared mapper produces.
4958
+ * @param {string} attr
4959
+ * @param {any} value
4960
+ * @returns {string|null} null for a value that removes the attribute
4961
+ * @throws when the class declares no such attribute
4962
+ */
4913
4963
  marshal(attr, value) {
4914
4964
  return this.#mapper(attr).marshal(value, attr, this);
4915
4965
  }
4916
4966
  /**
4917
- * @param {string} [name] - The name of the template target, defaults to 'default'
4967
+ * The registry that defined this element, the page's own for an undefined one.
4968
+ * @returns {typeof registry}
4918
4969
  */
4919
- /** The registry that defined this element, the page's own for an undefined one. */
4920
4970
  get _registry() {
4921
4971
  return this.#bits().registry;
4922
4972
  }
@@ -4924,10 +4974,18 @@ class ParsedElement extends HTMLElement {
4924
4974
  * The component registered under the name on this element's registry, the
4925
4975
  * one every ful loader and mapper resolves through.
4926
4976
  * @param {string} name
4977
+ * @returns {any} undefined when none is registered
4927
4978
  */
4928
4979
  component(name) {
4929
4980
  return this.#bits().registry.component(name);
4930
4981
  }
4982
+ /**
4983
+ * One of the class's templates, bound to its registry's scope with the
4984
+ * class's `static config` overlaid as `config`.
4985
+ * @param {string} [name] 'default', the `static template`, when omitted
4986
+ * @returns {Template}
4987
+ * @throws when the class declares no template of that name
4988
+ */
4931
4989
  template(name) {
4932
4990
  const target = this.#bits().TEMPLATES[name ?? 'default'];
4933
4991
  if (!target) {
@@ -4947,15 +5005,9 @@ class ParsedElement extends HTMLElement {
4947
5005
  if (oldValue === newValue) {
4948
5006
  return;
4949
5007
  }
4950
- //the mute is the attribute being reflected, not the element: a component
4951
- //that legitimately changes another observed attribute while one reflects
4952
- //is a change like any other, and used to be swallowed
4953
5008
  if (this.#reflecting.has(attr)) {
4954
5009
  return;
4955
5010
  }
4956
- //the properties go live only once the render is done: before that, an
4957
- //attribute write lands in the observed snapshot and the base applies it
4958
- //with the rest of the declared state
4959
5011
  if (!this.#parsed) {
4960
5012
  if (this.#pending !== null && attr in this.#pending) {
4961
5013
  this.#pending[attr] = this.unmarshal(attr, newValue);
@@ -4965,14 +5017,18 @@ class ParsedElement extends HTMLElement {
4965
5017
  this[this.#bits().ATTR_TO_PROPERTY[attr]] = this.unmarshal(attr, newValue);
4966
5018
  }
4967
5019
  /**
4968
- * Upgrades once: reads the declared attributes, keeps the observed half open
4969
- * to writes made while the render is pending, applies them to the properties
4970
- * when the render returns, and only then lets an attribute write forward.
5020
+ * Upgrades once, however often the element is connected: reads the declared
5021
+ * attributes, keeps the observed ones open to writes made while the render is
5022
+ * pending, renders, then applies the observed values to their properties in
5023
+ * the order the registry composed them, a base class's before a subclass's.
5024
+ * Only then do attribute writes forward to properties, and the element gains
5025
+ * the `rendered` custom state. A render that throws leaves the forwarding
5026
+ * shut and the state unset.
4971
5027
  *
4972
5028
  * An observed attribute drives the property the registry's `propertyOf`
4973
5029
  * names, so a hyphenated attribute is authored with its dashes and read as
4974
- * a camelCase property. A single-word attribute is its own property name,
4975
- * which is what every observed attribute in the library is.
5030
+ * a camelCase property.
5031
+ * @returns {Promise<void>} rejecting with what the render threw
4976
5032
  */
4977
5033
  async upgrade() {
4978
5034
  if (this.#started) {
@@ -4980,9 +5036,6 @@ class ParsedElement extends HTMLElement {
4980
5036
  }
4981
5037
  this.#started = true;
4982
5038
  const slots = this.#bits().SLOTS ? LightSlots.from(this) : undefined;
4983
- //both tiers are read here: the observed ones forward to a property once
4984
- //the render is done, the rest are the element's configuration, read the
4985
- //one time and never again
4986
5039
  const declared = Object.fromEntries(
4987
5040
  this.#bits().DECLARED.map((attribute) => [
4988
5041
  attribute,
@@ -4994,21 +5047,11 @@ class ParsedElement extends HTMLElement {
4994
5047
  this.#pending = declared;
4995
5048
  try {
4996
5049
  await this.render({ slots });
4997
- //the declared state reaches the properties once the dom the setters
4998
- //drive exists, in the order the registry composed the declarations:
4999
- //a base class's attributes before the subclass's own
5000
5050
  const properties = this.#bits().ATTR_TO_PROPERTY;
5001
5051
  for (const name of this.#bits().OBSERVED) {
5002
5052
  this[properties[name]] = declared[name];
5003
5053
  }
5004
- //the properties go live once the render is done: from here on, an
5005
- //attribute write forwards to the property. A render that threw
5006
- //leaves it shut, so a later attribute write cannot reach setters
5007
- //that assume pieces the failed render never adopted
5008
5054
  this.#parsed = true;
5009
- //the same moment, said to css. :defined is true from the constructor,
5010
- //which is before the dom exists, so a guard written against it reveals
5011
- //an element that has nothing in it yet
5012
5055
  this.internals.states.add('rendered');
5013
5056
  } finally {
5014
5057
  this.#pending = null;
@@ -5019,8 +5062,9 @@ class ParsedElement extends HTMLElement {
5019
5062
  * drive, and the base applies the declared state onto the properties as
5020
5063
  * soon as it returns. A render needing a declared value while it builds
5021
5064
  * reads it through `declared(name)`. The slots are undefined for an element
5022
- * declaring no slots.
5023
- * @param {{ slots: any }} c
5065
+ * declaring no slots. It may be async; the upgrade awaits it.
5066
+ * @param {{ slots: Record<string, DocumentFragment> | undefined }} c
5067
+ * @returns {void | Promise<void>}
5024
5068
  */
5025
5069
  render(c) {}
5026
5070
  /**
@@ -5032,12 +5076,9 @@ class ParsedElement extends HTMLElement {
5032
5076
  * observed name answers the snapshot while the render is pending, kept
5033
5077
  * open to attribute writes landing in that window, and the live attribute
5034
5078
  * afterwards, the property being live by then.
5035
- *
5036
- * The snapshot exists rather than a read of the dom because an element may
5037
- * write its own observed attributes while it renders, whether a reflection
5038
- * or a value the platform normalizes on the way in, and what the author
5039
- * declared is what the base applies, not what the render left behind.
5040
5079
  * @param {string} name
5080
+ * @returns {any}
5081
+ * @throws when the class declares no such attribute
5041
5082
  */
5042
5083
  declared(name) {
5043
5084
  if (name in this.#frozen) {
@@ -5048,15 +5089,18 @@ class ParsedElement extends HTMLElement {
5048
5089
  }
5049
5090
  return this.unmarshal(name, this.getAttribute(name));
5050
5091
  }
5051
- /** Whether the element's render completed: the moment its properties went live. */
5092
+ /**
5093
+ * Whether the element's render completed: the moment its properties went
5094
+ * live, which css reads as `:state(rendered)`. Unlike `:defined`, which holds
5095
+ * from the constructor, it is false until the dom exists.
5096
+ * @returns {boolean}
5097
+ */
5052
5098
  get rendered() {
5053
5099
  return this.#parsed;
5054
5100
  }
5055
5101
  /**
5056
5102
  * Projects a property back onto its observed attribute, marshalled through
5057
- * the mapper the attribute was declared with: the one place a property
5058
- * reaches its attribute, so a setter never has to know how its own type
5059
- * serializes.
5103
+ * the mapper the attribute was declared with.
5060
5104
  *
5061
5105
  * A value the attribute already carries is not written at all, so reflecting
5062
5106
  * what an attribute write just delivered ends there rather than looping, and
@@ -5098,7 +5142,10 @@ const POSITIONAL = /\{(\d+)\}/g;
5098
5142
  /** @param {any} v @returns {v is Record<string, string>} */
5099
5143
  const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
5100
5144
 
5101
- /** every l10n problem is reported once per session: a missing key in a row template must not flood the console */
5145
+ /** @param {any} problem @returns {string|undefined} the reason, unless it is missing or blank */
5146
+ const reasonOf = (problem) =>
5147
+ typeof problem?.reason === 'string' && problem.reason.trim() !== '' ? problem.reason : undefined;
5148
+
5102
5149
  const warned = new Set();
5103
5150
  const warnOnce = (problem) => {
5104
5151
  if (!warned.has(problem)) {
@@ -5109,11 +5156,7 @@ const warnOnce = (problem) => {
5109
5156
 
5110
5157
  const formatters = new BoundedCache(100);
5111
5158
  /**
5112
- * Intl construction is the expensive part of formatting (locale parsing, CLDR
5113
- * lookups), while formatting on a built instance is near free: a list rendering
5114
- * a size per row must not rebuild a NumberFormat per cell, so instances are
5115
- * memoized by constructor, locale and options.
5116
- *
5159
+ * An Intl instance, memoized by constructor, locale and options.
5117
5160
  * @param {{ new (locale: string | undefined, options: any): any }} ctor
5118
5161
  * @param {string | undefined} locale
5119
5162
  * @param {any} [options]
@@ -5139,6 +5182,9 @@ class Localization {
5139
5182
  *
5140
5183
  * @param {string} key
5141
5184
  * @param {...any} args
5185
+ * Each problem (a missing key, placeholder or plural form) is warned once
5186
+ * per page.
5187
+ *
5142
5188
  * @this {Receiver}
5143
5189
  * @returns {string} the message, or the key itself when the translations do not carry it
5144
5190
  */
@@ -5149,9 +5195,9 @@ class Localization {
5149
5195
  warnOnce(`missing message "${key}"`);
5150
5196
  return key;
5151
5197
  }
5198
+ const named = args.length === 1 && isPlainObject(args[0]) ? args[0] : null;
5152
5199
  if (isPlainObject(message)) {
5153
- const named = args.length === 1 && isPlainObject(args[0]) ? args[0] : {};
5154
- if (typeof named.count !== 'number') {
5200
+ if (typeof named?.count !== 'number') {
5155
5201
  warnOnce(`plural message "${key}" needs a numeric {count}`);
5156
5202
  message = message.other;
5157
5203
  } else {
@@ -5164,8 +5210,7 @@ class Localization {
5164
5210
  warnOnce(`plural message "${key}" has no "other" form`);
5165
5211
  return key;
5166
5212
  }
5167
- if (args.length === 1 && isPlainObject(args[0])) {
5168
- const named = args[0];
5213
+ if (named) {
5169
5214
  return message.replace(PLACEHOLDER, (literal, name) => {
5170
5215
  if (!Object.hasOwn(named, name)) {
5171
5216
  warnOnce(`message "${key}" wants {${name}}`);
@@ -5193,6 +5238,7 @@ class Localization {
5193
5238
  * @param {Date | number} value
5194
5239
  * @param {Intl.DateTimeFormatOptions} [options]
5195
5240
  * @this {Receiver}
5241
+ * @returns {string}
5196
5242
  */
5197
5243
  static date(value, options) {
5198
5244
  return formatter(Intl.DateTimeFormat, this.locale, options).format(value);
@@ -5204,6 +5250,7 @@ class Localization {
5204
5250
  * @param {number} value
5205
5251
  * @param {Intl.NumberFormatOptions} [options]
5206
5252
  * @this {Receiver}
5253
+ * @returns {string}
5207
5254
  */
5208
5255
  static number(value, options) {
5209
5256
  return formatter(Intl.NumberFormat, this.locale, options).format(value);
@@ -5216,6 +5263,7 @@ class Localization {
5216
5263
  *
5217
5264
  * @param {number} value
5218
5265
  * @this {Receiver}
5266
+ * @returns {string}
5219
5267
  */
5220
5268
  static bytes(value) {
5221
5269
  const format = formatter(Intl.NumberFormat, this.locale, { maximumFractionDigits: 2 }).format;
@@ -5231,16 +5279,37 @@ class Localization {
5231
5279
  return `${format(value)}B`;
5232
5280
  }
5233
5281
 
5282
+ /**
5283
+ * What went wrong, as text for the reader. A value carrying a non-empty
5284
+ * `problems` array reads as their reasons, one per line, a blank or missing
5285
+ * reason reading as the `failure.no-reason` message. Any other value with a
5286
+ * `message` reads as that message; anything else as its string form, and
5287
+ * null or undefined as the empty string.
5288
+ *
5289
+ * @param {any} value what was thrown or rejected with
5290
+ * @this {Receiver}
5291
+ * @returns {string}
5292
+ */
5293
+ static failure(value) {
5294
+ if (Array.isArray(value?.problems) && value.problems.length > 0) {
5295
+ return value.problems.map((p) => reasonOf(p) ?? Localization.t.call(this, 'failure.no-reason')).join('\n');
5296
+ }
5297
+ if (value?.message !== undefined) {
5298
+ return String(value.message);
5299
+ }
5300
+ return value == null ? '' : String(value);
5301
+ }
5302
+
5234
5303
  /**
5235
5304
  * An imperative facade over the module functions, resolving the translations
5236
- * and the locale from the registry overlays on every call.
5305
+ * and the locale from the registry overlays on every call, the same lookup a
5306
+ * template makes: a facade taken at module scope, before the plugin
5307
+ * configures, still sees the translations.
5237
5308
  *
5238
5309
  * @param {{ locale?: string }} [overrides] an explicit locale, winning over the registry one
5310
+ * @returns {{ t: (key: string, ...args: any[]) => string, date: (value: Date | number, options?: Intl.DateTimeFormatOptions) => string, number: (value: number, options?: Intl.NumberFormatOptions) => string, bytes: (value: number) => string, failure: (value: any) => string }}
5239
5311
  */
5240
5312
  static of(overrides = {}) {
5241
- //resolved per call, not per facade: a module-scope `Localization.of()` is
5242
- //bound before the plugin configures. The registry's own evaluator does the
5243
- //lookup, so this cannot drift from what a template sees
5244
5313
  const resolve = (prop) => registry.evaluator().resolve(prop);
5245
5314
  /** @param {any} fn */
5246
5315
  const bind =
@@ -5252,6 +5321,7 @@ class Localization {
5252
5321
  date: bind(Localization.date),
5253
5322
  number: bind(Localization.number),
5254
5323
  bytes: bind(Localization.bytes),
5324
+ failure: bind(Localization.failure),
5255
5325
  };
5256
5326
  }
5257
5327
  }