diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 101bfa3..71c7b95 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -72,6 +72,19 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo
---
+## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
+
+**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
+
+- Preview HTML must reference only local/package-bundled assets
+- Generated HTML must not include CDN-hosted JS/CSS libraries
+- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
+- Avoid introducing any new `
@@ -237,6 +255,12 @@ export class PreviewServer {
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
+ const asset = await this.resolveAsset(pathname);
+ if (asset) {
+ this.respondAsset(res, asset.contentType, asset.body);
+ return;
+ }
+
const result = await this.resolveRoute(pathname, maxPostsPerPage);
if (!result) {
this.respond(res, 404, 'Not Found');
@@ -360,10 +384,38 @@ export class PreviewServer {
return rendered.join('\n');
}
+ private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
+ const match = pathname.match(/^\/assets\/([^/]+)$/);
+ if (!match) return null;
+
+ const assetName = match[1] as keyof typeof PREVIEW_ASSETS;
+ const assetDefinition = PREVIEW_ASSETS[assetName];
+ if (!assetDefinition) return null;
+
+ try {
+ const absolutePath = require.resolve(assetDefinition.modulePath);
+ const body = await readFile(absolutePath);
+ return {
+ contentType: assetDefinition.contentType,
+ body,
+ };
+ } catch (error) {
+ console.error(`[PreviewServer] Failed to read local asset: ${assetDefinition.modulePath}`, error);
+ return null;
+ }
+ }
+
private respond(res: ServerResponse, status: number, body: string): void {
res.statusCode = status;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(body);
}
+
+ private respondAsset(res: ServerResponse, contentType: string, body: Buffer): void {
+ res.statusCode = 200;
+ res.setHeader('Content-Type', contentType);
+ res.setHeader('Cache-Control', 'no-store');
+ res.end(body);
+ }
}
diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts
index 847c6e7..9e923b0 100644
--- a/tests/engine/PreviewServer.test.ts
+++ b/tests/engine/PreviewServer.test.ts
@@ -121,6 +121,37 @@ describe('PreviewServer', () => {
expect(html).toContain('Newest
');
});
+ it('uses local CSS/JS assets and serves them from the preview server', async () => {
+ server = new PreviewServer({
+ postEngine: makeEngine([makePost()]),
+ settingsEngine: makeSettings(50),
+ getActiveProjectContext: async () => ({ projectId: 'default' }),
+ });
+
+ await server.start(0);
+
+ const rootResponse = await fetch(`${server.getBaseUrl()}/`);
+ expect(rootResponse.status).toBe(200);
+ const rootHtml = await rootResponse.text();
+
+ expect(rootHtml).toContain('href="/assets/pico.min.css"');
+ expect(rootHtml).toContain('href="/assets/lightbox.min.css"');
+ expect(rootHtml).toContain('src="/assets/lightbox.min.js"');
+ expect(rootHtml).not.toContain('cdn.jsdelivr.net');
+
+ const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`);
+ expect(picoResponse.status).toBe(200);
+ expect(picoResponse.headers.get('content-type')).toContain('text/css');
+
+ const lightboxCssResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.css`);
+ expect(lightboxCssResponse.status).toBe(200);
+ expect(lightboxCssResponse.headers.get('content-type')).toContain('text/css');
+
+ const lightboxJsResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.js`);
+ expect(lightboxJsResponse.status).toBe(200);
+ expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript');
+ });
+
it('limits list routes to 50 posts', async () => {
const posts = Array.from({ length: 60 }).map((_, index) =>
makePost({