Skip to content

Latest commit

 

History

History
259 lines (248 loc) · 20.3 KB

File metadata and controls

259 lines (248 loc) · 20.3 KB

recipe-kit — codebase map (for coding sessions)

The development map for the TypeScript app. (Transcribing photos instead? That's a separate activity — see transcribe/PROMPT.md, not this file.)

This repo holds two halves that meet only at the recipe collection:

  • transcribe/ — the producer: an LLM transcription brief (PROMPT.md), the canonical format standard (SPEC.md), worked examples (spec/), and the version guard (format-version.json + verify-format.mjs). Dependency-free; produces <slug>/recipe.cook (the clean recipe) and, when the photo left something unsettled, a sibling <slug>/clarifications.md checklist into a collection. The render code never imports from here.
  • src/ — the consumer: reads a collection via --recipes-dir and produces print artifacts. No extraction/OCR code, by design.

src/ layout

  • cli.ts — entry point. Commands: queue-recipes, config (read-only path diagnostic), audit, audit-review, audit-approve. (render/build-toc were removed 2026-06-20 — unused, redundant with queue-recipes --all + the auto-TOC.) Paths are config-first: the collection + output dirs resolve from config.json (flag > config > fail-fast, no silent default); parseOptions returns raw flags and resolveOptions applies the ladder, throwing ConfigError (caught at top level → verbose, non-zero) when a needed path can't be resolved. queue-recipes is now the DIGITAL render activity's pure touchpoints (stage 2; dispatch AND print-state are NOT here — F4 moved the print log + its verbs to print-hardware, pnpm print-hardware …): bare queue-recipes (produces every recipe whose PDF is stale against its source — new / edited / PDF deleted / --all — sorted reverse-alphabetical by title (Z→A) so the printed stack files A-on-top; gated purely on render-freshness, no print-state, no binder TOC), and queue-recipes --tidy-slugs [--apply] (rename folders that have drifted from their title back into line — dry-run by default, --apply performs it; safe because identity rides the id, so the print history is untouched by the move). The pending queue, marking, clearing history, and the binder TOC are print-hardware's now (via src/print-bridge.ts — see below). Option parsing (--recipes-dir, --out, --examples, --state, --all, --tidy-slugs, --apply, --no-format-warnings, --with-clarifications) lives here. The producing run (bare queue-recipes) calls the audit preflight first; --tidy-slugs (a filesystem tidy, no render) skips it. Clarification enforcement: a clean produce (bare queue-recipes) HOLDS BACK any recipe with unresolved clarifications and reports it loudly (never renders a clean page over an unsettled ambiguity); --with-clarifications instead renders those items onto the page as a review proof. (print-hardware's --list carries each entry's unresolvedClarifications count + a printable flag for the print gate.)
  • config.ts — config-first path loader: configDir() (env RECIPE_KIT_CONFIG_DIR

    $XDG_CONFIG_HOME/~/.config, + recipe-kit), loadConfig() (parses <configDir>/config.json; null if absent), resolveCollectionDir/resolveOutputDir (flag > config > throw ConfigError), and the EXAMPLES_DIR constant. The zero-dep transcribe/resolve-config.mjs mirrors this resolution for the transcribe half (keep in sync). config.json holds the paths; the chosen printer lives beside it in the agent-written printer.md (not here).

  • configure/ — the PATH-ONLY, cross-platform setup helper the conversational setup SKILL drives (JSON in/out; the configure command in cli.ts parses args + emits). It is the deterministic half of "conversational config setup": suggestDefaults (propose absolute default locations under a base), inspectDir (probe a candidate dir — exists / fileCount / isGitRepo / looksLikeCollection + recipeCount via loadCollection, so the skill can offer to ADOPT an existing collection), mergeConfig (pure, additive — only the given keys change, so setting one path never clobbers another) + writeConfig (atomic temp-then-rename). Paths persist absolute (the CLI resolve()s before merging) because config.json is host-local. It NEVER surveys or writes a printer — that cross-platform, judgment-needing work is the OS-aware setup agent's job (→ printer.md), the same code/agent ownership seam dispatch already uses. No process.platform branch exists here; Node's path/os/fs make the path side OS-agnostic by construction. Pure helpers are unit-tested (configure/configure.test.ts).
  • collection.ts — load a recipe collection from disk (parses each recipe.cook and attaches its clarifications.md sidecar, if any).
  • recipe-id.ts — the recipe identity layer. A hidden, git-tracked sidecar <slug>/.recipe-id (system-minted UUID) is the collection's true primary key, because the slug (folder name) is mutable and git persists file content but not xattrs. ensureIdentities is the heal-on-load pass every queue-recipes touchpoint runs: it mints a sidecar where missing and re-mints a duplicate id (the folder-copy case — a duplicate primary key), deterministically and idempotently. Purely a queue-recipes concern — the tracker keys on the id internally, but the agent-facing surface stays slug-addressed (--list emits slugs; --mark/--clear-history take slugs; the code resolves slug→id), so print-hardware never learns the id and no new stage coupling appears.
  • slug.tsslugifyTitle (the kebab-case folder name a title should have) and the lenient slugMatchesTitle (the non-strict slug↔title compliance check). Powers the --list slugInSync/suggestedSlug fields, the produce-time drift nudge, and the --tidy-slugs rename command (all share this one matcher — the flag and the fix can't disagree). A user edits titles freely; the folder is flagged (never blocked, never auto-renamed) when it drifts, and --tidy-slugs --apply renames it back into line on request. Safe because identity rides the id. The slug derivation here is the same fixed rule the transcribe brief now mandates, so freshly-transcribed folders never false-flag.
  • clarifications.ts — the transcription-clarifications sidecar: Clarification type, parseClarifications (markdown task-list - [ ]/- [x]), loadClarifications (per recipe folder), unresolvedCount. Lives out of cook/ because it is a sibling artifact, not part of the .cook grammar. Unresolved items block a clean print (cli.ts) and render as page warnings only under --with-clarifications (render/view-model.ts).
  • cook/ — the Cooklang dialect (the format standard this program reads): model.ts (types + recipeTitle), parse.ts (our own deserializer; extended dialect = = sections, ranges, inline -- notes --, >> metadata), parse.test.ts. parse.ts is the reader half of the format contract — it assumes well-formed, conformant input (the transcriber's clean output) and turns it into the typed Recipe the renderer consumes. format.ts — the format-version half of the contract: CURRENT_FORMAT (derived from transcribe/format-version.json, the single source of truth — so producer and consumer can't disagree about the version), MACHINE_META_KEYS (the content-vs-machine metadata split), and the version tripwire that flags a recipe whose >> format: differs from CURRENT_FORMAT. format.test.ts is the conformance check (parses every committed example, asserts valid current-format) plus the tripwire/suppression/meta-exclusion tests.
  • units/convert.ts (only cleanly-convertible quantities, render-time), config.ts (DEFAULT_UNITS_CONFIG; display = show both units), tests.
  • render/view-model.ts (presenter-neutral shared layer: aggregates ingredients, orders metadata, partitions notes, and computes page warnings — UPPERCASE: ... standalone notes and a format-version mismatch, default-on and suppressed by --no-format-warnings; also unresolved clarifications, but only under RenderOptions.clarifications / --with-clarifications, off by default — without emitting any markup), pdf.tsx (the PDF presenter: turns the view model into @react-pdf/renderer components — Yoga/flexbox layout, WASM not native, no browser — with an embedded Liberation Serif font under fonts/ so output is byte-identical across machines), toc.ts (alphabetical TOC — pure: takes whatever recipe list it's given; the binder TOC lists the filed subset, which print-hardware hands in via the print-bridge's renderBinderToc), tests. (The former Puppeteer/Chromium pdf.ts + html.ts/theme.ts HTML path was dropped — see the react-pdf migration; layout now lives in view-model.ts.) freshness.ts is queue-recipes' render-freshness — its OWN, print-agnostic answer to "does the rendered PDF still match the source?" (contentSignature + the shared changedSince query + a render-manifest.json recording id→last-rendered signature, living in the gitignored output dir beside the PDFs). The producing run re-renders a recipe iff its source changed since the last render (or the PDF is missing / --all), so a manual .cook edit re-produces its page with no reference to the print tracker — wipe the output and it all correctly regenerates. (The binder toc.pdf is NOT on this manifest: F4 made it print-hardware's, rendered in full every time with no stored state — see print-bridge.ts renderBinderToc. This general freshness machinery still serves recipe pages, and later the digital whole-collection TOC.) contentSignature is the single definition of the version identity: the print-bridge computes print-currency from it too, so render-freshness and print-currency judge "current" by construction-identical means (F2 collapsed the old duplicate hashContent into it).
  • print/ — after F4, just console.ts (box/prompt UI) + tests. F4 relocated the print log out of src/: the physical tracker now lives at print-hardware/print-state.ts, and queue-recipes no longer imports it. No printer.ts: dispatch to a physical (or virtual PDF) printer is the agent layer's job, not a code function (see activities-not-orchestrated).
  • print-bridge.tsthe entire code-level interface between print-hardware and queue-recipes (F4), two functions, both READ-ONLY over print-state (print-hardware is its sole owner/mutator): revisePrintState(prior, dirs, {all?, render?, renderOpts?}){ revised, pending, produced }revised is the caller's prior filings ∩ still-fresh (stale entries pruned; records pass through OPAQUELY via a generic <R>, so print-hardware's PrintRecord type never crosses), pending is the new/changed/reprint dispatch set (titles + signatures + pdf paths print-hardware can't derive), and it renders the stale pages as a side effect when render; and renderBinderToc(filedIds, destPath, dirs) renders a TOC of exactly those filed recipes in full, unconditionally (no currency, no manifest entry — the binder TOC carries no state). print-hardware passes its state IN and applies the results to its own store; this module never touches print-state.json and imports no print-hardware code, so the dependency is strictly one-directional (print-hardware → queue-recipes). Printed-currency = the same changedSince(contentSignature(...)) render-freshness uses, so the two halves can't disagree.
  • render/produce.ts — the print-state-FREE render core shared by bare queue-recipes produce and the bridge: loadSignedCollection (load + heal .recipe-id + contentSignature), outputPaths, staleRenders (manifest-gated freshness), partitionByReview (clarification hold-back), renderJobs, the slug-drift/held-back reporters, byTitle (the Z→A stacking order).
  • The print-hardware code home (print-hardware/, outside src/): print-state.ts is a pure storeprint-state.json records only {id → signature, printedAt} (id-keyed, so a folder rename keeps the record; migrateLegacyState rekeys a pre-feature slug-keyed version: 1 file, loadState coerces pre-F2 hash records + drops a pre-F3 toc record), with markPrinted (upsert — a reprinted edited recipe re-files at its new signature), clearPrinted, filedIds (the filed set it hands the bridge for the TOC), and imports nothing from src/; and cli.ts is print-hardware's own pnpm print-hardware surface (--list/produce/--mark/ --clear-history/--toc) that calls only the bridge + config. The agent side is print-hardware/PROMPT.md (the /print-hardware brief — all via pnpm print-hardware, the brief never naming queue-recipes [the F4 Layer-1 containment boundary]: list → produce → read the configured printer → confirm the batch at a review gate → dispatch (reverse-alphabetical) → mark → offer the refreshed binder TOC [pnpm print-hardware --toc re-renders toc.pdf in full → dispatch it; no status query, nothing to mark], only when it filed something); it is NOT code and a coding session doesn't need it. There is no ~5 batch ceiling; an always-on review gate confirms the batch before any hardware (skippable with an explicit --yes/unattended intent). The brief reads the chosen printer from the config dir's printer.md and fails fast if it's missing/stale — it no longer discovers or picks a printer (that moved to the setup agent, configure/PROMPT.md / /configure, which surveys printers and writes printer.md; the orchestrator is orchestrate/PROMPT.md, also not code).
  • photos/ — photo intake (the normalize-photos command): make an inbox of phone/ browser/scanner images vision-readable before transcribe. detect.ts (magic-byte format detection — content, not extension; pure), normalize.ts (dispositionFor ready/convert/unsupported split + toReadableJpeg + the non-destructive normalizeDir orchestration that archives converted originals under originals/), __fixtures__/ (a vendored HEVC-HEIC sample + an AVIF sample — see its README), tests. Two decoders, both pure-JS/WASM (no native addons, so the command embeds in the single-file binary): heic-convert (libheif WASM) for HEIC/HEIF — the HEVC-coded iPhone case — and jimp (pure JS) for TIFF + EXIF-orientation (its read path auto-rotates) + the JPEG re-encode. AVIF is recognized but unsupported: the bundled libheif is HEVC-only (no AV1 decoder) and jimp can't read AVIF, so dispositionFor classifies it unsupported (detected, named in the report, never attempted) rather than failing mid-batch. Already-readable inputs (JPEG/PNG/GIF/WebP) are passed through untouched. (Replaced sharp — native libvips — in the react-pdf/SEA migration, leaving zero native code in the binary.)
  • env/doctor.ts: the read-only doctor first-run environment self-check (Node in engines range, runtime-critical deps installed). PDF rendering is in-process (@react-pdf/renderer), so there's no separate browser/Chromium probe. Pure verdict logic (evaluateEnvironment + satisfiesRange) is unit-tested; the impure probe (gatherEnv) lives in cli.ts. Diagnoses only — never installs (the front door does).
  • audit/ — dependency-audit heartbeat. heartbeat.ts (pure, offline: staleness vs the committed audit-state.json + pnpm-lock.yaml hash, the verbose non-fatal warning, the NODE_ENV=production gate), approve.ts (reviewAudit runs pnpm audit and records an ephemeral .audit-review.json without stamping; approveAudit is the explicit offline "I approve" that stamps from that review), tests.

The recipe data model & the collection contract

  • Folder-per-recipe: <slug>/recipe.cook + <slug>/sources/ (origin images) + an optional <slug>/clarifications.md (the transcription-uncertainty checklist) + a hidden <slug>/.recipe-id (the machine-owned immutable identity — see recipe-id.ts; committed with the collection). The slug is a cosmetic, mutable label; the id is the key.
  • The collection is separate data (its own git repo), resolved config-first (config.json collectionDir, overridable with --recipes-dir). This repo ships only examples/ as a demo.
  • Rendered artifacts and print-state.json are gitignored / live with the collection — never commit output/.
  • The contract with the transcriber is the dialect cook/parse.ts reads, plus the convention that anything the author could not resolve from the source is recorded out of the recipe, in the clarifications.md sidecar (a - [ ] checklist) — not inline. recipe.cook stays clean; a clean print is blocked until every clarification is checked off (resolved). We do not validate or "repair" incoming files; interpretation is the author's job. A malformed file simply renders poorly and is fixed upstream. (The renderer still promotes any in-file UPPERCASE: ... standalone note and a format-version mismatch to a page warning — that surface remains, but transcription uncertainty no longer rides it by default.)
  • Format versioning. Each recipe carries a >> format: N machine-metadata marker; transcribe/SPEC.md owns the canonical number. The standard moves forward, never forks — cook/format.ts understands one version and parses only that grammar; a different version is flagged, never silently reinterpreted. The marker is read but never rendered. When the format changes, bump the version in transcribe/format-version.json + the >> format: lines, then node transcribe/verify-format.mjs --record (the guard fails otherwise).

Running

  • Node pinned via .nvmrc (Node 20); pnpm via the packageManager field (Corepack — corepack enable, no global install). All-TypeScript, run with tsx — no JS emit, .ts import specifiers. PDF rendering is in-process via @react-pdf/renderer (no browser/Chromium download); pnpm.onlyBuiltDependencies governs which deps may run install scripts. Committed pnpm-lock.yaml pins exact versions.
  • pnpm test — runs the format-version drift guard first (transcribe/verify-format.mjs, so spec drift fails the suite), then the node:test suite (parser, format/conformance, units, render, print tracker, audit). pnpm run typecheck before relying on a change.
  • pnpm run verify-format — the format-version drift guard on its own.
  • Packaging to a single-file binary (scripts/, gitignored dist/ output):
    • pnpm build → esbuild bundles src/cli.ts → one CommonJS file dist/cli.cjs (build.mjs). CJS because Node SEA runs its main as CommonJS (no top-level await — the entry is wrapped in main()). Everything is bundled in: there are no native addons, and the WASM (react-pdf's yoga, heic-convert's libheif) is base64-inlined inside those packages' own JS, so the one file is self-contained. The inline-fonts plugin replaces src/render/fonts/loader.ts with the .ttf bytes as base64 data: URIs (react-pdf accepts them), so fonts ride inside the bundle too. A --define injects engines.node as __RECIPE_KIT_NODE_RANGE__, whose presence also tells cli.ts it's packaged (skip the dev-only package.json/node_modules probes).
    • pnpm packagebuild then package-binary.mjs wraps cli.cjs in a Node SEA (blob + postject into a copy of node) → dist/recipe-kit (~100 MB). Because all assets inline into the bundle, the SEA needs no assets map — so it works on Node 20.10 (the asset API would need 20.12+). OS-aware: ad-hoc codesign on macOS.
    • The bundle/binary is purely a distribution artifact — development always runs via tsx (pnpm recipes …, pnpm test), unchanged. Both modes share one source.
  • Audit heartbeat — two deliberate, separate steps: pnpm run audit:review runs pnpm audit and records that you looked (no stamp), then pnpm run audit:approve is the explicit "I approve" that stamps the committed audit-state.json. The run commands warn (non-fatal) once approval is >14 days old or the lockfile changed; pnpm run audit:check is the exit-code gate for CI.

Conventions

  • Prefer typed, self-contained code; declare every dependency (no host tools).
  • Never commit without explicit approval; never auto-commit.