@barefootjs/shared 0.18.3 → 0.18.5

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.
@@ -42,9 +42,11 @@ export declare function toHTMLAttrName(key: string): string;
42
42
  * Runtime variant of attribute name mapping that additionally applies
43
43
  * camelCase→kebab conversion for `data-*` and `aria-*` convenience
44
44
  * props (e.g. `dataTestId` → `data-test-id`) and preserves SVG XML
45
- * attribute casing. All other non-SVG keys pass through unchanged
46
- * standard HTML attributes like `tabIndex` and `autoFocus` must not
47
- * be kebab-cased (`tab-index` / `auto-focus` are not valid attrs).
45
+ * attribute casing. HTML camelCase aliases resolve through the same
46
+ * `HTML_CAMEL_ALIASES` table as the compile-time variant (`tabIndex`
47
+ * `tabindex`); generic kebab-casing still applies ONLY to `data-*` /
48
+ * `aria-*` — an unknown camelCase key passes through unchanged rather
49
+ * than being guessed into a hyphenated non-attribute.
48
50
  */
49
51
  export declare function toHTMLAttrNameRuntime(key: string): string;
50
52
  /**
@@ -0,0 +1,43 @@
1
+ /**
2
+ * HTML character-reference decoding and escaping for STATIC template
3
+ * content.
4
+ *
5
+ * JSX decodes character references at parse time: `<span>Fish &amp;
6
+ * Chips</span>` means the TEXT `Fish & Chips`, and `&copy;` means `©`
7
+ * (Babel/esbuild/TypeScript's JSX emit all decode). Phase 1
8
+ * (`jsx-to-ir`) applies `decodeEntities` once so `IRText.value` and
9
+ * static attribute values carry the DECODED text — the semantics —
10
+ * and every adapter re-escapes for its own emission context
11
+ * (`escapeHtml` for HTML template output; the Hono adapter
12
+ * re-encodes for JSX source). An adapter that emitted the raw entity
13
+ * text passed `&copy;` through as bytes while the reference decoded it
14
+ * (the `html-entity-text` divergence), and one that skipped
15
+ * re-escaping emitted a parse-corrupting `<`.
16
+ *
17
+ * The named table is the curated set below, not the full HTML5 list
18
+ * (~2,200 names): an unknown name (`&foo;`) is left as raw text, so
19
+ * BOTH the reference adapter and the template adapters receive the
20
+ * same undecoded string from the IR and stay byte-identical — the
21
+ * degradation is consistent, exactly how a browser treats an unknown
22
+ * reference. Numeric references (`&#169;` / `&#xA9;`) decode fully.
23
+ */
24
+ /**
25
+ * Decode HTML character references in JSX literal text / static
26
+ * attribute values: numeric decimal (`&#169;`), numeric hex
27
+ * (`&#xA9;`), and the curated named set above. Anything unrecognized
28
+ * (unknown name, malformed numeric, bare `&`) is left verbatim.
29
+ */
30
+ export declare function decodeEntities(text: string): string;
31
+ /**
32
+ * Escape decoded static text for direct HTML emission: `&` `<` `>`
33
+ * `"` to their named forms. Used for BOTH text nodes and double-quoted
34
+ * attribute values — one set, no context-dependent under-escaping.
35
+ *
36
+ * `'` is deliberately NOT escaped: the reference (Hono JSX) escapes it
37
+ * as `&#39;`, but raw `'` is valid everywhere outside single-quoted
38
+ * attributes (which no adapter emits), and the conformance harness's
39
+ * `normalizeHTML` canonicalises the raw and entity forms to one
40
+ * spelling on both sides — so leaving apostrophes raw keeps every
41
+ * existing template byte-stable instead of rewriting all prose text.
42
+ */
43
+ export declare function escapeHtml(text: string): string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { BF_SCOPE, BF_SLOT, BF_HOST, BF_AT, BF_ROOT, BF_PROPS, BF_COND, BF_ITEM, BF_PORTAL_OWNER, BF_PORTAL_ID, BF_PORTAL_PLACEHOLDER, BF_PARENT_OWNED_PREFIX, BF_SCOPE_COMMENT_PREFIX, BF_LOOP_START, BF_LOOP_END, BF_LOOP_ITEM, loopItemMarker, loopStartMarker, loopEndMarker, BF_KEY, BF_KEY_PREFIX, BF_PLACEHOLDER, BF_ASYNC, BF_ASYNC_RESOLVE, BF_REGION, BF_PARENT_SCOPE_PLACEHOLDER, BF_SEAM_HYDRATE, BF_SEAM_HYDRATE_WITHIN, BF_SEAM_DISPOSE_WITHIN, BF_SEAM_PUSH_SEARCH, BF_SEAM_NAV_SEARCH, } from './markers.ts';
2
2
  export { classifyDOMProp, toHTMLAttrName, toHTMLAttrNameRuntime, isBooleanAttr, isEventProp, BOOLEAN_ATTRS, } from './dom-prop.ts';
3
3
  export type { DOMPropKind, DOMPropClassification } from './dom-prop.ts';
4
+ export { decodeEntities, escapeHtml } from './html-entities.ts';
4
5
  export type { ProfilerEvent, ProfilerEventType, ProfilerSubscriberKind, } from './profiler-events.ts';
package/dist/index.js CHANGED
@@ -84,6 +84,63 @@ var SVG_CAMEL_TO_KEBAB = {
84
84
  markerMid: "marker-mid",
85
85
  markerEnd: "marker-end"
86
86
  };
87
+ var HTML_CAMEL_ALIASES = {
88
+ acceptCharset: "accept-charset",
89
+ accessKey: "accesskey",
90
+ allowFullScreen: "allowfullscreen",
91
+ autoCapitalize: "autocapitalize",
92
+ autoComplete: "autocomplete",
93
+ autoCorrect: "autocorrect",
94
+ autoFocus: "autofocus",
95
+ autoPlay: "autoplay",
96
+ cellPadding: "cellpadding",
97
+ cellSpacing: "cellspacing",
98
+ charSet: "charset",
99
+ colSpan: "colspan",
100
+ contentEditable: "contenteditable",
101
+ controlsList: "controlslist",
102
+ crossOrigin: "crossorigin",
103
+ dateTime: "datetime",
104
+ dirName: "dirname",
105
+ encType: "enctype",
106
+ enterKeyHint: "enterkeyhint",
107
+ fetchPriority: "fetchpriority",
108
+ formAction: "formaction",
109
+ formEncType: "formenctype",
110
+ formMethod: "formmethod",
111
+ formNoValidate: "formnovalidate",
112
+ formTarget: "formtarget",
113
+ frameBorder: "frameborder",
114
+ hrefLang: "hreflang",
115
+ httpEquiv: "http-equiv",
116
+ imageSizes: "imagesizes",
117
+ imageSrcSet: "imagesrcset",
118
+ inputMode: "inputmode",
119
+ itemID: "itemid",
120
+ itemProp: "itemprop",
121
+ itemRef: "itemref",
122
+ itemScope: "itemscope",
123
+ itemType: "itemtype",
124
+ marginHeight: "marginheight",
125
+ marginWidth: "marginwidth",
126
+ maxLength: "maxlength",
127
+ minLength: "minlength",
128
+ noModule: "nomodule",
129
+ noValidate: "novalidate",
130
+ playsInline: "playsinline",
131
+ popoverTarget: "popovertarget",
132
+ popoverTargetAction: "popovertargetaction",
133
+ radioGroup: "radiogroup",
134
+ readOnly: "readonly",
135
+ referrerPolicy: "referrerpolicy",
136
+ rowSpan: "rowspan",
137
+ spellCheck: "spellcheck",
138
+ srcDoc: "srcdoc",
139
+ srcLang: "srclang",
140
+ srcSet: "srcset",
141
+ tabIndex: "tabindex",
142
+ useMap: "usemap"
143
+ };
87
144
  var SVG_XML_CAMEL_ATTRS = new Set([
88
145
  "allowReorder",
89
146
  "attributeName",
@@ -178,6 +235,9 @@ function toHTMLAttrName(key) {
178
235
  return "class";
179
236
  if (key === "htmlFor")
180
237
  return "for";
238
+ const htmlAlias = HTML_CAMEL_ALIASES[key];
239
+ if (htmlAlias !== undefined)
240
+ return htmlAlias;
181
241
  const svgKebab = SVG_CAMEL_TO_KEBAB[key];
182
242
  if (svgKebab !== undefined)
183
243
  return svgKebab;
@@ -188,6 +248,9 @@ function toHTMLAttrNameRuntime(key) {
188
248
  return "class";
189
249
  if (key === "htmlFor")
190
250
  return "for";
251
+ const htmlAlias = HTML_CAMEL_ALIASES[key];
252
+ if (htmlAlias !== undefined)
253
+ return htmlAlias;
191
254
  const svgKebab = SVG_CAMEL_TO_KEBAB[key];
192
255
  if (svgKebab !== undefined)
193
256
  return svgKebab;
@@ -201,6 +264,76 @@ function toHTMLAttrNameRuntime(key) {
201
264
  function isBooleanAttr(name) {
202
265
  return BOOLEAN_ATTRS.has(name.toLowerCase());
203
266
  }
267
+ // src/html-entities.ts
268
+ var NAMED_ENTITIES = {
269
+ amp: "&",
270
+ lt: "<",
271
+ gt: ">",
272
+ quot: '"',
273
+ apos: "'",
274
+ nbsp: " ",
275
+ copy: "©",
276
+ reg: "®",
277
+ trade: "™",
278
+ deg: "°",
279
+ plusmn: "±",
280
+ times: "×",
281
+ divide: "÷",
282
+ middot: "·",
283
+ bull: "•",
284
+ hellip: "…",
285
+ ndash: "–",
286
+ mdash: "—",
287
+ lsquo: "‘",
288
+ rsquo: "’",
289
+ ldquo: "“",
290
+ rdquo: "”",
291
+ laquo: "«",
292
+ raquo: "»",
293
+ sect: "§",
294
+ para: "¶",
295
+ dagger: "†",
296
+ Dagger: "‡",
297
+ euro: "€",
298
+ pound: "£",
299
+ yen: "¥",
300
+ cent: "¢",
301
+ sup1: "¹",
302
+ sup2: "²",
303
+ sup3: "³",
304
+ frac12: "½",
305
+ frac14: "¼",
306
+ frac34: "¾",
307
+ larr: "←",
308
+ uarr: "↑",
309
+ rarr: "→",
310
+ darr: "↓",
311
+ harr: "↔",
312
+ minus: "−",
313
+ infin: "∞",
314
+ ne: "≠",
315
+ le: "≤",
316
+ ge: "≥"
317
+ };
318
+ function decodeEntities(text) {
319
+ return text.replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body) => {
320
+ if (body[0] === "#") {
321
+ const isHex = body[1] === "x" || body[1] === "X";
322
+ const digits = body.slice(isHex ? 2 : 1);
323
+ if (!isHex && !/^[0-9]+$/.test(digits))
324
+ return match;
325
+ const code = parseInt(digits, isHex ? 16 : 10);
326
+ if (!Number.isFinite(code) || code > 1114111 || code >= 55296 && code <= 57343) {
327
+ return match;
328
+ }
329
+ return String.fromCodePoint(code);
330
+ }
331
+ return NAMED_ENTITIES[body] ?? match;
332
+ });
333
+ }
334
+ function escapeHtml(text) {
335
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
336
+ }
204
337
  export {
205
338
  toHTMLAttrNameRuntime,
206
339
  toHTMLAttrName,
@@ -209,6 +342,8 @@ export {
209
342
  loopEndMarker,
210
343
  isEventProp,
211
344
  isBooleanAttr,
345
+ escapeHtml,
346
+ decodeEntities,
212
347
  classifyDOMProp,
213
348
  BOOLEAN_ATTRS,
214
349
  BF_SLOT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/shared",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "Shared constants for BarefootJS compiler and runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -106,12 +106,27 @@ describe('toHTMLAttrName (compile-time)', () => {
106
106
  expect(toHTMLAttrName('strokeWidth')).toBe('stroke-width')
107
107
  })
108
108
 
109
- test('tabIndex passes through (no generic kebab)', () => {
110
- expect(toHTMLAttrName('tabIndex')).toBe('tabIndex')
109
+ // #2172: HTML camelCase aliases resolve through HTML_CAMEL_ALIASES
110
+ // these are known HTML attributes with a defined lowercase spelling,
111
+ // NOT the generic kebab conversion (which stays data-*/aria-* only).
112
+ test('tabIndex lowers to tabindex (HTML alias table)', () => {
113
+ expect(toHTMLAttrName('tabIndex')).toBe('tabindex')
111
114
  })
112
115
 
113
- test('autoFocus passes through (no generic kebab)', () => {
114
- expect(toHTMLAttrName('autoFocus')).toBe('autoFocus')
116
+ test('autoFocus lowers to autofocus (HTML alias table)', () => {
117
+ expect(toHTMLAttrName('autoFocus')).toBe('autofocus')
118
+ })
119
+
120
+ test('readOnly lowers to the BOOLEAN_ATTRS member readonly', () => {
121
+ expect(toHTMLAttrName('readOnly')).toBe('readonly')
122
+ })
123
+
124
+ test('spellCheck lowers to the enumerated (non-boolean) spellcheck', () => {
125
+ expect(toHTMLAttrName('spellCheck')).toBe('spellcheck')
126
+ })
127
+
128
+ test('unknown camelCase names still pass through unchanged', () => {
129
+ expect(toHTMLAttrName('myCustomAttr')).toBe('myCustomAttr')
115
130
  })
116
131
  })
117
132
 
@@ -144,12 +159,24 @@ describe('toHTMLAttrNameRuntime', () => {
144
159
  expect(toHTMLAttrNameRuntime('ariaLabel')).toBe('aria-label')
145
160
  })
146
161
 
147
- test('tabIndex passes through (no kebab)', () => {
148
- expect(toHTMLAttrNameRuntime('tabIndex')).toBe('tabIndex')
162
+ test('tabIndex lowers to tabindex (HTML alias table, #2172)', () => {
163
+ expect(toHTMLAttrNameRuntime('tabIndex')).toBe('tabindex')
164
+ })
165
+
166
+ test('autoFocus lowers to autofocus (HTML alias table, #2172)', () => {
167
+ expect(toHTMLAttrNameRuntime('autoFocus')).toBe('autofocus')
168
+ })
169
+
170
+ test('readOnly lowers to the boolean attr spelling readonly (HTML alias table, #2172)', () => {
171
+ expect(toHTMLAttrNameRuntime('readOnly')).toBe('readonly')
172
+ })
173
+
174
+ test('spellCheck lowers to the enumerated attr spelling spellcheck (HTML alias table, #2172)', () => {
175
+ expect(toHTMLAttrNameRuntime('spellCheck')).toBe('spellcheck')
149
176
  })
150
177
 
151
- test('autoFocus passes through (no kebab)', () => {
152
- expect(toHTMLAttrNameRuntime('autoFocus')).toBe('autoFocus')
178
+ test('unknown camelCase names still pass through unchanged', () => {
179
+ expect(toHTMLAttrNameRuntime('myCustomAttr')).toBe('myCustomAttr')
153
180
  })
154
181
  })
155
182
 
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { decodeEntities, escapeHtml } from '../html-entities'
3
+
4
+ describe('decodeEntities', () => {
5
+ test('escaping-set names decode', () => {
6
+ expect(decodeEntities('Fish &amp; Chips')).toBe('Fish & Chips')
7
+ expect(decodeEntities('a &lt; b &gt; c')).toBe('a < b > c')
8
+ expect(decodeEntities('&quot;x&quot; &apos;y&apos;')).toBe(`"x" 'y'`)
9
+ })
10
+
11
+ test('common typographic names decode', () => {
12
+ expect(decodeEntities('&copy; 2026')).toBe('© 2026')
13
+ expect(decodeEntities('1&nbsp;000')).toBe('1 000')
14
+ expect(decodeEntities('A&hellip;')).toBe('A…')
15
+ expect(decodeEntities('&euro;5 / &pound;4 / &yen;3')).toBe('€5 / £4 / ¥3')
16
+ })
17
+
18
+ test('numeric decimal and hex references decode', () => {
19
+ expect(decodeEntities('&#169;')).toBe('©')
20
+ expect(decodeEntities('&#xA9;')).toBe('©')
21
+ expect(decodeEntities('&#x1F600;')).toBe('😀')
22
+ })
23
+
24
+ test('unknown / malformed references stay verbatim', () => {
25
+ expect(decodeEntities('&unknownname;')).toBe('&unknownname;')
26
+ expect(decodeEntities('a & b')).toBe('a & b')
27
+ expect(decodeEntities('&amp')).toBe('&amp')
28
+ // Lone surrogate / out-of-range code points are refused, not
29
+ // replaced with garbage.
30
+ expect(decodeEntities('&#xD800;')).toBe('&#xD800;')
31
+ expect(decodeEntities('&#x110000;')).toBe('&#x110000;')
32
+ })
33
+
34
+ test('double-escaped input decodes exactly one level', () => {
35
+ expect(decodeEntities('&amp;copy;')).toBe('&copy;')
36
+ })
37
+ })
38
+
39
+ describe('escapeHtml', () => {
40
+ test('escapes & < > " and leaves the rest', () => {
41
+ expect(escapeHtml('Fish & Chips')).toBe('Fish &amp; Chips')
42
+ expect(escapeHtml('a < b > c')).toBe('a &lt; b &gt; c')
43
+ expect(escapeHtml('say "hi"')).toBe('say &quot;hi&quot;')
44
+ expect(escapeHtml("it's © fine")).toBe("it's © fine")
45
+ })
46
+
47
+ test('decode → escape round-trips the entity text', () => {
48
+ expect(escapeHtml(decodeEntities('Fish &amp; Chips'))).toBe('Fish &amp; Chips')
49
+ expect(escapeHtml(decodeEntities('a &lt; b'))).toBe('a &lt; b')
50
+ // Named references outside the escape set stay decoded — the
51
+ // literal character is the canonical emission (`©`, not `&copy;`).
52
+ expect(escapeHtml(decodeEntities('&copy; 2026'))).toBe('© 2026')
53
+ })
54
+ })
package/src/dom-prop.ts CHANGED
@@ -72,6 +72,79 @@ const SVG_CAMEL_TO_KEBAB: Readonly<Record<string, string>> = {
72
72
  markerEnd: 'marker-end',
73
73
  }
74
74
 
75
+ /**
76
+ * HTML attributes written in React-style camelCase in JSX that map to a
77
+ * lowercase (or hyphenated) HTML attribute name. Mirrors React DOM's
78
+ * HTML attribute aliases. `className` / `htmlFor` are handled before
79
+ * this table (they predate it); everything here is a plain rename —
80
+ * value semantics are untouched (`readOnly` becomes the BOOLEAN_ATTRS
81
+ * member `readonly`; `spellCheck` stays the *enumerated* — NOT boolean
82
+ * — `spellcheck`).
83
+ *
84
+ * Consumed by `toHTMLAttrName` (compile-time Phase 1: `processAttributes`
85
+ * normalizes IRAttribute.name so adapters emit the HTML name as-is) and
86
+ * by `toHTMLAttrNameRuntime` (runtime spread paths). Names NOT in this
87
+ * table pass through unchanged, so `data-*`, `aria-*`, and custom-element
88
+ * attributes are never rewritten.
89
+ */
90
+ const HTML_CAMEL_ALIASES: Readonly<Record<string, string>> = {
91
+ acceptCharset: 'accept-charset',
92
+ accessKey: 'accesskey',
93
+ allowFullScreen: 'allowfullscreen',
94
+ autoCapitalize: 'autocapitalize',
95
+ autoComplete: 'autocomplete',
96
+ autoCorrect: 'autocorrect',
97
+ autoFocus: 'autofocus',
98
+ autoPlay: 'autoplay',
99
+ cellPadding: 'cellpadding',
100
+ cellSpacing: 'cellspacing',
101
+ charSet: 'charset',
102
+ colSpan: 'colspan',
103
+ contentEditable: 'contenteditable',
104
+ controlsList: 'controlslist',
105
+ crossOrigin: 'crossorigin',
106
+ dateTime: 'datetime',
107
+ dirName: 'dirname',
108
+ encType: 'enctype',
109
+ enterKeyHint: 'enterkeyhint',
110
+ fetchPriority: 'fetchpriority',
111
+ formAction: 'formaction',
112
+ formEncType: 'formenctype',
113
+ formMethod: 'formmethod',
114
+ formNoValidate: 'formnovalidate',
115
+ formTarget: 'formtarget',
116
+ frameBorder: 'frameborder',
117
+ hrefLang: 'hreflang',
118
+ httpEquiv: 'http-equiv',
119
+ imageSizes: 'imagesizes',
120
+ imageSrcSet: 'imagesrcset',
121
+ inputMode: 'inputmode',
122
+ itemID: 'itemid',
123
+ itemProp: 'itemprop',
124
+ itemRef: 'itemref',
125
+ itemScope: 'itemscope',
126
+ itemType: 'itemtype',
127
+ marginHeight: 'marginheight',
128
+ marginWidth: 'marginwidth',
129
+ maxLength: 'maxlength',
130
+ minLength: 'minlength',
131
+ noModule: 'nomodule',
132
+ noValidate: 'novalidate',
133
+ playsInline: 'playsinline',
134
+ popoverTarget: 'popovertarget',
135
+ popoverTargetAction: 'popovertargetaction',
136
+ radioGroup: 'radiogroup',
137
+ readOnly: 'readonly',
138
+ referrerPolicy: 'referrerpolicy',
139
+ rowSpan: 'rowspan',
140
+ spellCheck: 'spellcheck',
141
+ srcDoc: 'srcdoc',
142
+ srcLang: 'srclang',
143
+ srcSet: 'srcset',
144
+ tabIndex: 'tabindex',
145
+ useMap: 'usemap',
146
+ }
147
+
75
148
  /**
76
149
  * SVG XML attribute names that are case-sensitive and MUST stay in camelCase.
77
150
  *
@@ -169,6 +242,8 @@ export function classifyDOMProp(key: string): DOMPropClassification {
169
242
  export function toHTMLAttrName(key: string): string {
170
243
  if (key === 'className') return 'class'
171
244
  if (key === 'htmlFor') return 'for'
245
+ const htmlAlias = HTML_CAMEL_ALIASES[key]
246
+ if (htmlAlias !== undefined) return htmlAlias
172
247
  const svgKebab = SVG_CAMEL_TO_KEBAB[key]
173
248
  if (svgKebab !== undefined) return svgKebab
174
249
  return key
@@ -178,13 +253,17 @@ export function toHTMLAttrName(key: string): string {
178
253
  * Runtime variant of attribute name mapping that additionally applies
179
254
  * camelCase→kebab conversion for `data-*` and `aria-*` convenience
180
255
  * props (e.g. `dataTestId` → `data-test-id`) and preserves SVG XML
181
- * attribute casing. All other non-SVG keys pass through unchanged
182
- * standard HTML attributes like `tabIndex` and `autoFocus` must not
183
- * be kebab-cased (`tab-index` / `auto-focus` are not valid attrs).
256
+ * attribute casing. HTML camelCase aliases resolve through the same
257
+ * `HTML_CAMEL_ALIASES` table as the compile-time variant (`tabIndex`
258
+ * `tabindex`); generic kebab-casing still applies ONLY to `data-*` /
259
+ * `aria-*` — an unknown camelCase key passes through unchanged rather
260
+ * than being guessed into a hyphenated non-attribute.
184
261
  */
185
262
  export function toHTMLAttrNameRuntime(key: string): string {
186
263
  if (key === 'className') return 'class'
187
264
  if (key === 'htmlFor') return 'for'
265
+ const htmlAlias = HTML_CAMEL_ALIASES[key]
266
+ if (htmlAlias !== undefined) return htmlAlias
188
267
  const svgKebab = SVG_CAMEL_TO_KEBAB[key]
189
268
  if (svgKebab !== undefined) return svgKebab
190
269
  if (SVG_XML_CAMEL_ATTRS.has(key)) return key
@@ -0,0 +1,123 @@
1
+ /**
2
+ * HTML character-reference decoding and escaping for STATIC template
3
+ * content.
4
+ *
5
+ * JSX decodes character references at parse time: `<span>Fish &amp;
6
+ * Chips</span>` means the TEXT `Fish & Chips`, and `&copy;` means `©`
7
+ * (Babel/esbuild/TypeScript's JSX emit all decode). Phase 1
8
+ * (`jsx-to-ir`) applies `decodeEntities` once so `IRText.value` and
9
+ * static attribute values carry the DECODED text — the semantics —
10
+ * and every adapter re-escapes for its own emission context
11
+ * (`escapeHtml` for HTML template output; the Hono adapter
12
+ * re-encodes for JSX source). An adapter that emitted the raw entity
13
+ * text passed `&copy;` through as bytes while the reference decoded it
14
+ * (the `html-entity-text` divergence), and one that skipped
15
+ * re-escaping emitted a parse-corrupting `<`.
16
+ *
17
+ * The named table is the curated set below, not the full HTML5 list
18
+ * (~2,200 names): an unknown name (`&foo;`) is left as raw text, so
19
+ * BOTH the reference adapter and the template adapters receive the
20
+ * same undecoded string from the IR and stay byte-identical — the
21
+ * degradation is consistent, exactly how a browser treats an unknown
22
+ * reference. Numeric references (`&#169;` / `&#xA9;`) decode fully.
23
+ */
24
+
25
+ /**
26
+ * Named character references JSX authors actually write in literal
27
+ * text. `amp`/`lt`/`gt`/`quot`/`apos` are the escaping set itself;
28
+ * the rest are the common typographic/symbol names.
29
+ */
30
+ const NAMED_ENTITIES: Record<string, string> = {
31
+ amp: '&',
32
+ lt: '<',
33
+ gt: '>',
34
+ quot: '"',
35
+ apos: "'",
36
+ nbsp: ' ',
37
+ copy: '©',
38
+ reg: '®',
39
+ trade: '™',
40
+ deg: '°',
41
+ plusmn: '±',
42
+ times: '×',
43
+ divide: '÷',
44
+ middot: '·',
45
+ bull: '•',
46
+ hellip: '…',
47
+ ndash: '–',
48
+ mdash: '—',
49
+ lsquo: '‘',
50
+ rsquo: '’',
51
+ ldquo: '“',
52
+ rdquo: '”',
53
+ laquo: '«',
54
+ raquo: '»',
55
+ sect: '§',
56
+ para: '¶',
57
+ dagger: '†',
58
+ Dagger: '‡',
59
+ euro: '€',
60
+ pound: '£',
61
+ yen: '¥',
62
+ cent: '¢',
63
+ sup1: '¹',
64
+ sup2: '²',
65
+ sup3: '³',
66
+ frac12: '½',
67
+ frac14: '¼',
68
+ frac34: '¾',
69
+ larr: '←',
70
+ uarr: '↑',
71
+ rarr: '→',
72
+ darr: '↓',
73
+ harr: '↔',
74
+ minus: '−',
75
+ infin: '∞',
76
+ ne: '≠',
77
+ le: '≤',
78
+ ge: '≥',
79
+ }
80
+
81
+ /**
82
+ * Decode HTML character references in JSX literal text / static
83
+ * attribute values: numeric decimal (`&#169;`), numeric hex
84
+ * (`&#xA9;`), and the curated named set above. Anything unrecognized
85
+ * (unknown name, malformed numeric, bare `&`) is left verbatim.
86
+ */
87
+ export function decodeEntities(text: string): string {
88
+ return text.replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body: string) => {
89
+ if (body[0] === '#') {
90
+ const isHex = body[1] === 'x' || body[1] === 'X'
91
+ const digits = body.slice(isHex ? 2 : 1)
92
+ if (!isHex && !/^[0-9]+$/.test(digits)) return match
93
+ const code = parseInt(digits, isHex ? 16 : 10)
94
+ // Reject out-of-range / lone-surrogate code points rather than
95
+ // producing replacement garbage — leave the reference raw.
96
+ if (!Number.isFinite(code) || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
97
+ return match
98
+ }
99
+ return String.fromCodePoint(code)
100
+ }
101
+ return NAMED_ENTITIES[body] ?? match
102
+ })
103
+ }
104
+
105
+ /**
106
+ * Escape decoded static text for direct HTML emission: `&` `<` `>`
107
+ * `"` to their named forms. Used for BOTH text nodes and double-quoted
108
+ * attribute values — one set, no context-dependent under-escaping.
109
+ *
110
+ * `'` is deliberately NOT escaped: the reference (Hono JSX) escapes it
111
+ * as `&#39;`, but raw `'` is valid everywhere outside single-quoted
112
+ * attributes (which no adapter emits), and the conformance harness's
113
+ * `normalizeHTML` canonicalises the raw and entity forms to one
114
+ * spelling on both sides — so leaving apostrophes raw keeps every
115
+ * existing template byte-stable instead of rewriting all prose text.
116
+ */
117
+ export function escapeHtml(text: string): string {
118
+ return text
119
+ .replace(/&/g, '&amp;')
120
+ .replace(/</g, '&lt;')
121
+ .replace(/>/g, '&gt;')
122
+ .replace(/"/g, '&quot;')
123
+ }
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  BOOLEAN_ATTRS,
42
42
  } from './dom-prop.ts'
43
43
  export type { DOMPropKind, DOMPropClassification } from './dom-prop.ts'
44
+ export { decodeEntities, escapeHtml } from './html-entities.ts'
44
45
 
45
46
  export type {
46
47
  ProfilerEvent,