app/lib/preview.server.ts
58 lines and 9 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| previewPolicy | 8 to 21 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| withSiteBase | 27 to 32 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:30> | 30 to 30 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| withLabel | 35 to 38 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:36> | 36 to 36 | 1 | 50% | OkCRAP 1.1 | No data: mutation testing has not run on this repo yet |
| <anonymous:37> | 37 to 37 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| previewResponse | 40 to 49 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| previewFailure | 51 to 64 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:52> | 52 to 52 | 1 | 50% | OkCRAP 1.1 | No data: mutation testing has not run on this repo yet |
Imports
It imports no other module of this repo. Imported by app/routes/ai-draft.preview.ts, app/routes/preview.ts.
Source
// The preview: the site renders the draft with its own pipeline, and Carrel serves that page into a
// sandboxed iframe. The page is the site's markup, so it runs with no script, no forms, no network
// but the site's own styles and images, and an opaque origin that cannot reach Carrel.
import { buildPolicy } from "@dustinedwards/site-runtime/csp";
/** The policy for a preview document. `sandbox` in the header holds even if the iframe attribute is lost. */
export function previewPolicy(siteOrigin: string): string {
return buildPolicy([
"sandbox",
"default-src 'none'",
`style-src ${siteOrigin} 'unsafe-inline'`,
`img-src ${siteOrigin} data:`,
`font-src ${siteOrigin}`,
`media-src ${siteOrigin}`,
"script-src 'none'",
"form-action 'none'",
`base-uri ${siteOrigin}`,
"frame-ancestors 'self'",
]);
}
/**
* Relative URLs in the site's page (its stylesheets, images) must resolve against the site, not
* Carrel, so a <base> goes first in <head>. Any <base> the page carries is removed first.
*/
export function withSiteBase(html: string, siteOrigin: string): string {
const base = `<base href="${siteOrigin}/">`;
const stripped = html.replace(/<base\b[^>]*>/gi, "");
if (/<head\b[^>]*>/i.test(stripped)) return stripped.replace(/<head\b[^>]*>/i, (head) => `${head}${base}`);
return `${base}${stripped}`;
}
/** A line of plain text at the top of the page saying whose text this is, for a preview of something other than the person's own draft. */
export function withLabel(html: string, label: string): string {
const note = `<p style="margin:0;padding:8px 12px;font:14px/1.4 system-ui,sans-serif;background:#fff3cd;color:#333;border-bottom:1px solid #d9c27a">${label.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)}</p>`;
return /<body\b[^>]*>/i.test(html) ? html.replace(/<body\b[^>]*>/i, (body) => `${body}${note}`) : `${note}${html}`;
}
export function previewResponse(html: string, siteOrigin: string): Response {
return new Response(withSiteBase(html, siteOrigin), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": previewPolicy(siteOrigin),
"X-Frame-Options": "SAMEORIGIN",
},
});
}
export function previewFailure(message: string): Response {
const escaped = message.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
return new Response(
`<!doctype html><html><head><meta charset="utf-8"><title>Preview unavailable</title></head><body><p>${escaped}</p></body></html>`,
{
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": buildPolicy(["sandbox", "default-src 'none'", "frame-ancestors 'self'"]),
"X-Frame-Options": "SAMEORIGIN",
},
},
);
}