|
1 | 1 | --- |
2 | | -export interface Props { |
3 | | - content: string; |
4 | | -} |
| 2 | +export interface Props { content: string } |
5 | 3 |
|
6 | 4 | const { content } = Astro.props; |
7 | 5 |
|
8 | | -function processCallouts(htmlContent: string): string { |
9 | | - // Define callout types and mapping |
10 | | - const CALLOUT_TYPES = "NOTE|TIP|WARNING|IMPORTANT|CAUTION"; |
11 | | - const CALLOUT_MAP: Record<string, string> = { |
12 | | - NOTE: "note", |
13 | | - TIP: "tip", |
14 | | - WARNING: "caution", |
15 | | - IMPORTANT: "note", |
16 | | - CAUTION: "danger", |
17 | | - }; |
18 | | -
|
19 | | - // Helper function to generate Starlight aside HTML |
20 | | - const createAside = (type: string, content: string): string => { |
21 | | - const starlightType = CALLOUT_MAP[type.toUpperCase()] ?? "note"; |
22 | | - return `<div class="starlight-aside starlight-aside--${starlightType}"><p class="starlight-aside__title">${type}</p><div class="starlight-aside__content"><p>${content}</p></div></div>`; |
23 | | - }; |
| 6 | +// Precompiled constants & regexes (outside function to avoid reallocation per render) |
| 7 | +const CALLOUT_TYPES = ["NOTE", "TIP", "WARNING", "IMPORTANT", "CAUTION"] as const; |
| 8 | +type CalloutType = typeof CALLOUT_TYPES[number]; |
| 9 | +const CALLOUT_TYPE_PATTERN = CALLOUT_TYPES.join("|"); |
| 10 | +const CALLOUT_MARKER_RE = new RegExp(`\\[!(${CALLOUT_TYPE_PATTERN})\\]`, "i"); |
| 11 | +const BLOCKQUOTE_RE = new RegExp(`<blockquote[^>]*>([\\s\\S]*?)<\\/blockquote>`, "gi"); |
| 12 | +// Markdown pattern: > [!TYPE]\n> line ... (stops at first blank or non > line) |
| 13 | +const MD_CALLOUT_RE = new RegExp( |
| 14 | + `(^|\n)>\\s*\\[!(${CALLOUT_TYPE_PATTERN})\\]\\s*\n((?:>.*(?:\n|$))+)(?=\n[^>]|")?`, |
| 15 | + "gi" |
| 16 | +); |
24 | 17 |
|
25 | | - // Helper function to clean HTML content |
26 | | - const cleanHtmlContent = (match: string): string => |
27 | | - match |
28 | | - .replace(/<blockquote[^>]*>/gi, "") |
29 | | - .replace(/<\/blockquote>/gi, "") |
30 | | - .replace( |
31 | | - new RegExp(`<p>\\s*\\[!(${CALLOUT_TYPES})\\]\\s*<\\/p>`, "gi"), |
32 | | - "", |
33 | | - ) |
34 | | - .replace(new RegExp(`\\[!(${CALLOUT_TYPES})\\]`, "gi"), "") |
35 | | - .replace(/<p>(.*?)<\/p>/gs, "$1 ") |
36 | | - .replace(/<(?!code|\/code)[^>]*>/g, "") // Preserve <code> tags |
37 | | - .replace(/\s+/g, " ") |
38 | | - .trim(); |
| 18 | +// Mapping to starlight aside variants |
| 19 | +const CALLOUT_MAP: Record<string, string> = { |
| 20 | + NOTE: "note", |
| 21 | + TIP: "tip", |
| 22 | + WARNING: "caution", |
| 23 | + IMPORTANT: "note", |
| 24 | + CAUTION: "danger", |
| 25 | +}; |
39 | 26 |
|
40 | | - // Helper function to clean markdown content |
41 | | - const cleanMarkdownContent = (content: string): string => |
42 | | - content |
43 | | - .split("\n") |
44 | | - .map((line: string) => line.replace(/^>\s*/, "").trim()) |
45 | | - .filter((line: string) => line.length > 0) |
46 | | - .join(" "); |
| 27 | +function createAside(rawType: string, innerHtml: string): string { |
| 28 | + const type = (rawType || "NOTE").toUpperCase() as CalloutType; |
| 29 | + const variant = CALLOUT_MAP[type] ?? "note"; |
| 30 | + // Avoid double-wrapping if already processed |
| 31 | + if (/starlight-aside__title/i.test(innerHtml)) return innerHtml; |
| 32 | + return `<div class="starlight-aside starlight-aside--${variant}" role="note" aria-label="${type} callout"><p class="starlight-aside__title">${type}</p><div class="starlight-aside__content">${innerHtml}</div></div>`; |
| 33 | +} |
47 | 34 |
|
48 | | - let processedContent = htmlContent; |
| 35 | +function stripFirstMarkerLine(block: string): { type: string; html: string } { |
| 36 | + const markerLineRe = new RegExp(`^\\s*(?:<p>)?\\s*\\[!(${CALLOUT_TYPE_PATTERN})\\]\\s*(?:<\\/p>)?\\s*`, "i"); |
| 37 | + const match = block.match(CALLOUT_MARKER_RE); |
| 38 | + const type = match?.[1] || "NOTE"; |
| 39 | + const html = block.replace(markerLineRe, "").trim(); |
| 40 | + return { type, html }; |
| 41 | +} |
49 | 42 |
|
50 | | - // Process blockquote-based callouts |
51 | | - processedContent = processedContent.replace( |
52 | | - new RegExp( |
53 | | - `<blockquote[^>]*>[\\s\\S]*?\\[!(${CALLOUT_TYPES})\\][\\s\\S]*?<\\/blockquote>`, |
54 | | - "gi", |
55 | | - ), |
56 | | - (match: string) => { |
57 | | - const typeMatch = match.match( |
58 | | - new RegExp(`\\[!(${CALLOUT_TYPES})\\]`, "i"), |
59 | | - ); |
60 | | - const type = typeMatch?.[1] ?? "NOTE"; |
61 | | - const cleanContent = cleanHtmlContent(match); |
62 | | - return createAside(type, cleanContent); |
63 | | - }, |
64 | | - ); |
| 43 | +function processBlockquoteCallouts(src: string): string { |
| 44 | + return src.replace(BLOCKQUOTE_RE, (full, inner) => { |
| 45 | + if (!CALLOUT_MARKER_RE.test(inner)) return full; // leave normal blockquotes |
| 46 | + const { type, html } = stripFirstMarkerLine(inner); |
| 47 | + return createAside(type, html); |
| 48 | + }); |
| 49 | +} |
65 | 50 |
|
66 | | - // Process markdown-style callouts |
67 | | - processedContent = processedContent.replace( |
68 | | - new RegExp( |
69 | | - `>\\s*\\[!(${CALLOUT_TYPES})\\]\\s*\\n((?:>\\s*.*(?:\\n|$))*)`, |
70 | | - "gim", |
71 | | - ), |
72 | | - (match: string, type: string, content: string) => { |
73 | | - const cleanContent = cleanMarkdownContent(content); |
74 | | - return createAside(type, cleanContent); |
75 | | - }, |
76 | | - ); |
| 51 | +function processMarkdownCallouts(src: string): string { |
| 52 | + return src.replace(MD_CALLOUT_RE, (full, _lead, type, lines) => { |
| 53 | + // Remove leading ">" markers & blank lines |
| 54 | + const cleaned = lines |
| 55 | + .split(/\n/) |
| 56 | + .map((l: string) => l.replace(/^>\s?/, "")) |
| 57 | + .filter((l: string) => l.trim().length > 0) |
| 58 | + .join("\n"); |
| 59 | + return `\n${createAside(type, cleaned)}\n`; |
| 60 | + }); |
| 61 | +} |
77 | 62 |
|
78 | | - return processedContent; |
| 63 | +function processCallouts(htmlContent: string): string { |
| 64 | + if (!htmlContent || typeof htmlContent !== "string") return htmlContent; |
| 65 | + // Quick escape if no markers |
| 66 | + if (!CALLOUT_MARKER_RE.test(htmlContent)) return htmlContent; |
| 67 | + let out = htmlContent; |
| 68 | + out = processBlockquoteCallouts(out); |
| 69 | + out = processMarkdownCallouts(out); |
| 70 | + return out; |
79 | 71 | } |
80 | 72 |
|
81 | | -const processedContent = processCallouts(content); |
| 73 | +let processedContent: string; |
| 74 | +try { |
| 75 | + processedContent = processCallouts(content); |
| 76 | +} catch (e) { |
| 77 | + // Fail open: render original content if processing breaks |
| 78 | + processedContent = content; |
| 79 | +} |
82 | 80 | --- |
83 | 81 |
|
84 | 82 | <div set:html={processedContent} /> |
0 commit comments