diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-11 14:24:49 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-11 14:24:49 -0700 |
| commit | 8c34d810af95fae0ef846f54370a8c88bfab7123 (patch) | |
| tree | 436beaf30f7b2b3f15741dd54a37e313964d1f7d /src/utility/markdown.ts | |
Diffstat (limited to 'src/utility/markdown.ts')
| -rw-r--r-- | src/utility/markdown.ts | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/src/utility/markdown.ts b/src/utility/markdown.ts new file mode 100644 index 0000000..f78cfee --- /dev/null +++ b/src/utility/markdown.ts @@ -0,0 +1,147 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export default (input: string): string => { + const codeBlocks: { content: string; index: number; language: string; }[] = []; + const codeBlockPattern = /```(\w*)[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/g; + const inlineCode: string[] = []; + const inlineCodePattern = /`([^`]+)`/g; + const refPattern = /\s*\[(\w+)\]\s+<([^>]+)>$/gm; + const refs = new Map<string, string>(); + + /*** extract and replace code blocks with placeholders ***/ + let processed = input.replace(codeBlockPattern, (_match, language, content) => { + const index = codeBlocks.length; + + codeBlocks.push({ + content: content.trimEnd(), + index, + language: language || "" + }); + + return `<!--CODE:BLOCK:${index}-->`; + }); + + /*** extract inline code ***/ + processed = processed.replace(inlineCodePattern, (_match, content) => { + const index = inlineCode.length; + inlineCode.push(content); + return `<!--INLINE:CODE:${index}-->`; + }); + + /*** extract reference definitions (images/links) ***/ + for (const match of processed.matchAll(refPattern)) { + const [, ref, url] = match; + refs.set(ref, url); + } + + /*** remove reference definitions before blockquote processing ***/ + processed = processed.replace(refPattern, ""); + + /*** process blockquotes ***/ + processed = processBlockquotes(processed); + + processed = processed + .replace(/(\n){6}/g, "") /*** remove 6 lines from the top of the memo ***/ + .replace("References", "") /*** remove reference to reference definitions from output ***/ + .replace(/(\*\*|__)(.*?)\1/g, `<strong style="white-space: nowrap;">$2</strong>`) /*** bold ***/ + .replace(/(\*|_)(.*?)\1/g, "<em>$2</em>") /*** italic ***/ + .replace(/(\~\~)(.*?)\1/g, `<del>$2</del>`) /*** strikethrough ***/ + .replace(/'/g, "’") /*** fancy apostrophe ***/ + .replace(/(:nbhyp:)/g, "‑") /*** non‑breaking hyphen ***/ + // deno-lint-ignore no-irregular-whitespace + .replace(/(:nbsp:)/g, " ") /*** non‑breaking space ***/ + .replace(/(\.\.\.)/g, "…") /*** ellipsis ***/ + .replace(/---/g, "<hr/>") + .replace(/📸\[([^\]]+)\]\[(\w+)\]/g, (match, alt, ref) => { + const url = refs.get(ref); + return url ? `<img src="${url}" alt="${alt}" loading="lazy">` : match; + }) + .replace(/📼\[([^\]]+)\]\[(\w+)\]/g, (match, alt, ref) => { + const url = refs.get(ref); + return url ? `<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen frameborder="0" referrerpolicy="strict-origin-when-cross-origin" src="${url}" title="${alt}"></iframe>` : match; + }) + .replace(/\[(\w+)\](?!\s+<|\()/g, (match, ref) => { + const url = refs.get(ref); + const isExternal = url && url.startsWith("http://") || url && url.startsWith("https://"); + + return url ? + isExternal ? + `<a href="${url + (url.includes("?") ? "&" : "?")}ref=blog.webb.page" target="_blank">[${ref}]</a>` : + `<a href="${url}">[${ref}]</a>` : + match; + }) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { + const isExternal = url.startsWith("http://") || url.startsWith("https://"); + + return isExternal ? + `<a href="${url}?ref=blog.webb.page" target="_blank">${text}</a>` : + `<a href="${url}">${text}</a>`; + }); + + /*** restore inline code ***/ + for (const block in inlineCode) { + processed = processed.replace(`<!--INLINE:CODE:${block}-->`, `<code>${escapeHtml(inlineCode[block])}</code>`); + } + + /*** restore code blocks ***/ + for (const block of codeBlocks) { + const langAttr = block.language ? ` data-language="${block.language}"` : ""; + const html = `<pre><code${langAttr}>\n${escapeHtml(block.content)}\n\n</code></pre>`; + + processed = processed.replace(`<!--CODE:BLOCK:${block.index}-->`, html); + } + + // console.log(processed); /*** for troubleshooting ***/ + + return processed.trimEnd(); +} + +/*** HELPER ------------------------------------------- ***/ + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +function processBlockquotes(input: string): string { + const lines = input.split("\n"); + const result: string[] = []; + let quoteBuffer: string[] = []; + + const flushQuote = () => { + if (quoteBuffer.length > 0) { + /*** special indentation to fit memo/remark formatting ***/ + result.push(`<blockquote>\n ${quoteBuffer.join("\n ")}\n\n</blockquote>`); + quoteBuffer = []; + // TODO + // : handle links within blockquotes (WM-032) + } + }; + + for (const line of lines) { + /*** preserve code block placeholders ***/ + if (line.includes("<!--CODE:BLOCK") || line.includes("<!--INLINE:CODE")) { + flushQuote(); + result.push(line); + continue; + } + + const quoteMatch = line.match(/\s*>\s?(.*)$/); + + if (quoteMatch) { + quoteBuffer.push(quoteMatch[1]); + } else { + flushQuote(); + result.push(line); + } + } + + flushQuote(); + return result.join("\n"); +} |
