diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e7511e2..2822b346e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -434,6 +434,24 @@ jobs: env: DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/board_workspace_smoke + extension-workspace: + name: Scaffolded extensions build against packed kits and load into a board + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 26 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Pack the kits, scaffold a plugin and a theme, prove them, install them into a board + run: pnpm extension:workspace:smoke + # MEI-77: the scaffold's own deploy kit — Dockerfile, compose.yml, # .github/workflows/build.yml — proves it actually builds and boots, not # just that its strings look right (scaffold.test.ts covers that half). diff --git a/apps/community/next.config.mjs b/apps/community/next.config.mjs index ade6ff580..aa6210c7a 100644 --- a/apps/community/next.config.mjs +++ b/apps/community/next.config.mjs @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -22,6 +22,8 @@ if (process.env.NODE_ENV !== 'production' && loadedEnvFiles.length > 0) { console.log(`- Environments: ${loadedEnvFiles.join(', ')} (${workspaceRoot})`) } +const FRAMEWORK_PACKAGES = ['next', 'react', 'react-dom'] + const nextConfig = { ...(process.env.VERCEL ? {} : { output: 'standalone' }), poweredByHeader: false, @@ -118,4 +120,15 @@ const nextConfig = { }, } +const boardManifestFile = path.join(workspaceRoot, 'package.json') +if (existsSync(boardManifestFile)) { + const boardManifest = JSON.parse(readFileSync(boardManifestFile, 'utf8')) + for (const name of Object.keys(boardManifest.dependencies ?? {})) { + if (FRAMEWORK_PACKAGES.includes(name)) continue + if (nextConfig.serverExternalPackages.includes(name)) continue + if (nextConfig.transpilePackages.includes(name)) continue + nextConfig.transpilePackages.push(name) + } +} + export default nextConfig diff --git a/docs/contributing/development.md b/docs/contributing/development.md index 6e7568e60..c039dbefc 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -291,6 +291,19 @@ serves neither its own script/style bundles nor its service worker fails the smoke rather than passing it (`scripts/board-smoke-assets.mts`, shared with `board-deploy-kit-smoke.mts` and `board-eject-smoke.mts`). +**`scripts/extension-workspace-smoke.mts`** (`pnpm extension:workspace:smoke`, +wired into CI as the `extension-workspace` job) is the same proof for the +extension scaffolds: it packs the `@meith/plugin-kit` and `@meith/theme-kit` +closures alongside the board closure, scaffolds a plugin and a theme with +`create-meith --plugin`/`--theme`, packs each the way `npm publish` would, +then installs, tests and typechecks both against the packed kits — not the +workspace aliases — and finally scaffolds a board, installs both extension +tarballs into it, registers them in `board.plugins.json`, +`community.plugins.ts` and `community.config.ts`, and runs `forum-web build` +in fixture mode. A kit whose `files` allowlist rotted, a scaffold that only +compiles against `workspace:*`, or an extension a real board cannot build +with fails here, before an author finds out. + Answering 200 is not the same as working, and two checks in that same file exist because a board did both while being unusable. The rendered `/` must not contain the theme's own message keys as text, which is what a board whose diff --git a/docs/customization/plugins.md b/docs/customization/plugins.md index 942f93c5d..48a67b541 100644 --- a/docs/customization/plugins.md +++ b/docs/customization/plugins.md @@ -42,6 +42,13 @@ export { greeter as plugin } from './definition' export { greeterMessages as messages } from './messages' ``` +A plugin ships TypeScript source, the way every `@meith/*` package does: +the board build compiles every dependency named in the board's own +`package.json` from source (they join Next's `transpilePackages`), so +there is no build step to ship and no compiled artifact to keep in sync. +`scripts/extension-workspace-smoke.mts` proves this path end to end +against a scaffolded plugin. + Installing one in this checkout is then `pnpm add`, `community plugin:add `, and a rebuild and redeploy. This repository carries two boards — `apps/community`, the in-repo dev target, and `boards/stock`, diff --git a/docs/customization/themes.md b/docs/customization/themes.md index 9f27777a1..9fdb4476b 100644 --- a/docs/customization/themes.md +++ b/docs/customization/themes.md @@ -28,7 +28,10 @@ export const acmeTheme = defineTheme({ ``` Register it in `community.config.ts`, and set `defaultTheme` to its key if it -should be the board's default. +should be the board's default. A theme ships TypeScript source like every +`@meith/*` package: a board build compiles every dependency in the board's +own `package.json` from source, so an installed theme needs no build step of +its own. Three shipped themes are worth reading before you write one: diff --git a/package.json b/package.json index c883d10dc..2ea7b2ed8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "extension:gen": "tsx scripts/extension-scaffold-gen.mts", "extension:gen:check": "tsx scripts/extension-scaffold-gen.mts --check", "board:workspace:smoke": "tsx scripts/board-workspace-smoke.mts", + "extension:workspace:smoke": "tsx scripts/extension-workspace-smoke.mts", "published:board:smoke": "tsx scripts/published-board-smoke.mts", "board:deploy-kit:smoke": "tsx scripts/board-deploy-kit-smoke.mts", "board:eject:smoke": "tsx scripts/board-eject-smoke.mts", diff --git a/scripts/extension-workspace-smoke.mts b/scripts/extension-workspace-smoke.mts new file mode 100644 index 000000000..6c9bde578 --- /dev/null +++ b/scripts/extension-workspace-smoke.mts @@ -0,0 +1,199 @@ +#!/usr/bin/env -S npx tsx +import { spawnSync } from 'node:child_process' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { packClosure } from './pack-workspace-closure.mts' +import { ROOT } from './workspace-packages.mjs' + +function run( + command: string, + args: readonly string[], + options: { cwd: string; env?: NodeJS.ProcessEnv }, +) { + console.log(`$ ${command} ${args.join(' ')} (in ${options.cwd})`) + const result = spawnSync(command, args, { + cwd: options.cwd, + stdio: 'inherit', + env: options.env ?? process.env, + }) + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited ${result.status ?? result.signal}`) + } +} + +const CLOSURE_ROOTS = [ + '@meith/web', + '@meith/cli', + '@meith/theme-default', + '@meith/plugin-kit', + '@meith/theme-kit', +] + +async function scaffold(parentDir: string, argv: readonly string[], name: string): Promise { + const { run: runCreateMeith } = await import(join(ROOT, 'packages/create-meith/src/cli.ts')) + const previousCwd = process.cwd() + process.chdir(parentDir) + try { + const result = await runCreateMeith(argv, '0.0.0-smoke') + if (result.code !== 0) { + throw new Error(`create-meith failed:\n${result.lines.join('\n')}`) + } + } finally { + process.chdir(previousCwd) + } + return join(parentDir, name) +} + +async function pointAtTarballs(dir: string, tarballs: ReadonlyMap) { + const packageJsonPath = join(dir, 'package.json') + const manifest = JSON.parse(await readFile(packageJsonPath, 'utf8')) + + const overrides: Record = {} + for (const [name, tarball] of tarballs) { + const fileSpecifier = `file:${tarball}` + if (name in (manifest.dependencies ?? {})) { + manifest.dependencies[name] = fileSpecifier + } + overrides[name] = fileSpecifier + } + manifest.overrides = overrides + + await writeFile(packageJsonPath, `${JSON.stringify(manifest, null, 2)}\n`) +} + +function replaceOnce(source: string, file: string, from: string, to: string): string { + if (!source.includes(from)) { + throw new Error( + `extension-workspace-smoke: ${file} does not contain the expected anchor:\n${from}\n` + + 'The scaffold changed shape — update this smoke to follow it.', + ) + } + return source.replace(from, to) +} + +async function editFile(dir: string, file: string, edit: (source: string) => string) { + const path = join(dir, file) + await writeFile(path, edit(await readFile(path, 'utf8')), 'utf8') +} + +async function registerPlugin(boardDir: string) { + await editFile( + boardDir, + 'board.plugins.json', + () => + `${JSON.stringify( + { plugins: [{ key: 'smoke-plugin', package: 'smoke-plugin', enabled: true }] }, + null, + 2, + )}\n`, + ) + await editFile(boardDir, 'community.plugins.ts', (source) => + replaceOnce( + source, + 'community.plugins.ts', + 'export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = []', + "import { messages as smokePluginMessages, plugin as smokePluginPlugin } from 'smoke-plugin'\n\n" + + 'export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [\n' + + " { key: 'smoke-plugin', enabled: true, plugin: smokePluginPlugin, messages: smokePluginMessages },\n" + + ']', + ), + ) +} + +async function registerTheme(boardDir: string) { + await editFile(boardDir, 'community.config.ts', (source) => { + const withImport = replaceOnce( + source, + 'community.config.ts', + "import { INSTALLED_PLUGINS } from './community.plugins'", + 'import {\n' + + ' BROWSER_THEME_COLOR as smokeThemeColor,\n' + + ' DARK_TOKENS as smokeThemeDark,\n' + + ' LIGHT_TOKENS as smokeThemeLight,\n' + + ' smokeThemeTheme,\n' + + "} from 'smoke-theme'\n\n" + + "import { INSTALLED_PLUGINS } from './community.plugins'", + ) + return replaceOnce( + withImport, + 'community.config.ts', + " },\n defaultTheme: 'default',", + ' ' + + "'smoke-theme': {\n" + + " key: 'smoke-theme',\n" + + " title: 'Smoke Theme',\n" + + ' tokens: { light: smokeThemeLight, dark: smokeThemeDark },\n' + + ' browserThemeColor: smokeThemeColor,\n' + + ' theme: smokeThemeTheme,\n' + + ' messages: defaultMessages,\n' + + ' },\n' + + " },\n defaultTheme: 'default',", + ) + }) +} + +async function packExtension(dir: string): Promise { + run('npm', ['pack'], { cwd: dir }) + const tarball = (await readdir(dir)).find((entry) => entry.endsWith('.tgz')) + if (tarball === undefined) { + throw new Error(`extension-workspace-smoke: npm pack left no tarball in ${dir}`) + } + return join(dir, tarball) +} + +async function main() { + const tarballDir = await mkdtemp(join(tmpdir(), 'extension-smoke-tarballs-')) + const parentDir = await mkdtemp(join(tmpdir(), 'extension-smoke-')) + + try { + console.log('== packing the workspace closure ==') + const tarballs = await packClosure(tarballDir, CLOSURE_ROOTS) + console.log(`packed ${tarballs.size} packages`) + + console.log('== scaffolding a plugin and a theme with create-meith ==') + const pluginDir = await scaffold(parentDir, ['--plugin', 'smoke-plugin'], 'smoke-plugin') + const themeDir = await scaffold(parentDir, ['--theme', 'smoke-theme'], 'smoke-theme') + + console.log('== packing both extensions the way npm publish would ==') + const pluginTarball = await packExtension(pluginDir) + const themeTarball = await packExtension(themeDir) + + for (const dir of [pluginDir, themeDir]) { + console.log(`== ${dir}: install, test and typecheck against the packed kits ==`) + await pointAtTarballs(dir, tarballs) + run('npm', ['install'], { cwd: dir }) + run('npm', ['test'], { cwd: dir }) + run('npm', ['run', 'typecheck'], { cwd: dir }) + } + + console.log('== scaffolding a board and installing both extensions into it ==') + const boardDir = await scaffold(parentDir, ['smoke-board'], 'smoke-board') + await pointAtTarballs(boardDir, tarballs) + run('npm', ['install'], { cwd: boardDir }) + run('npm', ['install', pluginTarball, themeTarball], { cwd: boardDir }) + + console.log('== registering the plugin and the theme ==') + await registerPlugin(boardDir) + await registerTheme(boardDir) + + console.log('== forum-web build (fixture mode) with both extensions registered ==') + run(join(boardDir, 'node_modules/.bin/forum-web'), ['build'], { + cwd: boardDir, + env: { ...process.env, DATABASE_URL: '', DATA_SOURCE: '' }, + }) + + console.log('✓ extension-workspace-smoke: scaffolded plugin and theme install, test and') + console.log(' typecheck against the packed kits, and a scaffolded board builds with both') + console.log(' installed from their own packed tarballs and registered.') + } catch (error) { + console.error(`✗ extension-workspace-smoke: ${error instanceof Error ? error.message : error}`) + process.exitCode = 1 + } finally { + await rm(tarballDir, { recursive: true, force: true }) + await rm(parentDir, { recursive: true, force: true }) + } +} + +await main()