preview-worker.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. /* global importScripts, marked, hljs */
  2. let librariesLoaded = false;
  3. let markedConfigured = false;
  4. let mermaidIdCounter = 0;
  5. const markedOptions = {
  6. gfm: true,
  7. breaks: true,
  8. pedantic: false,
  9. sanitize: false,
  10. smartypants: false,
  11. xhtml: false,
  12. headerIds: true,
  13. mangle: false,
  14. };
  15. const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m;
  16. const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/;
  17. const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/;
  18. const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/;
  19. const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/;
  20. const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/;
  21. const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/;
  22. const EMPTY_LINE_PATTERN = /^\s*$/;
  23. let suppressFootnotePreprocess = false;
  24. const footnoteDefinitions = new Map();
  25. const footnoteOrder = [];
  26. const footnoteRefCounts = new Map();
  27. const footnoteFirstRefId = new Map();
  28. let anonymousFootnoteCounter = 0;
  29. function escapeHtml(str) {
  30. return String(str)
  31. .replace(/&/g, "&")
  32. .replace(/</g, "&lt;")
  33. .replace(/>/g, "&gt;")
  34. .replace(/"/g, "&quot;");
  35. }
  36. function escapeHtmlAttribute(value) {
  37. return String(value)
  38. .replace(/&/g, "&amp;")
  39. .replace(/"/g, "&quot;")
  40. .replace(/'/g, "&#39;")
  41. .replace(/</g, "&lt;")
  42. .replace(/>/g, "&gt;");
  43. }
  44. function resetExtendedMarkdownState() {
  45. footnoteDefinitions.clear();
  46. footnoteOrder.length = 0;
  47. footnoteRefCounts.clear();
  48. footnoteFirstRefId.clear();
  49. anonymousFootnoteCounter = 0;
  50. }
  51. function normalizeFootnoteId(id) {
  52. const normalized = String(id || "")
  53. .trim()
  54. .toLowerCase()
  55. .replace(/[^a-z0-9_-]+/g, "-")
  56. .replace(/^-+|-+$/g, "");
  57. if (normalized) return normalized;
  58. anonymousFootnoteCounter += 1;
  59. return `footnote-${anonymousFootnoteCounter}`;
  60. }
  61. function parseInlineWithoutFootnotes(text) {
  62. suppressFootnotePreprocess = true;
  63. try {
  64. return marked.parseInline(text);
  65. } finally {
  66. suppressFootnotePreprocess = false;
  67. }
  68. }
  69. function renderDefinitionContent(content, options) {
  70. const appendHtml = options && options.appendHtml ? options.appendHtml : "";
  71. const paragraphs = String(content || "")
  72. .split(/\n(?:[ \t]*\n)+/)
  73. .map((paragraph) => paragraph.trim())
  74. .filter(Boolean);
  75. if (appendHtml) {
  76. if (paragraphs.length === 0) {
  77. paragraphs.push(appendHtml);
  78. } else {
  79. paragraphs[paragraphs.length - 1] = `${paragraphs[paragraphs.length - 1]} ${appendHtml}`;
  80. }
  81. }
  82. return paragraphs
  83. .map((paragraph) => `<p>${parseInlineWithoutFootnotes(paragraph)}</p>`)
  84. .join("");
  85. }
  86. function extractFootnoteDefinitions(markdown) {
  87. const lines = markdown.split("\n");
  88. const preservedLines = [];
  89. let index = 0;
  90. while (index < lines.length) {
  91. const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]);
  92. if (!match) {
  93. preservedLines.push(lines[index]);
  94. index += 1;
  95. continue;
  96. }
  97. const baseIndent = match[1] || "";
  98. const id = match[2].trim();
  99. const definitionLines = [match[3] || ""];
  100. index += 1;
  101. while (index < lines.length) {
  102. const line = lines[index];
  103. if (!line.startsWith(baseIndent)) break;
  104. const lineAfterBase = line.slice(baseIndent.length);
  105. const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase);
  106. if (indentedMatch) {
  107. definitionLines.push(indentedMatch[1]);
  108. index += 1;
  109. continue;
  110. }
  111. if (lineAfterBase.trim() === "") {
  112. const nextLine = lines[index + 1] || "";
  113. const nextAfterBase = nextLine.startsWith(baseIndent) ? nextLine.slice(baseIndent.length) : "";
  114. if (/^(?: {2,}|\t)/.test(nextAfterBase)) {
  115. definitionLines.push("");
  116. index += 1;
  117. continue;
  118. }
  119. }
  120. break;
  121. }
  122. footnoteDefinitions.set(id, definitionLines.join("\n").trim());
  123. }
  124. return preservedLines.join("\n");
  125. }
  126. function applyFootnotes(markdown) {
  127. const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) {
  128. const id = idText.trim();
  129. if (!id) return match;
  130. if (!footnoteOrder.includes(id)) footnoteOrder.push(id);
  131. const refCount = (footnoteRefCounts.get(id) || 0) + 1;
  132. footnoteRefCounts.set(id, refCount);
  133. const normalizedId = normalizeFootnoteId(id);
  134. const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`;
  135. if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId);
  136. const noteNumber = footnoteOrder.indexOf(id) + 1;
  137. return `<sup id="${escapeHtmlAttribute(refId)}" class="footnote-ref"><a href="#fn-${escapeHtmlAttribute(normalizedId)}" aria-label="Footnote ${noteNumber}">[${noteNumber}]</a></sup>`;
  138. });
  139. const footnotesHtml = footnoteOrder
  140. .filter((id) => footnoteDefinitions.has(id))
  141. .map((id) => {
  142. const normalizedId = normalizeFootnoteId(id);
  143. const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`;
  144. const backRefHtml = `<a href="#${escapeHtmlAttribute(backRefId)}" class="footnote-backref" aria-label="Back to content">&#8592;</a>`;
  145. const noteHtml = renderDefinitionContent(footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml });
  146. return `<li id="fn-${escapeHtmlAttribute(normalizedId)}">${noteHtml}</li>`;
  147. })
  148. .join("");
  149. if (!footnotesHtml) return markdownWithReferences;
  150. return `${markdownWithReferences}\n\n<section class="footnotes"><hr><ol>${footnotesHtml}</ol></section>`;
  151. }
  152. function configureMarked() {
  153. if (markedConfigured) return;
  154. const renderer = new marked.Renderer();
  155. const blockMathExtension = {
  156. name: "blockMath",
  157. level: "block",
  158. start(src) {
  159. const match = src.match(BLOCK_MATH_MARKER_PATTERN);
  160. return match ? match.index : undefined;
  161. },
  162. tokenizer(src) {
  163. const match = BLOCK_MATH_PATTERN.exec(src);
  164. if (!match) return undefined;
  165. return { type: "blockMath", raw: match[0], text: match[1] };
  166. },
  167. renderer(token) {
  168. return `<div class="math-block">$$\n${token.text}\n$$</div>\n`;
  169. },
  170. };
  171. const definitionListExtension = {
  172. name: "definitionList",
  173. level: "block",
  174. start(src) {
  175. const match = src.match(/\n:[ \t]+/);
  176. return match ? match.index + 1 : undefined;
  177. },
  178. tokenizer(src) {
  179. const lines = src.split("\n");
  180. if (lines.length < 2) return undefined;
  181. const term = lines[0];
  182. if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) return undefined;
  183. if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) return undefined;
  184. const definitions = [];
  185. const rawLines = [term];
  186. let index = 1;
  187. while (index < lines.length) {
  188. const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]);
  189. if (!itemMatch) break;
  190. rawLines.push(lines[index]);
  191. const definitionLines = [itemMatch[1]];
  192. index += 1;
  193. while (index < lines.length) {
  194. const line = lines[index];
  195. if (DEFINITION_LIST_ITEM_PATTERN.test(line)) break;
  196. if (EMPTY_LINE_PATTERN.test(line)) {
  197. const nextLine = lines[index + 1] || "";
  198. if (/^(?: {2,}|\t)/.test(nextLine)) {
  199. rawLines.push(line);
  200. definitionLines.push("");
  201. index += 1;
  202. continue;
  203. }
  204. break;
  205. }
  206. const continuationMatch = /^(?: {2,}|\t)(.*)$/.exec(line);
  207. if (!continuationMatch) break;
  208. rawLines.push(line);
  209. definitionLines.push(continuationMatch[1]);
  210. index += 1;
  211. }
  212. definitions.push(definitionLines.join("\n").trim());
  213. }
  214. if (definitions.length === 0) return undefined;
  215. let raw = rawLines.join("\n");
  216. if (src.startsWith(raw + "\n")) raw += "\n";
  217. return { type: "definitionList", raw, term: term.trim(), definitions };
  218. },
  219. renderer(token) {
  220. const termHtml = parseInlineWithoutFootnotes(token.term);
  221. const definitionHtml = token.definitions
  222. .map((definition) => `<dd>${renderDefinitionContent(definition)}</dd>`)
  223. .join("");
  224. return `<dl><dt>${termHtml}</dt>${definitionHtml}</dl>\n`;
  225. },
  226. };
  227. const superscriptExtension = {
  228. name: "superscript",
  229. level: "inline",
  230. start(src) {
  231. const index = src.indexOf("^");
  232. return index >= 0 ? index : undefined;
  233. },
  234. tokenizer(src) {
  235. const match = SUPERSCRIPT_PATTERN.exec(src);
  236. return match ? { type: "superscript", raw: match[0], text: match[1] } : undefined;
  237. },
  238. renderer(token) {
  239. return `<sup>${marked.parseInline(token.text)}</sup>`;
  240. },
  241. };
  242. const subscriptExtension = {
  243. name: "subscript",
  244. level: "inline",
  245. start(src) {
  246. const index = src.indexOf("~");
  247. return index >= 0 ? index : undefined;
  248. },
  249. tokenizer(src) {
  250. const match = SUBSCRIPT_PATTERN.exec(src);
  251. return match ? { type: "subscript", raw: match[0], text: match[1] } : undefined;
  252. },
  253. renderer(token) {
  254. return `<sub>${marked.parseInline(token.text)}</sub>`;
  255. },
  256. };
  257. const highlightExtension = {
  258. name: "highlight",
  259. level: "inline",
  260. start(src) {
  261. const index = src.indexOf("==");
  262. return index >= 0 ? index : undefined;
  263. },
  264. tokenizer(src) {
  265. const match = HIGHLIGHT_PATTERN.exec(src);
  266. return match ? { type: "highlight", raw: match[0], text: match[1] } : undefined;
  267. },
  268. renderer(token) {
  269. return `<mark>${marked.parseInline(token.text)}</mark>`;
  270. },
  271. };
  272. renderer.code = function(code, language) {
  273. if (language === "mermaid") {
  274. const uniqueId = `mermaid-diagram-worker-${mermaidIdCounter++}`;
  275. return `<div class="mermaid-container is-loading"><div class="mermaid" id="${uniqueId}" data-original-code="${encodeURIComponent(code)}">${escapeHtml(code)}</div></div>`;
  276. }
  277. const validLanguage = hljs && hljs.getLanguage(language) ? language : "plaintext";
  278. const highlightedCode = hljs
  279. ? hljs.highlight(code, { language: validLanguage }).value
  280. : escapeHtml(code);
  281. return `<pre><code class="hljs ${escapeHtmlAttribute(validLanguage)}">${highlightedCode}</code></pre>`;
  282. };
  283. renderer.heading = function(text, level, raw) {
  284. let id = raw
  285. .toLowerCase()
  286. .trim()
  287. .replace(/<[^>]*>/g, '')
  288. .replace(/\s+/g, '-')
  289. .replace(/[^\w-]/g, '')
  290. .replace(/-+/g, '-');
  291. if (!id) {
  292. id = `heading-worker-${Math.random().toString(36).substr(2, 9)}`;
  293. }
  294. return `<h${level} id="${id}">${text}</h${level}>`;
  295. };
  296. marked.use({
  297. extensions: [
  298. blockMathExtension,
  299. definitionListExtension,
  300. superscriptExtension,
  301. subscriptExtension,
  302. highlightExtension,
  303. ],
  304. hooks: {
  305. preprocess(markdown) {
  306. if (suppressFootnotePreprocess) return markdown;
  307. resetExtendedMarkdownState();
  308. const protectedMarkdown = markdown.replace(/\\\$/g, "&#36;");
  309. return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown));
  310. },
  311. },
  312. });
  313. marked.setOptions(Object.assign({}, markedOptions, { renderer }));
  314. markedConfigured = true;
  315. }
  316. function ensureLibraries(urls) {
  317. if (!librariesLoaded) {
  318. importScripts(urls.marked, urls.highlight);
  319. librariesLoaded = true;
  320. }
  321. configureMarked();
  322. }
  323. function isSegmentedPreviewSafe(markdown) {
  324. if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false;
  325. if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false;
  326. if (/\[\^[^\]\n]+\]/.test(markdown)) return false;
  327. if (/\n:[ \t]+/.test(markdown)) return false;
  328. if (/^\s{0,3}<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>)/m.test(markdown)) return false;
  329. return true;
  330. }
  331. function hashString(value) {
  332. let hash = 2166136261;
  333. for (let i = 0; i < value.length; i += 1) {
  334. hash ^= value.charCodeAt(i);
  335. hash = Math.imul(hash, 16777619);
  336. }
  337. return (hash >>> 0).toString(36);
  338. }
  339. function splitMarkdownBlocks(markdown) {
  340. const normalized = String(markdown || "").replace(/\r\n/g, "\n");
  341. const lines = normalized.split("\n");
  342. const blocks = [];
  343. let buffer = [];
  344. let startLine = 1;
  345. let inFence = false;
  346. let fenceChar = "";
  347. let fenceLength = 0;
  348. let inMathBlock = false;
  349. function flush(endLine) {
  350. const source = buffer.join("\n").trimEnd();
  351. if (source.trim()) {
  352. blocks.push({
  353. source,
  354. startLine,
  355. endLine,
  356. });
  357. }
  358. buffer = [];
  359. }
  360. for (let index = 0; index < lines.length; index += 1) {
  361. const line = lines[index];
  362. const lineNumber = index + 1;
  363. const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
  364. const trimmed = line.trim();
  365. if (fenceMatch) {
  366. const marker = fenceMatch[1];
  367. if (!inFence) {
  368. inFence = true;
  369. fenceChar = marker[0];
  370. fenceLength = marker.length;
  371. } else if (marker[0] === fenceChar && marker.length >= fenceLength) {
  372. inFence = false;
  373. }
  374. }
  375. if (!inFence && trimmed === "$$") {
  376. inMathBlock = !inMathBlock;
  377. }
  378. if (!inFence && !inMathBlock && trimmed === "") {
  379. flush(lineNumber);
  380. startLine = lineNumber + 1;
  381. continue;
  382. }
  383. if (buffer.length === 0) startLine = lineNumber;
  384. buffer.push(line);
  385. }
  386. flush(lines.length);
  387. return blocks;
  388. }
  389. function renderSegmentedMarkdown(markdown, options) {
  390. if (!isSegmentedPreviewSafe(markdown)) {
  391. return { mode: "full-required", reason: "unsafe-markdown" };
  392. }
  393. const blocks = splitMarkdownBlocks(markdown);
  394. if (blocks.length < (options.minimumBlocks || 1)) {
  395. return { mode: "full-required", reason: "too-few-blocks" };
  396. }
  397. const seenHashes = new Map();
  398. const renderedBlocks = blocks.map((block) => {
  399. const hash = hashString(block.source);
  400. const seenCount = seenHashes.get(hash) || 0;
  401. seenHashes.set(hash, seenCount + 1);
  402. const html = marked.parse(block.source);
  403. return {
  404. id: `preview-block-${hash}-${seenCount}`,
  405. hash,
  406. html,
  407. htmlLength: html.length,
  408. sourceLength: block.source.length,
  409. startLine: block.startLine,
  410. endLine: block.endLine,
  411. };
  412. });
  413. return {
  414. mode: "segmented",
  415. blocks: renderedBlocks,
  416. blockCount: renderedBlocks.length,
  417. };
  418. }
  419. self.onmessage = function(event) {
  420. const data = event.data || {};
  421. if (data.type !== "render") return;
  422. try {
  423. const options = data.options || {};
  424. ensureLibraries(options.libraryUrls || {});
  425. mermaidIdCounter = 0;
  426. const result = renderSegmentedMarkdown(data.markdown || "", options);
  427. self.postMessage({
  428. type: "render-result",
  429. requestId: data.requestId,
  430. result,
  431. });
  432. } catch (error) {
  433. self.postMessage({
  434. type: "render-error",
  435. requestId: data.requestId,
  436. error: error && error.message ? error.message : "Preview worker render failed.",
  437. });
  438. }
  439. };