auto-git:
[add] src/markdown/ [add] src/types/
This commit is contained in:
176
src/markdown/markdown-render.css
Normal file
176
src/markdown/markdown-render.css
Normal 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
319
src/markdown/markdown.ts
Normal 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, "<")
|
||||
.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(
|
||||
`<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
4
src/types/markdown-it.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "markdown-it" {
|
||||
const MarkdownIt: any;
|
||||
export default MarkdownIt;
|
||||
}
|
||||
Reference in New Issue
Block a user