Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,973 changes: 315 additions & 1,658 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,17 +188,28 @@
"dependencies": {
"@bugsplat/elfy": "^1.0.1",
"@vue/shared": "^3.x.x",
"@neilsustc/markdown-it-katex": "^1.0.0",
"element-plus": "^2.4.2",
"elfjs": "^2.2.0",
"elfy": "^1.0.0",
"marked": "^14.1.3",
"highlight.js": "^11.10.0",
"katex": "^0.16.11",
"markdown-it": "^13.0.2",
"markdown-it-deflist": "^2.1.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-github-alerts": "^0.1.2",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"markdown-it-task-lists": "^2.1.1",
"vue": "^3.3.8",
"vue-router": "^4.5.0"
},
"extensionDependencies": [
"ms-python.python"
],
"devDependencies": {
"@types/markdown-it": "^14.0.1",
"@types/mocha": "^10.0.7",
"@types/node": "^20.19.11",
"@types/vscode": "^1.96.0",
Expand Down
3 changes: 3 additions & 0 deletions src/markdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { MarkdownRenderer, markdownRenderer } from './renderer';
export type { MarkdownRenderOptions } from './renderer';
export { default as SlugifyMode } from './slugifyMode';
149 changes: 149 additions & 0 deletions src/markdown/renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import MarkdownIt from 'markdown-it';
import markdownItTaskLists from 'markdown-it-task-lists';
import markdownItGithubAlerts from 'markdown-it-github-alerts';
import markdownItKatex from '@neilsustc/markdown-it-katex';
import markdownItFootnote from 'markdown-it-footnote';
import markdownItDeflist from 'markdown-it-deflist';
import * as markdownItEmoji from 'markdown-it-emoji';
import markdownItSub from 'markdown-it-sub';
import markdownItSup from 'markdown-it-sup';
import hljs from 'highlight.js';
import { slugifyHeading } from './slugify';
import SlugifyMode from './slugifyMode';

export interface MarkdownRenderOptions {
breaks?: boolean;
linkify?: boolean;
enableMath?: boolean;
slugifyMode?: SlugifyMode;
mathMacros?: Record<string, string>;
}

interface MathOptions {
throwOnError?: boolean;
macros?: Record<string, string>;
}

interface EngineRecord {
md: MarkdownIt;
slugCount: Map<string, number>;
}

export class MarkdownRenderer {
private engines = new Map<string, EngineRecord>();

public render(text: string, options: MarkdownRenderOptions = {}): string {
const enableMath = options.enableMath !== false;
const slugifyMode = options.slugifyMode ?? SlugifyMode.GitHub;
const key = this.composeEngineKey(enableMath, slugifyMode, options.mathMacros);
const engineRecord = this.ensureEngine(key, enableMath, slugifyMode, options.mathMacros);

engineRecord.slugCount.clear();

engineRecord.md.set({
breaks: options.breaks === true,
linkify: options.linkify !== false,
});

const env = Object.create(null);
return engineRecord.md.render(text, env);
}

private composeEngineKey(enableMath: boolean, slugifyMode: SlugifyMode, macros?: Record<string, string>): string {
const macroEntries = macros ? Object.keys(macros).sort().map((k) => `${k}:${macros[k]}`) : [];
return `${enableMath ? 'math' : 'nomath'}|${slugifyMode}|${macroEntries.join(',')}`;
}

private ensureEngine(key: string, enableMath: boolean, slugifyMode: SlugifyMode, macros?: Record<string, string>): EngineRecord {
let record = this.engines.get(key);
if (!record) {
const slugCount = new Map<string, number>();
const md = this.createEngine(enableMath, slugCount, slugifyMode, macros);
record = { md, slugCount };
this.engines.set(key, record);
}
return record;
}

private createEngine(enableMath: boolean, slugCount: Map<string, number>, slugifyMode: SlugifyMode, macros?: Record<string, string>): MarkdownIt {
const md = new MarkdownIt({
html: true,
highlight: (str: string, lang?: string) => {
if (lang) {
const normalized = this.normalizeHighlightLang(lang);
if (normalized && hljs.getLanguage(normalized)) {
try {
return hljs.highlight(str, { language: normalized, ignoreIllegals: true }).value;
} catch {
// ignore highlighting errors and fallback to default rendering
}
}
}
return '';
}
});

md.use(markdownItTaskLists, { enabled: true, label: true, labelAfter: false });
md.use(markdownItGithubAlerts, { matchCaseSensitive: false });
md.use(markdownItFootnote);
md.use(markdownItDeflist);
md.use(markdownItEmoji.full || markdownItEmoji);
md.use(markdownItSub);
md.use(markdownItSup);

if (enableMath) {
require('katex/contrib/mhchem');
const katexOptions: MathOptions = { throwOnError: false };
if (macros && Object.keys(macros).length > 0) {
katexOptions.macros = JSON.parse(JSON.stringify(macros));
}
md.use(markdownItKatex, katexOptions);
}

this.addNamedHeaders(md, slugCount, slugifyMode);
return md;
}

private addNamedHeaders(md: MarkdownIt, slugCount: Map<string, number>, slugifyMode: SlugifyMode): void {
const originalHeadingOpen = md.renderer.rules.heading_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));

md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
const raw = tokens[idx + 1]?.content ?? '';
const baseSlug = slugifyHeading(raw, env ?? Object.create(null), slugifyMode) || 'section';
const finalSlug = this.resolveUniqueSlug(slugCount, baseSlug);
tokens[idx].attrs = [...(tokens[idx].attrs || []), ['id', finalSlug]];

return originalHeadingOpen(tokens, idx, options, env, self);
};
}

private resolveUniqueSlug(slugCount: Map<string, number>, base: string): string {
const previous = slugCount.get(base);
if (previous === undefined) {
slugCount.set(base, 0);
return base;
}

const next = previous + 1;
slugCount.set(base, next);
return `${base}-${next}`;
}

private normalizeHighlightLang(lang: string): string {
switch (lang.toLowerCase()) {
case 'tsx':
case 'typescriptreact':
return 'jsx';
case 'json5':
case 'jsonc':
return 'json';
case 'c#':
case 'csharp':
return 'cs';
default:
return lang.toLowerCase();
}
}
}

export const markdownRenderer = new MarkdownRenderer();
97 changes: 97 additions & 0 deletions src/markdown/slugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import MarkdownIt from 'markdown-it';
import SlugifyMode from './slugifyMode';

const inlineEngine = new MarkdownIt('commonmark');
const utf8Encoder = new TextEncoder();

const Regexp_Github_Punctuation = /[^\p{L}\p{M}\p{Nd}\p{Nl}\p{Pc}\- ]/gu;
const Regexp_Gitlab_Product_Suffix = /[ \t\r\n\f\v]*\**\((?:core|starter|premium|ultimate)(?:[ \t\r\n\f\v]+only)?\)\**/g;

function mdInlineToPlainText(text: string, env: object): string {
const inlineTokens = inlineEngine.parseInline(text, env)[0]?.children ?? [];
return inlineTokens.reduce<string>((result, token) => {
switch (token.type) {
case 'image':
case 'html_inline':
return result;
default:
return result + token.content;
}
}, '');
}

const slugifyHandlers: Record<SlugifyMode, (rawContent: string, env: object) => string> = {
[SlugifyMode.AzureDevOps]: (slug: string): string => {
slug = slug.trim().toLowerCase().replace(/\p{Zs}/gu, '-');
if (/^\d/.test(slug)) {
slug = Array.from(utf8Encoder.encode(slug), (b) => `%${b.toString(16)}`).join('').toUpperCase();
} else {
slug = encodeURIComponent(slug);
}
return slug;
},

[SlugifyMode.BitbucketCloud]: (slug: string, env: object): string => {
return 'markdown-header-' + slugifyHandlers[SlugifyMode.GitHub](slug, env).replace(/-+/g, '-');
},

[SlugifyMode.Gitea]: (slug: string): string => {
return slug
.replace(/^[^\p{L}\p{N}]+/u, '')
.replace(/[^\p{L}\p{N}]+$/u, '')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.toLowerCase();
},

[SlugifyMode.GitHub]: (slug: string, env: object): string => {
slug = mdInlineToPlainText(slug, env)
.replace(Regexp_Github_Punctuation, '')
.toLowerCase()
.replace(/ /g, '-');
return slug;
},

[SlugifyMode.GitLab]: (slug: string, env: object): string => {
slug = mdInlineToPlainText(slug, env)
.replace(/^[ \t\r\n\f\v]+/, '')
.replace(/[ \t\r\n\f\v]+$/, '')
.toLowerCase()
.replace(Regexp_Gitlab_Product_Suffix, '')
.replace(Regexp_Github_Punctuation, '')
.replace(/ /g, '-')
.replace(/-+/g, '-');
if (/^(\d+)$/.test(slug)) {
slug = `anchor-${slug}`;
}
return slug;
},

[SlugifyMode.VisualStudioCode]: (rawContent: string, env: object): string => {
const plain = inlineEngine.parseInline(rawContent, env)[0]?.children?.reduce<string>((result, token) => result + token.content, '') ?? '';
return encodeURI(
plain
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[\]\[\!\'\#\$\%\&\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\_\{\|\}\~\`。,、;:?!…—·ˉ¨‘’“”々~‖∶"'`|〃〔〕〈〉《》「」『』.〖〗【】()[]{}]/g, '')
.replace(/^\-+/, '')
.replace(/\-+$/, '')
);
},

[SlugifyMode.Zola]: (rawContent: string, env: object): string => {
// 退化为 GitHub 行为;如需完整兼容,可后续引入 zola-slug wasm 实现。
return slugifyHandlers[SlugifyMode.GitHub](rawContent, env);
},
};

export function slugifyHeading(raw: string, env: object, mode: SlugifyMode): string {
const handler = slugifyHandlers[mode] ?? slugifyHandlers[SlugifyMode.GitHub];
let slug = handler(raw, env);
slug = slug
.replace(/\s+/g, '-')
.replace(/\u0000/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return slug;
}
11 changes: 11 additions & 0 deletions src/markdown/slugifyMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const enum SlugifyMode {
AzureDevOps = 'azureDevops',
BitbucketCloud = 'bitbucket-cloud',
Gitea = 'gitea',
GitHub = 'github',
GitLab = 'gitlab',
VisualStudioCode = 'vscode',
Zola = 'zola',
}

export default SlugifyMode;
8 changes: 8 additions & 0 deletions src/types/markdown-it.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
declare module 'markdown-it-task-lists';
declare module 'markdown-it-github-alerts';
declare module '@neilsustc/markdown-it-katex';
declare module 'markdown-it-footnote';
declare module 'markdown-it-deflist';
declare module 'markdown-it-emoji';
declare module 'markdown-it-sub';
declare module 'markdown-it-sup';
7 changes: 4 additions & 3 deletions src/vue/about/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<Banner sub-title="关于" />

<div class="rt-page__content content_area">
<div v-html="readmeMarkdown"></div>
<div class="markdown-body" v-html="readmeMarkdown"></div>

<el-button type="primary" @click="openRTThreadGitHub">Open RT-Thread/Github</el-button>
</div>
Expand All @@ -13,10 +13,11 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useTheme } from '../composables/useTheme';
import { imgUrl } from '../assets/img';
import { sendCommand } from '../api/vscode';
import { extensionInfo } from '../setting/data';
import Banner from '../components/Banner.vue';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
import '../assets/markdown.css';

let readmeMarkdown = ref('');

Expand Down
Loading