zarazhangrui/frontend-slides · error
Not found
Error message
Not found
What it means
The thrower is not a library API but the throwaway Node HTTP static file server embedded in export-pdf.sh. For every request it tries readFileSync(join(SERVE_DIR, decodedUrl)) and, on ANY filesystem failure (missing file, EACCES, EISDIR), responds with a bare HTTP 404 and the body 'Not found'. The page itself loads because the server maps '/' to HTML_FILE, so this 404 almost always appears for relative assets (CSS/JS/images/fonts) referenced by the deck. Playwright only waits for networkidle, so a missing asset silently 404s and the PDF exports with missing styles or images.
Source
Thrown at plugins/frontend-slides/skills/frontend-slides/scripts/export-pdf.sh:179
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
const server = createServer((req, res) => {
// Decode URL-encoded characters (e.g., %20 → space) so filenames with spaces resolve correctly
const decodedUrl = decodeURIComponent(req.url);
let filePath = join(SERVE_DIR, decodedUrl === '/' ? HTML_FILE : decodedUrl);
try {
const content = readFileSync(filePath);
const ext = extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
res.end(content);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
// Find a free port
const port = await new Promise((resolve) => {
server.listen(0, () => resolve(server.address().port));
});
console.log(` Local server on port ${port}`);
// ─── Screenshot each slide ────────────────────────────────
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: VP_WIDTH, height: VP_HEIGHT },
});
View on GitHub (pinned to 9906a34d64)
Solutions
- Open the deck in a browser with devtools Network tab (or check the headless run) and identify which asset URL returned 404, then make that file exist relative to the HTML file's directory
- Move or copy the full presentation folder (HTML plus all assets) so the HTML and its referenced files live together; re-run export-pdf.sh on the HTML in place
- Fix case-sensitive filename mismatches and remove or encode '#'/'?' characters in asset filenames
- If assets intentionally live outside the served folder, inline them (data: URIs) or use absolute https URLs so the local server is not asked for them
- Verify by curling the running server: curl -i http://localhost:<port>/<asset-path> to reproduce the 404 and confirm the resolved path
Example fix
// before (index.html references a missing asset) <link rel="stylesheet" href="assets/theme.css"> // after (file present next to index.html, or inlined) <!-- ensure presentation/index.html AND presentation/assets/theme.css exist --> <link rel="stylesheet" href="assets/theme.css">
Defensive patterns
Strategy: validation
Validate before calling
import { existsSync } from 'fs';
import { join, dirname } from 'path';
// Before exporting, verify every local asset referenced by the HTML exists
// relative to the HTML file's directory:
const htmlDir = dirname(htmlPath);
const refs = [...html.matchAll(/(?:src|href)=["']([^"']+)["']/g)]
.map(m => m[1])
.filter(r => !/^(https?:|data:|#|\/)/.test(r));
const missing = refs.filter(r => !existsSync(join(htmlDir, r)));
if (missing.length) throw new Error(`Assets missing for export: ${missing.join(', ')}`); Type guard
function isServableAsset(htmlDir, url) {
const decoded = decodeURIComponent(url);
if (decoded.startsWith('/') || decoded.includes('..')) return false;
return existsSync(join(htmlDir, decoded));
} Try / catch
const server = createServer((req, res) => {
try {
const content = readFileSync(join(SERVE_DIR, decodeURIComponent(req.url)));
res.writeHead(200); res.end(content);
} catch (e) {
console.error(`404 for ${req.url}: ${e.message}`); // log which asset broke the export
res.writeHead(404); res.end('Not found');
}
}); Prevention
- Keep the HTML file and all its assets in the same folder tree; never move the HTML without its assets/ directory
- Open the deck in a real browser with the Network tab before exporting and confirm zero 404s
- Avoid '#' and '?' in asset filenames; URL-unsafe characters break naive path joining
- On case-sensitive filesystems, match asset filename case exactly as referenced in the HTML
- Watch the export run's console — add per-request logging to the embedded server so missing assets are named, not silent
When it happens
Trigger: The deck's index.html references a relative asset (e.g. ./assets/logo.png, styles.css, Google-Fonts-fallback local files) that does not exist under SERVE_DIR (the directory containing the HTML); a path contains URL-encoded characters the join/decode mishandles (e.g. '#' in a filename truncating the URL, or an absolute path like /foo.css joining outside SERVE_DIR); or the request resolves to a directory (readFileSync throws EISDIR).
Common situations: Running export-pdf.sh on an HTML file that lives outside its asset folder (file moved without its assets/ directory); typos in asset filenames or case-mismatch on case-sensitive filesystems (Logo.PNG vs logo.png); references to files with spaces or special characters; decks that load assets from a sibling directory via ../ paths that escape SERVE_DIR.
Related errors
AI-assisted analysis of zarazhangrui/frontend-slides@9906a34d64 (2026-08-29).
Data as JSON: /api/errors/f7573b3d7efdbfaf.
Report an issue: GitHub.