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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 14 additions & 1 deletion apps/community/next.config.mjs
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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,
Expand Down Expand Up @@ -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
13 changes: 13 additions & 0 deletions docs/contributing/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package>`, and a rebuild and redeploy. This repository carries
two boards — `apps/community`, the in-repo dev target, and `boards/stock`,
Expand Down
5 changes: 4 additions & 1 deletion docs/customization/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
199 changes: 199 additions & 0 deletions scripts/extension-workspace-smoke.mts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, string>) {
const packageJsonPath = join(dir, 'package.json')
const manifest = JSON.parse(await readFile(packageJsonPath, 'utf8'))

const overrides: Record<string, string> = {}
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<string> {
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()