@webjsdev/core 0.7.49 → 0.7.50

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.
@@ -750,11 +750,10 @@ function buildFormActionRecord(el, onEl, parts) {
750
750
  * record.
751
751
  *
752
752
  * FORMS FIRST, submitters second, regardless of the order the records were
753
- * collected in. A submitter asks whether its enclosing form is bound, and the
754
- * cheapest true answer is the identity field the form's own reconcile just
755
- * inserted. Document order gets this right for a form and a button in the SAME
756
- * template, but not for a `<form>` whose submitter arrives through a nested
757
- * template, so the ordering is made explicit rather than relied upon.
753
+ * collected in. Nothing reads an enclosing form's boundness any more (#1307
754
+ * made a bound submitter self-sufficient), but the ordering still makes a
755
+ * form's RELEASE run before its submitters reconcile, so it is kept rather
756
+ * than churned. See the note in the body.
758
757
  *
759
758
  * @param {FormActionRecord[] | null} formActions
760
759
  * @param {BoundPart[]} bound
@@ -16,6 +16,8 @@ import { isSuspense } from './suspense.js';
16
16
  import { unsafeHTML, isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
17
17
  import { stringify, parse } from './serialize.js';
18
18
  import { cspNonce } from './csp-nonce.js';
19
+ import { readAttributeValue, resolveAttributeProperty } from './attribute-reader.js';
20
+ import NAMED_ENTITIES, { LEGACY_NAMES } from './html-entities.js';
19
21
 
20
22
  /**
21
23
  * Render a TemplateResult (or any renderable value) to an HTML string.
@@ -180,23 +182,6 @@ async function renderTemplate(tr, ctx) {
180
182
  let pendingPropAttrs = [];
181
183
  /** @type {string[]} */
182
184
  let pendingSubmitterProps = [];
183
- // Whether the tag stream is currently inside a form that BOUND an action
184
- // (#1207), as THIS scan can see it. Three states, and the third is the point:
185
- // 'bound' an enclosing <form> opened here and bound an action
186
- // 'unbound' an enclosing <form> opened here and bound nothing
187
- // 'none' there is conclusively no enclosing <form>
188
- // 'unknown' there may be one, but this scan cannot see it
189
- //
190
- // The last two look alike and must not be merged, which is what a boolean did.
191
- // A component's template is rendered by a SEPARATE pass (`injectDSD` calls
192
- // `render` on it), so a `<button formaction=${fn}>` inside a component inside
193
- // a bound form read as "no form", was refused, and in production vanished
194
- // from a page that still returned 200. That pass now says 'unknown' and the
195
- // boundness question is skipped, exactly as the client skips it when it
196
- // cannot reach the form, so both renderers are best effort in the same place
197
- // and for the same reason. A top-level scan that simply contains no form
198
- // stays 'none' and is still refused, because there the answer IS known.
199
- //
200
185
  let isCloseTag = false;
201
186
 
202
187
  // A bound `action=${fn}` is committed at its hole, but the edits it implies
@@ -483,14 +468,13 @@ async function renderTemplate(tr, ctx) {
483
468
  // #1207: the submitter binding. The identity replaces the
484
469
  // `formaction=` hole IN PLACE with the button's own name/value
485
470
  // pair, the one channel a browser submits for the pressed button
486
- // alone. No `formaction` url is emitted, so the submission still
487
- // targets the page and the form-level identity is simply overridden
488
- // by this later entry.
471
+ // alone. No `formaction` url is emitted, so the submission targets
472
+ // whatever the FORM targets, and a form-level identity is simply
473
+ // overridden by this later entry.
489
474
  //
490
475
  // Refused here rather than at the `>` only where the answer cannot
491
- // change later: boundness of the ENCLOSING form is already decided
492
- // (its start tag is emitted), and an attribute written BEFORE the
493
- // hole is already in `out`. Everything else waits for the close,
476
+ // change later: an attribute written BEFORE the hole is already in
477
+ // `out`. Everything else waits for the close,
494
478
  // where `assertSubmitterStartTag` sees the whole tag.
495
479
  // A second binding hole on this same tag, refused here so the
496
480
  // author gets the duplicate message rather than a confusing
@@ -1035,14 +1019,12 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
1035
1019
  // Render the template to HTML. injectDSD recurses on the result so
1036
1020
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
1037
1021
  // get their own DSD pass.
1038
- // 'unknown' for the form scope (#1207): this is a SEPARATE render pass
1039
- // over one component's own template, driven by walking the already-emitted
1040
- // HTML, so it has no idea whether the host tag sits inside a bound
1041
- // `<form>`. Passing the default 'none' claimed there was no form at all,
1042
- // which refused a perfectly good `<button formaction=${fn}>` in a
1043
- // component inside a bound form and, because component SSR errors are
1044
- // isolated, made the button vanish from a page that still returned 200.
1045
- const rawInner = await render(tpl, ctx, 'unknown');
1022
+ // This is a SEPARATE render pass over one component's own template,
1023
+ // driven by walking the already-emitted HTML, so it has no idea whether
1024
+ // the host tag sits inside a `<form>`. It does not need to: a bound
1025
+ // submitter carries its whole submission (#1307), so nothing here asks
1026
+ // about an enclosing form.
1027
+ const rawInner = await render(tpl, ctx);
1046
1028
 
1047
1029
  if (isShadow) {
1048
1030
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
@@ -1279,7 +1261,7 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
1279
1261
  result += rest.slice(0, openStart);
1280
1262
  const attrs = m[1] || '';
1281
1263
  const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
1282
- const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
1264
+ const fallbackHtml = fbMatch ? decodeAttrEntities(fbMatch[1]) : '';
1283
1265
 
1284
1266
  // Pass the FULL-input ranges shifted into `rest` coordinates rather than
1285
1267
  // letting the helper re-tokenize the suffix. `rest` can begin mid-comment
@@ -1650,7 +1632,7 @@ function parseAttrs(attrStr) {
1650
1632
  function seedServerAttrs(instance, attrs) {
1651
1633
  if (!instance || typeof instance.setAttribute !== 'function') return;
1652
1634
  for (const [name, raw] of Object.entries(attrs)) {
1653
- instance.setAttribute(name, unescapeAttr(raw));
1635
+ instance.setAttribute(name, decodeAttrEntities(raw));
1654
1636
  }
1655
1637
  }
1656
1638
 
@@ -1701,67 +1683,41 @@ function appendReflectedAttrs(opening, instance, presentAttrNames) {
1701
1683
  * based on its static `properties` declaration.
1702
1684
  */
1703
1685
  function applyAttrsToInstance(instance, attrs, Cls) {
1704
- const props = Cls.properties || {};
1705
- for (const [key, raw] of Object.entries(attrs)) {
1706
- // Resolve the source attribute name to its property: a custom `attribute`
1707
- // option wins, else the kebab-cased property name, else the camelCase of
1708
- // the attribute (the common case). Mirrors the client attributeChangedCallback
1709
- // so a custom-attribute prop gets the right value in the SSR'd first paint.
1710
- let propName, rawDef;
1711
- for (const [k, decl] of Object.entries(props)) {
1712
- const d = typeof decl === 'object' ? decl : { type: decl };
1713
- if ((d.attribute || hyphenate(k)) === key) { propName = k; rawDef = decl; break; }
1714
- }
1715
- if (rawDef === undefined) {
1716
- rawDef = props[key] || props[camelCase(key)];
1717
- propName = props[key] ? key : camelCase(key);
1718
- }
1719
- if (!rawDef) {
1720
- instance[propName] = raw;
1721
- continue;
1722
- }
1723
- // The factory accepts a bare constructor shorthand (`{ expanded: Boolean }`)
1724
- // alongside the long form (`{ expanded: { type: Boolean } }`); normalize so
1725
- // the type-based coercion below sees a `{ type }` object either way.
1726
- const def = typeof rawDef === 'object' ? rawDef : { type: rawDef };
1727
- if (def.type === Number) instance[propName] = Number(raw);
1728
- else if (def.type === Boolean) instance[propName] = raw !== 'false';
1729
- else if (def.type === Object || def.type === Array) {
1730
- // `raw` is the entity-encoded attribute text (parseAttrs returns the
1731
- // literal characters between the quotes), so decode the HTML entities
1732
- // before JSON.parse. A JSON attribute carries `&quot;` for every `"`;
1733
- // parsing it raw throws, and the prop then falls to the failure value
1734
- // below rather than to the object the author wrote.
1735
- //
1736
- // An unparseable attribute falls back to `null`, NOT to the raw string
1737
- // (#1253), matching this branch's counterpart in
1738
- // `attributeChangedCallback`. They have to agree, or the same
1739
- // `<my-el cfg="oops">` SSRs holding a string and re-renders holding
1740
- // something else the moment the element upgrades. `null` is the right
1741
- // value to agree on: a string is never a valid value for a property the
1742
- // author declared `Object` or `Array`. This function iterates the
1743
- // attributes PRESENT on the source tag, so an absent one is not read here
1744
- // at all and its property keeps whatever the constructor gave it; the
1745
- // agreement being asserted is about a present, unparseable attribute.
1746
- //
1747
- // That agreement is about this FALLBACK, not a guarantee that the two
1748
- // readers see the same attributes in the first place. This function
1749
- // walks the parsed source tag while the client goes through
1750
- // `observedAttributes` and the browser's own name-lowercasing, and
1751
- // hand-written markup can land in the gaps between those routes (the
1752
- // entity decoding just below is one, since it reverses far less than a
1753
- // browser does). Those gaps predate #1253 and it neither causes nor
1754
- // closes any of them; no attempt is made here to enumerate them,
1755
- // because every attempt so far has been incomplete.
1756
- //
1757
- // Scoped to the DEFAULT converter, which is all this branch is. The
1758
- // client reader tries `converter.fromAttribute` FIRST and only falls
1759
- // through to here when there is none, while this function has no
1760
- // converter arm at all, so a prop declaring one is read differently by
1761
- // the two sides regardless of what this line does. That gap predates
1762
- // #1253 and is left alone rather than widened.
1763
- try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = null; }
1764
- } else instance[propName] = raw;
1686
+ for (const [sourceName, sourceValue] of Object.entries(attrs)) {
1687
+ // The browser LOWERCASES every attribute name while parsing an HTML
1688
+ // document, so `cfgData="x"` reaches the client reader as `cfgdata` and a
1689
+ // camelCase name can never match anything in `observedAttributes`.
1690
+ // Resolving the source case here made SSR read a name the platform cannot
1691
+ // deliver, which is the divergence, not the fix (#1341).
1692
+ const resolved = resolveAttributeProperty(Cls, sourceName.toLowerCase());
1693
+ // An attribute mapping to no attribute-backed property is IGNORED, exactly
1694
+ // as `attributeChangedCallback` ignores it and as lit's reader does. This
1695
+ // used to assign it as an instance property (`instance[propName] = raw`),
1696
+ // which no browser upgrade ever reproduces, and which on a real
1697
+ // HTMLElement could mutate DOM state through `id` / `hidden` / `slot`.
1698
+ // `seedServerAttrs` has already applied every source attribute properly,
1699
+ // so nothing needs the copy (#1341).
1700
+ if (resolved === undefined) continue;
1701
+ const { propName, def } = resolved;
1702
+ // A browser decodes every character reference BEFORE any reader sees the
1703
+ // value, so decode once here, for every branch, and hand the shared reader
1704
+ // an already-decoded string exactly as the DOM hands the client one. It
1705
+ // used to be a `decode` argument the reader applied on the JSON and
1706
+ // converter branches alone, which left a `String`-typed prop holding the
1707
+ // raw source text while `getAttribute()` on the client returned the decoded
1708
+ // one (#1341).
1709
+ //
1710
+ // One reader for both sides (#1340): `readAttributeValue` in
1711
+ // `attribute-reader.js` is the same function `attributeChangedCallback` in
1712
+ // `component.js` calls, so a custom `converter.fromAttribute` runs here
1713
+ // too, ahead of type coercion, and the #1253 unparseable-JSON fallback is
1714
+ // shared rather than mirrored.
1715
+ //
1716
+ // A converter that THROWS is not caught here. It lands in the
1717
+ // per-component error isolation below, which is deliberate: an author who
1718
+ // supplies a converter owns the read, the same rule `_reflectAttribute`
1719
+ // states for `toAttribute`. See the comment on `readAttributeValue`.
1720
+ instance[propName] = readAttributeValue(def, decodeAttrEntities(sourceValue));
1765
1721
  }
1766
1722
  }
1767
1723
 
@@ -1770,11 +1726,6 @@ function camelCase(s) {
1770
1726
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1771
1727
  }
1772
1728
 
1773
- /** Kebab-case a property name for its default HTML attribute (matches component.js). @param {string} s */
1774
- function hyphenate(s) {
1775
- return s.replace(/([A-Z])/g, '-$1').toLowerCase();
1776
- }
1777
-
1778
1729
  /**
1779
1730
  * Inverse of camelCase. `userName` -> `user-name`, `userID` -> `user-i-d`.
1780
1731
  * Used to serialize property-binding names into HTML attribute names,
@@ -1787,20 +1738,142 @@ function kebabCase(s) {
1787
1738
  return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
1788
1739
  }
1789
1740
 
1741
+ /** windows-1252 mappings the HTML tokenizer applies to the C1 range. */
1742
+ const C1_REPLACEMENTS = {
1743
+ 0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e, 0x85: 0x2026,
1744
+ 0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02c6, 0x89: 0x2030, 0x8a: 0x0160,
1745
+ 0x8b: 0x2039, 0x8c: 0x0152, 0x8e: 0x017d, 0x91: 0x2018, 0x92: 0x2019,
1746
+ 0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014,
1747
+ 0x98: 0x02dc, 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a, 0x9c: 0x0153,
1748
+ 0x9e: 0x017e, 0x9f: 0x0178,
1749
+ };
1750
+
1751
+ // The trailing `;` is OPTIONAL on the named arm, because a browser decodes a
1752
+ // legacy semicolon-less reference too. The callback decides which of the two
1753
+ // forms it has; see `decodeNamed`.
1754
+ const CHAR_REF = /&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|([a-zA-Z][a-zA-Z0-9]*)(;?))/g;
1755
+
1756
+ // A Map, not the imported object, so a name that collides with something on
1757
+ // `Object.prototype` misses instead of returning a function. See `decodeNamed`.
1758
+ const NAMED = new Map(Object.entries(NAMED_ENTITIES));
1759
+ const LEGACY = new Set(LEGACY_NAMES);
1760
+
1790
1761
  /**
1791
- * Reverse `escapeAttr` on a server-side attribute value. Needed
1792
- * because `parseAttrs` returns the literal characters between the
1793
- * quote marks; HTML entities are not decoded by the regex. The
1794
- * browser handles this automatically, so client-side reads via
1795
- * `getAttribute()` do not need the same step.
1762
+ * Decode HTML character references in an attribute value.
1763
+ *
1764
+ * `parseAttrs` hands back the literal characters between the quote marks, so
1765
+ * nothing has decoded them yet, while a browser decodes EVERY reference before
1766
+ * any reader sees the value. This closes that gap (#1341): the full WHATWG
1767
+ * named table plus decimal and hexadecimal numeric references, with the
1768
+ * tokenizer's own numeric fix-ups (null, a surrogate, and anything past
1769
+ * U+10FFFF become U+FFFD; the C1 range maps through windows-1252). It replaced
1770
+ * a three-entity `unescapeAttr`, which left a `String`-typed prop undecoded
1771
+ * entirely and turned `&lt;script&gt;` into the half-decoded `<script&gt;`.
1772
+ *
1773
+ * SINGLE PASS on purpose. A replacement is never rescanned, so `&amp;lt;`
1774
+ * decodes to the literal `&lt;` and never to `<`. The old function got that
1775
+ * from replacing `&amp;` last, which does not generalise past three entities.
1776
+ *
1777
+ * The 106 legacy semicolon-LESS names are covered too, because a browser really
1778
+ * does decode them and measurably: Chromium, Firefox, and WebKit all hand a
1779
+ * reader U+00A0 for `s="&nbsp"`. Leaving them literal would have been a value
1780
+ * divergence of exactly the kind this function exists to remove, not a
1781
+ * harmless non-goal. The rule that governs them, in an ATTRIBUTE value, is a
1782
+ * one-character LOOKAHEAD rather than deep tokenizer state, which is why it is
1783
+ * implementable here. `decodeNamed` carries the rule and the reason it takes
1784
+ * the shape it does; the short version is that `&nbsp` at the end of a value
1785
+ * decodes while `&nbspx`, `&nbsp=x`, and `&notin` stay literal, all three
1786
+ * verified against the three engines.
1787
+ *
1788
+ * The same function also decodes `data-webjs-fallback`, which is MARKUP, where
1789
+ * the tokenizer applies no such carve-out. Using the attribute rule there is
1790
+ * deliberate and strictly conservative: that payload is written by
1791
+ * `escapeAttr`, which emits only `&amp;` / `&quot;` / `&lt;`, so every
1792
+ * reference in it is semicolon-terminated and never reaches this path.
1796
1793
  *
1797
1794
  * @param {string} s
1795
+ * @returns {string}
1796
+ */
1797
+ function decodeAttrEntities(s) {
1798
+ // Load bearing, not a micro-optimisation: skipping the scan for the common
1799
+ // no-`&` value is what makes this cheaper per attribute than the three
1800
+ // chained `replace` calls it replaced, which paid for three passes always.
1801
+ if (s.indexOf('&') === -1) return s;
1802
+ return s.replace(CHAR_REF, (match, dec, hex, name, semi, offset) => {
1803
+ if (dec !== undefined) return fromCodePoint(parseInt(dec, 10));
1804
+ if (hex !== undefined) return fromCodePoint(parseInt(hex, 16));
1805
+ return decodeNamed(match, name, semi === ';', s[offset + match.length]);
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * Resolve one named reference, semicolon-terminated or legacy.
1811
+ *
1812
+ * The table is read through a `Map` rather than by indexing the imported
1813
+ * object. An object-literal lookup resolves through `Object.prototype`, so
1814
+ * `&constructor;` / `&toString;` / `&hasOwnProperty;` and four more returned a
1815
+ * FUNCTION instead of `undefined` and threw on the spread in
1816
+ * `codePointsToString`, a path `seedServerAttrs` reaches for every attribute of
1817
+ * every custom element, which rendered the whole component as an SSR error box.
1818
+ * A browser leaves those literal, since they are not named references, so the
1819
+ * throw was a divergence of exactly the kind this file exists to remove. A
1820
+ * `Map` has no prototype chain to fall through, which closes the shape rather
1821
+ * than guarding one call site.
1822
+ *
1823
+ * WHY THERE IS NO LONGEST-PREFIX LOOP. The tokenizer consumes the longest name
1824
+ * in the table, so `&notin` is `&not` followed by `in`. Here that always
1825
+ * collapses to the whole name: `CHAR_REF` captures `[a-zA-Z][a-zA-Z0-9]*`, so a
1826
+ * prefix SHORTER than the name is by construction followed by an ASCII
1827
+ * alphanumeric, which is precisely when the attribute carve-out declines to
1828
+ * decode. A loop over shorter prefixes could therefore only ever return
1829
+ * `match`, which is what falling through to the end already does. `&notin`
1830
+ * stays literal either way, verified against the three engines, though note it
1831
+ * gets there by not being a legacy name rather than by the carve-out.
1832
+ *
1833
+ * That same greediness means an ASCII alphanumeric character can never follow
1834
+ * the match: whatever follows is by construction not `[a-zA-Z0-9]`, or the
1835
+ * capture would have eaten it. So only the `=` check is needed for the
1836
+ * attribute carve-out. `&nbspx` is literal because `nbspx` is not a legacy
1837
+ * name, NOT because of the lookahead; `&nbsp=x` is the shape the lookahead
1838
+ * actually decides.
1839
+ *
1840
+ * @param {string} match the whole matched reference, returned unchanged when nothing decodes
1841
+ * @param {string} name the name, with no `&` and no `;`
1842
+ * @param {boolean} hadSemi
1843
+ * @param {string|undefined} nextChar the character after the match, if any
1844
+ * @returns {string}
1845
+ */
1846
+ function decodeNamed(match, name, hadSemi, nextChar) {
1847
+ if (hadSemi) {
1848
+ const cp = NAMED.get(name);
1849
+ return cp === undefined ? match : codePointsToString(cp);
1850
+ }
1851
+ if (!LEGACY.has(name)) return match;
1852
+ // The attribute carve-out: a legacy name decodes only when what follows is
1853
+ // neither `=` nor an ASCII alphanumeric. Nothing following at all (the end of
1854
+ // the value) decodes, which is the common `s="&nbsp"` shape. Because CHAR_REF
1855
+ // captures greedily, what follows is never an ASCII alphanumeric, so only `=`
1856
+ // needs to be checked here.
1857
+ if (nextChar === '=') return match;
1858
+ // Every legacy name is in the table under the same name, asserted by a test
1859
+ // rather than left to trust, so this cannot miss.
1860
+ return codePointsToString(NAMED.get(name));
1861
+ }
1862
+
1863
+ /** @param {number|number[]} cp */
1864
+ function codePointsToString(cp) {
1865
+ return typeof cp === 'number' ? String.fromCodePoint(cp) : String.fromCodePoint(...cp);
1866
+ }
1867
+
1868
+ /**
1869
+ * The tokenizer's numeric character reference fix-ups.
1870
+ * @param {number} n
1871
+ * @returns {string}
1798
1872
  */
1799
- function unescapeAttr(s) {
1800
- return s
1801
- .replace(/&lt;/g, '<')
1802
- .replace(/&quot;/g, '"')
1803
- .replace(/&amp;/g, '&');
1873
+ function fromCodePoint(n) {
1874
+ if (n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return '\uFFFD';
1875
+ if (C1_REPLACEMENTS[n] !== undefined) return String.fromCodePoint(C1_REPLACEMENTS[n]);
1876
+ return String.fromCodePoint(n);
1804
1877
  }
1805
1878
 
1806
1879
  /**
@@ -1819,7 +1892,7 @@ function consumePropAttrs(attrs) {
1819
1892
  if (!key.startsWith('data-webjs-prop-')) continue;
1820
1893
  const propName = camelCase(key.slice('data-webjs-prop-'.length));
1821
1894
  try {
1822
- props[propName] = parse(unescapeAttr(attrs[key]));
1895
+ props[propName] = parse(decodeAttrEntities(attrs[key]));
1823
1896
  } catch {
1824
1897
  // Malformed payload. Skip silently so the rest of the component
1825
1898
  // can still render. The client-side hydration will also try and
@@ -2009,8 +2082,6 @@ async function streamTemplate(tr, ctx, controller) {
2009
2082
  let pendingSubmitterProps = [];
2010
2083
  /** @type {string | null} */
2011
2084
  let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
2012
- // See the buffered machine: seeded from the caller so a nested template
2013
- // rendered into a bound form's children knows it is inside one.
2014
2085
  let isCloseTag = false;
2015
2086
 
2016
2087
  // See the buffered machine for why this runs at the `>` rather than at the
@@ -10,8 +10,19 @@
10
10
  * This object is read key by key, mostly by the server and for a few keys
11
11
  * by the CLI. Without a type or schema a typo'd key (e.g. `redirect` for
12
12
  * `redirects`) was silently dropped and the feature stayed at its default
13
- * with no diagnostic. This type plus the published JSON Schema close that
14
- * gap.
13
+ * with no diagnostic. Three things close that gap now, at three different
14
+ * moments: this type while an agent or a human authors the block, the
15
+ * published JSON Schema in an editor via the scaffold's `.vscode` `$ref`,
16
+ * and `webjs-config-validate.js` at BOOT (#1300), which runs that same
17
+ * schema from `createRequestHandler` and warns once, naming every unknown
18
+ * top-level key, every bad `enum` value, and every wrong-typed `boolean` /
19
+ * `integer` (9 of the 17 keys). It does NOT type-check the other 8
20
+ * (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`,
21
+ * `start`, `doctor`), and it does not descend into a nested object, so a
22
+ * misspelling inside `dev` or `start` is not reported either. Whether
23
+ * anything else notices what this check passes over is up to the
24
+ * individual reader. The boot check warns and never throws, so a typo
25
+ * costs one feature its setting rather than the whole app its boot.
15
26
  *
16
27
  * LOCKSTEP: this file, the JSON Schema at
17
28
  * packages/server/webjs-config.schema.json, and the reader functions MUST