2026-08-12 23:51:31 +08:00
/**
* Generate the model-facing client slot catalog consumed by `cordis_inspect
* what:"client"`. A dynamic package's browser half can only contribute UI
* through `ctx.slots.register`, and every fact it needs to do that safely —
* which keys exist, what each register call must pass, what the component
* receives, who already occupies the seat, and when the seat exists at all —
* is decided at compile time by the shipped web bundle. This generator reads
* those facts lexically (no type-checker program) and emits them as a data
* module inside `tool-cordis`, so the host-side toolset teaches the browser
* surface without importing a single client runtime module.
*
* `--check` verifies the committed artifact is fresh.
*/
import { mkdirSync , readFileSync , writeFileSync } from 'node:fs'
import { dirname , resolve } from 'node:path'
import {
declaredTypes ,
indexExportedTypes ,
referencedTypeNames ,
scanSlotFiles ,
slotDeclarations ,
slotRegistrations ,
standardKitMembers ,
} from './slot-walk.ts'
import type { ScannedFile , SlotDeclaration , SlotRegistration , TypeDeclaration } from './slot-walk.ts'
const root = resolve ( import . meta . dirname , '..' )
const OUT = 'packages/extensions/cordis-client-runner/src/client/slot-catalog.ts'
/** Source globs: every workspace package's sources, `.tsx` included (a contract may live in one). */
const SOURCE_GLOBS = [ 'packages/*/*/src/**/*.ts' , 'packages/*/*/src/**/*.tsx' ]
/** Slot cardinalities the contract allows. */
const KINDS = [ 'single' , 'list' , 'keyed' , 'chain' ] as const
/** Slot data scopes the contract allows. */
const SCOPES = [ 'root' , 'session' , 'session-maybe' ] as const
/** Declarations longer than this render truncated; the full shape stays in source. */
const MAX_DECL_CHARS = 1200
/**
* Line budget for ONE slot's expanded report. The whole point of narrowing to a
* single slot is to spend less context, so a report a model cannot finish
* reading is a defect rather than a detail. Today's widest slot renders 60
* lines, so this leaves room to document a slot properly while catching the two
* ways a report runs away: an owner share that hands down a subsystem instead of
* a share, and prose that grew into a manual.
*/
const MAX_ENTRY_LINES = 120
/** One register-call option as the catalog teaches it. */
interface OptionDoc {
readonly name : string
readonly requirement : 'required' | 'optional'
readonly type : string
readonly doc : string
}
/**
* Register options per cardinality, curated from `KindOptions` in
* `packages/client/ui-slots/src/index.ts` — the authority for what a register
* call may pass. Curated rather than projected because the authority is a
* conditional type keyed on the slot's kind: it has no per-kind declaration a
* lexical scan could read, and its own JSDoc addresses the compiler, not a
* registrant. `verify-client-catalog` pins the authority's text so a change
* there forces this table to be revisited.
*/
const REGISTER_OPTIONS : Readonly < Record < ( typeof KINDS ) [ number ] , readonly OptionDoc [ ] > > = {
single : [ ] ,
list : [
{ 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.' } ,
{ name : 'order' , requirement : 'optional' , type : 'number' , doc : 'Position among the entries, ascending (default 0).' } ,
{ 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.' } ,
] ,
keyed : [
{ 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.' } ,
] ,
chain : [
{ 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.' } ,
] ,
}
/** The one register option a dynamic package must NOT pass, and why. */
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.'
/** Cross-cutting rules a registrant needs once, not per slot. */
const CLIENT_NOTES : readonly string [ ] = [
'Contribute UI only through `ctx.slots.register(options, Component)`; declare `inject: [\'slots\']` in your returned plugin (object form) or the seat is withheld.' ,
'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.' ,
PRIORITY_NOTE ,
'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.' ,
'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)`.' ,
'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"`.' ,
]
/** Standard-kit interface that applies to each scope, beyond the global one. */
const SCOPE_KIT : Readonly < Record < ( typeof SCOPES ) [ number ] , string | undefined > > = {
'root' : undefined ,
'session' : 'SessionStandardProps' ,
'session-maybe' : 'SessionMaybeStandardProps' ,
}
/** One resolved catalog entry, ready to render. */
export interface SlotEntry {
readonly key : string
readonly kind : string
readonly scope : string
readonly summary : string
readonly doc : string
readonly registerOptions : readonly OptionDoc [ ]
readonly ownerProps : readonly string [ ]
readonly ownerPropsReferences : readonly string [ ]
readonly standardProps : readonly string [ ]
readonly keyDomain : string
readonly hookContext : string
readonly slotInject : string
readonly declaredBy : string
readonly occupants : readonly string [ ]
readonly replaceRisk : string
readonly example : string
readonly source : string
}
/**
* Read the workspace and resolve every catalog entry, failing loud on a
* contract the catalog cannot teach.
* @param scanRoot - repository root to scan.
* @returns the entries, sorted by key.
* @throws when any declared slot is unteachable or the scan contradicts itself.
*/
export function collectSlotEntries ( scanRoot : string ) : SlotEntry [ ] {
const files = scanSlotFiles ( scanRoot , SOURCE_GLOBS )
const declarations = files . flatMap ( file = > slotDeclarations ( file ) )
const registrations = files . flatMap ( file = > slotRegistrations ( file ) )
const types = indexExportedTypes ( scanRoot , SOURCE_GLOBS )
const problems = validateSlotContracts ( declarations , registrations , types )
if ( problems . length > 0 ) {
throw new Error ( ` gen-client-catalog: ${ String ( problems . length ) } contract violation(s): \ n ${ problems . map ( problem = > ` ${ problem } ` ) . join ( '\n' ) } ` )
}
const entries = resolveSlotEntries ( declarations , registrations , types , standardKits ( files ) )
const oversized = oversizedSlotReports ( entries )
if ( oversized . length > 0 ) {
throw new Error ( ` gen-client-catalog: ${ String ( oversized . length ) } slot(s) exceed the per-slot report budget `
+ ` of ${ String ( MAX_ENTRY_LINES ) } lines: \ n ${ oversized . map ( problem = > ` ${ problem } ` ) . join ( '\n' ) } ` )
}
return entries
}
/**
* Slots whose expanded report exceeds {@link MAX_ENTRY_LINES}. Separated from
* the scan so the budget is provable on one hand-built entry.
* @param entries - resolved catalog entries.
* @returns one message per over-budget slot, empty when every report is readable.
*/
export function oversizedSlotReports ( entries : readonly SlotEntry [ ] ) : string [ ] {
return entries
. filter ( entry = > entryLines ( entry ) > MAX_ENTRY_LINES )
. map ( entry = > ` slot ' ${ entry . key } ' ( ${ entry . source } ) reports ${ String ( entryLines ( entry ) ) } lines. `
+ 'Narrow the owner share it passes down (a slot hands a registrant a share, not a subsystem) or tighten '
+ 'its prose, so asking about one slot stays cheaper than asking about all of them.' )
}
/** Line count of one entry's variable-length content, the proxy for its rendered report. */
function entryLines ( entry : SlotEntry ) : number {
const blocks = [ entry . doc , entry . example , . . . entry . ownerProps , . . . entry . registerOptions . map ( option = > option . doc ) ]
return blocks . reduce ( ( total , block ) = > total + block . split ( '\n' ) . length , 0 )
+ entry . standardProps . length + entry . ownerPropsReferences . length + entry . occupants . length
}
/**
* Fail-closed contract checks: an unteachable slot must break the gate rather
* than ship an entry a model cannot act on. Pure, so every rejection is
* provable without scanning the workspace.
* @param declarations - every declared slot.
* @param registrations - every registration call site.
* @param types - exported type index the owner-props reference resolves against.
* @returns one message per violation, empty when the surface is teachable.
*/
export function validateSlotContracts (
declarations : readonly SlotDeclaration [ ] ,
registrations : readonly SlotRegistration [ ] ,
types : ReadonlyMap < string , TypeDeclaration > ,
) : string [ ] {
const problems : string [ ] = [ ]
const byKey = new Map < string , SlotDeclaration > ( )
for ( const declaration of declarations ) {
const where = ` slot ' ${ declaration . key } ' ( ${ declaration . source } ) `
const previous = byKey . get ( declaration . key )
if ( previous !== undefined ) {
problems . push ( ` ${ where } is also declared at ${ previous . source } ; SlotMap merges duplicates silently, so the catalog cannot tell which documentation wins. ` )
continue
}
byKey . set ( declaration . key , declaration )
if ( ! ( KINDS as readonly string [ ] ) . includes ( declaration . kind ) ) {
problems . push ( ` ${ where } has no literal 'kind'; the catalog derives the register options from it, so it must be one of ${ KINDS . join ( '/' ) } . ` )
}
if ( ! ( SCOPES as readonly string [ ] ) . includes ( declaration . scope ) ) {
problems . push ( ` ${ where } has no literal 'scope'; the catalog derives the framework props from it, so it must be one of ${ SCOPES . join ( '/' ) } . ` )
}
if ( docProse ( declaration . jsDoc ) === '' ) {
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). ` )
}
if ( declaration . ownerType !== undefined
&& /^[A-Za-z_$][\w$]*$/ . test ( declaration . ownerType )
&& ! types . has ( declaration . ownerType ) ) {
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. ` )
}
}
for ( const registration of registrations ) {
if ( ! byKey . has ( registration . key ) ) {
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. ` )
}
for ( const child of registration . children ) {
if ( ! byKey . has ( child ) ) {
problems . push ( ` registration at ${ registration . source } declares child slot ' ${ child } ' that no SlotMap merge types. ` )
}
}
}
return problems
}
/**
* Project validated declarations into catalog entries: cardinality decides the
* register options, scope decides the framework props, and the registration
* call sites decide who already sits in the seat and which owner's mount makes
* it exist. Pure, so the projection facts are provable without a workspace.
* @param declarations - validated slot declarations.
* @param registrations - every registration call site.
* @param types - exported type index for owner-props expansion.
* @param kits - framework prop seats per scope.
* @returns the entries, sorted by key.
*/
export function resolveSlotEntries (
declarations : readonly SlotDeclaration [ ] ,
registrations : readonly SlotRegistration [ ] ,
types : ReadonlyMap < string , TypeDeclaration > ,
kits : ReadonlyMap < string , readonly string [ ] > ,
) : SlotEntry [ ] {
const declaredBy = new Map < string , SlotRegistration > ( )
for ( const registration of registrations ) {
for ( const child of registration . children ) {
if ( ! declaredBy . has ( child ) ) declaredBy . set ( child , registration )
}
}
return declarations
. map ( declaration = > entryOf ( declaration , registrations , declaredBy . get ( declaration . key ) , types , kits ) )
. sort ( ( left , right ) = > left . key . localeCompare ( right . key ) )
}
/** The framework prop seats per scope, read from the merged standard-kit interfaces. */
function standardKits ( files : readonly ScannedFile [ ] ) : ReadonlyMap < string , readonly string [ ] > {
const global = standardKitMembers ( files , 'GlobalStandardProps' )
const kits = new Map < string , readonly string [ ] > ( )
for ( const scope of SCOPES ) {
const extra = SCOPE_KIT [ scope ]
kits . set ( scope , [ . . . global , . . . extra === undefined ? [ ] : standardKitMembers ( files , extra ) ] )
}
return kits
}
/** Resolve one declaration into its catalog entry. */
function entryOf (
declaration : SlotDeclaration ,
registrations : readonly SlotRegistration [ ] ,
declaredBy : SlotRegistration | undefined ,
types : ReadonlyMap < string , TypeDeclaration > ,
kits : ReadonlyMap < string , readonly string [ ] > ,
) : SlotEntry {
const occupants = registrations . filter ( registration = > registration . key === declaration . key )
const cellOccupied = occupants . some ( occupant = >
declaration . kind === 'single' || occupant . entryKey !== undefined )
const doc = docProse ( declaration . jsDoc )
const owner = ownerShapes ( declaration . ownerType , types )
return {
key : declaration.key ,
kind : declaration.kind ,
scope : declaration.scope ,
summary : firstSentence ( doc ) ,
doc ,
registerOptions : REGISTER_OPTIONS [ declaration . kind as ( typeof KINDS ) [ number ] ] ,
ownerProps : owner.declarations.map ( type = > truncate ( type . text ) ) ,
ownerPropsReferences : owner.references ,
standardProps : kits.get ( declaration . scope ) ? ? [ ] ,
keyDomain : keyDomainOf ( declaration , occupants ) ,
hookContext : declaration.hookContext ? ? '' ,
slotInject : declaration.injectType ? ? '' ,
declaredBy : declaredBy === undefined
? 'the runtime itself (built in; always present)'
: ` an entry in ' ${ declaredBy . key } ' ( ${ shortPackage ( declaredBy . package ) } ), so it exists while that entry is mounted ` ,
occupants : occupants.map ( occupant = > [
shortPackage ( occupant . package ) ,
occupant . component ,
. . . occupant . id === undefined ? [ ] : [ ` id ' ${ occupant . id } ' ` ] ,
. . . occupant . entryKey === undefined ? [ ] : [ ` key ' ${ occupant . entryKey } ' ` ] ,
] . join ( ' ' ) ) ,
replaceRisk : cellOccupied && ( declaration . kind === 'single' || declaration . kind === 'keyed' )
? 'shadows-shipped-ui'
: 'none' ,
example : exampleOf ( declaration ) ,
source : declaration.source ,
}
}
/**
* The owner-props contract at ONE level: the owner declaration(s) themselves,
* plus the names of the shapes their fields reference. Expanding transitively
* pulled the whole session model into four seats (one report exceeded 2400
* lines), which defeats the purpose of narrowing to a single slot — a registrant
* needs the fields and their documented meaning, not the type graph behind them.
*/
function ownerShapes (
ownerType : string | undefined ,
types : ReadonlyMap < string , TypeDeclaration > ,
) : { declarations : TypeDeclaration [ ] ; references : string [ ] } {
if ( ownerType === undefined ) return { declarations : [ ] , references : [ ] }
const declarations = declaredTypes ( referencedTypeNames ( [ ownerType ] , types ) , types )
const own = new Set ( declarations . map ( declaration = > declaration . name ) )
const references = referencedTypeNames ( declarations . map ( declaration = > declaration . text ) , types )
. filter ( name = > ! own . has ( name ) )
return { declarations , references }
}
/** How a keyed slot's key domain is constrained, '' for the other kinds. */
function keyDomainOf ( declaration : SlotDeclaration , occupants : readonly SlotRegistration [ ] ) : string {
if ( declaration . kind !== 'keyed' ) return ''
const taken = [ . . . new Set ( occupants . flatMap ( occupant = > occupant . entryKey === undefined ? [ ] : [ occupant . entryKey ] ) ) ] . sort ( )
const shipped = taken . length === 0 ? 'none are taken yet' : ` already taken: ${ taken . join ( ', ' ) } `
return declaration . keyProps === undefined
? ` open: any string the owner dispatches (no compile-time key set), ${ shipped } `
: ` fixed by the owner's key table ${ declaration . keyProps } , ${ shipped } `
}
/** A runnable minimal registration for one slot, per cardinality. */
function exampleOf ( declaration : SlotDeclaration ) : string {
const options = [ ` name: ' ${ declaration . key } ' ` , . . . KIND_EXAMPLE [ declaration . kind ] ? ? [ ] ] . join ( ', ' )
return [
'return {' ,
" inject: ['slots']," ,
' apply(ctx) {' ,
` ctx.slots.inject(' ${ declaration . key } ', () => ctx.slots.register( ` ,
` { ${ options } }, ` ,
" () => React.createElement('div', null, 'hello')," ,
' ))' ,
' },' ,
'}' ,
] . join ( '\n' )
}
/** Extra example options per cardinality. */
const KIND_EXAMPLE : Readonly < Record < string , readonly string [ ] > > = {
single : [ ] ,
list : [ "id: 'my-entry'" , 'order: 100' , "label: 'My entry'" ] ,
keyed : [ "key: '<one key the owner dispatches>'" ] ,
chain : [ 'select: owner => null' ] ,
}
/** Drop the `@deepseek-ai/dsh-` prefix so rows stay readable. */
function shortPackage ( name : string ) : string {
return name . replace ( '@deepseek-ai/dsh-' , '' )
}
/** Truncate an over-long declaration, naming the truncation. */
function truncate ( text : string ) : string {
return text . length > MAX_DECL_CHARS
? ` ${ text . slice ( 0 , MAX_DECL_CHARS ) } /* …truncated — full shape in source */ `
: text
}
/** JSDoc prose: comment markers and block tags removed, paragraphs kept. */
function docProse ( jsDoc : string ) : string {
const lines = jsDoc . replace ( /^\/\*\*/ , '' ) . replace ( /\*\/$/ , '' ) . split ( '\n' )
. map ( line = > line . replace ( /^\s*\*?\s?/ , '' ) . replace ( /\s+$/ , '' ) )
const kept : string [ ] = [ ]
for ( const line of lines ) {
if ( line . trimStart ( ) . startsWith ( '@' ) ) break
kept . push ( line )
}
return kept . join ( '\n' ) . replace ( /\{@link\s+([^}]+)\}/g , '$1' ) . replace ( /\n{3,}/g , '\n\n' ) . trim ( )
}
/** First sentence of a prose block, for the compact listing. */
function firstSentence ( doc : string ) : string {
const flat = doc . replace ( /\s+/g , ' ' ) . trim ( )
const match = /^(.*?[.!?])(?:\s|$)/ . exec ( flat )
return ( match ? . [ 1 ] ? ? flat ) . trim ( )
}
/** Render one value as a single-quoted TypeScript literal. */
function quote ( value : string ) : string {
return ` ' ${ value . replaceAll ( '\\' , '\\\\' ) . replaceAll ( "'" , "\\'" ) . replaceAll ( '\n' , '\\n' ) } ' `
}
/** Render a readonly string-array literal. */
function list ( values : readonly string [ ] , indent : string ) : string {
if ( values . length === 0 ) return '[]'
return [ '[' , . . . values . map ( value = > ` ${ indent } ${ quote ( value ) } , ` ) , ` ${ indent } ] ` ] . join ( '\n' )
}
/**
* Render the generated data module.
* @param entries - resolved catalog entries.
* @returns the module source.
*/
export function renderClientCatalog ( entries : readonly SlotEntry [ ] ) : string {
const lines : string [ ] = [
'/**' ,
' * Generated by scripts/gen-client-catalog.ts — do not edit by hand; run' ,
' * `pnpm run gen-client-catalog` to regenerate (freshness-gated by' ,
' * `pnpm run verify-client-catalog` in doc-sync).' ,
' *' ,
' * The compile-time contract of the shipped web bundle\'s slot surface, as' ,
' * `cordis_inspect what:"client"` serves it to the model: every SlotMap key a' ,
' * browser half can register into, what that register call must pass, what the' ,
' * component receives, who already occupies the seat, and which owner has to be' ,
' * mounted for the seat to exist. Data only — this module is the one legitimate' ,
' * meeting point of the two planes, so it carries strings, never client imports.' ,
' *' ,
' * @module @deepseek-ai/dsh-cordis-client-runner/client/slot-catalog' ,
' */' ,
'' ,
2026-08-13 01:00:49 +08:00
'/* jscpd:ignore-start */' ,
2026-08-12 23:51:31 +08:00
'/** One option a register call passes for a given slot cardinality. */' ,
'export interface ClientSlotOption {' ,
' /** Option name as written in the register options object. */' ,
' name: string' ,
' /** Whether the cardinality requires it. */' ,
' requirement: string' ,
' /** Accepted type, in source spelling. */' ,
' type: string' ,
' /** What it does, from the registrant\'s side. */' ,
' doc: string' ,
'}' ,
'' ,
'/** One browser-half slot a dynamic package can contribute UI into. */' ,
'export interface ClientSlotEntry {' ,
' /** SlotMap key passed as the register call\'s `name`. */' ,
' key: string' ,
' /** Cardinality: `single`, `list`, `keyed`, or `chain`. */' ,
' kind: string' ,
' /** Data scope: `root`, `session`, or `session-maybe`. */' ,
' scope: string' ,
' /** First sentence of the contract prose. */' ,
' summary: string' ,
' /** Full contract prose from the SlotMap declaration. */' ,
' doc: string' ,
' /** Options this cardinality accepts (beyond `name`). */' ,
' registerOptions: readonly ClientSlotOption[]' ,
' /** Declarations of the props the owner passes down, with their own documentation. */' ,
' ownerProps: readonly string[]' ,
' /** Names of the shapes those props reference; deliberately not expanded here. */' ,
' ownerPropsReferences: readonly string[]' ,
' /** Framework-supplied component props for this scope. */' ,
' standardProps: readonly string[]' ,
' /** For keyed slots: how the key set is constrained and which keys are taken. */' ,
' keyDomain: string' ,
' /** Opaque per-render-site context passed to slot-level hooks, when the slot declares one. */' ,
' hookContext: string' ,
' /** Slot-level inject face every entry receives, when the slot declares one. */' ,
' slotInject: string' ,
' /** Which mounted entry makes this slot exist. */' ,
' declaredBy: string' ,
' /** Entries the shipped composition already registered here. */' ,
' occupants: readonly string[]' ,
' /** `shadows-shipped-ui` when registering here replaces shipped UI; `none` when additive. */' ,
' replaceRisk: string' ,
' /** A minimal browser half that registers into this slot. */' ,
' example: string' ,
' /** Source pointer of the contract declaration. */' ,
' source: string' ,
'}' ,
'' ,
'/** Rules that apply to every browser-half contribution, in reading order. */' ,
'export const CLIENT_NOTES: readonly string[] = [' ,
. . . CLIENT_NOTES . map ( note = > ` ${ quote ( note ) } , ` ) ,
']' ,
'' ,
'/** Every slot the shipped web bundle declares, sorted by key. */' ,
// The entries below repeat by nature: seats of one cardinality share their
// register options and framework props verbatim, and that sameness is the
// contract a registrant reads, not a refactor waiting to happen. Clone
// detection is told so here rather than through a config exception, which is
// how this repository marks duplication that belongs to its subject.
'// Seats of one cardinality repeat their register options and framework props' ,
'// verbatim; that sameness IS the contract a registrant reads, so clone' ,
'// detection is told to skip the data rather than the file.' ,
'export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [' ,
]
for ( const entry of entries ) {
lines . push ( ' {' )
lines . push ( ` key: ${ quote ( entry . key ) } , ` )
lines . push ( ` kind: ${ quote ( entry . kind ) } , ` )
lines . push ( ` scope: ${ quote ( entry . scope ) } , ` )
lines . push ( ` summary: ${ quote ( entry . summary ) } , ` )
lines . push ( ` doc: ${ quote ( entry . doc ) } , ` )
if ( entry . registerOptions . length === 0 ) {
lines . push ( ' registerOptions: [],' )
} else {
lines . push ( ' registerOptions: [' )
for ( const option of entry . registerOptions ) {
lines . push ( ' {' )
lines . push ( ` name: ${ quote ( option . name ) } , ` )
lines . push ( ` requirement: ${ quote ( option . requirement ) } , ` )
lines . push ( ` type: ${ quote ( option . type ) } , ` )
lines . push ( ` doc: ${ quote ( option . doc ) } , ` )
lines . push ( ' },' )
}
lines . push ( ' ],' )
}
lines . push ( ` ownerProps: ${ list ( entry . ownerProps , ' ' ) } , ` )
lines . push ( ` ownerPropsReferences: ${ list ( entry . ownerPropsReferences , ' ' ) } , ` )
lines . push ( ` standardProps: ${ list ( entry . standardProps , ' ' ) } , ` )
lines . push ( ` keyDomain: ${ quote ( entry . keyDomain ) } , ` )
lines . push ( ` hookContext: ${ quote ( entry . hookContext ) } , ` )
lines . push ( ` slotInject: ${ quote ( entry . slotInject ) } , ` )
lines . push ( ` declaredBy: ${ quote ( entry . declaredBy ) } , ` )
lines . push ( ` occupants: ${ list ( entry . occupants , ' ' ) } , ` )
lines . push ( ` replaceRisk: ${ quote ( entry . replaceRisk ) } , ` )
lines . push ( ` example: ${ quote ( entry . example ) } , ` )
lines . push ( ` source: ${ quote ( entry . source ) } , ` )
lines . push ( ' },' )
}
lines . push ( ']' , '/* jscpd:ignore-end */' , '' )
return lines . join ( '\n' )
}
/**
* CLI entry: regenerate the catalog, or with `--check` fail when it is stale.
* @returns nothing; writes the artifact or reports freshness through the process.
*/
export function main ( ) : void {
const content = renderClientCatalog ( collectSlotEntries ( root ) )
const destination = resolve ( root , OUT )
if ( process . argv . includes ( '--check' ) ) {
let committed : string | null = null
try {
committed = readFileSync ( destination , 'utf8' )
} catch {
// Only ENOENT (never generated) is expected here, and its remedy is the
// same as a stale artifact's: regenerate.
committed = null
}
if ( committed === content ) {
console . log ( ` gen-client-catalog: ${ OUT } is up to date. ` )
process . exit ( 0 )
}
console . error ( ` gen-client-catalog: stale — ${ OUT } . Run \` pnpm run gen-client-catalog \` and commit the result. ` )
process . exit ( 1 )
}
mkdirSync ( dirname ( destination ) , { recursive : true } )
writeFileSync ( destination , content )
console . log ( ` gen-client-catalog: wrote ${ OUT } . ` )
}
// Run only when invoked as a script, not when imported by a test.
if ( process . argv [ 1 ] && import . meta . filename === resolve ( process . argv [ 1 ] ) ) {
main ( )
}