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 +532 -32
- package/package.json +2 -2
- package/src/bin.ts +1 -1
- package/src/cli.ts +46 -10
- package/src/extension-templates.ts +43 -0
- package/src/index.ts +12 -0
- package/src/scaffold-extension.ts +372 -0
- package/src/scaffold.ts +28 -21
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 = `#
|
|
103
|
-
#
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
912
|
-
|
|
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/
|
|
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/
|
|
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", "
|
|
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’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
|
-
"
|
|
1299
|
-
"
|
|
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
|
|
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}`, "",
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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.
|
|
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);
|