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({