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, "${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(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;
+}