/* Copyright (c) 2024-2025 Richard Rodger, MIT License */ // The filesystem contract jostraca actually uses. // // Deliberately narrower than `typeof import('node:fs')`, which is what this // was until the `memfs` package was replaced by src/util/memfs.ts. That // declaration promised the whole Node API while the code only ever called // what is listed here, so a provider had to implement far more than jostraca // needed -- and `res.fs()` advertised methods an in-repo filesystem has no // reason to carry. // // Real `node:fs` satisfies this structurally, so passing it, or any broader // handle, still works. type FST = { // Required. `existsSync` is the one asserted at runtime, by // BuildContext and FileHandler, as the provider-validity check. existsSync(path: string): boolean readFileSync(path: string, options?: any): any writeFileSync(path: string, data: any, options?: any): void mkdirSync(path: string, options?: any): any statSync(path: string, options?: any): any readdirSync(path: string, options?: any): any // Feature-detected: each call site tests `typeof` first and has a // fallback, so a provider may omit any of these. // renameSync -- FileHandler falls back to a direct write // chmodSync -- modes are left at their default // unlinkSync -- temp files are not cleaned up // realpathSync -- CopyOp falls back to identity, losing cycle detection renameSync?: (from: string, to: string) => void chmodSync?: (path: string, mode: any) => void unlinkSync?: (path: string) => void realpathSync?: (path: string) => any } /* // For calling code. type JostracaOptions = { folder?: string // Base output folder for generated files. Default: `.`. meta?: any // Provide meta data to the generation process. Default: `{}` fs?: any // File system API (used for testing). Default: `node:fs`. log?: Log // Logging interface. debug?: boolean // Generate additional debugging information. // TOOD: needs rethink exclude?: boolean // Exclude modified output files. Default: `false`. model?: any build?: boolean mem?: boolean vol?: any run?: { dry?: boolean } // Component specific options. cmp?: { Copy?: { ignore?: RegExp[] } } } */ // For calling code. type JostracaResult = { when: number, files: { preserved: string[], written: string[], presented: string[], diffed: string[], merged: string[], conflicted: string[], unchanged: string[], } audit: () => Audit[] vol?: () => any fs?: () => FST } /** * One way a committed file differs from what the generators produce. * * `kind` says which question failed, and the bytes are carried rather * than rendered: a CLI wants a diff, a test wants an assertion, and a * report wants a count. Rendering is the caller's. */ type CheckDrift = { /** Path relative to the checked folder, forward slashes. */ path: string /** * `missing`: the generators claim it and the folder does not hold it. * `content`: the bytes differ. * `mode`: the bytes match and the permission bits do not, where the * tree stated them with `File.mode`. */ kind: 'missing' | 'content' | 'mode' /** * What the generators produce. Absent for no kind -- a missing file * still has generated bytes, which is what would be written. */ generated?: Buffer /** What the folder holds. Absent when `kind` is `missing`. */ existing?: Buffer /** * The mode the tree declared, and the bits on disk. Both present only * when `kind` is `mode`. */ mode?: number existingMode?: number } /** * What `check()` answers: the tree the generators produce, held against * the tree on disk. */ type CheckResult = { /** The folder that was checked, as given. */ folder: string /** * Every path compared, relative and sorted. A file on disk that the * generators do not claim is not here: see the `check` reference for * why an unclaimed file is left alone. */ checked: string[] /** * Sorted by path. Empty means the folder is what the generators * produce. */ drift: CheckDrift[] /** * The run report of the generate that produced the comparison, for a * caller that wants to say what ran. */ files: JostracaResult["files"] } type Node = { kind: string meta: any // POLYMORPHIC, on `kind`. A `content` node -- what `Content` and // `Line` define -- holds the rendered STRING. Every container node // (file, slot, fragment, copy) holds the LIST OF PARTS its children // pushed, which the op then joins. It was declared `any[]`, which is // wrong for the first of those: `ContentOp` carried an // `as unknown as string` to read back what `Content` had written, and // the cast was the only sign that the declaration did not hold. content: any children?: Node[] name?: string path: string[] from?: string folder?: string after?: any exclude?: boolean | string | RegExp | (string | RegExp)[] // POSIX permission bits for the generated file, e.g. 0o755 to make a // script executable. Unset leaves the platform default (or, when the // file already exists, its current mode). mode?: number // string | number, because that is what `indent()` accepts and what the // component reference promises: a number is that many spaces, a string is // a literal prefix. It was declared `string` alone, and only Fragment // could see the discrepancy -- the other components take untyped props, // so `indent: 2` type-checked everywhere and was assigned into a field // that said it could not hold it. indent?: string | number filter?: (props: any, children: any, component: any) => boolean fullpath?: string replace?: Record } type OpStep = (node: Node, ctx$: any, buildctx: any) => Promise | void type OpDef = { before: OpStep, after: OpStep, } /** * The ambient context a component runs in. * * Written into every props object by `cmp()` as `ctx$`, and never by an * author: a component reads it, a caller does not supply it. That is why * no props type carries it -- a props type says what a caller writes, * and `cmp()` adds this on the way in. */ type CmpContext = { /** The node this component is defining. Filling it in is the whole job. */ node: Node /** * Where this component's own children accumulate, and the root of the * tree the define phase is building. */ children: Node[] root: Node /** The generate model, in scope for every template rendered. */ model: any /** Options as the run was started with, and the caller's `meta`. */ opts: any meta: any /** The run's output folder, and the filesystem in force for it. */ folder: string fs: () => FST log: Log debug: boolean now: () => number content: any } /** * Props as a component body sees them: what the caller wrote, plus the * ambient context `cmp()` writes in under `ctx$`. */ type CmpProps

= P & { ctx$: CmpContext } /** * One child of a component: the callback that defines it. * * The callback receives the args its parent bound for this invocation -- * `ListItems` binds `{item, indent, replace}` (see `ListItemProps`), and * `Project` binds its own props -- which is why the parameter is here * rather than the callback being nullary. * * Literal text is not here. Three components take text as a child and * seven do not, so it rides on `Component`'s `Child` parameter instead * of being admitted for all ten. */ type CmpChild = (args: any) => any /** One child, or a list of them. */ type CmpChildren = CmpChild | CmpChild[] /** * A component, as a caller sees it. * * `P` is the props the component reads; each component exports its own * (`ContentProps`, `FileProps`, ...). * * `Arg` is the positional form: `Content('text')`, which `cmp()` reads * as `Content({arg: 'text'})`. `Child` is literal text in the children * position: `Content({indent: 2}, 'text')`. Two components take the * first, three take the second, and both stay at `never` everywhere * else -- which makes `File('text')` a type error rather than the * silent no-op it is at run time, since `File` never reads `arg`. * * The first signature is the props-less call, `File(() => {...})`: * `cmp()` reads a lone function or array as the children. * * `P` defaults to `any`, so `cmp(function My(props: any) {...})` still * types as it always did -- a component written outside this package * declares whatever it likes, and declares nothing if it likes that. * * PROPS LAST, and the order is load-bearing. When no overload matches, * TypeScript reports the LAST one's error, so `File({nosuchprop: 1})` * has to end at the props signature to be told about `FileProps` -- * with the signatures the other way round it was told that * `nosuchprop` does not exist on `CmpChild[] | ((args: any) => any)`, * which is an answer to a question nobody asked. */ type Component

= { (children: CmpChildren | Arg): void (props: P, children?: CmpChildren | Child): void } type Log = { trace: (...args: any[]) => any debug: (...args: any[]) => any info: (...args: any[]) => any warn: (...args: any[]) => any error: (...args: any[]) => any fatal: (...args: any[]) => any } type FileEntry = { path: string action: 'write' | 'preserve' | 'present' | 'diff' copy?: string } type Audit = [string, any][] export type { // JostracaOptions, JostracaResult, CheckDrift, CheckResult, Node, OpStep, OpDef, Component, CmpContext, CmpProps, CmpChild, CmpChildren, Log, FileEntry, FST, Audit, }