All files / src/lib blog-content.ts

100% Statements 11/11
100% Branches 11/11
100% Functions 5/5
100% Lines 9/9

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}>`,
			)
	);
}