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.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +245 -0
- package/README.md +160 -200
- package/apps/aws-stack/package.json +1 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +94 -31
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +226 -3
- package/apps/web/package.json +2 -1
- package/apps/web/scripts/build.mjs +25 -2
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +31 -2
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/Services.md +33 -1
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/connection-do.ts +18 -6
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/commands/account-auth.ts +46 -18
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/nickserv.ts +16 -4
- package/packages/irc-core/src/commands/registration.ts +27 -16
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
- package/packages/irc-core/tests/commands/registration.test.ts +227 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -30
- package/packages/irc-server/tests/actor.test.ts +365 -3
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- 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/`;
|
|
8
|
-
the
|
|
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>
|
|
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 — 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>
|
package/apps/web/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/web",
|
|
3
|
-
"version": "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),
|
|
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, '&')
|
|
132
|
+
.replace(/</g, '<')
|
|
133
|
+
.replace(/>/g, '>')
|
|
134
|
+
.replace(/"/g, '"');
|
|
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('
|
|
73
|
+
it('links to the rendered project docs at /docs/', () => {
|
|
74
74
|
const html = readFileSync(landingSrc, 'utf8');
|
|
75
|
-
expect(html).
|
|
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
|
+
});
|