Skip to content

Commit e3560de

Browse files
committed
feat(markdown): 引入可配置结构规范化并修复强调语法误判
本次提交将 Markdown 结构层面的修正规则从旧的 lint 编号风格重构为语义化、可逐项开关的实现,统一核心与 Web 端配置项命名,并补齐默认值与回归基线。 核心逻辑变更:新增 src/core/markdown/normalize.ts,集中实现行尾空白清理、标题井号空格规范化、列表标记空格规范化、标题/列表/围栏前后空行补齐、文件末尾单换行等规则;通过 line guard 跳过围栏代码块、HTML 注释块和表格分隔线等受保护区域。 集成变更:src/core/markdown-typeset.ts 在非预览模式下按开关调用 applyMarkdownNormalizeRules;src/core/index.ts 导出该能力,便于外部模块复用。 配置模型变更:src/core/models/option.ts 新增 8 个语义化 Markdown 开关字段;src/core/models/default-pure-setting.ts 将这 8 个开关默认设为 true,实现“默认开启这些设置项”的行为。 Web 配置界面变更:src/web/app/defs.ts 增加与语义化字段一一对应的选项文案,删除 MD 编号暴露方式,只保留功能导向描述,支持用户逐项控制。 回归修复:针对“*测试*文本”被误识别为列表项导致输出为“* 测试*”的问题,收紧列表规则匹配并加入强调语法保护,确保左侧保留段首空格、右侧不额外插入空格。 测试与基线:构建通过;回归测试 plain/markdown 均通过;更新 tests/baseline/markdown.output.md 以匹配新默认行为与修复结果。
1 parent c6060b3 commit e3560de

7 files changed

Lines changed: 263 additions & 1 deletion

File tree

src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export type { Option } from "./models/option";
88
export { defaultPTS } from "./models/default-pure-setting";
99
export { typeset } from "./typeset";
1010
export { typesetMarkdown } from "./markdown-typeset";
11+
export { applyMarkdownNormalizeRules } from "./markdown/normalize";
1112
export { supportsAdvancedRegex } from "./regex-support";

src/core/markdown-typeset.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { Option } from "./models/option";
99
import { lineGuard } from "./markdown/line-guard";
1010
import { fmtMdLine } from "./markdown/inline";
11+
import { applyMarkdownNormalizeRules } from "./markdown/normalize";
1112
import { initGuard, safeMdOpt, stripKeep, keepWrap } from "./markdown/shared";
1213

1314
type MdBlockKind =
@@ -44,7 +45,26 @@ function typesetMarkdown(text: string, opt: Option, preview = false): string {
4445
lines[i] = applyMdIndent(formatted, line, opt);
4546
}
4647

47-
return lines.join("\n");
48+
let out = lines.join("\n");
49+
50+
if (!preview) {
51+
const normalizeSwitches = {
52+
trimTrailingSpaces: opt.mdTrimTrailingSpaces,
53+
headingSpaceAfterHash: opt.mdHeadingSpaceAfterHash,
54+
headingSingleSpaceAfterHash: opt.mdHeadingSingleSpaceAfterHash,
55+
blankLineAroundHeadings: opt.mdBlankLineAroundHeadings,
56+
listMarkerSpace: opt.mdListMarkerSpace,
57+
blankLineAroundFences: opt.mdBlankLineAroundFences,
58+
blankLineAroundLists: opt.mdBlankLineAroundLists,
59+
ensureSingleTrailingNewline: opt.mdEnsureSingleTrailingNewline,
60+
};
61+
const hasNormalizeEnabled = Object.values(normalizeSwitches).some(Boolean);
62+
if (hasNormalizeEnabled) {
63+
out = applyMarkdownNormalizeRules(out, normalizeSwitches);
64+
}
65+
}
66+
67+
return out;
4868
}
4969

5070
function insertMdBlankLines(lines: string[]): string[] {

src/core/markdown/normalize.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Markdown 结构规范化(轻量版):
3+
* - 覆盖一批低风险、可自动修复的结构规则;
4+
* - 跳过受保护区域(围栏代码块、HTML 注释块、表格分隔线)。
5+
*/
6+
7+
import { lineGuard } from "./line-guard";
8+
import { initGuard } from "./shared";
9+
10+
type MarkdownNormalizeSwitches = {
11+
trimTrailingSpaces: boolean;
12+
headingSpaceAfterHash: boolean;
13+
headingSingleSpaceAfterHash: boolean;
14+
blankLineAroundHeadings: boolean;
15+
listMarkerSpace: boolean;
16+
blankLineAroundFences: boolean;
17+
blankLineAroundLists: boolean;
18+
ensureSingleTrailingNewline: boolean;
19+
};
20+
21+
function applyMarkdownNormalizeRules(text: string, opt: MarkdownNormalizeSwitches): string {
22+
const source = text.replace(/\r\n/g, "\n");
23+
let lines = source.split("\n");
24+
const protectedLines = markProtected(lines);
25+
26+
for (let i = 0; i < lines.length; i += 1) {
27+
if (protectedLines[i]) {
28+
continue;
29+
}
30+
31+
let curr = lines[i];
32+
33+
if (opt.headingSpaceAfterHash || opt.headingSingleSpaceAfterHash) {
34+
const noSpaceHeading = curr.match(/^(\s{0,3}#{1,6})([^#\s].*)$/);
35+
if (noSpaceHeading && opt.headingSpaceAfterHash) {
36+
curr = `${noSpaceHeading[1]} ${noSpaceHeading[2]}`;
37+
} else {
38+
const multiSpaceHeading = curr.match(/^(\s{0,3}#{1,6})[ \t]{2,}(\S.*)$/);
39+
if (multiSpaceHeading && opt.headingSingleSpaceAfterHash) {
40+
curr = `${multiSpaceHeading[1]} ${multiSpaceHeading[2]}`;
41+
}
42+
}
43+
}
44+
45+
if (opt.listMarkerSpace) {
46+
const noSpaceList = curr.match(/^([ \t]{0,3})([-+*]|\d+[.)])(\S.*)$/);
47+
if (noSpaceList) {
48+
const indent = noSpaceList[1];
49+
const marker = noSpaceList[2];
50+
const content = noSpaceList[3];
51+
if (shouldNormalizeNoSpaceList(marker, content)) {
52+
curr = `${indent}${marker} ${content}`;
53+
}
54+
} else {
55+
const multiSpaceList = curr.match(/^([ \t]{0,3}(?:[-+*]|\d+[.)]))[ \t]{2,}(\S.*)$/);
56+
if (multiSpaceList) {
57+
curr = `${multiSpaceList[1]} ${multiSpaceList[2]}`;
58+
}
59+
}
60+
}
61+
62+
if (opt.trimTrailingSpaces) {
63+
curr = curr.replace(/[ \t]+$/g, "");
64+
}
65+
66+
lines[i] = curr;
67+
}
68+
69+
lines = fixStructuralBlankLines(lines, protectedLines, opt);
70+
71+
let out = lines.join("\n");
72+
if (opt.ensureSingleTrailingNewline) {
73+
out = out.replace(/\n*$/g, "\n");
74+
}
75+
return out;
76+
}
77+
78+
function fixStructuralBlankLines(
79+
lines: string[],
80+
protectedLines: boolean[],
81+
opt: MarkdownNormalizeSwitches
82+
): string[] {
83+
const out: string[] = [];
84+
85+
for (let i = 0; i < lines.length; i += 1) {
86+
const curr = lines[i];
87+
const prevOut = out.length > 0 ? out[out.length - 1] : null;
88+
const next = i + 1 < lines.length ? lines[i + 1] : null;
89+
90+
const currFence = isFenceLine(curr);
91+
const currHeading = !protectedLines[i] && isAtxHeadingLine(curr);
92+
const currList = !protectedLines[i] && isListLine(curr);
93+
94+
if (prevOut !== null && !isBlankLine(prevOut)) {
95+
if (currHeading && opt.blankLineAroundHeadings) {
96+
out.push("");
97+
} else if (currFence && opt.blankLineAroundFences) {
98+
out.push("");
99+
} else if (
100+
currList &&
101+
opt.blankLineAroundLists &&
102+
!isListLine(prevOut) &&
103+
!isListContinuationLine(prevOut)
104+
) {
105+
out.push("");
106+
}
107+
}
108+
109+
out.push(curr);
110+
111+
if (next !== null && !isBlankLine(next)) {
112+
if (currHeading && opt.blankLineAroundHeadings) {
113+
out.push("");
114+
} else if (currFence && opt.blankLineAroundFences) {
115+
out.push("");
116+
} else if (
117+
currList &&
118+
opt.blankLineAroundLists &&
119+
!isListLine(next) &&
120+
!isListContinuationLine(next)
121+
) {
122+
out.push("");
123+
}
124+
}
125+
}
126+
127+
return out;
128+
}
129+
130+
function markProtected(lines: string[]): boolean[] {
131+
const state = initGuard();
132+
const out = new Array<boolean>(lines.length);
133+
134+
for (let i = 0; i < lines.length; i += 1) {
135+
const reason = lineGuard(lines[i], state);
136+
out[i] = reason !== null;
137+
}
138+
139+
return out;
140+
}
141+
142+
function isBlankLine(line: string): boolean {
143+
return line.trim().length === 0;
144+
}
145+
146+
function isAtxHeadingLine(line: string): boolean {
147+
return /^[ \t]{0,3}#{1,6}(?:\s|$)/.test(line);
148+
}
149+
150+
function isListLine(line: string): boolean {
151+
return /^[ \t]{0,3}(?:[-+*]|\d+[.)])\s+/.test(line);
152+
}
153+
154+
function isListContinuationLine(line: string): boolean {
155+
return /^\s{2,}\S/.test(line);
156+
}
157+
158+
function isFenceLine(line: string): boolean {
159+
return /^\s*([`~])\1{2,}/.test(line);
160+
}
161+
162+
function shouldNormalizeNoSpaceList(marker: string, content: string): boolean {
163+
if (marker !== "*") {
164+
return true;
165+
}
166+
167+
const firstToken = content.match(/^\S+/)?.[0] ?? "";
168+
return !firstToken.includes("*");
169+
}
170+
171+
export { applyMarkdownNormalizeRules };
172+
export type { MarkdownNormalizeSwitches };

src/core/models/default-pure-setting.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ const defaultPTS: Option = {
3434
mdIndentParagraphs: true,
3535
mdStyleSpacing: true,
3636
mdAutoBlankLines: true,
37+
mdTrimTrailingSpaces: true,
38+
mdHeadingSpaceAfterHash: true,
39+
mdHeadingSingleSpaceAfterHash: true,
40+
mdBlankLineAroundHeadings: true,
41+
mdListMarkerSpace: true,
42+
mdBlankLineAroundFences: true,
43+
mdBlankLineAroundLists: true,
44+
mdEnsureSingleTrailingNewline: true,
3745

3846
fixOthers: true,
3947
insertSpaceAfterPercentSign: true,

src/core/models/option.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ interface Option {
3535
mdIndentParagraphs: boolean;
3636
mdStyleSpacing: boolean;
3737
mdAutoBlankLines: boolean;
38+
mdTrimTrailingSpaces: boolean;
39+
mdHeadingSpaceAfterHash: boolean;
40+
mdHeadingSingleSpaceAfterHash: boolean;
41+
mdBlankLineAroundHeadings: boolean;
42+
mdListMarkerSpace: boolean;
43+
mdBlankLineAroundFences: boolean;
44+
mdBlankLineAroundLists: boolean;
45+
mdEnsureSingleTrailingNewline: boolean;
3846

3947
fixOthers: boolean;
4048
insertSpaceAfterPercentSign: boolean;

src/web/app/defs.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,54 @@ const defs: SettingDef[] = [
7373
containerId: "other-settings",
7474
mdOnly: true,
7575
},
76+
{
77+
key: "mdTrimTrailingSpaces",
78+
label: "删除行尾空白",
79+
containerId: "other-settings",
80+
mdOnly: true,
81+
},
82+
{
83+
key: "mdHeadingSpaceAfterHash",
84+
label: "标题井号后补空格",
85+
containerId: "other-settings",
86+
mdOnly: true,
87+
},
88+
{
89+
key: "mdHeadingSingleSpaceAfterHash",
90+
label: "标题井号后空格归一化",
91+
containerId: "other-settings",
92+
mdOnly: true,
93+
},
94+
{
95+
key: "mdBlankLineAroundHeadings",
96+
label: "标题前后补空行",
97+
containerId: "other-settings",
98+
mdOnly: true,
99+
},
100+
{
101+
key: "mdListMarkerSpace",
102+
label: "列表标记后空格归一化",
103+
containerId: "other-settings",
104+
mdOnly: true,
105+
},
106+
{
107+
key: "mdBlankLineAroundFences",
108+
label: "围栏代码块前后补空行",
109+
containerId: "other-settings",
110+
mdOnly: true,
111+
},
112+
{
113+
key: "mdBlankLineAroundLists",
114+
label: "列表前后补空行",
115+
containerId: "other-settings",
116+
mdOnly: true,
117+
},
118+
{
119+
key: "mdEnsureSingleTrailingNewline",
120+
label: "文件末尾保证单个换行",
121+
containerId: "other-settings",
122+
mdOnly: true,
123+
},
76124
];
77125

78126
const mdOffKeys: ReadonlyArray<BoolKey> = ["deleteBlankLines", "insertIndent"];

tests/baseline/markdown.output.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,19 @@
2222
-->
2323

2424
```ts
25+
2526
// fenced code block should stay intact
2627
const msg = "中文,English. 5%";
2728
function sum(a:number,b:number){return a+b;}
29+
2830
```
2931

3032
~~~python
33+
3134
# another fence style
3235
text = "中文,English. 5%"
3336
print(text)
37+
3438
~~~
3539

3640
  引号边界样例(英文引号输入)
@@ -50,3 +54,4 @@ print(text)
5054
  *测试* 文本之二。
5155

5256
  测 <u>试文本之三</u> 。
57+

0 commit comments

Comments
 (0)