Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A new package adds a row here, a `CLAUDE.md` of its own, a `docgen.json`, its `#
- **`dist/` is the product.** Each package is a `vp pack` (tsdown) library with one ESM bundle + `.d.ts` per subpath; `exports` points at `dist/` and is also the list `vite.package.ts` derives the pack entries from. A provider consumes the core through its `dist/`, so every provider task carries a `{ task: "build", from: "dependencies" }` edge, and `tests/dist-consumer` type-checks a consumer fixture against the emitted declarations.
- **One flat module per subpath.** `src/<Name>.ts` is published as `./<Name>` (`src/Treasury.FinancialAccount.ts` → `./Treasury.FinancialAccount`). A new public module is a new `exports` entry; `src/internal/**` is never exported.
- **`vp run docgen` executes your examples.** `@effect/docgen` type-checks every fenced `ts` / `typescript` block in a JSDoc comment (an `@example` or a description) under the native `tsc`, then runs them all with `tsx` (`"runExamples": true` in each `packages/*/docgen.json`; docgen alone only type-checks). An example imports packages by name, so it resolves through `exports` to `dist/` (the task's build edge). It must be complete (import everything it uses; no `declare const`, which has no runtime value) and must not throw. It must also be hermetic: one that would call a provider builds the program and stops short of running it (`const run = () => Effect.runPromise(…)`, never invoked), or runs it over a stubbed `FetchHttpClient.Fetch`. docgen also reads a file's module doc as the doc of its first statement: an `index.ts` that opens with `export * as X from …` runs its module example twice (harmless), and one that opens with `export * from "./X.js"` fails on an example file name containing `/`, so the core's `index.ts` opens with a named export.
- **`vp run docgen` executes the package READMEs too.** Every fenced `ts` / `typescript` block in `packages/*/README.md` is an example under the same rules (complete, non-throwing, hermetic): the package's `docgen` task has `scripts/readme-examples.ts` write the blocks into a generated module under `docs/readme/src/` and runs docgen a second time over it (#178). A failure names the block by its README line (`…-constant-line19-0.ts` is the block opening on line 19). A block that is deliberately a fragment opts out with docgen's own fence marker, ` ```ts skip-type-checking `, which neither compiles nor runs it; use it only for a fragment, never to silence a block that has drifted from the API.
- **`vp run docgen` executes the READMEs too.** Every fenced `ts` / `typescript` block in `packages/*/README.md` and in the root `README.md` is an example under the same rules (complete, non-throwing, hermetic): the package's `docgen` task has `scripts/readme-examples.ts` write the blocks into a generated module under `docs/readme/src/` and runs docgen a second time over it (#178). The root README's blocks run in payments-stripe's task (`rootReadme: true` in its `vite.config.ts`), since they import the core and the Stripe package; a root block that imports another provider needs that provider as a dependency of the package that runs it. A failure names the README and the block's line (`…-docs-readme-src-README.ts-constant-line24-0.ts` is the root block opening on line 24; a package README's module is `packages.<dir>.README.ts`). A block that is deliberately a fragment opts out with docgen's own fence marker, ` ```ts skip-type-checking `, which neither compiles nor runs it; use it only for a fragment, never to silence a block that has drifted from the API.
- **`"sideEffects": false` is a claim about `dist/`.** Every emitted `.js` must be pure declarations: no top-level statements with effects.
- **`effect` is a peer, never a dependency.** Two copies break `Context` / `Layer` / `Schema` identity; the consuming app owns the singleton.
- **No vendor vocabulary in the core, no application vocabulary anywhere.** Provider-specific shapes stay in the provider that maps the neutral model onto them; nothing here names gardeneria's plans, entitlements or database tables.
Expand All @@ -43,7 +43,7 @@ Package-local terms, invariants and import boundaries live in each package's `CL
- `vp check` — format + lint (Oxfmt + Oxlint). `vp run check` — the type gate: every package's `src` + `tests` (plus `payments-stripe`'s `examples` and `scripts`) under the Effect-patched native `tsc`, plus the dist-consumer check (which builds `dist/` and asserts the emitted types). They are different commands.
- `vp run -r test:unit` — unit tests (every vendor seam stubbed; nothing needs a credential).
- `vp run -r build` — `vp pack` → every package's `dist/`.
- `vp run docgen` — every package's `@effect/docgen`: type-checks and runs each JSDoc example and each package README `ts` / `typescript` block against `dist/` (it builds first), and writes the git-ignored API reference to `packages/*/docs/`.
- `vp run docgen` — every package's `@effect/docgen`: type-checks and runs each JSDoc example and each package and root README `ts` / `typescript` block against `dist/` (it builds first), and writes the git-ignored API reference to `packages/*/docs/`.
- `vp run test:coverage` — every package's unit suite under one Istanbul map at `coverage/coverage-final.json`, which `fallow audit` reads for exact per-function CRAP scores. Run it before the audit.
- `vp run --filter ./packages/payments-stripe generate:resources` (offline) — regenerate the Stripe resource modules (`packages/payments-stripe/src/<Resource>.ts`) from the installed `@distilled.cloud/stripe` and `scripts/resources.ts`; run it after a Distilled bump or a change to `scripts/`. Never hand-edit a generated module: `tests/resources.test.ts` (a unit test) fails when one differs from the generator's output. A Dependabot PR from the `distilled` group (`.github/dependabot.yml`) is finished by hand: check out its branch, run `pnpm install`, rerun the generator, update the followed API version, and push the regenerated modules to the PR branch.
- `pnpm changeset` — add a changeset (see [Branches and pull requests](#branches-and-pull-requests)).
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ vp run --filter ./packages/payments-stripe check # one package's type gate
vp run -r test:unit # unit tests
vp test run tests/Webhook.test.ts # single file (from inside the package)
vp run -r build # emit dist/
vp run docgen # type-check and run every JSDoc @example and README block
vp run docgen # type-check and run every JSDoc @example and package/root README block
vp run test:coverage # one Istanbul map for fallow
vp run fallow:audit # change-set review (not a CI gate)
```
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const layer = StripePayments.layer({ apiKey: Redacted.make("sk_test_...") }).pip
Layer.provide(FetchHttpClient.layer),
);

await Effect.runPromise(program.pipe(Effect.provide(layer)));
// Calls Stripe when invoked: `const customer = await createCustomer();`
const createCustomer = () => Effect.runPromise(program.pipe(Effect.provide(layer)));
```

Each package's README is its API reference. Runnable examples are in [`packages/payments-stripe/examples`](packages/payments-stripe/examples).
Expand Down
4 changes: 3 additions & 1 deletion packages/payments-stripe/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { packageConfig } from "../../vite.package.ts";

export default packageConfig({ url: import.meta.url });
// `rootReadme`: the root README's quick start imports the core and this
// package, so its blocks run in this package's docgen task (#178).
export default packageConfig({ url: import.meta.url, rootReadme: true });
111 changes: 69 additions & 42 deletions scripts/readme-examples.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
/**
* Hands a package README's TypeScript code blocks to `@effect/docgen` (#178).
* Hands README TypeScript code blocks to `@effect/docgen` (#178).
*
* docgen only reads JSDoc under its `srcDir`, so this writes every fenced
* `ts` / `typescript` block of the `README.md` in the working directory (a
* package) into the doc comment of one export of a generated module,
* `docs/readme/src/README.ts` (git-ignored, like the rest of `docs/`). The
* package's `docgen` task then runs docgen a second time over that directory,
* so each block is type-checked and run exactly as a JSDoc example is:
* imports resolve by package name through `exports` to `dist/`.
* `ts` / `typescript` block of each README named on the command line (default:
* the `README.md` in the working directory, a package) into the doc comment of
* one export of a generated module under `docs/readme/src/` (git-ignored, like
* the rest of `docs/`), one module per README. The package's `docgen` task then
* runs docgen a second time over that directory, so each block is type-checked
* and run exactly as a JSDoc example is: imports resolve by package name
* through `exports` to `dist/`. payments-stripe's task also passes the root
* `README.md`, whose blocks import the core and the Stripe package.
*
* The info string is copied verbatim, so docgen's own opt-out applies: a block
* fenced as `ts skip-type-checking` is a fragment, neither compiled nor run.
* Each export is named after the block's line in the README (`line19`), which
* docgen puts in the name of the example file it reports a failure in.
* Each export is named after the block's line in its README (`line19`), which
* docgen puts in the name of the example file it reports a failure in; the
* module is named after the README's path from the workspace root
* (`README.ts` for the root one, `packages.payments-stripe.README.ts`).
*/
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as Arr from "effect/Array";

const outDir = "docs/readme/src";
const lines = readFileSync("README.md", "utf8").split("\n");
const workspace = fileURLToPath(new URL("..", import.meta.url));

interface Block {
readonly line: number;
Expand All @@ -30,6 +36,12 @@ interface Scan {
readonly open: Block | undefined;
readonly errors: ReadonlyArray<string>;
}
interface Readme {
/** The README's path from the workspace root, as errors and docs name it. */
readonly label: string;
readonly blocks: ReadonlyArray<Block>;
readonly errors: ReadonlyArray<string>;
}

/** Outside a block: an opening `ts` / `typescript` fence starts one. */
const outside = (scan: Scan, line: string, index: number): Scan => {
Expand All @@ -40,54 +52,69 @@ const outside = (scan: Scan, line: string, index: number): Scan => {
};

/** Inside a block: a bare fence closes it, and any other line joins its body. */
const inside = (scan: Scan, open: Block, line: string, index: number): Scan => {
const inside = (label: string, scan: Scan, open: Block, line: string, index: number): Scan => {
if (/^```\s*$/.test(line)) return { ...scan, blocks: [...scan.blocks, open], open: undefined };
if (line.includes("*/")) {
const error = `README.md:${index + 1}: "*/" would end the generated doc comment`;
const error = `${label}:${index + 1}: "*/" would end the generated doc comment`;
return { ...scan, errors: [...scan.errors, error] };
}
return { ...scan, open: { ...open, body: [...open.body, line] } };
};

const scan = lines.reduce<Scan>(
(state, line, index) =>
state.open === undefined ? outside(state, line, index) : inside(state, state.open, line, index),
{ blocks: [], open: undefined, errors: [] },
);
const errors =
scan.open === undefined
? scan.errors
: [...scan.errors, `README.md:${scan.open.line}: unclosed code fence`];
if (Arr.isReadonlyArrayNonEmpty(errors)) {
console.error(errors.join("\n"));
process.exit(1);
}
const { blocks } = scan;
/** Every TypeScript block of the README at `path`, plus what stops it being written. */
const scanReadme = (path: string): Readme => {
const label = relative(workspace, resolve(path));
const scan = readFileSync(path, "utf8")
.split("\n")
.reduce<Scan>(
(state, line, index) =>
state.open === undefined
? outside(state, line, index)
: inside(label, state, state.open, line, index),
{ blocks: [], open: undefined, errors: [] },
);
const unclosed =
scan.open === undefined ? [] : [`${label}:${scan.open.line}: unclosed code fence`];
return { label, blocks: scan.blocks, errors: [...scan.errors, ...unclosed] };
};

const exports = blocks.map(({ line, info, body }) =>
/** One block as a documented export whose doc comment carries it. */
const renderBlock = (label: string, { line, info, body }: Block): string =>
[
"/**",
` * README.md, line ${line}.`,
` * ${label}, line ${line}.`,
" *",
` * \`\`\`${info}`,
...body.map((text) => (text === "" ? " *" : ` * ${text}`)),
" * ```",
" */",
`export const line${line} = ${line};`,
].join("\n"),
);
].join("\n");

/** Write one README's generated module into `outDir`, named after its label. */
const writeReadme = ({ label, blocks }: Readme): void => {
const file = `${outDir}/${label.replaceAll("/", ".").replace(/\.md$/i, "")}.ts`;
writeFileSync(
file,
[
// docgen reads the first statement's doc as the module doc, so a block
// there would run twice: the first statement carries no block.
`/** The TypeScript blocks of ${label}, generated by scripts/readme-examples.ts. */`,
`export const source = "${label}";`,
...blocks.map((block) => renderBlock(label, block)),
"",
].join("\n\n"),
);
console.log(`${label}: ${blocks.length} TypeScript block(s) written to ${file}`);
};

const args = process.argv.slice(2);
const readmes = (Arr.isReadonlyArrayNonEmpty(args) ? args : ["README.md"]).map(scanReadme);
const errors = readmes.flatMap((readme) => readme.errors);
if (Arr.isReadonlyArrayNonEmpty(errors)) {
console.error(errors.join("\n"));
process.exit(1);
}
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
writeFileSync(
`${outDir}/README.ts`,
[
// docgen reads the first statement's doc as the module doc, so a block
// there would run twice: the first statement carries no block.
"/** The TypeScript blocks of README.md, generated by scripts/readme-examples.ts. */",
'export const source = "README.md";',
...exports,
"",
].join("\n\n"),
);
console.log(`README.md: ${blocks.length} TypeScript block(s) written to ${outDir}/README.ts`);
readmes.forEach(writeReadme);
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ export default defineConfig({
cache: false,
},
// Every package's docgen: type-checks AND RUNS each JSDoc `@example`
// and each `ts` / `typescript` block of the package README against the
// and each `ts` / `typescript` block of the package README (and, in
// payments-stripe's, of this root README) against the
// built `dist/`, then writes the API reference under that
// package's (git-ignored) `docs/`. Same pure-graph-node shape as
// `check`, so a new package adds itself here too.
Expand Down
16 changes: 12 additions & 4 deletions vite.package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { defineConfig } from "vite-plus";
export interface PackageConfigOptions {
/** The package's `import.meta.url`, so paths resolve relative to it. */
readonly url: string;
/**
* Also run the workspace root `README.md`'s TypeScript blocks in this
* package's `docgen` task (#178). Set on the one package whose dependencies
* resolve every import those blocks make (payments-stripe).
*/
readonly rootReadme?: boolean | undefined;
}

/**
Expand Down Expand Up @@ -74,10 +80,11 @@ type Manifest = typeof Manifest.Type;
/** Parse a `package.json`'s text as a {@link Manifest}, throwing when it is not one. */
const decodeManifest = Schema.decodeUnknownSync(Schema.fromJsonString(Manifest));

export const packageConfig = ({ url }: PackageConfigOptions) => {
export const packageConfig = ({ url, rootReadme = false }: PackageConfigOptions) => {
const manifest = decodeManifest(readFileSync(new URL("./package.json", url), "utf8"));
const srcDir = new URL("./src/", url).pathname;
const maxWorkers = process.env.VITEST_MAX_WORKERS;
const readmes = rootReadme ? "README.md ../../README.md" : "README.md";
return defineConfig({
run: {
tasks: {
Expand Down Expand Up @@ -132,15 +139,16 @@ export const packageConfig = ({ url }: PackageConfigOptions) => {
// own fence marker, `ts skip-type-checking`. The second run takes its
// directories from DOCGEN_SRC / DOCGEN_OUT, not `--src` / `--out`:
// docgen resolves a flag to an absolute path, then joins it onto the
// working directory.
// working directory. With `rootReadme`, the root README's blocks are
// written alongside (a module of their own) and run in the same pass.
docgen: {
command:
"docgen && node ../../scripts/readme-examples.ts && DOCGEN_SRC=docs/readme/src DOCGEN_OUT=docs/readme docgen",
command: `docgen && node ../../scripts/readme-examples.ts ${readmes} && DOCGEN_SRC=docs/readme/src DOCGEN_OUT=docs/readme docgen`,
dependsOn: ["build"],
cache: {
input: [
...cacheInputs,
"README.md",
...(rootReadme ? [{ pattern: "README.md", base: "workspace" as const }] : []),
{ pattern: "scripts/readme-examples.ts", base: "workspace" as const },
],
output: ["docs/**"],
Expand Down
Loading