@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.
- package/dist/dom-prop.d.ts +5 -3
- package/dist/html-entities.d.ts +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +135 -0
- package/package.json +1 -1
- package/src/__tests__/dom-prop.test.ts +35 -8
- package/src/__tests__/html-entities.test.ts +54 -0
- package/src/dom-prop.ts +82 -3
- package/src/html-entities.ts +123 -0
- package/src/index.ts +1 -0
package/dist/dom-prop.d.ts
CHANGED
|
@@ -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.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 &
|
|
6
|
+
* Chips</span>` means the TEXT `Fish & Chips`, and `©` 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 `©` 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 (`©` / `©`) decode fully.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Decode HTML character references in JSX literal text / static
|
|
26
|
+
* attribute values: numeric decimal (`©`), numeric hex
|
|
27
|
+
* (`©`), 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 `'`, 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
@@ -106,12 +106,27 @@ describe('toHTMLAttrName (compile-time)', () => {
|
|
|
106
106
|
expect(toHTMLAttrName('strokeWidth')).toBe('stroke-width')
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
114
|
-
expect(toHTMLAttrName('autoFocus')).toBe('
|
|
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
|
|
148
|
-
expect(toHTMLAttrNameRuntime('tabIndex')).toBe('
|
|
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('
|
|
152
|
-
expect(toHTMLAttrNameRuntime('
|
|
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 & Chips')).toBe('Fish & Chips')
|
|
7
|
+
expect(decodeEntities('a < b > c')).toBe('a < b > c')
|
|
8
|
+
expect(decodeEntities('"x" 'y'')).toBe(`"x" 'y'`)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('common typographic names decode', () => {
|
|
12
|
+
expect(decodeEntities('© 2026')).toBe('© 2026')
|
|
13
|
+
expect(decodeEntities('1 000')).toBe('1 000')
|
|
14
|
+
expect(decodeEntities('A…')).toBe('A…')
|
|
15
|
+
expect(decodeEntities('€5 / £4 / ¥3')).toBe('€5 / £4 / ¥3')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('numeric decimal and hex references decode', () => {
|
|
19
|
+
expect(decodeEntities('©')).toBe('©')
|
|
20
|
+
expect(decodeEntities('©')).toBe('©')
|
|
21
|
+
expect(decodeEntities('😀')).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('&')).toBe('&')
|
|
28
|
+
// Lone surrogate / out-of-range code points are refused, not
|
|
29
|
+
// replaced with garbage.
|
|
30
|
+
expect(decodeEntities('�')).toBe('�')
|
|
31
|
+
expect(decodeEntities('�')).toBe('�')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('double-escaped input decodes exactly one level', () => {
|
|
35
|
+
expect(decodeEntities('&copy;')).toBe('©')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('escapeHtml', () => {
|
|
40
|
+
test('escapes & < > " and leaves the rest', () => {
|
|
41
|
+
expect(escapeHtml('Fish & Chips')).toBe('Fish & Chips')
|
|
42
|
+
expect(escapeHtml('a < b > c')).toBe('a < b > c')
|
|
43
|
+
expect(escapeHtml('say "hi"')).toBe('say "hi"')
|
|
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 & Chips'))).toBe('Fish & Chips')
|
|
49
|
+
expect(escapeHtml(decodeEntities('a < b'))).toBe('a < b')
|
|
50
|
+
// Named references outside the escape set stay decoded — the
|
|
51
|
+
// literal character is the canonical emission (`©`, not `©`).
|
|
52
|
+
expect(escapeHtml(decodeEntities('© 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.
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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 &
|
|
6
|
+
* Chips</span>` means the TEXT `Fish & Chips`, and `©` 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 `©` 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 (`©` / `©`) 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 (`©`), numeric hex
|
|
84
|
+
* (`©`), 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 `'`, 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, '&')
|
|
120
|
+
.replace(/</g, '<')
|
|
121
|
+
.replace(/>/g, '>')
|
|
122
|
+
.replace(/"/g, '"')
|
|
123
|
+
}
|