kitfly 0.1.2

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 (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/VERSION +1 -0
  5. package/package.json +63 -0
  6. package/schemas/README.md +32 -0
  7. package/schemas/site.schema.json +5 -0
  8. package/schemas/theme.schema.json +5 -0
  9. package/schemas/v0/site.schema.json +172 -0
  10. package/schemas/v0/theme.schema.json +210 -0
  11. package/scripts/build-all.ts +121 -0
  12. package/scripts/build.ts +601 -0
  13. package/scripts/bundle.ts +781 -0
  14. package/scripts/dev.ts +777 -0
  15. package/scripts/generate-checksums.sh +78 -0
  16. package/scripts/release/export-release-key.sh +28 -0
  17. package/scripts/release/release-guard-tag-version.sh +79 -0
  18. package/scripts/release/sign-release-assets.sh +123 -0
  19. package/scripts/release/upload-release-assets.sh +76 -0
  20. package/scripts/release/upload-release-provenance.sh +52 -0
  21. package/scripts/release/verify-public-key.sh +48 -0
  22. package/scripts/release/verify-signatures.sh +117 -0
  23. package/scripts/version-sync.ts +82 -0
  24. package/src/__tests__/build.test.ts +240 -0
  25. package/src/__tests__/bundle.test.ts +786 -0
  26. package/src/__tests__/cli.test.ts +706 -0
  27. package/src/__tests__/crucible.test.ts +1043 -0
  28. package/src/__tests__/engine.test.ts +157 -0
  29. package/src/__tests__/init.test.ts +450 -0
  30. package/src/__tests__/pipeline.test.ts +1087 -0
  31. package/src/__tests__/productbook.test.ts +1206 -0
  32. package/src/__tests__/runbook.test.ts +974 -0
  33. package/src/__tests__/server-registry.test.ts +1251 -0
  34. package/src/__tests__/servicebook.test.ts +1248 -0
  35. package/src/__tests__/shared.test.ts +2005 -0
  36. package/src/__tests__/styles.test.ts +14 -0
  37. package/src/__tests__/theme-schema.test.ts +47 -0
  38. package/src/__tests__/theme.test.ts +554 -0
  39. package/src/cli.ts +582 -0
  40. package/src/commands/init.ts +92 -0
  41. package/src/commands/update.ts +444 -0
  42. package/src/engine.ts +20 -0
  43. package/src/logger.ts +15 -0
  44. package/src/migrations/0000_schema_versioning.ts +67 -0
  45. package/src/migrations/0001_server_port.ts +52 -0
  46. package/src/migrations/0002_brand_logo.ts +49 -0
  47. package/src/migrations/index.ts +26 -0
  48. package/src/migrations/schema.ts +24 -0
  49. package/src/server-registry.ts +405 -0
  50. package/src/shared.ts +1239 -0
  51. package/src/site/styles.css +931 -0
  52. package/src/site/template.html +193 -0
  53. package/src/templates/crucible.ts +1163 -0
  54. package/src/templates/driver.ts +876 -0
  55. package/src/templates/handbook.ts +339 -0
  56. package/src/templates/minimal.ts +139 -0
  57. package/src/templates/pipeline.ts +966 -0
  58. package/src/templates/productbook.ts +1032 -0
  59. package/src/templates/runbook.ts +829 -0
  60. package/src/templates/schema.ts +119 -0
  61. package/src/templates/servicebook.ts +1242 -0
  62. package/src/theme.ts +245 -0
package/scripts/dev.ts ADDED
@@ -0,0 +1,777 @@
1
+ /**
2
+ * Kitfly - Development server with hot reload
3
+ *
4
+ * Usage: bun run dev [folder] [options]
5
+ *
6
+ * Options:
7
+ * -p, --port <number> Port to serve on [env: KITFLY_DEV_PORT] [default: 3333]
8
+ * -H, --host <string> Host to bind to [env: KITFLY_DEV_HOST] [default: localhost]
9
+ * -o, --open Open browser on start [env: KITFLY_DEV_OPEN] [default: true]
10
+ * --no-open Don't open browser
11
+ * --help Show help message
12
+ *
13
+ * Opens browser and watches for file changes, automatically reloading.
14
+ */
15
+
16
+ import { watch } from "node:fs";
17
+ import { readFile } from "node:fs/promises";
18
+ import { basename, extname, join, resolve } from "node:path";
19
+ import { marked, Renderer } from "marked";
20
+ import { ENGINE_ASSETS_DIR, ENGINE_SITE_DIR } from "../src/engine.ts";
21
+ import {
22
+ buildBreadcrumbsSimple,
23
+ buildFooter,
24
+ buildNavSimple,
25
+ buildPageMeta,
26
+ buildToc,
27
+ // Network utilities
28
+ checkPortOrExit,
29
+ // Navigation/template building
30
+ collectFiles,
31
+ envBool,
32
+ envInt,
33
+ // Config helpers
34
+ envString,
35
+ // Formatting
36
+ escapeHtml,
37
+ // Provenance
38
+ generateProvenance,
39
+ // YAML/Config parsing
40
+ loadSiteConfig,
41
+ type Provenance,
42
+ // Markdown utilities
43
+ parseFrontmatter,
44
+ resolveStylesPath,
45
+ resolveTemplatePath,
46
+ // Types
47
+ type SiteConfig,
48
+ slugify,
49
+ toUrlPath,
50
+ validatePath,
51
+ } from "../src/shared.ts";
52
+ import { generateThemeCSS, getPrismUrls, loadTheme, type Theme } from "../src/theme.ts";
53
+
54
+ // Defaults
55
+ const DEFAULT_PORT = 3333;
56
+ const DEFAULT_HOST = "localhost";
57
+
58
+ let PORT = DEFAULT_PORT;
59
+ let HOST = DEFAULT_HOST;
60
+ let ROOT = process.cwd();
61
+ let OPEN_BROWSER = true;
62
+ let LOG_FORMAT = ""; // "structured" when invoked by CLI daemon
63
+
64
+ // Structured logger for daemon mode — set during main() init.
65
+ // When null, all output goes through console.log (standalone mode).
66
+ let daemonLog: {
67
+ info: (msg: string) => void;
68
+ warn: (msg: string) => void;
69
+ error: (msg: string) => void;
70
+ } | null = null;
71
+
72
+ /** Log info — uses structured logger in daemon mode, console.log otherwise */
73
+ function logInfo(msg: string): void {
74
+ if (daemonLog) daemonLog.info(msg);
75
+ else console.log(msg);
76
+ }
77
+
78
+ /** Log warning — uses structured logger in daemon mode, console.warn otherwise */
79
+ function logWarn(msg: string): void {
80
+ if (daemonLog) daemonLog.warn(msg);
81
+ else console.warn(msg);
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // CLI argument parsing
86
+ // ---------------------------------------------------------------------------
87
+
88
+ interface ParsedArgs {
89
+ port?: number;
90
+ host?: string;
91
+ open?: boolean;
92
+ folder?: string;
93
+ logFormat?: string;
94
+ }
95
+
96
+ function parseArgs(argv: string[]): ParsedArgs {
97
+ const result: ParsedArgs = {};
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const arg = argv[i];
100
+ const next = argv[i + 1];
101
+
102
+ if ((arg === "--port" || arg === "-p") && next) {
103
+ result.port = parseInt(next, 10);
104
+ i++;
105
+ } else if ((arg === "--host" || arg === "-H") && next && !next.startsWith("-")) {
106
+ result.host = next;
107
+ i++;
108
+ } else if (arg === "--log-format") {
109
+ result.logFormat = next;
110
+ i++;
111
+ } else if (arg === "--open" || arg === "-o") {
112
+ result.open = true;
113
+ } else if (arg === "--no-open") {
114
+ result.open = false;
115
+ } else if (!arg.startsWith("-") && !result.folder) {
116
+ result.folder = arg;
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ function getConfig(): {
123
+ port: number;
124
+ host: string;
125
+ open: boolean;
126
+ folder?: string;
127
+ logFormat?: string;
128
+ } {
129
+ const args = parseArgs(process.argv.slice(2));
130
+ return {
131
+ port: args.port ?? envInt("KITFLY_DEV_PORT", DEFAULT_PORT),
132
+ host: args.host ?? envString("KITFLY_DEV_HOST", DEFAULT_HOST),
133
+ open: args.open ?? envBool("KITFLY_DEV_OPEN", true),
134
+ folder: args.folder,
135
+ logFormat: args.logFormat,
136
+ };
137
+ }
138
+
139
+ function getContentType(filePath: string): string {
140
+ const ext = extname(filePath).toLowerCase();
141
+ switch (ext) {
142
+ case ".css":
143
+ return "text/css";
144
+ case ".js":
145
+ return "text/javascript";
146
+ case ".json":
147
+ return "application/json";
148
+ case ".svg":
149
+ return "image/svg+xml";
150
+ case ".png":
151
+ return "image/png";
152
+ case ".jpg":
153
+ case ".jpeg":
154
+ return "image/jpeg";
155
+ case ".gif":
156
+ return "image/gif";
157
+ case ".webp":
158
+ return "image/webp";
159
+ case ".ico":
160
+ return "image/x-icon";
161
+ case ".pdf":
162
+ return "application/pdf";
163
+ default:
164
+ return "application/octet-stream";
165
+ }
166
+ }
167
+
168
+ // Configure marked with custom renderer for mermaid support and heading IDs
169
+ const renderer = new Renderer();
170
+ const originalCode = renderer.code.bind(renderer);
171
+ renderer.code = (code: { type: "code"; raw: string; text: string; lang?: string }) => {
172
+ if (code.lang === "mermaid") {
173
+ // Store source in data attribute for theme toggle re-rendering
174
+ const escaped = code.text.replace(/"/g, "&quot;");
175
+ return `<pre class="mermaid" data-mermaid-source="${escaped}">${code.text}</pre>`;
176
+ }
177
+ return originalCode(code);
178
+ };
179
+ renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
180
+ const plain = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
181
+ const id = slugify(plain);
182
+ const inner = marked.parseInline(text) as string;
183
+ return `<h${depth} id="${id}">${inner}</h${depth}>\n`;
184
+ };
185
+ marked.use({ renderer });
186
+
187
+ // Track connected clients for hot reload
188
+ const clients: Set<ReadableStreamDefaultController> = new Set();
189
+
190
+ // Convert markdown to HTML with template
191
+ async function renderPage(
192
+ filePath: string,
193
+ urlPath: string,
194
+ provenance: Provenance,
195
+ config: SiteConfig,
196
+ theme: Theme,
197
+ ): Promise<string> {
198
+ const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
199
+ const content = await readFile(filePath, "utf-8");
200
+ const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
201
+
202
+ let title = basename(filePath, extname(filePath));
203
+ let htmlContent: string;
204
+ let pageMeta = "";
205
+
206
+ if (filePath.endsWith(".yaml")) {
207
+ // Render YAML as code block
208
+ htmlContent = `<h1>${title}</h1>\n<pre><code class="language-yaml">${escapeHtml(content)}</code></pre>`;
209
+ } else if (filePath.endsWith(".json")) {
210
+ // Render JSON as code block (pretty-printed)
211
+ let prettyJson = content;
212
+ try {
213
+ prettyJson = JSON.stringify(JSON.parse(content), null, 2);
214
+ } catch {
215
+ // Use original if not valid JSON
216
+ }
217
+ htmlContent = `<h1>${title}</h1>\n<pre><code class="language-json">${escapeHtml(prettyJson)}</code></pre>`;
218
+ } else {
219
+ const { frontmatter, body } = parseFrontmatter(content);
220
+ if (frontmatter.title) {
221
+ title = frontmatter.title as string;
222
+ }
223
+ pageMeta = buildPageMeta(frontmatter);
224
+ htmlContent = marked.parse(body) as string;
225
+ }
226
+
227
+ const files = await collectFiles(ROOT, config);
228
+ const currentUrlPath = urlPath.slice(1).replace(/\.html$/, "");
229
+ const nav = buildNavSimple(files, config, currentUrlPath);
230
+ const footer = buildFooter(provenance, config);
231
+ const breadcrumbs = buildBreadcrumbsSimple(urlPath, files, config);
232
+ const toc = buildToc(htmlContent);
233
+ const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
234
+ const themeCSS = generateThemeCSS(theme);
235
+ const prismUrls = getPrismUrls(theme);
236
+ const pathPrefix = "/";
237
+
238
+ const hotReloadScript = `
239
+ <script>
240
+ const es = new EventSource('/__reload');
241
+ es.onmessage = () => location.reload();
242
+ es.onerror = () => setTimeout(() => location.reload(), 1000);
243
+ </script>`;
244
+
245
+ const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
246
+
247
+ return template
248
+ .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
249
+ .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
250
+ .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
251
+ .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
252
+ .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
253
+ .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
254
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
255
+ .replace(/\{\{SITE_TITLE\}\}/g, config.title)
256
+ .replace("{{TITLE}}", title)
257
+ .replace("{{VERSION}}", uiVersion)
258
+ .replace("{{BRANCH}}", provenance.gitBranch)
259
+ .replace("{{BREADCRUMBS}}", breadcrumbs)
260
+ .replace("{{PAGE_META}}", pageMeta)
261
+ .replace("{{NAV}}", nav)
262
+ .replace("{{CONTENT}}", htmlContent)
263
+ .replace("{{TOC}}", toc)
264
+ .replace("{{FOOTER}}", footer)
265
+ .replace("{{THEME_CSS}}", themeCSS)
266
+ .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
267
+ .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
268
+ .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
269
+ }
270
+
271
+ // Render Getting Started page when no config
272
+ async function renderGettingStarted(
273
+ provenance: Provenance,
274
+ config: SiteConfig,
275
+ theme: Theme,
276
+ ): Promise<string> {
277
+ const uiVersion = provenance.version ? `v${provenance.version}` : "unversioned";
278
+ const template = await readFile(await resolveTemplatePath(ROOT), "utf-8");
279
+ const htmlContent = `
280
+ <h1>Getting Started</h1>
281
+ <p>Welcome! To configure your kitfly site, create a <code>site.yaml</code> file in the repository root:</p>
282
+ <pre><code class="language-yaml"># yaml-language-server: $schema=./schemas/v0/site.schema.json
283
+ schemaVersion: "0.1.0"
284
+ docroot: "."
285
+ title: "My Docs"
286
+
287
+ brand:
288
+ name: "My Brand"
289
+ url: "https://example.com"
290
+ external: true
291
+
292
+ sections:
293
+ - name: "Overview"
294
+ path: "."
295
+ files: ["README.md"]
296
+ - name: "Guides"
297
+ path: "guides"
298
+ </code></pre>
299
+ <p>Or create a <code>content/</code> directory with subdirectories for auto-discovery.</p>
300
+ `;
301
+
302
+ const brandTarget = config.brand.external ? ' target="_blank" rel="noopener"' : "";
303
+ const themeCSS = generateThemeCSS(theme);
304
+ const prismUrls = getPrismUrls(theme);
305
+ const pathPrefix = "/";
306
+
307
+ const hotReloadScript = `
308
+ <script>
309
+ const es = new EventSource('/__reload');
310
+ es.onmessage = () => location.reload();
311
+ es.onerror = () => setTimeout(() => location.reload(), 1000);
312
+ </script>`;
313
+
314
+ const logoClass = config.brand.logoType === "wordmark" ? "logo-wordmark" : "logo-icon";
315
+
316
+ return template
317
+ .replace(/\{\{PATH_PREFIX\}\}/g, pathPrefix)
318
+ .replace(/\{\{BRAND_URL\}\}/g, config.brand.url)
319
+ .replace(/\{\{BRAND_TARGET\}\}/g, brandTarget)
320
+ .replace(/\{\{BRAND_NAME\}\}/g, config.brand.name)
321
+ .replace(/\{\{BRAND_LOGO\}\}/g, config.brand.logo || "assets/brand/logo.png")
322
+ .replace(/\{\{BRAND_FAVICON\}\}/g, config.brand.favicon || "assets/brand/favicon.png")
323
+ .replace(/\{\{BRAND_LOGO_CLASS\}\}/g, logoClass)
324
+ .replace(/\{\{SITE_TITLE\}\}/g, config.title)
325
+ .replace("{{TITLE}}", "Getting Started")
326
+ .replace("{{VERSION}}", uiVersion)
327
+ .replace("{{BRANCH}}", provenance.gitBranch)
328
+ .replace("{{BREADCRUMBS}}", "")
329
+ .replace("{{PAGE_META}}", "")
330
+ .replace("{{NAV}}", "<ul></ul>")
331
+ .replace("{{CONTENT}}", htmlContent)
332
+ .replace("{{TOC}}", "")
333
+ .replace("{{FOOTER}}", buildFooter(provenance, config))
334
+ .replace("{{THEME_CSS}}", themeCSS)
335
+ .replace("{{PRISM_LIGHT_URL}}", prismUrls.light)
336
+ .replace("{{PRISM_DARK_URL}}", prismUrls.dark)
337
+ .replace("{{HOT_RELOAD_SCRIPT}}", hotReloadScript);
338
+ }
339
+
340
+ async function tryServeFile(filePath: string): Promise<Response | null> {
341
+ try {
342
+ const file = Bun.file(filePath);
343
+ if (!(await file.exists())) return null;
344
+ return new Response(file, {
345
+ headers: {
346
+ "Content-Type": getContentType(filePath),
347
+ "Cache-Control": "no-cache",
348
+ },
349
+ });
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ async function tryServeContentAsset(
356
+ urlPathname: string,
357
+ config: SiteConfig,
358
+ ): Promise<Response | null> {
359
+ // Serve common binary assets from docroot (images, PDFs, etc.)
360
+ if (!/\.[a-z0-9]+$/i.test(urlPathname)) return null;
361
+ if (urlPathname.endsWith(".html")) return null;
362
+ if (urlPathname === "/styles.css" || urlPathname.startsWith("/assets/")) return null;
363
+
364
+ const rel = decodeURIComponent(urlPathname).replace(/^\//, "");
365
+ if (!rel) return null;
366
+ const fsPath = validatePath(ROOT, config.docroot, rel, true);
367
+ if (!fsPath) return null;
368
+ return tryServeFile(fsPath);
369
+ }
370
+
371
+ // Find file for a URL path
372
+ async function findFile(urlPath: string, config: SiteConfig): Promise<string | null> {
373
+ const { stat } = await import("node:fs/promises");
374
+
375
+ // Remove leading slash and .html extension (for compatibility with built links)
376
+ const path = urlPath.slice(1).replace(/\.html$/, "") || "";
377
+
378
+ // If empty (home page), check for dedicated home or use first file
379
+ if (!path) {
380
+ if (config.home) {
381
+ const homePath = validatePath(ROOT, config.docroot, config.home, true);
382
+ if (homePath) {
383
+ try {
384
+ await stat(homePath);
385
+ return homePath;
386
+ } catch {
387
+ // Home file not found, fall through
388
+ }
389
+ }
390
+ }
391
+ // Fallback to first file
392
+ const files = await collectFiles(ROOT, config);
393
+ return files.length > 0 ? files[0].path : null;
394
+ }
395
+
396
+ // Check configured sections
397
+ for (const section of config.sections) {
398
+ const sectionPath = validatePath(ROOT, config.docroot, section.path, true);
399
+ if (!sectionPath) continue;
400
+
401
+ if (section.files) {
402
+ // Check explicit files
403
+ for (const file of section.files) {
404
+ const name = file.replace(/\.(md|yaml|json)$/, "").toLowerCase();
405
+ if (name === path) {
406
+ const filePath = join(sectionPath, file);
407
+ try {
408
+ await stat(filePath);
409
+ return filePath;
410
+ } catch {
411
+ // Continue
412
+ }
413
+ }
414
+ }
415
+ } else {
416
+ // Check directory for matching file (supports nested paths)
417
+ const urlBase = toUrlPath(ROOT, sectionPath);
418
+ if (path.startsWith(`${urlBase}/`) || path === urlBase) {
419
+ const relPath = path === urlBase ? "" : path.slice(urlBase.length + 1);
420
+ // Guard against path traversal
421
+ if (relPath.includes("..")) continue;
422
+ const extensions = [".md", ".yaml", ".json"];
423
+
424
+ if (relPath === "") {
425
+ // Section root URL — try index file
426
+ for (const ext of extensions) {
427
+ const filePath = join(sectionPath, `index${ext}`);
428
+ try {
429
+ await stat(filePath);
430
+ return filePath;
431
+ } catch {
432
+ // Continue
433
+ }
434
+ }
435
+ } else {
436
+ // Try direct file match at nested path
437
+ for (const ext of extensions) {
438
+ const filePath = join(sectionPath, relPath + ext);
439
+ try {
440
+ await stat(filePath);
441
+ return filePath;
442
+ } catch {
443
+ // Continue
444
+ }
445
+ }
446
+ // Try as directory with index file
447
+ for (const ext of extensions) {
448
+ const filePath = join(sectionPath, relPath, `index${ext}`);
449
+ try {
450
+ await stat(filePath);
451
+ return filePath;
452
+ } catch {
453
+ // Continue
454
+ }
455
+ }
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ return null;
462
+ }
463
+
464
+ // Notify all clients to reload
465
+ function notifyReload() {
466
+ for (const controller of clients) {
467
+ try {
468
+ controller.enqueue("data: reload\n\n");
469
+ } catch {
470
+ clients.delete(controller);
471
+ }
472
+ }
473
+ }
474
+
475
+ // Start file watcher
476
+ function startWatcher(config: SiteConfig) {
477
+ const watchDirs = [ROOT, ENGINE_SITE_DIR];
478
+
479
+ // Watch site overrides if present
480
+ const overrideDir = join(ROOT, "kitfly");
481
+ watchDirs.push(overrideDir);
482
+
483
+ // Add section directories
484
+ for (const section of config.sections) {
485
+ if (section.path !== ".") {
486
+ const sectionPath = validatePath(ROOT, config.docroot, section.path);
487
+ if (sectionPath) {
488
+ watchDirs.push(sectionPath);
489
+ }
490
+ }
491
+ }
492
+
493
+ for (const dir of watchDirs) {
494
+ try {
495
+ watch(dir, { recursive: true }, (_event, filename) => {
496
+ if (
497
+ filename &&
498
+ (filename.endsWith(".md") ||
499
+ filename.endsWith(".yaml") ||
500
+ filename.endsWith(".json") ||
501
+ filename.endsWith(".html") ||
502
+ filename.endsWith(".css"))
503
+ ) {
504
+ logInfo(`File changed: ${filename}`);
505
+ notifyReload();
506
+ }
507
+ });
508
+ } catch {
509
+ // Directory doesn't exist, skip
510
+ }
511
+ }
512
+ }
513
+
514
+ // Main server startup
515
+ async function main() {
516
+ // Initialize structured logger early so all daemon output is captured.
517
+ // Dynamic import so standalone sites without tsfulmen don't break.
518
+ if (LOG_FORMAT === "structured") {
519
+ try {
520
+ const { createStructuredLogger } = await import("@fulmenhq/tsfulmen/logging");
521
+ daemonLog = createStructuredLogger("kitfly");
522
+ } catch {
523
+ // tsfulmen not available — fall back to console
524
+ }
525
+ }
526
+
527
+ // Load configuration
528
+ const config = await loadSiteConfig(ROOT);
529
+ logInfo(`Loaded config: "${config.title}" (${config.sections.length} sections)`);
530
+
531
+ // Apply server config from site.yaml if CLI didn't override
532
+ if (config.server?.port && PORT === DEFAULT_PORT) {
533
+ PORT = config.server.port;
534
+ }
535
+ if (config.server?.host && HOST === DEFAULT_HOST) {
536
+ HOST = config.server.host;
537
+ }
538
+
539
+ // Load theme
540
+ const theme = await loadTheme(ROOT);
541
+ logInfo(`Loaded theme: "${theme.name || "default"}"`);
542
+
543
+ // Generate provenance once at startup (dev mode)
544
+ const provenance = await generateProvenance(ROOT, true, config.version);
545
+
546
+ // Check port availability before starting server
547
+ await checkPortOrExit(PORT, HOST);
548
+
549
+ // Core request handler
550
+ async function handleRequest(req: Request): Promise<Response> {
551
+ const url = new URL(req.url);
552
+
553
+ // SSE endpoint for hot reload
554
+ if (url.pathname === "/__reload") {
555
+ const stream = new ReadableStream({
556
+ start(controller) {
557
+ clients.add(controller);
558
+ },
559
+ cancel(controller) {
560
+ clients.delete(controller);
561
+ },
562
+ });
563
+
564
+ return new Response(stream, {
565
+ headers: {
566
+ "Content-Type": "text/event-stream",
567
+ "Cache-Control": "no-cache",
568
+ Connection: "keep-alive",
569
+ },
570
+ });
571
+ }
572
+
573
+ // Serve provenance.json
574
+ if (url.pathname === "/provenance.json") {
575
+ return new Response(JSON.stringify(provenance, null, 2), {
576
+ headers: { "Content-Type": "application/json" },
577
+ });
578
+ }
579
+
580
+ // Serve CSS
581
+ if (url.pathname === "/styles.css") {
582
+ const css = await readFile(await resolveStylesPath(ROOT), "utf-8");
583
+ return new Response(css, {
584
+ headers: { "Content-Type": "text/css" },
585
+ });
586
+ }
587
+
588
+ // Serve built-in or site-provided assets
589
+ if (url.pathname.startsWith("/assets/")) {
590
+ const rel = decodeURIComponent(url.pathname).replace(/^\/assets\//, "");
591
+ const sitePath = join(ROOT, "assets", rel);
592
+ const siteResp = await tryServeFile(sitePath);
593
+ if (siteResp) return siteResp;
594
+
595
+ const enginePath = join(ENGINE_ASSETS_DIR, rel);
596
+ return (await tryServeFile(enginePath)) || new Response("Asset not found", { status: 404 });
597
+ }
598
+
599
+ // Serve content-linked assets (images, PDFs, etc.)
600
+ const assetResponse = await tryServeContentAsset(url.pathname, config);
601
+ if (assetResponse) return assetResponse;
602
+
603
+ // Check for content
604
+ const files = await collectFiles(ROOT, config);
605
+ if (files.length === 0) {
606
+ // No content - render Getting Started page
607
+ const html = await renderGettingStarted(provenance, config, theme);
608
+ return new Response(html, {
609
+ headers: { "Content-Type": "text/html" },
610
+ });
611
+ }
612
+
613
+ // Find and render markdown/yaml file
614
+ const filePath = await findFile(url.pathname, config);
615
+ if (filePath) {
616
+ // If this is an index/readme file and the URL lacks a trailing slash,
617
+ // redirect so relative links resolve correctly (BUG-003)
618
+ const stem = basename(filePath, extname(filePath)).toLowerCase();
619
+ if (
620
+ (stem === "index" || stem === "readme") &&
621
+ !url.pathname.endsWith("/") &&
622
+ url.pathname !== "/"
623
+ ) {
624
+ return new Response(null, {
625
+ status: 301,
626
+ headers: { Location: `${url.pathname}/` },
627
+ });
628
+ }
629
+ const html = await renderPage(filePath, url.pathname, provenance, config, theme);
630
+ return new Response(html, {
631
+ headers: { "Content-Type": "text/html" },
632
+ });
633
+ }
634
+
635
+ // Check if this is a section path - redirect to first file
636
+ const cleanPath = url.pathname.replace(/\/$/, "").slice(1); // Remove leading/trailing slashes
637
+ for (const file of files) {
638
+ const parts = file.urlPath.split("/");
639
+ if (parts.length > 1) {
640
+ const sectionPath = parts.slice(0, -1).join("/");
641
+ if (sectionPath === cleanPath) {
642
+ // Redirect to first file in this section
643
+ return new Response(null, {
644
+ status: 302,
645
+ headers: { Location: `/${file.urlPath}` },
646
+ });
647
+ }
648
+ }
649
+ }
650
+
651
+ // 404
652
+ return new Response("Not found", { status: 404 });
653
+ }
654
+
655
+ // Wrap with request logging middleware when in structured log mode
656
+ const fetch = daemonLog
657
+ ? async (req: Request) => {
658
+ const start = performance.now();
659
+ const response = await handleRequest(req);
660
+ const duration = (performance.now() - start).toFixed(0);
661
+ const url = new URL(req.url);
662
+ if (url.pathname !== "/__reload") {
663
+ daemonLog?.info(`${req.method} ${url.pathname} ${response.status} ${duration}ms`);
664
+ }
665
+ return response;
666
+ }
667
+ : handleRequest;
668
+
669
+ // Create server
670
+ Bun.serve({
671
+ port: PORT,
672
+ hostname: HOST,
673
+ fetch,
674
+ });
675
+
676
+ // Start watcher
677
+ startWatcher(config);
678
+
679
+ const displayHost = HOST === "0.0.0.0" ? "localhost" : HOST;
680
+ const serverUrl = `http://${displayHost}:${PORT}`;
681
+
682
+ if (daemonLog) {
683
+ // Daemon mode — structured log lines, no ANSI
684
+ logInfo(`Server started on ${serverUrl}`);
685
+ logInfo(`Content root: ${ROOT}`);
686
+ logInfo(`Version: ${provenance.version ? `v${provenance.version}` : "unversioned"}`);
687
+ if (HOST === "0.0.0.0") {
688
+ logWarn("Binding to all interfaces (0.0.0.0)");
689
+ }
690
+ } else {
691
+ // Foreground mode — pretty banner
692
+ console.log(`
693
+ \x1b[32m┌─────────────────────────────────────────┐
694
+ │ │
695
+ │ ${config.title.padEnd(35)}│
696
+ │ │
697
+ │ Local: ${serverUrl.padEnd(28)}│
698
+ │ Version: ${(provenance.version ? `v${provenance.version}` : "unversioned").padEnd(29)}│
699
+ │ │
700
+ │ Hot reload enabled - edit any .md │
701
+ │ or .yaml file to see changes │
702
+ │ │
703
+ └─────────────────────────────────────────┘\x1b[0m
704
+ `);
705
+
706
+ if (HOST === "0.0.0.0") {
707
+ console.log("\x1b[33m⚠ Binding to all interfaces (0.0.0.0)\x1b[0m\n");
708
+ }
709
+ }
710
+
711
+ // Open browser (macOS)
712
+ if (OPEN_BROWSER) {
713
+ Bun.spawn(["open", serverUrl]);
714
+ }
715
+ }
716
+
717
+ // Export for CLI usage
718
+ export interface DevOptions {
719
+ folder?: string;
720
+ port?: number;
721
+ host?: string;
722
+ open?: boolean;
723
+ logFormat?: string;
724
+ }
725
+
726
+ export async function dev(options: DevOptions = {}) {
727
+ if (options.folder) {
728
+ ROOT = resolve(process.cwd(), options.folder);
729
+ }
730
+ if (options.port) {
731
+ PORT = options.port;
732
+ }
733
+ if (options.host) {
734
+ HOST = options.host;
735
+ }
736
+ if (options.open === false) {
737
+ OPEN_BROWSER = false;
738
+ }
739
+ if (options.logFormat) {
740
+ LOG_FORMAT = options.logFormat;
741
+ }
742
+ await main();
743
+ }
744
+
745
+ // Run directly if executed as script
746
+ if (import.meta.main) {
747
+ // Check for help flag
748
+ if (process.argv.includes("--help")) {
749
+ console.log(`
750
+ Usage: bun run dev [folder] [options]
751
+
752
+ Options:
753
+ -p, --port <number> Port to serve on [env: KITFLY_DEV_PORT] [default: ${DEFAULT_PORT}]
754
+ -H, --host <string> Host to bind to [env: KITFLY_DEV_HOST] [default: ${DEFAULT_HOST}]
755
+ -o, --open Open browser on start [env: KITFLY_DEV_OPEN] [default: true]
756
+ --no-open Don't open browser
757
+ --help Show this help message
758
+
759
+ Examples:
760
+ bun run dev
761
+ bun run dev ./docs
762
+ bun run dev --port 8080
763
+ bun run dev ./docs -p 8080 --no-open
764
+ KITFLY_DEV_PORT=8080 bun run dev
765
+ `);
766
+ process.exit(0);
767
+ }
768
+
769
+ const cfg = getConfig();
770
+ dev({
771
+ folder: cfg.folder,
772
+ port: cfg.port,
773
+ host: cfg.host,
774
+ open: cfg.open,
775
+ logFormat: cfg.logFormat,
776
+ }).catch(console.error);
777
+ }