Skip to content

Commit 3edca9d

Browse files
authored
Generate our own HTML attribute type library from @types/react (part 1/2 of #16) (#20)
## What Part 1/2 of #16, closes #18. Adds **our own HTML attribute type library**, generated from the exact-pinned `@types/react@19.2.17` — not rescript-webapi (it has no JSX attribute types), not MDN crawling (`@types/react` is the versioned machine-readable source). **Zero behavior change**: nothing consumes the library yet — generator output for real packages is byte-identical (benchmark: all 6 packages PASS, baselines untouched). The wiring that uses it (record-props emission) is part 2 (#19). ## The hierarchy ``` ariaAttributes (53) domAttributes (~190 events incl. capture variants) └──────────┬──────────────┘ htmlAttributes (+55 globals) │ 51 per-element leaves: buttonHTMLAttributes, inputHTMLAttributes, … ``` 54 groups / 571 fields, expressed with ReScript record type spread. ## Pieces - **`scripts/gen-html-attrs.mjs`** (dev-time, not shipped): walks the *declared* members of the attribute interfaces in `@types/react`; deterministic mapping table (full aria typing, `ReactEvent.*` handlers — precise `Pointer`/`Composition`/`Transition` modules, capture variants included, literal unions → poly variants, `CSSProperties` → `JsxDOM.style`, int/float by the existing `numberType()` heuristic). Same-type override redeclarations (e.g. input's `onChange`) are dropped; typed overrides are recorded for narrowing. - **`src/html-attrs-data.mjs`** (committed output): the structured data. Update loop: bump the pin → `npm run gen:attrs` → review the diff. - **`src/html-attrs.mjs`** (shipped): plans + renders `HtmlAttrs.res`. Because ReScript record spread **forbids duplicate fields**, "base minus keys" (TS `Omit<…>`, own-prop collisions) is solved by **narrowed variants** that re-materialize only the affected slice (e.g. `Omit<…, "style"|"className">` materializes just the ~55-field globals slice; aria + events stay shared), deduped by `(group, removedKeys)`. - **Tests**: `test/html-attrs.mjs` in `npm test` — pin contract, hierarchy shape, **no-duplicate-field-id invariant** (the compile-error class), narrowing semantics (16 checks). `test/golden-compile.mjs` now also compiles the full rendered `HtmlAttrs.res` (794 lines) on ReScript 12 — passes clean. ## Verified - `npm test`: 22 goldens byte-identical + 16 new checks green - `npm run test:compile`: all goldens + full `HtmlAttrs.res` compile clean - `npm run bench`: all 6 packages PASS, zero baseline diffs - `npm pack`: library ships under `src/`; generator script does not
1 parent 8640aaa commit 3edca9d

8 files changed

Lines changed: 1478 additions & 4 deletions

File tree

package-lock.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@
1616
},
1717
"scripts": {
1818
"gen": "node src/cli.mjs",
19-
"test": "node test/smoke.mjs && node test/golden.mjs",
19+
"test": "node test/smoke.mjs && node test/golden.mjs && node test/html-attrs.mjs",
2020
"test:smoke": "node test/smoke.mjs",
2121
"test:golden": "node test/golden.mjs",
2222
"test:golden:update": "node test/golden.mjs --update",
2323
"test:compile": "node test/golden-compile.mjs",
2424
"bench": "node benchmark/run.mjs",
25-
"bench:update": "node benchmark/run.mjs --update"
25+
"bench:update": "node benchmark/run.mjs --update",
26+
"gen:attrs": "node scripts/gen-html-attrs.mjs"
2627
},
2728
"files": [
2829
"src/",
@@ -57,5 +58,8 @@
5758
},
5859
"dependencies": {
5960
"typescript": "^5.6.0"
61+
},
62+
"devDependencies": {
63+
"@types/react": "19.2.17"
6064
}
6165
}

scripts/gen-html-attrs.mjs

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// ============================================================================
2+
// gen-html-attrs.mjs — DEV-TIME generator for src/html-attrs-data.mjs.
3+
//
4+
// Reads the DECLARED members of React's attribute interfaces from the pinned
5+
// @types/react (devDependency, exact version) and emits them as structured
6+
// data modelling the hierarchy:
7+
//
8+
// AriaAttributes ─┐
9+
// DOMAttributes ─┴→ HTMLAttributes → 50+ per-element *HTMLAttributes leaves
10+
//
11+
// This is OUR OWN attribute library — not rescript-webapi (which has no JSX
12+
// attribute types) and not MDN-crawled (@types/react IS the versioned,
13+
// machine-readable encoding of the same data). Update loop:
14+
//
15+
// 1. bump the exact @types/react pin in package.json devDependencies
16+
// 2. npm run gen:attrs
17+
// 3. review the src/html-attrs-data.mjs diff + golden diffs
18+
//
19+
// Deterministic: same pin -> byte-identical output. Run: npm run gen:attrs
20+
// ============================================================================
21+
import ts from 'typescript'
22+
import { readFileSync, writeFileSync } from 'fs'
23+
import { join, dirname } from 'path'
24+
import { fileURLToPath } from 'url'
25+
import { label, numberType } from '../src/emit.mjs'
26+
27+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
28+
const OUT = join(ROOT, 'src', 'html-attrs-data.mjs')
29+
30+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'))
31+
const PIN = pkg.devDependencies?.['@types/react']
32+
if (!PIN || !/^\d/.test(PIN)) {
33+
console.error('gen-html-attrs: @types/react must be an EXACT-version devDependency (the pin is the contract)')
34+
process.exit(1)
35+
}
36+
37+
const DTS = join(ROOT, 'node_modules', '@types', 'react', 'index.d.ts')
38+
const installed = JSON.parse(readFileSync(join(ROOT, 'node_modules', '@types', 'react', 'package.json'), 'utf-8')).version
39+
if (installed !== PIN) {
40+
console.error(`gen-html-attrs: installed @types/react ${installed} != pinned ${PIN} — run npm install first`)
41+
process.exit(1)
42+
}
43+
44+
// ── mapping tables (deterministic, no checker) ──────────────────────────────
45+
46+
// aria-* → ReScript type. Enums/bools/numerics follow JsxDOM.res precedent;
47+
// any aria-* not listed here is a string (correct for the newer label-ish ones).
48+
const ARIA = {
49+
'aria-checked': '[#"true" | #"false" | #mixed]',
50+
'aria-pressed': '[#"true" | #"false" | #mixed]',
51+
'aria-current': '[#page | #step | #location | #date | #time | #"true" | #"false"]',
52+
'aria-haspopup': '[#menu | #listbox | #tree | #grid | #dialog | #"true" | #"false"]',
53+
'aria-invalid': '[#grammar | #"false" | #spelling | #"true"]',
54+
'aria-autocomplete': '[#inline | #list | #both | #none]',
55+
'aria-orientation': '[#horizontal | #vertical | #undefined]',
56+
'aria-live': '[#off | #polite | #assertive | #rude]',
57+
'aria-dropeffect': '[#copy | #move | #link | #execute | #popup | #none]',
58+
'aria-disabled': 'bool', 'aria-hidden': 'bool', 'aria-expanded': 'bool',
59+
'aria-modal': 'bool', 'aria-multiline': 'bool', 'aria-multiselectable': 'bool',
60+
'aria-readonly': 'bool', 'aria-required': 'bool', 'aria-selected': 'bool',
61+
'aria-atomic': 'bool', 'aria-busy': 'bool', 'aria-grabbed': 'bool',
62+
'aria-level': 'int', 'aria-colcount': 'int', 'aria-colindex': 'int',
63+
'aria-colspan': 'int', 'aria-posinset': 'int', 'aria-rowcount': 'int',
64+
'aria-rowindex': 'int', 'aria-rowspan': 'int', 'aria-setsize': 'int',
65+
'aria-valuemax': 'float', 'aria-valuemin': 'float', 'aria-valuenow': 'float',
66+
}
67+
68+
// React `*EventHandler<T>` aliases → ReScript callback type. ReactEvent has a
69+
// dedicated module per category (incl. Pointer/Composition/Transition), so the
70+
// attribute library maps each handler to its precise event — strictly better
71+
// than collapsing to Mouse, and proven compilable by the render-compile test.
72+
const HANDLERS = {
73+
ReactEventHandler: 'ReactEvent.Synthetic.t',
74+
ClipboardEventHandler: 'ReactEvent.Clipboard.t',
75+
CompositionEventHandler: 'ReactEvent.Composition.t',
76+
DragEventHandler: 'ReactEvent.Mouse.t', // DragEvent extends MouseEvent; no ReactEvent.Drag module
77+
FocusEventHandler: 'ReactEvent.Focus.t',
78+
FormEventHandler: 'ReactEvent.Form.t',
79+
ChangeEventHandler: 'ReactEvent.Form.t',
80+
InputEventHandler: 'ReactEvent.Form.t',
81+
SubmitEventHandler: 'ReactEvent.Form.t',
82+
KeyboardEventHandler: 'ReactEvent.Keyboard.t',
83+
MouseEventHandler: 'ReactEvent.Mouse.t',
84+
TouchEventHandler: 'ReactEvent.Touch.t',
85+
PointerEventHandler: 'ReactEvent.Pointer.t',
86+
UIEventHandler: 'ReactEvent.UI.t',
87+
WheelEventHandler: 'ReactEvent.Wheel.t',
88+
AnimationEventHandler: 'ReactEvent.Animation.t',
89+
TransitionEventHandler: 'ReactEvent.Transition.t',
90+
ToggleEventHandler: 'ReactEvent.Synthetic.t',
91+
}
92+
93+
// Named TS reference types → ReScript. Anything not here falls back to `string`
94+
// (flagged loose in the summary). String-ish unions with `(string & {})` escape
95+
// hatches (AnchorTarget, InputType, ReferrerPolicy…) are deliberately `string`.
96+
const NAMED = {
97+
Booleanish: 'bool',
98+
CSSProperties: 'JsxDOM.style',
99+
ReactNode: 'React.element',
100+
ReactElement: 'React.element',
101+
AriaRole: 'string',
102+
HTMLAttributeReferrerPolicy: 'string', // union includes "" — not poly-variant-able
103+
HTMLAttributeAnchorTarget: 'string',
104+
HTMLInputTypeAttribute: 'string',
105+
HTMLInputAutoCompleteAttribute: 'string',
106+
CrossOrigin: 'string', // union includes ""
107+
TrustedHTML: 'string',
108+
}
109+
110+
const polyTag = (s) => (/^[A-Za-z][A-Za-z0-9_]*$/.test(s) && !['true', 'false'].includes(s) ? `#${s}` : `#${JSON.stringify(s)}`)
111+
112+
let looseCount = 0
113+
const loose = []
114+
115+
/** Map one member's syntactic type node to a ReScript type string. */
116+
function mapType(fieldName, node) {
117+
if (!node) return fallback(fieldName, '<none>')
118+
switch (node.kind) {
119+
case ts.SyntaxKind.StringKeyword: return 'string'
120+
case ts.SyntaxKind.BooleanKeyword: return 'bool'
121+
case ts.SyntaxKind.NumberKeyword: return numberType(fieldName)
122+
case ts.SyntaxKind.AnyKeyword:
123+
case ts.SyntaxKind.UnknownKeyword: return fallback(fieldName, 'any/unknown')
124+
case ts.SyntaxKind.LiteralType: {
125+
const lit = node.literal
126+
if (ts.isStringLiteral(lit)) return `[${polyTag(lit.text)}]`
127+
return fallback(fieldName, 'non-string literal')
128+
}
129+
case ts.SyntaxKind.TypeReference: {
130+
const name = node.typeName.getText()
131+
if (HANDLERS[name]) return `${HANDLERS[name]} => unit`
132+
if (NAMED[name] !== undefined) return NAMED[name]
133+
return fallback(fieldName, name)
134+
}
135+
case ts.SyntaxKind.UnionType: {
136+
// strip undefined/null
137+
const parts = node.types.filter((t) =>
138+
t.kind !== ts.SyntaxKind.UndefinedKeyword &&
139+
!(ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword))
140+
if (parts.length === 1) return mapType(fieldName, parts[0])
141+
const kinds = parts.map((t) => t.kind)
142+
// boolean | "true" | "false" (inline Booleanish) → bool
143+
const lits = parts.filter((t) => ts.isLiteralTypeNode(t) && ts.isStringLiteral(t.literal)).map((t) => t.literal.text)
144+
if (kinds.includes(ts.SyntaxKind.BooleanKeyword) && lits.every((s) => s === 'true' || s === 'false')) return 'bool'
145+
// all string literals → inline closed poly variant
146+
if (lits.length === parts.length) {
147+
if (lits.some((s) => s === '')) return 'string' // "" can't be a poly tag
148+
return `[${lits.map(polyTag).join(' | ')}]`
149+
}
150+
// string | number (| readonly string[]) — the value/defaultValue shape → string (JsxDOM precedent)
151+
if (kinds.every((k) => k === ts.SyntaxKind.StringKeyword || k === ts.SyntaxKind.NumberKeyword || k === ts.SyntaxKind.TypeOperator || k === ts.SyntaxKind.ArrayType)) return 'string'
152+
// string | SomethingElse → string keeps it usable
153+
if (kinds.includes(ts.SyntaxKind.StringKeyword)) return 'string'
154+
if (kinds.includes(ts.SyntaxKind.NumberKeyword)) return numberType(fieldName)
155+
if (kinds.includes(ts.SyntaxKind.BooleanKeyword)) return 'bool'
156+
return fallback(fieldName, 'mixed union')
157+
}
158+
case ts.SyntaxKind.TypeLiteral: {
159+
// dangerouslySetInnerHTML?: { __html: string | TrustedHTML }
160+
if (fieldName === 'dangerouslySetInnerHTML') return '{"__html": string}'
161+
return fallback(fieldName, 'object literal')
162+
}
163+
default: return fallback(fieldName, ts.SyntaxKind[node.kind])
164+
}
165+
}
166+
167+
function fallback(fieldName, why) {
168+
looseCount++
169+
loose.push(`${fieldName} (${why})`)
170+
return 'string'
171+
}
172+
173+
// ── walk @types/react ───────────────────────────────────────────────────────
174+
const sf = ts.createSourceFile('index.d.ts', readFileSync(DTS, 'utf-8'), ts.ScriptTarget.Latest, true)
175+
176+
const SPECIAL_GROUP_NAMES = { HTMLAttributes: 'htmlAttributes', DOMAttributes: 'domAttributes', AriaAttributes: 'ariaAttributes' }
177+
const groupNameOf = (tsName) => SPECIAL_GROUP_NAMES[tsName] || tsName.charAt(0).toLowerCase() + tsName.slice(1)
178+
const isTarget = (n) =>
179+
n === 'AriaAttributes' || n === 'DOMAttributes' ||
180+
(/HTMLAttributes$/.test(n) && n !== 'AllHTMLAttributes')
181+
182+
/** Collect target InterfaceDeclarations from the top level and `declare namespace React`. */
183+
function collectInterfaces(node, out) {
184+
if (ts.isInterfaceDeclaration(node) && isTarget(node.name.text)) out.push(node)
185+
else if (ts.isModuleDeclaration(node) && node.body && ts.isModuleBlock(node.body)) {
186+
for (const s of node.body.statements) collectInterfaces(s, out)
187+
}
188+
}
189+
const ifaces = []
190+
for (const s of sf.statements) collectInterfaces(s, ifaces)
191+
if (!ifaces.length) { console.error('gen-html-attrs: no attribute interfaces found — @types/react layout changed?'); process.exit(1) }
192+
193+
const groups = {} // groupName -> { ts, spreads: [groupName], fields: [{name, res}] }
194+
for (const iface of ifaces) {
195+
const tsName = iface.name.text
196+
const g = groupNameOf(tsName)
197+
const spreads = []
198+
for (const h of iface.heritageClauses || []) {
199+
for (const t of h.types) {
200+
const base = t.expression.getText()
201+
if (isTarget(base)) spreads.push(groupNameOf(base))
202+
}
203+
}
204+
const fields = []
205+
for (const m of iface.members) {
206+
if (!ts.isPropertySignature(m) || !m.name) continue
207+
const name = ts.isStringLiteral(m.name) ? m.name.text : m.name.getText()
208+
const res = /^aria-/.test(name) ? (ARIA[name] || 'string') : (name === 'role' ? 'string' : mapType(name, m.type))
209+
fields.push({ name, res })
210+
}
211+
groups[g] = { ts: tsName, spreads, fields }
212+
}
213+
214+
// ── override resolution (record spread forbids duplicate fields) ────────────
215+
// A leaf redeclaring a chain field with the SAME rendered type → drop the
216+
// redeclaration. A DIFFERENT type → keep the leaf's field and record the name
217+
// in `overrides` (the renderer must narrow the base spread for that group).
218+
function chainFieldMap(g, seen = new Set()) {
219+
if (seen.has(g)) return new Map()
220+
seen.add(g)
221+
const out = new Map()
222+
for (const s of groups[g]?.spreads || []) for (const [k, v] of chainFieldMap(s, seen)) out.set(k, v)
223+
for (const f of groups[g]?.fields || []) out.set(f.name, f.res)
224+
return out
225+
}
226+
let droppedOverrides = 0
227+
for (const [g, def] of Object.entries(groups)) {
228+
if (!def.spreads.length) continue
229+
const inherited = new Map()
230+
for (const s of def.spreads) for (const [k, v] of chainFieldMap(s)) inherited.set(k, v)
231+
const overrides = []
232+
def.fields = def.fields.filter((f) => {
233+
if (!inherited.has(f.name)) return true
234+
if (inherited.get(f.name) === f.res) { droppedOverrides++; return false } // same type → spread already covers it
235+
overrides.push(f.name) // different type → leaf wins, base must be narrowed
236+
return true
237+
})
238+
if (overrides.length) def.overrides = overrides
239+
}
240+
241+
// Sanity: post-resolution, no duplicate label() ids inside any group's own fields.
242+
for (const [g, def] of Object.entries(groups)) {
243+
const ids = new Set()
244+
for (const f of def.fields) {
245+
const id = label(f.name).id
246+
if (ids.has(id)) { console.error(`gen-html-attrs: duplicate field id '${id}' in ${g}`); process.exit(1) }
247+
ids.add(id)
248+
}
249+
}
250+
251+
// ── emit ────────────────────────────────────────────────────────────────────
252+
const order = Object.keys(groups).sort((a, b) => {
253+
// dependency order (bases first), then alphabetical for stability
254+
const depth = (g, seen = new Set()) => seen.has(g) ? 0 : (seen.add(g), 1 + Math.max(0, ...(groups[g]?.spreads || []).map((s) => depth(s, seen))))
255+
return depth(a) - depth(b) || a.localeCompare(b)
256+
})
257+
258+
const L = []
259+
L.push('// ============================================================================')
260+
L.push('// html-attrs-data.mjs — GENERATED by scripts/gen-html-attrs.mjs. DO NOT EDIT.')
261+
L.push(`// Source: @types/react@${PIN} (exact devDependency pin — the update contract).`)
262+
L.push('// Regenerate: bump the pin, npm install, npm run gen:attrs, review the diff.')
263+
L.push('// ============================================================================')
264+
L.push('')
265+
L.push(`export const HTML_ATTRS_PIN = '@types/react@${PIN}'`)
266+
L.push('')
267+
L.push('// groupName -> { ts: TS interface name, spreads: [base groups], fields: [{name, res}],')
268+
L.push('// overrides?: [field names that REPLACE a base field (base must be narrowed)] }')
269+
L.push('export const ATTR_GROUPS = {')
270+
for (const g of order) {
271+
const d = groups[g]
272+
L.push(` ${g}: {`)
273+
L.push(` ts: '${d.ts}',`)
274+
L.push(` spreads: [${d.spreads.map((s) => `'${s}'`).join(', ')}],`)
275+
if (d.overrides) L.push(` overrides: [${d.overrides.map((s) => `'${s}'`).join(', ')}],`)
276+
L.push(` fields: [`)
277+
for (const f of d.fields) L.push(` { name: ${JSON.stringify(f.name)}, res: ${JSON.stringify(f.res)} },`)
278+
L.push(` ],`)
279+
L.push(` },`)
280+
}
281+
L.push('}')
282+
L.push('')
283+
writeFileSync(OUT, L.join('\n'))
284+
285+
const total = Object.values(groups).reduce((n, d) => n + d.fields.length, 0)
286+
console.error(`[gen-html-attrs] ${Object.keys(groups).length} groups, ${total} fields → src/html-attrs-data.mjs`)
287+
console.error(`[gen-html-attrs] dropped ${droppedOverrides} same-type override redeclaration(s); ${Object.values(groups).filter((d) => d.overrides).length} group(s) with typed overrides`)
288+
console.error(`[gen-html-attrs] ${looseCount} field(s) fell back to string: ${loose.slice(0, 12).join(', ')}${loose.length > 12 ? ` … +${loose.length - 12}` : ''}`)

src/emit.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ function hasRecGroupLabelCollision(records, objUnboxed, opaque, idOf, depsOf) {
9191
return false
9292
}
9393

94-
function label(name) {
94+
export function label(name) {
9595
const isPlainIdent = /^[a-z_][a-zA-Z0-9_]*$/.test(name)
9696
if (isPlainIdent && !RESERVED.has(name)) return { as: null, id: name }
9797
if (RESERVED.has(name)) return { as: name, id: name + '_' }
@@ -109,7 +109,7 @@ function label(name) {
109109
* @param {string} propName
110110
* @returns {'int' | 'float'}
111111
*/
112-
function numberType(propName) {
112+
export function numberType(propName) {
113113
return INT_BY_NAME.has(propName) ? 'int' : 'float'
114114
}
115115

0 commit comments

Comments
 (0)