feat: preview uses no outside ressources
This commit is contained in:
13
.github/copilot-instructions.md
vendored
13
.github/copilot-instructions.md
vendored
@@ -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 `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
|
||||||
|
|
||||||
|
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture Principles
|
## Architecture Principles
|
||||||
|
|
||||||
### Separation of Concerns
|
### Separation of Concerns
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -22,12 +22,14 @@
|
|||||||
"@milkdown/react": "^7.18.0",
|
"@milkdown/react": "^7.18.0",
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
"@milkdown/theme-nord": "^7.18.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"lightbox2": "^2.11.5",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -4452,6 +4454,12 @@
|
|||||||
"url": "https://github.com/sponsors/ocavue"
|
"url": "https://github.com/sponsors/ocavue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@picocss/pico": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -9326,6 +9334,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightbox2": {
|
||||||
|
"version": "2.11.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightbox2/-/lightbox2-2.11.5.tgz",
|
||||||
|
"integrity": "sha512-IsDqv/D9pjgh7GvwTNvmHF98+nrIcOD17fraXgtx8ivq469y95l5ycLi6SeZAZHdeyD3cGLjYwbDX8SRfWx5fA=="
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
|||||||
@@ -67,12 +67,14 @@
|
|||||||
"@milkdown/react": "^7.18.0",
|
"@milkdown/react": "^7.18.0",
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
"@milkdown/theme-nord": "^7.18.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@picocss/pico": "^2.1.1",
|
||||||
"@xmldom/xmldom": "^0.8.11",
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"lightbox2": "^2.11.5",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
||||||
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
|
||||||
@@ -30,6 +31,21 @@ const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
|||||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
const MAX_MAX_POSTS_PER_PAGE = 500;
|
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||||
|
|
||||||
|
const PREVIEW_ASSETS = {
|
||||||
|
'pico.min.css': {
|
||||||
|
modulePath: '@picocss/pico/css/pico.min.css',
|
||||||
|
contentType: 'text/css; charset=utf-8',
|
||||||
|
},
|
||||||
|
'lightbox.min.css': {
|
||||||
|
modulePath: 'lightbox2/dist/css/lightbox.min.css',
|
||||||
|
contentType: 'text/css; charset=utf-8',
|
||||||
|
},
|
||||||
|
'lightbox.min.js': {
|
||||||
|
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
|
||||||
|
contentType: 'application/javascript; charset=utf-8',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
function clampMaxPostsPerPage(value: unknown): number {
|
function clampMaxPostsPerPage(value: unknown): number {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
return DEFAULT_MAX_POSTS_PER_PAGE;
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||||
@@ -111,7 +127,8 @@ function getPageHtml(content: string, title: string): string {
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>${escapeHtml(title)}</title>
|
<title>${escapeHtml(title)}</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
<link rel="stylesheet" href="/assets/pico.min.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; }
|
:root { color-scheme: light dark; }
|
||||||
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||||
@@ -120,6 +137,7 @@ function getPageHtml(content: string, title: string): string {
|
|||||||
.post iframe { width: 100%; min-height: 20rem; }
|
.post iframe { width: 100%; min-height: 20rem; }
|
||||||
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; }
|
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; }
|
||||||
</style>
|
</style>
|
||||||
|
<script defer src="/assets/lightbox.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@@ -237,6 +255,12 @@ export class PreviewServer {
|
|||||||
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
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);
|
const result = await this.resolveRoute(pathname, maxPostsPerPage);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.respond(res, 404, 'Not Found');
|
this.respond(res, 404, 'Not Found');
|
||||||
@@ -360,10 +384,38 @@ export class PreviewServer {
|
|||||||
return rendered.join('\n');
|
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 {
|
private respond(res: ServerResponse, status: number, body: string): void {
|
||||||
res.statusCode = status;
|
res.statusCode = status;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.end(body);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,37 @@ describe('PreviewServer', () => {
|
|||||||
expect(html).toContain('<h1>Newest</h1>');
|
expect(html).toContain('<h1>Newest</h1>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it('limits list routes to 50 posts', async () => {
|
||||||
const posts = Array.from({ length: 60 }).map((_, index) =>
|
const posts = Array.from({ length: 60 }).map((_, index) =>
|
||||||
makePost({
|
makePost({
|
||||||
|
|||||||
Reference in New Issue
Block a user