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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/VERSION +1 -0
- package/package.json +63 -0
- package/schemas/README.md +32 -0
- package/schemas/site.schema.json +5 -0
- package/schemas/theme.schema.json +5 -0
- package/schemas/v0/site.schema.json +172 -0
- package/schemas/v0/theme.schema.json +210 -0
- package/scripts/build-all.ts +121 -0
- package/scripts/build.ts +601 -0
- package/scripts/bundle.ts +781 -0
- package/scripts/dev.ts +777 -0
- package/scripts/generate-checksums.sh +78 -0
- package/scripts/release/export-release-key.sh +28 -0
- package/scripts/release/release-guard-tag-version.sh +79 -0
- package/scripts/release/sign-release-assets.sh +123 -0
- package/scripts/release/upload-release-assets.sh +76 -0
- package/scripts/release/upload-release-provenance.sh +52 -0
- package/scripts/release/verify-public-key.sh +48 -0
- package/scripts/release/verify-signatures.sh +117 -0
- package/scripts/version-sync.ts +82 -0
- package/src/__tests__/build.test.ts +240 -0
- package/src/__tests__/bundle.test.ts +786 -0
- package/src/__tests__/cli.test.ts +706 -0
- package/src/__tests__/crucible.test.ts +1043 -0
- package/src/__tests__/engine.test.ts +157 -0
- package/src/__tests__/init.test.ts +450 -0
- package/src/__tests__/pipeline.test.ts +1087 -0
- package/src/__tests__/productbook.test.ts +1206 -0
- package/src/__tests__/runbook.test.ts +974 -0
- package/src/__tests__/server-registry.test.ts +1251 -0
- package/src/__tests__/servicebook.test.ts +1248 -0
- package/src/__tests__/shared.test.ts +2005 -0
- package/src/__tests__/styles.test.ts +14 -0
- package/src/__tests__/theme-schema.test.ts +47 -0
- package/src/__tests__/theme.test.ts +554 -0
- package/src/cli.ts +582 -0
- package/src/commands/init.ts +92 -0
- package/src/commands/update.ts +444 -0
- package/src/engine.ts +20 -0
- package/src/logger.ts +15 -0
- package/src/migrations/0000_schema_versioning.ts +67 -0
- package/src/migrations/0001_server_port.ts +52 -0
- package/src/migrations/0002_brand_logo.ts +49 -0
- package/src/migrations/index.ts +26 -0
- package/src/migrations/schema.ts +24 -0
- package/src/server-registry.ts +405 -0
- package/src/shared.ts +1239 -0
- package/src/site/styles.css +931 -0
- package/src/site/template.html +193 -0
- package/src/templates/crucible.ts +1163 -0
- package/src/templates/driver.ts +876 -0
- package/src/templates/handbook.ts +339 -0
- package/src/templates/minimal.ts +139 -0
- package/src/templates/pipeline.ts +966 -0
- package/src/templates/productbook.ts +1032 -0
- package/src/templates/runbook.ts +829 -0
- package/src/templates/schema.ts +119 -0
- package/src/templates/servicebook.ts +1242 -0
- 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, """);
|
|
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
|
+
}
|