Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | 13x 10x 30x 1x 1x 9x 6x 4x 5x | import sanitizeHtml from "sanitize-html";
// Sanitizes blog post HTML before it is rendered with set:html. Blog content is
// authored in the portal's TipTap rich-text editor and stored as raw HTML, so it
// must be sanitized to prevent XSS (disallowed tags/attributes/schemes are dropped).
//
// It also drops <img> tags that have no usable src. sanitize-html strips a src
// whose scheme is not allowlisted (e.g. data:/blob: pasted into the editor), which
// would otherwise leave a blank "ghost" <img> in the output. The strip cannot
// restore a missing image — that requires re-uploading it via the portal blog
// editor — it only stops the empty frame from rendering.
export function sanitizeBlogContent(html: string | null | undefined): string {
if (!html) return "";
return sanitizeHtml(html, {
allowedTags: [
"p", "br", "strong", "em", "u", "del", "s", // Text formatting
"h1", "h2", "h3", "h4", "h5", "h6", // Headings
"ul", "ol", "li", // Lists
"blockquote", "pre", "code", // Quotes and code
"a", "img", // Links and images
"table", "thead", "tbody", "tr", "th", "td", // Tables
"hr", // Horizontal rules
// Structural wrappers for the editor's layout blocks (hero, grids,
// before/after, tip cards, captioned images). Without these,
// sanitize-html UNWRAPS them — keeping the inner text/imgs but
// dropping the wrapper — and the layouts flatten. Styling comes
// from the `class` allowed on every element below.
"div", "figure", "figcaption", "span",
],
allowedAttributes: {
a: ["href", "title", "class"],
img: ["src", "alt", "title", "class", "style", "referrerpolicy", "loading"],
// `class` on every element drives all layout-block styling; no
// element needs inline `style` except `img` (above), which keeps the
// XSS surface minimal.
"*": ["class"],
},
allowedSchemes: ["http", "https", "mailto"],
// Drop <img> tags whose src was stripped (disallowed scheme), never set, or
// serialized from an uninitialized layout-block node as the literal "null" —
// they render as blank ghost frames (or fire a broken-image request) otherwise.
exclusiveFilter: (frame) =>
frame.tag === "img" &&
(!frame.attribs.src?.trim() || frame.attribs.src === "null"),
});
}
// Inline formatting tags that are redundant inside a heading: the heading is
// already bold/styled by prose, and a stray <u> renders an underline that reads
// as a broken link. Scoped to heading inner-HTML only — body <u>/<strong> are
// intentional (e.g. "How things <u>look</u>" in a paragraph).
const HEADING_INLINE_FMT = /<\/?(?:strong|b|em|i|u|s|del)(?:\s[^>]*)?>/gi;
const HEADING_BLOCK = /<(h[1-6])(\b[^>]*)?>([\s\S]*?)<\/\1>/gi;
// Normalizes headings in already-sanitized blog HTML. The page template renders
// blog.title as the page's single <h1>, but TipTap/AI-authored content frequently
// ships its own in-body <h1>s (often wrapped in <strong>/<u>), which collide with
// the page title, render at oversized prose-default h1 scale, and break heading
// hierarchy + SEO. Input must already be sanitize-html output, so tags are
// well-formed and these regexes are safe.
export function normalizeBlogHeadings(html: string | null | undefined): string {
if (!html) return "";
return (
html
// 1. Strip a leading <h1> — it duplicates the page title. Must run before
// the demotion below, while the title is still an <h1>.
.replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/i, "")
// 2. Demote any remaining <h1> to <h2> so in-body headings match the
// prose-h2 styling and the page keeps a single <h1>.
.replace(/<h1(\s[^>]*)?>/gi, (_m, attrs) => `<h2${attrs ?? ""}>`)
.replace(/<\/h1>/gi, "</h2>")
// 3. Unwrap redundant inline formatting inside heading tags only.
.replace(
HEADING_BLOCK,
(_m, tag, attrs, inner) =>
`<${tag}${attrs ?? ""}>${inner.replace(HEADING_INLINE_FMT, "")}</${tag}>`,
)
);
}
|