@webjsdev/core 0.7.48 → 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.
- package/README.md +3 -2
- package/dist/webjs-core-browser.js +4 -4
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +18 -18
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +5 -5
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +12 -4
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +178 -0
- package/src/component.d.ts +10 -0
- package/src/component.js +89 -32
- package/src/html-entities.js +2179 -0
- package/src/render-client.js +4 -5
- package/src/render-server.js +192 -90
- package/src/router-client.js +28 -10
- package/src/webjs-config.d.ts +13 -2
- package/src/websocket-client.js +5 -2
package/src/render-client.js
CHANGED
|
@@ -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.
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
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
|
package/src/render-server.js
CHANGED
|
@@ -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
|
|
@@ -400,12 +385,15 @@ async function renderTemplate(tr, ctx) {
|
|
|
400
385
|
// page-level `.prop` on a native element could have set the
|
|
401
386
|
// property to begin with.
|
|
402
387
|
out = out.slice(0, attrStart);
|
|
403
|
-
// `<webjs-suspense .fallback=${html`...`}>` (#471).
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
388
|
+
// `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
|
|
389
|
+
// defined only in the browser, so the injectDSD walk skips it
|
|
390
|
+
// (`lookup(tag)` finds no class) and no server-side instance runs
|
|
391
|
+
// consumePropAttrs. A normal data-webjs-prop-* binding would then
|
|
392
|
+
// land at connectedCallback, too late for the streaming placeholder.
|
|
393
|
+
// So render the fallback to HTML now and carry it as
|
|
394
|
+
// data-webjs-fallback, which the injectDSD streaming pre-pass reads
|
|
395
|
+
// as the boundary placeholder. (The value itself would serialize
|
|
396
|
+
// fine: a TemplateResult is a plain {strings, values} object.)
|
|
409
397
|
if (currentTag === 'webjs-suspense' && name === 'fallback') {
|
|
410
398
|
const fbHtml = await render(val, ctx);
|
|
411
399
|
out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
|
|
@@ -480,14 +468,13 @@ async function renderTemplate(tr, ctx) {
|
|
|
480
468
|
// #1207: the submitter binding. The identity replaces the
|
|
481
469
|
// `formaction=` hole IN PLACE with the button's own name/value
|
|
482
470
|
// pair, the one channel a browser submits for the pressed button
|
|
483
|
-
// alone. No `formaction` url is emitted, so the submission
|
|
484
|
-
//
|
|
485
|
-
// 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.
|
|
486
474
|
//
|
|
487
475
|
// Refused here rather than at the `>` only where the answer cannot
|
|
488
|
-
// change later:
|
|
489
|
-
//
|
|
490
|
-
// 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,
|
|
491
478
|
// where `assertSubmitterStartTag` sees the whole tag.
|
|
492
479
|
// A second binding hole on this same tag, refused here so the
|
|
493
480
|
// author gets the duplicate message rather than a confusing
|
|
@@ -1032,14 +1019,12 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
1032
1019
|
// Render the template to HTML. injectDSD recurses on the result so
|
|
1033
1020
|
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
|
|
1034
1021
|
// get their own DSD pass.
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
// isolated, made the button vanish from a page that still returned 200.
|
|
1042
|
-
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);
|
|
1043
1028
|
|
|
1044
1029
|
if (isShadow) {
|
|
1045
1030
|
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
@@ -1276,7 +1261,7 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
1276
1261
|
result += rest.slice(0, openStart);
|
|
1277
1262
|
const attrs = m[1] || '';
|
|
1278
1263
|
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
1279
|
-
const fallbackHtml = fbMatch ?
|
|
1264
|
+
const fallbackHtml = fbMatch ? decodeAttrEntities(fbMatch[1]) : '';
|
|
1280
1265
|
|
|
1281
1266
|
// Pass the FULL-input ranges shifted into `rest` coordinates rather than
|
|
1282
1267
|
// letting the helper re-tokenize the suffix. `rest` can begin mid-comment
|
|
@@ -1647,7 +1632,7 @@ function parseAttrs(attrStr) {
|
|
|
1647
1632
|
function seedServerAttrs(instance, attrs) {
|
|
1648
1633
|
if (!instance || typeof instance.setAttribute !== 'function') return;
|
|
1649
1634
|
for (const [name, raw] of Object.entries(attrs)) {
|
|
1650
|
-
instance.setAttribute(name,
|
|
1635
|
+
instance.setAttribute(name, decodeAttrEntities(raw));
|
|
1651
1636
|
}
|
|
1652
1637
|
}
|
|
1653
1638
|
|
|
@@ -1698,39 +1683,41 @@ function appendReflectedAttrs(opening, instance, presentAttrNames) {
|
|
|
1698
1683
|
* based on its static `properties` declaration.
|
|
1699
1684
|
*/
|
|
1700
1685
|
function applyAttrsToInstance(instance, attrs, Cls) {
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
//
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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));
|
|
1734
1721
|
}
|
|
1735
1722
|
}
|
|
1736
1723
|
|
|
@@ -1739,11 +1726,6 @@ function camelCase(s) {
|
|
|
1739
1726
|
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1740
1727
|
}
|
|
1741
1728
|
|
|
1742
|
-
/** Kebab-case a property name for its default HTML attribute (matches component.js). @param {string} s */
|
|
1743
|
-
function hyphenate(s) {
|
|
1744
|
-
return s.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
1729
|
/**
|
|
1748
1730
|
* Inverse of camelCase. `userName` -> `user-name`, `userID` -> `user-i-d`.
|
|
1749
1731
|
* Used to serialize property-binding names into HTML attribute names,
|
|
@@ -1756,20 +1738,142 @@ function kebabCase(s) {
|
|
|
1756
1738
|
return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
1757
1739
|
}
|
|
1758
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
|
+
|
|
1759
1761
|
/**
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
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 `<script>` into the half-decoded `<script>`.
|
|
1772
|
+
*
|
|
1773
|
+
* SINGLE PASS on purpose. A replacement is never rescanned, so `&lt;`
|
|
1774
|
+
* decodes to the literal `<` and never to `<`. The old function got that
|
|
1775
|
+
* from replacing `&` 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=" "`. 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 ` ` at the end of a value
|
|
1785
|
+
* decodes while ` x`, ` =x`, and `¬in` 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 `&` / `"` / `<`, so every
|
|
1792
|
+
* reference in it is semicolon-terminated and never reaches this path.
|
|
1765
1793
|
*
|
|
1766
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 `¬in` is `¬` 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. `¬in`
|
|
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. ` x` is literal because `nbspx` is not a legacy
|
|
1837
|
+
* name, NOT because of the lookahead; ` =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=" "` 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}
|
|
1767
1872
|
*/
|
|
1768
|
-
function
|
|
1769
|
-
return
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
.replace(/&/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);
|
|
1773
1877
|
}
|
|
1774
1878
|
|
|
1775
1879
|
/**
|
|
@@ -1788,7 +1892,7 @@ function consumePropAttrs(attrs) {
|
|
|
1788
1892
|
if (!key.startsWith('data-webjs-prop-')) continue;
|
|
1789
1893
|
const propName = camelCase(key.slice('data-webjs-prop-'.length));
|
|
1790
1894
|
try {
|
|
1791
|
-
props[propName] = parse(
|
|
1895
|
+
props[propName] = parse(decodeAttrEntities(attrs[key]));
|
|
1792
1896
|
} catch {
|
|
1793
1897
|
// Malformed payload. Skip silently so the rest of the component
|
|
1794
1898
|
// can still render. The client-side hydration will also try and
|
|
@@ -1978,8 +2082,6 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1978
2082
|
let pendingSubmitterProps = [];
|
|
1979
2083
|
/** @type {string | null} */
|
|
1980
2084
|
let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
|
|
1981
|
-
// See the buffered machine: seeded from the caller so a nested template
|
|
1982
|
-
// rendered into a bound form's children knows it is inside one.
|
|
1983
2085
|
let isCloseTag = false;
|
|
1984
2086
|
|
|
1985
2087
|
// See the buffered machine for why this runs at the `>` rather than at the
|
package/src/router-client.js
CHANGED
|
@@ -859,9 +859,13 @@ function onSubmit(e) {
|
|
|
859
859
|
const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
|
|
860
860
|
if (submitter && submitter.hasAttribute('data-no-router')) return;
|
|
861
861
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
862
|
+
// Presence, not truthiness, exactly as `getSubmitAction` resolves the action
|
|
863
|
+
// (#1322). The form-submission algorithm asks whether the submitter HAS a
|
|
864
|
+
// `formtarget`, so a present-but-empty one overrides the form and then means
|
|
865
|
+
// the current browsing context, per the rules for choosing a navigable.
|
|
866
|
+
const target = (submitter && submitter.hasAttribute('formtarget'))
|
|
867
|
+
? (submitter.getAttribute('formtarget') || '')
|
|
868
|
+
: (form.getAttribute('target') || '');
|
|
865
869
|
if (target && target !== '_self') return;
|
|
866
870
|
|
|
867
871
|
const method = getSubmitMethod(form, submitter);
|
|
@@ -911,14 +915,21 @@ function onSubmit(e) {
|
|
|
911
915
|
* Method resolution: submitter's `formmethod` wins over form's `method`.
|
|
912
916
|
* Returns lowercase.
|
|
913
917
|
*
|
|
918
|
+
* Resolved on PRESENCE, not truthiness (#1322), the same way `getSubmitAction`
|
|
919
|
+
* below resolves the action. The form-submission algorithm reads the
|
|
920
|
+
* submitter's `formmethod` if the submitter HAS one, so a present-but-empty
|
|
921
|
+
* `formmethod=""` overrides the form and then falls to its own invalid-value
|
|
922
|
+
* default, GET. Resolving with `||` instead sent a multipart POST for a button
|
|
923
|
+
* every engine submits as `GET`, which is a JS-on versus JS-off divergence.
|
|
924
|
+
*
|
|
914
925
|
* @param {HTMLFormElement} form
|
|
915
926
|
* @param {HTMLElement | null} submitter
|
|
916
927
|
*/
|
|
917
928
|
function getSubmitMethod(form, submitter) {
|
|
918
|
-
const v = (submitter && submitter.
|
|
919
|
-
|
|
920
|
-
|| '
|
|
921
|
-
return v.toLowerCase();
|
|
929
|
+
const v = (submitter && submitter.hasAttribute('formmethod'))
|
|
930
|
+
? (submitter.getAttribute('formmethod') || '')
|
|
931
|
+
: (form.getAttribute('method') || '');
|
|
932
|
+
return (v || 'get').toLowerCase();
|
|
922
933
|
}
|
|
923
934
|
|
|
924
935
|
/**
|
|
@@ -965,12 +976,19 @@ function normalizeEnctype(raw) {
|
|
|
965
976
|
* `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
|
|
966
977
|
* resolves it the same way, in `core/drive/form_submission.js`.
|
|
967
978
|
*
|
|
979
|
+
* On PRESENCE, not truthiness (#1322), for the reason spelled out on
|
|
980
|
+
* `getSubmitMethod`: a present-but-empty `formenctype=""` overrides the form
|
|
981
|
+
* and normalizes to urlencoded, its own invalid-value default, so a button on
|
|
982
|
+
* a multipart form sends urlencoded with JS on exactly as it does with JS off.
|
|
983
|
+
*
|
|
968
984
|
* @param {HTMLFormElement} form
|
|
969
985
|
* @param {HTMLElement | null} submitter
|
|
970
986
|
*/
|
|
971
987
|
function getSubmitEnctype(form, submitter) {
|
|
972
988
|
return normalizeEnctype(
|
|
973
|
-
(submitter && submitter.
|
|
989
|
+
(submitter && submitter.hasAttribute('formenctype'))
|
|
990
|
+
? submitter.getAttribute('formenctype')
|
|
991
|
+
: form.getAttribute('enctype'),
|
|
974
992
|
);
|
|
975
993
|
}
|
|
976
994
|
|
|
@@ -1537,7 +1555,7 @@ function setNavigating(on) {
|
|
|
1537
1555
|
* @returns {Map<string, { routeKey: string, start: Comment, end: Comment }> | null}
|
|
1538
1556
|
* The boundary map, or null when the tree's boundaries are malformed.
|
|
1539
1557
|
*/
|
|
1540
|
-
|
|
1558
|
+
function collectBoundaries(root) {
|
|
1541
1559
|
/** @type {Map<string, { routeKey: string, start: Comment, end: Comment }>} */
|
|
1542
1560
|
const out = new Map();
|
|
1543
1561
|
/** @type {{ segment: string, routeKey: string, start: Comment }[]} */
|
|
@@ -1654,7 +1672,7 @@ export function collectBoundaries(root) {
|
|
|
1654
1672
|
* live: { routeKey: string, start: Comment, end: Comment },
|
|
1655
1673
|
* incoming: { routeKey: string, start: Comment, end: Comment } } | null}
|
|
1656
1674
|
*/
|
|
1657
|
-
|
|
1675
|
+
function planBoundarySwap(here, there) {
|
|
1658
1676
|
// Shared segments, shallowest first (a nested path prefix is shorter).
|
|
1659
1677
|
const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
|
|
1660
1678
|
if (shared.length === 0) return null;
|
package/src/webjs-config.d.ts
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
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
|
package/src/websocket-client.js
CHANGED
|
@@ -81,7 +81,8 @@ export function connectWS(url, opts = {}) {
|
|
|
81
81
|
|
|
82
82
|
return {
|
|
83
83
|
/** Send a message. Objects are JSON-stringified; strings and binary pass through.
|
|
84
|
-
* If the socket isn't open yet, the message is queued until it is.
|
|
84
|
+
* If the socket isn't open yet, the message is queued until it is.
|
|
85
|
+
* @param {string | ArrayBuffer | ArrayBufferView | object} data */
|
|
85
86
|
send(data) {
|
|
86
87
|
const payload =
|
|
87
88
|
typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)
|
|
@@ -90,7 +91,9 @@ export function connectWS(url, opts = {}) {
|
|
|
90
91
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(/** @type any */ (payload));
|
|
91
92
|
else queue.push(payload);
|
|
92
93
|
},
|
|
93
|
-
/** Permanently close the socket. Disables reconnect.
|
|
94
|
+
/** Permanently close the socket. Disables reconnect.
|
|
95
|
+
* @param {number} [code]
|
|
96
|
+
* @param {string} [reason] */
|
|
94
97
|
close(code, reason) {
|
|
95
98
|
stopped = true;
|
|
96
99
|
try { ws?.close(code, reason); } catch {}
|