diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33be88e..ec646cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,6 @@ jobs: - name: Typecheck run: pnpm run typecheck - - name: Lint - run: pnpm run lint - - name: Format check run: pnpm run format:check @@ -43,6 +40,8 @@ jobs: run: pnpm run test:coverage - name: Build + # Node 22 builds as part of package:check below. + if: matrix.node == 24 run: pnpm run build - name: Verify packed package diff --git a/README.md b/README.md index dc7252d..93f379f 100644 --- a/README.md +++ b/README.md @@ -385,9 +385,18 @@ This specialized API intentionally accepts one dependency. Use `useEffectWhen` w **Example:** +The example includes a minimal product type and the application's analytics client shape. + + + ```tsx import { useEffectWhenMatch } from "@okyrychenko-dev/react-effect-when"; +type Product = { id: string }; +declare const analytics: { + track(event: string, properties: Record): void; +}; + type ProductQueryPending = { status: "pending" }; type ProductQueryError = { status: "error"; error: Error }; type ProductQuerySuccess = { status: "success"; data: Product }; @@ -409,7 +418,9 @@ function ProductAnalytics({ query }: ProductAnalyticsProps) { } ``` -The array form is additive: existing single-value calls work unchanged. For example, using the same `ProductQuery` type: +The array form is additive: existing single-value calls work unchanged. The following example uses the imports, product/query types and analytics client declared in the preceding example: + + ```tsx function ProductQueryObserver({ query }: ProductAnalyticsProps) { @@ -450,6 +461,8 @@ For standalone factory calls, provide the key type, complete discriminated union **Example:** + + ```tsx import { createEffectWhen, matchPredicate } from "@okyrychenko-dev/react-effect-when"; @@ -499,6 +512,8 @@ Binds the complete source union once and returns a matching factory. Its key and The source union still needs an explicit type: a key and selection cannot describe the fields of unselected variants. Binding it in a separate step lets TypeScript infer the later key and selection; defaulting the selected type in the existing factory would instead widen it to all source variants. + + ```tsx import { createEffectWhen, matchPredicateFor, useEffectWhen } from "@okyrychenko-dev/react-effect-when"; @@ -887,25 +902,31 @@ public documentation for package usage. ## Development +`format:check` includes linting. `package:check` builds the package, checks its packed files and runtime exports, compiles installed ESM/CommonJS consumers, and runs ATTW and publint. + +The packed checker also compiles the README examples marked `matching-explicit`, `matching-source-bound`, `matching-hook-scalar`, and `matching-hook-multiple`. The multi-value hook example uses the preceding scalar example's documented context. Their bodies and public imports are checked unchanged against both declaration adapters; missing or duplicate selection markers fail the check. These compilation checks supplement the dedicated exact-type and rejection fixtures. Other README snippets and example runtime execution are outside this check's scope. Temporary consumers and tarballs are removed on success and failure. + ```bash pnpm install --frozen-lockfile pnpm run typecheck -pnpm run lint pnpm run format:check pnpm run test:run pnpm run test:coverage -pnpm run build -pnpm pack --pack-destination /tmp/react-effect-when-pack +pnpm run package:check ``` +CI runs coverage tests on Node 22 and 24, uploads coverage on Node 22, and runs the packed contract on Node 22. Node 24 retains a separate build; Node 22 uses the build owned by `package:check`. + ## Publish Checklist -Before publishing a new version, make sure the combined release check passes: +Before publishing a new version, make sure the combined release check passes. It cleans build output, runs the full tests, typechecks, checks lint/formatting and verifies the packed contract: ```bash pnpm run release:check ``` +`prepublishOnly` retains the same release gate for direct publication. The release workflow checks the tag against the package version before publishing with provenance; its detached tag checkout uses `--no-git-checks`. Both the explicit workflow gate and the publication lifecycle gate remain in place. + ## License MIT © Oleksii Kyrychenko diff --git a/package.json b/package.json index cdcf0d5..9e283b9 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "package:check": "pnpm run build && node scripts/check-packed-package.mjs && attw --pack . && publint", "dev": "tsup --watch", "clean": "rm -rf dist", - "release:check": "pnpm run clean && pnpm run test:run && pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run package:check", + "release:check": "pnpm run clean && pnpm run test:run && pnpm run typecheck && pnpm run format:check && pnpm run package:check", "prepublishOnly": "pnpm run release:check", "typecheck": "tsc --noEmit", "lint": "eslint src scripts --ext .ts,.tsx,.mjs", diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.mjs index 129322d..ad09cbf 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.mjs @@ -87,6 +87,62 @@ function assertExportTargets(manifest, packageRoot) { } } +function typecheck(files, context) { + try { + execFileSync( + join(repositoryPath, "node_modules/.bin/tsc"), + [ + "--noEmit", + "--strict", + "--skipLibCheck", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "--target", + "ES2020", + ...files, + ], + { cwd: consumerRoot, stdio: "inherit" } + ); + } catch (cause) { + throw new Error(`Packed declaration check failed: ${context}`, { cause }); + } +} + +function documentedExample(readme, identity) { + const sections = readme.split(``); + const context = `README example ${identity} (ESM/CommonJS)`; + invariant(sections.length === 2, `${context}: expected exactly one selection marker`); + const block = sections[1].match(/^\s*```tsx\r?\n([\s\S]*?)\r?\n```(?=\r?\n|$)/u); + invariant(block !== null, `${context}: expected a fenced tsx example after the marker`); + return block[1]; +} + +function checkDocumentedExamples(packageRoot) { + const readme = readFileSync(join(packageRoot, "README.md"), "utf8"); + // These are the selected matching examples, not a general Markdown compiler. + const exampleGroups = [ + ["matching-explicit"], + ["matching-source-bound"], + ["matching-hook-scalar"], + // The array example explicitly uses the preceding scalar example's context. + ["matching-hook-scalar", "matching-hook-multiple"], + ]; + + for (const identities of exampleGroups) { + const contents = identities.map((identity) => documentedExample(readme, identity)).join("\n\n"); + for (const [adapter, extension] of [ + ["ESM", "mts"], + ["CommonJS", "cts"], + ]) { + const filename = `${identities.join("+")}.${extension}`; + writeFileSync(join(consumerRoot, filename), contents); + typecheck([filename], `README examples ${identities.join(", ")} (${adapter})`); + } + } +} + try { run("pnpm", ["pack", "--out", tarballPath]); mkdirSync(extractRoot, { recursive: true }); @@ -192,23 +248,8 @@ try { run("node", [join(consumerRoot, "esm.mjs")]); run("node", [join(consumerRoot, "cjs.cjs")]); - execFileSync( - join(repositoryPath, "node_modules/.bin/tsc"), - [ - "--noEmit", - "--strict", - "--skipLibCheck", - "--module", - "NodeNext", - "--moduleResolution", - "NodeNext", - "--target", - "ES2020", - "consumer.mts", - "consumer.cts", - ], - { cwd: consumerRoot, stdio: "inherit" } - ); + typecheck(["consumer.mts", "consumer.cts"], "exact-type and rejection fixtures (ESM/CommonJS)"); + checkDocumentedExamples(packageRoot); } finally { rmSync(temporaryRoot, { force: true, recursive: true }); }