feat: 添加文章大纲解析与状态管理核心逻辑及单元测试
This commit is contained in:
1 parent
f9c1caed6d
commit
674123e6c9
3 files changed
+105
No files matched your search
@@ -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 = () => {};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user