From c59f7ab4295cea5d4b49d7052fb752620b770648 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 7 May 2026 19:42:40 +0200 Subject: [PATCH] auto-git: [add] src/markdown/ [add] src/types/ --- src/markdown/markdown-render.css | 176 +++++++++++++++++ src/markdown/markdown.ts | 319 +++++++++++++++++++++++++++++++ src/types/markdown-it.d.ts | 4 + 3 files changed, 499 insertions(+) create mode 100644 src/markdown/markdown-render.css create mode 100644 src/markdown/markdown.ts create mode 100644 src/types/markdown-it.d.ts diff --git a/src/markdown/markdown-render.css b/src/markdown/markdown-render.css new file mode 100644 index 0000000..b4bb50e --- /dev/null +++ b/src/markdown/markdown-render.css @@ -0,0 +1,176 @@ +.md-root { + --md-border-color: var(--line); + --md-surface: rgba(255, 255, 255, 0.68); + --md-surface-strong: var(--surface); + --md-text-color: var(--ink); + --md-muted-color: var(--muted); + --md-link-color: var(--accent); + --md-link-hover-color: var(--accent-strong); + --md-code-bg: #fff7f8; + --md-code-inline-bg: rgba(159, 18, 57, 0.08); + --md-code-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + + color: var(--md-text-color); + font-size: 14px; + line-height: 1.55; +} + +.md-heading { + margin: 1.35rem 0 0.65rem; + font-weight: 650; + line-height: 1.25; + color: var(--accent); +} + +.md-root > .md-heading:first-child { + margin-top: 0; +} + +.md-heading--1 { + font-size: 1.7rem; +} + +.md-heading--2 { + font-size: 1.35rem; +} + +.md-heading--3 { + font-size: 1.15rem; +} + +.md-heading--4, +.md-heading--5, +.md-heading--6 { + font-size: 1rem; +} + +.md-paragraph { + margin: 0 0 0.85rem; +} + +.md-paragraph:last-child { + margin-bottom: 0; +} + +.md-hr { + border: 0; + border-top: 1px solid var(--md-border-color); + margin: 1.4rem 0; +} + +.md-list { + margin: 0.45rem 0 0.95rem; + padding-left: 1.35rem; +} + +.md-list--unordered { + list-style: disc; +} + +.md-list--ordered { + list-style: decimal; +} + +.md-list__item { + margin: 0.18rem 0; +} + +.md-blockquote { + margin: 1rem 0; + padding: 0.7rem 0.9rem; + border-left: 3px solid var(--accent); + background: var(--md-surface); + color: var(--md-muted-color); +} + +.md-table { + width: 100%; + margin: 1rem 0; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--md-border-color); + border-radius: var(--radius); + overflow: hidden; +} + +.md-table thead { + background: var(--bg-main); +} + +.md-table__head-cell, +.md-table__cell { + padding: 0.58rem 0.7rem; + vertical-align: top; + text-align: left; + border-bottom: 1px solid var(--md-border-color); +} + +.md-table__row:last-child .md-table__cell { + border-bottom: none; +} + +.md-align-left { + text-align: left; +} + +.md-align-center { + text-align: center; +} + +.md-align-right { + text-align: right; +} + +.md-codeblock { + margin: 1rem 0 0.35rem; + padding: 0.75rem; + overflow: auto; + max-width: 100%; + border: 1px solid var(--md-border-color); + border-radius: var(--radius); + background: var(--md-code-bg); +} + +.md-codeblock code { + display: block; + max-width: 100%; + font-family: var(--md-code-font); + font-size: 0.94em; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.md-codeblock__label { + margin: 0 0 1rem; + font-family: var(--md-code-font); + font-size: 11px; + color: var(--subtle); +} + +.md-inline-code { + padding: 0.1em 0.24em; + border-radius: 4px; + background: var(--md-code-inline-bg); + font-family: var(--md-code-font); + font-size: 0.94em; +} + +.md-link { + color: var(--md-link-color); + text-decoration: underline; + text-underline-offset: 2px; +} + +.md-link:hover, +.md-link:focus-visible { + color: var(--md-link-hover-color); +} + +.md-image { + display: block; + max-width: 100%; + height: auto; + margin: 1rem 0; + border-radius: var(--radius); +} diff --git a/src/markdown/markdown.ts b/src/markdown/markdown.ts new file mode 100644 index 0000000..2410f3a --- /dev/null +++ b/src/markdown/markdown.ts @@ -0,0 +1,319 @@ +import MarkdownIt from "markdown-it"; + +const EXTRA_BLANK_LINE_MARKER = "\uE000CONCEPT_MAKER_EXTRA_BLANK_LINE\uE000"; + +const escapeHtml = (value = "") => + value + .replace(/&/g, "&") + .replace(//g, ">"); + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const appendClass = (token: any, className: string) => { + const existing = token.attrGet("class"); + token.attrSet("class", existing ? `${existing} ${className}` : className); +}; + +const removeAttr = (token: any, attrName: string) => { + const index = token.attrIndex(attrName); + if (index >= 0) token.attrs.splice(index, 1); +}; + +const alignmentClassFromToken = (token: any) => { + const style = token.attrGet("style") || ""; + const match = style.match(/text-align:\s*(left|right|center)/i); + return `md-align-${match?.[1]?.toLowerCase() || "left"}`; +}; + +const safeLink = (hrefRaw: string) => { + const href = (hrefRaw || "").trim(); + if (!href) return ""; + if (/^(https?|mailto|tel):/i.test(href)) return href; + if (/^[./#]/.test(href)) return href; + if (!/^[a-z][a-z0-9+.-]*:/i.test(href)) return href; + return ""; +}; + +const preserveExtraBlankLines = (source: string) => + source.replace(/([^\n])(\n{3,})([^\n])/g, (_match, before, newlines, after) => { + const markers = Array.from( + { length: newlines.length - 2 }, + () => EXTRA_BLANK_LINE_MARKER + ).join("\n\n"); + return `${before}\n\n${markers}\n\n${after}`; + }); + +const restoreExtraBlankLines = (html: string) => { + const markerParagraph = new RegExp( + `

${escapeRegExp(EXTRA_BLANK_LINE_MARKER)}

\\n?`, + "g" + ); + return html.replace(markerParagraph, "
"); +}; + +const cleanMarkdownForPreview = (source: string) => + source + .replace(/[\s\S]*?(?:<\/think(?:ing)?>|$)/gi, "") + .replace(/[\u00a0\u202f\u2007]/g, " "); + +function balanceStreamingCodeFence(markdownSource: string) { + const lines = markdownSource.split(/\r?\n/); + let open: { fenceChar: string; fenceLen: number } | null = null; + + for (const line of lines) { + if (!open) { + const match = line.match(/^\s*([`~]{3,})([^\s]*)?.*$/); + if (match) { + open = { fenceChar: match[1][0], fenceLen: match[1].length }; + } + continue; + } + + const closeFence = new RegExp(`^\\s*(${open.fenceChar}{${open.fenceLen},})\\s*$`); + if (closeFence.test(line)) { + open = null; + } + } + + if (!open) return markdownSource; + + const virtualFence = open.fenceChar.repeat(open.fenceLen); + return markdownSource.endsWith("\n") + ? markdownSource + virtualFence + : `${markdownSource}\n${virtualFence}`; +} + +const markdown = new MarkdownIt({ + html: false, + linkify: true, + breaks: true, + typographer: false +}); + +markdown.linkify.set({ fuzzyEmail: false, fuzzyIP: false }); +markdown.validateLink = (href: string) => Boolean(safeLink(href)); + +markdown.renderer.rules.paragraph_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-paragraph"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.heading_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + const level = tokens[idx].tag.replace(/^h/i, "") || "1"; + appendClass(tokens[idx], `md-heading md-heading--${level}`); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.hr = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-hr"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.bullet_list_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-list md-list--unordered"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.ordered_list_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-list md-list--ordered"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.list_item_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-list__item"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.blockquote_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-blockquote"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.table_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], "md-table"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.thead_open = ( + tokens: any[], + idx: number, + options: any, + env: any, + self: any +) => { + env.inTableHead = true; + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.thead_close = ( + tokens: any[], + idx: number, + options: any, + env: any, + self: any +) => { + env.inTableHead = false; + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.tr_open = ( + tokens: any[], + idx: number, + options: any, + env: any, + self: any +) => { + appendClass( + tokens[idx], + env.inTableHead ? "md-table__row md-table__row--head" : "md-table__row" + ); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.th_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], `md-table__head-cell ${alignmentClassFromToken(tokens[idx])}`); + removeAttr(tokens[idx], "style"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.td_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + appendClass(tokens[idx], `md-table__cell ${alignmentClassFromToken(tokens[idx])}`); + removeAttr(tokens[idx], "style"); + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.strong_open = () => ""; +markdown.renderer.rules.strong_close = () => ""; +markdown.renderer.rules.em_open = () => ""; +markdown.renderer.rules.em_close = () => ""; +markdown.renderer.rules.softbreak = () => "
"; +markdown.renderer.rules.hardbreak = () => "
"; + +markdown.renderer.rules.code_inline = (tokens: any[], idx: number) => + `${escapeHtml(tokens[idx].content)}`; + +markdown.renderer.rules.fence = (tokens: any[], idx: number) => { + const info = tokens[idx].info.trim(); + const language = info || "code"; + return `
${escapeHtml(tokens[idx].content).replace(
+    /\n$/,
+    ""
+  )}
${escapeHtml(language)}
`; +}; + +markdown.renderer.rules.code_block = (tokens: any[], idx: number) => + `
${escapeHtml(tokens[idx].content).replace(
+    /\n$/,
+    ""
+  )}
`; + +markdown.renderer.rules.link_open = ( + tokens: any[], + idx: number, + options: any, + _env: any, + self: any +) => { + const href = safeLink(tokens[idx].attrGet("href") || ""); + + if (!href) return ""; + + tokens[idx].attrSet("href", href); + tokens[idx].attrSet("class", "md-link"); + + if (/^https?:\/\//i.test(href)) { + tokens[idx].attrSet("target", "_blank"); + tokens[idx].attrSet("rel", "noreferrer noopener"); + } + + return self.renderToken(tokens, idx, options); +}; + +markdown.renderer.rules.image = ( + tokens: any[], + idx: number, + options: any, + env: any, + self: any +) => { + const token = tokens[idx]; + const src = safeLink(token.attrGet("src") || ""); + if (!src) return escapeHtml(token.content || ""); + + token.attrSet("src", src); + token.attrSet("alt", self.renderInlineAsText(token.children, options, env)); + token.attrSet("loading", "lazy"); + appendClass(token, "md-image"); + + return self.renderToken(tokens, idx, options); +}; + +export function markdownToHTML(text: string) { + let source = cleanMarkdownForPreview(String(text ?? "")); + source = balanceStreamingCodeFence(source); + source = preserveExtraBlankLines(source); + + const html = markdown.render(source, {}); + return restoreExtraBlankLines(html).replace(/\n+$/g, ""); +} diff --git a/src/types/markdown-it.d.ts b/src/types/markdown-it.d.ts new file mode 100644 index 0000000..eee4811 --- /dev/null +++ b/src/types/markdown-it.d.ts @@ -0,0 +1,4 @@ +declare module "markdown-it" { + const MarkdownIt: any; + export default MarkdownIt; +}