feat: 添加文章大纲解析与状态管理核心逻辑及单元测试

This commit is contained in:
seahi committed 2026-09-24 08:48:15 +08:00
1 parent f9c1caed6d
commit 674123e6c9
3 files changed
+105

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
import type { OutlineHeading } from './outline';
// Each editing page owns its state; never share article content across SSR requests.
export class EditorOutline {
headings = $state.raw<OutlineHeading[]>([]);
active = $state(-1);
navigate: (heading: OutlineHeading) => void = () => {};
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { activeHeadingAt, extractOutline } from './outline';
describe('editor outline', () => {
it('recognizes all heading levels and Setext headings with exact source positions', () => {
const source =
'# 一\n\n## 二 ##\n\n### 三\n\n#### 四\n\n##### 五\n\n###### 六\n\n下划线标题\n====\n';
const headings = extractOutline(source);
expect(headings.map(({ level, text }) => [level, text])).toEqual([
[1, '一'],
[2, '二'],
[3, '三'],
[4, '四'],
[5, '五'],
[6, '六'],
[1, '下划线标题']
]);
expect(headings.at(-1)?.from).toBe(source.indexOf('下划线标题'));
});
it('ignores fenced and indented code, HTML blocks and escaped heading markers', () => {
const source =
'```md\n# 假标题\n```\n\n~~~\n## 假标题\n~~~\n\n # 代码\n\n\\# 普通文本\n\n<div>\n# HTML 内文本\n</div>\n\n## 真标题\n';
expect(extractOutline(source).map((heading) => heading.text)).toEqual(['真标题']);
});
it('keeps duplicate headings distinct and includes headings inside quotes and lists', () => {
const source = '## 重复\n\n> ### 引用\n\n- #### 列表\n\n## 重复\n';
const headings = extractOutline(source);
expect(headings.map((heading) => heading.text)).toEqual(['重复', '引用', '列表', '重复']);
expect(headings[3].from).toBe(source.lastIndexOf('## 重复'));
});
it('shows plain labels for formatted headings and handles empty headings', () => {
expect(
extractOutline('## **粗体** [链接](https://example.com) `code`\n\n#\n').map((h) => h.text)
).toEqual(['粗体 链接 code', '未命名标题']);
});
it('includes offscreen headings and refreshes offsets after insertion or deletion', () => {
const source = '正文\n\n'.repeat(4000) + '## 结尾\n';
expect(extractOutline(source)[0].from).toBe(source.indexOf('##'));
expect(extractOutline('前言\n\n' + source)[0].from).toBe(source.indexOf('##') + 4);
expect(extractOutline(source.replace('## 结尾', '结尾'))).toEqual([]);
});
it('finds the enclosing section for cursor or viewport positions', () => {
const headings = extractOutline('前言\n\n## 第一节\n内容\n\n## 第二节\n内容');
expect(activeHeadingAt(headings, 0)).toBe(-1);
expect(activeHeadingAt(headings, headings[0].from)).toBe(0);
expect(activeHeadingAt(headings, headings[1].from - 1)).toBe(0);
expect(activeHeadingAt(headings, 100)).toBe(1);
expect(activeHeadingAt([], 100)).toBe(-1);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { markdownLanguage } from '@codemirror/lang-markdown';
import { Lexer, type Token } from 'marked';
export type OutlineHeading = { from: number; level: number; text: string };
function plainText(tokens: Token[]): string {
return tokens
.map((token) => {
if (token.type === 'html') return '';
if ('tokens' in token && token.tokens) return plainText(token.tokens);
return 'text' in token ? token.text : token.raw;
})
.join('');
}
/** Parse the whole document, including headings beyond CodeMirror's rendered viewport. */
export function extractOutline(source: string): OutlineHeading[] {
const headings: OutlineHeading[] = [];
markdownLanguage.parser.parse(source).iterate({
enter(node) {
const match = /^(?:ATX|Setext)Heading([1-6])$/.exec(node.name);
if (!match) return;
let start = node.from;
let raw = '';
for (let child = node.node.firstChild; child; child = child.nextSibling) {
if (child.name === 'HeaderMark') {
raw += source.slice(start, child.from);
start = child.to;
}
}
raw += source.slice(start, node.to);
headings.push({
from: node.from,
level: Number(match[1]),
text: plainText(Lexer.lexInline(raw.trim())).trim() || '未命名标题'
});
return false;
}
});
return headings;
}
export function activeHeadingAt(headings: OutlineHeading[], position: number): number {
let active = -1;
for (let i = 0; i < headings.length && headings[i].from <= position; i++) active = i;
return active;
}