实战:Markdown 生产力套件
把语言服务、Webview、命令、诊断四类能力注入 Markdown 编辑器:打字时自动补全表格和代码块语言、快捷键一键加粗/斜体/插链接、标题层级越级即时提示,右侧实时预览渲染效果。
目标
- 实时预览:markdown-it 渲染,自定义编辑器分栏展示
- 语法补全:表格、链接、代码块模板自动完成
- 代码块语言:输入 ``` 后提示语言标识
- 快捷键命令:加粗、斜体、插入链接
- 标题诊断:跳级标题(如
##直接跳到####)标黄提示
项目结构
markdown-suite/
├── package.json
├── tsconfig.json
├── src/
│ ├── extension.ts # 激活入口:注册全部能力
│ ├── previewProvider.ts # markdown-it 实时预览(自定义编辑器)
│ ├── completion.ts # 语法补全 + 代码块语言识别
│ ├── commands.ts # 加粗/斜体/链接命令
│ └── diagnostics.ts # 标题层级检查package.json
{
"name": "markdown-suite",
"displayName": "Markdown Suite",
"description": "Markdown 增强写作:实时预览、语法补全、快捷键、标题诊断",
"version": "1.0.0",
"publisher": "mypublisher",
"engines": { "vscode": "^1.80.0" },
"categories": ["Other"],
"main": "./out/extension.js",
"activationEvents": [
"onLanguage:markdown",
"onCommand:markdownSuite.bold",
"onCommand:markdownSuite.italic",
"onCommand:markdownSuite.link"
],
"contributes": {
"commands": [
{ "command": "markdownSuite.bold", "title": "Markdown: 加粗选区" },
{ "command": "markdownSuite.italic", "title": "Markdown: 斜体选区" },
{ "command": "markdownSuite.link", "title": "Markdown: 插入链接" }
],
"keybindings": [
{ "command": "markdownSuite.bold", "key": "ctrl+b", "when": "editorLangId == markdown && editorTextFocus" },
{ "command": "markdownSuite.italic", "key": "ctrl+i", "when": "editorLangId == markdown && editorTextFocus" },
{ "command": "markdownSuite.link", "key": "ctrl+shift+l", "when": "editorLangId == markdown && editorTextFocus" }
],
"customEditors": [
{
"viewType": "markdownSuite.preview",
"displayName": "Markdown 分栏预览",
"selector": [{ "filenamePattern": "*.md" }],
"priority": "option"
}
]
},
"scripts": { "compile": "tsc -p ./" },
"dependencies": {
"markdown-it": "^14.0.0"
},
"devDependencies": {
"@types/vscode": "^1.80.0",
"@types/markdown-it": "^14.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}ctrl+b / ctrl+i 在纯 Markdown 编辑器里默认是粗体/斜体命令(VS Code 内置),这里用更高优先级的 when: editorLangId == markdown 覆盖为自定义实现,可叠加自己的逻辑(如无选区时自动包裹单词)。
previewProvider.ts:markdown-it 实时预览
自定义编辑器右侧渲染:文档变更即重新渲染 HTML 推给 Webview。markdown-it 是 CommonJS 模块,在 Node 侧直接调用:
import * as vscode from 'vscode';
import MarkdownIt from 'markdown-it';
// 全局单例:渲染器可安全复用
const md = new MarkdownIt({
html: true, // 允许 Markdown 内嵌 HTML
linkify: true, // 自动识别裸链接
typographer: true // 中英文标点优化
});
export class MarkdownPreviewProvider implements vscode.CustomTextEditorProvider {
async resolveCustomTextEditor(
document: vscode.TextDocument,
panel: vscode.WebviewPanel
): Promise<void> {
panel.webview.options = { enableScripts: true };
// 初始渲染
panel.webview.html = this.renderHtml(document.getText());
// 文档变更 → 重新渲染(可加防抖,此处简化)
const sub = vscode.workspace.onDidChangeTextDocument((e) => {
if (e.document.uri.toString() === document.uri.toString()) {
panel.webview.html = this.renderHtml(e.document.getText());
}
});
panel.onDidDispose(() => sub.dispose());
}
private renderHtml(markdown: string): string {
// 1. markdown-it 渲染正文
const body = md.render(markdown);
// 2. 包一层带主题适配的 HTML
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline';">
<style>
body { font-family: var(--vscode-font-family); padding: 0 24px; }
pre {
background: var(--vscode-textBlockQuote-background);
padding: 12px; border-radius: 6px; overflow-x: auto;
}
code { font-family: var(--vscode-editor-font-family); }
blockquote { border-left: 3px solid var(--vscode-panel-border); margin-left: 0; padding-left: 12px; color: var(--vscode-descriptionForeground); }
table { border-collapse: collapse; }
th, td { border: 1px solid var(--vscode-panel-border); padding: 4px 8px; }
img { max-width: 100%; }
</style>
</head>
<body>${body}</body>
</html>`;
}
}completion.ts:语法补全与代码块语言识别
两个 CompletionItemProvider 分工:一个提供通用语法模板(表格、链接、图片、代码块),一个在代码块首行提供语言标识补全:
import * as vscode from 'vscode';
// ---------- 通用语法模板补全 ----------
const templates: Array<{
label: string; detail: string; insert: string;
}> = [
{
label: 'table',
detail: '插入 Markdown 表格',
insert: '| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 内容 | 内容 | 内容 |'
},
{
label: 'link',
detail: '插入链接 [text](url)',
insert: '[链接文字](https://example.com)'
},
{
label: 'image',
detail: '插入图片 ',
insert: ''
},
{
label: 'code-fence',
detail: '插入代码块',
insert: '```typescript\n\n```'
}
];
export const syntaxCompletion: vscode.CompletionItemProvider = {
provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position
): vscode.CompletionItem[] {
// 行首空白后触发,避免干扰正常单词
const lineText = document.lineAt(position).text;
if (!/^\s*$/.test(lineText.slice(0, position.character))) {
return [];
}
return templates.map((t) => {
const item = new vscode.CompletionItem(t.label, vscode.CompletionItemKind.Snippet);
item.detail = t.detail;
// 用 SnippetString 支持占位符与跳转
item.insertText = new vscode.SnippetString(t.insert);
return item;
});
}
};
// ---------- 代码块语言补全 ----------
// 常见语言列表(演示节选,实际可读取 languages.getLanguages())
const LANGUAGES = [
'typescript', 'javascript', 'json', 'jsonc', 'html', 'css',
'markdown', 'yaml', 'xml', 'python', 'java', 'go', 'rust',
'cpp', 'c', 'csharp', 'shell', 'sql', 'diff', 'bash'
];
export const languageCompletion: vscode.CompletionItemProvider = {
provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position
): vscode.CompletionItem[] {
// 检查光标前是否为代码块起始行 ```
const line = document.lineAt(position.line).text;
if (!/^\s*```\s*$/.test(line)) {
return [];
}
return LANGUAGES.map((lang) => {
const item = new vscode.CompletionItem(lang, vscode.CompletionItemKind.Value);
// 补全后光标跳到行尾,方便直接换行写代码
item.insertText = lang;
return item;
});
}
};CompletionItemKind.Snippet 与 SnippetString 组合是模板补全的标准姿势:$1、$2 占位符可让用户在补全后用 Tab 依次跳转填写。
commands.ts:加粗 / 斜体 / 链接
三个命令共用同一套「包裹选区」逻辑:选中文字被 ** 包裹;无选区时把光标所在单词包起来并把光标放进包裹符之间:
import * as vscode from 'vscode';
/**
* 通用包裹命令:在选区前后各加 wrap,光标智能定位。
* 支持多选区,逐个处理。
*/
function wrapSelection(
editor: vscode.TextEditor,
wrap: string,
placeCursorInside: boolean
): void {
const edits: Array<{ range: vscode.Range; newText: string }> = [];
for (const selection of editor.selections) {
const text = editor.document.getText(selection);
const range = selection.isEmpty
// 无选区:扩展为光标所在单词
? editor.document.getWordRangeAtPosition(selection.active) ?? selection
: selection;
const word = editor.document.getText(range);
// 已包裹则去除(切换语义),未包裹则加上
const isWrapped = word.startsWith(wrap) && word.endsWith(wrap) && word.length > wrap.length * 2;
let newText: string;
let cursor: vscode.Position;
if (isWrapped) {
newText = word.slice(wrap.length, word.length - wrap.length);
cursor = range.start.translate(0, Math.floor(newText.length / 2));
} else {
newText = wrap + word + wrap;
cursor = placeCursorInside
? range.start.translate(0, wrap.length)
: range.end.translate(0, wrap.length);
}
edits.push({ range, newText });
// 记录新光标(单选区场景使用)
if (editor.selections.length === 1) {
editor.selection = new vscode.Selection(cursor, cursor);
}
}
// 一次 edit 应用所有修改,保留撤销栈
editor.edit((builder) => {
for (const e of edits) {
builder.replace(e.range, e.newText);
}
});
}
export function registerMarkdownCommands(context: vscode.ExtensionContext): void {
// 加粗:**text**
context.subscriptions.push(
vscode.commands.registerTextEditorCommand('markdownSuite.bold', (editor) => {
wrapSelection(editor, '**', true);
})
);
// 斜体:*text*
context.subscriptions.push(
vscode.commands.registerTextEditorCommand('markdownSuite.italic', (editor) => {
wrapSelection(editor, '*', true);
})
);
// 插入链接:[text](url),URL 通过输入框获取
context.subscriptions.push(
vscode.commands.registerTextEditorCommand('markdownSuite.link', async (editor) => {
const text = editor.document.getText(editor.selection) || '链接文字';
const url = await vscode.window.showInputBox({
prompt: '输入链接地址',
value: 'https://'
});
if (url === undefined) return;
await editor.edit((builder) => {
builder.replace(editor.selection, `[${text}](${url})`);
});
})
);
}registerTextEditorCommand 比 registerCommand 多一个优势:它在没有活动编辑器时直接不触发,适合「只对编辑器生效」的命令。
diagnostics.ts:标题层级检查
解析全文标题,用 DiagnosticCollection 标记跳级问题:## 直接跟 #### 属于跳级(缺 ###),按「上一个标题的层级 +1」判断:
import * as vscode from 'vscode';
const collection = vscode.languages.createDiagnosticCollection('markdownSuite');
export function checkHeadings(document: vscode.TextDocument): void {
if (document.languageId !== 'markdown') {
return;
}
const diagnostics: vscode.Diagnostic[] = [];
// 期望的下一级标题层级:h1 → 2,h2 → 3,以此类推
let expectedLevel = 1;
for (let i = 0; i < document.lineCount; i++) {
const line = document.lineAt(i);
const match = line.text.match(/^(#{1,6})\s+/);
if (!match) continue;
const level = match[1].length;
if (level > expectedLevel) {
// 跳级:比如 expected 3 却出现 ####(4 级)
const range = new vscode.Range(i, 0, i, match[1].length);
const diag = new vscode.Diagnostic(
range,
`标题层级跳级:当前 ${level} 级,期望不高于 ${expectedLevel} 级(上一个标题的下一级)`,
vscode.DiagnosticSeverity.Warning
);
diagnostics.push(diag);
}
// 更新期望:同级或降级后,下一个标题应从「本级 +1」开始
expectedLevel = level + 1;
}
collection.set(document.uri, diagnostics);
}
export function activateDiagnostics(context: vscode.ExtensionContext): void {
// 打开与修改时都检查
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(checkHeadings),
vscode.workspace.onDidChangeTextDocument((e) => checkHeadings(e.document)),
collection
);
// 已有文档补检
vscode.workspace.textDocuments.forEach(checkHeadings);
}extension.ts:装配
import * as vscode from 'vscode';
import { MarkdownPreviewProvider } from './previewProvider';
import { syntaxCompletion, languageCompletion } from './completion';
import { registerMarkdownCommands } from './commands';
import { activateDiagnostics } from './diagnostics';
export function activate(context: vscode.ExtensionContext) {
// 1. 实时预览(自定义编辑器)
context.subscriptions.push(
vscode.window.registerCustomEditorProvider(
'markdownSuite.preview',
new MarkdownPreviewProvider()
)
);
// 2. 语法模板补全(仅 markdown 语言)
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
{ language: 'markdown' },
syntaxCompletion
)
);
// 3. 代码块语言补全:同一 provider 挂两套触发字符
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
{ language: 'markdown' },
languageCompletion,
'`' // 输入反引号时立即触发
)
);
// 4. 快捷键命令
registerMarkdownCommands(context);
// 5. 标题层级诊断
activateDiagnostics(context);
}
export function deactivate() {}运行与验证
按 F5 启动调试:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 打开 .md 文件 → 右键打开方式 → Markdown 分栏预览 | 右侧渲染出标题/表格/代码块 |
| 2 | 修改文档 | 预览即时更新 |
| 3 | 行首输入 table 回车补全 | 插入三列表格模板 |
| 4 | 输入 ``` 触发补全 | 弹出语言列表,选 typescript |
| 5 | 选中文字按 Ctrl+B | 文字被 ** 包裹,再按一次取消 |
| 6 | 无选区按 Ctrl+B | 光标所在单词被包裹 |
| 7 | 写 ## a 后直接写 #### b | #### 行出现黄色波浪线提示跳级 |
| 8 | 修复为 ### b | 警告消失 |
常见问题
| 问题 | 处理 |
|---|---|
| Ctrl+B 被内置命令抢占 | 检查 keybinding 的 when 是否写全 editorLangId == markdown |
| 预览不更新 | onDidChangeTextDocument 里比对 uri,避免其它文档误触发 |
| 补全不出现 | 确认 activationEvents 包含 onLanguage:markdown,或改用 onStartupFinished |
| 表格渲染错位 | 开启 html: true 后注意 XSS:预览内容只来自本地文档,风险可控 |
| 跳级误报 | 标题紧挨 # 一级 后写 ## 二级 是合法的,检查 expectedLevel 计算 |
本实战把语言侧能力(补全、诊断)与 UI 侧能力(预览、命令)组合成一套完整的写作增强插件;模板补全与诊断逻辑都可以推广到其他标记语言,比如 reStructuredText 或 AsciiDoc。