@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/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 never sets either: `FORUM_WORKSPACE_ROOT`
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 appDir = join(workspaceRoot, '.meith', 'app')
67
-
68
- // `next dev|build` runs with `.meith/app` as its own cwd (see `run()` below),
69
- // so a relative FORUM_WORKSPACE_ROOT / FORUM_ALIASES_FROM — the shape
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
- // Files this package ships (see its `files` allowlist) that belong inside
80
- // the materialized app. Board files (community.config.ts, board.plugins.json,
81
- // community.plugins.ts) are never copied they are read in place, from the
82
- // workspace, through the generated tsconfig instead.
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 workspaceRootOverride = process.env.FORUM_WORKSPACE_ROOT
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(cssPath, rebaseGlobalsCssSources(css, dirname(cssPath), workspaceRootOverride))
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
- // Replace each shipped entry, but never `rm -rf` the whole directory:
172
- // `.next` lives here too once a build has run, and `forum-web start` needs
173
- // that build still on disk after this same function re-materializes the
174
- // sources ahead of launching the standalone server.
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 run(executable, args, cwd) {
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) => process.exit(code ?? (signal ? 1 : 0)))
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 [, , command, ...rest] = process.argv
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('Usage: forum-web <dev|build|start> [next arguments]')
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
- // `next.config.mjs` sets `output: 'standalone'`, and a standalone build is
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(standaloneRoot, relative(workspaceRoot, appDir), 'server.js')
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(process.execPath, [nextBin, command, ...rest], appDir)
508
+ run(
509
+ process.execPath,
510
+ [nextBin, command, ...rest],
511
+ appDir,
512
+ command === 'build' ? stageStandaloneAssets : undefined,
513
+ )
270
514
  }
271
515
  }
@@ -1,4 +1,10 @@
1
- import { describe, expect, it } from 'vitest'
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
+ })