@meith/web 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.
Files changed (73) hide show
  1. package/app/(board)/notifications/preferences/page.tsx +15 -1
  2. package/app/(board)/unsubscribe/page.tsx +5 -3
  3. package/app/admin/api/marketplace/screenshot/route.ts +0 -8
  4. package/app/admin/users/mail/page.tsx +1 -1
  5. package/app/layout.tsx +1 -3
  6. package/app/manifest.ts +1 -3
  7. package/bin/forum-web.mjs +1 -182
  8. package/bin/forum-web.test.ts +0 -22
  9. package/next.config.mjs +14 -29
  10. package/package.json +47 -47
  11. package/proxy.ts +0 -14
  12. package/src/components/account/notification-forms.tsx +45 -0
  13. package/src/components/admin/admin-nav.tsx +0 -4
  14. package/src/components/admin/marketplace-listing.tsx +0 -9
  15. package/src/components/auth/passkey-client.ts +0 -5
  16. package/src/components/auth/register-form.tsx +18 -0
  17. package/src/components/content/composer-ids.ts +0 -1
  18. package/src/components/content/markdown-editor.tsx +0 -7
  19. package/src/components/shell/demo-banner.tsx +0 -8
  20. package/src/config.ts +0 -11
  21. package/src/server/admin-actions.ts +0 -5
  22. package/src/server/antispam.ts +27 -14
  23. package/src/server/auth-actions.ts +12 -1
  24. package/src/server/auth-config.ts +0 -11
  25. package/src/server/auth-events.ts +0 -5
  26. package/src/server/brand-assets.ts +2 -6
  27. package/src/server/container.ts +0 -8
  28. package/src/server/cookies.ts +0 -24
  29. package/src/server/demo-uploads.ts +0 -11
  30. package/src/server/demo.ts +0 -21
  31. package/src/server/federation-actions.ts +0 -6
  32. package/src/server/federation.ts +0 -7
  33. package/src/server/feed-routes.ts +0 -5
  34. package/src/server/i18n.ts +1 -3
  35. package/src/server/location-header.ts +0 -30
  36. package/src/server/mail-send.ts +0 -6
  37. package/src/server/marketplace-actions.ts +0 -6
  38. package/src/server/marketplace-admin.ts +0 -33
  39. package/src/server/navigation.ts +0 -5
  40. package/src/server/notification-actions.ts +25 -0
  41. package/src/server/onboarding.ts +0 -5
  42. package/src/server/plugin-admin-actions.ts +0 -11
  43. package/src/server/plugin-admin.ts +0 -3
  44. package/src/server/plugin-host.ts +15 -6
  45. package/src/server/plugin-pages.ts +3 -68
  46. package/src/server/plugin-routes.ts +1 -1
  47. package/src/server/plugin-runtime.ts +78 -0
  48. package/src/server/presence.ts +3 -34
  49. package/src/server/relations.ts +1 -3
  50. package/src/server/reply-core.ts +1 -0
  51. package/src/server/request-fingerprint.ts +0 -1
  52. package/src/server/search-page.ts +0 -4
  53. package/src/server/session-actions.ts +0 -1
  54. package/src/server/settings.ts +0 -11
  55. package/src/server/subscription-actions.ts +12 -4
  56. package/src/server/syndication.ts +0 -4
  57. package/src/server/theme-admin.ts +2 -6
  58. package/src/server/thread-core.ts +6 -1
  59. package/src/server/two-factor-actions.ts +0 -5
  60. package/src/server/two-factor.ts +0 -6
  61. package/src/server/upgrade-notice.ts +1 -1
  62. package/src/view/account-copy.ts +5 -0
  63. package/src/view/auth-copy.ts +2 -0
  64. package/src/view/editor-toolbar.ts +0 -1
  65. package/src/view/navigation.ts +0 -4
  66. package/src/view/notification-menu.ts +0 -2
  67. package/src/view/notifications.ts +1 -0
  68. package/src/view/pager.ts +0 -6
  69. package/src/view/panel-nav.ts +0 -1
  70. package/src/view/security-activity.ts +0 -5
  71. package/src/view/subscriptions.ts +1 -0
  72. package/src/view/two-factor.ts +0 -5
  73. package/src/view/usercp.ts +1 -3
@@ -1,10 +1,14 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
- import { NotificationPreferencesForm } from '@/components/account/notification-forms'
4
+ import {
5
+ AnnouncementsOptInForm,
6
+ NotificationPreferencesForm,
7
+ } from '@/components/account/notification-forms'
5
8
  import { PushDeviceForm } from '@/components/account/push-device-form'
6
9
  import { BoardNotice } from '@/components/shell/board-notice'
7
10
  import { PanelPage } from '@/components/shell/panel-page'
11
+ import { getContainer } from '@/server/container'
8
12
  import { getActor } from '@/server/context'
9
13
  import { getTranslator, tr } from '@/server/i18n'
10
14
  import { audiencesForActor } from '@/server/notification-audience'
@@ -34,6 +38,9 @@ export default async function NotificationPreferencesPage({
34
38
  await Promise.all(audiences.map((audience) => service.preferences(userId, audience)))
35
39
  ).flat()
36
40
 
41
+ const { memberSettings } = getContainer()
42
+ const settings = memberSettings === null ? null : await memberSettings.read(userId)
43
+
37
44
  const push = await pushAvailability()
38
45
  const view = buildPreferencesView(rows, await getTranslator())
39
46
  const notice = notificationNotice(query, await getTranslator())
@@ -60,6 +67,13 @@ export default async function NotificationPreferencesPage({
60
67
  push={push.enabled}
61
68
  copy={notificationFormsCopy(await getTranslator())}
62
69
  />
70
+
71
+ {settings !== null && (
72
+ <AnnouncementsOptInForm
73
+ optedIn={settings.massMailOptInAt !== null}
74
+ copy={notificationFormsCopy(await getTranslator())}
75
+ />
76
+ )}
63
77
  </PanelPage>
64
78
  )
65
79
  }
@@ -60,9 +60,11 @@ export default async function UnsubscribePage({
60
60
  <UnsubscribeConfirmForm
61
61
  token={token}
62
62
  description={
63
- claim.scope === 'email'
64
- ? await tr('page.unsubscribe.emailScope')
65
- : await tr('page.unsubscribe.targetScope')
63
+ claim.scope === 'mass-mail'
64
+ ? await tr('page.unsubscribe.massMailScope')
65
+ : claim.scope === 'email'
66
+ ? await tr('page.unsubscribe.emailScope')
67
+ : await tr('page.unsubscribe.targetScope')
66
68
  }
67
69
  copy={followFormCopy(translator)}
68
70
  />
@@ -17,14 +17,6 @@ function fail(status: number, message: string): Response {
17
17
  })
18
18
  }
19
19
 
20
- /**
21
- * Streams one marketplace screenshot to the admin panel. The board fetches
22
- * it, not the browser — this is the "proxied" half of "screenshots proxied
23
- * or linked from the feed's own host only" (see docs/marketplace.md and the
24
- * MEI-80 issue). `key` and `index` are looked up against the currently
25
- * cached feed rather than trusted as a URL themselves, so nothing this route
26
- * fetches is ever chosen by the caller.
27
- */
28
20
  export async function GET(request: NextRequest): Promise<Response> {
29
21
  try {
30
22
  await requireAdmin()
@@ -58,7 +58,7 @@ export default async function AdminMassMailPage() {
58
58
  <p>{await tr('page.before-press-it')}</p>
59
59
  <ul className="flex list-disc flex-col gap-1 pl-4">
60
60
  <li>{translator.t('adminUsers.massMailQueued')}</li>
61
- <li>{translator.t('adminUsers.massMailNoUnsubscribe')}</li>
61
+ <li>{translator.t('adminUsers.massMailOptIn')}</li>
62
62
  <li>{translator.t('adminUsers.massMailContinues')}</li>
63
63
  <li>{await tr('page.email-cannot-be-unsent')}</li>
64
64
  </ul>
package/app/layout.tsx CHANGED
@@ -41,9 +41,7 @@ export async function generateMetadata(): Promise<Metadata> {
41
41
  name = settings.get('board.name').trim() || BOARD_TITLE
42
42
  description = settings.get('board.description').trim() || undefined
43
43
  origin = resolveBoardUrl({ environment: env, settings }).url || origin
44
- } catch {
45
- /* ignore */
46
- }
44
+ } catch {}
47
45
 
48
46
  return {
49
47
  title: {
package/app/manifest.ts CHANGED
@@ -16,9 +16,7 @@ export default async function manifest(): Promise<MetadataRoute.Manifest> {
16
16
  const settings = await getSettings()
17
17
  name = settings.get('board.name').trim() || BOARD_TITLE
18
18
  description = settings.get('board.description').trim()
19
- } catch {
20
- /* ignore */
21
- }
19
+ } catch {}
22
20
 
23
21
  const { browserThemeColor } = await getThemeRuntimeStyle()
24
22
 
package/bin/forum-web.mjs CHANGED
@@ -1,81 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * `forum-web` — the bin that makes `@meith/web` runnable as a plain
4
- * dependency of an external board workspace (see docs/development.md,
5
- * "Consuming the board from a workspace").
6
- *
7
- * A Next.js app is not consumable as a bare dependency: `next dev|build|start`
8
- * need to run with the app's own directory as the project root, and the
9
- * board-config seam (`@board/config` / `@board/plugins`, see
10
- * docs/architecture.md) is a pair of tsconfig path aliases that inside this
11
- * monorepo point at `apps/community`'s own files. Neither survives npm
12
- * installing this package into somebody else's workspace unchanged.
13
- *
14
- * So on every invocation this bin:
15
- *
16
- * 1. Copies this package's own Next app sources into `.meith/app/` inside
17
- * the invoking workspace (the current working directory) — gitignored,
18
- * regenerated every time, never a merge target.
19
- * 2. Writes `.meith/app/tsconfig.json`, a fresh tsconfig whose `paths`
20
- * point `@board/config` and `@board/plugins` at *this workspace's own*
21
- * `community.config.ts` / `community.plugins.ts` — the same seam, wired
22
- * to a different pair of files. tsconfig `paths` are a compiler/bundler
23
- * alias, not a package boundary, so nothing stops one from naming a
24
- * path two directories up; that is the whole trick.
25
- * 3. Runs `next dev|build|start` with `.meith/app/` as the project root.
26
- *
27
- * `.meith/app/` sits exactly two directories below the workspace root
28
- * (`<root>/.meith/app`) on purpose: `next.config.mjs` (copied verbatim, see
29
- * below) computes its own workspace root as two directories up from itself,
30
- * so materializing at that exact depth keeps that computation correct
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".
57
- *
58
- * This assumes a *hoisted* `node_modules` (npm, yarn classic, or pnpm with
59
- * `node-linker=hoisted`) — see docs/development.md for why.
60
- *
61
- * `boards/stock` (docker/Dockerfile) is this bin's one *in-repo* consumer,
62
- * and it has neither a hoisted `node_modules` nor a two-directories-up
63
- * workspace root — it is a workspace member of this monorepo's own
64
- * (non-hoisted) pnpm install, nested two directories deeper again. Two
65
- * environment variables, set only by that workspace's own `build`/`dev`
66
- * scripts, cover the difference without changing anything for a real
67
- * external board, which sets neither itself: `FORUM_WORKSPACE_ROOT`
68
- * (apps/community/next.config.mjs) points tracing at this repository's real
69
- * root, and `FORUM_ALIASES_FROM` (`monorepoAliases()` below) carries this
70
- * repository's own `@meith/*` tsconfig aliases into the generated tsconfig,
71
- * since packages here resolve each other through those aliases rather than
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.
78
- */
79
2
  import { spawn } from 'node:child_process'
80
3
  import {
81
4
  cpSync,
@@ -105,12 +28,6 @@ for (const name of ['FORUM_WORKSPACE_ROOT', 'FORUM_ALIASES_FROM']) {
105
28
  }
106
29
  if (!process.env.FORUM_WORKSPACE_ROOT) process.env.FORUM_WORKSPACE_ROOT = workspaceRoot
107
30
 
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
- */
114
31
  const APP_ENTRIES = [
115
32
  'app',
116
33
  'src',
@@ -141,15 +58,6 @@ function walkFiles(dir, prefix, into) {
141
58
  return into
142
59
  }
143
60
 
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
61
  function intendedRootFiles() {
154
62
  const files = []
155
63
  for (const entry of APP_ENTRIES) {
@@ -174,13 +82,6 @@ function absoluteRootPath(rel) {
174
82
  return join(workspaceRoot, ...rel.split('/'))
175
83
  }
176
84
 
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
85
  function materializedContent(rel) {
185
86
  const source = readFileSync(join(packageRoot, ...rel.split('/')))
186
87
  if (rel !== GLOBALS_CSS) return source
@@ -213,17 +114,6 @@ function pruneEmptyDirectories(rel) {
213
114
  }
214
115
  }
215
116
 
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
117
  function claimRootFiles(intended) {
228
118
  const owned = new Set(readMaterializedRecord())
229
119
 
@@ -247,12 +137,6 @@ function claimRootFiles(intended) {
247
137
  }
248
138
  }
249
139
 
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
140
  function removeStaleRootFiles(intended) {
257
141
  const keeping = new Set(intended)
258
142
  for (const rel of readMaterializedRecord()) {
@@ -267,15 +151,6 @@ function recordRootFiles(intended) {
267
151
  writeFileSync(MATERIALIZED_RECORD, `${JSON.stringify({ files: intended }, null, 2)}\n`)
268
152
  }
269
153
 
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
154
  function warnAboutStrayBoardFiles(intended) {
280
155
  const written = new Set(intended)
281
156
  const owned = APP_ENTRIES.filter((entry) => !SHARED_ENTRIES.includes(entry))
@@ -297,7 +172,7 @@ function warnAboutStrayBoardFiles(intended) {
297
172
  'They are left exactly as they are. A scaffolded board gitignores these directories as ' +
298
173
  'a unit, though, so nothing above is committed, and a deploy that builds from the ' +
299
174
  '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.',
175
+ '(docs/customization/plugins.md, docs/customization/themes.md), not by adding files here.',
301
176
  )
302
177
  }
303
178
 
@@ -309,24 +184,6 @@ function toPosixRelative(from, to) {
309
184
  const SOURCE_LINE = /[ \t]*@source "((?:\.\.\/)+)([^"]+)";\n?/g
310
185
  const BOARD_PACKAGES = ['node_modules', '@meith']
311
186
 
312
- /**
313
- * MEI-131: every `@source` in globals.css names a directory of *this*
314
- * repository — `themes`, `plugins`, `packages/ui/src`. Rebasing them onto the
315
- * board's workspace root is right for the one in-repo consumer (boards/stock,
316
- * whose FORUM_WORKSPACE_ROOT is this repo) and wrong for every scaffolded
317
- * board, where none of those directories exist: the same code is installed
318
- * under `node_modules/@meith` instead. Tailwind does not fail on a `@source`
319
- * that resolves to nothing — it builds green and emits no utilities for
320
- * anything it could not scan — so a board deployed this way served a page
321
- * with no styling at all.
322
- *
323
- * A source whose target is really there is kept and rebased as before. One
324
- * that is not is dropped in favour of a single source over the installed
325
- * packages, which covers the themes, `@meith/ui` and the plugins together.
326
- * Tailwind ignores `node_modules` when detecting sources itself, but honours
327
- * it when named here, and follows the symlinks a pnpm install leaves — both
328
- * confirmed against the version this app pins.
329
- */
330
187
  export function rebaseGlobalsCssSources(css, cssDir, workspaceRoot, exists = existsSync) {
331
188
  const rebased = []
332
189
  let missing = false
@@ -359,29 +216,6 @@ function rewriteGlobalsCssSourcePaths() {
359
216
  )
360
217
  }
361
218
 
362
- /**
363
- * A board outside this monorepo has a hoisted `node_modules` (see the module
364
- * comment), so plain bare-specifier resolution reaches every `@meith/*`
365
- * package this app's dependency graph needs, and `transpilePackages`
366
- * (apps/community/next.config.mjs) is what lets Next compile the `.ts`
367
- * source that resolution lands on. A board built *inside* this monorepo's
368
- * own pnpm install has no such hoisting — packages here resolve each other
369
- * through tsconfig path aliases straight to source (docs/architecture.md,
370
- * "The board-config seam"; docs/development.md, "The workspace") rather than
371
- * through real `dependencies` entries, so plain node_modules resolution
372
- * finds nothing for most of them.
373
- *
374
- * `FORUM_ALIASES_FROM`, set only by docker/Dockerfile for `boards/stock`,
375
- * names that other tsconfig (this repository's own `tsconfig.base.json`).
376
- * When set, every `@meith/*` alias it declares is copied into the
377
- * materialized app's own generated tsconfig, rebased to be relative to
378
- * `.meith/app` — the same aliases apps/community's own tsconfig.json
379
- * hand-maintains for exactly this reason. Unset (the default, and the only
380
- * path a real external board ever takes), this is a no-op. `@board/config`
381
- * and `@board/plugins` are excluded from the copy — they are this
382
- * workspace's own seam, wired below to *this* board's files, never to
383
- * whatever apps/community's own tsconfig happens to alias them to.
384
- */
385
219
  function monorepoAliases() {
386
220
  const configFile = process.env.FORUM_ALIASES_FROM
387
221
  if (!configFile) return {}
@@ -398,14 +232,6 @@ function monorepoAliases() {
398
232
  return aliases
399
233
  }
400
234
 
401
- /**
402
- * Replaces each shipped entry (`APP_ENTRIES`) but never `rm -rf`s the whole
403
- * `.meith/app` directory: `.next` lives there too once a build has run, and
404
- * `forum-web start` needs that build still on disk after this same function
405
- * re-materializes the sources ahead of launching the standalone server. The
406
- * `next-env.d.ts` it writes only needs to exist, not be complete — next
407
- * regenerates it with the right content on first run.
408
- */
409
235
  function materialize() {
410
236
  const boardConfig = join(workspaceRoot, 'community.config.ts')
411
237
  const boardPlugins = join(workspaceRoot, 'community.plugins.ts')
@@ -475,13 +301,6 @@ function materialize() {
475
301
  }
476
302
  }
477
303
 
478
- /**
479
- * Resolved from this package's own directory rather than the workspace root:
480
- * `next` is `@meith/web`'s dependency, not necessarily the workspace
481
- * manifest's, and resolving from here finds it either way — hoisted to the
482
- * workspace root (npm, yarn classic) or nested under this package's own
483
- * `node_modules` (pnpm's default, non-hoisted layout).
484
- */
485
304
  function resolveNextBin() {
486
305
  const require = createRequire(join(packageRoot, 'package.json'))
487
306
  try {
@@ -36,14 +36,6 @@ describe('rebaseGlobalsCssSources', () => {
36
36
  expect(rewritten).toContain('.foo { color: red; }')
37
37
  })
38
38
 
39
- /**
40
- * Against the real shipped file, not a fixture: this rebase now runs on
41
- * every materialization including the default one, where it must be a
42
- * no-op. Every `@source` in that file is written relative to the workspace
43
- * root, and this only stays a no-op while that holds — a `@source` meaning
44
- * anything else (`../fonts`, for `src/fonts`) would be silently retargeted
45
- * at the workspace root, and fails here instead.
46
- */
47
39
  const shippedGlobalsCss = readFileSync(
48
40
  new URL('../src/styles/globals.css', import.meta.url),
49
41
  'utf8',
@@ -87,13 +79,6 @@ describe('rebaseGlobalsCssSources', () => {
87
79
  ).toBe(css)
88
80
  })
89
81
 
90
- /**
91
- * MEI-131. A scaffolded board has none of the directories these sources
92
- * name — the same code is installed under `node_modules/@meith` — and
93
- * Tailwind emits no utilities for a source it cannot scan while still
94
- * exiting 0, so the failure is a board that serves an unstyled page rather
95
- * than a build that stops.
96
- */
97
82
  describe('in a board, where none of those directories exist', () => {
98
83
  const inBoard = (path: string) => path.includes('node_modules')
99
84
 
@@ -157,13 +142,6 @@ describe('rebaseGlobalsCssSources', () => {
157
142
  })
158
143
  })
159
144
 
160
- /**
161
- * `forum-web start --at-root` materializes and then stops, because there is
162
- * no standalone build for it to exec — which makes it the whole
163
- * materialization path in under a tenth of a second, with no `next build`
164
- * anywhere near it. Materialization is the part that has been wrong; the
165
- * build is not.
166
- */
167
145
  const BIN = fileURLToPath(new URL('./forum-web.mjs', import.meta.url))
168
146
 
169
147
  function materializeAtRoot(dir: string) {
package/next.config.mjs CHANGED
@@ -1,33 +1,13 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
5
  const here = path.dirname(fileURLToPath(import.meta.url))
6
6
 
7
- /**
8
- * Two directories up from this file's own location by default — correct
9
- * where `forum-web` materializes at `.meith/app` and where this file sits in
10
- * `apps/community` unmaterialized. `boards/stock` and `forum-web --at-root`
11
- * are both at other depths, and neither relies on this default:
12
- * `FORUM_WORKSPACE_ROOT` is set for the first by that board's own scripts
13
- * and passed on by `forum-web` in every case, so this fallback only applies
14
- * when the file is read outside that bin entirely (see
15
- * docs/architecture.md, "The stock board"; docs/development.md, "Consuming
16
- * the board from a workspace").
17
- */
18
7
  const workspaceRoot = process.env.FORUM_WORKSPACE_ROOT
19
8
  ? path.resolve(process.env.FORUM_WORKSPACE_ROOT)
20
9
  : path.join(here, '../../')
21
10
 
22
- /**
23
- * The relative equivalent of `workspaceRoot`, for `outputFileTracingIncludes`
24
- * below — see docs/development.md, "Consuming the board from a workspace",
25
- * for why that option needs a path relative to this file rather than an
26
- * absolute one. It is `.` rather than the empty string when this file already
27
- * sits at the workspace root, which is what `forum-web build --at-root`
28
- * materializes (same section) — an empty prefix would make the glob below
29
- * read as an absolute path and match nothing.
30
- */
31
11
  const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/') || '.'
32
12
 
33
13
  const loadedEnvFiles = []
@@ -42,6 +22,8 @@ if (process.env.NODE_ENV !== 'production' && loadedEnvFiles.length > 0) {
42
22
  console.log(`- Environments: ${loadedEnvFiles.join(', ')} (${workspaceRoot})`)
43
23
  }
44
24
 
25
+ const FRAMEWORK_PACKAGES = ['next', 'react', 'react-dom']
26
+
45
27
  const nextConfig = {
46
28
  ...(process.env.VERCEL ? {} : { output: 'standalone' }),
47
29
  poweredByHeader: false,
@@ -58,11 +40,9 @@ const nextConfig = {
58
40
  'nodemailer',
59
41
  ],
60
42
  outputFileTracingRoot: workspaceRoot,
61
- /** See docs/development.md, "Consuming the board from a workspace", for why this is set. */
62
43
  turbopack: {
63
44
  root: workspaceRoot,
64
45
  },
65
- /** Works around a gap in Next's own tracing — see docs/development.md, "Consuming the board from a workspace". */
66
46
  outputFileTracingIncludes: {
67
47
  '/**': [
68
48
  `${upToWorkspaceRoot}/node_modules/.pnpm/@swc+helpers@0.5.23/node_modules/@swc/helpers/**/*`,
@@ -72,12 +52,6 @@ const nextConfig = {
72
52
  images: {
73
53
  unoptimized: true,
74
54
  },
75
- /**
76
- * Every `@meith/*` package this app's dependency graph reaches, not just the
77
- * ones apps/community imports directly — see docs/development.md,
78
- * "Consuming the board from a workspace", and docs/release.md, "They ship
79
- * TypeScript source, deliberately".
80
- */
81
55
  transpilePackages: [
82
56
  '@meith/accounts',
83
57
  '@meith/admin',
@@ -146,4 +120,15 @@ const nextConfig = {
146
120
  },
147
121
  }
148
122
 
123
+ const boardManifestFile = path.join(workspaceRoot, 'package.json')
124
+ if (existsSync(boardManifestFile)) {
125
+ const boardManifest = JSON.parse(readFileSync(boardManifestFile, 'utf8'))
126
+ for (const name of Object.keys(boardManifest.dependencies ?? {})) {
127
+ if (FRAMEWORK_PACKAGES.includes(name)) continue
128
+ if (nextConfig.serverExternalPackages.includes(name)) continue
129
+ if (nextConfig.transpilePackages.includes(name)) continue
130
+ nextConfig.transpilePackages.push(name)
131
+ }
132
+ }
133
+
149
134
  export default nextConfig
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,52 +43,52 @@
43
43
  "typescript": "7.0.2",
44
44
  "@types/react": "^19",
45
45
  "@types/react-dom": "^19",
46
- "@meith/accounts": "0.21.2",
47
- "@meith/admin": "0.21.2",
48
- "@meith/antispam": "0.21.2",
49
- "@meith/authorization": "0.21.2",
50
- "@meith/api": "0.21.2",
51
- "@meith/attachments": "0.21.2",
52
- "@meith/avatars": "0.21.2",
53
- "@meith/db": "0.21.2",
54
- "@meith/core": "0.21.2",
55
- "@meith/demo": "0.21.2",
56
- "@meith/drivers": "0.21.2",
57
- "@meith/events": "0.21.2",
58
- "@meith/drafts": "0.21.2",
59
- "@meith/forums": "0.21.2",
60
- "@meith/i18n": "0.21.2",
61
- "@meith/import": "0.21.2",
62
- "@meith/groups": "0.21.2",
63
- "@meith/install": "0.21.2",
64
- "@meith/mail": "0.21.2",
65
- "@meith/markdown": "0.21.2",
66
- "@meith/marketplace": "0.21.2",
67
- "@meith/messages": "0.21.2",
68
- "@meith/moderation": "0.21.2",
69
- "@meith/plugin-dues": "0.21.2",
70
- "@meith/notifications": "0.21.2",
71
- "@meith/polls": "0.21.2",
72
- "@meith/plugin-kit": "0.21.2",
73
- "@meith/reputation": "0.21.2",
74
- "@meith/relations": "0.21.2",
75
- "@meith/profile-fields": "0.21.2",
76
- "@meith/runtime": "0.21.2",
77
- "@meith/settings": "0.21.2",
78
- "@meith/search": "0.21.2",
79
- "@meith/posts": "0.21.2",
80
- "@meith/signatures": "0.21.2",
81
- "@meith/subscriptions": "0.21.2",
82
- "@meith/theme-clubhouse": "0.21.2",
83
- "@meith/theme-default": "0.21.2",
84
- "@meith/tasks": "0.21.2",
85
- "@meith/theme-kit": "0.21.2",
86
- "@meith/theme-phasebook": "0.21.2",
87
- "@meith/theme-raidframe": "0.21.2",
88
- "@meith/theme-midnight": "0.21.2",
89
- "@meith/threads": "0.21.2",
90
- "@meith/ui": "0.21.2",
91
- "@meith/upgrade": "0.21.2"
46
+ "@meith/admin": "0.22.0",
47
+ "@meith/antispam": "0.22.0",
48
+ "@meith/api": "0.22.0",
49
+ "@meith/accounts": "0.22.0",
50
+ "@meith/attachments": "0.22.0",
51
+ "@meith/authorization": "0.22.0",
52
+ "@meith/avatars": "0.22.0",
53
+ "@meith/core": "0.22.0",
54
+ "@meith/drafts": "0.22.0",
55
+ "@meith/db": "0.22.0",
56
+ "@meith/drivers": "0.22.0",
57
+ "@meith/demo": "0.22.0",
58
+ "@meith/groups": "0.22.0",
59
+ "@meith/forums": "0.22.0",
60
+ "@meith/import": "0.22.0",
61
+ "@meith/events": "0.22.0",
62
+ "@meith/i18n": "0.22.0",
63
+ "@meith/install": "0.22.0",
64
+ "@meith/markdown": "0.22.0",
65
+ "@meith/marketplace": "0.22.0",
66
+ "@meith/mail": "0.22.0",
67
+ "@meith/messages": "0.22.0",
68
+ "@meith/notifications": "0.22.0",
69
+ "@meith/plugin-dues": "0.22.0",
70
+ "@meith/moderation": "0.22.0",
71
+ "@meith/plugin-kit": "0.22.0",
72
+ "@meith/posts": "0.22.0",
73
+ "@meith/polls": "0.22.0",
74
+ "@meith/profile-fields": "0.22.0",
75
+ "@meith/relations": "0.22.0",
76
+ "@meith/reputation": "0.22.0",
77
+ "@meith/runtime": "0.22.0",
78
+ "@meith/search": "0.22.0",
79
+ "@meith/settings": "0.22.0",
80
+ "@meith/signatures": "0.22.0",
81
+ "@meith/subscriptions": "0.22.0",
82
+ "@meith/theme-kit": "0.22.0",
83
+ "@meith/theme-clubhouse": "0.22.0",
84
+ "@meith/theme-default": "0.22.0",
85
+ "@meith/theme-phasebook": "0.22.0",
86
+ "@meith/theme-midnight": "0.22.0",
87
+ "@meith/theme-raidframe": "0.22.0",
88
+ "@meith/threads": "0.22.0",
89
+ "@meith/ui": "0.22.0",
90
+ "@meith/tasks": "0.22.0",
91
+ "@meith/upgrade": "0.22.0"
92
92
  },
93
93
  "scripts": {
94
94
  "dev": "next dev",
package/proxy.ts CHANGED
@@ -48,20 +48,6 @@ interface Policy {
48
48
  readonly value: string
49
49
  }
50
50
 
51
- /**
52
- * Passes the request through, and hands a reader something to be counted by.
53
- *
54
- * The cookie is minted here because the Edge is the only place that can set one
55
- * on an ordinary page response — a render cannot. The row it stands for is
56
- * written by the render, which has the database this runtime does not.
57
- *
58
- * Whether the client *returned* the cookie is the load-bearing part, and it
59
- * cannot be read downstream: Next reflects a cookie set here into the request
60
- * the render sees, so a first-ever visit is indistinguishable from a returning
61
- * one by cookie alone. Hence the header — set when the cookie is new, deleted
62
- * otherwise, so a crawler that never keeps one is never counted and a client
63
- * cannot supply its own.
64
- */
65
51
  function withPath(req: NextRequest, policy: Policy): NextResponse {
66
52
  const fresh = !req.cookies.has(GUEST_COOKIE) && !req.cookies.has(DEV_GUEST_COOKIE)
67
53