serverless-ircd 0.8.0 → 0.9.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -4,8 +4,9 @@
4
4
 
5
5
  Served at `/` by the Worker `[assets]` binding (apps/cf-worker/wrangler.toml).
6
6
  The build (`scripts/build.mjs`) copies this file verbatim to `dist/index.html`.
7
- The Kiwi IRC SPA lives at `/webclient/`; this page links through to it plus
8
- the project docs and source repo. Pure static HTML + inline CSS, no JavaScript.
7
+ The Kiwi IRC SPA lives at `/webclient/`; the rendered docs at `/docs/`
8
+ (built from the `docs/` submodule); this page links through to both plus
9
+ the source repo. Pure static HTML + inline CSS, no JavaScript.
9
10
  -->
10
11
  <html lang="en">
11
12
  <head>
@@ -135,6 +136,113 @@
135
136
  border-radius: 4px;
136
137
  font-size: 0.9em;
137
138
  }
139
+ pre {
140
+ font-family: var(--mono);
141
+ background-color: var(--surface);
142
+ border: 1px solid var(--border);
143
+ border-radius: 8px;
144
+ padding: 14px 16px;
145
+ margin: 12px 0;
146
+ overflow-x: auto;
147
+ line-height: 1.5;
148
+ font-size: 0.88rem;
149
+ }
150
+ pre code {
151
+ background: none;
152
+ border: none;
153
+ padding: 0;
154
+ font-size: inherit;
155
+ }
156
+ section.connect {
157
+ margin: 36px 0 8px;
158
+ padding: 24px 0;
159
+ border-top: 1px solid var(--border);
160
+ }
161
+ section.connect h2 {
162
+ margin: 0 0 12px;
163
+ font-size: 1.4rem;
164
+ letter-spacing: -0.01em;
165
+ }
166
+ section.connect h3 {
167
+ margin: 22px 0 6px;
168
+ font-size: 1.08rem;
169
+ color: var(--accent-hover);
170
+ }
171
+ section.connect p {
172
+ margin: 8px 0;
173
+ color: var(--text);
174
+ }
175
+ section.connect p.lead {
176
+ color: var(--muted);
177
+ }
178
+ section.connect .note {
179
+ color: var(--muted);
180
+ font-size: 0.92rem;
181
+ }
182
+ section.connect .cta {
183
+ margin: 14px 0;
184
+ }
185
+ /* Client picker — pure CSS, no JavaScript. Radio inputs hold the
186
+ selected tab; the labels are the visible buttons arranged in a
187
+ horizontal row; `:checked ~` reveals the matching panel and
188
+ highlights its label. See build-smoke "works without JavaScript". */
189
+ .client-tabs {
190
+ margin: 8px 0 4px;
191
+ }
192
+ .client-tabs > input[type="radio"] {
193
+ position: absolute;
194
+ opacity: 0;
195
+ pointer-events: none;
196
+ }
197
+ .client-tabs .tablist {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ gap: 8px;
201
+ margin-bottom: 16px;
202
+ }
203
+ .client-tabs .tablist label {
204
+ padding: 10px 16px;
205
+ cursor: pointer;
206
+ border: 1px solid var(--border);
207
+ border-radius: 8px;
208
+ background-color: var(--surface);
209
+ color: var(--muted);
210
+ font-weight: 600;
211
+ font-size: 0.95rem;
212
+ -webkit-tap-highlight-color: transparent;
213
+ transition: border-color 0.15s, color 0.15s, background-color 0.15s;
214
+ }
215
+ .client-tabs .tablist label:hover {
216
+ border-color: var(--accent);
217
+ color: var(--text);
218
+ }
219
+ .client-tabs .panel {
220
+ display: none;
221
+ }
222
+ .client-tabs .panel h3 {
223
+ margin-top: 0;
224
+ }
225
+ #tab-web:checked ~ .tablist label[for="tab-web"],
226
+ #tab-weechat:checked ~ .tablist label[for="tab-weechat"],
227
+ #tab-hexchat:checked ~ .tablist label[for="tab-hexchat"],
228
+ #tab-irccloud:checked ~ .tablist label[for="tab-irccloud"] {
229
+ background-color: var(--bg);
230
+ color: var(--accent-hover);
231
+ border-color: var(--accent);
232
+ }
233
+ #tab-web:focus-visible ~ .tablist label[for="tab-web"],
234
+ #tab-weechat:focus-visible ~ .tablist label[for="tab-weechat"],
235
+ #tab-hexchat:focus-visible ~ .tablist label[for="tab-hexchat"],
236
+ #tab-irccloud:focus-visible ~ .tablist label[for="tab-irccloud"] {
237
+ outline: 2px solid var(--accent);
238
+ outline-offset: 2px;
239
+ }
240
+ #tab-web:checked ~ #panel-web,
241
+ #tab-weechat:checked ~ #panel-weechat,
242
+ #tab-hexchat:checked ~ #panel-hexchat,
243
+ #tab-irccloud:checked ~ #panel-irccloud {
244
+ display: block;
245
+ }
138
246
  .links {
139
247
  margin-top: 28px;
140
248
  padding: 20px 0;
@@ -170,7 +278,7 @@
170
278
  <header>
171
279
  <div class="container">
172
280
  <h1>Serverless<span class="irc">IRCd</span></h1>
173
- <p>An IRC daemon with a serverless deployment model.</p>
281
+ <p>IRC daemon with a serverless deployment model.</p>
174
282
  </div>
175
283
  </header>
176
284
 
@@ -194,7 +302,122 @@
194
302
  <a class="primary" href="/webclient/">Launch Web Client</a>
195
303
  </div>
196
304
 
305
+ <section class="connect">
306
+ <h2>Connect</h2>
307
+ <p class="lead">
308
+ ServerlessIRCd speaks IRC over secure WebSocket
309
+ (<code>wss://</code>, port <code>443</code>) as its primary
310
+ transport, and optionally raw <code>irc+tls</code> on port
311
+ <code>6697</code>. Replace <code>irc.example.com</code> with your
312
+ deployed Worker hostname in the snippets below. This page is
313
+ static and identical across staging and prod.
314
+ </p>
315
+
316
+ <div class="client-tabs">
317
+ <input type="radio" name="client-tab" id="tab-web" checked />
318
+ <input type="radio" name="client-tab" id="tab-weechat" />
319
+ <input type="radio" name="client-tab" id="tab-hexchat" />
320
+ <input type="radio" name="client-tab" id="tab-irccloud" />
321
+
322
+ <div
323
+ class="tablist"
324
+ role="tablist"
325
+ aria-label="IRC client connect instructions"
326
+ >
327
+ <label for="tab-web" class="tab" role="tab">Web client</label>
328
+ <label for="tab-weechat" class="tab" role="tab">WeeChat</label>
329
+ <label for="tab-hexchat" class="tab" role="tab"
330
+ >HexChat / irssi</label
331
+ >
332
+ <label for="tab-irccloud" class="tab" role="tab"
333
+ >IRCCloud / TheLounge</label
334
+ >
335
+ </div>
336
+
337
+ <div
338
+ class="panel"
339
+ id="panel-web"
340
+ role="tabpanel"
341
+ aria-labelledby="tab-web"
342
+ >
343
+ <p>
344
+ <code>wss</code> on port <code>443</code>, same origin as this
345
+ page. No client install or bridge required.
346
+ </p>
347
+ <div class="cta">
348
+ <a class="primary" href="/webclient/">Launch Web Client</a>
349
+ </div>
350
+ </div>
351
+
352
+ <div
353
+ class="panel"
354
+ id="panel-weechat"
355
+ role="tabpanel"
356
+ aria-labelledby="tab-weechat"
357
+ >
358
+ <p>
359
+ Connect over <code>wss</code> (port <code>443</code>) using the
360
+ snippet below, or directly over <code>irc+tls</code> on port
361
+ <code>6697</code> when the TCP+TLS surface is deployed.
362
+ </p>
363
+ <pre><code>/server add ircd irc.example.com/443
364
+ /set irc.server.ircd.ssl on
365
+ /connect ircd
366
+ /join #test</code></pre>
367
+ <p class="note">
368
+ For the <code>irc+tls :6697</code> path, swap the port above for
369
+ <code>6697</code>. The TCP surface is a separate deployment
370
+ (Cloudflare Spectrum + Container); see the
371
+ <a
372
+ href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/wiki/Cloudflare-TCP-Deployment"
373
+ >Cloudflare TCP Deployment Guide</a
374
+ >.
375
+ </p>
376
+ </div>
377
+
378
+ <div
379
+ class="panel"
380
+ id="panel-hexchat"
381
+ role="tabpanel"
382
+ aria-labelledby="tab-hexchat"
383
+ >
384
+ <p>
385
+ These TCP-only IRC clients do not support WebSocket natively.
386
+ Use <code>tools/tcp-ws-forwarder</code> to bridge them: it
387
+ listens on a local TCP port and forwards each IRC line over
388
+ <code>wss</code> to the Worker, negotiating the
389
+ <code>binary.ircv3.net</code> subprotocol upstream.
390
+ </p>
391
+ <pre><code>pnpm --filter tcp-ws-forwarder start \
392
+ --target wss://irc.example.com/ --listen-port 16667</code></pre>
393
+ <p class="note">
394
+ Then point HexChat / irssi at <code>127.0.0.1:16667</code>
395
+ (TLS off &mdash; the bridge is local). Bridging instructions
396
+ are in the
397
+ <a
398
+ href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/wiki/WebClientGuide"
399
+ >Web Client Guide</a
400
+ >.
401
+ </p>
402
+ </div>
403
+
404
+ <div
405
+ class="panel"
406
+ id="panel-irccloud"
407
+ role="tabpanel"
408
+ aria-labelledby="tab-irccloud"
409
+ >
410
+ <p>
411
+ Both support WebSocket natively. Add a network pointing at
412
+ <code>wss://irc.example.com/</code> (port <code>443</code>, TLS
413
+ on); no bridge required.
414
+ </p>
415
+ </div>
416
+ </div>
417
+ </section>
418
+
197
419
  <div class="links">
420
+ <a href="/docs/">Docs</a>
198
421
  <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Source Code (Gitea)</a>
199
422
  <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/issues">Issue Tracker</a>
200
423
  <a href="/webclient/">Web IRC Client</a>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/web",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Web client workspace: Kiwi IRC SPA served at /webclient/ and project landing page at /",
6
6
  "license": "BSD-3-Clause",
@@ -21,6 +21,7 @@
21
21
  "devDependencies": {
22
22
  "@types/node": "^26.1.1",
23
23
  "@vitest/coverage-v8": "^4.1.0",
24
+ "marked": "^18.0.9",
24
25
  "rimraf": "^6.0.0",
25
26
  "typescript": "^5.6.0",
26
27
  "vitest": "^4.1.0"
@@ -5,12 +5,17 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { resolveConfigFile } from '../src/build-env.ts';
7
7
  import { parseKiwiConfig } from '../src/config-schema.ts';
8
+ import { buildDocs } from '../src/render-docs.ts';
8
9
 
9
10
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const upstream = path.resolve(pkgRoot, 'upstream');
11
12
  const dist = path.resolve(pkgRoot, 'dist');
12
13
  const appDist = path.join(dist, 'webclient');
13
14
  const staticDir = path.join(pkgRoot, 'static');
15
+ // The `docs/` submodule lives at the monorepo root (two levels up from
16
+ // apps/web). Rendered to dist/docs/ by the docs build step (see step 6).
17
+ const docsSrc = path.resolve(pkgRoot, '..', '..', 'docs');
18
+ const docsDist = path.join(dist, 'docs');
14
19
 
15
20
  // The SPA is served at /webclient/ on the Worker (the [assets] binding serves
16
21
  // `dist/`). All hashed asset URLs must be /webclient/-prefixed so they load
@@ -44,6 +49,16 @@ if (!existsSync(upstream)) {
44
49
  );
45
50
  }
46
51
 
52
+ // The `docs/` submodule must be checked out — step 6 renders every docs/*.md
53
+ // to dist/docs/. Mirrors the Kiwi upstream guard above so a stale/partial
54
+ // submodule checkout fails loudly with the recovery command instead of
55
+ // producing an empty docs/ section on the deployed site.
56
+ if (!existsSync(docsSrc)) {
57
+ throw new Error(
58
+ `Docs submodule missing at ${docsSrc}. Run \`git submodule update --init docs\` first.`,
59
+ );
60
+ }
61
+
47
62
  // 1. Install upstream deps. Kiwi ships a yarn.lock; --frozen-lockfile gives a
48
63
  // reproducible tree. Contributors get yarn via `corepack enable`. The
49
64
  // guard checks for the actual binary the build invokes, not just the
@@ -116,8 +131,8 @@ await writeFile(
116
131
 
117
132
  // 5. Landing page: copy the static landing page (landing/index.html) to
118
133
  // dist/index.html so the Worker `[assets]` binding serves it at `/`. Pure
119
- // static HTML + inline CSS (no JS); links to /webclient/ (the SPA), docs/,
120
- // and the source repo.
134
+ // static HTML + inline CSS (no JS); links to /webclient/ (the SPA),
135
+ // /docs/ (the rendered docs built in step 6), and the source repo.
121
136
  await mkdir(dist, { recursive: true });
122
137
  const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
123
138
  if (!existsSync(landingSrc)) {
@@ -127,6 +142,14 @@ if (!existsSync(landingSrc)) {
127
142
  }
128
143
  await cp(landingSrc, path.join(dist, 'index.html'));
129
144
 
145
+ // 6. Docs: render every docs/*.md to standalone HTML under dist/docs/ so the
146
+ // Worker `[assets]` binding serves them at /docs/<name>.html (and /docs/
147
+ // for the index — Home.md becomes index.html). Requires the `docs/`
148
+ // submodule (guarded at step 0). Zero Worker changes — the existing
149
+ // [assets] binding already serves dist/ with not_found_handling = "none".
150
+ const docsResult = await buildDocs({ srcDir: docsSrc, outDir: docsDist });
151
+
130
152
  console.log(
131
153
  `Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
132
154
  );
155
+ console.log(`Docs rendered -> dist/docs/ (${docsResult.emitted.length} pages, index=index.html)`);
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Markdown → static-HTML render pipeline for the `docs/` submodule.
3
+ *
4
+ * Renders every `docs/*.md` to a standalone HTML page under `dist/docs/` so
5
+ * the Worker `[assets]` binding serves them at `/docs/<name>.html` (and
6
+ * `/docs/` for the index — `Home.md` becomes `index.html`). Wired into the
7
+ * `apps/web` build by `scripts/build.mjs`.
8
+ *
9
+ * Extracted from the build script so the rendering pipeline is unit-testable
10
+ * without running the (heavy) Kiwi upstream SPA build.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { marked } from 'marked';
17
+
18
+ // GFM is on by default in marked v18+, but pin it explicitly so a future
19
+ // upstream default flip can't silently change the rendered output. `async:
20
+ // false` makes `.parse()` return a plain string (its return type is the
21
+ // union `string | Promise<string>` — narrowed with a type assertion at the
22
+ // call site).
23
+ marked.use({ gfm: true, async: false });
24
+
25
+ /** Basename of the docs landing page (rendered to `index.html`). */
26
+ const HOME_SLUG = 'Home';
27
+
28
+ /**
29
+ * Render GitHub-flavored markdown to an HTML fragment (no surrounding
30
+ * `<html>`/`<body>` — {@link buildDocs} wraps the result in a full page).
31
+ */
32
+ export function renderMarkdown(md: string): string {
33
+ return marked.parse(md, { async: false }) as string;
34
+ }
35
+
36
+ /**
37
+ * Rewrite intra-docs links in already-rendered HTML so relative references
38
+ * resolve on the static site. Two source shapes are handled:
39
+ *
40
+ * 1. `[x](OtherDoc.md)` / `[x](./OtherDoc.md)` — Gitea-relative, emitted by
41
+ * marked as `<a href="./OtherDoc.md">`.
42
+ * 2. `[x](OtherDoc)` — Gitea-wiki "bare" link (no extension), used heavily
43
+ * in `Home.md` and `ADR-Index.md`.
44
+ *
45
+ * Only slugs present in `knownDocs` are rewritten, so external links, repo
46
+ * paths, and unknown targets are left untouched. Anchor fragments
47
+ * (`#section`) are preserved.
48
+ */
49
+ export function rewriteDocLinks(html: string, knownDocs: Set<string>): string {
50
+ return html.replace(/href="([^"]*)"/g, (match, raw: string) => {
51
+ // Leave external URLs and pure-anchor links alone.
52
+ if (raw === '' || raw.startsWith('#') || /^(?:https?:|mailto:|tel:|data:)/i.test(raw)) {
53
+ return match;
54
+ }
55
+ const [pathPartRaw, ...fragParts] = raw.split('#');
56
+ const pathPart = pathPartRaw ?? '';
57
+ const fragment = fragParts.length > 0 ? `#${fragParts.join('#')}` : '';
58
+ const slug = pathPart.replace(/^\.\//, '').replace(/\.md$/i, '').trim();
59
+ if (slug === '' || !knownDocs.has(slug)) {
60
+ return match;
61
+ }
62
+ return `href="${slug}.html${fragment}"`;
63
+ });
64
+ }
65
+
66
+ export interface BuildDocsOptions {
67
+ /** Source directory containing the `*.md` files (typically the `docs/` submodule). */
68
+ srcDir: string;
69
+ /** Output directory to write the rendered site (typically `dist/docs/`). */
70
+ outDir: string;
71
+ }
72
+
73
+ export interface BuildDocsResult {
74
+ /** Relative names of the files written under `outDir`. */
75
+ emitted: string[];
76
+ }
77
+
78
+ /**
79
+ * Render every `*.md` under `srcDir` into a standalone HTML page under
80
+ * `outDir`. `Home.md` becomes `index.html`; every other file `<Slug>.md`
81
+ * becomes `<Slug>.html`. Throws a clear error if `srcDir` is missing or
82
+ * empty — the build script surfaces this so a stale `docs/` submodule
83
+ * checkout fails loudly (mirroring the Kiwi `upstream/` guard).
84
+ */
85
+ export async function buildDocs({ srcDir, outDir }: BuildDocsOptions): Promise<BuildDocsResult> {
86
+ if (!existsSync(srcDir)) {
87
+ throw new Error(
88
+ `Docs source missing at ${srcDir}. Run \`git submodule update --init docs\` first.`,
89
+ );
90
+ }
91
+ const entries = await readdir(srcDir);
92
+ const mdFiles = entries.filter((f) => f.toLowerCase().endsWith('.md'));
93
+ if (mdFiles.length === 0) {
94
+ throw new Error(
95
+ `Docs source directory is empty at ${srcDir}. Run \`git submodule update --init docs\` first.`,
96
+ );
97
+ }
98
+
99
+ const knownSlugs = new Set(mdFiles.map((f) => f.replace(/\.md$/i, '')));
100
+ await mkdir(outDir, { recursive: true });
101
+
102
+ // Shared stylesheet — single source of truth for every page, mirroring the
103
+ // landing page's dark theme (CSS variables kept inline so the rendered
104
+ // docs work as static files served from any path).
105
+ await writeFile(path.join(outDir, 'docs.css'), DOCS_CSS, 'utf8');
106
+
107
+ const emitted: string[] = [];
108
+ for (const file of mdFiles) {
109
+ const slug = file.replace(/\.md$/i, '');
110
+ const md = await readFile(path.join(srcDir, file), 'utf8');
111
+ const body = rewriteDocLinks(renderMarkdown(md), knownSlugs);
112
+ const title = extractTitle(md) ?? slug;
113
+ const outName = slug === HOME_SLUG ? 'index.html' : `${slug}.html`;
114
+ const html = wrapPage(title, body, slug === HOME_SLUG);
115
+ await writeFile(path.join(outDir, outName), html, 'utf8');
116
+ emitted.push(outName);
117
+ }
118
+
119
+ return { emitted };
120
+ }
121
+
122
+ /** Extract the first ATX `# Heading` as the page title; `undefined` if none. */
123
+ function extractTitle(md: string): string | undefined {
124
+ const match = md.match(/^#\s+(.+?)\s*$/m);
125
+ return match?.[1];
126
+ }
127
+
128
+ /** Escape the four significant HTML characters in a text string. */
129
+ function escapeHtml(s: string): string {
130
+ return s
131
+ .replace(/&/g, '&amp;')
132
+ .replace(/</g, '&lt;')
133
+ .replace(/>/g, '&gt;')
134
+ .replace(/"/g, '&quot;');
135
+ }
136
+
137
+ /** Wrap a rendered HTML fragment in a full page with the shared stylesheet. */
138
+ function wrapPage(title: string, body: string, isIndex: boolean): string {
139
+ const back = isIndex ? '' : '<p class="back"><a href="./index.html">Docs home</a></p>';
140
+ return `<!doctype html>
141
+ <html lang="en">
142
+ <head>
143
+ <meta charset="utf-8" />
144
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
145
+ <title>${escapeHtml(title)} — ServerlessIRCd Docs</title>
146
+ <link rel="stylesheet" href="./docs.css" />
147
+ </head>
148
+ <body>
149
+ <header>
150
+ <div class="container">
151
+ <h1><a href="./index.html">ServerlessIRCd Docs</a></h1>
152
+ </div>
153
+ </header>
154
+ <main class="container">
155
+ ${back}
156
+ <article>
157
+ ${body}
158
+ </article>
159
+ </main>
160
+ <footer>
161
+ <div class="container">
162
+ <p><a href="/">Back to landing</a></p>
163
+ </div>
164
+ </footer>
165
+ </body>
166
+ </html>
167
+ `;
168
+ }
169
+
170
+ const DOCS_CSS = `:root {
171
+ --bg: #0d1117;
172
+ --surface: #161b22;
173
+ --border: #30363d;
174
+ --text: #e6edf3;
175
+ --muted: #8b949e;
176
+ --accent: #58a6ff;
177
+ --accent-hover: #79c0ff;
178
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
179
+ "Liberation Mono", monospace;
180
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
181
+ sans-serif;
182
+ }
183
+ * { box-sizing: border-box; }
184
+ html { -webkit-text-size-adjust: 100%; }
185
+ body {
186
+ margin: 0;
187
+ font-family: var(--sans);
188
+ line-height: 1.6;
189
+ color: var(--text);
190
+ background-color: var(--bg);
191
+ display: flex;
192
+ flex-direction: column;
193
+ min-height: 100vh;
194
+ }
195
+ .container {
196
+ width: 100%;
197
+ max-width: 820px;
198
+ margin: 0 auto;
199
+ padding: 0 20px;
200
+ }
201
+ header {
202
+ padding: 24px 0 14px;
203
+ border-bottom: 1px solid var(--border);
204
+ }
205
+ header h1 {
206
+ margin: 0;
207
+ font-size: 1.4rem;
208
+ letter-spacing: -0.02em;
209
+ }
210
+ header h1 a {
211
+ color: var(--accent);
212
+ text-decoration: none;
213
+ }
214
+ header h1 a:hover { text-decoration: underline; }
215
+ main { flex: 1 0 auto; padding: 28px 0; }
216
+ article {
217
+ font-size: 1rem;
218
+ overflow-wrap: break-word;
219
+ }
220
+ article h1, article h2, article h3, article h4 {
221
+ letter-spacing: -0.01em;
222
+ line-height: 1.25;
223
+ margin-top: 1.6em;
224
+ margin-bottom: 0.6em;
225
+ }
226
+ article h1 { font-size: 1.7rem; margin-top: 0; }
227
+ article h2 { font-size: 1.35rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
228
+ article h3 { font-size: 1.12rem; }
229
+ article h4 { font-size: 1rem; color: var(--accent-hover); }
230
+ article p { margin: 0.8em 0; }
231
+ article a { color: var(--accent); text-decoration: none; }
232
+ article a:hover { text-decoration: underline; }
233
+ article ul, article ol { padding-left: 1.6em; }
234
+ article li { margin: 0.25em 0; }
235
+ article blockquote {
236
+ margin: 0.9em 0;
237
+ padding: 0.4em 1em;
238
+ border-left: 3px solid var(--border);
239
+ color: var(--muted);
240
+ }
241
+ article blockquote p { margin: 0.4em 0; }
242
+ article code {
243
+ font-family: var(--mono);
244
+ background-color: var(--surface);
245
+ padding: 2px 6px;
246
+ border-radius: 4px;
247
+ font-size: 0.9em;
248
+ }
249
+ article pre {
250
+ font-family: var(--mono);
251
+ background-color: var(--surface);
252
+ border: 1px solid var(--border);
253
+ border-radius: 8px;
254
+ padding: 14px 16px;
255
+ margin: 12px 0;
256
+ overflow-x: auto;
257
+ line-height: 1.5;
258
+ font-size: 0.88rem;
259
+ }
260
+ article pre code {
261
+ background: none; border: none; padding: 0; font-size: inherit;
262
+ }
263
+ article table {
264
+ border-collapse: collapse;
265
+ margin: 1em 0;
266
+ display: block;
267
+ overflow-x: auto;
268
+ }
269
+ article table th, article table td {
270
+ border: 1px solid var(--border);
271
+ padding: 6px 12px;
272
+ text-align: left;
273
+ }
274
+ article table th { background-color: var(--surface); }
275
+ article hr {
276
+ border: none;
277
+ border-top: 1px solid var(--border);
278
+ margin: 1.6em 0;
279
+ }
280
+ article img { max-width: 100%; }
281
+ .back { margin-top: 0; color: var(--muted); font-size: 0.92rem; }
282
+ .back a { color: var(--accent); }
283
+ footer {
284
+ flex-shrink: 0;
285
+ padding: 18px 0;
286
+ border-top: 1px solid var(--border);
287
+ color: var(--muted);
288
+ font-size: 0.9rem;
289
+ }
290
+ footer a { color: var(--muted); text-decoration: none; }
291
+ footer a:hover { color: var(--accent); }
292
+ `;
@@ -70,9 +70,9 @@ describe('landing page source (landing/index.html)', () => {
70
70
  expect(html).toMatch(/href=["']\/webclient\/?["']/);
71
71
  });
72
72
 
73
- it('does not link to the project docs (docs button temporarily removed)', () => {
73
+ it('links to the rendered project docs at /docs/', () => {
74
74
  const html = readFileSync(landingSrc, 'utf8');
75
- expect(html).not.toMatch(/href=["'][^"']*docs\/?["']/);
75
+ expect(html).toMatch(/href=["']\/docs\/?["']/);
76
76
  });
77
77
 
78
78
  it('links to the source repository on Gitea', () => {
@@ -101,3 +101,32 @@ describe.skipIf(!landingBuilt)('landing page build output (dist/index.html)', ()
101
101
  expect(dist).toBe(src);
102
102
  });
103
103
  });
104
+
105
+ // Validates the docs/ build pass: `pnpm --filter web build` renders every
106
+ // `docs/*.md` to standalone HTML under `dist/docs/` so the Worker `[assets]`
107
+ // binding serves them at `/docs/<name>.html` (and `/docs/` for the index).
108
+ // Gated on the directory's presence — like the Kiwi SPA gate above — so CI
109
+ // can run this test file without paying the heavy SPA build cost; the
110
+ // pipeline runs in full via `apps/web/tests/render-docs.test.ts`.
111
+ const docsDist = path.join(pkgRoot, 'dist', 'docs');
112
+ const docsBuilt = existsSync(docsDist) && statSync(docsDist).size > 0;
113
+
114
+ describe.skipIf(!docsBuilt)('docs build output (dist/docs/)', () => {
115
+ it('emits dist/docs/index.html', () => {
116
+ expect(existsSync(path.join(docsDist, 'index.html'))).toBe(true);
117
+ });
118
+
119
+ it('emits Services.html with rendered HTML (an <h1>, not raw markdown)', () => {
120
+ const target = path.join(docsDist, 'Services.html');
121
+ expect(existsSync(target)).toBe(true);
122
+ const html = readFileSync(target, 'utf8');
123
+ expect(html).toMatch(/<h1[^>]*>/);
124
+ // The markdown H1 marker must not survive rendering.
125
+ expect(html).not.toMatch(/^# IRC Services Reference/m);
126
+ });
127
+
128
+ it('shares a stylesheet (a .css file is emitted alongside the HTML)', () => {
129
+ const css = readdirSync(docsDist).filter((f) => f.endsWith('.css'));
130
+ expect(css.length).toBeGreaterThanOrEqual(1);
131
+ });
132
+ });