1// Sanitized readme-subset HTML for the document preset. Two independent pieces:
2//
3// - `sanitizeHtml`: a GitHub-style allowlist sanitizer over DOMParser. Disallowed-but-harmless
4// elements unwrap to their children; actively dangerous ones drop with their content; every
5// attribute and URL is allowlisted. This is the only path from document text to rendered DOM.
6// - `classifyHtmlBlock`: a tag-balance scan that decides how the live preview treats an HTMLBlock:
7// render it (balanced), absorb it (a centering wrapper split from its close by blank lines —
8// GFM's markdown-in-HTML rule), collapse it (comment), or leave it as visible source (anything
9// else).
10//
11// Security stance: the allowlist is absolute — no script/style/iframe, no on* attributes, no
12// javascript: URLs, ever. A cloned repo's README renders inside the workbench; treat every input
13// as hostile.
14
15export interface HtmlOptions {
16 /** Maps relative image/source URLs to something fetchable; identity when omitted. */
17 resolveUrl?: (url: string) => string;
18}
19
20const ALLOWED_TAGS = new Set([
21 "a",
22 "abbr",
23 "b",
24 "blockquote",
25 "br",
26 "center",
27 "code",
28 "dd",
29 "del",
30 "details",
31 "div",
32 "dl",
33 "dt",
34 "em",
35 "figcaption",
36 "figure",
37 "h1",
38 "h2",
39 "h3",
40 "h4",
41 "h5",
42 "h6",
43 "hr",
44 "i",
45 "img",
46 "ins",
47 "kbd",
48 "li",
49 "mark",
50 "ol",
51 "p",
52 "picture",
53 "pre",
54 "q",
55 "samp",
56 "small",
57 "source",
58 "span",
59 "strong",
60 "sub",
61 "summary",
62 "sup",
63 "table",
64 "tbody",
65 "td",
66 "tfoot",
67 "th",
68 "thead",
69 "tr",
70 "ul",
71 "var",
72]);
73
74/** Dropped with their entire content — unwrapping these would leak what they guard. */
75const DROP_WITH_CONTENT = new Set([
76 "script",
77 "style",
78 "iframe",
79 "object",
80 "embed",
81 "template",
82 "link",
83 "meta",
84 "base",
85 "form",
86 "input",
87 "button",
88 "textarea",
89 "select",
90 "option",
91 "dialog",
92 "slot",
93 "noscript",
94 "title",
95 "svg",
96 "math",
97 "audio",
98 "video",
99 "canvas",
100 "map",
101 "area",
102 "frame",
103 "frameset",
104 "portal",
105]);
106
107const GLOBAL_ATTRS = new Set(["align", "title", "width", "height", "dir", "lang"]);
108
109const TAG_ATTRS: Record<string, Set<string>> = {
110 a: new Set(["href"]),
111 img: new Set(["src", "srcset", "alt", "loading"]),
112 source: new Set(["src", "srcset", "media", "type"]),
113 details: new Set(["open"]),
114 ol: new Set(["start", "reversed", "type"]),
115 th: new Set(["colspan", "rowspan", "scope"]),
116 td: new Set(["colspan", "rowspan"]),
117 q: new Set(["cite"]),
118 del: new Set(["cite"]),
119 ins: new Set(["cite"]),
120};
121
122const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
123
124function safeLinkUrl(value: string): string | null {
125 const url = value.trim();
126 const scheme = SCHEME.exec(url)?.[0]?.toLowerCase();
127 if (!scheme) return url; // relative or fragment
128 return ["http:", "https:", "mailto:"].includes(scheme) ? url : null;
129}
130
131function safeMediaUrl(value: string, opts: HtmlOptions): string | null {
132 const url = value.trim();
133 const scheme = SCHEME.exec(url)?.[0]?.toLowerCase();
134 if (!scheme) return opts.resolveUrl ? opts.resolveUrl(url) : url;
135 if (scheme === "http:" || scheme === "https:") return url;
136 if (scheme === "data:" && /^data:image\//i.test(url)) return url;
137 return null;
138}
139
140/** Sanitize a srcset value entry by entry; entries with rejected URLs drop out. */
141function safeSrcset(value: string, opts: HtmlOptions): string | null {
142 // Split on comma-followed-by-whitespace: data: URLs carry internal commas with no space after.
143 const entries = value
144 .split(/,(?=\s)/)
145 .map((entry) => {
146 const [url, ...descriptor] = entry.trim().split(/\s+/);
147 if (!url) return null;
148 const safe = safeMediaUrl(url, opts);
149 return safe === null ? null : [safe, ...descriptor].join(" ");
150 })
151 .filter((entry): entry is string => entry !== null);
152 return entries.length === 0 ? null : entries.join(", ");
153}
154
155function sanitizeAttributes(source: Element, target: Element, opts: HtmlOptions): void {
156 const tag = source.tagName.toLowerCase();
157 const allowed = TAG_ATTRS[tag];
158 for (const attr of source.getAttributeNames()) {
159 const name = attr.toLowerCase();
160 if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
161 const value = source.getAttribute(attr);
162 if (value === null) continue;
163 if (name === "href") {
164 const safe = safeLinkUrl(value);
165 if (safe === null) continue;
166 target.setAttribute("href", safe);
167 // Keep link clicks from navigating the embedding app away.
168 target.setAttribute("target", "_blank");
169 target.setAttribute("rel", "noopener noreferrer");
170 continue;
171 }
172 if (name === "src") {
173 const safe = safeMediaUrl(value, opts);
174 if (safe !== null) target.setAttribute("src", safe);
175 continue;
176 }
177 if (name === "srcset") {
178 const safe = safeSrcset(value, opts);
179 if (safe !== null) target.setAttribute("srcset", safe);
180 continue;
181 }
182 if (name === "cite") {
183 const safe = safeLinkUrl(value);
184 if (safe !== null) target.setAttribute("cite", safe);
185 continue;
186 }
187 target.setAttribute(name, value);
188 }
189}
190
191function sanitizeInto(parent: Node, children: NodeListOf<ChildNode>, opts: HtmlOptions): void {
192 for (const child of children) {
193 if (child.nodeType === 3) {
194 parent.appendChild(document.createTextNode(child.textContent ?? ""));
195 continue;
196 }
197 if (child.nodeType !== 1) continue; // comments, PIs, doctypes
198 const element = child as Element;
199 const tag = element.tagName.toLowerCase();
200 if (DROP_WITH_CONTENT.has(tag)) continue;
201 if (!ALLOWED_TAGS.has(tag)) {
202 // Harmless-unknown: unwrap, keeping the children.
203 sanitizeInto(parent, element.childNodes, opts);
204 continue;
205 }
206 const clean = document.createElement(tag);
207 sanitizeAttributes(element, clean, opts);
208 sanitizeInto(clean, element.childNodes, opts);
209 parent.appendChild(clean);
210 }
211}
212
213/**
214 * Parse and sanitize an HTML fragment. Returns a detached element ready to append, or null when
215 * nothing renderable survives.
216 */
217export function sanitizeHtml(source: string, opts: HtmlOptions = {}): HTMLElement | null {
218 const parsed = new DOMParser().parseFromString(source, "text/html");
219 const root = document.createElement("div");
220 sanitizeInto(root, parsed.body.childNodes, opts);
221 return root.childNodes.length === 0 ? null : root;
222}
223
224// ---------------------------------------------------------------------------
225// Block classification
226
227const VOID_TAGS = new Set([
228 "br",
229 "hr",
230 "img",
231 "source",
232 "input",
233 "meta",
234 "link",
235 "base",
236 "area",
237 "col",
238 "embed",
239 "track",
240 "wbr",
241]);
242
243/** Wrapper elements whose lone open/close blocks the preview absorbs instead of rendering. */
244const WRAPPER_TAGS = new Set(["div", "p", "center", "section", "header"]);
245
246const TOKEN = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
247
248export type HtmlTokenType = "comment" | "bracket" | "tag" | "attr" | "value";
249
250export interface HtmlToken {
251 type: HtmlTokenType;
252 from: number;
253 to: number;
254}
255
256const ATTR = /([\w-]+)(\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
257
258/**
259 * Tokenize an HTML source slice for the revealed-source highlighting: brackets, tag names,
260 * attribute names/values, comments. Pure over the string; offsets are slice-relative.
261 */
262export function htmlTokens(source: string): HtmlToken[] {
263 const tokens: HtmlToken[] = [];
264 TOKEN.lastIndex = 0;
265 for (let match = TOKEN.exec(source); match; match = TOKEN.exec(source)) {
266 const start = match.index;
267 if (match[0].startsWith("<!--")) {
268 tokens.push({ type: "comment", from: start, to: start + match[0].length });
269 continue;
270 }
271 const closing = match[1] === "/";
272 const tag = match[2]!;
273 const open = 1 + (closing ? 1 : 0);
274 tokens.push({ type: "bracket", from: start, to: start + open });
275 tokens.push({ type: "tag", from: start + open, to: start + open + tag.length });
276 const attrsFrom = start + open + tag.length;
277 const attrs = match[3] ?? "";
278 ATTR.lastIndex = 0;
279 for (let attr = ATTR.exec(attrs); attr; attr = ATTR.exec(attrs)) {
280 const nameFrom = attrsFrom + attr.index;
281 tokens.push({ type: "attr", from: nameFrom, to: nameFrom + attr[1]!.length });
282 if (attr[3] !== undefined) {
283 const valueFrom = attrsFrom + attr.index + attr[0].length - attr[3].length;
284 tokens.push({ type: "value", from: valueFrom, to: valueFrom + attr[3].length });
285 }
286 }
287 const end = start + match[0].length;
288 tokens.push({ type: "bracket", from: end - 1 - (match[4] === "/" ? 1 : 0), to: end });
289 }
290 return tokens;
291}
292
293export type HtmlBlockShape =
294 | { kind: "comment" }
295 | { kind: "balanced" }
296 | { kind: "open"; tag: string; center: boolean }
297 | { kind: "close"; tag: string }
298 | { kind: "other" };
299
300const CENTERED_ATTRS = /align\s*=\s*["']?center|text-align\s*:\s*center/i;
301
302/**
303 * Decide how the preview treats one HTMLBlock's source. "open"/"close" only fire for a block that
304 * is exactly one lone wrapper tag (plus comments/whitespace) — anything richer that does not
305 * balance is "other" and stays visible source.
306 */
307export function classifyHtmlBlock(source: string): HtmlBlockShape {
308 TOKEN.lastIndex = 0;
309 const stack: string[] = [];
310 let tags = 0;
311 let comments = 0;
312 let loneOpen: { tag: string; center: boolean } | null = null;
313 let loneClose: string | null = null;
314 let broken = false;
315 let last = 0;
316 let textOutside = false;
317
318 for (let match = TOKEN.exec(source); match; match = TOKEN.exec(source)) {
319 if (source.slice(last, match.index).trim() !== "") textOutside = true;
320 last = match.index + match[0].length;
321 if (match[0].startsWith("<!--")) {
322 comments++;
323 continue;
324 }
325 const closing = match[1] === "/";
326 const tag = match[2]!.toLowerCase();
327 const selfClosed = match[4] === "/" || VOID_TAGS.has(tag);
328 tags++;
329 if (closing) {
330 if (stack.length === 0) {
331 if (tags === 1 && WRAPPER_TAGS.has(tag)) loneClose = tag;
332 else broken = true;
333 } else if (stack[stack.length - 1] === tag) {
334 stack.pop();
335 } else {
336 broken = true;
337 }
338 } else if (!selfClosed) {
339 if (tags === 1 && WRAPPER_TAGS.has(tag)) {
340 loneOpen = { tag, center: tag === "center" || CENTERED_ATTRS.test(match[3] ?? "") };
341 }
342 stack.push(tag);
343 }
344 }
345 if (source.slice(last).trim() !== "") textOutside = true;
346
347 if (broken) return { kind: "other" };
348 // An unmatched close is only meaningful as the lone wrapper-closing half.
349 if (loneClose) {
350 return tags === 1 && !textOutside ? { kind: "close", tag: loneClose } : { kind: "other" };
351 }
352 if (tags === 0) return comments > 0 && !textOutside ? { kind: "comment" } : { kind: "other" };
353 if (stack.length === 0) return { kind: "balanced" };
354 if (stack.length === 1 && loneOpen && tags === 1 && !textOutside) {
355 return { kind: "open", tag: loneOpen.tag, center: loneOpen.center };
356 }
357 return { kind: "other" };
358}