auto-git:

[add] src/markdown/
 [add] src/types/
This commit is contained in:
2026-05-07 19:42:40 +02:00
parent b520f53ae5
commit c59f7ab429
3 changed files with 499 additions and 0 deletions

View File

@@ -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);
}

319
src/markdown/markdown.ts Normal file
View File

@@ -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, "&lt;")
.replace(/>/g, "&gt;");
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(
`<p class="md-paragraph">${escapeRegExp(EXTRA_BLANK_LINE_MARKER)}</p>\\n?`,
"g"
);
return html.replace(markerParagraph, "<br />");
};
const cleanMarkdownForPreview = (source: string) =>
source
.replace(/<think(?:ing)?>[\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 = () => "<b>";
markdown.renderer.rules.strong_close = () => "</b>";
markdown.renderer.rules.em_open = () => "<i>";
markdown.renderer.rules.em_close = () => "</i>";
markdown.renderer.rules.softbreak = () => "<br />";
markdown.renderer.rules.hardbreak = () => "<br />";
markdown.renderer.rules.code_inline = (tokens: any[], idx: number) =>
`<code class="md-inline-code">${escapeHtml(tokens[idx].content)}</code>`;
markdown.renderer.rules.fence = (tokens: any[], idx: number) => {
const info = tokens[idx].info.trim();
const language = info || "code";
return `<pre class="md-codeblock"><code>${escapeHtml(tokens[idx].content).replace(
/\n$/,
""
)}</code></pre><div class="md-codeblock__label">${escapeHtml(language)}</div>`;
};
markdown.renderer.rules.code_block = (tokens: any[], idx: number) =>
`<pre class="md-codeblock"><code>${escapeHtml(tokens[idx].content).replace(
/\n$/,
""
)}</code></pre>`;
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, "");
}

4
src/types/markdown-it.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "markdown-it" {
const MarkdownIt: any;
export default MarkdownIt;
}