mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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&name<x>.css"');
|
||||
expect(out).toContain('data-od-inline-asset="weird&name.css"');
|
||||
expect(out).not.toContain(`data-od-inline-asset="${href}"`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user