Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5 386d37194b fix(test): load the preview script without a shebang or a built path
test/website-preview.test.ts failed to parse on windows-pwsh only, with
`SyntaxError: Invalid or unexpected token`:

- `preview.mjs` opened with `#!/usr/bin/env node`. The file is imported by
  the suite, and `#` is an unexpected token for any parser that does not
  strip it. The script is never executed directly - `pnpm start` runs
  `node scripts/preview.mjs` - and the file is not executable, so the
  shebang was decorative.
- The suite imported it by a path built with `path.join`, which on Windows
  is `D:\\...`: not a module specifier. Use a literal relative specifier, so
  the runner resolves it the same way it resolves every other import here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:48:19 -05:00
Clay GoodandClaude Opus 5 2b8be50321 fix(test): import the preview script through a file URL for Windows
A bare absolute path is not a valid module specifier on Windows, so
`await import('D:\\a\\OpenSpec\\website\\scripts\\preview.mjs')` failed the suite
with `SyntaxError: Invalid or unexpected token` on windows-pwsh only. Route it
through `pathToFileURL`, and add the release changeset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:33:24 -05:00
Clay GoodandClaude Opus 5 7313d6b024 fix(website): preview the built site without a server dependency
`pnpm start` ran `serve`, a dev-only tool whose transitive tree kept
publishing advisories into the docs site's graph. Four of them had to be
pinned by hand in #2019 (brace-expansion x3, fast-uri) after the Security
workflow went red on main, for code that never reached the published site.

Replace it with a first-party static preview script, which has no tree to
audit, and drop the three pins that existed only to patch serve's tree
(brace-expansion, fast-uri, compression). The build-time pins (postcss,
sharp, nanoid) stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 15:48:43 -05:00
7 changed files with 298 additions and 639 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@fission-ai/openspec': patch
---
Preview the built docs site with a first-party static server instead of `serve`.
`pnpm start` in `website/` now runs `scripts/preview.mjs`, a dependency-free
server that mirrors Cloudflare Pages routing for a Next static export. This
drops the `serve` dev dependency whose transitive tree kept publishing
advisories (brace-expansion x3, fast-uri, compression) that had to be pinned by
hand even though none of that code ever reached the published site.
+18
View File
@@ -54,6 +54,24 @@ describe('pnpm workspace configuration', () => {
expect(workspace.overrides).toEqual(lockfile.overrides);
});
it('previews the built site without a server dependency', () => {
const packageJson = readJson('website/package.json');
const lockfile = readYaml('website/pnpm-lock.yaml');
// `pnpm start` ran `serve`, whose dev-only tree kept publishing advisories
// into the site's graph — four of them (brace-expansion x3, fast-uri) turned
// the Security workflow red on `main` and had to be pinned by hand, for code
// that never reached the published site. A first-party preview script has no
// tree to audit. Re-adding a server dependency means taking that back on
// purpose, so it should update this test too.
expect(packageJson.scripts.start).toBe('node scripts/preview.mjs');
expect(fs.existsSync(path.join(projectRoot, 'website/scripts/preview.mjs'))).toBe(true);
const serverPackages = Object.keys(lockfile.packages).filter((key) =>
/^(serve|serve-handler)@/.test(key)
);
expect(serverPackages).toEqual([]);
});
it('includes install policy changes in Nix and security validation', () => {
const flake = fs.readFileSync(path.join(projectRoot, 'flake.nix'), 'utf8');
const ci = fs.readFileSync(path.join(projectRoot, '.github/workflows/ci.yml'), 'utf8');
+135
View File
@@ -0,0 +1,135 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { AddressInfo } from 'net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
type PreviewModule = {
resolveStaticFile(root: string, requestPath: string): string | null;
contentTypeFor(filePath: string): string;
createPreviewServer(root: string): import('http').Server;
};
// A literal relative specifier, so the runner resolves it the same way it
// resolves every other import in this suite. Building the path at runtime
// instead handed it a Windows absolute path, which is not a module specifier:
// the suite failed to load on windows-pwsh with `SyntaxError: Invalid or
// unexpected token`, and a `file://` URL did not fix it either.
// `test/pnpm-workspace-config.test.ts` pins the script's location.
const preview = (await import(
'../website/scripts/preview.mjs'
)) as unknown as PreviewModule;
/**
* Mirrors a Next static export: a route is written as `docs/cli.html`, and a
* directory of the same name holds its data files but no index.html.
*/
function buildFixture(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-preview-'));
fs.writeFileSync(path.join(root, 'index.html'), '<html>home</html>');
fs.writeFileSync(path.join(root, '404.html'), '<html>missing</html>');
fs.writeFileSync(path.join(root, 'robots.txt'), 'User-agent: *\n');
fs.mkdirSync(path.join(root, 'docs', 'cli'), { recursive: true });
fs.writeFileSync(path.join(root, 'docs', 'cli.html'), '<html>cli</html>');
fs.writeFileSync(path.join(root, 'docs', 'cli', 'data.txt'), 'data');
fs.mkdirSync(path.join(root, 'guide'), { recursive: true });
fs.writeFileSync(path.join(root, 'guide', 'index.html'), '<html>guide</html>');
return root;
}
describe('website preview server', () => {
let root: string;
beforeAll(() => {
root = buildFixture();
});
afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe('resolveStaticFile', () => {
it('serves the root index', () => {
expect(preview.resolveStaticFile(root, '/')).toBe(path.join(root, 'index.html'));
});
it('falls back to the exported .html file for a route', () => {
expect(preview.resolveStaticFile(root, '/docs/cli')).toBe(
path.join(root, 'docs', 'cli.html')
);
});
it('resolves a route the same way with a trailing slash', () => {
expect(preview.resolveStaticFile(root, '/docs/cli/')).toBe(
path.join(root, 'docs', 'cli.html')
);
});
it('prefers a directory index when one exists', () => {
expect(preview.resolveStaticFile(root, '/guide')).toBe(
path.join(root, 'guide', 'index.html')
);
});
it('serves a file by its exact path', () => {
expect(preview.resolveStaticFile(root, '/robots.txt')).toBe(path.join(root, 'robots.txt'));
});
it('returns null for a path with no match', () => {
expect(preview.resolveStaticFile(root, '/nope')).toBeNull();
});
it('refuses to escape the export directory', () => {
const outside = path.join(root, '..', 'escaped.txt');
fs.writeFileSync(outside, 'secret');
try {
expect(preview.resolveStaticFile(root, '/../escaped.txt')).toBeNull();
expect(preview.resolveStaticFile(root, '/%2e%2e/escaped.txt')).toBeNull();
expect(preview.resolveStaticFile(root, '/docs/../../escaped.txt')).toBeNull();
} finally {
fs.rmSync(outside, { force: true });
}
});
it('rejects malformed encoding and null bytes', () => {
expect(preview.resolveStaticFile(root, '/%E0%A4%A')).toBeNull();
expect(preview.resolveStaticFile(root, '/index.html%00.txt')).toBeNull();
});
});
describe('contentTypeFor', () => {
it('names the common static types', () => {
expect(preview.contentTypeFor('a/b.html')).toBe('text/html; charset=utf-8');
expect(preview.contentTypeFor('a/b.css')).toBe('text/css; charset=utf-8');
expect(preview.contentTypeFor('a/b.svg')).toBe('image/svg+xml');
expect(preview.contentTypeFor('a/b.woff2')).toBe('font/woff2');
});
it('falls back to a byte stream for anything else', () => {
expect(preview.contentTypeFor('a/b.unknown')).toBe('application/octet-stream');
});
});
describe('over HTTP', () => {
it('serves pages and answers a miss with the 404 page', async () => {
const server = preview.createPreviewServer(root);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
try {
const page = await fetch(`http://127.0.0.1:${port}/docs/cli`);
expect(page.status).toBe(200);
expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
expect(await page.text()).toBe('<html>cli</html>');
const missing = await fetch(`http://127.0.0.1:${port}/nope`);
expect(missing.status).toBe(404);
expect(await missing.text()).toBe('<html>missing</html>');
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
});
});
});
+1 -2
View File
@@ -8,7 +8,7 @@
"sync:docs:watch": "node --watch-path=../docs --watch-path=../docs-lab --watch-path=docs.sync.config.mjs --watch-preserve-output scripts/sync-docs.mjs",
"build": "pnpm run sync:docs && fumadocs-mdx && next build",
"dev": "pnpm run sync:docs && (pnpm run sync:docs:watch & next dev)",
"start": "serve out",
"start": "node scripts/preview.mjs",
"types:check": "pnpm run sync:docs && fumadocs-mdx && next typegen && tsc --noEmit"
},
"dependencies": {
@@ -29,7 +29,6 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"postcss": "^8.5.29",
"serve": "^14.2.6",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3"
}
-627
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -12,17 +12,7 @@ allowBuilds:
overrides:
postcss: ^8.5.28
sharp: ^0.35.3
# GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr — brace-expansion
# DoS via nested or comma-heavy brace groups. Dev-only (serve > serve-handler >
# minimatch); never in the static site.
brace-expansion@<5.0.12: '>=5.0.12 <6'
# GHSA-hrr3-gc8f-f4qj — fast-uri. Dev-only (serve > ajv).
fast-uri@<3.1.8: ^3.1.8
# GHSA-2v37-7h3g-55p8 / CVE-2026-67213 — nanoid infinite loop on size=0. Build-time
# only (transitive via postcss); this is a statically exported site with no server
# runtime. Remove once transitive nanoid is >=3.3.17 (check: pnpm why nanoid).
nanoid@<3.3.17: '>=3.3.17 <4'
# GHSA-vc2v-76pw-4v95 — compression memory-leak DoS on premature response close.
# Dev-only (serve > compression); never in the static site. Remove once
# transitive compression is >=1.8.2 (check: pnpm why compression).
compression@<1.8.2: '>=1.8.2 <2'
+134
View File
@@ -0,0 +1,134 @@
// Serve the built static site in `out/` for a local look at what Cloudflare
// Pages will publish. Backs `pnpm start`.
//
// Deliberately dependency-free. This replaced `serve`, whose transitive tree
// kept publishing advisories into a dev-only tool: four of them (brace-expansion
// x3, fast-uri) turned the Security workflow red on `main` and had to be pinned
// by hand, and none of that code ever reached the published site. A preview
// server for a static export is small enough to own outright.
//
// Routing approximates Cloudflare Pages for a Next static export:
// / -> out/index.html
// /docs/cli -> out/docs/cli.html (or out/docs/cli/index.html)
// /docs/cli/ -> the same, so links work with or without a trailing slash
// anything else -> out/404.html with status 404
//
// `out/_redirects` is a Cloudflare feature and is not applied here, same as
// before: the preview serves the exported files as they are.
import { createServer } from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const websiteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const outDir = join(websiteRoot, 'out');
const CONTENT_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
};
export function contentTypeFor(filePath) {
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
}
/**
* Resolves a request path to a file inside `root`, or null when nothing matches.
*
* Escaping `root` is never a match: the decoded path is normalized and the
* result is checked to still sit under `root`, so `..` segments and an
* encoded `%2e%2e` both fall through to the 404 page.
*/
export function resolveStaticFile(root, requestPath) {
let decoded;
try {
decoded = decodeURIComponent(requestPath);
} catch {
return null; // Malformed percent-encoding.
}
if (decoded.includes('\0')) return null;
// Normalize in POSIX terms first: a request path is URL-shaped, so its
// separator is `/` on every platform.
// Trailing separators are dropped so a directory request and the `.html`
// fallback for it build the same base path.
const relativePath = normalize(decoded.replace(/^\/+/, '').split('/').join(sep)).replace(
new RegExp(`\\${sep}+$`),
''
);
const base = relativePath === '' || relativePath === '.' ? root : join(root, relativePath);
if (base !== root && !base.startsWith(root + sep)) return null;
// Next's export writes a route as `docs/cli.html`, not `docs/cli/index.html`,
// so both spellings have to resolve either way the link was written.
const candidates = decoded.endsWith('/')
? [join(base, 'index.html'), `${base}.html`]
: [base, join(base, 'index.html'), `${base}.html`];
for (const candidate of candidates) {
if (candidate !== root && !candidate.startsWith(root + sep)) continue;
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
/** The preview server for a built export directory. Exported for tests. */
export function createPreviewServer(root) {
return createServer((request, response) => {
const requestPath = new URL(request.url ?? '/', 'http://localhost').pathname;
const filePath = resolveStaticFile(root, requestPath);
if (!filePath) {
const notFound = join(root, '404.html');
if (existsSync(notFound)) {
response.writeHead(404, { 'content-type': 'text/html; charset=utf-8' });
createReadStream(notFound).pipe(response);
return;
}
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
response.end('Not found\n');
return;
}
response.writeHead(200, { 'content-type': contentTypeFor(filePath) });
createReadStream(filePath).pipe(response);
});
}
function main() {
if (!existsSync(outDir)) {
console.error(`No build found at ${outDir}. Run "pnpm build" first.`);
process.exit(1);
}
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? 'localhost';
createPreviewServer(outDir).listen(port, host, () => {
console.log(`Serving ${outDir} at http://${host}:${port}`);
});
}
// Importing this file for its helpers must not start a server.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}