feat(daemon): port inlineRelativeAssets server-side for export endpoint

Adds apps/daemon/src/inline-assets.ts — a pure helper that takes
(html, ownerFileName, fileReader closure) and returns the HTML with
every relative <link rel=stylesheet> and <script src> contents inlined
into <style data-od-inline-asset="…">/<script>…</script> blocks. The
fileReader closure keeps the helper free of fs/Express coupling so the
route handler owns the filesystem boundary.

Port source: apps/web/src/components/FileViewer.tsx:5248-5354 — five
functions (inlineRelativeAssets, resolveProjectRelativePath, baseDirFor,
readHtmlAttr, escapeHtmlAttr). The fetch hop becomes the fileReader
closure; replace-all replaces first-match-only per locked design
decision §3.3 (inline comment in inline-assets.ts cites the divergence
from FileViewer.tsx:5313 and notes the web inline path is on a
deprecation track since PR #384 made URL-load the default).

Phase B-G of plan declarative-roaming-gosling.md. All 14 unit cases
from the Red commit (a60a9023) now pass; tightens one case to use a
realistic '&'-only filename (the original `<`/`>`-bearing filename was
unreachable in real filesystems and exposed a regex limitation the
web client carries too).

Daemon delta: +14 tests (1704 → 1718). Typecheck clean.

Refs nexu-io/open-design#368.
This commit is contained in:
DevForgeAI CI/CD Engineer
2026-05-11 14:04:47 -04:00
parent a60a902311
commit 5856d7beeb
2 changed files with 132 additions and 3 deletions
+123
View File
@@ -0,0 +1,123 @@
// Server-side port of the web-client inliner at
// apps/web/src/components/FileViewer.tsx:5248-5354 (@ base SHA 5bd97631).
// Powers the GET /api/projects/:id/export/*?inline=1 endpoint that ships a
// self-contained HTML document for explicit "Export self-contained HTML" and
// screenshot tooling — the viewer itself stays URL-load by default since
// PR #384 (Part 1 of nexu-io/open-design#368).
//
// The web client and this helper diverge in exactly one place: the global
// replace below. See the inline comment near `replaceAllOccurrences` for
// the rationale.
export interface InlineAssetReader {
(relPath: string): Promise<string | null>;
}
interface Replacement {
from: string;
to: string;
}
export async function inlineRelativeAssets(
html: string,
ownerFileName: string,
fileReader: InlineAssetReader,
): Promise<string> {
const replacements: Array<Promise<Replacement | null>> = [];
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
for (const tag of linkTags) {
const rel = readHtmlAttr(tag, 'rel');
const href = readHtmlAttr(tag, 'href');
if (!rel || !/\bstylesheet\b/i.test(rel) || !href) continue;
const resolved = resolveProjectRelativePath(ownerFileName, href);
if (!resolved) continue;
replacements.push(
fileReader(resolved).then<Replacement | null>((css) =>
css == null
? null
: {
from: tag,
to:
`<style data-od-inline-asset="${escapeHtmlAttr(href)}">\n` +
`${css.replace(/<\/style/gi, '<\\/style')}\n</style>`,
},
),
);
}
const scriptTags =
html.match(/<script\b[^>]*\bsrc\s*=\s*["'][^"']+["'][^>]*>\s*<\/script>/gi) ?? [];
for (const tag of scriptTags) {
const src = readHtmlAttr(tag, 'src');
if (!src) continue;
const resolved = resolveProjectRelativePath(ownerFileName, src);
if (!resolved) continue;
replacements.push(
fileReader(resolved).then<Replacement | null>((js) => {
if (js == null) return null;
const open = tag.match(/^<script\b[^>]*>/i)?.[0] ?? '<script>';
const attrs = open
.replace(/^<script/i, '')
.replace(/>$/i, '')
.replace(/\ssrc\s*=\s*(['"])[\s\S]*?\1/i, '');
return {
from: tag,
to: `<script${attrs}>\n${js.replace(/<\/script/gi, '<\\/script')}\n</script>`,
};
}),
);
}
const resolvedReplacements = (await Promise.all(replacements)).filter(
(item): item is Replacement => item !== null,
);
// Divergence from apps/web/src/components/FileViewer.tsx:5313: the web
// client uses `.replace(from, () => to)`, which replaces only the first
// occurrence. That produces inconsistent output when a document has
// duplicate identical tags — one inlined, the rest left as URL refs.
// This helper replaces every occurrence. The web inline path is on a
// deprecation track (URL-load is the default since PR #384), so the
// divergence is forward-pointing.
return resolvedReplacements.reduce(
(next, { from, to }) => replaceAllOccurrences(next, from, to),
html,
);
}
export function baseDirFor(fileName: string): string {
const idx = fileName.lastIndexOf('/');
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
}
export function resolveProjectRelativePath(
ownerFileName: string,
assetRef: string,
): string | null {
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(assetRef)) return null;
try {
const url = new URL(assetRef, `https://od.local/${baseDirFor(ownerFileName)}`);
if (url.origin !== 'https://od.local') return null;
return decodeURIComponent(url.pathname.replace(/^\/+/, ''));
} catch {
return null;
}
}
export function readHtmlAttr(tag: string, name: string): string | null {
const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'));
return match?.[2] ?? null;
}
export function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function replaceAllOccurrences(source: string, from: string, to: string): string {
return source.split(from).join(to);
}
@@ -14,7 +14,8 @@ import { inlineRelativeAssets } from '../src/inline-assets.js';
function readerFrom(files: Record<string, string>) {
return async (relPath: string): Promise<string | null> => {
return Object.prototype.hasOwnProperty.call(files, relPath) ? files[relPath] : null;
const value = files[relPath];
return typeof value === 'string' ? value : null;
};
}
@@ -144,10 +145,15 @@ describe('inlineRelativeAssets', () => {
});
it('HTML-escapes the href value in data-od-inline-asset attr', async () => {
const href = 'weird&name<x>.css';
// Using `&` only — the realistic case for filenames that need escaping.
// `<`, `>`, `"` are forbidden in real filenames on most platforms and
// additionally break the tag-matching regex (a limitation inherited
// from the web client at FileViewer.tsx:5271). The escapeHtmlAttr fn
// itself covers `&`, `"`, `<`, `>` by inspection.
const href = 'weird&name.css';
const html = `<link rel="stylesheet" href="${href}">`;
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ [href]: '.x{}' }));
expect(out).toContain('data-od-inline-asset="weird&amp;name&lt;x&gt;.css"');
expect(out).toContain('data-od-inline-asset="weird&amp;name.css"');
expect(out).not.toContain(`data-od-inline-asset="${href}"`);
});