diff --git a/app/assets/scss/common.scss b/app/assets/scss/common.scss index e4c9473..de5f279 100644 --- a/app/assets/scss/common.scss +++ b/app/assets/scss/common.scss @@ -1,45 +1,3 @@ @use "./markdown/reset.scss"; @use "./markdown/green.scss"; - -// .code-block-wrapper { -// position: relative; -// margin: 16px 0; -// border-radius: 8px; -// background: #1e1e1e; -// overflow: hidden; -// } -// .code-header { -// display: flex; -// justify-content: space-between; -// align-items: center; -// padding: 8px 12px; -// background: #2d2d2d; -// color: #ccc; -// font-size: 14px; -// } -// .code-lang { -// font-weight: 600; -// text-transform: uppercase; -// } -// .copy-btn { -// padding: 4px 8px; -// border: none; -// border-radius: 4px; -// background: #444; -// color: #fff; -// cursor: pointer; -// font-size: 12px; -// transition: all 0.2s; -// } -// .copy-btn:hover { -// background: #555; -// } -// pre { -// margin: 0; -// padding: 12px; -// overflow-x: auto; -// } -// code { -// color: #d4d4d4; -// font-family: 'Consolas', 'Monaco', monospace; -// } \ No newline at end of file +@use "./markdown/highlight.scss"; diff --git a/app/assets/scss/markdown/green.scss b/app/assets/scss/markdown/green.scss index 25081a2..a08ba4f 100644 --- a/app/assets/scss/markdown/green.scss +++ b/app/assets/scss/markdown/green.scss @@ -171,108 +171,134 @@ del { text-decoration: line-through var(--color-base) 2px; } - code { - color: rgb(45, 55, 72); - background-color: rgba(160,174,192,0.25); + /* 行内代码:仅匹配非代码块场景,避免和 pre/code 嵌套互相覆盖 */ + :not(pre) > code { + color: #d73a49; + background-color: rgba(160, 174, 192, 0.25); font-family: inherit; font-size: 1em; - // color: #ffffff; - // background-color: #ff4c209c; border-radius: 4px; padding: 1px 6px; - // font-size: 0.875em; - // font-size: 1.0769em; - // position: relative; - // top: 0.1em; margin: 0 2px; vertical-align: bottom; } - pre { - code { - padding: 0; - font-size: inherit; - font-weight: inherit; - color: inherit; - white-space: pre; - background-color: transparent; - vertical-align: baseline; - border-radius: 0; - margin: 0; - } - } - - /* 代码块整体 */ - pre { - background: #f7f7f7; /* One Dark 背景 */ - color: #333; /* 文字色 */ - border-radius: 10px; /* 圆角 */ - padding: 1.2rem; + + /* MarkdownIt highlight 输出的代码块容器 */ + .code-block-wrapper { margin: 1rem 0; - overflow-x: auto; /* 长代码横向滚动 */ - box-shadow: 0 2px 8px rgba(0,0,0,0.15); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + background: #f7f7f7; + color: #333; font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; font-size: 0.95em; line-height: 1.5; - } - - /* 行内代码 `code` */ - code { - background: #f0f0f0; - color: #d73a49; - padding: 2px 6px; - border-radius: 4px; - } - pre code { - background: transparent; - padding: 0; - color: inherit; - } - table { - width: 100%; - display: table; - border: 1px solid var(--color-light); - tr:hover { - background: var(--color-light); - } - &:hover{ - border: 1px solid var(--color-extra); - th { - border-right: 1px solid var(--color-extra); - background: var(--color-extra); - border-bottom: 1px solid var(--color-extra); - } - td { - border-right: 1px solid var(--color-extra); - } - caption { - border-right: 1px solid var(--color-extra); + + .code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1rem; + background: #efefef; + + .code-lang { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + color: #64748b; + text-transform: uppercase; } - thead { - th { - background: var(--color-extra); + + .copy-btn { + border: 1px solid #d0d7de; + background: #fff; + color: #475569; + border-radius: 6px; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + line-height: 1.3; + cursor: pointer; + transition: all 0.18s ease; + + &:hover { + background: #f8fafc; + border-color: #b8c2cc; + color: #334155; + } + + &.is-copied { + background: #dcfce7; + border-color: #86efac; + color: #166534; } } } - // table-layout: fixed; - th { - border-right: 1px solid var(--color-light); - padding: 0.5em 1em; - background: var(--color-light); + + pre { + margin: 0; + padding: 1.2rem; + overflow-x: auto; + background: transparent; + box-shadow: none; + + code { + display: block; + padding: 0; + margin: 0; + color: inherit; + background: transparent; + border-radius: 0; + white-space: pre; + } + + } + } + table { + width: fit-content; + display: block; + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--color-light); + border-radius: 8px; + border-collapse: separate; + border-spacing: 0; + margin: 1rem 0; + background: #fff; + + caption { + padding: 0.6em 1em; + text-align: left; + color: var(--color-fg-subtle); border-bottom: 1px solid var(--color-light); + background: #fff; } + + thead th { + background: var(--color-extra); + border-bottom: 1px solid var(--color-light); + font-weight: 600; + } + + th, td { + padding: 0.55em 1em; + white-space: nowrap; border-right: 1px solid var(--color-light); - padding: 0.5em 1em; + border-bottom: 1px solid var(--color-light); } - caption { - border-right: 1px solid var(--color-light); - padding: 0.5em 1em; - border-bottom: none; + + th:last-child, + td:last-child { + border-right: 0; } - thead { - th { - background: var(--color-extra); - } + + tbody tr:last-child td { + border-bottom: 0; + } + + tbody tr:hover td { + background: var(--color-light); } } h1, diff --git a/app/assets/scss/markdown/highlight.scss b/app/assets/scss/markdown/highlight.scss new file mode 100644 index 0000000..7e5a278 --- /dev/null +++ b/app/assets/scss/markdown/highlight.scss @@ -0,0 +1,53 @@ +.markdown-body { + .code-block-wrapper { + pre { + code.hljs { + color: #1f2937; + } + + .hljs-comment, + .hljs-quote { + color: #6b7280; + font-style: italic; + } + + .hljs-keyword, + .hljs-selector-tag, + .hljs-literal, + .hljs-section, + .hljs-link { + color: #9333ea; + } + + .hljs-string, + .hljs-title, + .hljs-name, + .hljs-type, + .hljs-attribute, + .hljs-symbol, + .hljs-bullet, + .hljs-addition { + color: #0f766e; + } + + .hljs-number, + .hljs-variable, + .hljs-template-variable, + .hljs-regexp, + .hljs-meta .hljs-string { + color: #d97706; + } + + .hljs-built_in, + .hljs-function, + .hljs-class .hljs-title { + color: #2563eb; + } + + .hljs-deletion, + .hljs-subst { + color: #dc2626; + } + } + } +} diff --git a/app/utils/render-markdown.ts b/app/utils/render-markdown.ts index cab9db2..cc4bcb2 100644 --- a/app/utils/render-markdown.ts +++ b/app/utils/render-markdown.ts @@ -1,55 +1,93 @@ import MarkdownIt from 'markdown-it' +import hljs from 'highlight.js' import DOMPurify from 'isomorphic-dompurify' import { stripFrontMatter } from './markdown-front-matter' +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + +const toSafeLanguageClass = (lang: string): string => { + const normalized = lang.trim().toLowerCase().replaceAll(/[^a-z0-9_-]/g, '') + return normalized || 'text' +} + +const getHighlightedCode = (source: string, lang: string): string => { + const normalizedLang = lang.trim().toLowerCase() + if (normalizedLang && hljs.getLanguage(normalizedLang)) { + return hljs.highlight(source, { language: normalizedLang, ignoreIllegals: true }).value + } + if (normalizedLang) { + return hljs.highlightAuto(source).value + } + return escapeHtml(source) +} + const md = new MarkdownIt({ html: false, linkify: true, typographer: false, breaks: true, - // // 开启代码块语言识别 - // highlight: function (str: string, lang: string) { - // // 处理语言标签(如果没有指定语言,默认 text) - // const language = lang || 'text' - // // 生成带语言标识 + 复制按钮的代码块外壳 - // return ` - //
- //
- // ${language} - // - //
- //
${md.utils.escapeHtml(str)}
- //
- // ` - // }, + // 开启代码块语言识别 + highlight: function (str: string, lang: string) { + // 处理语言标签(如果没有指定语言,默认 text) + const language = toSafeLanguageClass(lang || 'text') + const highlightedCode = getHighlightedCode(str, lang || '') + // 生成带语言标识 + 复制按钮的代码块外壳 + return ( + `
${language}` + + `
` + + `
${highlightedCode}
` + ) + }, }) -// /** 全局复制代码函数(挂载到 window,确保页面可调用) */ -// declare global { -// interface Window { -// copyCode: (btn: HTMLButtonElement) => void -// } -// } - -// // 只在客户端环境注册复制函数 -// if (typeof window !== 'undefined') { -// window.copyCode = function (btn: HTMLButtonElement) { -// const codeBlock = btn.closest('.code-block-wrapper')!.querySelector('code')! -// const text = codeBlock.textContent || '' +/** 全局复制代码函数与事件绑定标记 */ +declare global { + interface Window { + copyCode: (btn: HTMLButtonElement) => void + __markdownCopyBound?: boolean + } +} + +// 只在客户端环境注册复制函数 +if (typeof window !== 'undefined') { + window.copyCode = function (btn: HTMLButtonElement) { + const codeBlock = btn.closest('.code-block-wrapper')!.querySelector('code')! + const text = codeBlock.textContent || '' -// navigator.clipboard.writeText(text).then(() => { -// const originalText = btn.textContent -// btn.textContent = '已复制' -// btn.style.backgroundColor = '#10b981' + navigator.clipboard.writeText(text).then(() => { + const originalText = btn.textContent + btn.textContent = '已复制' + btn.classList.add('is-copied') -// // 2秒后恢复按钮文字 -// setTimeout(() => { -// btn.textContent = originalText -// btn.style.backgroundColor = '' -// }, 2000) -// }) -// } -// } + // 2秒后恢复按钮文字 + setTimeout(() => { + btn.textContent = originalText + btn.classList.remove('is-copied') + }, 2000) + }) + } + + if (!window.__markdownCopyBound) { + document.addEventListener('click', (event) => { + const target = event.target + if (!(target instanceof Element)) { + return + } + const btn = target.closest('button[data-copy-code]') + if (!btn) { + return + } + window.copyCode(btn) + }) + window.__markdownCopyBound = true + } +} /** 将 Markdown 转为可安全用于 `v-html` 的 HTML(禁用源码中的原始 HTML)。 */ export function renderSafeMarkdown(src: string): string { diff --git a/bun.lock b/bun.lock index 35569ac..c488ad4 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "drizzle-seed": "0.3.1", "drizzle-zod": "0.8.3", "fast-xml-parser": "5.7.0", + "highlight.js": "^11.11.1", "isomorphic-dompurify": "3.9.0", "log4js": "6.9.1", "logger": "workspace:*", @@ -1332,6 +1333,8 @@ "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], diff --git a/package.json b/package.json index b3bc43a..1bab8a0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "drizzle-seed": "0.3.1", "drizzle-zod": "0.8.3", "fast-xml-parser": "5.7.0", + "highlight.js": "^11.11.1", "isomorphic-dompurify": "3.9.0", "log4js": "6.9.1", "logger": "workspace:*", diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 84ff945..b1c19ea 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ