diff --git a/.changeset/collections-types-cleanup.md b/.changeset/collections-types-cleanup.md new file mode 100644 index 00000000..fbd5f86d --- /dev/null +++ b/.changeset/collections-types-cleanup.md @@ -0,0 +1,13 @@ +--- +"@stackwright/collections": patch +--- + +chore(collections): remove duplicate CollectionProvider definitions + +`file-collection-provider.ts` now imports `CollectionProvider`, `CollectionEntry`, +`CollectionListOptions`, and `CollectionListResult` directly from `@stackwright/types` +rather than from the local `./types` file. + +`types.ts` is converted to a re-export shim so any unexpected downstream imports +remain backward-compatible. This completes the Phase 1 cleanup from the +types-hierarchy-refactor. diff --git a/.changeset/generate-agent-docs-interfaces.md b/.changeset/generate-agent-docs-interfaces.md new file mode 100644 index 00000000..0d220fe5 --- /dev/null +++ b/.changeset/generate-agent-docs-interfaces.md @@ -0,0 +1,15 @@ +--- +"@stackwright/cli": patch +--- + +feat(cli): generate-agent-docs now emits an interface contracts table + +A new auto-generated section in AGENTS.md documents the TypeScript interface +contracts defined in `@stackwright/types`: + +- CollectionProvider, CollectionEntry, CollectionListOptions, CollectionListResult +- ScaffoldHookContext, ScaffoldHook, HookHandler, ScaffoldHookType + +The section is delimited by `` markers +and updated by `pnpm stackwright -- generate-agent-docs` alongside the existing +content-type-table. This completes Phase 1 step 8 of the types-hierarchy-refactor. diff --git a/.changeset/hookhandler-reexport.md b/.changeset/hookhandler-reexport.md new file mode 100644 index 00000000..de9c85f9 --- /dev/null +++ b/.changeset/hookhandler-reexport.md @@ -0,0 +1,15 @@ +--- +"@stackwright/hooks-registry": patch +"@stackwright/scaffold-core": patch +--- + +fix(scaffold-core): export HookHandler type from hooks-registry and scaffold-core + +`HookHandler` is the canonical type alias for scaffold hook handler functions, +defined in `@stackwright/types`. It was re-exported by `hooks-registry/src/hooks.ts` +but not forwarded through `index.ts`, making it unavailable via the public package +import paths. + +Both `@stackwright/hooks-registry` and `@stackwright/scaffold-core` now re-export +`HookHandler` alongside the other scaffold hook types. This completes Phase 1 step 4 +of the types-hierarchy-refactor. diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 4b0958c2..3eeabdec 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -48,7 +48,7 @@ jobs: git config user.name "github-actions" git config user.email "github-actions@github.com" git add .changeset/pre.json - git commit -m "chore: enter prerelease mode [skip ci]" + git commit -m "chore: enter prerelease mode" git push origin dev fi @@ -66,7 +66,7 @@ jobs: - name: Push changes back to dev run: | git add . - git commit -m "chore: bump prerelease versions [skip ci]" || echo "No changes to commit" + git commit -m "chore: bump prerelease versions" || echo "No changes to commit" git push origin dev - name: Publish prereleases with tag 'alpha' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef72acaa..84e4eb3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,14 +18,12 @@ permissions: jobs: release: runs-on: ubuntu-latest - # Use startsWith (not contains) to check ONLY the commit title for [skip ci]. - # Squash merges embed individual commit messages in the body, and automated - # commits (prerelease bumps, etc.) include [skip ci] — which would incorrectly - # suppress the release workflow if we used contains() on the full body. + # startsWith guards against the release workflow re-triggering on its own + # version bump commit. Must stay in sync with the commit message below. if: >- ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && - !startsWith(github.event.head_commit.message, 'chore: version packages for release [skip ci]')) }} + !startsWith(github.event.head_commit.message, 'chore: version packages for release')) }} steps: - name: Generate PerAsperaCI token id: app-token @@ -120,7 +118,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add . - git commit -m "chore: version packages for release [skip ci]" || echo "No changes to commit" + git commit -m "chore: version packages for release" || echo "No changes to commit" git push origin main - name: Back-merge into dev @@ -141,7 +139,7 @@ jobs: if [ ! -f ".changeset/pre.json" ]; then pnpm changeset pre enter alpha git add .changeset/pre.json - git commit -m "chore: re-enter prerelease mode after back-merge [skip ci]" + git commit -m "chore: re-enter prerelease mode after back-merge" fi # Force push is required after a rebase to rewrite dev's remote history. git push origin dev --force-with-lease diff --git a/AGENTS.md b/AGENTS.md index 835816f4..356277cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,29 @@ The YAML key is the key used inside `content_items` entries. All types inherit ` **TypographyVariant values:** `h1` `h2` `h3` `h4` `h5` `h6` `subtitle1` `subtitle2` `body1` `body2` `caption` `button` `overline` +### Interface Contracts + +**AGENTS: This table is auto-generated from `@stackwright/types`. Run `pnpm stackwright -- generate-agent-docs` to regenerate. Do NOT edit the content between the markers manually.** + + +All interface contracts are defined in `@stackwright/types` and re-exported from `@stackwright/collections`, `@stackwright/hooks-registry`, and `@stackwright/scaffold-core` for backward compatibility. + +| Interface / Type | Kind | Fields / Signature | +|---|---|---| +| `CollectionProvider` | interface | `list(collection, opts?)` (Promise), `get(collection, slug)` (Promise), `collections()` (Promise) | +| `CollectionEntry` | interface | `slug` (string), `[key: string]` (unknown) | +| `CollectionListOptions` | interface | `limit`? (number), `offset`? (number), `sort`? (string), `filter`? (Record) | +| `CollectionListResult` | interface | `entries` (CollectionEntry[]), `total` (number) | +| `ScaffoldHookContext` | interface | `targetDir` (string), `projectName` (string), `siteTitle` (string), `themeId` (string), `packageJson` (Record), `dependencyMode` ('workspace' | 'standalone'), `codePuppyConfig`? (Record), `pages`? (string[]), `install`? (boolean), `[key: string]`? (unknown) | +| `ScaffoldHook` | interface | `type` (ScaffoldHookType), `name` (string), `handler` (HookHandler), `priority`? (number), `critical`? (boolean) | +| `HookHandler` | type | `(context: ScaffoldHookContext)` (Promise | void) | +| `ScaffoldHookType` | type | `values` ('preScaffold' | 'preInstall' | 'postInstall' | 'postScaffold') | + +**Import paths (all equivalent):** +- `CollectionProvider` — `@stackwright/types` · `@stackwright/collections` +- `ScaffoldHookContext`, `ScaffoldHook`, `HookHandler`, `ScaffoldHookType` — `@stackwright/types` · `@stackwright/hooks-registry` · `@stackwright/scaffold-core` + + ### Dark Mode & Color Preferences Stackwright has first-class dark mode and cookie-based preference persistence: diff --git a/examples/stackwright-docs/AGENTS.md b/examples/stackwright-docs/AGENTS.md index 3b8aab11..07defe21 100644 --- a/examples/stackwright-docs/AGENTS.md +++ b/examples/stackwright-docs/AGENTS.md @@ -52,6 +52,29 @@ The YAML key is the key used inside `content_items` entries. All types inherit ` **TypographyVariant values:** `h1` `h2` `h3` `h4` `h5` `h6` `subtitle1` `subtitle2` `body1` `body2` `caption` `button` `overline` +### Interface Contracts + +**AGENTS: This table is auto-generated from `@stackwright/types`. Run `pnpm stackwright -- generate-agent-docs` to regenerate. Do NOT edit the content between the markers manually.** + + +All interface contracts are defined in `@stackwright/types` and re-exported from `@stackwright/collections`, `@stackwright/hooks-registry`, and `@stackwright/scaffold-core` for backward compatibility. + +| Interface / Type | Kind | Fields / Signature | +|---|---|---| +| `CollectionProvider` | interface | `list(collection, opts?)` (Promise), `get(collection, slug)` (Promise), `collections()` (Promise) | +| `CollectionEntry` | interface | `slug` (string), `[key: string]` (unknown) | +| `CollectionListOptions` | interface | `limit`? (number), `offset`? (number), `sort`? (string), `filter`? (Record) | +| `CollectionListResult` | interface | `entries` (CollectionEntry[]), `total` (number) | +| `ScaffoldHookContext` | interface | `targetDir` (string), `projectName` (string), `siteTitle` (string), `themeId` (string), `packageJson` (Record), `dependencyMode` ('workspace' | 'standalone'), `codePuppyConfig`? (Record), `pages`? (string[]), `install`? (boolean), `[key: string]`? (unknown) | +| `ScaffoldHook` | interface | `type` (ScaffoldHookType), `name` (string), `handler` (HookHandler), `priority`? (number), `critical`? (boolean) | +| `HookHandler` | type | `(context: ScaffoldHookContext)` (Promise | void) | +| `ScaffoldHookType` | type | `values` ('preScaffold' | 'preInstall' | 'postInstall' | 'postScaffold') | + +**Import paths (all equivalent):** +- `CollectionProvider` — `@stackwright/types` · `@stackwright/collections` +- `ScaffoldHookContext`, `ScaffoldHook`, `HookHandler`, `ScaffoldHookType` — `@stackwright/types` · `@stackwright/hooks-registry` · `@stackwright/scaffold-core` + + ## Development Commands ```bash diff --git a/package.json b/package.json index bcde585e..3ebad323 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "uuid": ">=14.0.0", "fast-uri": ">=3.1.2", "js-yaml": ">=4.1.1", + "read-yaml-file>js-yaml": "^3", "postcss": ">=8.5.10" }, "onlyBuiltDependencies": [ diff --git a/packages/cli/src/commands/generate-agent-docs.ts b/packages/cli/src/commands/generate-agent-docs.ts index 44f28ac8..e0fff5ab 100644 --- a/packages/cli/src/commands/generate-agent-docs.ts +++ b/packages/cli/src/commands/generate-agent-docs.ts @@ -36,6 +36,9 @@ export interface GenerateAgentDocsResult { const START_MARKER = ''; const END_MARKER = ''; +const INTERFACE_START_MARKER = ''; +const INTERFACE_END_MARKER = ''; + // --------------------------------------------------------------------------- // Schema name registry — maps Zod schema object references to display names. // When zodSchemaToTypeString encounters one of these schemas (after resolving @@ -254,6 +257,148 @@ function generateTypographyLine(): string { return `**TypographyVariant values:** ${values.map((v) => `\`${v}\``).join(' ')}`; } +// --------------------------------------------------------------------------- +// Interface contracts table — documents the TypeScript interface contracts +// defined in @stackwright/types. These are not Zod schemas so they are +// described via a static config rather than runtime introspection. +// --------------------------------------------------------------------------- + +interface InterfaceField { + name: string; + type: string; + optional: boolean; +} + +interface InterfaceContract { + name: string; + kind: 'interface' | 'type'; + description: string; + fields: InterfaceField[]; +} + +const INTERFACE_CONTRACTS: InterfaceContract[] = [ + { + name: 'CollectionProvider', + kind: 'interface', + description: + 'Core runtime contract for Stackwright data backends. Every backend (file, S3, Contentful, OpenAPI, etc.) implements this interface.', + fields: [ + { name: 'list(collection, opts?)', type: 'Promise', optional: false }, + { name: 'get(collection, slug)', type: 'Promise', optional: false }, + { name: 'collections()', type: 'Promise', optional: false }, + ], + }, + { + name: 'CollectionEntry', + kind: 'interface', + description: 'A single entry returned by a CollectionProvider.', + fields: [ + { name: 'slug', type: 'string', optional: false }, + { name: '[key: string]', type: 'unknown', optional: false }, + ], + }, + { + name: 'CollectionListOptions', + kind: 'interface', + description: 'Options for filtering, sorting and paginating collection list results.', + fields: [ + { name: 'limit', type: 'number', optional: true }, + { name: 'offset', type: 'number', optional: true }, + { name: 'sort', type: 'string', optional: true }, + { name: 'filter', type: 'Record', optional: true }, + ], + }, + { + name: 'CollectionListResult', + kind: 'interface', + description: 'Result shape returned by CollectionProvider.list().', + fields: [ + { name: 'entries', type: 'CollectionEntry[]', optional: false }, + { name: 'total', type: 'number', optional: false }, + ], + }, + { + name: 'ScaffoldHookContext', + kind: 'interface', + description: + "Mutable context object passed to every scaffold hook handler. Earlier hooks' changes are visible to later hooks.", + fields: [ + { name: 'targetDir', type: 'string', optional: false }, + { name: 'projectName', type: 'string', optional: false }, + { name: 'siteTitle', type: 'string', optional: false }, + { name: 'themeId', type: 'string', optional: false }, + { name: 'packageJson', type: 'Record', optional: false }, + { name: 'dependencyMode', type: "'workspace' | 'standalone'", optional: false }, + { name: 'codePuppyConfig', type: 'Record', optional: true }, + { name: 'pages', type: 'string[]', optional: true }, + { name: 'install', type: 'boolean', optional: true }, + { name: '[key: string]', type: 'unknown', optional: true }, + ], + }, + { + name: 'ScaffoldHook', + kind: 'interface', + description: + 'A single scaffold hook registration. Pass to registerScaffoldHook() from @stackwright/scaffold-core.', + fields: [ + { name: 'type', type: 'ScaffoldHookType', optional: false }, + { name: 'name', type: 'string', optional: false }, + { name: 'handler', type: 'HookHandler', optional: false }, + { name: 'priority', type: 'number', optional: true }, + { name: 'critical', type: 'boolean', optional: true }, + ], + }, + { + name: 'HookHandler', + kind: 'type', + description: 'Function signature for scaffold hook handlers.', + fields: [ + { name: '(context: ScaffoldHookContext)', type: 'Promise | void', optional: false }, + ], + }, + { + name: 'ScaffoldHookType', + kind: 'type', + description: + 'Lifecycle point union. Execution order: preScaffold → preInstall → postInstall → postScaffold.', + fields: [ + { + name: 'values', + type: "'preScaffold' | 'preInstall' | 'postInstall' | 'postScaffold'", + optional: false, + }, + ], + }, +]; + +function generateInterfaceTable(): string { + const lines = [ + 'All interface contracts are defined in `@stackwright/types` and re-exported from `@stackwright/collections`, `@stackwright/hooks-registry`, and `@stackwright/scaffold-core` for backward compatibility.', + '', + '| Interface / Type | Kind | Fields / Signature |', + '|---|---|---|', + ]; + + for (const contract of INTERFACE_CONTRACTS) { + const fieldList = contract.fields + .map((f) => { + const namePart = f.optional ? `\`${f.name}\`?` : `\`${f.name}\``; + return `${namePart} (${f.type})`; + }) + .join(', '); + lines.push(`| \`${contract.name}\` | ${contract.kind} | ${fieldList} |`); + } + + lines.push(''); + lines.push('**Import paths (all equivalent):**'); + lines.push('- `CollectionProvider` — `@stackwright/types` · `@stackwright/collections`'); + lines.push( + '- `ScaffoldHookContext`, `ScaffoldHook`, `HookHandler`, `ScaffoldHookType` — `@stackwright/types` · `@stackwright/hooks-registry` · `@stackwright/scaffold-core`' + ); + + return lines.join('\n'); +} + // --------------------------------------------------------------------------- // Build the full generated block (content between the markers) // --------------------------------------------------------------------------- @@ -280,19 +425,21 @@ function buildGeneratedBlock(): string { // File update logic // --------------------------------------------------------------------------- -function updateAgentsMd( +function updateMarkerBlock( filePath: string, + startMarker: string, + endMarker: string, newBlock: string ): 'updated' | 'up-to-date' | 'no-markers' | 'not-found' { if (!fs.existsSync(filePath)) return 'not-found'; const current = fs.readFileSync(filePath, 'utf-8'); - const startIdx = current.indexOf(START_MARKER); - const endIdx = current.indexOf(END_MARKER); + const startIdx = current.indexOf(startMarker); + const endIdx = current.indexOf(endMarker); if (startIdx === -1 || endIdx === -1) return 'no-markers'; - const before = current.slice(0, startIdx + START_MARKER.length); + const before = current.slice(0, startIdx + startMarker.length); const after = current.slice(endIdx); const updated = `${before}\n${newBlock}\n${after}`; @@ -302,12 +449,20 @@ function updateAgentsMd( return 'updated'; } +function updateAgentsMd( + filePath: string, + newBlock: string +): 'updated' | 'up-to-date' | 'no-markers' | 'not-found' { + return updateMarkerBlock(filePath, START_MARKER, END_MARKER, newBlock); +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function generateAgentDocs(root: string = process.cwd()): GenerateAgentDocsResult { const newBlock = buildGeneratedBlock(); + const newInterfaceBlock = generateInterfaceTable(); const targetFiles = [ path.join(root, 'AGENTS.md'), @@ -319,21 +474,49 @@ export function generateAgentDocs(root: string = process.cwd()): GenerateAgentDo const errors: string[] = []; for (const filePath of targetFiles) { + // Update content-type-table const result = updateAgentsMd(filePath, newBlock); switch (result) { case 'updated': - filesUpdated.push(filePath); + if (!filesUpdated.includes(filePath)) filesUpdated.push(filePath); break; case 'up-to-date': - filesSkipped.push(filePath); + if (!filesUpdated.includes(filePath) && !filesSkipped.includes(filePath)) + filesSkipped.push(filePath); break; case 'no-markers': - errors.push(`Markers not found in: ${filePath}`); + errors.push(`Content-type-table markers not found in: ${filePath}`); break; case 'not-found': errors.push(`File not found: ${filePath}`); break; } + + // Update interface-table + const interfaceResult = updateMarkerBlock( + filePath, + INTERFACE_START_MARKER, + INTERFACE_END_MARKER, + newInterfaceBlock + ); + switch (interfaceResult) { + case 'updated': { + if (!filesUpdated.includes(filePath)) filesUpdated.push(filePath); + // Remove from skipped if it was added there by the first pass + const skipIdx = filesSkipped.indexOf(filePath); + if (skipIdx !== -1) filesSkipped.splice(skipIdx, 1); + break; + } + case 'up-to-date': + // already tracked correctly + break; + case 'no-markers': + errors.push(`Interface-table markers not found in: ${filePath}`); + break; + case 'not-found': + // already reported above + break; + } } return { filesUpdated, filesSkipped, errors }; diff --git a/packages/collections/src/file-collection-provider.ts b/packages/collections/src/file-collection-provider.ts index afa48eef..65daabd8 100644 --- a/packages/collections/src/file-collection-provider.ts +++ b/packages/collections/src/file-collection-provider.ts @@ -5,7 +5,7 @@ import type { CollectionEntry, CollectionListOptions, CollectionListResult, -} from './types'; +} from '@stackwright/types'; /** * File-backed CollectionProvider. diff --git a/packages/collections/src/types.ts b/packages/collections/src/types.ts index 044497af..81d8a37e 100644 --- a/packages/collections/src/types.ts +++ b/packages/collections/src/types.ts @@ -1,40 +1,15 @@ /** * CollectionProvider — the core abstraction for Stackwright collections. * - * Every data backend (file-based, S3, Contentful, Sanity, OpenAPI, etc.) - * implements this interface. Swapping backends is a one-line registration - * change; the YAML content and rendering layer never change. + * Interface contracts have moved to @stackwright/types so they are accessible + * to Pro packages and other consumers without a dependency on this package. + * This file re-exports them for any internal consumers that import from ./types. + * + * Do NOT re-add type definitions here — edit @stackwright/types instead. */ - -export interface CollectionEntry { - slug: string; - [key: string]: unknown; -} - -export interface CollectionListOptions { - /** Maximum number of entries to return. */ - limit?: number; - /** Number of entries to skip (for pagination). */ - offset?: number; - /** Field name to sort by. Prefix with `-` for descending (e.g. `-date`). */ - sort?: string; - /** Exact-match filters. Keys are field names, values are expected values. */ - filter?: Record; -} - -export interface CollectionListResult { - entries: CollectionEntry[]; - /** Total matching entries before limit/offset — enables pagination. */ - total: number; -} - -export interface CollectionProvider { - /** List entries in a collection with optional filtering, sorting, and pagination. */ - list(collection: string, opts?: CollectionListOptions): Promise; - - /** Get a single entry by slug. Returns null if not found. */ - get(collection: string, slug: string): Promise; - - /** List available collection names. */ - collections(): Promise; -} +export type { + CollectionProvider, + CollectionEntry, + CollectionListOptions, + CollectionListResult, +} from '@stackwright/types'; diff --git a/packages/hooks-registry/src/index.ts b/packages/hooks-registry/src/index.ts index 5e08b7ba..6e51984e 100644 --- a/packages/hooks-registry/src/index.ts +++ b/packages/hooks-registry/src/index.ts @@ -6,7 +6,7 @@ */ // Types re-export -export type { ScaffoldHook, ScaffoldHookType, ScaffoldHookContext } from './hooks'; +export type { ScaffoldHook, ScaffoldHookType, ScaffoldHookContext, HookHandler } from './hooks'; // Registry functions export { diff --git a/packages/scaffold-core/src/index.ts b/packages/scaffold-core/src/index.ts index bf28622e..587f1e6e 100644 --- a/packages/scaffold-core/src/index.ts +++ b/packages/scaffold-core/src/index.ts @@ -11,6 +11,7 @@ export type { ScaffoldHook, ScaffoldHookType, ScaffoldHookContext, + HookHandler, } from '@stackwright/hooks-registry'; // Re-export all registry functions diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc2255b7..bc7ff55e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,7 @@ overrides: uuid: '>=14.0.0' fast-uri: '>=3.1.2' js-yaml: '>=4.1.1' + read-yaml-file>js-yaml: ^3 postcss: '>=8.5.10' importers: