Skip to content

Commit ecf906f

Browse files
committed
feat(FR-2165): add serve:web dev server and package scripts (#5650)
Resolves #5630 (FR-2165) ## Summary - Create `preview-server-website.ts` with `startWebsitePreviewServer()` for multi-page website development - Dev server performs initial `build:web`, watches `src/{lang}/**/*.md` and `book.config.yaml` for changes, rebuilds on change, and injects live-reload script into HTML responses - Add `serve:web` CLI command with `--lang` and `--port` options (default port: 3458) - Add package scripts to `webui-docs/package.json`: `build:web`, `build:web:en`, `build:web:ko`, `serve:web`, `serve:web:ko`, `serve:web:ja`, `serve:web:th` - Export `startWebsitePreviewServer` and `WebsitePreviewOptions` from the package index ## Test plan - [ ] Verify TypeScript compilation passes (`npx tsc --noEmit`) - [ ] Run `docs-toolkit serve:web --lang en` and verify pages are served at `http://localhost:3458` - [ ] Edit a markdown file and verify live-reload triggers a page refresh - [ ] Verify `pnpm run serve:web` works from the webui-docs package - [ ] Verify `pnpm run build:web` generates static files in `dist/web/` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 44c310a commit ecf906f

4 files changed

Lines changed: 243 additions & 1 deletion

File tree

packages/backend.ai-docs-toolkit/src/cli.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'url';
1717
import { loadToolkitConfig, resolveConfig } from './config.js';
1818
import type { ResolvedDocConfig, AgentConfig } from './config.js';
1919

20-
const COMMANDS = ['pdf', 'preview', 'preview:html', 'build:web', 'init', 'agents', 'help'] as const;
20+
const COMMANDS = ['pdf', 'preview', 'preview:html', 'build:web', 'serve:web', 'init', 'agents', 'help'] as const;
2121
type Command = (typeof COMMANDS)[number];
2222

2323
function printUsage(): void {
@@ -32,6 +32,7 @@ Commands:
3232
preview PDF preview server (live-reload)
3333
preview:html HTML preview server (live-reload, no PDF)
3434
build:web Generate static multi-page website
35+
serve:web Website dev server (live-reload)
3536
init Initialize a new documentation project
3637
agents Generate Claude AI agent files from templates
3738
help Show this help message
@@ -55,6 +56,10 @@ Options:
5556
build:web:
5657
--lang <all|en|ko|...> Language(s) to generate (default: all)
5758
59+
serve:web:
60+
--lang <en|ko|...> Language (default: en)
61+
--port <number> Port number (default: 3458)
62+
5863
agents:
5964
--force Overwrite existing agent files
6065
@@ -66,6 +71,8 @@ Examples:
6671
docs-toolkit preview:html --lang en
6772
docs-toolkit build:web --lang all
6873
docs-toolkit build:web --lang en
74+
docs-toolkit serve:web --lang en
75+
docs-toolkit serve:web --lang ko --port 3459
6976
docs-toolkit init
7077
docs-toolkit agents
7178
docs-toolkit agents --force
@@ -366,6 +373,12 @@ async function main(): Promise<void> {
366373
break;
367374
}
368375

376+
case 'serve:web': {
377+
const { startWebsitePreviewServer } = await import('./preview-server-website.js');
378+
await startWebsitePreviewServer(config);
379+
break;
380+
}
381+
369382
case 'agents': {
370383
const force = hasFlag(argv, '--force');
371384
await runAgents(config, force);

packages/backend.ai-docs-toolkit/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export { startPreviewServer } from './preview-server.js';
8484
export type { PreviewServerOptions } from './preview-server.js';
8585
export { startHtmlPreviewServer } from './preview-server-web.js';
8686
export type { HtmlPreviewOptions } from './preview-server-web.js';
87+
export { startWebsitePreviewServer } from './preview-server-website.js';
88+
export type { WebsitePreviewOptions } from './preview-server-website.js';
8789

8890
// ── Styles ──────────────────────────────────────────────────────
8991
export { generatePdfStyles } from './styles.js';
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/**
2+
* Website Preview Server - serves the multi-page static website with live-reload.
3+
* Rebuilds pages on file changes and serves the generated files from disk.
4+
*/
5+
6+
import fs from 'fs';
7+
import http from 'http';
8+
import path from 'path';
9+
import { parse as parseYaml } from 'yaml';
10+
import { generateWebsite } from './website-generator.js';
11+
import type { ResolvedDocConfig } from './config.js';
12+
13+
interface BookConfig {
14+
title: string;
15+
description: string;
16+
languages: string[];
17+
navigation: Record<string, Array<{ title: string; path: string }>>;
18+
}
19+
20+
export interface WebsitePreviewOptions {
21+
lang: string;
22+
port: number;
23+
}
24+
25+
function parseArgs(argv: string[]): WebsitePreviewOptions {
26+
let lang = 'en';
27+
let port = 3458;
28+
for (let i = 0; i < argv.length; i++) {
29+
if (argv[i] === '--lang' && argv[i + 1]) { lang = argv[i + 1]; i++; }
30+
if (argv[i] === '--port' && argv[i + 1]) { port = parseInt(argv[i + 1], 10); i++; }
31+
}
32+
return { lang, port };
33+
}
34+
35+
/** MIME types for static files */
36+
const MIME_TYPES: Record<string, string> = {
37+
'.html': 'text/html; charset=utf-8',
38+
'.css': 'text/css; charset=utf-8',
39+
'.js': 'application/javascript; charset=utf-8',
40+
'.json': 'application/json; charset=utf-8',
41+
'.png': 'image/png',
42+
'.jpg': 'image/jpeg',
43+
'.jpeg': 'image/jpeg',
44+
'.gif': 'image/gif',
45+
'.svg': 'image/svg+xml',
46+
'.webp': 'image/webp',
47+
};
48+
49+
/**
50+
* Start a development server for the multi-page website with live-reload.
51+
* Performs an initial build, watches for changes, rebuilds, and serves files.
52+
*/
53+
export async function startWebsitePreviewServer(
54+
config: ResolvedDocConfig,
55+
options?: Partial<WebsitePreviewOptions>,
56+
): Promise<void> {
57+
const args = { ...parseArgs(process.argv.slice(2)), ...options };
58+
59+
const configPath = path.join(config.srcDir, 'book.config.yaml');
60+
const bookConfig: BookConfig = parseYaml(fs.readFileSync(configPath, 'utf-8'));
61+
62+
const navigation = bookConfig.navigation[args.lang];
63+
if (!navigation) {
64+
console.error(`No navigation found for language: ${args.lang}`);
65+
console.error(`Available: ${Object.keys(bookConfig.navigation).join(', ')}`);
66+
process.exit(1);
67+
}
68+
69+
const websiteOutDir = config.website?.outDir ?? 'web';
70+
const distBase = path.join(config.distDir, websiteOutDir);
71+
72+
let currentEtag = Date.now().toString(36);
73+
74+
// Initial build
75+
console.log(` Building website (${args.lang})...`);
76+
await generateWebsite(config, { lang: args.lang });
77+
78+
// Serialized rebuild: prevents concurrent builds and unhandled promise rejections
79+
const debounceMs = 500;
80+
let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
81+
let currentBuild: Promise<void> | null = null;
82+
let pendingRebuild = false;
83+
84+
function runSerializedBuild(changedFile: string) {
85+
if (currentBuild) {
86+
pendingRebuild = true;
87+
return;
88+
}
89+
90+
pendingRebuild = false;
91+
currentBuild = (async () => {
92+
console.log(` File changed: ${path.relative(config.projectRoot, changedFile)}`);
93+
try {
94+
await generateWebsite(config, { lang: args.lang });
95+
currentEtag = Date.now().toString(36);
96+
console.log(' Rebuild complete.');
97+
} catch (err) {
98+
console.error(' Rebuild failed:', err);
99+
} finally {
100+
currentBuild = null;
101+
if (pendingRebuild) {
102+
runSerializedBuild(changedFile);
103+
}
104+
}
105+
})();
106+
}
107+
108+
function scheduleRebuild(changedFile: string) {
109+
if (rebuildTimer) clearTimeout(rebuildTimer);
110+
rebuildTimer = setTimeout(() => {
111+
rebuildTimer = null;
112+
runSerializedBuild(changedFile);
113+
}, debounceMs);
114+
}
115+
116+
// Watch source files
117+
const srcLangDir = path.join(config.srcDir, args.lang);
118+
if (fs.existsSync(srcLangDir)) {
119+
fs.watch(srcLangDir, { recursive: true }, (_event, filename) => {
120+
const name = filename?.toString();
121+
if (name && (name.endsWith('.md') || name.endsWith('.yaml'))) {
122+
scheduleRebuild(path.join(srcLangDir, name));
123+
}
124+
});
125+
}
126+
127+
// Watch config
128+
fs.watch(configPath, () => { scheduleRebuild(configPath); });
129+
130+
// Inject live-reload script into HTML responses
131+
const RELOAD_SCRIPT = `<script>
132+
(function(){
133+
var etag='';
134+
setInterval(function(){
135+
fetch('/__reload').then(function(r){return r.json()}).then(function(d){
136+
if(etag&&d.etag!==etag)location.reload();
137+
etag=d.etag;
138+
}).catch(function(){});
139+
},1000);
140+
})();
141+
</script>`;
142+
143+
// HTTP server
144+
const server = http.createServer((req, res) => {
145+
const url = new URL(req.url || '/', `http://localhost:${args.port}`);
146+
147+
// Live-reload polling endpoint
148+
if (url.pathname === '/__reload') {
149+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
150+
res.end(JSON.stringify({ etag: currentEtag }));
151+
return;
152+
}
153+
154+
// Resolve file path from URL
155+
let filePath: string;
156+
if (url.pathname === '/' || url.pathname === `/${args.lang}/` || url.pathname === `/${args.lang}`) {
157+
// Serve language index for root and language root paths
158+
filePath = path.join(distBase, args.lang, 'index.html');
159+
} else {
160+
// Normalize: remove leading slash
161+
const safePath = path.normalize(url.pathname).replace(/^\/+/, '');
162+
filePath = path.resolve(distBase, safePath);
163+
}
164+
165+
// Security: ensure file is within distBase
166+
if (!filePath.startsWith(distBase + path.sep) && filePath !== distBase) {
167+
res.writeHead(403, { 'Content-Type': 'text/plain' });
168+
res.end('Forbidden');
169+
return;
170+
}
171+
172+
// Check if file exists
173+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
174+
// Try with .html extension
175+
if (!filePath.endsWith('.html') && fs.existsSync(filePath + '.html')) {
176+
filePath = filePath + '.html';
177+
} else {
178+
res.writeHead(404, { 'Content-Type': 'text/plain' });
179+
res.end('Not found');
180+
return;
181+
}
182+
}
183+
184+
const ext = path.extname(filePath).toLowerCase();
185+
const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream';
186+
187+
// For HTML files, inject live-reload script
188+
if (ext === '.html') {
189+
let html = fs.readFileSync(filePath, 'utf-8');
190+
html = html.replace('</body>', `${RELOAD_SCRIPT}\n</body>`);
191+
res.writeHead(200, { 'Content-Type': mimeType, 'Cache-Control': 'no-cache' });
192+
res.end(html);
193+
return;
194+
}
195+
196+
// Serve static files with error handling to prevent process crash
197+
res.writeHead(200, { 'Content-Type': mimeType, 'Cache-Control': 'max-age=5' });
198+
const stream = fs.createReadStream(filePath);
199+
stream.on('error', (err) => {
200+
console.error(` Stream error for ${filePath}:`, err.message);
201+
if (!res.headersSent) {
202+
res.writeHead(500, { 'Content-Type': 'text/plain' });
203+
}
204+
res.end('Internal server error');
205+
});
206+
stream.pipe(res);
207+
});
208+
209+
server.listen(args.port, () => {
210+
const productName = config.productName;
211+
console.log('');
212+
console.log(` ${productName} - Website Preview`);
213+
console.log(` Language: ${args.lang}`);
214+
console.log(` URL: http://localhost:${args.port}`);
215+
console.log('');
216+
console.log(` Editing src/${args.lang}/**/*.md will auto-reload the page.`);
217+
console.log(' Press Ctrl+C to stop.');
218+
console.log('');
219+
});
220+
}

packages/backend.ai-webui-docs/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
"preview:html:ja": "pnpm run build:toolkit && docs-toolkit preview:html --lang ja",
2323
"preview:html:th": "pnpm run build:toolkit && docs-toolkit preview:html --lang th",
2424
"preview:html:catalog": "pnpm run build:toolkit && docs-toolkit preview:html --mode catalog",
25+
"build:web": "pnpm run build:toolkit && docs-toolkit build:web --lang all",
26+
"build:web:en": "pnpm run build:toolkit && docs-toolkit build:web --lang en",
27+
"build:web:ko": "pnpm run build:toolkit && docs-toolkit build:web --lang ko",
28+
"serve:web": "pnpm run build:toolkit && docs-toolkit serve:web --lang en",
29+
"serve:web:ko": "pnpm run build:toolkit && docs-toolkit serve:web --lang ko",
30+
"serve:web:ja": "pnpm run build:toolkit && docs-toolkit serve:web --lang ja",
31+
"serve:web:th": "pnpm run build:toolkit && docs-toolkit serve:web --lang th",
2532
"agents": "docs-toolkit agents",
2633
"agents:force": "docs-toolkit agents --force"
2734
},

0 commit comments

Comments
 (0)