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
6 changes: 6 additions & 0 deletions .changeset/p1-7-verify-typestyles-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@typestyles/build-runner': minor
'@typestyles/next': patch
---

Add `verifyTypestylesBuild()` to `@typestyles/build-runner` for CI checks that extracted CSS and optional manifest exist and contain expected output. Re-export from `@typestyles/next/build` and document on the zero-runtime page.
2 changes: 1 addition & 1 deletion IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Bugs and credibility issues that lose evaluations on contact. Do these first.
getting-started, components, and theming pages.
- Full editable REPL can follow later (P3).

- [ ] **P1.7 — Productize the extraction verifier** (PR: )
- [x] **P1.7 — Productize the extraction verifier** (PR: #92)
- Zero-runtime extraction is execute-and-collect; styles unreachable from the
convention entry are silently dropped. Promote the pattern from
`examples/next-app/scripts/verify-typestyles.mts` into
Expand Down
40 changes: 40 additions & 0 deletions docs/content/docs/zero-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,46 @@ Import the emitted CSS from your App Router layout (`import './typestyles.css'`)

For full manual control, **`withTypestylesExtract`** is still available (always merges production defines). **`discoverDefaultExtractModules`** and **`DEFAULT_EXTRACT_MODULE_CANDIDATES`** are re-exported from `@typestyles/next/build` (same as `@typestyles/build-runner` / `@typestyles/vite`).

### Verify extraction in CI

Zero-runtime extraction is execute-and-collect: styles that never run during the build step are **silently omitted** from the CSS file. There is no compile error when a component file is missing from your convention entry.

Use **`verifyTypestylesBuild()`** from `@typestyles/build-runner` (also re-exported from `@typestyles/next/build`) after your build step to fail fast when output is missing or incomplete:

```ts
// scripts/typestyles-verify.mts
import { verifyTypestylesBuild } from '@typestyles/build-runner';

verifyTypestylesBuild({
root: process.cwd(),
cssFile: 'app/typestyles.css',
manifestFile: 'app/typestyles.manifest.json',
minBytes: 500,
// App-specific: class names / token vars you expect from the entry graph
requiredCssSubstrings: ['.my-scope-button-base {', ':root { --my-scope-color-primary:'],
});
```

Checks performed:

- CSS file exists and meets `minBytes` (defaults to non-empty when omitted)
- Optional `requiredCssSubstrings` — catch missing imports from the entry barrel
- Optional manifest exists with `version: 1` and `css` pointing at your stylesheet path

Wire it into `package.json` before `next build` (see `@examples/next`):

```json
{
"scripts": {
"typestyles:build": "tsx ./scripts/typestyles-build.mts",
"typestyles:verify": "tsx ./scripts/typestyles-verify.mts",
"build": "pnpm typestyles:build && pnpm typestyles:verify && next build"
}
}
```

For Vite/Rollup, pass the emitted asset path (e.g. `dist/typestyles.css`) and omit `manifestFile` unless you write one yourself.

Add the `TypestylesProvider` to your root layout to handle streaming SSR (React 18 App Router):

```tsx
Expand Down
1 change: 1 addition & 0 deletions examples/next-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"typestyles": "workspace:*"
},
"devDependencies": {
"@typestyles/build-runner": "workspace:*",
"@types/node": "^20.0.0",
"eslint": "^9.39.2",
"tsx": "^4.21.0",
Expand Down
61 changes: 23 additions & 38 deletions examples/next-app/scripts/verify-typestyles.mts
Original file line number Diff line number Diff line change
@@ -1,46 +1,31 @@
import { readFileSync, existsSync } from 'node:fs';
import { verifyTypestylesBuild } from '@typestyles/build-runner';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const cssPath = resolve(root, 'app/typestyles.css');
const manifestPath = resolve(root, 'app/typestyles.manifest.json');

function fail(message: string): never {
console.error(`[typestyles:verify] ${message}`);
try {
const result = verifyTypestylesBuild({
root,
cssFile: 'app/typestyles.css',
manifestFile: 'app/typestyles.manifest.json',
minBytes: 500,
// Semantic class names are prefixed with the sanitized scopeId
// (`example-ds` / `example-app`), matching how tokens are scoped.
requiredCssSubstrings: [
':root { --example-ds-color-background-app:',
'.example-ds-button-base {',
'.theme-example-ds-default {',
'.example-ds-ds-layout-base {',
'.example-app-app-site-page {',
],
});

console.log(
`[typestyles:verify] OK — ${result.cssPath} (${result.cssBytes} bytes), manifest v${result.manifestVersion}`,
);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}

if (!existsSync(cssPath)) fail(`Missing ${cssPath} — run pnpm typestyles:build first.`);

const css = readFileSync(cssPath, 'utf8');
if (css.length < 500) fail(`CSS file unexpectedly small (${css.length} bytes).`);

// Semantic class names are prefixed with the sanitized scopeId
// (`example-ds` / `example-app`), matching how tokens are scoped.
const requiredSubstrings = [
':root { --example-ds-color-background-app:',
'.example-ds-button-base {',
'.theme-example-ds-default {',
'.example-ds-ds-layout-base {',
'.example-app-app-site-page {',
];
for (const s of requiredSubstrings) {
if (!css.includes(s)) fail(`Expected CSS to contain ${JSON.stringify(s)}.`);
}

if (!existsSync(manifestPath)) fail(`Missing ${manifestPath}.`);

const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
version?: number;
css?: string;
};
if (manifest.version !== 1) fail(`Expected manifest version 1, got ${manifest.version}.`);
if (manifest.css !== 'app/typestyles.css') {
fail(`Expected manifest.css "app/typestyles.css", got ${JSON.stringify(manifest.css)}.`);
}

console.log(
`[typestyles:verify] OK — ${cssPath} (${css.length} bytes), manifest v${manifest.version}`,
);
4 changes: 3 additions & 1 deletion packages/build-runner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
Expand All @@ -33,7 +34,8 @@
"@types/node": "^25.2.1",
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.4.0"
"typescript": "^5.4.0",
"vitest": "^3.0.0"
},
"license": "Apache-2.0",
"repository": {
Expand Down
7 changes: 7 additions & 0 deletions packages/build-runner/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export { DEFAULT_EXTRACT_MODULE_CANDIDATES, discoverDefaultExtractModules } from './discover';
export {
verifyTypestylesBuild,
VerifyTypestylesBuildError,
type TypestylesExtractManifestV1,
type VerifyTypestylesBuildOptions,
type VerifyTypestylesBuildResult,
} from './verify';

import { build as esbuildBuild } from 'esbuild';
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
Expand Down
116 changes: 116 additions & 0 deletions packages/build-runner/src/verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { VerifyTypestylesBuildError, verifyTypestylesBuild } from './verify';

function writeFixture(
root: string,
css: string,
manifest?: { version: number; css: string },
): { cssFile: string; manifestFile?: string } {
mkdirSync(join(root, 'app'), { recursive: true });
const cssFile = 'app/typestyles.css';
writeFileSync(join(root, cssFile), css, 'utf8');
if (manifest) {
const manifestFile = 'app/typestyles.manifest.json';
writeFileSync(join(root, manifestFile), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
return { cssFile, manifestFile };
}
return { cssFile };
}

describe('verifyTypestylesBuild', () => {
it('passes when CSS and manifest match defaults', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-ok-'));
const { cssFile, manifestFile } = writeFixture(root, '.button { color: red; }', {
version: 1,
css: 'app/typestyles.css',
});

try {
const result = verifyTypestylesBuild({
root,
cssFile,
manifestFile,
requiredCssSubstrings: ['.button {'],
});
expect(result.cssBytes).toBeGreaterThan(0);
expect(result.manifestVersion).toBe(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('skips manifest checks when manifestFile is omitted', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-css-only-'));
const { cssFile } = writeFixture(root, '.card { padding: 1rem; }');

try {
const result = verifyTypestylesBuild({ root, cssFile });
expect(result.manifestPath).toBeUndefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('throws when CSS is missing', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-missing-css-'));
try {
expect(() => verifyTypestylesBuild({ root, cssFile: 'app/typestyles.css' })).toThrow(
VerifyTypestylesBuildError,
);
expect(() => verifyTypestylesBuild({ root, cssFile: 'app/typestyles.css' })).toThrow(
/Missing .*typestyles\.css/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('throws when CSS is below minBytes', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-small-css-'));
const { cssFile } = writeFixture(root, 'x');

try {
expect(() => verifyTypestylesBuild({ root, cssFile, minBytes: 500 })).toThrow(
/unexpectedly small/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('throws when a required substring is missing', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-substring-'));
const { cssFile } = writeFixture(root, '.button { color: red; }');

try {
expect(() =>
verifyTypestylesBuild({
root,
cssFile,
requiredCssSubstrings: ['.missing-class {'],
}),
).toThrow(/Expected CSS to contain/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('throws when manifest css path does not match', () => {
const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-manifest-css-'));
const { cssFile, manifestFile } = writeFixture(root, '.button { color: red; }', {
version: 1,
css: 'wrong/path.css',
});

try {
expect(() => verifyTypestylesBuild({ root, cssFile, manifestFile })).toThrow(
/Expected manifest\.css/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading
Loading