Skip to the content

Capsid menu

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

The functions of preview.server.ts
FunctionLinesComplexityCoveredCRAPMutation
previewPolicy8 to 211100%OkCRAP 1No data: mutation testing has not run on this repo yet
withSiteBase27 to 322100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:30>30 to 301100%OkCRAP 1No data: mutation testing has not run on this repo yet
withLabel35 to 382100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:36>36 to 36150%OkCRAP 1.1No data: mutation testing has not run on this repo yet
<anonymous:37>37 to 371100%OkCRAP 1No data: mutation testing has not run on this repo yet
previewResponse40 to 491100%OkCRAP 1No data: mutation testing has not run on this repo yet
previewFailure51 to 641100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:52>52 to 52150%OkCRAP 1.1No 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",
      },
    },
  );
}