gen-client-catalog.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. /**
  2. * Generate the model-facing client slot catalog consumed by `cordis_inspect
  3. * what:"client"`. A dynamic package's browser half can only contribute UI
  4. * through `ctx.slots.register`, and every fact it needs to do that safely —
  5. * which keys exist, what each register call must pass, what the component
  6. * receives, who already occupies the seat, and when the seat exists at all —
  7. * is decided at compile time by the shipped web bundle. This generator reads
  8. * those facts lexically (no type-checker program) and emits them as a data
  9. * module inside `tool-cordis`, so the host-side toolset teaches the browser
  10. * surface without importing a single client runtime module.
  11. *
  12. * `--check` verifies the committed artifact is fresh.
  13. */
  14. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  15. import { dirname, resolve } from 'node:path'
  16. import {
  17. declaredTypes,
  18. indexExportedTypes,
  19. referencedTypeNames,
  20. scanSlotFiles,
  21. slotDeclarations,
  22. slotRegistrations,
  23. standardKitMembers,
  24. } from './slot-walk.ts'
  25. import type { ScannedFile, SlotDeclaration, SlotRegistration, TypeDeclaration } from './slot-walk.ts'
  26. const root = resolve(import.meta.dirname, '..')
  27. const OUT = 'packages/extensions/cordis-client-runner/src/client/slot-catalog.ts'
  28. /** Source globs: every workspace package's sources, `.tsx` included (a contract may live in one). */
  29. const SOURCE_GLOBS = ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx']
  30. /** Slot cardinalities the contract allows. */
  31. const KINDS = ['single', 'list', 'keyed', 'chain'] as const
  32. /** Slot data scopes the contract allows. */
  33. const SCOPES = ['root', 'session', 'session-maybe'] as const
  34. /** Declarations longer than this render truncated; the full shape stays in source. */
  35. const MAX_DECL_CHARS = 1200
  36. /**
  37. * Line budget for ONE slot's expanded report. The whole point of narrowing to a
  38. * single slot is to spend less context, so a report a model cannot finish
  39. * reading is a defect rather than a detail. The widest measured slot renders 60
  40. * lines, so this leaves room to document a slot properly while catching the two
  41. * ways a report runs away: an owner share that hands down a subsystem instead of
  42. * a share, and prose that grew into a manual.
  43. */
  44. const MAX_ENTRY_LINES = 120
  45. /** One register-call option as the catalog teaches it. */
  46. interface OptionDoc {
  47. readonly name: string
  48. readonly requirement: 'required' | 'optional'
  49. readonly type: string
  50. readonly doc: string
  51. }
  52. /**
  53. * Register options per cardinality, curated from `KindOptions` in
  54. * `packages/client/ui-slots/src/index.ts` — the authority for what a register
  55. * call may pass. Curated rather than projected because the authority is a
  56. * conditional type keyed on the slot's kind: it has no per-kind declaration a
  57. * lexical scan could read, and its own JSDoc addresses the compiler, not a
  58. * registrant. `verify-client-catalog` pins the authority's text so a change
  59. * there forces this table to be revisited.
  60. */
  61. const REGISTER_OPTIONS: Readonly<Record<(typeof KINDS)[number], readonly OptionDoc[]>> = {
  62. single: [],
  63. list: [
  64. { name: 'id', requirement: 'required', type: 'string', doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.' },
  65. { name: 'order', requirement: 'optional', type: 'number', doc: 'Position among the entries, ascending (default 0).' },
  66. { name: 'label', requirement: 'optional', type: 'string | (() => string)', doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.' },
  67. ],
  68. keyed: [
  69. { name: 'key', requirement: 'required', type: 'string', doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.' },
  70. ],
  71. chain: [
  72. { name: 'select', requirement: 'required', type: '(owner) => unknown | null', doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.' },
  73. ],
  74. }
  75. /** The one register option a dynamic package must NOT pass, and why. */
  76. const PRIORITY_NOTE = 'Do NOT pass `priority`: the browser-half facade assigns one automatically, and it is LOWER than every shipped entry — in a single or keyed cell that means your entry is the one that renders.'
  77. /** Cross-cutting rules a registrant needs once, not per slot. */
  78. const CLIENT_NOTES: readonly string[] = [
  79. 'Contribute UI only through `ctx.slots.register(options, Component)`; declare `inject: [\'slots\']` in your returned plugin (object form) or the seat is withheld.',
  80. 'Wrap every registration in `ctx.slots.inject(key, () => ctx.slots.register(...))`. A slot exists only while the entry that declared it is mounted, and registering into an undeclared slot throws; `inject` runs your registration when the declaration is (or becomes) live and re-runs it if the owner remounts.',
  81. PRIORITY_NOTE,
  82. 'You cannot `import` anything, so the design-system components are out of reach: build markup with `React.createElement` and ship CSS through `styles.insert(css)`. Use the theme CSS variables (`var(--dsw-alias-bg-layer-1)`, `var(--dsw-alias-label-primary)`, …) instead of literal colors, or your contribution breaks in the other color scheme.',
  83. 'Every component receives the framework hook seats listed under `framework props` for its scope; a selector hook is called with a selector, e.g. `useSessions(state => state.current)`.',
  84. 'This catalog is the COMPILE-TIME contract of the shipped web bundle, not a snapshot of one page: a key is registrable only where the owner that declares it is mounted. A failed registration surfaces in the browser-half load report — read it back with `cordis_inspect what:"temporary"`.',
  85. ]
  86. /** Standard-kit interface that applies to each scope, beyond the global one. */
  87. const SCOPE_KIT: Readonly<Record<(typeof SCOPES)[number], string | undefined>> = {
  88. 'root': undefined,
  89. 'session': 'SessionStandardProps',
  90. 'session-maybe': 'SessionMaybeStandardProps',
  91. }
  92. /** One resolved catalog entry, ready to render. */
  93. export interface SlotEntry {
  94. readonly key: string
  95. readonly kind: string
  96. readonly scope: string
  97. readonly summary: string
  98. readonly doc: string
  99. readonly registerOptions: readonly OptionDoc[]
  100. readonly ownerProps: readonly string[]
  101. readonly ownerPropsReferences: readonly string[]
  102. readonly standardProps: readonly string[]
  103. readonly keyDomain: string
  104. readonly hookContext: string
  105. readonly slotInject: string
  106. readonly declaredBy: string
  107. readonly occupants: readonly string[]
  108. readonly replaceRisk: string
  109. readonly example: string
  110. readonly source: string
  111. }
  112. /**
  113. * Read the workspace and resolve every catalog entry, failing loud on a
  114. * contract the catalog cannot teach.
  115. * @param scanRoot - repository root to scan.
  116. * @returns the entries, sorted by key.
  117. * @throws when any declared slot is unteachable or the scan contradicts itself.
  118. */
  119. export function collectSlotEntries(scanRoot: string): SlotEntry[] {
  120. const files = scanSlotFiles(scanRoot, SOURCE_GLOBS)
  121. const declarations = files.flatMap(file => slotDeclarations(file))
  122. const registrations = files.flatMap(file => slotRegistrations(file))
  123. const types = indexExportedTypes(scanRoot, SOURCE_GLOBS)
  124. const problems = validateSlotContracts(declarations, registrations, types)
  125. if (problems.length > 0) {
  126. throw new Error(`gen-client-catalog: ${String(problems.length)} contract violation(s):\n${problems.map(problem => ` ${problem}`).join('\n')}`)
  127. }
  128. const entries = resolveSlotEntries(declarations, registrations, types, standardKits(files))
  129. const oversized = oversizedSlotReports(entries)
  130. if (oversized.length > 0) {
  131. throw new Error(`gen-client-catalog: ${String(oversized.length)} slot(s) exceed the per-slot report budget `
  132. + `of ${String(MAX_ENTRY_LINES)} lines:\n${oversized.map(problem => ` ${problem}`).join('\n')}`)
  133. }
  134. return entries
  135. }
  136. /**
  137. * Slots whose expanded report exceeds {@link MAX_ENTRY_LINES}. Separated from
  138. * the scan so the budget is provable on one hand-built entry.
  139. * @param entries - resolved catalog entries.
  140. * @returns one message per over-budget slot, empty when every report is readable.
  141. */
  142. export function oversizedSlotReports(entries: readonly SlotEntry[]): string[] {
  143. return entries
  144. .filter(entry => entryLines(entry) > MAX_ENTRY_LINES)
  145. .map(entry => `slot '${entry.key}' (${entry.source}) reports ${String(entryLines(entry))} lines. `
  146. + 'Narrow the owner share it passes down (a slot hands a registrant a share, not a subsystem) or tighten '
  147. + 'its prose, so asking about one slot stays cheaper than asking about all of them.')
  148. }
  149. /** Line count of one entry's variable-length content, the proxy for its rendered report. */
  150. function entryLines(entry: SlotEntry): number {
  151. const blocks = [entry.doc, entry.example, ...entry.ownerProps, ...entry.registerOptions.map(option => option.doc)]
  152. return blocks.reduce((total, block) => total + block.split('\n').length, 0)
  153. + entry.standardProps.length + entry.ownerPropsReferences.length + entry.occupants.length
  154. }
  155. /**
  156. * Fail-closed contract checks: an unteachable slot must break the gate rather
  157. * than ship an entry a model cannot act on. Pure, so every rejection is
  158. * provable without scanning the workspace.
  159. * @param declarations - every declared slot.
  160. * @param registrations - every registration call site.
  161. * @param types - exported type index the owner-props reference resolves against.
  162. * @returns one message per violation, empty when the surface is teachable.
  163. */
  164. export function validateSlotContracts(
  165. declarations: readonly SlotDeclaration[],
  166. registrations: readonly SlotRegistration[],
  167. types: ReadonlyMap<string, TypeDeclaration>,
  168. ): string[] {
  169. const problems: string[] = []
  170. const byKey = new Map<string, SlotDeclaration>()
  171. for (const declaration of declarations) {
  172. const where = `slot '${declaration.key}' (${declaration.source})`
  173. const previous = byKey.get(declaration.key)
  174. if (previous !== undefined) {
  175. problems.push(`${where} is also declared at ${previous.source}; SlotMap merges duplicates silently, so the catalog cannot tell which documentation wins.`)
  176. continue
  177. }
  178. byKey.set(declaration.key, declaration)
  179. if (!(KINDS as readonly string[]).includes(declaration.kind)) {
  180. problems.push(`${where} has no literal 'kind'; the catalog derives the register options from it, so it must be one of ${KINDS.join('/')}.`)
  181. }
  182. if (!(SCOPES as readonly string[]).includes(declaration.scope)) {
  183. problems.push(`${where} has no literal 'scope'; the catalog derives the framework props from it, so it must be one of ${SCOPES.join('/')}.`)
  184. }
  185. if (docProse(declaration.jsDoc) === '') {
  186. problems.push(`${where} has no JSDoc prose. Write it from the REGISTRANT's side: what to pass, what the component receives, whom a registration replaces, and what absence looks like (packages/client/ui-settings/src/client/contract/slots.ts is the template).`)
  187. }
  188. if (declaration.ownerType !== undefined
  189. && /^[A-Za-z_$][\w$]*$/.test(declaration.ownerType)
  190. && !types.has(declaration.ownerType)) {
  191. problems.push(`${where} names owner props '${declaration.ownerType}' that no exported declaration provides; export the interface so the catalog can show what the component receives.`)
  192. }
  193. }
  194. for (const registration of registrations) {
  195. if (registration.factory !== true && !byKey.has(registration.key)) {
  196. problems.push(`registration into '${registration.key}' (${registration.source}) targets a slot no SlotMap merge declares; either the scan has a blind spot or the registration is dead.`)
  197. }
  198. for (const child of registration.children) {
  199. if (!byKey.has(child)) {
  200. problems.push(`registration at ${registration.source} declares child slot '${child}' that no SlotMap merge types.`)
  201. }
  202. }
  203. }
  204. return problems
  205. }
  206. /**
  207. * Project validated declarations into catalog entries: cardinality decides the
  208. * register options, scope decides the framework props, and the registration
  209. * call sites decide who already sits in the seat and which owner's mount makes
  210. * it exist. Pure, so the projection facts are provable without a workspace.
  211. * @param declarations - validated slot declarations.
  212. * @param registrations - every registration call site.
  213. * @param types - exported type index for owner-props expansion.
  214. * @param kits - framework prop seats per scope.
  215. * @returns the entries, sorted by key.
  216. */
  217. export function resolveSlotEntries(
  218. declarations: readonly SlotDeclaration[],
  219. registrations: readonly SlotRegistration[],
  220. types: ReadonlyMap<string, TypeDeclaration>,
  221. kits: ReadonlyMap<string, readonly string[]>,
  222. ): SlotEntry[] {
  223. const declaredBy = new Map<string, SlotRegistration>()
  224. for (const registration of registrations) {
  225. for (const child of registration.children) {
  226. if (!declaredBy.has(child)) declaredBy.set(child, registration)
  227. }
  228. }
  229. return declarations
  230. .map(declaration => entryOf(declaration, registrations, declaredBy.get(declaration.key), types, kits))
  231. .sort((left, right) => left.key.localeCompare(right.key))
  232. }
  233. /** The framework prop seats per scope, read from the merged standard-kit interfaces. */
  234. function standardKits(files: readonly ScannedFile[]): ReadonlyMap<string, readonly string[]> {
  235. const global = standardKitMembers(files, 'GlobalStandardProps')
  236. const kits = new Map<string, readonly string[]>()
  237. for (const scope of SCOPES) {
  238. const extra = SCOPE_KIT[scope]
  239. kits.set(scope, [...global, ...extra === undefined ? [] : standardKitMembers(files, extra)])
  240. }
  241. return kits
  242. }
  243. /** Resolve one declaration into its catalog entry. */
  244. function entryOf(
  245. declaration: SlotDeclaration,
  246. registrations: readonly SlotRegistration[],
  247. declaredBy: SlotRegistration | undefined,
  248. types: ReadonlyMap<string, TypeDeclaration>,
  249. kits: ReadonlyMap<string, readonly string[]>,
  250. ): SlotEntry {
  251. const occupants = registrations.filter(registration =>
  252. registration.factory !== true && registration.key === declaration.key)
  253. const cellOccupied = occupants.some(occupant =>
  254. declaration.kind === 'single' || occupant.entryKey !== undefined)
  255. const doc = docProse(declaration.jsDoc)
  256. const owner = ownerShapes(declaration.ownerType, types)
  257. return {
  258. key: declaration.key,
  259. kind: declaration.kind,
  260. scope: declaration.scope,
  261. summary: firstSentence(doc),
  262. doc,
  263. registerOptions: REGISTER_OPTIONS[declaration.kind as (typeof KINDS)[number]],
  264. ownerProps: owner.declarations.map(type => truncate(type.text)),
  265. ownerPropsReferences: owner.references,
  266. standardProps: kits.get(declaration.scope) ?? [],
  267. keyDomain: keyDomainOf(declaration, occupants),
  268. hookContext: declaration.hookContext ?? '',
  269. slotInject: declaration.injectType ?? '',
  270. declaredBy: declaredBy === undefined
  271. ? 'the runtime itself (built in; always present)'
  272. : declaredBy.factory === true
  273. ? `factory '${declaredBy.key}' (${shortPackage(declaredBy.package)}), so it exists while that definition is registered`
  274. : `an entry in '${declaredBy.key}' (${shortPackage(declaredBy.package)}), so it exists while that entry is mounted`,
  275. occupants: occupants.map(occupant => [
  276. shortPackage(occupant.package),
  277. occupant.component,
  278. ...occupant.id === undefined ? [] : [`id '${occupant.id}'`],
  279. ...occupant.entryKey === undefined ? [] : [`key '${occupant.entryKey}'`],
  280. ].join(' ')),
  281. replaceRisk: cellOccupied && (declaration.kind === 'single' || declaration.kind === 'keyed')
  282. ? 'shadows-shipped-ui'
  283. : 'none',
  284. example: exampleOf(declaration),
  285. source: declaration.source,
  286. }
  287. }
  288. /**
  289. * The owner-props contract at ONE level: the owner declaration(s) themselves,
  290. * plus the names of the shapes their fields reference. Expanding transitively
  291. * pulled the whole session model into four seats (one report exceeded 2400
  292. * lines), which defeats the purpose of narrowing to a single slot — a registrant
  293. * needs the fields and their documented meaning, not the type graph behind them.
  294. */
  295. function ownerShapes(
  296. ownerType: string | undefined,
  297. types: ReadonlyMap<string, TypeDeclaration>,
  298. ): { declarations: TypeDeclaration[]; references: string[] } {
  299. if (ownerType === undefined) return { declarations: [], references: [] }
  300. const declarations = declaredTypes(referencedTypeNames([ownerType], types), types)
  301. const own = new Set(declarations.map(declaration => declaration.name))
  302. const references = referencedTypeNames(declarations.map(declaration => declaration.text), types)
  303. .filter(name => !own.has(name))
  304. return { declarations, references }
  305. }
  306. /** How a keyed slot's key domain is constrained, '' for the other kinds. */
  307. function keyDomainOf(declaration: SlotDeclaration, occupants: readonly SlotRegistration[]): string {
  308. if (declaration.kind !== 'keyed') return ''
  309. const taken = [...new Set(occupants.flatMap(occupant => occupant.entryKey === undefined ? [] : [occupant.entryKey]))].sort()
  310. const shipped = taken.length === 0 ? 'none are taken yet' : `already taken: ${taken.join(', ')}`
  311. return declaration.keyProps === undefined
  312. ? `open: any string the owner dispatches (no compile-time key set), ${shipped}`
  313. : `fixed by the owner's key table ${declaration.keyProps}, ${shipped}`
  314. }
  315. /** A runnable minimal registration for one slot, per cardinality. */
  316. function exampleOf(declaration: SlotDeclaration): string {
  317. const options = [`name: '${declaration.key}'`, ...KIND_EXAMPLE[declaration.kind] ?? []].join(', ')
  318. return [
  319. 'return {',
  320. " inject: ['slots'],",
  321. ' apply(ctx) {',
  322. ` ctx.slots.inject('${declaration.key}', () => ctx.slots.register(`,
  323. ` { ${options} },`,
  324. " () => React.createElement('div', null, 'hello'),",
  325. ' ))',
  326. ' },',
  327. '}',
  328. ].join('\n')
  329. }
  330. /** Extra example options per cardinality. */
  331. const KIND_EXAMPLE: Readonly<Record<string, readonly string[]>> = {
  332. single: [],
  333. list: ["id: 'my-entry'", 'order: 100', "label: 'My entry'"],
  334. keyed: ["key: '<one key the owner dispatches>'"],
  335. chain: ['select: owner => null'],
  336. }
  337. /** Drop the `@deepseek-ai/dsh-` prefix so rows stay readable. */
  338. function shortPackage(name: string): string {
  339. return name.replace('@deepseek-ai/dsh-', '')
  340. }
  341. /** Truncate an over-long declaration, naming the truncation. */
  342. function truncate(text: string): string {
  343. return text.length > MAX_DECL_CHARS
  344. ? `${text.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
  345. : text
  346. }
  347. /** JSDoc prose: comment markers and block tags removed, paragraphs kept. */
  348. function docProse(jsDoc: string): string {
  349. const lines = jsDoc.replace(/^\/\*\*/, '').replace(/\*\/$/, '').split('\n')
  350. .map(line => line.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
  351. const kept: string[] = []
  352. for (const line of lines) {
  353. if (line.trimStart().startsWith('@')) break
  354. kept.push(line)
  355. }
  356. return kept.join('\n').replace(/\{@link\s+([^}]+)\}/g, '$1').replace(/\n{3,}/g, '\n\n').trim()
  357. }
  358. /** First sentence of a prose block, for the compact listing. */
  359. function firstSentence(doc: string): string {
  360. const flat = doc.replace(/\s+/g, ' ').trim()
  361. const match = /^(.*?[.!?])(?:\s|$)/.exec(flat)
  362. return (match?.[1] ?? flat).trim()
  363. }
  364. /** Render one value as a single-quoted TypeScript literal. */
  365. function quote(value: string): string {
  366. return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`
  367. }
  368. /** Render a readonly string-array literal. */
  369. function list(values: readonly string[], indent: string): string {
  370. if (values.length === 0) return '[]'
  371. return ['[', ...values.map(value => `${indent} ${quote(value)},`), `${indent}]`].join('\n')
  372. }
  373. /**
  374. * Render the generated data module.
  375. * @param entries - resolved catalog entries.
  376. * @returns the module source.
  377. */
  378. export function renderClientCatalog(entries: readonly SlotEntry[]): string {
  379. const lines: string[] = [
  380. '/**',
  381. ' * Generated by scripts/gen-client-catalog.ts — do not edit by hand; run',
  382. ' * `pnpm run gen-client-catalog` to regenerate (freshness-gated by',
  383. ' * `pnpm run verify-client-catalog` in doc-sync).',
  384. ' *',
  385. ' * The compile-time contract of the shipped web bundle\'s slot surface, as',
  386. ' * `cordis_inspect what:"client"` serves it to the model: every SlotMap key a',
  387. ' * browser half can register into, what that register call must pass, what the',
  388. ' * component receives, who already occupies the seat, and which owner has to be',
  389. ' * mounted for the seat to exist. Data only — this module is the one legitimate',
  390. ' * meeting point of the two planes, so it carries strings, never client imports.',
  391. ' *',
  392. ' * @module @deepseek-ai/dsh-cordis-client-runner/client/slot-catalog',
  393. ' */',
  394. '',
  395. '/* jscpd:ignore-start */',
  396. '/** One option a register call passes for a given slot cardinality. */',
  397. 'export interface ClientSlotOption {',
  398. ' /** Option name as written in the register options object. */',
  399. ' name: string',
  400. ' /** Whether the cardinality requires it. */',
  401. ' requirement: string',
  402. ' /** Accepted type, in source spelling. */',
  403. ' type: string',
  404. ' /** What it does, from the registrant\'s side. */',
  405. ' doc: string',
  406. '}',
  407. '',
  408. '/** One browser-half slot a dynamic package can contribute UI into. */',
  409. 'export interface ClientSlotEntry {',
  410. ' /** SlotMap key passed as the register call\'s `name`. */',
  411. ' key: string',
  412. ' /** Cardinality: `single`, `list`, `keyed`, or `chain`. */',
  413. ' kind: string',
  414. ' /** Data scope: `root`, `session`, or `session-maybe`. */',
  415. ' scope: string',
  416. ' /** First sentence of the contract prose. */',
  417. ' summary: string',
  418. ' /** Full contract prose from the SlotMap declaration. */',
  419. ' doc: string',
  420. ' /** Options this cardinality accepts (beyond `name`). */',
  421. ' registerOptions: readonly ClientSlotOption[]',
  422. ' /** Declarations of the props the owner passes down, with their own documentation. */',
  423. ' ownerProps: readonly string[]',
  424. ' /** Names of the shapes those props reference; deliberately not expanded here. */',
  425. ' ownerPropsReferences: readonly string[]',
  426. ' /** Framework-supplied component props for this scope. */',
  427. ' standardProps: readonly string[]',
  428. ' /** For keyed slots: how the key set is constrained and which keys are taken. */',
  429. ' keyDomain: string',
  430. ' /** Opaque per-render-site context passed to slot-level hooks, when the slot declares one. */',
  431. ' hookContext: string',
  432. ' /** Slot-level inject face every entry receives, when the slot declares one. */',
  433. ' slotInject: string',
  434. ' /** Which mounted entry makes this slot exist. */',
  435. ' declaredBy: string',
  436. ' /** Entries the shipped composition already registered here. */',
  437. ' occupants: readonly string[]',
  438. ' /** `shadows-shipped-ui` when registering here replaces shipped UI; `none` when additive. */',
  439. ' replaceRisk: string',
  440. ' /** A minimal browser half that registers into this slot. */',
  441. ' example: string',
  442. ' /** Source pointer of the contract declaration. */',
  443. ' source: string',
  444. '}',
  445. '',
  446. '/** Rules that apply to every browser-half contribution, in reading order. */',
  447. 'export const CLIENT_NOTES: readonly string[] = [',
  448. ...CLIENT_NOTES.map(note => ` ${quote(note)},`),
  449. ']',
  450. '',
  451. '/** Every slot the shipped web bundle declares, sorted by key. */',
  452. // The entries below repeat by nature: seats of one cardinality share their
  453. // register options and framework props verbatim, and that sameness is the
  454. // contract a registrant reads, not a refactor waiting to happen. Clone
  455. // detection is told so here rather than through a config exception, which is
  456. // how this repository marks duplication that belongs to its subject.
  457. '// Seats of one cardinality repeat their register options and framework props',
  458. '// verbatim; that sameness IS the contract a registrant reads, so clone',
  459. '// detection is told to skip the data rather than the file.',
  460. 'export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [',
  461. ]
  462. for (const entry of entries) {
  463. lines.push(' {')
  464. lines.push(` key: ${quote(entry.key)},`)
  465. lines.push(` kind: ${quote(entry.kind)},`)
  466. lines.push(` scope: ${quote(entry.scope)},`)
  467. lines.push(` summary: ${quote(entry.summary)},`)
  468. lines.push(` doc: ${quote(entry.doc)},`)
  469. if (entry.registerOptions.length === 0) {
  470. lines.push(' registerOptions: [],')
  471. } else {
  472. lines.push(' registerOptions: [')
  473. for (const option of entry.registerOptions) {
  474. lines.push(' {')
  475. lines.push(` name: ${quote(option.name)},`)
  476. lines.push(` requirement: ${quote(option.requirement)},`)
  477. lines.push(` type: ${quote(option.type)},`)
  478. lines.push(` doc: ${quote(option.doc)},`)
  479. lines.push(' },')
  480. }
  481. lines.push(' ],')
  482. }
  483. lines.push(` ownerProps: ${list(entry.ownerProps, ' ')},`)
  484. lines.push(` ownerPropsReferences: ${list(entry.ownerPropsReferences, ' ')},`)
  485. lines.push(` standardProps: ${list(entry.standardProps, ' ')},`)
  486. lines.push(` keyDomain: ${quote(entry.keyDomain)},`)
  487. lines.push(` hookContext: ${quote(entry.hookContext)},`)
  488. lines.push(` slotInject: ${quote(entry.slotInject)},`)
  489. lines.push(` declaredBy: ${quote(entry.declaredBy)},`)
  490. lines.push(` occupants: ${list(entry.occupants, ' ')},`)
  491. lines.push(` replaceRisk: ${quote(entry.replaceRisk)},`)
  492. lines.push(` example: ${quote(entry.example)},`)
  493. lines.push(` source: ${quote(entry.source)},`)
  494. lines.push(' },')
  495. }
  496. lines.push(']', '/* jscpd:ignore-end */', '')
  497. return lines.join('\n')
  498. }
  499. /**
  500. * CLI entry: regenerate the catalog, or with `--check` fail when it is stale.
  501. * @returns nothing; writes the artifact or reports freshness through the process.
  502. */
  503. export function main(): void {
  504. const content = renderClientCatalog(collectSlotEntries(root))
  505. const destination = resolve(root, OUT)
  506. if (process.argv.includes('--check')) {
  507. let committed: string | null = null
  508. try {
  509. committed = readFileSync(destination, 'utf8')
  510. } catch {
  511. // Only ENOENT (never generated) is expected here, and its remedy is the
  512. // same as a stale artifact's: regenerate.
  513. committed = null
  514. }
  515. if (committed === content) {
  516. console.log(`gen-client-catalog: ${OUT} is up to date.`)
  517. process.exit(0)
  518. }
  519. console.error(`gen-client-catalog: stale — ${OUT}. Run \`pnpm run gen-client-catalog\` and commit the result.`)
  520. process.exit(1)
  521. }
  522. mkdirSync(dirname(destination), { recursive: true })
  523. writeFileSync(destination, content)
  524. console.log(`gen-client-catalog: wrote ${OUT}.`)
  525. }
  526. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  527. main()
  528. }