diff --git a/README.md b/README.md index d7c53b9..12a678e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Markdown 文档自动即时渲染为微信图文,让你不再为微信文章 - [x] 支持自定义 CSS 样式 - [x] 支持 Markdown 所有基础语法、代码块、LaTeX 公式 +- [x] 支持 [GFM 警告块](https://github.com/orgs/community/discussions/16925) - [x] 支持浅色、深色两种编辑器外观 - [x] 支持 Alt + Shift + F 快速格式化文档 - [x] 支持色盘取色,快速替换文章整体色调 diff --git a/src/assets/example/theme-css.txt b/src/assets/example/theme-css.txt index 53f9a92..230082f 100644 --- a/src/assets/example/theme-css.txt +++ b/src/assets/example/theme-css.txt @@ -33,6 +33,27 @@ blockquote { /* 引用段落样式 */ blockquote_p { } +/* GFM 警告块 */ +markdown-alert { +} +/* GFM 警告块标题 */ +markdown-alert-title { +} +/* GFM note */ +markdown-alert-title-note { +} +/* GFM tip */ +markdown-alert-title-tip { +} +/* GFM important */ +markdown-alert-title-important { +} +/* GFM warning */ +markdown-alert-title-warning { +} +/* GFM caution */ +markdown-alert-title-caution { +} /* 段落样式 */ p { } diff --git a/src/config/theme.ts b/src/config/theme.ts index 483d683..e4e3d0c 100644 --- a/src/config/theme.ts +++ b/src/config/theme.ts @@ -10,7 +10,7 @@ const defaultTheme: Theme = { }, block: { // 一级标题 - h1: { + 'h1': { 'display': `table`, 'padding': `0 1em`, 'border-bottom': `2px solid var(--md-primary-color)`, @@ -22,7 +22,7 @@ const defaultTheme: Theme = { }, // 二级标题 - h2: { + 'h2': { 'display': `table`, 'padding': `0 0.2em`, 'margin': `4em auto 2em`, @@ -34,7 +34,7 @@ const defaultTheme: Theme = { }, // 三级标题 - h3: { + 'h3': { 'padding-left': `8px`, 'border-left': `3px solid var(--md-primary-color)`, 'margin': `2em 8px 0.75em 0`, @@ -45,7 +45,7 @@ const defaultTheme: Theme = { }, // 四级标题 - h4: { + 'h4': { 'margin': `2em 8px 0.5em`, 'color': `var(--md-primary-color)`, 'font-size': `1em`, @@ -53,7 +53,7 @@ const defaultTheme: Theme = { }, // 五级标题 - h5: { + 'h5': { 'margin': `1.5em 8px 0.5em`, 'color': `var(--md-primary-color)`, 'font-size': `1em`, @@ -61,14 +61,14 @@ const defaultTheme: Theme = { }, // 六级标题 - h6: { + 'h6': { 'margin': `1.5em 8px 0.5em`, 'font-size': `1em`, 'color': `var(--md-primary-color)`, }, // 段落 - p: { + 'p': { 'margin': `1.5em 8px`, 'letter-spacing': `0.1em`, 'color': `var(--el-text-color-regular)`, @@ -76,7 +76,7 @@ const defaultTheme: Theme = { }, // 引用 - blockquote: { + 'blockquote': { 'font-style': `normal`, 'border-left': `none`, 'padding': `1em`, @@ -87,15 +87,52 @@ const defaultTheme: Theme = { }, // 引用内容 - blockquote_p: { + 'blockquote_p': { 'display': `block`, 'font-size': `1em`, 'letter-spacing': `0.1em`, 'color': `rgb(80, 80, 80)`, }, + // GFM 警告块 + 'markdown-alert': { + 'font-style': `normal`, + 'border-left': `none`, + 'padding': `1em`, + 'border-radius': `8px`, + 'background': `#f7f7f7`, + 'margin': `2em 8px`, + '--el-text-color-regular': `rgb(80, 80, 80) !important`, + }, + + // GFM 警告块标题 + 'markdown-alert-title': { + 'display': `flex`, + 'align-items': `center`, + }, + + 'markdown-alert-title-note': { + color: `#478be6`, + }, + + 'markdown-alert-title-tip': { + color: `#57ab5a`, + }, + + 'markdown-alert-title-important': { + color: `#986ee2`, + }, + + 'markdown-alert-title-warning': { + color: `#c69026`, + }, + + 'markdown-alert-title-caution': { + color: `#e5534b`, + }, + // 代码块 - code_pre: { + 'code_pre': { 'font-size': `14px`, 'overflow-x': `auto`, 'border-radius': `8px`, @@ -105,14 +142,14 @@ const defaultTheme: Theme = { }, // 行内代码 - code: { + 'code': { 'margin': 0, 'white-space': `nowrap`, 'font-family': `Menlo, Operator Mono, Consolas, Monaco, monospace`, }, // 图片 - image: { + 'image': { 'display': `block`, 'width': `100% !important`, 'margin': `0.1em auto 0.5em`, @@ -120,32 +157,32 @@ const defaultTheme: Theme = { }, // 有序列表 - ol: { + 'ol': { 'padding-left': `1em`, 'margin-left': `0`, 'color': `var(--el-text-color-regular)`, }, // 无序列表 - ul: { + 'ul': { 'list-style': `circle`, 'padding-left': `1em`, 'margin-left': `0`, 'color': `var(--el-text-color-regular)`, }, - footnotes: { + 'footnotes': { 'margin': `0.5em 8px`, 'font-size': `80%`, 'color': `var(--el-text-color-regular)`, }, - figure: { + 'figure': { margin: `1.5em 8px`, color: `var(--el-text-color-regular)`, }, - hr: { + 'hr': { 'border-style': `solid`, 'border-width': `1px 0 0`, 'border-color': `rgba(0,0,0,0.1)`, @@ -230,43 +267,43 @@ const graceTheme = toMerged(defaultTheme, { base: { }, block: { - h1: { + 'h1': { 'padding': `0.5em 1em`, 'border-bottom': `2px solid var(--md-primary-color)`, 'font-size': `1.4em`, 'text-shadow': `2px 2px 4px rgba(0,0,0,0.1)`, }, - h2: { + 'h2': { 'padding': `0.3em 1em`, 'border-radius': `8px`, 'font-size': `1.3em`, 'box-shadow': `0 4px 6px rgba(0,0,0,0.1)`, }, - h3: { + 'h3': { 'padding-left': `12px`, 'font-size': `1.2em`, 'border-left': `4px solid var(--md-primary-color)`, 'border-bottom': `1px dashed var(--md-primary-color)`, }, - h4: { + 'h4': { 'font-size': `1.1em`, }, - h5: { + 'h5': { 'font-size': `1em`, }, - h6: { + 'h6': { 'font-size': `1em`, }, - p: { + 'p': { }, - blockquote: { + 'blockquote': { 'font-style': `italic`, 'padding': `1em 1em 1em 2em`, 'border-left': `4px solid var(--md-primary-color)`, @@ -276,41 +313,51 @@ const graceTheme = toMerged(defaultTheme, { 'box-shadow': `0 4px 6px rgba(0,0,0,0.05)`, }, - blockquote_p: { + 'blockquote_p': { }, - code_pre: { + 'markdown-alert': { + 'font-style': `italic`, + 'padding': `1em 1em 1em 2em`, + 'border-left': `4px solid var(--md-primary-color)`, + 'border-radius': `6px`, + 'color': `rgba(0,0,0,0.6)`, + 'background': `linear-gradient(to right, #f7f7f7, #ffffff)`, + 'box-shadow': `0 4px 6px rgba(0,0,0,0.05)`, + }, + + 'code_pre': { 'box-shadow': `inset 0 0 10px rgba(0,0,0,0.05)`, }, - code: { + 'code': { 'white-space': `pre-wrap`, 'font-family': `'Fira Code', Menlo, Operator Mono, Consolas, Monaco, monospace`, }, - image: { + 'image': { 'border-radius': `8px`, 'box-shadow': `0 4px 8px rgba(0,0,0,0.1)`, }, - ol: { + 'ol': { 'padding-left': `1.5em`, }, - ul: { + 'ul': { 'list-style': `none`, 'padding-left': `1.5em`, }, - footnotes: { + 'footnotes': { }, - figure: { + 'figure': { }, - hr: { + 'hr': { height: `1px`, border: `none`, margin: `2em 0`, diff --git a/src/stores/index.ts b/src/stores/index.ts index 087fd9f..43ab9ed 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -2,6 +2,7 @@ import DEFAULT_CONTENT from '@/assets/example/markdown.md?raw' import DEFAULT_CSS_CONTENT from '@/assets/example/theme-css.txt?raw' import { altKey, codeBlockThemeOptions, colorOptions, fontFamilyOptions, fontSizeOptions, legendOptions, shiftKey, themeMap, themeOptions } from '@/config' import { addPrefix, css2json, customCssWithTemplate, customizeTheme, downloadMD, exportHTML, formatDoc } from '@/utils' +import markedAlert from '@/utils/MDAlert' import { initRenderer } from '@/utils/renderer' import { useDark, useStorage, useToggle } from '@vueuse/core' @@ -144,6 +145,7 @@ export const useStore = defineStore(`store`, () => { const editorRefresh = () => { codeThemeChange() renderer.reset({ citeStatus: isCiteStatus.value, legend: legend.value, isUseIndent: isUseIndent.value }) + let outputTemp = marked.parse(editor.value!.getValue()) as string // 去除第一行的 margin-top @@ -184,6 +186,8 @@ export const useStore = defineStore(`store`, () => { renderer.setOptions({ theme: newTheme, }) + marked.use(markedAlert({ theme: newTheme })) + editorRefresh() } // 初始化 CSS 编辑器 @@ -354,7 +358,7 @@ export const useStore = defineStore(`store`, () => { const reader = new FileReader() reader.readAsText(file) reader.onload = (event) => { - (editor.value!).setValue((event.target !).result as string) + (editor.value!).setValue((event.target!).result as string) ElMessage.success(`文档导入成功`) } } diff --git a/src/types/index.ts b/src/types/index.ts index 2858a33..28742e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,7 @@ import type { PropertiesHyphen } from 'csstype' +import type { Token } from 'marked' + export type Block = `h1` | `h2` | `h3` | `h4` | `h5` | `h6` | `p` | `blockquote` | `blockquote_p` | `code_pre` | `code` | `image` | `ol` | `ul` | `footnotes` | `figure` | `hr` export type Inline = `listitem` | `codespan` | `link` | `wx_link` | `strong` | `table` | `thead` | `td` | `footnote` | `figcaption` | `em` @@ -12,8 +14,8 @@ export type ExtendedProperties = PropertiesHyphen & CustomCSSProperties export interface Theme { base: ExtendedProperties - block: Record - inline: Record + block: Record + inline: Record } export interface IOpts { @@ -32,3 +34,39 @@ export interface IConfigOption { value: VT desc: string } + +/** + * Options for the `markedAlert` extension. + */ +export interface AlertOptions { + className?: string + variants?: AlertVariantItem[] + theme?: Theme +} + +/** + * Configuration for an alert type. + */ +export interface AlertVariantItem { + type: string + icon: string + title?: string + titleClassName?: string +} + +/** + * Represents an alert token. + */ +export interface Alert { + type: `alert` + meta: { + className: string + variant: string + icon: string + title: string + titleClassName: string + } + raw: string + text: string + tokens: Token[] +} diff --git a/src/utils/MDAlert.ts b/src/utils/MDAlert.ts new file mode 100644 index 0000000..6b0bca1 --- /dev/null +++ b/src/utils/MDAlert.ts @@ -0,0 +1,155 @@ +import type { AlertOptions, AlertVariantItem } from '@/types' +import type { MarkedExtension, Tokens } from 'marked' + +/** + * https://github.com/bent10/marked-extensions/tree/main/packages/alert + * To support theme, we need to modify the source code. + * A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925). + */ +export default function markedAlert(options: AlertOptions = {}): MarkedExtension { + const { className = `markdown-alert`, variants = [] } = options + const resolvedVariants = resolveVariants(variants) + + return { + walkTokens(token) { + if (token.type !== `blockquote`) + return + + const matchedVariant = resolvedVariants.find(({ type }) => + new RegExp(createSyntaxPattern(type)).test(token.text), + ) + + if (matchedVariant) { + const { + type: variantType, + icon, + title = ucfirst(variantType), + titleClassName = `${className}-title`, + } = matchedVariant + const typeRegexp = new RegExp(createSyntaxPattern(variantType)) + + Object.assign(token, { + type: `alert`, + meta: { + className, + variant: variantType, + icon, + title, + titleClassName, + style: { + ...options.theme?.block[className], + ...options.theme?.block[`${className}-${variantType}`], + }, + titleStyle: { + ...options.theme?.block[titleClassName], + ...options.theme?.block[`${titleClassName}-${variantType}`], + }, + }, + }) + + console.log({ + ...options.theme?.block[className], + ...options.theme?.block[`${className}-${variantType}`], + }, `style`) + + const firstLine = token.tokens?.[0] as Tokens.Paragraph + const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim() + + if (firstLineText) { + const patternToken = firstLine.tokens[0] as Tokens.Text + + Object.assign(patternToken, { + raw: patternToken.raw.replace(typeRegexp, ``), + text: patternToken.text.replace(typeRegexp, ``), + }) + + if (firstLine.tokens[1]?.type === `br`) { + firstLine.tokens.splice(1, 1) + } + } + else { + token.tokens?.shift() + } + } + }, + extensions: [ + { + name: `alert`, + level: `block`, + renderer({ meta, tokens = [] }) { + let tmpl = `
\n` + tmpl += `

` + tmpl += meta.icon.replace( + `\n` + tmpl += this.parser.parse(tokens) + tmpl += `

\n` + + return tmpl + }, + }, + ], + } +} + +/** + * The default configuration for alert variants. + */ +const defaultAlertVariant: AlertVariantItem[] = [ + { + type: `note`, + icon: ``, + }, + { + type: `tip`, + icon: ``, + }, + { + type: `important`, + icon: ``, + }, + { + type: `warning`, + icon: ``, + }, + { + type: `caution`, + icon: ``, + }, +] + +/** + * Resolves the variants configuration, combining the provided variants with + * the default variants. + */ +export function resolveVariants(variants: AlertVariantItem[]) { + if (!variants.length) + return defaultAlertVariant + + return Object.values( + [...defaultAlertVariant, ...variants].reduce( + (map, item) => { + map[item.type] = item + return map + }, + {} as { [key: string]: AlertVariantItem }, + ), + ) +} + +/** + * Returns regex pattern to match alert syntax. + */ +export function createSyntaxPattern(type: string) { + return `^(?:\\[!${type.toUpperCase()}])\\s*?\n*` +} + +/** + * Capitalizes the first letter of a string. + */ +export function ucfirst(str: string) { + return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase() +} diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 39c2ad7..17d12dd 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -6,6 +6,7 @@ import hljs from 'highlight.js' import { marked } from 'marked' import mermaid from 'mermaid' +import markedAlert from './MDAlert' import { MDKatex } from './MDKatex' marked.use(MDKatex({ nonStandard: true })) @@ -103,6 +104,8 @@ export function initRenderer(opts: IOpts) { let listIndex: number = 0 let isOrdered: boolean = false + marked.use(markedAlert({ theme: opts.theme })) + function styles(tag: string, addition: string = ``): string { return getStyles(styleMapping, tag, addition) } diff --git a/src/views/CodemirrorEditor.vue b/src/views/CodemirrorEditor.vue index d1c1b64..cb338bf 100644 --- a/src/views/CodemirrorEditor.vue +++ b/src/views/CodemirrorEditor.vue @@ -519,6 +519,17 @@ onMounted(() => { border-spacing: 0; } +:deep(.markdown-alert) { + display: block; + padding: 1em; + border-radius: 8px; + background: #f7f7f7; +} + +:deep(.markdown-alert-title) { + font-weight: bold; +} + .codeMirror-wrapper, .preview-wrapper { height: 100%;