|
| 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}` : ''}`) |
0 commit comments