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/dist/bin.mjs CHANGED
@@ -99,8 +99,12 @@ var ENV_AUTH_SECRET_PROSE = `# Session and token signing. No default, deliberate
99
99
  var ENV_TICK_SECRET_PROSE = `# The shared secret the tick caller presents to GET /api/system/tick. Generate
100
100
  # it the same way. Without it the tick is unauthenticated, and the tick is how
101
101
  # bans expire and digests send.`;
102
- var ENV_DATA_SOURCE_PROSE = `# fixture = deterministic in-memory sample data, no database needed. This is
103
- # what \`npm run build\` uses, and what a checkout with no database falls back to.`;
102
+ var ENV_DATA_SOURCE_PROSE = `# Derived, and left commented out on purpose: with no DATABASE_URL the board
103
+ # serves \`fixture\` \u2014 deterministic in-memory sample data, no database needed,
104
+ # which is what \`npm run build\` uses and what \`npm run dev\` falls back to \u2014 and
105
+ # with one it serves \`postgres\`. Setting it to postgres while DATABASE_URL is
106
+ # still blank is refused at boot, so uncomment it only to override the
107
+ # derivation.`;
104
108
  var ENV_APP_URL_PROSE = `# Absolute, no trailing slash. Used in mail, feeds and canonical URLs \u2014 every
105
109
  # place a relative URL cannot work because there is no request to be relative to.
106
110
  #
@@ -144,7 +148,7 @@ TICK_SECRET=
144
148
  ${ENV_OPTIONAL_HEADING}
145
149
 
146
150
  ${ENV_DATA_SOURCE_PROSE}
147
- DATA_SOURCE=postgres
151
+ # DATA_SOURCE=postgres
148
152
 
149
153
  ${ENV_APP_URL_PROSE}
150
154
  APP_URL=
@@ -432,7 +436,7 @@ save-exact=true
432
436
  *
433
437
  * Adding a theme is: \`npm install\` it, add a line here, redeploy. Adding a
434
438
  * plugin is the same, through board.plugins.json and community.plugins.ts \u2014
435
- * see docs/plugin-api.md.
439
+ * see docs/customization/plugins.md.
436
440
  */
437
441
  import { defineForumConfig } from '@meith/web/config'
438
442
  import {
@@ -470,7 +474,7 @@ export default defineForumConfig({
470
474
  * The board's installed-plugin list.
471
475
  *
472
476
  * Inside the Meith monorepo this file is generated from board.plugins.json
473
- * by \`pnpm board:gen\` (see docs/plugin-api.md) \u2014 that generator is
477
+ * by \`pnpm board:gen\` (see docs/customization/plugins.md) \u2014 that generator is
474
478
  * repository tooling, not something this workspace carries, so this file
475
479
  * starts as a plain, valid file with the same shape instead. Add a plugin by
476
480
  * importing its \`plugin\`/\`messages\` exports and adding an entry:
@@ -517,7 +521,7 @@ export function installedPluginDefinitions() {
517
521
  #
518
522
  # FROM the published framework base image \u2014 deps + framework layers only,
519
523
  # locked to this exact release (see the meith repository's
520
- # docs/self-hosting.md, "Custom boards", and docker/Dockerfile.base for what
524
+ # docs/getting-started/deployment/docker-compose.md, "Custom boards", and docker/Dockerfile.base for what
521
525
  # it is and is not). This board's own Dockerfile only ever installs its own
522
526
  # delta on top of it \u2014 a new plugin's own dependency, typically nothing more
523
527
  # \u2014 which is what keeps a rebuild after \`npm install some-plugin\` a matter
@@ -527,12 +531,12 @@ export function installedPluginDefinitions() {
527
531
  # to Next's own standalone output. The migrate role below runs \`community
528
532
  # migrate\`, and \`community\` materializes @meith/cli's sources and runs them
529
533
  # with tsx at the moment it runs (see the meith repository's
530
- # docs/development.md, "Consuming the board from a workspace") \u2014 it needs
534
+ # docs/contributing/development.md, "Consuming the board from a workspace") \u2014 it needs
531
535
  # the full, un-pruned node_modules tree this board installed, not what Next
532
536
  # traced as reachable from the web server alone. The tick itself is driven
533
537
  # by docker-compose.yml's own \`worker\` service \u2014 a lightweight loop against
534
538
  # /api/system/tick, not a compiled worker process, because @meith/worker is
535
- # not published (see the meith repository's docs/release.md).
539
+ # not published (see the meith repository's docs/contributing/release.md).
536
540
  ARG MEITH_VERSION
537
541
  FROM ghcr.io/meith-dev/meith-base:\${MEITH_VERSION} AS deps
538
542
  WORKDIR /board
@@ -557,7 +561,7 @@ ENV NODE_ENV=production
557
561
  # persists into every container started from this image afterward, and this
558
562
  # Dockerfile has no later stage to reset it in (see "Two stages, not three"
559
563
  # above). The build needs neither a database nor a production secret (see
560
- # the meith repository's docs/development.md, "Fixture mode"), but baking
564
+ # the meith repository's docs/contributing/development.md, "Fixture mode"), but baking
561
565
  # DATA_SOURCE=fixture into the image itself would silently force fixture
562
566
  # mode \u2014 and with it the in-memory queue driver \u2014 at runtime too, no matter
563
567
  # what DATABASE_URL an operator supplies to \`docker run\`.
@@ -794,9 +798,9 @@ services:
794
798
  condition: service_completed_successfully
795
799
 
796
800
  # @meith/worker is not published (see the meith repository's
797
- # docs/release.md), so there is no compiled worker binary a scaffolded
801
+ # docs/contributing/release.md), so there is no compiled worker binary a scaffolded
798
802
  # board can run \u2014 this drives the tick the alternative way the meith
799
- # repository documents in docs/self-hosting.md, "Running the tick without
803
+ # repository documents in docs/getting-started/deployment/docker-compose.md, "Running the tick without
800
804
  # a second set of credentials": a small loop calling /api/system/tick.
801
805
  worker:
802
806
  image: alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
@@ -866,7 +870,7 @@ one value only you know:
866
870
 
867
871
  3. **Deploy, then \`/install\` on your own domain.** Coolify issues the
868
872
  certificate; the installer from there is the one
869
- [docs/quickstart.md](${repositoryUrl}/blob/main/docs/quickstart.md#4-run-the-installer)
873
+ [docs/getting-started/deployment/coolify.md](${repositoryUrl}/blob/main/docs/getting-started/deployment/coolify.md#4-run-the-installer)
870
874
  walks through, screen for screen. It seals itself when it finishes, and
871
875
  \`/install\` answers 404 from then on \u2014 run it **against the database you
872
876
  are going to keep**. Every push to \`main\` after this rebuilds the
@@ -884,7 +888,7 @@ rather not use GitHub Actions for the build \u2014 push the result wherever
884
888
  docker build --build-arg MEITH_VERSION=$(node -p "require('./package.json').dependencies['@meith/web']") -t ${name} .
885
889
  \`\`\`
886
890
 
887
- **Without a panel**: [docs/self-hosting.md](${repositoryUrl}/blob/main/docs/self-hosting.md)
891
+ **Without a panel**: [docs/getting-started/deployment/docker-compose.md](${repositoryUrl}/blob/main/docs/getting-started/deployment/docker-compose.md)
888
892
  is the same four containers by hand \u2014 your own \`.env\`, a reverse proxy you
889
893
  already run, no Coolify. \`Dockerfile\` and \`docker-compose.yml\` here are this
890
894
  board's own version of exactly that shape.
@@ -904,12 +908,15 @@ Two things nothing configures for you:
904
908
 
905
909
  \`\`\`sh
906
910
  npm install
907
- cp .env.example .env.local
908
911
  npm run dev
909
912
  \`\`\`
910
913
 
911
- With no \`DATABASE_URL\`, the board runs on deterministic in-memory sample data \u2014
912
- enough to click through every reading surface. Posting needs a database:
914
+ No environment file, no database: with no \`DATABASE_URL\` the board serves
915
+ deterministic in-memory sample data, which is enough to click through every
916
+ reading surface.
917
+
918
+ Posting needs Postgres. Copy \`.env.example\` to \`.env.local\`, set
919
+ \`DATABASE_URL\` and the two secrets in it, then:
913
920
 
914
921
  \`\`\`sh
915
922
  npm run community -- migrate
@@ -957,7 +964,7 @@ project's own \`.npmrc\` sets \`save-exact=true\` for the same reason, so an
957
964
  build workflow also refuses to build from anything but an exact version, as
958
965
  a second line of defense. Once the rebuilt image is deployed, run
959
966
  \`npm run community -- upgrade\` against it for the plugin migrations \u2014 see
960
- [the operator CLI](${repositoryUrl}/blob/main/docs/operating.md#the-operator-cli)
967
+ [the operator CLI](${repositoryUrl}/blob/main/docs/guides/operations/operating.md#the-operator-cli)
961
968
  for running it against this deployment.
962
969
 
963
970
  Migrations are forward-only. Recovery is by restore, so take a backup first \u2014
@@ -1130,7 +1137,7 @@ the board's name and address and for the first administrator's username, email
1130
1137
  and password, creates the board and that account, and then **seals itself**:
1131
1138
  \`/install\` answers 404 from then on. Run it against the database you intend to
1132
1139
  keep \u2014 the screens are the ones
1133
- [docs/quickstart.md](${repositoryUrl}/blob/main/docs/quickstart.md#4-run-the-installer)
1140
+ [docs/getting-started/deployment/docker-compose.md](${repositoryUrl}/blob/main/docs/getting-started/deployment/docker-compose.md#6-install-it)
1134
1141
  walks through.
1135
1142
 
1136
1143
  ## The tick
@@ -1246,16 +1253,486 @@ one.
1246
1253
  ## Somewhere other than Vercel
1247
1254
 
1248
1255
  Everything above is one deployment shape.
1249
- [docs/self-hosting.md](${repositoryUrl}/blob/main/docs/self-hosting.md) is the
1256
+ [docs/getting-started/deployment/docker-compose.md](${repositoryUrl}/blob/main/docs/getting-started/deployment/docker-compose.md) is the
1250
1257
  same board as containers you run yourself, and \`npx create-meith <name>\`
1251
1258
  scaffolds that shape instead \u2014 a Dockerfile, a compose file and a workflow that
1252
- builds the image. [docs/scaling.md](${repositoryUrl}/blob/main/docs/scaling.md)
1259
+ builds the image. [docs/guides/operations/scaling.md](${repositoryUrl}/blob/main/docs/guides/operations/scaling.md)
1253
1260
  explains why the drivers above are what they are, and why an S3-compatible
1254
1261
  bucket is the portable choice for uploads everywhere but here.
1255
1262
  `;
1256
1263
  }
1257
1264
  function nextSteps(name) {
1258
- return [`cd ${name}`, "npm install", "cp .env.example .env.local", "npm run dev"];
1265
+ return [`cd ${name}`, "npm install", "npm run dev"];
1266
+ }
1267
+
1268
+ // src/extension-templates.ts
1269
+ var PLUGIN_TEMPLATES = [
1270
+ {
1271
+ path: "src/plugin.tsx",
1272
+ contents: `import { definePlugin } from '@meith/plugin-kit'
1273
+
1274
+ export const __MEITH_EXTENSION_CAMEL__Plugin = definePlugin({
1275
+ key: '__MEITH_EXTENSION_KEY__',
1276
+ name: '__MEITH_EXTENSION_TITLE__',
1277
+ version: '0.1.0',
1278
+ description:
1279
+ 'A plugin scaffolded by create-meith: a footer link, a greeting ' +
1280
+ 'on the board index, one setting, one migration, one task and an admin page.',
1281
+ apiVersion: '0',
1282
+
1283
+ settings: [
1284
+ {
1285
+ key: 'greeting',
1286
+ label: 'Greeting',
1287
+ default: 'A greeting from the __MEITH_EXTENSION_TITLE__ plugin!',
1288
+ description: 'Shown on the status page under Admin \u2192 Plugins \u2192 __MEITH_EXTENSION_TITLE__.',
1289
+ },
1290
+ ],
1291
+
1292
+ migrations: [
1293
+ {
1294
+ id: '0001_create_wave_table',
1295
+ statements: [
1296
+ \`create table if not exists plugin___MEITH_EXTENSION_SNAKE___wave (
1297
+ id integer generated by default as identity primary key,
1298
+ waved_at timestamptz not null default now()
1299
+ )\`,
1300
+ ],
1301
+ },
1302
+ ],
1303
+
1304
+ tasks: [
1305
+ {
1306
+ id: 'wave',
1307
+ intervalSeconds: 3600,
1308
+ run: (context) => {
1309
+ context.logger.info('__MEITH_EXTENSION_KEY__ plugin: waving', {
1310
+ greeting: context.settings.greeting,
1311
+ })
1312
+ },
1313
+ },
1314
+ ],
1315
+
1316
+ adminPages: [
1317
+ {
1318
+ path: 'status',
1319
+ title: '__MEITH_EXTENSION_TITLE__ plugin',
1320
+ render: (context) => (
1321
+ <div className="flex flex-col gap-2 text-sm">
1322
+ <p>{String(context.settings.greeting)}</p>
1323
+ <p className="text-muted-foreground">
1324
+ This page is rendered by the plugin itself. Edit the greeting on the plugin&rsquo;s
1325
+ settings screen and reload to see the resolved value arrive in{' '}
1326
+ <code className="text-xs">context.settings</code>.
1327
+ </p>
1328
+ </div>
1329
+ ),
1330
+ },
1331
+ ],
1332
+
1333
+ contributions: [
1334
+ {
1335
+ region: 'index.footer',
1336
+ render: (context) => (
1337
+ <p className="text-xs text-muted-foreground" data-plugin="__MEITH_EXTENSION_KEY__">
1338
+ {context.viewer.isGuest
1339
+ ? 'Greetings, guest \u2014 this line comes from the __MEITH_EXTENSION_TITLE__ plugin.'
1340
+ : 'Greetings, member \u2014 this line comes from the __MEITH_EXTENSION_TITLE__ plugin.'}
1341
+ </p>
1342
+ ),
1343
+ },
1344
+ ],
1345
+
1346
+ hooks: {
1347
+ 'view.footer': (footer) => ({
1348
+ ...footer,
1349
+ links: [
1350
+ ...footer.links,
1351
+ {
1352
+ label: '__MEITH_EXTENSION_TITLE__ plugin',
1353
+ href: '__MEITH_EXTENSION_REPOSITORY__',
1354
+ },
1355
+ ],
1356
+ }),
1357
+
1358
+ 'post.created': () => {},
1359
+ },
1360
+ })
1361
+ `
1362
+ },
1363
+ {
1364
+ path: "src/plugin.test.ts",
1365
+ contents: "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\u2019s 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"
1366
+ }
1367
+ ];
1368
+ var THEME_TEMPLATES = [
1369
+ {
1370
+ path: "src/index.ts",
1371
+ contents: "export { __MEITH_EXTENSION_CAMEL__Theme } from './theme'\nexport { BROWSER_THEME_COLOR, DARK_TOKENS, LIGHT_TOKENS } from './tokens'\n"
1372
+ },
1373
+ {
1374
+ path: "src/theme.ts",
1375
+ contents: "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"
1376
+ },
1377
+ {
1378
+ path: "src/tokens.ts",
1379
+ contents: "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"
1380
+ },
1381
+ {
1382
+ path: "src/slots/footer.tsx",
1383
+ contents: `import type { FooterModel } from '@meith/theme-kit'
1384
+
1385
+ export function Footer({ boardTitle, links, timezoneLabel, poweredBy }: FooterModel) {
1386
+ return (
1387
+ <footer className="mt-auto border-t-2 border-primary bg-card">
1388
+ <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">
1389
+ <span className="font-semibold uppercase tracking-widest text-foreground">
1390
+ {boardTitle}
1391
+ </span>
1392
+
1393
+ {links.length > 0 && (
1394
+ <nav aria-label="Footer" className="flex flex-wrap justify-center gap-x-4 gap-y-1">
1395
+ {links.map((link) => (
1396
+ <a
1397
+ key={link.href}
1398
+ href={link.href}
1399
+ className="underline decoration-primary underline-offset-2 hover:text-foreground"
1400
+ >
1401
+ {link.label}
1402
+ </a>
1403
+ ))}
1404
+ </nav>
1405
+ )}
1406
+
1407
+ <span>
1408
+ Times are shown in {timezoneLabel}
1409
+ {poweredBy && (
1410
+ <>
1411
+ {' \u2014 '}
1412
+ <a
1413
+ href={poweredBy.href}
1414
+ className="underline decoration-primary underline-offset-2 hover:text-foreground"
1415
+ >
1416
+ {poweredBy.label}
1417
+ </a>
1418
+ </>
1419
+ )}
1420
+ </span>
1421
+ </div>
1422
+ </footer>
1423
+ )
1424
+ }
1425
+ `
1426
+ },
1427
+ {
1428
+ path: "src/theme.test.ts",
1429
+ contents: "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"
1430
+ }
1431
+ ];
1432
+
1433
+ // src/scaffold-extension.ts
1434
+ var EXTENSION_KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
1435
+ function validateExtensionName(name) {
1436
+ if (name === "") return "An extension name is required.";
1437
+ if (!EXTENSION_KEY_PATTERN.test(name)) {
1438
+ return "Use lower-case letters, digits and hyphens, starting with a letter, 2 to 40 characters \u2014 the name becomes the definePlugin/defineTheme key and the npm package name.";
1439
+ }
1440
+ return null;
1441
+ }
1442
+ function extensionTitle(name) {
1443
+ return name.split("-").filter((word) => word !== "").map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`).join(" ");
1444
+ }
1445
+ function extensionCamel(name) {
1446
+ const words = name.split("-").filter((word) => word !== "");
1447
+ const [head, ...rest] = words;
1448
+ return `${head ?? ""}${rest.map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`).join("")}`;
1449
+ }
1450
+ function extensionSnake(name) {
1451
+ return name.replace(/-/g, "_");
1452
+ }
1453
+ function defaultExtensionRepositoryUrl(name) {
1454
+ return `https://github.com/your-name/${name}`;
1455
+ }
1456
+ function meithRange(version) {
1457
+ const [major = "0", minor = "0"] = version.split(".");
1458
+ return `>=${major}.${minor} <${Number(major) + 1}`;
1459
+ }
1460
+ function substitute(contents, values) {
1461
+ return contents.replaceAll("__MEITH_EXTENSION_CAMEL__", values.camel).replaceAll("__MEITH_EXTENSION_SNAKE__", values.snake).replaceAll("__MEITH_EXTENSION_REPOSITORY__", values.repositoryUrl).replaceAll("__MEITH_EXTENSION_KEY__", values.key).replaceAll("__MEITH_EXTENSION_TITLE__", values.title);
1462
+ }
1463
+ function extensionManifest(options, repositoryUrl, description, dependencies) {
1464
+ return `${JSON.stringify(
1465
+ {
1466
+ name: options.name,
1467
+ version: "0.1.0",
1468
+ description,
1469
+ license: "MIT",
1470
+ repository: { type: "git", url: repositoryUrl },
1471
+ type: "module",
1472
+ main: "./src/index.ts",
1473
+ types: "./src/index.ts",
1474
+ files: ["src", "!src/**/*.test.*"],
1475
+ scripts: { test: "vitest run", typecheck: "tsc --noEmit" },
1476
+ dependencies,
1477
+ peerDependencies: { react: "^19.2.0" },
1478
+ devDependencies: {
1479
+ "@types/node": "^26.2.0",
1480
+ "@types/react": "^19.2.18",
1481
+ react: "^19.2.0",
1482
+ typescript: "^7.0.2",
1483
+ vitest: "^4.1.10"
1484
+ },
1485
+ publishConfig: { access: "public" }
1486
+ },
1487
+ null,
1488
+ 2
1489
+ )}
1490
+ `;
1491
+ }
1492
+ function extensionTsconfig() {
1493
+ return `${JSON.stringify(
1494
+ {
1495
+ compilerOptions: {
1496
+ target: "es2022",
1497
+ lib: ["es2022", "dom"],
1498
+ module: "esnext",
1499
+ moduleResolution: "bundler",
1500
+ jsx: "react-jsx",
1501
+ strict: true,
1502
+ resolveJsonModule: true,
1503
+ skipLibCheck: true,
1504
+ noEmit: true
1505
+ },
1506
+ include: ["src"]
1507
+ },
1508
+ null,
1509
+ 2
1510
+ )}
1511
+ `;
1512
+ }
1513
+ function extensionVitestConfig() {
1514
+ return `import { defineConfig } from 'vitest/config'
1515
+
1516
+ export default defineConfig({
1517
+ test: { environment: 'node' },
1518
+ })
1519
+ `;
1520
+ }
1521
+ function extensionGitignore() {
1522
+ return `node_modules
1523
+ *.log
1524
+ .DS_Store
1525
+ `;
1526
+ }
1527
+ function listing(options, kind, repositoryUrl, description) {
1528
+ return `${JSON.stringify(
1529
+ {
1530
+ key: options.name,
1531
+ kind,
1532
+ package: options.name,
1533
+ name: extensionTitle(options.name),
1534
+ description,
1535
+ screenshots: [`${options.name}-light.png`],
1536
+ version: "0.1.0",
1537
+ apiVersion: 0,
1538
+ meith: meithRange(options.version),
1539
+ repository: repositoryUrl,
1540
+ licence: "MIT"
1541
+ },
1542
+ null,
1543
+ 2
1544
+ )}
1545
+ `;
1546
+ }
1547
+ function pluginReadme(options, camel, title) {
1548
+ const { name } = options;
1549
+ return `# ${title}
1550
+
1551
+ A Meith plugin, scaffolded by \`create-meith\` from the meith repository's
1552
+ \`examples/hello-plugin\`: a footer link, a greeting on the board index, one
1553
+ setting, one migration, one task and an admin page \u2014 every extension point
1554
+ exercised once, each easy to delete.
1555
+
1556
+ ## Develop
1557
+
1558
+ npm install
1559
+ npm test
1560
+
1561
+ \`src/plugin.tsx\` is the plugin. What a plugin may and may not do is
1562
+ documented in the meith repository under \`docs/customization/plugins.md\`;
1563
+ every hook and payload is listed in \`docs/reference/plugin-hooks.md\`.
1564
+
1565
+ ## Run it inside a board
1566
+
1567
+ Scaffold a board next to this directory if you do not have one
1568
+ (\`npx create-meith my-board\`), then install this workspace into it by path:
1569
+
1570
+ cd ../my-board
1571
+ npm install ../${name}
1572
+
1573
+ npm installs a local directory as a symlink, so edits here are picked up by
1574
+ the board's next build without reinstalling.
1575
+
1576
+ Register the plugin in the board's \`community.plugins.ts\` \u2014 the comment at
1577
+ the top of that file shows the shape:
1578
+
1579
+ import { messages as ${camel}Messages, plugin as ${camel}Plugin } from '${name}'
1580
+
1581
+ export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [
1582
+ { key: '${name}', enabled: true, plugin: ${camel}Plugin, messages: ${camel}Messages },
1583
+ ]
1584
+
1585
+ and add the matching entry to \`board.plugins.json\`:
1586
+
1587
+ { "plugins": [{ "key": "${name}", "package": "${name}", "enabled": true }] }
1588
+
1589
+ Rebuild the board (\`npm run build\`) and, because this plugin ships a
1590
+ migration, run \`npx community migrate\`. The plugin then appears under
1591
+ **Admin \u2192 Plugins**.
1592
+
1593
+ ## Publish and list it
1594
+
1595
+ \`npm publish\` ships \`src/\` as TypeScript source, the way every
1596
+ \`@meith/*\` package ships. To offer the plugin on the meith.dev marketplace,
1597
+ finish \`listing.json\` (its \`repository\` field starts as a placeholder),
1598
+ add the screenshot it names, and open a pull request against the meith
1599
+ repository \u2014 the submission process and the review bar are documented there
1600
+ in \`docs/customization/marketplace.md\`.
1601
+ `;
1602
+ }
1603
+ function themeReadme(options, camel, title) {
1604
+ const { name } = options;
1605
+ return `# ${title}
1606
+
1607
+ A Meith theme, scaffolded by \`create-meith\` from the meith repository's
1608
+ \`examples/iris-theme\`: the default theme recoloured (one brand group of
1609
+ tokens) plus a single slot override, the footer.
1610
+
1611
+ ## Develop
1612
+
1613
+ npm install
1614
+ npm test
1615
+
1616
+ \`src/theme.ts\` declares the theme, \`src/tokens.ts\` carries the palette,
1617
+ and slots live in \`src/slots/\`. What a theme may and may not do is
1618
+ documented in the meith repository under \`docs/customization/themes.md\`;
1619
+ every slot and view model is listed in \`docs/reference/theme-slots.md\`.
1620
+
1621
+ ## Run it inside a board
1622
+
1623
+ Scaffold a board next to this directory if you do not have one
1624
+ (\`npx create-meith my-board\`), then install this workspace into it by path:
1625
+
1626
+ cd ../my-board
1627
+ npm install ../${name}
1628
+
1629
+ npm installs a local directory as a symlink, so edits here are picked up by
1630
+ the board's next build without reinstalling.
1631
+
1632
+ Register the theme in the board's \`community.config.ts\`, beside the
1633
+ default entry:
1634
+
1635
+ import { defaultMessages } from '@meith/theme-default'
1636
+ import { BROWSER_THEME_COLOR, DARK_TOKENS, LIGHT_TOKENS, ${camel}Theme } from '${name}'
1637
+
1638
+ themes: {
1639
+ '${name}': {
1640
+ key: '${name}',
1641
+ title: '${title}',
1642
+ tokens: { light: LIGHT_TOKENS, dark: DARK_TOKENS },
1643
+ browserThemeColor: BROWSER_THEME_COLOR,
1644
+ theme: ${camel}Theme,
1645
+ messages: defaultMessages,
1646
+ },
1647
+ },
1648
+
1649
+ Rebuild the board (\`npm run build\`); the theme is then offered to members
1650
+ on the appearance screen and to administrators under **Admin \u2192 Themes**.
1651
+
1652
+ ## Publish and list it
1653
+
1654
+ \`npm publish\` ships \`src/\` as TypeScript source, the way every
1655
+ \`@meith/*\` package ships. To offer the theme on the meith.dev marketplace,
1656
+ finish \`listing.json\` (its \`repository\` field starts as a placeholder),
1657
+ add the screenshot it names, and open a pull request against the meith
1658
+ repository \u2014 the submission process and the review bar are documented there
1659
+ in \`docs/customization/marketplace.md\`.
1660
+ `;
1661
+ }
1662
+ function scaffoldPlugin(options) {
1663
+ const key = options.name;
1664
+ const title = extensionTitle(key);
1665
+ const camel = extensionCamel(key);
1666
+ const snake = extensionSnake(key);
1667
+ const repositoryUrl = options.repositoryUrl ?? defaultExtensionRepositoryUrl(key);
1668
+ const description = `${title} \u2014 a Meith plugin.`;
1669
+ const files = /* @__PURE__ */ new Map();
1670
+ files.set(
1671
+ "package.json",
1672
+ extensionManifest(options, repositoryUrl, description, {
1673
+ "@meith/plugin-kit": options.version
1674
+ })
1675
+ );
1676
+ files.set("tsconfig.json", extensionTsconfig());
1677
+ files.set("vitest.config.ts", extensionVitestConfig());
1678
+ files.set(".gitignore", extensionGitignore());
1679
+ files.set("README.md", pluginReadme(options, camel, title));
1680
+ files.set("listing.json", listing(options, "plugin", repositoryUrl, description));
1681
+ files.set(
1682
+ "src/index.ts",
1683
+ `export { ${camel}Plugin, ${camel}Plugin as plugin } from './plugin'
1684
+ export { ${camel}Messages, ${camel}Messages as messages } from './messages'
1685
+ `
1686
+ );
1687
+ files.set(
1688
+ "src/messages/index.ts",
1689
+ `import en from './en.json'
1690
+
1691
+ export const ${camel}Messages = { en }
1692
+ `
1693
+ );
1694
+ files.set("src/messages/en.json", "{}\n");
1695
+ for (const template of PLUGIN_TEMPLATES) {
1696
+ files.set(
1697
+ template.path,
1698
+ substitute(template.contents, { key, title, camel, snake, repositoryUrl })
1699
+ );
1700
+ }
1701
+ return files;
1702
+ }
1703
+ function scaffoldTheme(options) {
1704
+ const key = options.name;
1705
+ const title = extensionTitle(key);
1706
+ const camel = extensionCamel(key);
1707
+ const snake = extensionSnake(key);
1708
+ const repositoryUrl = options.repositoryUrl ?? defaultExtensionRepositoryUrl(key);
1709
+ const description = `${title} \u2014 a Meith theme.`;
1710
+ const files = /* @__PURE__ */ new Map();
1711
+ files.set(
1712
+ "package.json",
1713
+ extensionManifest(options, repositoryUrl, description, {
1714
+ "@meith/theme-default": options.version,
1715
+ "@meith/theme-kit": options.version
1716
+ })
1717
+ );
1718
+ files.set("tsconfig.json", extensionTsconfig());
1719
+ files.set("vitest.config.ts", extensionVitestConfig());
1720
+ files.set(".gitignore", extensionGitignore());
1721
+ files.set("README.md", themeReadme(options, camel, title));
1722
+ files.set("listing.json", listing(options, "theme", repositoryUrl, description));
1723
+ for (const template of THEME_TEMPLATES) {
1724
+ files.set(
1725
+ template.path,
1726
+ substitute(template.contents, { key, title, camel, snake, repositoryUrl })
1727
+ );
1728
+ }
1729
+ return files;
1730
+ }
1731
+ function scaffoldExtension(kind, options) {
1732
+ return kind === "plugin" ? scaffoldPlugin(options) : scaffoldTheme(options);
1733
+ }
1734
+ function extensionNextSteps(name) {
1735
+ return [`cd ${name}`, "npm install", "npm test"];
1259
1736
  }
1260
1737
 
1261
1738
  // src/cli.ts
@@ -1291,20 +1768,34 @@ async function run(argv, version) {
1291
1768
  return {
1292
1769
  code: 0,
1293
1770
  lines: [
1294
- "create-meith \u2014 scaffold a forum project.",
1771
+ "create-meith \u2014 scaffold a forum project, a plugin or a theme.",
1295
1772
  "",
1296
1773
  " npx create-meith <name> [--repo <url>] [--no-git]",
1774
+ " npx create-meith --plugin <name> [--repo <url>] [--no-git]",
1775
+ " npx create-meith --theme <name> [--repo <url>] [--no-git]",
1297
1776
  "",
1298
- "Writes package.json, community.config.ts, .env.example, .gitignore and",
1299
- "README.md into ./<name>, then tells you what to run.",
1777
+ "The first form writes a deployable board into ./<name>, then tells you",
1778
+ "what to run. --plugin and --theme write an extension workspace instead:",
1779
+ "source and a passing test copied from the meith repository\u2019s worked",
1780
+ "examples, plus a README and a marketplace listing.json.",
1300
1781
  "",
1301
1782
  "--no-git skips initializing a git repository in the new directory."
1302
1783
  ]
1303
1784
  };
1304
1785
  }
1305
- const invalid = validateName(name);
1786
+ const wantsPlugin = argv.includes("--plugin");
1787
+ const wantsTheme = argv.includes("--theme");
1788
+ if (wantsPlugin && wantsTheme) {
1789
+ return {
1790
+ code: 1,
1791
+ lines: ["create-meith: --plugin and --theme are two different scaffolds \u2014 pass one."]
1792
+ };
1793
+ }
1794
+ const kind = wantsPlugin ? "plugin" : wantsTheme ? "theme" : null;
1795
+ const usage = kind === null ? "Usage: npx create-meith <name>" : `Usage: npx create-meith --${kind} <name>`;
1796
+ const invalid = kind === null ? validateName(name) : validateExtensionName(name);
1306
1797
  if (invalid !== null) {
1307
- return { code: 1, lines: [`create-meith: ${invalid}`, "", "Usage: npx create-meith <name>"] };
1798
+ return { code: 1, lines: [`create-meith: ${invalid}`, "", usage] };
1308
1799
  }
1309
1800
  const repoIndex = argv.indexOf("--repo");
1310
1801
  const repositoryUrl = repoIndex === -1 ? DEFAULT_REPOSITORY_URL : argv[repoIndex + 1] ?? DEFAULT_REPOSITORY_URL;
@@ -1318,7 +1809,11 @@ async function run(argv, version) {
1318
1809
  ]
1319
1810
  };
1320
1811
  }
1321
- const files = scaffold({ name, version, repositoryUrl });
1812
+ const files = kind === null ? scaffold({ name, version, repositoryUrl }) : scaffoldExtension(kind, {
1813
+ name,
1814
+ version,
1815
+ repositoryUrl: repoIndex === -1 ? void 0 : repositoryUrl
1816
+ });
1322
1817
  for (const [relative, contents] of files) {
1323
1818
  const path = join(target, relative);
1324
1819
  await mkdir(dirname(path), { recursive: true });
@@ -1330,7 +1825,7 @@ async function run(argv, version) {
1330
1825
  lines: [
1331
1826
  `Created ${name} \u2014 ${files.size} files.`,
1332
1827
  "",
1333
- ...nextSteps(name).map((step) => ` ${step}`),
1828
+ ...(kind === null ? nextSteps(name) : extensionNextSteps(name)).map((step) => ` ${step}`),
1334
1829
  "",
1335
1830
  ...gitReady ? [
1336
1831
  "Initialized a git repository here and staged every file. Commit it,",
@@ -1348,15 +1843,20 @@ async function run(argv, version) {
1348
1843
  " git push -u origin main"
1349
1844
  ],
1350
1845
  "",
1351
- "Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.",
1352
- "Something must run the tick every minute \u2014 the worker process, or",
1353
- "`community task:run`. Without it nothing catches up, and nothing errors."
1846
+ ...kind === null ? [
1847
+ "Then set DATABASE_URL, AUTH_SECRET and TICK_SECRET and deploy.",
1848
+ "Something must run the tick every minute \u2014 the worker process, or",
1849
+ "`community task:run`. Without it nothing catches up, and nothing errors."
1850
+ ] : [
1851
+ `Then follow README.md \u2014 it walks through running the ${kind} inside a`,
1852
+ "scaffolded board and submitting it to the meith.dev marketplace."
1853
+ ]
1354
1854
  ]
1355
1855
  };
1356
1856
  }
1357
1857
 
1358
1858
  // src/bin.ts
1359
- var result = await run(process.argv.slice(2), "0.21.2");
1859
+ var result = await run(process.argv.slice(2), "0.22.0");
1360
1860
  for (const line of result.lines) {
1361
1861
  if (result.code === 0) console.log(line);
1362
1862
  else console.error(line);