From af3b6f5ac5886f43cbd61ab29de892b35db54669 Mon Sep 17 00:00:00 2001 From: Chai Pin Zheng Date: Fri, 19 Jun 2026 17:15:34 +0700 Subject: [PATCH] cli(feat): add opt-in demo seed + leak-proof admin field hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help users reach good block content after install, two ways, without baking sample copy into saved documents: - `payload-components seed ` and `add --demo` write a runnable seed script (payload-components/seed-.ts) that creates one labeled, deletable example Page (slug payload-components-demo-) pre-filled from manifest.sampleContent. The CLI never opens the database — the user runs the script via `payload run` in their own toolchain, so the published bundle keeps its ajv+semver-only runtime-dependency contract. Seed-script generation is extracted into a Playwright-free shared module (tools/payload-components/seed/seed-script.ts) reused by the smoke harness. - admin.placeholder / admin.description hints on the shared field modules and the HeroBasic/EmbedBasic configs. They render in the admin but are never written to the document, so guidance can't leak to production. Deliberately NOT defaultValue. Tests: new add --demo orchestration cases; smoke-seed unit test repointed to the shared module. Verified locally: tsc, lint, registry:check, test:int (68 passed), test:pack (dep contract holds). Co-Authored-By: Claude Opus 4.8 --- .../source/blocks/EmbedBasic/config.ts | 3 + .../source/blocks/HeroBasic/config.ts | 4 + .../blocks/shared/callToActionFields.ts | 6 + .../source/blocks/shared/contentFields.ts | 10 + .../source/blocks/shared/featureFields.ts | 9 + .../source/blocks/shared/heroFields.ts | 9 + .../source/blocks/shared/integrationFields.ts | 16 ++ .../source/blocks/shared/logoCloudFields.ts | 10 + .../source/blocks/shared/teamFields.ts | 15 ++ tests/int/fresh-payload-smoke.int.spec.ts | 2 +- ...payload-components-add-command.int.spec.ts | 35 ++++ tools/payload-components/cli.ts | 34 +++- tools/payload-components/commands/add.ts | 21 ++- tools/payload-components/commands/seed.ts | 80 ++++++++ tools/payload-components/seed/seed-script.ts | 173 ++++++++++++++++++ .../smoke/fresh-payload-repo.ts | 126 +------------ 16 files changed, 425 insertions(+), 128 deletions(-) create mode 100644 tools/payload-components/commands/seed.ts create mode 100644 tools/payload-components/seed/seed-script.ts diff --git a/payload-components/source/blocks/EmbedBasic/config.ts b/payload-components/source/blocks/EmbedBasic/config.ts index 85864c0..5c4a631 100644 --- a/payload-components/source/blocks/EmbedBasic/config.ts +++ b/payload-components/source/blocks/EmbedBasic/config.ts @@ -39,6 +39,9 @@ export const EmbedBasic: Block = { { name: 'caption', type: 'text', + admin: { + placeholder: 'Optional caption shown below the embed', + }, }, { name: 'allowFullscreen', diff --git a/payload-components/source/blocks/HeroBasic/config.ts b/payload-components/source/blocks/HeroBasic/config.ts index 5e87b35..894d49a 100644 --- a/payload-components/source/blocks/HeroBasic/config.ts +++ b/payload-components/source/blocks/HeroBasic/config.ts @@ -13,6 +13,7 @@ export const HeroBasic: Block = { name: 'proofItems', type: 'array', admin: { + description: 'Add 2-4 short trust badges (e.g. SOC 2 Type II).', initCollapsed: true, }, fields: [ @@ -20,6 +21,9 @@ export const HeroBasic: Block = { name: 'label', type: 'text', required: true, + admin: { + placeholder: 'SOC 2 Type II', + }, }, ], maxRows: 4, diff --git a/payload-components/source/blocks/shared/callToActionFields.ts b/payload-components/source/blocks/shared/callToActionFields.ts index ce19e30..970837a 100644 --- a/payload-components/source/blocks/shared/callToActionFields.ts +++ b/payload-components/source/blocks/shared/callToActionFields.ts @@ -18,9 +18,15 @@ export const callToActionFields: Field[] = [ name: 'title', type: 'text', required: true, + admin: { + placeholder: 'Action-oriented headline (e.g. Start building today)', + }, }, { name: 'description', type: 'textarea', + admin: { + placeholder: 'A short nudge toward the primary action', + }, }, ] diff --git a/payload-components/source/blocks/shared/contentFields.ts b/payload-components/source/blocks/shared/contentFields.ts index aa7dd27..b2b8fb9 100644 --- a/payload-components/source/blocks/shared/contentFields.ts +++ b/payload-components/source/blocks/shared/contentFields.ts @@ -22,11 +22,17 @@ export const contentFields: Field[] = [ { name: 'eyebrow', type: 'text', + admin: { + placeholder: 'Optional kicker shown above the title', + }, }, { name: 'title', type: 'text', required: true, + admin: { + placeholder: 'Section heading', + }, }, { name: 'paragraphs', @@ -34,6 +40,7 @@ export const contentFields: Field[] = [ minRows: 1, maxRows: 4, admin: { + description: 'One to four short paragraphs of body copy.', initCollapsed: true, }, fields: [ @@ -41,6 +48,9 @@ export const contentFields: Field[] = [ name: 'text', type: 'textarea', required: true, + admin: { + placeholder: 'A short paragraph of body copy', + }, }, ], }, diff --git a/payload-components/source/blocks/shared/featureFields.ts b/payload-components/source/blocks/shared/featureFields.ts index 1aeddcb..c2c9739 100644 --- a/payload-components/source/blocks/shared/featureFields.ts +++ b/payload-components/source/blocks/shared/featureFields.ts @@ -17,14 +17,23 @@ export const featureFields: Field[] = [ { name: 'eyebrow', type: 'text', + admin: { + placeholder: 'Optional kicker shown above the section title', + }, }, { name: 'title', type: 'text', required: true, + admin: { + placeholder: 'Section heading for the feature group', + }, }, { name: 'description', type: 'textarea', + admin: { + placeholder: 'A sentence framing the features below', + }, }, ] diff --git a/payload-components/source/blocks/shared/heroFields.ts b/payload-components/source/blocks/shared/heroFields.ts index 011f3db..00492e8 100644 --- a/payload-components/source/blocks/shared/heroFields.ts +++ b/payload-components/source/blocks/shared/heroFields.ts @@ -17,16 +17,25 @@ export const heroFields: Field[] = [ { name: 'eyebrow', type: 'text', + admin: { + placeholder: 'Optional kicker shown above the headline', + }, }, { name: 'title', type: 'text', required: true, + admin: { + placeholder: 'Headline — the main outcome you deliver', + }, }, { name: 'description', type: 'textarea', required: true, + admin: { + placeholder: 'A sentence or two expanding on the headline', + }, }, linkGroup({ overrides: { diff --git a/payload-components/source/blocks/shared/integrationFields.ts b/payload-components/source/blocks/shared/integrationFields.ts index 36009a2..7280357 100644 --- a/payload-components/source/blocks/shared/integrationFields.ts +++ b/payload-components/source/blocks/shared/integrationFields.ts @@ -25,10 +25,16 @@ export const integrationFields: Field[] = [ name: 'heading', type: 'text', required: true, + admin: { + placeholder: 'Heading shown above the integrations', + }, }, { name: 'subtext', type: 'textarea', + admin: { + placeholder: 'Optional sentence below the heading', + }, }, { name: 'integrations', @@ -37,6 +43,7 @@ export const integrationFields: Field[] = [ minRows: 2, maxRows: 12, admin: { + description: 'Add the integrations — upload a logo and an accessible name for each.', initCollapsed: true, }, fields: [ @@ -50,14 +57,23 @@ export const integrationFields: Field[] = [ name: 'name', type: 'text', required: true, + admin: { + placeholder: 'Accessible integration name', + }, }, { name: 'description', type: 'textarea', + admin: { + placeholder: 'Optional one-line description', + }, }, { name: 'href', type: 'text', + admin: { + placeholder: 'Optional link, e.g. https://tool.com', + }, }, ], }, diff --git a/payload-components/source/blocks/shared/logoCloudFields.ts b/payload-components/source/blocks/shared/logoCloudFields.ts index bff5c70..84635c7 100644 --- a/payload-components/source/blocks/shared/logoCloudFields.ts +++ b/payload-components/source/blocks/shared/logoCloudFields.ts @@ -22,6 +22,9 @@ export const logoCloudFields: Field[] = [ name: 'heading', type: 'text', required: true, + admin: { + placeholder: 'Heading shown above the logo wall', + }, }, { name: 'logos', @@ -30,6 +33,7 @@ export const logoCloudFields: Field[] = [ minRows: 2, maxRows: 12, admin: { + description: 'Add the brand logos — upload an image and an accessible name for each.', initCollapsed: true, }, fields: [ @@ -43,10 +47,16 @@ export const logoCloudFields: Field[] = [ name: 'name', type: 'text', required: true, + admin: { + placeholder: 'Accessible brand name (used as alt text)', + }, }, { name: 'href', type: 'text', + admin: { + placeholder: 'Optional link, e.g. https://brand.com', + }, }, ], }, diff --git a/payload-components/source/blocks/shared/teamFields.ts b/payload-components/source/blocks/shared/teamFields.ts index c526cb7..9760ec9 100644 --- a/payload-components/source/blocks/shared/teamFields.ts +++ b/payload-components/source/blocks/shared/teamFields.ts @@ -22,11 +22,17 @@ export const teamFields: Field[] = [ { name: 'eyebrow', type: 'text', + admin: { + placeholder: 'Optional kicker shown above the title', + }, }, { name: 'title', type: 'text', required: true, + admin: { + placeholder: 'Section heading', + }, }, ] @@ -41,14 +47,23 @@ export const teamMemberFields: Field[] = [ name: 'name', type: 'text', required: true, + admin: { + placeholder: 'Full name', + }, }, { name: 'role', type: 'text', required: true, + admin: { + placeholder: 'Job title or role', + }, }, { name: 'href', type: 'text', + admin: { + placeholder: 'Optional profile link', + }, }, ] diff --git a/tests/int/fresh-payload-smoke.int.spec.ts b/tests/int/fresh-payload-smoke.int.spec.ts index 818badf..0940842 100644 --- a/tests/int/fresh-payload-smoke.int.spec.ts +++ b/tests/int/fresh-payload-smoke.int.spec.ts @@ -8,7 +8,7 @@ import { loadManifest } from '../../tools/payload-components/manifest' import { sampleContentNeedsSmokeMedia, writeSeedScript, -} from '../../tools/payload-components/smoke/fresh-payload-repo' +} from '../../tools/payload-components/seed/seed-script' describe('fresh Payload smoke seed generation', () => { const tempDirs: string[] = [] diff --git a/tests/int/payload-components-add-command.int.spec.ts b/tests/int/payload-components-add-command.int.spec.ts index a3a2ae6..0f6b638 100644 --- a/tests/int/payload-components-add-command.int.spec.ts +++ b/tests/int/payload-components-add-command.int.spec.ts @@ -109,6 +109,7 @@ describe('payload-components add command orchestration', () => { }) const printHeader = vi.fn() const runCommand = vi.fn().mockResolvedValue(undefined) + const seedCommand = vi.fn().mockResolvedValue(undefined) vi.doMock('../../tools/payload-components/manifest', () => ({ loadManifest, @@ -147,6 +148,9 @@ describe('payload-components add command orchestration', () => { runCommand, } }) + vi.doMock('../../tools/payload-components/commands/seed', () => ({ + seedCommand, + })) const addCommandModule = await import('../../tools/payload-components/commands/add') @@ -165,6 +169,7 @@ describe('payload-components add command orchestration', () => { recordInstallFailure, recordInstalledState, runCommand, + seedCommand, verifyInstalledManifestFiles, verifyInstalledPayloadFragments, }, @@ -327,4 +332,34 @@ describe('payload-components add command orchestration', () => { expect(mocks.runCommand).toHaveBeenCalledOnce() expect(mocks.recordInstalledState).toHaveBeenCalledOnce() }) + + it('writes the demo seed script after a successful install when --demo is set', async () => { + const { addCommand, mocks } = await setup() + + mocks.checkDependencyRequirements + .mockResolvedValueOnce({ installed: { payload: '3.82.1' }, missing: [] }) + .mockResolvedValueOnce({ installed: {}, missing: [] }) + + await addCommand({ componentName: 'hero-basic', cwd: '/tmp/fixture', demo: true }) + + expect(mocks.recordInstalledState).toHaveBeenCalledOnce() + expect(mocks.seedCommand).toHaveBeenCalledOnce() + expect(mocks.seedCommand).toHaveBeenCalledWith({ + componentName: 'hero-basic', + cwd: '/tmp/fixture', + }) + }) + + it('does not write the demo seed script by default', async () => { + const { addCommand, mocks } = await setup() + + mocks.checkDependencyRequirements + .mockResolvedValueOnce({ installed: { payload: '3.82.1' }, missing: [] }) + .mockResolvedValueOnce({ installed: {}, missing: [] }) + + await addCommand({ componentName: 'hero-basic', cwd: '/tmp/fixture' }) + + expect(mocks.recordInstalledState).toHaveBeenCalledOnce() + expect(mocks.seedCommand).not.toHaveBeenCalled() + }) }) diff --git a/tools/payload-components/cli.ts b/tools/payload-components/cli.ts index aa18a33..34c4188 100644 --- a/tools/payload-components/cli.ts +++ b/tools/payload-components/cli.ts @@ -3,20 +3,26 @@ import path from 'node:path' import { addCommand } from './commands/add' import { doctorCommand } from './commands/doctor' import { initCommand } from './commands/init' +import { seedCommand } from './commands/seed' const usage = `payload-components Usage: - payload-components add [--cwd ] + payload-components add [--cwd ] [--demo] + payload-components seed [--cwd ] payload-components init [--cwd ] payload-components doctor [--cwd ] payload-components --help Alpha commands: add Install an alpha component through the payload-components wrapper and shadcn-compatible registry flow. + seed Write a labeled demo seed script (one example Page) you can run and delete. init Initialize shadcn in the project (creates components.json) so components can be installed. doctor Diagnose project readiness and recorded component installs without changing files. +Flags: + --demo After "add", also write the demo seed script for the component. + Current alpha components: hero-basic feature-grid-basic @@ -61,6 +67,7 @@ Current alpha components: const parseArgs = (argv: string[]) => { const args = [...argv] let cwd = process.cwd() + let demo = false let help = false const positional: string[] = [] @@ -82,6 +89,11 @@ const parseArgs = (argv: string[]) => { continue } + if (current === '--demo') { + demo = true + continue + } + if (current === '--help' || current === '-h') { help = true continue @@ -92,13 +104,14 @@ const parseArgs = (argv: string[]) => { return { cwd, + demo, help, positional, } } const main = async () => { - const { cwd, help, positional } = parseArgs(process.argv.slice(2)) + const { cwd, demo, help, positional } = parseArgs(process.argv.slice(2)) const [command, ...rest] = positional @@ -119,6 +132,23 @@ const main = async () => { await addCommand({ cwd, componentName, + demo, + }) + return + } + + if (command === 'seed') { + const [componentName] = rest + + if (!componentName) { + throw new Error( + 'payload-components seed requires a component name. Try "payload-components seed hero-basic".', + ) + } + + await seedCommand({ + cwd, + componentName, }) return } diff --git a/tools/payload-components/commands/add.ts b/tools/payload-components/commands/add.ts index fc84d21..3043b69 100644 --- a/tools/payload-components/commands/add.ts +++ b/tools/payload-components/commands/add.ts @@ -23,6 +23,8 @@ import { } from '../state' import { getRunScriptCommand, printHeader, runCommand } from '../utils' +import { seedCommand } from './seed' + import type { InstallStage } from '../types' const postInstallEnv = { @@ -38,7 +40,7 @@ const normalizeFileList = (files: string[]) => [...new Set(files)].sort() const formatStageError = (error: unknown) => (error instanceof Error ? error.message : 'Unknown error') -export const addCommand = async ({ +const installComponent = async ({ cwd, componentName, }: { @@ -195,3 +197,20 @@ export const addCommand = async ({ printHeader(`payload-components: installed "${manifest.name}" successfully.`) } + +export const addCommand = async ({ + cwd, + componentName, + demo = false, +}: { + cwd: string + componentName: string + demo?: boolean +}) => { + await installComponent({ cwd, componentName }) + + // Opt-in, strictly last: a seed-script write must never affect install state. + if (demo) { + await seedCommand({ cwd, componentName }) + } +} diff --git a/tools/payload-components/commands/seed.ts b/tools/payload-components/commands/seed.ts new file mode 100644 index 0000000..bc915b2 --- /dev/null +++ b/tools/payload-components/commands/seed.ts @@ -0,0 +1,80 @@ +import path from 'node:path' + +import { loadManifest } from '../manifest' +import { + assertManifestSupport, + detectProject, + verifyInstalledManifestFiles, + verifyInstalledPayloadFragments, +} from '../project' +import { writeSeedScript, type SeedTarget } from '../seed/seed-script' +import { printHeader } from '../utils' + +import type { ComponentManifest, PackageManager } from '../types' + +const demoTarget = (manifest: ComponentManifest): SeedTarget => ({ + configImportPath: '../src/payload.config', + placeholderRelPath: path.join('.payload-components', 'demo-placeholder.svg'), + scriptRelPath: path.join('payload-components', `seed-${manifest.name}.ts`), + slug: `payload-components-demo-${manifest.name}`, + title: `Payload Components — ${manifest.title} demo`, +}) + +const runScriptCommand = (packageManager: PackageManager, scriptRelPath: string) => { + if (packageManager === 'pnpm') { + return `pnpm exec payload run ${scriptRelPath}` + } + + if (packageManager === 'yarn') { + return `yarn payload run ${scriptRelPath}` + } + + if (packageManager === 'bun') { + return `bunx payload run ${scriptRelPath}` + } + + return `npx payload run ${scriptRelPath}` +} + +/** + * Write a labeled, opt-in demo seed script into the consumer project. The CLI + * never connects to the database — it only writes a runnable script that the + * user executes inside their own Payload toolchain (`payload run`), which + * creates one clearly-labeled example Page they can study and delete. + */ +export const seedCommand = async ({ cwd, componentName }: { cwd: string; componentName: string }) => { + const manifest = await loadManifest(componentName) + const project = await detectProject(cwd) + + assertManifestSupport(project, manifest) + + // Soft check: the seeded page references the block type, which only renders + // once the block is registered in the Payload config. Warn (don't fail) so a + // user can write the script first and install after. + const [fileCheck, fragmentCheck] = await Promise.all([ + verifyInstalledManifestFiles({ cwd, manifest }), + verifyInstalledPayloadFragments({ cwd, manifest }), + ]) + + if (!fileCheck.isValid || !fragmentCheck.isValid) { + printHeader( + `payload-components: "${manifest.name}" does not look installed yet. Run "payload-components add ${manifest.name}" before running the seed script below.`, + ) + } + + const target = demoTarget(manifest) + + await writeSeedScript(cwd, [manifest], target) + + printHeader( + [ + `payload-components: wrote ${target.scriptRelPath}`, + '', + 'Run it against your database to create one example page:', + ` ${runScriptCommand(project.packageManager, target.scriptRelPath)}`, + '', + `Then open /${target.slug} to see "${manifest.title}" filled in like the catalog preview.`, + 'The page is labeled and safe to delete — re-running the script replaces only that page.', + ].join('\n'), + ) +} diff --git a/tools/payload-components/seed/seed-script.ts b/tools/payload-components/seed/seed-script.ts new file mode 100644 index 0000000..32af11f --- /dev/null +++ b/tools/payload-components/seed/seed-script.ts @@ -0,0 +1,173 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import type { ComponentManifest } from '../types' + +/** + * Shared seed-script generator. + * + * Emits a standalone TypeScript script that, run inside a consumer Payload + * project (`payload run` / `tsx`), creates ONE labeled, deletable Page whose + * layout is pre-filled from each manifest's `sampleContent`. Used by both the + * fresh-project smoke harness and the user-facing `payload-components seed` + * command, so the seeding logic never forks. + * + * This module must stay free of `@playwright/test` (and any heavy import): the + * published CLI bundles `cli.ts`, whose runtime deps are contractually limited + * to `ajv` + `semver`. The smoke harness keeps its Playwright import; it just + * imports these builders from here. + */ + +export type SmokeSampleBlock = ComponentManifest['sampleContent'] & { + avatars?: Array> + integrations?: Array> + logos?: Array> +} + +export type SeedTarget = { + /** Module specifier the script imports the Payload config from, relative to the script. */ + configImportPath: string + /** Path (relative to the project root) for the throwaway placeholder SVG. */ + placeholderRelPath: string + /** Path (relative to the project root) the seed script is written to. */ + scriptRelPath: string + /** Slug of the single demo Page the script creates and re-creates. */ + slug: string + /** Title of the demo Page. */ + title: string +} + +/** Default target used by the fresh-project smoke harness. */ +export const SMOKE_SEED_TARGET: SeedTarget = { + configImportPath: '../src/payload.config', + placeholderRelPath: path.join('.payload-components', 'smoke-placeholder.svg'), + scriptRelPath: path.join('.payload-components', 'smoke-seed.ts'), + slug: 'payload-components-smoke', + title: 'Payload Component Smoke', +} + +const isMissingUploadReference = (item: Record, fieldName: string) => + typeof item[fieldName] === 'undefined' || item[fieldName] === null || item[fieldName] === '' + +export const sampleContentNeedsSmokeMedia = (sampleContent: ComponentManifest['sampleContent']) => { + const block = sampleContent as SmokeSampleBlock + + return ( + block.logos?.some((item) => isMissingUploadReference(item, 'logo')) || + block.integrations?.some((item) => isMissingUploadReference(item, 'logo')) || + block.avatars?.some((item) => isMissingUploadReference(item, 'avatar')) || + false + ) +} + +export const buildSeedScript = ({ + manifests, + target, +}: { + manifests: ComponentManifest[] + target: SeedTarget +}): string => { + const layout = manifests.map((manifest) => manifest.sampleContent) + const needsSmokeMedia = manifests.some((manifest) => + sampleContentNeedsSmokeMedia(manifest.sampleContent), + ) + + return `import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { getPayload } from 'payload' + +const { default: config } = await import(${JSON.stringify(target.configImportPath)}) + +type SmokeSampleItem = Record +type SmokeSampleBlock = SmokeSampleItem & { + avatars?: SmokeSampleItem[] + integrations?: SmokeSampleItem[] + logos?: SmokeSampleItem[] +} + +const rawLayout = ${JSON.stringify(layout, null, 2)} satisfies SmokeSampleBlock[] +const needsSmokeMedia = ${JSON.stringify(needsSmokeMedia)} + +const addUploadReference = (items: SmokeSampleItem[] | undefined, fieldName: string, mediaID: unknown) => + items?.map((item) => ({ + ...item, + [fieldName]: item[fieldName] ?? mediaID, + })) + +const addSmokeUploadReferences = (block: SmokeSampleBlock, mediaID: unknown): SmokeSampleBlock => ({ + ...block, + ...(block.avatars ? { avatars: addUploadReference(block.avatars, 'avatar', mediaID) } : {}), + ...(block.integrations ? { integrations: addUploadReference(block.integrations, 'logo', mediaID) } : {}), + ...(block.logos ? { logos: addUploadReference(block.logos, 'logo', mediaID) } : {}), +}) + +const createSmokeMedia = async () => { + const mediaPath = path.join(process.cwd(), ${JSON.stringify(target.placeholderRelPath)}) + + await mkdir(path.dirname(mediaPath), { recursive: true }) + await writeFile( + mediaPath, + '', + ) + + return payload.create({ + collection: 'media', + data: { + alt: ${JSON.stringify(`${target.title} placeholder`)}, + }, + filePath: mediaPath, + overrideAccess: true, + }) +} + +const payload = await getPayload({ config }) +const slug = ${JSON.stringify(target.slug)} +const smokeMedia = needsSmokeMedia ? await createSmokeMedia() : undefined +const layout = smokeMedia + ? rawLayout.map((block) => addSmokeUploadReferences(block, smokeMedia.id)) + : rawLayout + +await payload.delete({ + collection: 'pages', + context: { + disableRevalidate: true, + }, + overrideAccess: true, + where: { + slug: { + equals: slug, + }, + }, +}).catch(() => undefined) + +await payload.create({ + collection: 'pages', + context: { + disableRevalidate: true, + }, + data: { + title: ${JSON.stringify(target.title)}, + slug, + layout, + _status: 'published', + }, + overrideAccess: true, +}) + +console.log('Seeded /' + slug) +` +} + +export const writeSeedScript = async ( + targetPath: string, + manifests: ComponentManifest[], + target: SeedTarget = SMOKE_SEED_TARGET, +): Promise => { + const scriptPath = path.join(targetPath, target.scriptRelPath) + + await mkdir(path.dirname(scriptPath), { recursive: true }) + await writeFile(scriptPath, buildSeedScript({ manifests, target })) + + return scriptPath +} diff --git a/tools/payload-components/smoke/fresh-payload-repo.ts b/tools/payload-components/smoke/fresh-payload-repo.ts index a8f8c19..8d2241a 100644 --- a/tools/payload-components/smoke/fresh-payload-repo.ts +++ b/tools/payload-components/smoke/fresh-payload-repo.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from 'node:child_process' -import { access, cp, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { access, cp, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { createServer, type Server, type ServerResponse } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { chromium } from '@playwright/test' import { loadManifest } from '../manifest' +import { writeSeedScript } from '../seed/seed-script' import { shadcnCliPackage } from '../utils' import type { ComponentManifest } from '../types' @@ -87,12 +88,6 @@ type StaticRegistryServer = { urlTemplate: string } -type SmokeSampleBlock = ComponentManifest['sampleContent'] & { - avatars?: Array> - integrations?: Array> - logos?: Array> -} - const dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(dirname, '..', '..', '..') const rootPackagePath = path.join(repoRoot, 'package.json') @@ -560,123 +555,6 @@ const runDirectShadcnUrlSmoke = async ({ return targetPath } -const isMissingUploadReference = (item: Record, fieldName: string) => - typeof item[fieldName] === 'undefined' || item[fieldName] === null || item[fieldName] === '' - -export const sampleContentNeedsSmokeMedia = (sampleContent: ComponentManifest['sampleContent']) => { - const block = sampleContent as SmokeSampleBlock - - return ( - block.logos?.some((item) => isMissingUploadReference(item, 'logo')) || - block.integrations?.some((item) => isMissingUploadReference(item, 'logo')) || - block.avatars?.some((item) => isMissingUploadReference(item, 'avatar')) || - false - ) -} - -export const writeSeedScript = async (targetPath: string, manifests: ComponentManifest[]) => { - const layout = manifests.map((manifest) => manifest.sampleContent) - const needsSmokeMedia = manifests.some((manifest) => - sampleContentNeedsSmokeMedia(manifest.sampleContent), - ) - const scriptPath = path.join(targetPath, '.payload-components', 'smoke-seed.ts') - - await mkdir(path.dirname(scriptPath), { - recursive: true, - }) - - await writeFile( - scriptPath, - `import { mkdir, writeFile } from 'node:fs/promises' -import path from 'node:path' - -import { getPayload } from 'payload' - -const { default: config } = await import('../src/payload.config') - -type SmokeSampleItem = Record -type SmokeSampleBlock = SmokeSampleItem & { - avatars?: SmokeSampleItem[] - integrations?: SmokeSampleItem[] - logos?: SmokeSampleItem[] -} - -const rawLayout = ${JSON.stringify(layout, null, 2)} satisfies SmokeSampleBlock[] -const needsSmokeMedia = ${JSON.stringify(needsSmokeMedia)} - -const addUploadReference = (items: SmokeSampleItem[] | undefined, fieldName: string, mediaID: unknown) => - items?.map((item) => ({ - ...item, - [fieldName]: item[fieldName] ?? mediaID, - })) - -const addSmokeUploadReferences = (block: SmokeSampleBlock, mediaID: unknown): SmokeSampleBlock => ({ - ...block, - ...(block.avatars ? { avatars: addUploadReference(block.avatars, 'avatar', mediaID) } : {}), - ...(block.integrations ? { integrations: addUploadReference(block.integrations, 'logo', mediaID) } : {}), - ...(block.logos ? { logos: addUploadReference(block.logos, 'logo', mediaID) } : {}), -}) - -const createSmokeMedia = async () => { - const mediaPath = path.join(process.cwd(), '.payload-components', 'smoke-placeholder.svg') - - await mkdir(path.dirname(mediaPath), { recursive: true }) - await writeFile( - mediaPath, - '', - ) - - return payload.create({ - collection: 'media', - data: { - alt: 'Payload Components smoke placeholder', - }, - filePath: mediaPath, - overrideAccess: true, - }) -} - -const payload = await getPayload({ config }) -const slug = 'payload-components-smoke' -const smokeMedia = needsSmokeMedia ? await createSmokeMedia() : undefined -const layout = smokeMedia - ? rawLayout.map((block) => addSmokeUploadReferences(block, smokeMedia.id)) - : rawLayout - -await payload.delete({ - collection: 'pages', - context: { - disableRevalidate: true, - }, - overrideAccess: true, - where: { - slug: { - equals: slug, - }, - }, -}).catch(() => undefined) - -await payload.create({ - collection: 'pages', - context: { - disableRevalidate: true, - }, - data: { - title: 'Payload Component Smoke', - slug, - layout, - _status: 'published', - }, - overrideAccess: true, -}) - -console.log('Seeded /payload-components-smoke') -`, - ) - - return scriptPath -} - const waitForRoute = async (routeUrl: string, timeoutMs: number) => { const deadline = Date.now() + timeoutMs let lastError: unknown