create-meith 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-meith",
3
- "version": "0.21.2",
4
- "description": "Scaffold a Meith board — npx create-meith <name> writes a workspace that depends on @meith/web and @meith/cli.",
3
+ "version": "0.22.0",
4
+ "description": "Scaffold a Meith board, plugin or theme — npx create-meith <name> writes a deployable board workspace; --plugin and --theme write extension workspaces built on the published kits.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/bin.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from './cli'
3
3
 
4
- const result = await run(process.argv.slice(2), '0.21.2')
4
+ const result = await run(process.argv.slice(2), '0.22.0')
5
5
  for (const line of result.lines) {
6
6
  if (result.code === 0) console.log(line)
7
7
  else console.error(line)
package/src/cli.ts CHANGED
@@ -4,6 +4,12 @@ import { dirname, join, resolve } from 'node:path'
4
4
  import { promisify } from 'node:util'
5
5
 
6
6
  import { DEFAULT_REPOSITORY_URL, nextSteps, scaffold, validateName } from './scaffold'
7
+ import {
8
+ type ExtensionKind,
9
+ extensionNextSteps,
10
+ scaffoldExtension,
11
+ validateExtensionName,
12
+ } from './scaffold-extension'
7
13
 
8
14
  const execFileAsync = promisify(execFile)
9
15
 
@@ -46,21 +52,37 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
46
52
  return {
47
53
  code: 0,
48
54
  lines: [
49
- 'create-meith — scaffold a forum project.',
55
+ 'create-meith — scaffold a forum project, a plugin or a theme.',
50
56
  '',
51
57
  ' npx create-meith <name> [--repo <url>] [--no-git]',
58
+ ' npx create-meith --plugin <name> [--repo <url>] [--no-git]',
59
+ ' npx create-meith --theme <name> [--repo <url>] [--no-git]',
52
60
  '',
53
- 'Writes package.json, community.config.ts, .env.example, .gitignore and',
54
- 'README.md into ./<name>, then tells you what to run.',
61
+ 'The first form writes a deployable board into ./<name>, then tells you',
62
+ 'what to run. --plugin and --theme write an extension workspace instead:',
63
+ 'source and a passing test copied from the meith repository’s worked',
64
+ 'examples, plus a README and a marketplace listing.json.',
55
65
  '',
56
66
  '--no-git skips initializing a git repository in the new directory.',
57
67
  ],
58
68
  }
59
69
  }
60
70
 
61
- const invalid = validateName(name)
71
+ const wantsPlugin = argv.includes('--plugin')
72
+ const wantsTheme = argv.includes('--theme')
73
+ if (wantsPlugin && wantsTheme) {
74
+ return {
75
+ code: 1,
76
+ lines: ['create-meith: --plugin and --theme are two different scaffolds — pass one.'],
77
+ }
78
+ }
79
+ const kind: ExtensionKind | null = wantsPlugin ? 'plugin' : wantsTheme ? 'theme' : null
80
+ const usage =
81
+ kind === null ? 'Usage: npx create-meith <name>' : `Usage: npx create-meith --${kind} <name>`
82
+
83
+ const invalid = kind === null ? validateName(name) : validateExtensionName(name)
62
84
  if (invalid !== null) {
63
- return { code: 1, lines: [`create-meith: ${invalid}`, '', 'Usage: npx create-meith <name>'] }
85
+ return { code: 1, lines: [`create-meith: ${invalid}`, '', usage] }
64
86
  }
65
87
 
66
88
  const repoIndex = argv.indexOf('--repo')
@@ -78,7 +100,14 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
78
100
  }
79
101
  }
80
102
 
81
- const files = scaffold({ name, version, repositoryUrl })
103
+ const files =
104
+ kind === null
105
+ ? scaffold({ name, version, repositoryUrl })
106
+ : scaffoldExtension(kind, {
107
+ name,
108
+ version,
109
+ repositoryUrl: repoIndex === -1 ? undefined : repositoryUrl,
110
+ })
82
111
  for (const [relative, contents] of files) {
83
112
  const path = join(target, relative)
84
113
  await mkdir(dirname(path), { recursive: true })
@@ -92,7 +121,7 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
92
121
  lines: [
93
122
  `Created ${name} — ${files.size} files.`,
94
123
  '',
95
- ...nextSteps(name).map((step) => ` ${step}`),
124
+ ...(kind === null ? nextSteps(name) : extensionNextSteps(name)).map((step) => ` ${step}`),
96
125
  '',
97
126
  ...(gitReady
98
127
  ? [
@@ -112,9 +141,16 @@ export async function run(argv: readonly string[], version: string): Promise<Cli
112
141
  ' git push -u origin main',
113
142
  ]),
114
143
  '',
115
- 'Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.',
116
- 'Something must run the tick every minute — the worker process, or',
117
- '`community task:run`. Without it nothing catches up, and nothing errors.',
144
+ ...(kind === null
145
+ ? [
146
+ 'Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.',
147
+ 'Something must run the tick every minute — the worker process, or',
148
+ '`community task:run`. Without it nothing catches up, and nothing errors.',
149
+ ]
150
+ : [
151
+ `Then follow README.md — it walks through running the ${kind} inside a`,
152
+ 'scaffolded board and submitting it to the meith.dev marketplace.',
153
+ ]),
118
154
  ],
119
155
  }
120
156
  }
@@ -0,0 +1,43 @@
1
+ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: template contents are emitted into scaffolded files verbatim, never interpolated here
2
+ import type { ExtensionTemplate } from './scaffold-extension'
3
+
4
+ export const PLUGIN_TEMPLATES: readonly ExtensionTemplate[] = [
5
+ {
6
+ path: 'src/plugin.tsx',
7
+ contents:
8
+ "import { definePlugin } from '@meith/plugin-kit'\n\nexport const __MEITH_EXTENSION_CAMEL__Plugin = definePlugin({\n key: '__MEITH_EXTENSION_KEY__',\n name: '__MEITH_EXTENSION_TITLE__',\n version: '0.1.0',\n description:\n 'A plugin scaffolded by create-meith: a footer link, a greeting ' +\n 'on the board index, one setting, one migration, one task and an admin page.',\n apiVersion: '0',\n\n settings: [\n {\n key: 'greeting',\n label: 'Greeting',\n default: 'A greeting from the __MEITH_EXTENSION_TITLE__ plugin!',\n description: 'Shown on the status page under Admin → Plugins → __MEITH_EXTENSION_TITLE__.',\n },\n ],\n\n migrations: [\n {\n id: '0001_create_wave_table',\n statements: [\n `create table if not exists plugin___MEITH_EXTENSION_SNAKE___wave (\n id integer generated by default as identity primary key,\n waved_at timestamptz not null default now()\n )`,\n ],\n },\n ],\n\n tasks: [\n {\n id: 'wave',\n intervalSeconds: 3600,\n run: (context) => {\n context.logger.info('__MEITH_EXTENSION_KEY__ plugin: waving', {\n greeting: context.settings.greeting,\n })\n },\n },\n ],\n\n adminPages: [\n {\n path: 'status',\n title: '__MEITH_EXTENSION_TITLE__ plugin',\n render: (context) => (\n <div className=\"flex flex-col gap-2 text-sm\">\n <p>{String(context.settings.greeting)}</p>\n <p className=\"text-muted-foreground\">\n This page is rendered by the plugin itself. Edit the greeting on the plugin&rsquo;s\n settings screen and reload to see the resolved value arrive in{' '}\n <code className=\"text-xs\">context.settings</code>.\n </p>\n </div>\n ),\n },\n ],\n\n contributions: [\n {\n region: 'index.footer',\n render: (context) => (\n <p className=\"text-xs text-muted-foreground\" data-plugin=\"__MEITH_EXTENSION_KEY__\">\n {context.viewer.isGuest\n ? 'Greetings, guest — this line comes from the __MEITH_EXTENSION_TITLE__ plugin.'\n : 'Greetings, member — this line comes from the __MEITH_EXTENSION_TITLE__ plugin.'}\n </p>\n ),\n },\n ],\n\n hooks: {\n 'view.footer': (footer) => ({\n ...footer,\n links: [\n ...footer.links,\n {\n label: '__MEITH_EXTENSION_TITLE__ plugin',\n href: '__MEITH_EXTENSION_REPOSITORY__',\n },\n ],\n }),\n\n 'post.created': () => {},\n },\n})\n",
9
+ },
10
+ {
11
+ path: 'src/plugin.test.ts',
12
+ contents:
13
+ "import { describe, expect, it } from 'vitest'\n\nimport { type FilterHandler, unavailableHookRuntime } from '@meith/plugin-kit'\n\nimport { __MEITH_EXTENSION_CAMEL__Plugin } from './plugin'\n\ndescribe('the __MEITH_EXTENSION_KEY__ plugin', () => {\n it('has a validated manifest (definePlugin threw at import time otherwise)', () => {\n expect(__MEITH_EXTENSION_CAMEL__Plugin.key).toBe('__MEITH_EXTENSION_KEY__')\n expect(__MEITH_EXTENSION_CAMEL__Plugin.version).toBe('0.1.0')\n })\n\n it('appends its footer link without disturbing the board’s own', () => {\n const filter = __MEITH_EXTENSION_CAMEL__Plugin.hooks?.['view.footer'] as FilterHandler<'view.footer'>\n const footer = {\n boardTitle: 'A board',\n links: [{ label: 'Contact', href: '/contact' }],\n timezoneLabel: 'Europe/Dublin',\n }\n\n const filtered = filter(\n footer,\n { userId: null, isGuest: true, requestId: null },\n unavailableHookRuntime('this test drives the filter directly'),\n )\n\n expect(filtered).toMatchObject({\n boardTitle: 'A board',\n links: [\n { label: 'Contact', href: '/contact' },\n { label: '__MEITH_EXTENSION_TITLE__ plugin', href: expect.stringContaining('__MEITH_EXTENSION_REPOSITORY__') },\n ],\n })\n expect(footer.links).toHaveLength(1)\n })\n})\n",
14
+ },
15
+ ]
16
+
17
+ export const THEME_TEMPLATES: readonly ExtensionTemplate[] = [
18
+ {
19
+ path: 'src/index.ts',
20
+ contents:
21
+ "export { __MEITH_EXTENSION_CAMEL__Theme } from './theme'\nexport { BROWSER_THEME_COLOR, DARK_TOKENS, LIGHT_TOKENS } from './tokens'\n",
22
+ },
23
+ {
24
+ path: 'src/theme.ts',
25
+ contents:
26
+ "import { defaultTheme } from '@meith/theme-default'\nimport { defineTheme } from '@meith/theme-kit'\n\nimport { Footer } from './slots/footer'\n\nexport const __MEITH_EXTENSION_CAMEL__Theme = defineTheme({\n key: '__MEITH_EXTENSION_KEY__',\n title: '__MEITH_EXTENSION_TITLE__',\n extends: defaultTheme,\n slots: { Footer },\n})\n",
27
+ },
28
+ {
29
+ path: 'src/tokens.ts',
30
+ contents:
31
+ "import { DARK_TOKENS as DEFAULT_DARK, LIGHT_TOKENS as DEFAULT_LIGHT } from '@meith/theme-default'\n\nexport const LIGHT_TOKENS: Record<string, string> = {\n ...DEFAULT_LIGHT,\n primary: 'oklch(0.49 0.19 300)',\n 'primary-hover': 'oklch(0.42 0.17 300)',\n ring: 'oklch(0.49 0.19 300)',\n}\n\nexport const DARK_TOKENS: Record<string, string> = {\n ...DEFAULT_DARK,\n primary: 'oklch(0.78 0.12 300)',\n 'primary-foreground': 'oklch(0.18 0.03 300)',\n 'primary-hover': 'oklch(0.84 0.11 300)',\n ring: 'oklch(0.78 0.12 300)',\n}\n\nexport { BROWSER_THEME_COLOR } from '@meith/theme-default'\n",
32
+ },
33
+ {
34
+ path: 'src/slots/footer.tsx',
35
+ contents:
36
+ 'import type { FooterModel } from \'@meith/theme-kit\'\n\nexport function Footer({ boardTitle, links, timezoneLabel, poweredBy }: FooterModel) {\n return (\n <footer className="mt-auto border-t-2 border-primary bg-card">\n <div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-2 px-4 py-6 text-center text-xs text-muted-foreground">\n <span className="font-semibold uppercase tracking-widest text-foreground">\n {boardTitle}\n </span>\n\n {links.length > 0 && (\n <nav aria-label="Footer" className="flex flex-wrap justify-center gap-x-4 gap-y-1">\n {links.map((link) => (\n <a\n key={link.href}\n href={link.href}\n className="underline decoration-primary underline-offset-2 hover:text-foreground"\n >\n {link.label}\n </a>\n ))}\n </nav>\n )}\n\n <span>\n Times are shown in {timezoneLabel}\n {poweredBy && (\n <>\n {\' — \'}\n <a\n href={poweredBy.href}\n className="underline decoration-primary underline-offset-2 hover:text-foreground"\n >\n {poweredBy.label}\n </a>\n </>\n )}\n </span>\n </div>\n </footer>\n )\n}\n',
37
+ },
38
+ {
39
+ path: 'src/theme.test.ts',
40
+ contents:
41
+ "import { describe, expect, it } from 'vitest'\n\nimport { defaultTheme, TOKEN_NAMES } from '@meith/theme-default'\nimport { assertThemeContract, resolveTheme } from '@meith/theme-kit'\n\nimport { __MEITH_EXTENSION_CAMEL__Theme } from './theme'\nimport { DARK_TOKENS, LIGHT_TOKENS } from './tokens'\n\ndescribe('the __MEITH_EXTENSION_KEY__ theme', () => {\n it('satisfies the theme-kit contract', () => {\n expect(assertThemeContract(resolveTheme(__MEITH_EXTENSION_CAMEL__Theme)).missing).toEqual([])\n })\n\n it('inherits from the default theme rather than copying it', () => {\n expect(resolveTheme(__MEITH_EXTENSION_CAMEL__Theme).chain).toEqual(['__MEITH_EXTENSION_KEY__', 'default'])\n })\n\n it('overrides exactly one slot: the footer', () => {\n expect(Object.keys(__MEITH_EXTENSION_CAMEL__Theme.slots)).toEqual(['Footer'])\n expect(__MEITH_EXTENSION_CAMEL__Theme.slots.Footer).not.toBe(defaultTheme.slots.Footer)\n })\n\n it('declares a value for every token the theme layer names', () => {\n for (const name of TOKEN_NAMES) {\n expect(LIGHT_TOKENS[name], `light ${name}`).toBeDefined()\n expect(DARK_TOKENS[name], `dark ${name}`).toBeDefined()\n }\n })\n\n it('recolours the brand group and nothing greyscale', () => {\n expect(LIGHT_TOKENS.primary).not.toBe(DARK_TOKENS.primary)\n expect(LIGHT_TOKENS.background).toBe('oklch(0.968 0 0)')\n expect(DARK_TOKENS.background).toBe('oklch(0.15 0 0)')\n })\n})\n",
42
+ },
43
+ ]
package/src/index.ts CHANGED
@@ -14,3 +14,15 @@ export {
14
14
  VERCEL_PROMPTED_ENV,
15
15
  validateName,
16
16
  } from './scaffold'
17
+ export {
18
+ EXTENSION_KEY_PATTERN,
19
+ type ExtensionKind,
20
+ type ExtensionScaffoldOptions,
21
+ type ExtensionTemplate,
22
+ extensionNextSteps,
23
+ meithRange,
24
+ scaffoldExtension,
25
+ scaffoldPlugin,
26
+ scaffoldTheme,
27
+ validateExtensionName,
28
+ } from './scaffold-extension'
@@ -0,0 +1,372 @@
1
+ import { PLUGIN_TEMPLATES, THEME_TEMPLATES } from './extension-templates'
2
+
3
+ export type ExtensionKind = 'plugin' | 'theme'
4
+
5
+ export interface ExtensionTemplate {
6
+ readonly path: string
7
+ readonly contents: string
8
+ }
9
+
10
+ export interface ExtensionScaffoldOptions {
11
+ readonly name: string
12
+ readonly version: string
13
+ readonly repositoryUrl?: string | undefined
14
+ }
15
+
16
+ export const EXTENSION_KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
17
+
18
+ export function validateExtensionName(name: string): string | null {
19
+ if (name === '') return 'An extension name is required.'
20
+ if (!EXTENSION_KEY_PATTERN.test(name)) {
21
+ return (
22
+ 'Use lower-case letters, digits and hyphens, starting with a letter, 2 to 40 characters — ' +
23
+ 'the name becomes the definePlugin/defineTheme key and the npm package name.'
24
+ )
25
+ }
26
+ return null
27
+ }
28
+
29
+ export function extensionTitle(name: string): string {
30
+ return name
31
+ .split('-')
32
+ .filter((word) => word !== '')
33
+ .map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
34
+ .join(' ')
35
+ }
36
+
37
+ export function extensionCamel(name: string): string {
38
+ const words = name.split('-').filter((word) => word !== '')
39
+ const [head, ...rest] = words
40
+ return `${head ?? ''}${rest
41
+ .map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
42
+ .join('')}`
43
+ }
44
+
45
+ export function extensionSnake(name: string): string {
46
+ return name.replace(/-/g, '_')
47
+ }
48
+
49
+ export function defaultExtensionRepositoryUrl(name: string): string {
50
+ return `https://github.com/your-name/${name}`
51
+ }
52
+
53
+ export function meithRange(version: string): string {
54
+ const [major = '0', minor = '0'] = version.split('.')
55
+ return `>=${major}.${minor} <${Number(major) + 1}`
56
+ }
57
+
58
+ function substitute(
59
+ contents: string,
60
+ values: { key: string; title: string; camel: string; snake: string; repositoryUrl: string },
61
+ ): string {
62
+ return contents
63
+ .replaceAll('__MEITH_EXTENSION_CAMEL__', values.camel)
64
+ .replaceAll('__MEITH_EXTENSION_SNAKE__', values.snake)
65
+ .replaceAll('__MEITH_EXTENSION_REPOSITORY__', values.repositoryUrl)
66
+ .replaceAll('__MEITH_EXTENSION_KEY__', values.key)
67
+ .replaceAll('__MEITH_EXTENSION_TITLE__', values.title)
68
+ }
69
+
70
+ function extensionManifest(
71
+ options: ExtensionScaffoldOptions,
72
+ repositoryUrl: string,
73
+ description: string,
74
+ dependencies: Record<string, string>,
75
+ ): string {
76
+ return `${JSON.stringify(
77
+ {
78
+ name: options.name,
79
+ version: '0.1.0',
80
+ description,
81
+ license: 'MIT',
82
+ repository: { type: 'git', url: repositoryUrl },
83
+ type: 'module',
84
+ main: './src/index.ts',
85
+ types: './src/index.ts',
86
+ files: ['src', '!src/**/*.test.*'],
87
+ scripts: { test: 'vitest run', typecheck: 'tsc --noEmit' },
88
+ dependencies,
89
+ peerDependencies: { react: '^19.2.0' },
90
+ devDependencies: {
91
+ '@types/node': '^26.2.0',
92
+ '@types/react': '^19.2.18',
93
+ react: '^19.2.0',
94
+ typescript: '^7.0.2',
95
+ vitest: '^4.1.10',
96
+ },
97
+ publishConfig: { access: 'public' },
98
+ },
99
+ null,
100
+ 2,
101
+ )}\n`
102
+ }
103
+
104
+ function extensionTsconfig(): string {
105
+ return `${JSON.stringify(
106
+ {
107
+ compilerOptions: {
108
+ target: 'es2022',
109
+ lib: ['es2022', 'dom'],
110
+ module: 'esnext',
111
+ moduleResolution: 'bundler',
112
+ jsx: 'react-jsx',
113
+ strict: true,
114
+ resolveJsonModule: true,
115
+ skipLibCheck: true,
116
+ noEmit: true,
117
+ },
118
+ include: ['src'],
119
+ },
120
+ null,
121
+ 2,
122
+ )}\n`
123
+ }
124
+
125
+ function extensionVitestConfig(): string {
126
+ return `import { defineConfig } from 'vitest/config'
127
+
128
+ export default defineConfig({
129
+ test: { environment: 'node' },
130
+ })
131
+ `
132
+ }
133
+
134
+ function extensionGitignore(): string {
135
+ return `node_modules
136
+ *.log
137
+ .DS_Store
138
+ `
139
+ }
140
+
141
+ function listing(
142
+ options: ExtensionScaffoldOptions,
143
+ kind: ExtensionKind,
144
+ repositoryUrl: string,
145
+ description: string,
146
+ ): string {
147
+ return `${JSON.stringify(
148
+ {
149
+ key: options.name,
150
+ kind,
151
+ package: options.name,
152
+ name: extensionTitle(options.name),
153
+ description,
154
+ screenshots: [`${options.name}-light.png`],
155
+ version: '0.1.0',
156
+ apiVersion: 0,
157
+ meith: meithRange(options.version),
158
+ repository: repositoryUrl,
159
+ licence: 'MIT',
160
+ },
161
+ null,
162
+ 2,
163
+ )}\n`
164
+ }
165
+
166
+ function pluginReadme(options: ExtensionScaffoldOptions, camel: string, title: string): string {
167
+ const { name } = options
168
+ return `# ${title}
169
+
170
+ A Meith plugin, scaffolded by \`create-meith\` from the meith repository's
171
+ \`examples/hello-plugin\`: a footer link, a greeting on the board index, one
172
+ setting, one migration, one task and an admin page — every extension point
173
+ exercised once, each easy to delete.
174
+
175
+ ## Develop
176
+
177
+ npm install
178
+ npm test
179
+
180
+ \`src/plugin.tsx\` is the plugin. What a plugin may and may not do is
181
+ documented in the meith repository under \`docs/customization/plugins.md\`;
182
+ every hook and payload is listed in \`docs/reference/plugin-hooks.md\`.
183
+
184
+ ## Run it inside a board
185
+
186
+ Scaffold a board next to this directory if you do not have one
187
+ (\`npx create-meith my-board\`), then install this workspace into it by path:
188
+
189
+ cd ../my-board
190
+ npm install ../${name}
191
+
192
+ npm installs a local directory as a symlink, so edits here are picked up by
193
+ the board's next build without reinstalling.
194
+
195
+ Register the plugin in the board's \`community.plugins.ts\` — the comment at
196
+ the top of that file shows the shape:
197
+
198
+ import { messages as ${camel}Messages, plugin as ${camel}Plugin } from '${name}'
199
+
200
+ export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [
201
+ { key: '${name}', enabled: true, plugin: ${camel}Plugin, messages: ${camel}Messages },
202
+ ]
203
+
204
+ and add the matching entry to \`board.plugins.json\`:
205
+
206
+ { "plugins": [{ "key": "${name}", "package": "${name}", "enabled": true }] }
207
+
208
+ Rebuild the board (\`npm run build\`) and, because this plugin ships a
209
+ migration, run \`npx community migrate\`. The plugin then appears under
210
+ **Admin → Plugins**.
211
+
212
+ ## Publish and list it
213
+
214
+ \`npm publish\` ships \`src/\` as TypeScript source, the way every
215
+ \`@meith/*\` package ships. To offer the plugin on the meith.dev marketplace,
216
+ finish \`listing.json\` (its \`repository\` field starts as a placeholder),
217
+ add the screenshot it names, and open a pull request against the meith
218
+ repository — the submission process and the review bar are documented there
219
+ in \`docs/customization/marketplace.md\`.
220
+ `
221
+ }
222
+
223
+ function themeReadme(options: ExtensionScaffoldOptions, camel: string, title: string): string {
224
+ const { name } = options
225
+ return `# ${title}
226
+
227
+ A Meith theme, scaffolded by \`create-meith\` from the meith repository's
228
+ \`examples/iris-theme\`: the default theme recoloured (one brand group of
229
+ tokens) plus a single slot override, the footer.
230
+
231
+ ## Develop
232
+
233
+ npm install
234
+ npm test
235
+
236
+ \`src/theme.ts\` declares the theme, \`src/tokens.ts\` carries the palette,
237
+ and slots live in \`src/slots/\`. What a theme may and may not do is
238
+ documented in the meith repository under \`docs/customization/themes.md\`;
239
+ every slot and view model is listed in \`docs/reference/theme-slots.md\`.
240
+
241
+ ## Run it inside a board
242
+
243
+ Scaffold a board next to this directory if you do not have one
244
+ (\`npx create-meith my-board\`), then install this workspace into it by path:
245
+
246
+ cd ../my-board
247
+ npm install ../${name}
248
+
249
+ npm installs a local directory as a symlink, so edits here are picked up by
250
+ the board's next build without reinstalling.
251
+
252
+ Register the theme in the board's \`community.config.ts\`, beside the
253
+ default entry:
254
+
255
+ import { defaultMessages } from '@meith/theme-default'
256
+ import { BROWSER_THEME_COLOR, DARK_TOKENS, LIGHT_TOKENS, ${camel}Theme } from '${name}'
257
+
258
+ themes: {
259
+ '${name}': {
260
+ key: '${name}',
261
+ title: '${title}',
262
+ tokens: { light: LIGHT_TOKENS, dark: DARK_TOKENS },
263
+ browserThemeColor: BROWSER_THEME_COLOR,
264
+ theme: ${camel}Theme,
265
+ messages: defaultMessages,
266
+ },
267
+ },
268
+
269
+ Rebuild the board (\`npm run build\`); the theme is then offered to members
270
+ on the appearance screen and to administrators under **Admin → Themes**.
271
+
272
+ ## Publish and list it
273
+
274
+ \`npm publish\` ships \`src/\` as TypeScript source, the way every
275
+ \`@meith/*\` package ships. To offer the theme on the meith.dev marketplace,
276
+ finish \`listing.json\` (its \`repository\` field starts as a placeholder),
277
+ add the screenshot it names, and open a pull request against the meith
278
+ repository — the submission process and the review bar are documented there
279
+ in \`docs/customization/marketplace.md\`.
280
+ `
281
+ }
282
+
283
+ export function scaffoldPlugin(options: ExtensionScaffoldOptions): ReadonlyMap<string, string> {
284
+ const key = options.name
285
+ const title = extensionTitle(key)
286
+ const camel = extensionCamel(key)
287
+ const snake = extensionSnake(key)
288
+ const repositoryUrl = options.repositoryUrl ?? defaultExtensionRepositoryUrl(key)
289
+ const description = `${title} — a Meith plugin.`
290
+
291
+ const files = new Map<string, string>()
292
+
293
+ files.set(
294
+ 'package.json',
295
+ extensionManifest(options, repositoryUrl, description, {
296
+ '@meith/plugin-kit': options.version,
297
+ }),
298
+ )
299
+ files.set('tsconfig.json', extensionTsconfig())
300
+ files.set('vitest.config.ts', extensionVitestConfig())
301
+ files.set('.gitignore', extensionGitignore())
302
+ files.set('README.md', pluginReadme(options, camel, title))
303
+ files.set('listing.json', listing(options, 'plugin', repositoryUrl, description))
304
+
305
+ files.set(
306
+ 'src/index.ts',
307
+ `export { ${camel}Plugin, ${camel}Plugin as plugin } from './plugin'
308
+ export { ${camel}Messages, ${camel}Messages as messages } from './messages'
309
+ `,
310
+ )
311
+ files.set(
312
+ 'src/messages/index.ts',
313
+ `import en from './en.json'
314
+
315
+ export const ${camel}Messages = { en }
316
+ `,
317
+ )
318
+ files.set('src/messages/en.json', '{}\n')
319
+
320
+ for (const template of PLUGIN_TEMPLATES) {
321
+ files.set(
322
+ template.path,
323
+ substitute(template.contents, { key, title, camel, snake, repositoryUrl }),
324
+ )
325
+ }
326
+
327
+ return files
328
+ }
329
+
330
+ export function scaffoldTheme(options: ExtensionScaffoldOptions): ReadonlyMap<string, string> {
331
+ const key = options.name
332
+ const title = extensionTitle(key)
333
+ const camel = extensionCamel(key)
334
+ const snake = extensionSnake(key)
335
+ const repositoryUrl = options.repositoryUrl ?? defaultExtensionRepositoryUrl(key)
336
+ const description = `${title} — a Meith theme.`
337
+
338
+ const files = new Map<string, string>()
339
+
340
+ files.set(
341
+ 'package.json',
342
+ extensionManifest(options, repositoryUrl, description, {
343
+ '@meith/theme-default': options.version,
344
+ '@meith/theme-kit': options.version,
345
+ }),
346
+ )
347
+ files.set('tsconfig.json', extensionTsconfig())
348
+ files.set('vitest.config.ts', extensionVitestConfig())
349
+ files.set('.gitignore', extensionGitignore())
350
+ files.set('README.md', themeReadme(options, camel, title))
351
+ files.set('listing.json', listing(options, 'theme', repositoryUrl, description))
352
+
353
+ for (const template of THEME_TEMPLATES) {
354
+ files.set(
355
+ template.path,
356
+ substitute(template.contents, { key, title, camel, snake, repositoryUrl }),
357
+ )
358
+ }
359
+
360
+ return files
361
+ }
362
+
363
+ export function scaffoldExtension(
364
+ kind: ExtensionKind,
365
+ options: ExtensionScaffoldOptions,
366
+ ): ReadonlyMap<string, string> {
367
+ return kind === 'plugin' ? scaffoldPlugin(options) : scaffoldTheme(options)
368
+ }
369
+
370
+ export function extensionNextSteps(name: string): readonly string[] {
371
+ return [`cd ${name}`, 'npm install', 'npm test']
372
+ }