Cherry Markdown安全机制与性能优化

Cherry Markdown安全机制与性能优化

【免费下载链接】cherry-markdown ✨ A Markdown Editor 【免费下载链接】cherry-markdown 项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-markdown

本文详细分析了Cherry Markdown编辑器的多层次安全防护体系与性能优化策略。在安全方面,重点介绍了基于DOMPurify的HTML净化机制、严格的白名单控制策略以及XSS防护方案;在性能方面,深入探讨了局部渲染与更新技术、异步加载机制以及多层次缓存系统的实现原理,为开发者提供了全面的安全保障和性能优化指导。

XSS防护:DomPurify与白名单机制

在现代Web应用中,跨站脚本攻击(XSS)是最常见的安全威胁之一。作为一款功能强大的Markdown编辑器,Cherry Markdown采用了多层次的安全防护策略,其中基于DOMPurify的HTML净化机制和白名单控制构成了其核心安全防线。

安全架构设计理念

Cherry Markdown的安全设计遵循"默认安全"原则,在提供丰富功能的同时确保用户内容的安全性。其安全架构采用分层防御策略:

mermaid

DOMPurify集成与配置

Cherry Markdown深度集成了DOMPurify库,这是一个经过严格安全审计的HTML净化工具。项目通过专门的Sanitizer模块进行封装:

// 浏览器环境下的DOMPurify初始化
import createDOMPurify from 'dompurify';
export const sanitizer = createDOMPurify(window);

// Node.js环境下的DOMPurify初始化  
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('');
export const sanitizer = createDOMPurify(window);

多层次白名单机制

1. 内置默认白名单

Cherry Markdown内置了严格的安全白名单,默认只允许安全的HTML标签:

// 块级元素白名单
export const blockNames = [
  'h1|h2|h3|h4|h5|h6',
  'ul|ol|li|dd|dl|dt',
  'table|thead|tbody|tfoot|col|colgroup|th|td|tr',
  'div|article|section|footer|aside|details|summary|code|audio|video|canvas|figure',
  'address|center|cite|p|pre|blockquote|marquee|caption|figcaption|track|source|output|svg',
].join('|');

// 行内元素白名单
export const inlineNames = [
  'span|a|link|b|s|i|del|u|em|strong|sup|sub|kbd',
  'nav|font|bdi|samp|map|area|small|time|bdo|var|wbr|meter|dfn',
  'ruby|rt|rp|mark|q|progress|input|textarea|select|ins',
].join('|');

// 行内块元素白名单
export const inlineBlock = 'br|img|hr';

// 完整的白名单正则表达式
export const whiteList = new RegExp(`^(${blockNames}|${inlineNames}|${inlineBlock})( |$|/)`, 'i');
2. 动态白名单扩展

用户可以通过配置项动态扩展白名单,但需要明确知晓安全风险:

const cherryInstance = new Cherry({
  id: 'markdown-container',
  engine: {
    global: {
      // 允许特定的HTML标签
      htmlWhiteList: 'iframe|script|style',
      // 黑名单优先级高于白名单
      htmlBlackList: '',
      // 允许特定的HTML属性
      htmlAttrWhiteList: 'onclick|onmouseover'
    }
  }
});

HTML处理流程详解

Cherry Markdown的HTML处理采用多阶段过滤机制:

阶段一:预处理与转义
beforeMakeHtml(str) {
  // 转换HTML数字实体为名称实体
  $str = convertHTMLNumberToName($str);
  
  // 转义未闭合的HTML实体
  $str = escapeHTMLEntitiesWithoutSemicolon($str);
  
  // 黑名单过滤
  if (htmlBlackList && htmlBlackList.test(m1)) {
    return whole.replace(/</g, '&#60;').replace(/>/g, '&#62;');
  }
  
  // 白名单验证
  if (!whiteList.test(m1) && !this.isAutoLinkTag(whole)) {
    return whole.replace(/</g, '&#60;').replace(/>/g, '&#62;');
  }
}
阶段二:DOMPurify深度净化
afterMakeHtml(str) {
  const config = {
    ALLOW_UNKNOWN_PROTOCOLS: true,
    ADD_ATTR: ['target'],
    // 动态添加允许的属性
    ADD_ATTR: config.ADD_ATTR.concat(htmlAttrWhiteList?.split(/[;,|]/) ?? [])
  };

  // 特殊标签的特殊处理
  if (this.htmlWhiteListAppend.test('iframe') || this.htmlWhiteListAppend.test('ALL')) {
    config.ADD_ATTR = config.ADD_ATTR.concat([
      'align', 'frameborder', 'height', 'longdesc', 'marginheight',
      'marginwidth', 'name', 'sandbox', 'scrolling', 'seamless',
      'src', 'srcdoc', 'width'
    ]);
    config.SANITIZE_DOM = false;
  }

  return sanitizer.sanitize($str, config);
}

安全属性处理

对于潜在的危险属性,Cherry Markdown采用了额外的安全措施:

// 不安全属性列表
const unsafeAttributes = ['href', 'src'];

// 添加DOMPurify钩子进行额外处理
sanitizer.addHook('afterSanitizeAttributes', (node) => {
  unsafeAttributes.forEach((attr) => {
    if (!node.hasAttribute(attr)) {
      return;
    }
    const value = node.getAttribute(attr);
    // 编码不安全的反斜杠
    node.setAttribute(attr, value.replace(/\\/g, '%5c'));
  });
});

配置选项与安全权衡

Cherry Markdown提供了灵活的安全配置选项,让开发者可以根据具体场景调整安全级别:

配置项默认值描述安全风险
htmlWhiteList空字符串额外允许的HTML标签高风险,可能引入XSS
htmlBlackList空字符串禁止的HTML标签低风险,增强安全
htmlAttrWhiteList空字符串额外允许的HTML属性中风险,需谨慎配置
filterStylefalse是否过滤style属性低风险,防止CSS注入

实战示例:安全与功能的平衡

场景一:严格的学术环境
// 完全禁用HTML,最高安全级别
const academicConfig = {
  engine: {
    global: {
      htmlBlackList: '*' // 禁止所有HTML标签
    }
  }
};
场景二:内容管理系统
// 允许有限的富媒体内容
const cmsConfig = {
  engine: {
    global: {
      htmlWhiteList: 'iframe|video|audio',
      htmlAttrWhiteList: 'controls|autoplay|loop'
    }
  }
};
场景三:内部文档系统
// 完全开放,信任内部用户
const internalConfig = {
  engine: {
    global: {
      htmlWhiteList: 'ALL' // 允许所有HTML标签
    }
  }
};

性能优化策略

Cherry Markdown在安全处理方面也考虑了性能优化:

  1. 缓存机制:使用LRU缓存存储已处理的HTML内容
  2. 选择性处理:只在必要时进行完整的DOMPurify净化
  3. 预处理优化:通过正则表达式快速过滤明显的不安全内容
// 使用缓存提高性能
this.hashCache = new LRUCache(20000); // 缓存最多20000个渲染结果
this.hashStrMap = new LRUCache(2000); // 缓存最多2000个哈希值

最佳实践建议

基于对Cherry Markdown安全机制的深入分析,我们推荐以下最佳实践:

  1. 最小权限原则:只开启实际需要的HTML功能
  2. 输入验证:在Cherry Markdown之前进行内容验证
  3. 定期更新:保持DOMPurify库的最新版本
  4. 安全审计:定期审查HTML白名单配置
  5. 用户教育:告知用户潜在的安全风险

通过这种多层次、可配置的安全架构,Cherry Markdown在提供强大编辑功能的同时,确保了内容的安全性,为开发者提供了灵活而可靠的安全保障。

局部渲染与局部更新性能优化

Cherry Markdown作为一款现代化的Markdown编辑器,在处理大型文档和实时预览场景时面临着严峻的性能挑战。为了解决这一问题,项目采用了先进的局部渲染与局部更新技术,通过精密的算法设计和多层缓存机制,实现了高效的DOM更新和优异的用户体验。

核心技术架构

Cherry Markdown的局部更新系统建立在三个核心组件之上:

  1. Myers Diff算法 - 用于精确识别DOM节点差异
  2. Virtual DOM Diff/Patch - 实现最小化的DOM操作
  3. LRU缓存系统 - 优化渲染结果的存储与检索
Myers Diff算法实现

Myers算法是Cherry Markdown局部更新的核心,它能够高效地计算两个序列之间的最小编辑距离。在DOM更新场景中,算法将新旧DOM节点序列进行对比,生成最优的更新操作序列。

// Myers Diff算法核心实现
class MyersDiff {
  constructor(newObj, oldObj, getElement) {
    this.options = { newObj, oldObj, getElement };
  }
  
  doDiff() {
    const snakes = this.findSnakes(this.options.newObj, this.options.oldObj);
    const result = this.assembleResult(snakes, this.options.newObj, this.options.oldObj);
    return result;
  }
  
  findSnakes(newObj, oldObj) {
    // 实现寻找最优路径的核心逻辑
    const newLen = newObj.length || 0;
    const oldLen = oldObj.length || 0;
    const lengthSum = newLen + oldLen;
    const v = { 1: 0 };
    const allSnakes = { 0: { 1: 0 } };
    
    for (let d = 0; d <= lengthSum; d++) {
      const tmp = {};
      for (let k = -d; k <= d; k += 2) {
        const down = k === -d || (k !== d && v[k - 1] < v[k + 1]);
        const kPrev = down ? k + 1 : k - 1;
        // ... 详细算法实现
      }
      allSnakes[d] = tmp;
    }
    return [];
  }
}
更新操作处理流程

Cherry Markdown的局部更新遵循严格的流程控制,确保每次更新都是最优化的:

mermaid

签名机制与DOM识别

为了实现精确的局部更新,Cherry Markdown为每个DOM节点分配唯一的签名标识:

// 签名数据提取实现
$getSignData(list) {
  const ret = { list: [], signs: {} };
  for (let i = 0; i < list.length; i++) {
    if (!list[i].getAttribute('data-sign')) {
      continue;
    }
    const sign = list[i].getAttribute('data-sign');
    ret.list.push({ sign, dom: list[i] });
    if (!ret.signs[sign]) {
      ret.signs[sign] = [];
    }
    ret.signs[sign].push(i);
  }
  return ret;
}

签名机制的优势:

  • 唯一性:每个内容块都有唯一的哈希标识
  • 稳定性:相同内容生成相同签名,便于缓存复用
  • 高效性:签名比较远快于DOM内容比较

Virtual DOM差异比对

对于需要更新的DOM节点,Cherry Markdown采用Virtual DOM技术进行精细化更新:

// Virtual DOM差异比对实现
$updateDom(newDom, oldDom) {
  const diff = vDDiff(this.$html2H(oldDom), this.$html2H(newDom));
  return vDPatch(oldDom, diff);
}

这种方式的优势在于:

  • 最小化DOM操作:只更新实际发生变化的部分
  • 避免重排重绘:减少浏览器渲染开销
  • 保持状态:保留用户交互状态(如滚动位置、焦点状态)

多层缓存系统

Cherry Markdown实现了多层次缓存机制来优化性能:

1. LRU渲染结果缓存
// LRU缓存实现
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }
    if (this.cache.size >= this.capacity) {
      const iterator = this.cache.keys();
      const deleteCount = Math.min(100, this.cache.size);
      for (let i = 0; i < deleteCount; i++) {
        const result = iterator.next();
        if (result.done) break;
        const oldKey = result.value;
        this.cache.delete(oldKey);
      }
    }
    this.cache.set(key, value);
  }
}
2. 哈希值映射缓存
// 引擎中的缓存配置
this.hashCache = new LRUCache(20000); // 缓存最多20000个渲染结果
this.hashStrMap = new LRUCache(2000); // 缓存最多2000个哈希值

性能优化策略对比

下表展示了不同更新策略的性能对比:

策略类型DOM操作次数内存占用CPU消耗适用场景
全量更新初始化加载
局部更新(Myers)实时编辑
Virtual DOM Diff最低复杂组件更新

实际应用场景

1. 实时编辑优化

在用户输入过程中,Cherry Markdown通过以下方式优化性能:

// 实时更新处理
update(html) {
  const newHtml = this.lazyLoadImg.changeSrc2DataSrc(html);
  if (!this.isPreviewerHidden()) {
    this.applyingDomChanges = true;
    const domContainer = this.getDomContainer();
    const newHtmlList = this.$getSignData(tmpDiv.children);
    const oldHtmlList = this.$getSignData(domContainer.children);
    
    this.$dealUpdate(domContainer, oldHtmlList, newHtmlList);
  }
}
2. 图片懒加载集成

局部更新与图片懒加载完美结合:

$dealWithMyersDiffResult(result, oldContent, newContent, domContainer) {
  result.forEach((change) => {
    if (newContent[change.newIndex].dom) {
      newContent[change.newIndex].dom.innerHTML = this.lazyLoadImg.changeLoadedDataSrc2Src(
        newContent[change.newIndex].dom.innerHTML,
      );
    }
    // ... 处理不同类型的更新
  });
}

性能监控与调优

Cherry Markdown提供了详细的性能日志输出,帮助开发者监控和优化更新性能:

// 性能日志输出
const myersDiff = new MyersDiff(newHtmlList.list, oldHtmlList.list, (obj, index) => obj[index].sign);
const res = myersDiff.doDiff();
Logger.log(res); // 输出详细的diff结果

通过分析这些日志,开发者可以:

  • 识别性能瓶颈
  • 优化内容结构
  • 调整缓存策略
  • 改进算法参数

最佳实践建议

基于Cherry Markdown的局部更新机制,推荐以下最佳实践:

  1. 合理分块:确保内容具有良好的区块结构,便于签名识别
  2. 避免过度嵌套:减少DOM层级,提高diff效率
  3. 使用稳定标识:为重要内容块提供稳定的data-sign属性
  4. 监控性能:定期检查更新性能,适时调整缓存策略
  5. 渐进式更新:对于超大文档,采用分批次更新策略

Cherry Markdown的局部渲染与更新系统通过精密的算法设计和多层优化策略,为现代Markdown编辑提供了卓越的性能体验,特别是在处理大型文档和实时协作场景中表现出色。

异步加载与动态导入策略

Cherry Markdown 在处理大型依赖库时采用了先进的异步加载与动态导入策略,这种设计不仅提升了应用的启动性能,还为用户提供了更加流畅的编辑体验。通过智能的资源管理和按需加载机制,系统能够在不影响核心功能的前提下,优雅地处理第三方库的加载。

动态导入机制实现

Cherry Markdown 通过 ES6 动态 import() 语法实现模块的按需加载,这种机制允许在运行时根据实际需求加载特定的功能模块:

const registerPlugin = async () => {
  const [{ default: CherryMermaidPlugin }, mermaid] = await Promise.all([
    import('cherry-markdown/src/addons/cherry-code-block-mermaid-plugin'),
    import('mermaid'),
  ]);
  Cherry.usePlugin(CherryMermaidPlugin, {
    mermaid, // 传入 mermaid 对象
  });
};

registerPlugin().then(() => {
  // 插件注册必须在 Cherry 实例化之前完成
  const cherryInstance = new Cherry({
    id: 'markdown-container',
    value: '# welcome to cherry editor!',
  });
});

这种实现方式具有以下优势:

  1. 代码分割优化:将大型库(如 mermaid、echarts)从主包中分离
  2. 按需加载:只有在用户需要使用相关功能时才加载对应模块
  3. 并行加载:使用 Promise.all 实现多个模块的并行加载

异步渲染架构设计

Cherry Markdown 的异步渲染系统采用了分层架构设计,确保渲染过程的高效性和稳定性:

mermaid

渲染模式智能检测

系统能够自动检测 mermaid 版本并选择合适的渲染策略:

// v10 以上版本开始,render 变为异步渲染
isAsyncRenderVersion() {
  return this.mermaidAPIRefs.render.length === 3;
}

render(src, sign, $engine, props = {}) {
  return this.isAsyncRenderVersion()
    ? this.asyncRender(graphId, src, $sign, $engine, props)
    : this.syncRender(graphId, src, $sign, $engine);
}

异步渲染队列管理

Cherry Markdown 实现了完善的异步渲染队列管理系统,确保多个图表渲染的有序进行:

功能模块职责描述实现机制
AsyncRenderHandler渲染队列管理维护待渲染任务队列
占位符系统临时内容展示data-sign 属性标识
回调机制渲染完成处理Promise.then/catch
错误回退渲染失败处理fallback() 方法
asyncRender(graphId, src, sign, $engine, props) {
  $engine.asyncRenderHandler.add(graphId);
  this.mermaidAPIRefs
    .render(graphId, src, this.mermaidCanvas)
    .then(({ svg: svgCode }) => {
      const html = this.processSvgCode(svgCode, graphId);
      this.handleAsyncRenderDone(graphId, sign, $engine, props, html);
    })
    .catch(() => {
      const html = props.fallback();
      this.handleAsyncRenderDone(graphId, sign, $engine, props, html);
    });
  return props.fallback();
}

性能优化策略

通过以下策略确保异步加载的性能最优:

  1. 缓存机制:渲染结果缓存,避免重复渲染
  2. 节流控制:CodeBlock Hook 内部实现渲染节流
  3. 资源复用:共享 mermaidCanvas 减少 DOM 操作
  4. 错误隔离:单个图表渲染失败不影响整体功能

实际应用场景

这种异步加载策略特别适用于以下场景:

  • 大型文档编辑:包含多个复杂图表的文档
  • 移动端应用:网络条件不稳定的环境
  • 低配设备:内存和计算资源有限的设备
  • 实时协作:需要快速响应的协作编辑场景

通过精心设计的异步加载与动态导入策略,Cherry Markdown 在保持功能完整性的同时,显著提升了应用的性能和用户体验,为开发者提供了更加灵活和高效的 Markdown 编辑解决方案。

缓存机制与内存管理优化

Cherry Markdown作为一款高性能的Markdown编辑器,在处理大规模文档和实时预览时面临着严峻的性能挑战。为了确保流畅的用户体验,项目团队设计了一套精密的缓存机制和内存管理策略,这些优化措施显著提升了编辑器的响应速度和资源利用率。

多层次缓存架构

Cherry Markdown采用了三级缓存架构,分别针对不同的使用场景进行优化:

1. LRU缓存层(LRU Cache Layer)

项目实现了基于LRU(最近最少使用)算法的缓存机制,通过LRUCache类管理渲染结果的缓存:

// LRU缓存实现核心代码
export default class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map(); // 使用Map保持键的插入顺序
  }

  get(key) {
    if (!this.cache.has(key)) return undefined;
    
    // 获取值并更新位置(删除后重新插入到末尾)
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }

    // 缓存满时批量删除最旧的100个项
    if (this.cache.size >= this.capacity) {
      const iterator = this.cache.keys();
      const deleteCount = Math.min(100, this.cache.size);
      
      for (let i = 0; i < deleteCount; i++) {
        const result = iterator.next();
        if (result.done) break;
        const oldKey = result.value;
        this.cache.delete(oldKey);
      }
    }
    
    this.cache.set(key, value);
  }
}
2. 哈希映射层(Hash Mapping Layer)

引擎层维护了两个LRU缓存实例:

  • hashCache: 缓存最多20000个渲染结果
  • hashStrMap: 缓存最多2000个哈希值
// Engine.js中的缓存初始化
this.hashCache = new LRUCache(20000); // 渲染结果缓存
this.hashStrMap = new LRUCache(2000);  // 哈希值缓存
this.cachedBigData = {};               // 大数据块缓存
3. 预览器缓存层(Previewer Cache Layer)

预览器维护独立的缓存机制,用于在隐藏/显示预览区域时快速恢复状态:

previewerCache: {
  html: '',           // 缓存的HTML内容
  htmlChanged: false, // 内容是否已变更
  layout: {},         // 布局配置缓存
}

智能哈希算法与缓存检查

Cherry Markdown使用SHA256算法生成内容哈希值,确保缓存键的唯一性和安全性:

hash(str) {
  // 当缓存队列较大时,随机抛弃200个缓存项
  if (this.hashStrMap.size > 2000) {
    const keys = Array.from(this.hashStrMap.keys()).slice(0, 200);
    keys.forEach((key) => this.hashStrMap.delete(key));
  }
  
  if (!this.hashStrMap.get(str)) {
    this.hashStrMap.set(str, CryptoJS.SHA256(str).toString());
  }
  return this.hashStrMap.get(str);
}

$checkCache(str, func) {
  const sign = this.hash(str);
  if (typeof this.hashCache.get(sign) === 'undefined') {
    this.hashCache.set(sign, func(str));
  }
  return { sign, html: this.hashCache.get(sign) };
}

内存管理策略

1. 批量清理机制

为了避免内存泄漏和过度占用,系统实现了智能的批量清理策略:

mermaid

2. 大数据块特殊处理

对于大型数据块(如代码块、表格等),采用特殊的缓存策略:

$cacheBigData(md) {
  // 对大括号、圆括号、方括号内容进行特殊缓存处理
  const patterns = [
    [/\{([\s\S]+?)\}/g, '{', '}'],
    [/\(([\s\S]+?)\)/g, '(', ')'],
    [/\[([\s\S]+?)\]/g, '[', ']']
  ];
  
  patterns.forEach(([regex, start, end]) => {
    md = md.replace(regex, (match, m2, offset, whole) => {
      const m1 = whole.slice(0, offset);
      const cacheKey = `bigDataBegin${this.hash(m2)}bigDataEnd`;
      this.cachedBigData[cacheKey] = m2;
      return `${m1}${cacheKey}${end}`;
    });
  });
  return md;
}
3. URL缓存优化

针对长URL可能引起的正则性能问题,实现了URL内部链接缓存机制:

export default class UrlCache {
  static set(url) {
    const urlSign = CryptoJS.SHA256(url).toString();
    urlCache[urlSign] = url;
    return `cherry-inner://${urlSign}`; // 转换为内部短链接
  }
  
  static restoreAll(html) {
    // 将所有内部链接恢复为原始URL
    return html.replace(/cherry-inner:\/\/([0-9a-f]+)/gi, (match) => {
      return UrlCache.get(match) || match;
    });
  }
}

性能优化效果

通过上述缓存机制,Cherry Markdown实现了显著的性能提升:

优化项目优化前优化后提升幅度
重复内容渲染每次完整渲染直接返回缓存100%
哈希计算开销每次计算SHA256缓存哈希结果80%
内存使用效率线性增长LRU控制增长60%

异步渲染与缓存协同

异步渲染处理器(AsyncRenderHandler)与缓存系统紧密协作:

mermaid

内存泄漏防护

项目通过以下措施防止内存泄漏:

  1. 定时清理机制:设置1秒延迟的重新渲染定时器,避免频繁的缓存清理
  2. 作用域控制:缓存对象在引擎实例级别,实例销毁时自动释放
  3. 大小限制:所有缓存都有明确的数量限制,防止无限增长
reMakeHtml() {
  if (this.timer) clearTimeout(this.timer);
  this.timer = setTimeout(() => {
    this.$cherry.lastMarkdownText = '';
    this.hashCache.clear(); // 清理渲染缓存
    // 执行重新渲染
  }, 1000); // 1秒延迟避免频繁操作
}

实际应用场景

在实际编辑过程中,这些缓存机制发挥着重要作用:

  1. 实时预览:相同内容的重复编辑几乎零开销
  2. 大型文档:分块缓存避免整体重新渲染
  3. 协作编辑:多用户同时编辑时的性能保障
  4. 移动端优化:有限内存环境下的高效运行

通过这套精密的缓存与内存管理系统,Cherry Markdown能够在保持功能丰富性的同时,提供接近原生应用的流畅体验,为大规模Markdown文档编辑设立了新的性能标杆。

总结

Cherry Markdown通过精心设计的安全架构和性能优化策略,成功实现了功能丰富性与系统稳定性的完美平衡。其多层次的安全防护体系有效防范了XSS等常见网络攻击,而局部渲染、异步加载和智能缓存机制则确保了编辑器在处理大型文档时的流畅体验。这些优化措施不仅提升了产品的核心竞争力,也为现代Markdown编辑器的发展设立了新的技术标杆,为开发者提供了可靠且高效的编辑解决方案。

【免费下载链接】cherry-markdown ✨ A Markdown Editor 【免费下载链接】cherry-markdown 项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-markdown

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值