@meith/web 0.17.1 → 0.18.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/LICENSE.md +21 -165
- package/app/admin/api/marketplace/screenshot/route.ts +6 -3
- package/app/admin/forums/[id]/page.tsx +6 -3
- package/app/admin/groups/promotions/page.tsx +6 -0
- package/app/admin/settings/page.tsx +2 -2
- package/app/admin/system/page.tsx +1 -2
- package/app/admin/themes/page.tsx +6 -3
- package/app/api/system/tick/route.ts +27 -10
- package/bin/forum-web.mjs +292 -48
- package/bin/forum-web.test.ts +164 -1
- package/next.config.mjs +31 -64
- package/package.json +49 -48
- package/public/placeholder-logo.png +0 -0
- package/public/placeholder-logo.svg +1 -0
- package/public/placeholder-user.jpg +0 -0
- package/public/placeholder.jpg +0 -0
- package/public/placeholder.svg +1 -0
- package/public/sw.js +81 -0
- package/src/server/admin-settings-actions.ts +8 -8
- package/src/server/group-admin.ts +2 -6
- package/src/server/install.ts +2 -1
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/user-admin-actions.ts +23 -21
- package/src/server/user-admin.ts +8 -7
package/bin/forum-web.mjs
CHANGED
|
@@ -28,7 +28,32 @@
|
|
|
28
28
|
* (`<root>/.meith/app`) on purpose: `next.config.mjs` (copied verbatim, see
|
|
29
29
|
* below) computes its own workspace root as two directories up from itself,
|
|
30
30
|
* so materializing at that exact depth keeps that computation correct
|
|
31
|
-
* without touching the file.
|
|
31
|
+
* without touching the file. This bin now also passes that root explicitly,
|
|
32
|
+
* as `FORUM_WORKSPACE_ROOT`, so the depth is what keeps the *copied file's*
|
|
33
|
+
* own default honest rather than what the build depends on.
|
|
34
|
+
*
|
|
35
|
+
* `--at-root` materializes into the workspace root itself instead
|
|
36
|
+
* (`<root>`, depth zero), which is the one thing Vercel's Next.js preset
|
|
37
|
+
* needs and `.meith/app` cannot give it: the build artefact at
|
|
38
|
+
* `<root>/.next`, where that builder looks and where, for a Next.js project,
|
|
39
|
+
* it cannot be told to look elsewhere. Nothing about the seam changes — the
|
|
40
|
+
* generated tsconfig's `paths` name `./community.config.ts` instead of
|
|
41
|
+
* `../../community.config.ts`, and every other path in here is computed from
|
|
42
|
+
* `appDir` rather than assumed. Because that mode writes framework-owned
|
|
43
|
+
* names (`app/`, `src/`, `next.config.mjs`, ...) into a directory the board
|
|
44
|
+
* also keeps its own files in, ownership there is decided per *file* — the
|
|
45
|
+
* files it wrote last time, recorded in `.meith/materialized.json`, plus any
|
|
46
|
+
* whose contents are already byte for byte what it would write. Everything
|
|
47
|
+
* else is the board's: never removed, never overwritten, and a collision
|
|
48
|
+
* stops the build naming every file involved. A board can therefore keep
|
|
49
|
+
* `public/ads.txt` beside the shipped `public/sw.js`.
|
|
50
|
+
*
|
|
51
|
+
* `GENERATED_ENTRIES` are the exception, and have to be: `tsconfig.json` and
|
|
52
|
+
* `next-env.d.ts` are written from scratch rather than copied, so there is no
|
|
53
|
+
* shipped file to compare a board's own against and nothing to tell one apart
|
|
54
|
+
* from a stale one this bin wrote. Both are replaced without asking, so a
|
|
55
|
+
* board cannot keep its own compiler options at the root of an `--at-root`
|
|
56
|
+
* workspace. See docs/development.md, "Consuming the board from a workspace".
|
|
32
57
|
*
|
|
33
58
|
* This assumes a *hoisted* `node_modules` (npm, yarn classic, or pnpm with
|
|
34
59
|
* `node-linker=hoisted`) — see docs/development.md for why.
|
|
@@ -39,21 +64,29 @@
|
|
|
39
64
|
* (non-hoisted) pnpm install, nested two directories deeper again. Two
|
|
40
65
|
* environment variables, set only by that workspace's own `build`/`dev`
|
|
41
66
|
* scripts, cover the difference without changing anything for a real
|
|
42
|
-
* external board, which
|
|
67
|
+
* external board, which sets neither itself: `FORUM_WORKSPACE_ROOT`
|
|
43
68
|
* (apps/community/next.config.mjs) points tracing at this repository's real
|
|
44
69
|
* root, and `FORUM_ALIASES_FROM` (`monorepoAliases()` below) carries this
|
|
45
70
|
* repository's own `@meith/*` tsconfig aliases into the generated tsconfig,
|
|
46
71
|
* since packages here resolve each other through those aliases rather than
|
|
47
72
|
* through real `dependencies` entries a hoisted `node_modules` would need.
|
|
73
|
+
* `FORUM_WORKSPACE_ROOT` is always exported onward from here, defaulting to
|
|
74
|
+
* the invoking workspace's own root when that workspace did not set it, so
|
|
75
|
+
* that everything downstream — the copied `next.config.mjs`, and the
|
|
76
|
+
* `@source` rebase below — reads one answer rather than each re-deriving it
|
|
77
|
+
* from where it happens to sit.
|
|
48
78
|
*/
|
|
49
79
|
import { spawn } from 'node:child_process'
|
|
50
80
|
import {
|
|
51
81
|
cpSync,
|
|
52
82
|
existsSync,
|
|
53
83
|
mkdirSync,
|
|
84
|
+
readdirSync,
|
|
54
85
|
readFileSync,
|
|
55
86
|
realpathSync,
|
|
87
|
+
rmdirSync,
|
|
56
88
|
rmSync,
|
|
89
|
+
statSync,
|
|
57
90
|
writeFileSync,
|
|
58
91
|
} from 'node:fs'
|
|
59
92
|
import { createRequire } from 'node:module'
|
|
@@ -63,26 +96,25 @@ import { fileURLToPath } from 'node:url'
|
|
|
63
96
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
64
97
|
const packageRoot = join(here, '..')
|
|
65
98
|
const workspaceRoot = process.cwd()
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// boards/stock/package.json's scripts write, relative to *this* process's
|
|
71
|
-
// own cwd (the board's own directory) — would resolve against the wrong
|
|
72
|
-
// directory if left for next.config.mjs to resolve itself. Rewriting them to
|
|
73
|
-
// absolute paths here, before spawning, means both env vars mean the same
|
|
74
|
-
// thing regardless of which process reads them.
|
|
99
|
+
const AT_ROOT_FLAG = '--at-root'
|
|
100
|
+
const atRoot = process.argv.includes(AT_ROOT_FLAG)
|
|
101
|
+
const appDir = atRoot ? workspaceRoot : join(workspaceRoot, '.meith', 'app')
|
|
102
|
+
|
|
75
103
|
for (const name of ['FORUM_WORKSPACE_ROOT', 'FORUM_ALIASES_FROM']) {
|
|
76
104
|
if (process.env[name]) process.env[name] = resolve(workspaceRoot, process.env[name])
|
|
77
105
|
}
|
|
106
|
+
if (!process.env.FORUM_WORKSPACE_ROOT) process.env.FORUM_WORKSPACE_ROOT = workspaceRoot
|
|
78
107
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Files this package ships (see its `files` allowlist) that belong inside the
|
|
110
|
+
* materialized app. Board files (community.config.ts, board.plugins.json,
|
|
111
|
+
* community.plugins.ts) are never copied — they are read in place, from the
|
|
112
|
+
* workspace, through the generated tsconfig instead.
|
|
113
|
+
*/
|
|
83
114
|
const APP_ENTRIES = [
|
|
84
115
|
'app',
|
|
85
116
|
'src',
|
|
117
|
+
'public',
|
|
86
118
|
'next.config.mjs',
|
|
87
119
|
'postcss.config.mjs',
|
|
88
120
|
'components.json',
|
|
@@ -95,6 +127,180 @@ function fail(message) {
|
|
|
95
127
|
process.exit(1)
|
|
96
128
|
}
|
|
97
129
|
|
|
130
|
+
const GENERATED_ENTRIES = ['tsconfig.json', 'next-env.d.ts']
|
|
131
|
+
const SHARED_ENTRIES = ['public']
|
|
132
|
+
const GLOBALS_CSS = 'src/styles/globals.css'
|
|
133
|
+
const MATERIALIZED_RECORD = join(workspaceRoot, '.meith', 'materialized.json')
|
|
134
|
+
|
|
135
|
+
function walkFiles(dir, prefix, into) {
|
|
136
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const rel = prefix === '' ? item.name : `${prefix}/${item.name}`
|
|
138
|
+
if (item.isDirectory()) walkFiles(join(dir, item.name), rel, into)
|
|
139
|
+
else into.push(rel)
|
|
140
|
+
}
|
|
141
|
+
return into
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every file `--at-root` is about to write into the workspace root, as posix
|
|
146
|
+
* relative paths: the shipped entries expanded file by file, plus the two
|
|
147
|
+
* this bin generates. Ownership is decided per file rather than per
|
|
148
|
+
* top-level name so that a board can keep its own files inside a directory
|
|
149
|
+
* the framework also writes into — `public/ads.txt` beside the shipped
|
|
150
|
+
* `public/sw.js`. See docs/development.md, "Consuming the board from a
|
|
151
|
+
* workspace".
|
|
152
|
+
*/
|
|
153
|
+
function intendedRootFiles() {
|
|
154
|
+
const files = []
|
|
155
|
+
for (const entry of APP_ENTRIES) {
|
|
156
|
+
const source = join(packageRoot, entry)
|
|
157
|
+
if (!existsSync(source)) continue
|
|
158
|
+
if (statSync(source).isDirectory()) walkFiles(source, entry, files)
|
|
159
|
+
else files.push(entry)
|
|
160
|
+
}
|
|
161
|
+
return [...files, ...GENERATED_ENTRIES]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readMaterializedRecord() {
|
|
165
|
+
if (!existsSync(MATERIALIZED_RECORD)) return []
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(readFileSync(MATERIALIZED_RECORD, 'utf8')).files ?? []
|
|
168
|
+
} catch {
|
|
169
|
+
return []
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function absoluteRootPath(rel) {
|
|
174
|
+
return join(workspaceRoot, ...rel.split('/'))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What this bin would leave at `rel`, which is the shipped file's own bytes
|
|
179
|
+
* for everything except `globals.css` — that one is rewritten in place after
|
|
180
|
+
* the copy (`rewriteGlobalsCssSourcePaths`), so comparing a materialized
|
|
181
|
+
* tree against the package source would report the one file this bin always
|
|
182
|
+
* edits as though the board had edited it.
|
|
183
|
+
*/
|
|
184
|
+
function materializedContent(rel) {
|
|
185
|
+
const source = readFileSync(join(packageRoot, ...rel.split('/')))
|
|
186
|
+
if (rel !== GLOBALS_CSS) return source
|
|
187
|
+
return Buffer.from(
|
|
188
|
+
rebaseGlobalsCssSources(
|
|
189
|
+
source.toString('utf8'),
|
|
190
|
+
dirname(absoluteRootPath(rel)),
|
|
191
|
+
process.env.FORUM_WORKSPACE_ROOT,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function alreadyMaterialized(rel) {
|
|
197
|
+
try {
|
|
198
|
+
return readFileSync(absoluteRootPath(rel)).equals(materializedContent(rel))
|
|
199
|
+
} catch {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pruneEmptyDirectories(rel) {
|
|
205
|
+
let current = dirname(absoluteRootPath(rel))
|
|
206
|
+
while (current !== workspaceRoot && current.startsWith(workspaceRoot)) {
|
|
207
|
+
try {
|
|
208
|
+
rmdirSync(current)
|
|
209
|
+
} catch {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
current = dirname(current)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `--at-root` only; every other mode owns `.meith/app` outright and replaces
|
|
218
|
+
* it wholesale. A file this bin is about to write is its own to replace when
|
|
219
|
+
* the record says it wrote that file before, or when what is on disk is byte
|
|
220
|
+
* for byte what it would write anyway — the second case being a checkout
|
|
221
|
+
* that committed a materialized file, where no record exists and refusing
|
|
222
|
+
* would fail the deploy over a file identical to the one being written.
|
|
223
|
+
* Anything else is the board's, and the board's is never overwritten —
|
|
224
|
+
* except `GENERATED_ENTRIES`, which are written rather than copied and so
|
|
225
|
+
* can satisfy neither test; those are replaced unconditionally.
|
|
226
|
+
*/
|
|
227
|
+
function claimRootFiles(intended) {
|
|
228
|
+
const owned = new Set(readMaterializedRecord())
|
|
229
|
+
|
|
230
|
+
const collisions = intended.filter((rel) => {
|
|
231
|
+
if (!existsSync(absoluteRootPath(rel))) return false
|
|
232
|
+
if (owned.has(rel)) return false
|
|
233
|
+
if (GENERATED_ENTRIES.includes(rel)) return false
|
|
234
|
+
return !alreadyMaterialized(rel)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
if (collisions.length > 0) {
|
|
238
|
+
fail(
|
|
239
|
+
`refusing to overwrite ${collisions.length} file(s) in ${workspaceRoot} that ` +
|
|
240
|
+
`"${AT_ROOT_FLAG}" did not write:\n` +
|
|
241
|
+
collisions.map((rel) => ` ${rel}`).join('\n') +
|
|
242
|
+
`\nThis mode materializes @meith/web's own app into this directory, and each of ` +
|
|
243
|
+
"these is either the board's own file under a name the framework ships, or a " +
|
|
244
|
+
'materialized file that has been edited since. Move it aside, or drop the flag ' +
|
|
245
|
+
'to materialize into .meith/app instead.',
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Files the previous run wrote and this one will not: the framework stopped
|
|
252
|
+
* shipping them. They were this bin's own, so they go — nothing the board
|
|
253
|
+
* added is in the record, which is why removal is driven from it rather than
|
|
254
|
+
* from the directory.
|
|
255
|
+
*/
|
|
256
|
+
function removeStaleRootFiles(intended) {
|
|
257
|
+
const keeping = new Set(intended)
|
|
258
|
+
for (const rel of readMaterializedRecord()) {
|
|
259
|
+
if (keeping.has(rel)) continue
|
|
260
|
+
rmSync(absoluteRootPath(rel), { recursive: true, force: true })
|
|
261
|
+
pruneEmptyDirectories(rel)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function recordRootFiles(intended) {
|
|
266
|
+
mkdirSync(dirname(MATERIALIZED_RECORD), { recursive: true })
|
|
267
|
+
writeFileSync(MATERIALIZED_RECORD, `${JSON.stringify({ files: intended }, null, 2)}\n`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Files the board itself put under a directory the framework owns outright.
|
|
272
|
+
* Nothing here removes them — ownership is per file, so they simply survive —
|
|
273
|
+
* but a scaffolded board gitignores those directories as a unit, so a route
|
|
274
|
+
* added under `app/` works locally, is never committed, and is absent from a
|
|
275
|
+
* deploy built out of the checkout. Warned about rather than refused: the
|
|
276
|
+
* file is the author's to keep, and this is the only moment anything looks at
|
|
277
|
+
* it. `public/` is excluded because that one is shared on purpose.
|
|
278
|
+
*/
|
|
279
|
+
function warnAboutStrayBoardFiles(intended) {
|
|
280
|
+
const written = new Set(intended)
|
|
281
|
+
const owned = APP_ENTRIES.filter((entry) => !SHARED_ENTRIES.includes(entry))
|
|
282
|
+
const strays = []
|
|
283
|
+
|
|
284
|
+
for (const entry of owned) {
|
|
285
|
+
const dir = join(workspaceRoot, entry)
|
|
286
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue
|
|
287
|
+
for (const rel of walkFiles(dir, entry, [])) {
|
|
288
|
+
if (!written.has(rel)) strays.push(rel)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (strays.length === 0) return
|
|
293
|
+
|
|
294
|
+
console.warn(
|
|
295
|
+
`forum-web: ${strays.length} file(s) here are not @meith/web's, under a directory that is:\n` +
|
|
296
|
+
`${strays.map((rel) => ` ${rel}`).join('\n')}\n` +
|
|
297
|
+
'They are left exactly as they are. A scaffolded board gitignores these directories as ' +
|
|
298
|
+
'a unit, though, so nothing above is committed, and a deploy that builds from the ' +
|
|
299
|
+
'checkout will not see it. A board extends the forum through plugins and themes ' +
|
|
300
|
+
'(docs/plugin-api.md, docs/theme-api.md), not by adding files here.',
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
98
304
|
function toPosixRelative(from, to) {
|
|
99
305
|
const rel = relative(from, to).split(sep).join('/')
|
|
100
306
|
return rel.startsWith('.') ? rel : `./${rel}`
|
|
@@ -108,14 +314,14 @@ export function rebaseGlobalsCssSources(css, cssDir, workspaceRootOverride) {
|
|
|
108
314
|
}
|
|
109
315
|
|
|
110
316
|
function rewriteGlobalsCssSourcePaths() {
|
|
111
|
-
const
|
|
112
|
-
if (!workspaceRootOverride) return
|
|
113
|
-
|
|
114
|
-
const cssPath = join(appDir, 'src', 'styles', 'globals.css')
|
|
317
|
+
const cssPath = join(appDir, ...GLOBALS_CSS.split('/'))
|
|
115
318
|
if (!existsSync(cssPath)) return
|
|
116
319
|
|
|
117
320
|
const css = readFileSync(cssPath, 'utf8')
|
|
118
|
-
writeFileSync(
|
|
321
|
+
writeFileSync(
|
|
322
|
+
cssPath,
|
|
323
|
+
rebaseGlobalsCssSources(css, dirname(cssPath), process.env.FORUM_WORKSPACE_ROOT),
|
|
324
|
+
)
|
|
119
325
|
}
|
|
120
326
|
|
|
121
327
|
/**
|
|
@@ -136,7 +342,10 @@ function rewriteGlobalsCssSourcePaths() {
|
|
|
136
342
|
* materialized app's own generated tsconfig, rebased to be relative to
|
|
137
343
|
* `.meith/app` — the same aliases apps/community's own tsconfig.json
|
|
138
344
|
* hand-maintains for exactly this reason. Unset (the default, and the only
|
|
139
|
-
* path a real external board ever takes), this is a no-op.
|
|
345
|
+
* path a real external board ever takes), this is a no-op. `@board/config`
|
|
346
|
+
* and `@board/plugins` are excluded from the copy — they are this
|
|
347
|
+
* workspace's own seam, wired below to *this* board's files, never to
|
|
348
|
+
* whatever apps/community's own tsconfig happens to alias them to.
|
|
140
349
|
*/
|
|
141
350
|
function monorepoAliases() {
|
|
142
351
|
const configFile = process.env.FORUM_ALIASES_FROM
|
|
@@ -148,15 +357,20 @@ function monorepoAliases() {
|
|
|
148
357
|
|
|
149
358
|
const aliases = {}
|
|
150
359
|
for (const [alias, targets] of Object.entries(paths)) {
|
|
151
|
-
// `@board/config`/`@board/plugins` are this workspace's own seam, wired
|
|
152
|
-
// below to *this* board's files — never to whatever apps/community's own
|
|
153
|
-
// tsconfig happens to alias them to.
|
|
154
360
|
if (alias === '@board/config' || alias === '@board/plugins') continue
|
|
155
361
|
aliases[alias] = targets.map((target) => toPosixRelative(appDir, join(sourceDir, target)))
|
|
156
362
|
}
|
|
157
363
|
return aliases
|
|
158
364
|
}
|
|
159
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Replaces each shipped entry (`APP_ENTRIES`) but never `rm -rf`s the whole
|
|
368
|
+
* `.meith/app` directory: `.next` lives there too once a build has run, and
|
|
369
|
+
* `forum-web start` needs that build still on disk after this same function
|
|
370
|
+
* re-materializes the sources ahead of launching the standalone server. The
|
|
371
|
+
* `next-env.d.ts` it writes only needs to exist, not be complete — next
|
|
372
|
+
* regenerates it with the right content on first run.
|
|
373
|
+
*/
|
|
160
374
|
function materialize() {
|
|
161
375
|
const boardConfig = join(workspaceRoot, 'community.config.ts')
|
|
162
376
|
const boardPlugins = join(workspaceRoot, 'community.plugins.ts')
|
|
@@ -168,16 +382,18 @@ function materialize() {
|
|
|
168
382
|
)
|
|
169
383
|
}
|
|
170
384
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
385
|
+
const rootFiles = atRoot ? intendedRootFiles() : null
|
|
386
|
+
if (rootFiles) {
|
|
387
|
+
claimRootFiles(rootFiles)
|
|
388
|
+
removeStaleRootFiles(rootFiles)
|
|
389
|
+
}
|
|
390
|
+
|
|
175
391
|
mkdirSync(appDir, { recursive: true })
|
|
176
392
|
|
|
177
393
|
for (const entry of APP_ENTRIES) {
|
|
178
394
|
const source = join(packageRoot, entry)
|
|
179
395
|
const target = join(appDir, entry)
|
|
180
|
-
rmSync(target, { recursive: true, force: true })
|
|
396
|
+
if (!atRoot) rmSync(target, { recursive: true, force: true })
|
|
181
397
|
if (!existsSync(source)) continue
|
|
182
398
|
cpSync(source, target, { recursive: true })
|
|
183
399
|
}
|
|
@@ -213,20 +429,25 @@ function materialize() {
|
|
|
213
429
|
|
|
214
430
|
writeFileSync(join(appDir, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`)
|
|
215
431
|
|
|
216
|
-
// next dev/build reads its own next-env.d.ts if present; an empty one is
|
|
217
|
-
// enough, and next regenerates it with the right content on first run.
|
|
218
432
|
writeFileSync(
|
|
219
433
|
join(appDir, 'next-env.d.ts'),
|
|
220
434
|
'/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
|
|
221
435
|
)
|
|
436
|
+
|
|
437
|
+
if (rootFiles) {
|
|
438
|
+
recordRootFiles(rootFiles)
|
|
439
|
+
warnAboutStrayBoardFiles(rootFiles)
|
|
440
|
+
}
|
|
222
441
|
}
|
|
223
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Resolved from this package's own directory rather than the workspace root:
|
|
445
|
+
* `next` is `@meith/web`'s dependency, not necessarily the workspace
|
|
446
|
+
* manifest's, and resolving from here finds it either way — hoisted to the
|
|
447
|
+
* workspace root (npm, yarn classic) or nested under this package's own
|
|
448
|
+
* `node_modules` (pnpm's default, non-hoisted layout).
|
|
449
|
+
*/
|
|
224
450
|
function resolveNextBin() {
|
|
225
|
-
// Resolved from this package's own directory rather than the workspace
|
|
226
|
-
// root: `next` is @meith/web's dependency, not necessarily the workspace
|
|
227
|
-
// manifest's, and resolving from here finds it either way — hoisted to
|
|
228
|
-
// the workspace root (npm, yarn classic) or nested under this package's
|
|
229
|
-
// own node_modules (pnpm's default, non-hoisted layout).
|
|
230
451
|
const require = createRequire(join(packageRoot, 'package.json'))
|
|
231
452
|
try {
|
|
232
453
|
return require.resolve('next/dist/bin/next')
|
|
@@ -235,37 +456,60 @@ function resolveNextBin() {
|
|
|
235
456
|
}
|
|
236
457
|
}
|
|
237
458
|
|
|
238
|
-
function
|
|
459
|
+
function standaloneAppDir() {
|
|
460
|
+
return join(appDir, '.next', 'standalone', relative(workspaceRoot, appDir))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function stageStandaloneAssets() {
|
|
464
|
+
const targetAppDir = standaloneAppDir()
|
|
465
|
+
|
|
466
|
+
const staticTarget = join(targetAppDir, '.next', 'static')
|
|
467
|
+
rmSync(staticTarget, { recursive: true, force: true })
|
|
468
|
+
cpSync(join(appDir, '.next', 'static'), staticTarget, { recursive: true })
|
|
469
|
+
|
|
470
|
+
const publicSource = join(appDir, 'public')
|
|
471
|
+
const publicTarget = join(targetAppDir, 'public')
|
|
472
|
+
rmSync(publicTarget, { recursive: true, force: true })
|
|
473
|
+
if (existsSync(publicSource)) {
|
|
474
|
+
cpSync(publicSource, publicTarget, { recursive: true })
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function run(executable, args, cwd, onSuccess) {
|
|
239
479
|
const child = spawn(executable, args, { cwd, stdio: 'inherit' })
|
|
240
|
-
child.on('exit', (code, signal) =>
|
|
480
|
+
child.on('exit', (code, signal) => {
|
|
481
|
+
const exitCode = code ?? (signal ? 1 : 0)
|
|
482
|
+
if (exitCode === 0 && onSuccess) onSuccess()
|
|
483
|
+
process.exit(exitCode)
|
|
484
|
+
})
|
|
241
485
|
child.on('error', (error) => fail(error.message))
|
|
242
486
|
}
|
|
243
487
|
|
|
244
488
|
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
245
|
-
const [
|
|
489
|
+
const [command, ...rest] = process.argv.slice(2).filter((argument) => argument !== AT_ROOT_FLAG)
|
|
246
490
|
|
|
247
491
|
if (!['dev', 'build', 'start'].includes(command ?? '')) {
|
|
248
|
-
console.error(
|
|
492
|
+
console.error(`Usage: forum-web <dev|build|start> [${AT_ROOT_FLAG}] [next arguments]`)
|
|
249
493
|
process.exit(1)
|
|
250
494
|
}
|
|
251
495
|
|
|
252
496
|
materialize()
|
|
253
497
|
|
|
254
498
|
if (command === 'start') {
|
|
255
|
-
|
|
256
|
-
// run from its own traced server.js, not `next start` (see docker/Dockerfile
|
|
257
|
-
// and docker/entrypoint.sh, which run the image's board the same way). The
|
|
258
|
-
// tracing root is the workspace root (see the module comment on why
|
|
259
|
-
// `.meith/app` sits exactly two levels below it), so the standalone bundle
|
|
260
|
-
// preserves that same relative path down to the app directory.
|
|
499
|
+
const targetAppDir = standaloneAppDir()
|
|
261
500
|
const standaloneRoot = join(appDir, '.next', 'standalone')
|
|
262
|
-
const serverScript = join(
|
|
501
|
+
const serverScript = join(targetAppDir, 'server.js')
|
|
263
502
|
if (!existsSync(serverScript)) {
|
|
264
503
|
fail(`no standalone build at ${serverScript} — run "forum-web build" first.`)
|
|
265
504
|
}
|
|
266
505
|
run(process.execPath, [serverScript, ...rest], standaloneRoot)
|
|
267
506
|
} else {
|
|
268
507
|
const nextBin = resolveNextBin()
|
|
269
|
-
run(
|
|
508
|
+
run(
|
|
509
|
+
process.execPath,
|
|
510
|
+
[nextBin, command, ...rest],
|
|
511
|
+
appDir,
|
|
512
|
+
command === 'build' ? stageStandaloneAssets : undefined,
|
|
513
|
+
)
|
|
270
514
|
}
|
|
271
515
|
}
|
package/bin/forum-web.test.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
2
8
|
|
|
3
9
|
// @ts-expect-error forum-web.mjs ships untyped, imported directly for its pure export
|
|
4
10
|
import { rebaseGlobalsCssSources } from './forum-web.mjs'
|
|
@@ -28,6 +34,35 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
28
34
|
expect(rewritten).toContain('.foo { color: red; }')
|
|
29
35
|
})
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Against the real shipped file, not a fixture: this rebase now runs on
|
|
39
|
+
* every materialization including the default one, where it must be a
|
|
40
|
+
* no-op. Every `@source` in that file is written relative to the workspace
|
|
41
|
+
* root, and this only stays a no-op while that holds — a `@source` meaning
|
|
42
|
+
* anything else (`../fonts`, for `src/fonts`) would be silently retargeted
|
|
43
|
+
* at the workspace root, and fails here instead.
|
|
44
|
+
*/
|
|
45
|
+
const shippedGlobalsCss = readFileSync(
|
|
46
|
+
new URL('../src/styles/globals.css', import.meta.url),
|
|
47
|
+
'utf8',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it('leaves the shipped globals.css byte for byte alone at the default depth', () => {
|
|
51
|
+
expect(
|
|
52
|
+
rebaseGlobalsCssSources(shippedGlobalsCss, '/board/.meith/app/src/styles', '/board'),
|
|
53
|
+
).toBe(shippedGlobalsCss)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('rebases every shipped @source against the board root at depth zero', () => {
|
|
57
|
+
const rewritten = rebaseGlobalsCssSources(shippedGlobalsCss, '/board/src/styles', '/board')
|
|
58
|
+
const sources = [...rewritten.matchAll(/@source "([^"]+)";/g)].map((match) => match[1])
|
|
59
|
+
|
|
60
|
+
expect(sources.length).toBeGreaterThan(0)
|
|
61
|
+
expect(sources).toContain('../../themes')
|
|
62
|
+
expect(sources).toContain('../../packages/ui/src')
|
|
63
|
+
for (const source of sources) expect(source.startsWith('../../')).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
31
66
|
it('is a no-op for a file with no @source lines', () => {
|
|
32
67
|
const css = '@import "tailwindcss";\n.foo { color: red; }\n'
|
|
33
68
|
expect(rebaseGlobalsCssSources(css, '/repo/boards/stock/.meith/app/src/styles', '/repo')).toBe(
|
|
@@ -35,3 +70,131 @@ describe('rebaseGlobalsCssSources', () => {
|
|
|
35
70
|
)
|
|
36
71
|
})
|
|
37
72
|
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `forum-web start --at-root` materializes and then stops, because there is
|
|
76
|
+
* no standalone build for it to exec — which makes it the whole
|
|
77
|
+
* materialization path in under a tenth of a second, with no `next build`
|
|
78
|
+
* anywhere near it. Materialization is the part that has been wrong; the
|
|
79
|
+
* build is not.
|
|
80
|
+
*/
|
|
81
|
+
const BIN = fileURLToPath(new URL('./forum-web.mjs', import.meta.url))
|
|
82
|
+
|
|
83
|
+
function materializeAtRoot(dir: string) {
|
|
84
|
+
const result = spawnSync(process.execPath, [BIN, 'start', '--at-root'], {
|
|
85
|
+
cwd: dir,
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
})
|
|
88
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
|
|
89
|
+
return {
|
|
90
|
+
output,
|
|
91
|
+
refused: output.includes('refusing to overwrite'),
|
|
92
|
+
materialized: output.includes('no standalone build'),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('materializing at the workspace root', () => {
|
|
97
|
+
const boards: string[] = []
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
for (const dir of boards) rmSync(dir, { recursive: true, force: true })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
function board(): string {
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), 'forum-web-at-root-'))
|
|
105
|
+
writeFileSync(join(dir, 'community.config.ts'), 'export default {}\n')
|
|
106
|
+
writeFileSync(join(dir, 'community.plugins.ts'), 'export const INSTALLED_PLUGINS = []\n')
|
|
107
|
+
boards.push(dir)
|
|
108
|
+
return dir
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('writes the app into the workspace root and records every file it wrote', () => {
|
|
112
|
+
const dir = board()
|
|
113
|
+
|
|
114
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
115
|
+
|
|
116
|
+
for (const rel of ['next.config.mjs', 'app', 'src/styles/globals.css', 'public/sw.js']) {
|
|
117
|
+
expect(existsSync(join(dir, rel))).toBe(true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record = JSON.parse(readFileSync(join(dir, '.meith/materialized.json'), 'utf8'))
|
|
121
|
+
expect(record.files).toContain('public/sw.js')
|
|
122
|
+
expect(record.files).toContain('next.config.mjs')
|
|
123
|
+
expect(record.files).toContain('tsconfig.json')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('leaves a board file under a shared directory alone on a second run', () => {
|
|
127
|
+
const dir = board()
|
|
128
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
129
|
+
|
|
130
|
+
writeFileSync(join(dir, 'public/ads.txt'), 'board-owned\n')
|
|
131
|
+
mkdirSync(join(dir, 'public/.well-known'), { recursive: true })
|
|
132
|
+
writeFileSync(join(dir, 'public/.well-known/thing'), 'verify\n')
|
|
133
|
+
|
|
134
|
+
const second = materializeAtRoot(dir)
|
|
135
|
+
|
|
136
|
+
expect(second.refused).toBe(false)
|
|
137
|
+
expect(second.materialized).toBe(true)
|
|
138
|
+
expect(readFileSync(join(dir, 'public/ads.txt'), 'utf8')).toBe('board-owned\n')
|
|
139
|
+
expect(readFileSync(join(dir, 'public/.well-known/thing'), 'utf8')).toBe('verify\n')
|
|
140
|
+
expect(existsSync(join(dir, 'public/sw.js'))).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('refuses a board file under a name the framework ships, before writing anything', () => {
|
|
144
|
+
const dir = board()
|
|
145
|
+
writeFileSync(join(dir, 'instrumentation.ts'), 'export function register() {}\n')
|
|
146
|
+
|
|
147
|
+
const result = materializeAtRoot(dir)
|
|
148
|
+
|
|
149
|
+
expect(result.refused).toBe(true)
|
|
150
|
+
expect(result.output).toContain('instrumentation.ts')
|
|
151
|
+
expect(readFileSync(join(dir, 'instrumentation.ts'), 'utf8')).toBe(
|
|
152
|
+
'export function register() {}\n',
|
|
153
|
+
)
|
|
154
|
+
expect(existsSync(join(dir, 'app'))).toBe(false)
|
|
155
|
+
expect(existsSync(join(dir, 'next.config.mjs'))).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('proceeds on a fresh checkout whose materialized files are committed', () => {
|
|
159
|
+
const dir = board()
|
|
160
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
161
|
+
|
|
162
|
+
rmSync(join(dir, '.meith'), { recursive: true, force: true })
|
|
163
|
+
const second = materializeAtRoot(dir)
|
|
164
|
+
|
|
165
|
+
expect(second.refused).toBe(false)
|
|
166
|
+
expect(second.materialized).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('removes a file it recorded and no longer ships, and nothing else', () => {
|
|
170
|
+
const dir = board()
|
|
171
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
172
|
+
|
|
173
|
+
const recordPath = join(dir, '.meith/materialized.json')
|
|
174
|
+
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
|
|
175
|
+
writeFileSync(join(dir, 'src/dropped-by-a-release.ts'), 'stale\n')
|
|
176
|
+
writeFileSync(join(dir, 'src/mine.ts'), 'board\n')
|
|
177
|
+
record.files.push('src/dropped-by-a-release.ts')
|
|
178
|
+
writeFileSync(recordPath, JSON.stringify(record))
|
|
179
|
+
|
|
180
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
181
|
+
|
|
182
|
+
expect(existsSync(join(dir, 'src/dropped-by-a-release.ts'))).toBe(false)
|
|
183
|
+
expect(readFileSync(join(dir, 'src/mine.ts'), 'utf8')).toBe('board\n')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('warns about a board file under a directory the framework owns outright', () => {
|
|
187
|
+
const dir = board()
|
|
188
|
+
expect(materializeAtRoot(dir).materialized).toBe(true)
|
|
189
|
+
|
|
190
|
+
mkdirSync(join(dir, 'app/custom'), { recursive: true })
|
|
191
|
+
writeFileSync(join(dir, 'app/custom/page.tsx'), 'export default () => null\n')
|
|
192
|
+
|
|
193
|
+
const second = materializeAtRoot(dir)
|
|
194
|
+
|
|
195
|
+
expect(second.refused).toBe(false)
|
|
196
|
+
expect(second.output).toContain('app/custom/page.tsx')
|
|
197
|
+
expect(second.output).toContain("not @meith/web's")
|
|
198
|
+
expect(existsSync(join(dir, 'app/custom/page.tsx'))).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
})
|