payload-contracts/scripts/extract-types.ts
Martin Porwoll 774d7bc402 feat: initial payload-contracts package
Shared TypeScript types, API client, and block registry for
coordinated CMS-to-frontend development across all tenants.

- Type extraction script from payload-types.ts (12,782 lines)
- 39 frontend collection types, 42 block types
- createPayloadClient() with tenant isolation
- createBlockRenderer() for type-safe block mapping
- Media helpers (getImageUrl, getSrcSet)
- Work order system for cross-server coordination
- Block catalog documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:51:40 +00:00

316 lines
9.4 KiB
TypeScript

#!/usr/bin/env tsx
/**
* extract-types.ts
*
* Copies payload-types.ts from the CMS repo into the contracts package
* and generates curated re-export modules for frontend consumption.
*
* Usage: tsx scripts/extract-types.ts [path-to-payload-types.ts]
* Default: ../payload-cms/src/payload-types.ts
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const TYPES_DIR = resolve(ROOT, 'src/types')
const sourcePath = process.argv[2] || resolve(ROOT, '../payload-cms/src/payload-types.ts')
console.log(`[extract] Reading types from: ${sourcePath}`)
const source = readFileSync(sourcePath, 'utf-8')
// Ensure output directory exists
mkdirSync(TYPES_DIR, { recursive: true })
// 1. Copy full payload-types.ts
const header = `/* Auto-extracted from Payload CMS — DO NOT EDIT MANUALLY */
/* Re-run: pnpm extract */
/* Source: payload-cms/src/payload-types.ts */\n\n`
// Strip the `declare module 'payload'` augmentation — frontends don't have payload installed
const cleanedSource = source.replace(/\s*declare module 'payload' \{[\s\S]*?\}\s*$/, '\n')
writeFileSync(resolve(TYPES_DIR, 'payload-types.ts'), header + cleanedSource)
console.log('[extract] Copied payload-types.ts')
// 2. Extract interface names from the Config.collections mapping
const collectionsMatch = source.match(/collections:\s*\{([^}]+)\}/s)
if (!collectionsMatch) {
console.error('[extract] Could not find collections in Config interface')
process.exit(1)
}
// Parse lines like " users: User;" or " 'social-links': SocialLink;"
const collectionEntries = [...collectionsMatch[1].matchAll(/^\s*'?([a-z][\w-]*)'?\s*:\s*(\w+)\s*;/gm)]
.map(m => ({ slug: m[1], typeName: m[2] }))
console.log(`[extract] Found ${collectionEntries.length} collections`)
// Define which collections are frontend-relevant (exclude system/admin-only)
const SYSTEM_COLLECTIONS = new Set([
'payload-kv', 'payload-locked-documents', 'payload-preferences', 'payload-migrations',
'email-logs', 'audit-logs', 'consent-logs', 'form-submissions', 'redirects',
'social-accounts', 'social-platforms', 'community-interactions', 'community-templates',
'community-rules', 'report-schedules', 'youtube-channels', 'youtube-content',
'yt-tasks', 'yt-notifications', 'yt-batches', 'yt-monthly-goals',
'yt-script-templates', 'yt-checklist-templates', 'yt-series',
])
const frontendCollections = collectionEntries.filter(c => !SYSTEM_COLLECTIONS.has(c.slug))
const systemCollections = collectionEntries.filter(c => SYSTEM_COLLECTIONS.has(c.slug))
console.log(`[extract] Frontend collections: ${frontendCollections.length}`)
console.log(`[extract] System collections (excluded): ${systemCollections.length}`)
// 3. Generate collections.ts — re-exports of frontend-relevant collection types
const collectionsFile = `/**
* Frontend-relevant collection types
* Auto-generated by extract-types.ts — DO NOT EDIT MANUALLY
*/
import type {
${frontendCollections.map(c => ` ${c.typeName},`).join('\n')}
} from './payload-types'
// Re-export all types
export type {
${frontendCollections.map(c => ` ${c.typeName},`).join('\n')}
} from './payload-types'
/**
* Collection slug to type mapping for frontend use
*/
export interface CollectionTypeMap {
${frontendCollections.map(c => ` '${c.slug}': ${c.typeName};`).join('\n')}
}
export type CollectionSlug = keyof CollectionTypeMap
`
writeFileSync(resolve(TYPES_DIR, 'collections.ts'), collectionsFile)
console.log('[extract] Generated collections.ts')
// 4. Extract block types from the Page interface
// Blocks are defined as a union within the Page.layout array
const blockTypes: string[] = []
const blockTypeRegex = /blockType:\s*'([^']+)'/g
let match: RegExpExecArray | null
// Only scan the Page interface section (roughly lines 519-3070)
const pageSection = source.substring(
source.indexOf('export interface Page'),
source.indexOf('export interface Video')
)
while ((match = blockTypeRegex.exec(pageSection)) !== null) {
if (!blockTypes.includes(match[1])) {
blockTypes.push(match[1])
}
}
console.log(`[extract] Found ${blockTypes.length} block types`)
// 5. Generate blocks.ts
const blocksFile = `/**
* Block type definitions
* Auto-generated by extract-types.ts — DO NOT EDIT MANUALLY
*/
export type { Page } from './payload-types'
/**
* Union of all page block types (extracted from Page.layout)
* Each block has a discriminant 'blockType' field.
*
* Usage:
* import type { Block } from '@c2s/payload-contracts/types'
* if (block.blockType === 'hero-block') { ... }
*/
import type { Page } from './payload-types'
export type Block = NonNullable<Page['layout']>[number]
/**
* Extract a specific block type by its blockType discriminant
*
* Usage:
* type HeroBlock = BlockByType<'hero-block'>
*/
export type BlockByType<T extends Block['blockType']> = Extract<Block, { blockType: T }>
/**
* All block type slugs as a const array
*/
export const BLOCK_TYPES = [
${blockTypes.map(b => ` '${b}',`).join('\n')}
] as const
export type BlockType = typeof BLOCK_TYPES[number]
`
writeFileSync(resolve(TYPES_DIR, 'blocks.ts'), blocksFile)
console.log('[extract] Generated blocks.ts')
// 6. Generate media.ts — dedicated Media type with image sizes documentation
const mediaFile = `/**
* Media type with responsive image sizes
* Auto-generated by extract-types.ts — DO NOT EDIT MANUALLY
*/
export type { Media } from './payload-types'
import type { Media } from './payload-types'
/**
* All available image size names
*
* Standard sizes: thumbnail, small, medium, large, xlarge, 2k, og
* AVIF sizes: medium_avif, large_avif, xlarge_avif
*/
export type ImageSizeName = keyof NonNullable<Media['sizes']>
/**
* A single image size entry
*/
export type ImageSize = NonNullable<NonNullable<Media['sizes']>[ImageSizeName]>
/**
* Helper to get the URL of a specific size, with fallback
*/
export function getImageUrl(media: Media | null | undefined, size: ImageSizeName = 'large'): string | null {
if (!media) return null
const sizeData = media.sizes?.[size]
return sizeData?.url || media.url || null
}
/**
* Get srcSet string for responsive images
*/
export function getSrcSet(media: Media | null | undefined, sizes: ImageSizeName[] = ['small', 'medium', 'large', 'xlarge']): string {
if (!media?.sizes) return ''
return sizes
.map(size => {
const s = media.sizes?.[size]
if (!s?.url || !s?.width) return null
return \`\${s.url} \${s.width}w\`
})
.filter(Boolean)
.join(', ')
}
`
writeFileSync(resolve(TYPES_DIR, 'media.ts'), mediaFile)
console.log('[extract] Generated media.ts')
// 7. Generate api.ts — API response types that Payload returns
const apiFile = `/**
* Payload CMS REST API response types
* These match the actual JSON responses from the Payload REST API.
*/
/**
* Paginated collection response
* Returned by: GET /api/{collection}
*/
export interface PaginatedResponse<T> {
docs: T[]
totalDocs: number
limit: number
totalPages: number
page: number
pagingCounter: number
hasPrevPage: boolean
hasNextPage: boolean
prevPage: number | null
nextPage: number | null
}
/**
* Single document response
* Returned by: GET /api/{collection}/{id}
*/
export type SingleResponse<T> = T
/**
* Error response from Payload API
*/
export interface PayloadError {
errors: Array<{
message: string
name?: string
data?: Record<string, unknown>
}>
}
/**
* Query parameters for collection requests
*/
export interface CollectionQueryParams {
/** Depth of relationship population (0-10) */
depth?: number
/** Locale for localized fields */
locale?: 'de' | 'en'
/** Fallback locale when field is empty */
fallbackLocale?: 'de' | 'en' | 'none'
/** Number of results per page */
limit?: number
/** Page number */
page?: number
/** Sort field (prefix with - for descending) */
sort?: string
/** Payload query filter (where clause) */
where?: Record<string, unknown>
/** Draft mode */
draft?: boolean
}
/**
* Query parameters for global requests
*/
export interface GlobalQueryParams {
depth?: number
locale?: 'de' | 'en'
fallbackLocale?: 'de' | 'en' | 'none'
draft?: boolean
}
/**
* Available locales in this Payload instance
*/
export type Locale = 'de' | 'en'
export const DEFAULT_LOCALE: Locale = 'de'
`
writeFileSync(resolve(TYPES_DIR, 'api.ts'), apiFile)
console.log('[extract] Generated api.ts')
// 8. Generate index.ts
const indexFile = `/**
* @c2s/payload-contracts — Type definitions
*
* Re-exports curated types for frontend consumption.
* Full Payload types available via './payload-types' if needed.
*/
// Collection types (frontend-relevant)
export * from './collections'
// Block types
export * from './blocks'
// Media with helpers
export * from './media'
// API response types
export * from './api'
`
writeFileSync(resolve(TYPES_DIR, 'index.ts'), indexFile)
console.log('[extract] Generated index.ts')
console.log('\n[extract] Done! Generated files in src/types/:')
console.log(' - payload-types.ts (full copy from CMS)')
console.log(' - collections.ts (frontend collection re-exports)')
console.log(' - blocks.ts (block type union + helpers)')
console.log(' - media.ts (media type + image helpers)')
console.log(' - api.ts (API response types)')
console.log(' - index.ts (barrel export)')