growork 1.0.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,8 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import * as fs from 'fs';
4
- import * as path from 'path';
5
3
  import * as yaml from 'yaml';
6
4
 
7
- // 直接测试配置解析逻辑,不依赖 process.cwd()
5
+ // ============ V1.0 配置类型(用于兼容性测试)============
8
6
  interface DocConfig {
9
7
  name: string;
10
8
  type: 'feishu' | 'notion';
@@ -35,6 +33,133 @@ function validateConfig(config: GroworkConfig): void {
35
33
  }
36
34
  }
37
35
 
36
+ // ============ V2.0 配置类型和函数(从 config.ts 复制)============
37
+ type DocInput = string | { url: string; name?: string };
38
+
39
+ type FeatureValue = DocInput[] | {
40
+ prd?: DocInput[];
41
+ design?: DocInput[];
42
+ api?: DocInput[];
43
+ test?: DocInput[];
44
+ };
45
+
46
+ interface GroworkConfigV2 {
47
+ feishu?: { appId: string; appSecret: string; domain?: 'feishu' | 'lark' };
48
+ notion?: { token: string };
49
+ outputDir?: string;
50
+ custom?: DocInput[];
51
+ versions?: {
52
+ [version: string]: {
53
+ [feature: string]: FeatureValue;
54
+ };
55
+ };
56
+ }
57
+
58
+ interface NormalizedDoc {
59
+ url: string;
60
+ name?: string;
61
+ type: 'feishu' | 'notion' | 'figma';
62
+ outputPath: string;
63
+ }
64
+
65
+ interface SyncOptions {
66
+ version?: string;
67
+ feature?: string;
68
+ custom?: boolean;
69
+ }
70
+
71
+ function inferDocType(url: string): 'feishu' | 'notion' | 'figma' {
72
+ if (url.includes('feishu.cn') || url.includes('larksuite.com')) {
73
+ return 'feishu';
74
+ }
75
+ if (url.includes('notion.so') || url.includes('notion.site')) {
76
+ return 'notion';
77
+ }
78
+ if (url.includes('figma.com')) {
79
+ return 'figma';
80
+ }
81
+ throw new Error(`无法从 URL 推断文档类型: ${url}`);
82
+ }
83
+
84
+ function sanitizeFileName(title: string): string {
85
+ return title
86
+ .replace(/[\/\\:*?"<>|]/g, '')
87
+ .replace(/\s+/g, '-')
88
+ .replace(/-+/g, '-')
89
+ .replace(/^-|-$/g, '');
90
+ }
91
+
92
+ function parseDocInput(input: DocInput): { url: string; name?: string } {
93
+ if (typeof input === 'string') {
94
+ return { url: input };
95
+ }
96
+ return input;
97
+ }
98
+
99
+ function isTypedFeature(value: FeatureValue): value is { prd?: DocInput[]; design?: DocInput[]; api?: DocInput[]; test?: DocInput[] } {
100
+ return !Array.isArray(value);
101
+ }
102
+
103
+ function normalizeConfig(config: GroworkConfigV2, options: SyncOptions = {}): NormalizedDoc[] {
104
+ const docs: NormalizedDoc[] = [];
105
+ const outputDir = config.outputDir || 'docs';
106
+
107
+ if (config.custom && (options.custom || (!options.version && !options.feature))) {
108
+ for (const input of config.custom) {
109
+ const { url, name } = parseDocInput(input);
110
+ docs.push({
111
+ url,
112
+ name,
113
+ type: inferDocType(url),
114
+ outputPath: `${outputDir}/custom/{title}.md`,
115
+ });
116
+ }
117
+ }
118
+
119
+ if (options.custom) {
120
+ return docs;
121
+ }
122
+
123
+ if (config.versions) {
124
+ for (const [version, features] of Object.entries(config.versions)) {
125
+ if (options.version && options.version !== version) continue;
126
+
127
+ for (const [feature, value] of Object.entries(features)) {
128
+ if (options.feature && options.feature !== feature) continue;
129
+
130
+ if (isTypedFeature(value)) {
131
+ for (const docType of ['prd', 'design', 'api', 'test'] as const) {
132
+ const docInputs = value[docType];
133
+ if (!docInputs) continue;
134
+
135
+ for (const input of docInputs) {
136
+ const { url, name } = parseDocInput(input);
137
+ docs.push({
138
+ url,
139
+ name,
140
+ type: inferDocType(url),
141
+ outputPath: `${outputDir}/${version}/${feature}/${docType}/{title}.md`,
142
+ });
143
+ }
144
+ }
145
+ } else {
146
+ for (const input of value) {
147
+ const { url, name } = parseDocInput(input);
148
+ docs.push({
149
+ url,
150
+ name,
151
+ type: inferDocType(url),
152
+ outputPath: `${outputDir}/${version}/${feature}/{title}.md`,
153
+ });
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ return docs;
161
+ }
162
+
38
163
  describe('配置文件解析', () => {
39
164
  it('应正确解析有效的 YAML 配置', () => {
40
165
  const yamlContent = `
@@ -184,3 +309,258 @@ describe('配置文件验证', () => {
184
309
  );
185
310
  });
186
311
  });
312
+
313
+ // ============ V2.0 配置测试 ============
314
+
315
+ describe('文档类型推断', () => {
316
+ it('应识别飞书 feishu.cn URL', () => {
317
+ assert.strictEqual(inferDocType('https://xxx.feishu.cn/docx/abc123'), 'feishu');
318
+ });
319
+
320
+ it('应识别飞书 larksuite.com URL', () => {
321
+ assert.strictEqual(inferDocType('https://xxx.larksuite.com/docx/abc123'), 'feishu');
322
+ });
323
+
324
+ it('应识别 Notion notion.so URL', () => {
325
+ assert.strictEqual(inferDocType('https://www.notion.so/abc123'), 'notion');
326
+ });
327
+
328
+ it('应识别 Notion notion.site URL', () => {
329
+ assert.strictEqual(inferDocType('https://myworkspace.notion.site/abc123'), 'notion');
330
+ });
331
+
332
+ it('应识别 Figma URL', () => {
333
+ assert.strictEqual(inferDocType('https://www.figma.com/design/abc123'), 'figma');
334
+ });
335
+
336
+ it('应识别 Figma 文件 URL', () => {
337
+ assert.strictEqual(inferDocType('https://figma.com/file/abc123/My-Design'), 'figma');
338
+ });
339
+
340
+ it('应拒绝未知域名', () => {
341
+ assert.throws(
342
+ () => inferDocType('https://example.com/doc/123'),
343
+ /无法从 URL 推断文档类型/
344
+ );
345
+ });
346
+ });
347
+
348
+ describe('文件名清理', () => {
349
+ it('应移除文件系统保留字符', () => {
350
+ assert.strictEqual(sanitizeFileName('test/file:name'), 'testfilename');
351
+ });
352
+
353
+ it('应将空格替换为连字符', () => {
354
+ assert.strictEqual(sanitizeFileName('hello world'), 'hello-world');
355
+ });
356
+
357
+ it('应合并连续空格为单个连字符', () => {
358
+ assert.strictEqual(sanitizeFileName('hello world'), 'hello-world');
359
+ });
360
+
361
+ it('应移除首尾连字符', () => {
362
+ assert.strictEqual(sanitizeFileName(' hello world '), 'hello-world');
363
+ });
364
+
365
+ it('应处理复杂标题', () => {
366
+ assert.strictEqual(sanitizeFileName('PRD: 用户登录 v1.0'), 'PRD-用户登录-v1.0');
367
+ });
368
+
369
+ it('应处理只有特殊字符的情况', () => {
370
+ assert.strictEqual(sanitizeFileName('***'), '');
371
+ });
372
+ });
373
+
374
+ describe('文档输入解析', () => {
375
+ it('应解析字符串输入', () => {
376
+ const result = parseDocInput('https://xxx.feishu.cn/docx/abc');
377
+ assert.deepStrictEqual(result, { url: 'https://xxx.feishu.cn/docx/abc' });
378
+ });
379
+
380
+ it('应解析对象输入(只有 url)', () => {
381
+ const result = parseDocInput({ url: 'https://xxx.feishu.cn/docx/abc' });
382
+ assert.deepStrictEqual(result, { url: 'https://xxx.feishu.cn/docx/abc' });
383
+ });
384
+
385
+ it('应解析对象输入(带 name)', () => {
386
+ const result = parseDocInput({ url: 'https://xxx.feishu.cn/docx/abc', name: '产品文档' });
387
+ assert.deepStrictEqual(result, { url: 'https://xxx.feishu.cn/docx/abc', name: '产品文档' });
388
+ });
389
+ });
390
+
391
+ describe('V2.0 配置规范化', () => {
392
+ it('应处理 custom 文档', () => {
393
+ const config: GroworkConfigV2 = {
394
+ custom: ['https://xxx.feishu.cn/docx/abc123']
395
+ };
396
+ const docs = normalizeConfig(config);
397
+
398
+ assert.strictEqual(docs.length, 1);
399
+ assert.strictEqual(docs[0].url, 'https://xxx.feishu.cn/docx/abc123');
400
+ assert.strictEqual(docs[0].type, 'feishu');
401
+ assert.strictEqual(docs[0].outputPath, 'docs/custom/{title}.md');
402
+ });
403
+
404
+ it('应处理带 name 的 custom 文档', () => {
405
+ const config: GroworkConfigV2 = {
406
+ custom: [{ url: 'https://www.notion.so/abc123', name: '技术架构' }]
407
+ };
408
+ const docs = normalizeConfig(config);
409
+
410
+ assert.strictEqual(docs.length, 1);
411
+ assert.strictEqual(docs[0].name, '技术架构');
412
+ assert.strictEqual(docs[0].type, 'notion');
413
+ });
414
+
415
+ it('应处理 versions 中的简单 feature', () => {
416
+ const config: GroworkConfigV2 = {
417
+ versions: {
418
+ 'v1.0': {
419
+ '用户登录': ['https://xxx.feishu.cn/docx/abc']
420
+ }
421
+ }
422
+ };
423
+ const docs = normalizeConfig(config);
424
+
425
+ assert.strictEqual(docs.length, 1);
426
+ assert.strictEqual(docs[0].outputPath, 'docs/v1.0/用户登录/{title}.md');
427
+ });
428
+
429
+ it('应处理 versions 中的分类型 feature', () => {
430
+ const config: GroworkConfigV2 = {
431
+ versions: {
432
+ 'v1.0': {
433
+ '用户登录': {
434
+ prd: ['https://xxx.feishu.cn/docx/prd'],
435
+ design: ['https://xxx.feishu.cn/docx/design'],
436
+ api: ['https://xxx.feishu.cn/docx/api']
437
+ }
438
+ }
439
+ }
440
+ };
441
+ const docs = normalizeConfig(config);
442
+
443
+ assert.strictEqual(docs.length, 3);
444
+ assert.strictEqual(docs[0].outputPath, 'docs/v1.0/用户登录/prd/{title}.md');
445
+ assert.strictEqual(docs[1].outputPath, 'docs/v1.0/用户登录/design/{title}.md');
446
+ assert.strictEqual(docs[2].outputPath, 'docs/v1.0/用户登录/api/{title}.md');
447
+ });
448
+
449
+ it('应支持自定义 outputDir', () => {
450
+ const config: GroworkConfigV2 = {
451
+ outputDir: 'output',
452
+ custom: ['https://xxx.feishu.cn/docx/abc']
453
+ };
454
+ const docs = normalizeConfig(config);
455
+
456
+ assert.strictEqual(docs[0].outputPath, 'output/custom/{title}.md');
457
+ });
458
+
459
+ it('应支持 version 过滤', () => {
460
+ const config: GroworkConfigV2 = {
461
+ versions: {
462
+ 'v1.0': { 'feat1': ['https://xxx.feishu.cn/docx/1'] },
463
+ 'v2.0': { 'feat2': ['https://xxx.feishu.cn/docx/2'] }
464
+ }
465
+ };
466
+ const docs = normalizeConfig(config, { version: 'v1.0' });
467
+
468
+ assert.strictEqual(docs.length, 1);
469
+ assert.ok(docs[0].outputPath.includes('v1.0'));
470
+ });
471
+
472
+ it('应支持 feature 过滤', () => {
473
+ const config: GroworkConfigV2 = {
474
+ versions: {
475
+ 'v1.0': {
476
+ '登录': ['https://xxx.feishu.cn/docx/1'],
477
+ '注册': ['https://xxx.feishu.cn/docx/2']
478
+ }
479
+ }
480
+ };
481
+ const docs = normalizeConfig(config, { feature: '登录' });
482
+
483
+ assert.strictEqual(docs.length, 1);
484
+ assert.ok(docs[0].outputPath.includes('登录'));
485
+ });
486
+
487
+ it('应支持 custom 选项(只获取 custom 文档)', () => {
488
+ const config: GroworkConfigV2 = {
489
+ custom: ['https://xxx.feishu.cn/docx/custom'],
490
+ versions: {
491
+ 'v1.0': { 'feat': ['https://xxx.feishu.cn/docx/versioned'] }
492
+ }
493
+ };
494
+ const docs = normalizeConfig(config, { custom: true });
495
+
496
+ assert.strictEqual(docs.length, 1);
497
+ assert.ok(docs[0].outputPath.includes('custom'));
498
+ });
499
+
500
+ it('默认情况下应返回所有文档', () => {
501
+ const config: GroworkConfigV2 = {
502
+ custom: ['https://xxx.feishu.cn/docx/custom'],
503
+ versions: {
504
+ 'v1.0': { 'feat': ['https://xxx.feishu.cn/docx/versioned'] }
505
+ }
506
+ };
507
+ const docs = normalizeConfig(config);
508
+
509
+ assert.strictEqual(docs.length, 2);
510
+ });
511
+
512
+ it('应正确处理多版本多 feature', () => {
513
+ const config: GroworkConfigV2 = {
514
+ versions: {
515
+ 'v1.0': {
516
+ 'feat1': ['https://xxx.feishu.cn/docx/1'],
517
+ 'feat2': ['https://xxx.feishu.cn/docx/2']
518
+ },
519
+ 'v2.0': {
520
+ 'feat3': ['https://xxx.feishu.cn/docx/3']
521
+ }
522
+ }
523
+ };
524
+ const docs = normalizeConfig(config);
525
+
526
+ assert.strictEqual(docs.length, 3);
527
+ });
528
+ });
529
+
530
+ describe('V2.0 YAML 配置解析', () => {
531
+ it('应正确解析 V2.0 配置', () => {
532
+ const yamlContent = `
533
+ feishu:
534
+ appId: "cli_test"
535
+ appSecret: "secret_test"
536
+ domain: "feishu"
537
+
538
+ outputDir: "docs"
539
+
540
+ custom:
541
+ - "https://xxx.feishu.cn/docx/custom1"
542
+ - url: "https://xxx.feishu.cn/docx/custom2"
543
+ name: "技术架构"
544
+
545
+ versions:
546
+ v1.0:
547
+ 用户登录:
548
+ prd:
549
+ - "https://xxx.feishu.cn/docx/prd"
550
+ api:
551
+ - "https://xxx.feishu.cn/docx/api"
552
+ 小优化:
553
+ - "https://xxx.feishu.cn/docx/opt"
554
+ `;
555
+ const config = yaml.parse(yamlContent) as GroworkConfigV2;
556
+
557
+ assert.strictEqual(config.feishu?.appId, 'cli_test');
558
+ assert.strictEqual(config.feishu?.domain, 'feishu');
559
+ assert.strictEqual(config.outputDir, 'docs');
560
+ assert.strictEqual(config.custom?.length, 2);
561
+ assert.ok(config.versions?.['v1.0']);
562
+
563
+ const docs = normalizeConfig(config);
564
+ assert.strictEqual(docs.length, 5); // 2 custom + 2 分类 + 1 简单
565
+ });
566
+ });
@@ -239,6 +239,16 @@ describe('Block 转 Markdown', () => {
239
239
  return `## ${textElementsToMarkdown(block.heading2?.elements)}\n`;
240
240
  case 5: // Heading3
241
241
  return `### ${textElementsToMarkdown(block.heading3?.elements)}\n`;
242
+ case 6: // Heading4
243
+ return `#### ${textElementsToMarkdown(block.heading4?.elements)}\n`;
244
+ case 7: // Heading5
245
+ return `##### ${textElementsToMarkdown(block.heading5?.elements)}\n`;
246
+ case 8: // Heading6
247
+ return `###### ${textElementsToMarkdown(block.heading6?.elements)}\n`;
248
+ case 9: // Heading7
249
+ case 10: // Heading8
250
+ case 11: // Heading9
251
+ return `###### ${textElementsToMarkdown(block[`heading${blockType - 6}`]?.elements)}\n`;
242
252
  case 12: // Bullet
243
253
  return `- ${textElementsToMarkdown(block.bullet?.elements)}`;
244
254
  case 13: // Ordered
@@ -254,6 +264,12 @@ describe('Block 转 Markdown', () => {
254
264
  return `- [${checked}] ${textElementsToMarkdown(block.todo?.elements)}`;
255
265
  case 18: // Divider
256
266
  return '---\n';
267
+ case 19: // Image
268
+ const imageToken = block.image?.token;
269
+ return imageToken ? `![image](${imageToken})\n` : '';
270
+ case 30: // Sheet
271
+ const sheetToken = block.sheet?.token;
272
+ return sheetToken ? `[嵌入表格: ${sheetToken}]\n` : '';
257
273
  default:
258
274
  return null;
259
275
  }
@@ -342,4 +358,175 @@ describe('Block 转 Markdown', () => {
342
358
  it('应转换分割线', () => {
343
359
  assert.strictEqual(blockToMarkdown({ block_type: 18 }), '---\n');
344
360
  });
361
+
362
+ it('应转换 Heading4', () => {
363
+ assert.strictEqual(
364
+ blockToMarkdown({ block_type: 6, heading4: { elements: [{ text_run: { content: 'H4' } }] } }),
365
+ '#### H4\n'
366
+ );
367
+ });
368
+
369
+ it('应转换 Heading5', () => {
370
+ assert.strictEqual(
371
+ blockToMarkdown({ block_type: 7, heading5: { elements: [{ text_run: { content: 'H5' } }] } }),
372
+ '##### H5\n'
373
+ );
374
+ });
375
+
376
+ it('应转换 Heading6', () => {
377
+ assert.strictEqual(
378
+ blockToMarkdown({ block_type: 8, heading6: { elements: [{ text_run: { content: 'H6' } }] } }),
379
+ '###### H6\n'
380
+ );
381
+ });
382
+
383
+ it('应转换 Heading7-9 为 H6', () => {
384
+ // Heading7-9 都映射为 ###### (H6)
385
+ assert.strictEqual(
386
+ blockToMarkdown({ block_type: 9, heading3: { elements: [{ text_run: { content: 'H7' } }] } }),
387
+ '###### H7\n'
388
+ );
389
+ assert.strictEqual(
390
+ blockToMarkdown({ block_type: 10, heading4: { elements: [{ text_run: { content: 'H8' } }] } }),
391
+ '###### H8\n'
392
+ );
393
+ assert.strictEqual(
394
+ blockToMarkdown({ block_type: 11, heading5: { elements: [{ text_run: { content: 'H9' } }] } }),
395
+ '###### H9\n'
396
+ );
397
+ });
398
+
399
+ it('应转换图片块', () => {
400
+ const block = {
401
+ block_type: 19,
402
+ image: { token: 'img_token_123' }
403
+ };
404
+ assert.strictEqual(blockToMarkdown(block), '![image](img_token_123)\n');
405
+ });
406
+
407
+ it('应处理无 token 的图片块', () => {
408
+ const block = { block_type: 19, image: {} };
409
+ assert.strictEqual(blockToMarkdown(block), '');
410
+ });
411
+
412
+ it('应转换嵌入表格', () => {
413
+ const block = {
414
+ block_type: 30,
415
+ sheet: { token: 'sheet_token_456' }
416
+ };
417
+ assert.strictEqual(blockToMarkdown(block), '[嵌入表格: sheet_token_456]\n');
418
+ });
419
+
420
+ it('应处理无 token 的嵌入表格', () => {
421
+ const block = { block_type: 30, sheet: {} };
422
+ assert.strictEqual(blockToMarkdown(block), '');
423
+ });
424
+
425
+ it('应对未知块类型返回 null', () => {
426
+ assert.strictEqual(blockToMarkdown({ block_type: 999 }), null);
427
+ });
428
+ });
429
+
430
+ describe('表格转 Markdown', () => {
431
+ function getCellContent(cellBlock: any, blockMap: Map<string, any>): string {
432
+ if (!cellBlock) return '';
433
+ const children = cellBlock.children || [];
434
+ const contents: string[] = [];
435
+ for (const childId of children) {
436
+ const child = blockMap.get(childId);
437
+ if (!child) continue;
438
+ if (child.block_type === 2) {
439
+ contents.push(textElementsToMarkdown(child.text?.elements));
440
+ }
441
+ }
442
+ return contents.join(' ').replace(/\|/g, '\\|').replace(/\n/g, ' ');
443
+ }
444
+
445
+ function tableToMarkdown(tableBlock: any, blockMap: Map<string, any>): string {
446
+ const table = tableBlock.table;
447
+ if (!table) return '';
448
+ const colSize = table.property?.column_size || 0;
449
+ const rowSize = table.property?.row_size || 0;
450
+ const cellIds = table.cells || [];
451
+ if (colSize === 0 || rowSize === 0) return '';
452
+
453
+ const rows: string[][] = [];
454
+ for (let row = 0; row < rowSize; row++) {
455
+ const rowCells: string[] = [];
456
+ for (let col = 0; col < colSize; col++) {
457
+ const cellIndex = row * colSize + col;
458
+ const cellId = cellIds[cellIndex];
459
+ const cellBlock = blockMap.get(cellId);
460
+ const cellContent = getCellContent(cellBlock, blockMap);
461
+ rowCells.push(cellContent);
462
+ }
463
+ rows.push(rowCells);
464
+ }
465
+
466
+ const lines: string[] = [];
467
+ if (rows.length > 0) {
468
+ lines.push('| ' + rows[0].join(' | ') + ' |');
469
+ lines.push('| ' + rows[0].map(() => '---').join(' | ') + ' |');
470
+ }
471
+ for (let i = 1; i < rows.length; i++) {
472
+ lines.push('| ' + rows[i].join(' | ') + ' |');
473
+ }
474
+ return lines.join('\n') + '\n';
475
+ }
476
+
477
+ it('应转换简单表格', () => {
478
+ const blockMap = new Map<string, any>();
479
+ // 创建单元格内容块
480
+ blockMap.set('text1', { block_type: 2, text: { elements: [{ text_run: { content: 'Header1' } }] } });
481
+ blockMap.set('text2', { block_type: 2, text: { elements: [{ text_run: { content: 'Header2' } }] } });
482
+ blockMap.set('text3', { block_type: 2, text: { elements: [{ text_run: { content: 'Cell1' } }] } });
483
+ blockMap.set('text4', { block_type: 2, text: { elements: [{ text_run: { content: 'Cell2' } }] } });
484
+ // 创建单元格
485
+ blockMap.set('cell1', { children: ['text1'] });
486
+ blockMap.set('cell2', { children: ['text2'] });
487
+ blockMap.set('cell3', { children: ['text3'] });
488
+ blockMap.set('cell4', { children: ['text4'] });
489
+
490
+ const tableBlock = {
491
+ block_type: 31,
492
+ table: {
493
+ property: { column_size: 2, row_size: 2 },
494
+ cells: ['cell1', 'cell2', 'cell3', 'cell4']
495
+ }
496
+ };
497
+
498
+ const result = tableToMarkdown(tableBlock, blockMap);
499
+ assert.strictEqual(result, '| Header1 | Header2 |\n| --- | --- |\n| Cell1 | Cell2 |\n');
500
+ });
501
+
502
+ it('应处理空表格', () => {
503
+ const blockMap = new Map<string, any>();
504
+ const tableBlock = {
505
+ block_type: 31,
506
+ table: { property: { column_size: 0, row_size: 0 }, cells: [] }
507
+ };
508
+ assert.strictEqual(tableToMarkdown(tableBlock, blockMap), '');
509
+ });
510
+
511
+ it('应处理无 table 属性的块', () => {
512
+ const blockMap = new Map<string, any>();
513
+ assert.strictEqual(tableToMarkdown({ block_type: 31 }, blockMap), '');
514
+ });
515
+
516
+ it('应转义单元格中的管道符', () => {
517
+ const blockMap = new Map<string, any>();
518
+ blockMap.set('text1', { block_type: 2, text: { elements: [{ text_run: { content: 'A|B' } }] } });
519
+ blockMap.set('cell1', { children: ['text1'] });
520
+
521
+ const tableBlock = {
522
+ block_type: 31,
523
+ table: {
524
+ property: { column_size: 1, row_size: 1 },
525
+ cells: ['cell1']
526
+ }
527
+ };
528
+
529
+ const result = tableToMarkdown(tableBlock, blockMap);
530
+ assert.ok(result.includes('A\\|B'));
531
+ });
345
532
  });
@@ -102,6 +102,87 @@ describe('Notion URL 解析', () => {
102
102
  });
103
103
  });
104
104
 
105
+ describe('Notion 页面标题提取', () => {
106
+ function getPageTitle(page: any): string {
107
+ const properties = page.properties;
108
+ if (!properties) return '未命名';
109
+
110
+ for (const key of Object.keys(properties)) {
111
+ const prop = properties[key];
112
+ if (prop.type === 'title' && prop.title?.length > 0) {
113
+ return prop.title.map((t: any) => t.plain_text).join('');
114
+ }
115
+ }
116
+
117
+ return '未命名';
118
+ }
119
+
120
+ it('应提取标准 title 属性', () => {
121
+ const page = {
122
+ properties: {
123
+ Name: {
124
+ type: 'title',
125
+ title: [{ plain_text: 'My Page Title' }]
126
+ }
127
+ }
128
+ };
129
+ assert.strictEqual(getPageTitle(page), 'My Page Title');
130
+ });
131
+
132
+ it('应合并多个 title 片段', () => {
133
+ const page = {
134
+ properties: {
135
+ Title: {
136
+ type: 'title',
137
+ title: [{ plain_text: 'Hello ' }, { plain_text: 'World' }]
138
+ }
139
+ }
140
+ };
141
+ assert.strictEqual(getPageTitle(page), 'Hello World');
142
+ });
143
+
144
+ it('应处理空 properties', () => {
145
+ const page = { properties: {} };
146
+ assert.strictEqual(getPageTitle(page), '未命名');
147
+ });
148
+
149
+ it('应处理无 properties 的页面', () => {
150
+ const page = {};
151
+ assert.strictEqual(getPageTitle(page), '未命名');
152
+ });
153
+
154
+ it('应处理空 title 数组', () => {
155
+ const page = {
156
+ properties: {
157
+ Name: { type: 'title', title: [] }
158
+ }
159
+ };
160
+ assert.strictEqual(getPageTitle(page), '未命名');
161
+ });
162
+
163
+ it('应跳过非 title 类型属性', () => {
164
+ const page = {
165
+ properties: {
166
+ Status: { type: 'select', select: { name: 'Done' } },
167
+ Name: { type: 'title', title: [{ plain_text: 'Actual Title' }] }
168
+ }
169
+ };
170
+ assert.strictEqual(getPageTitle(page), 'Actual Title');
171
+ });
172
+
173
+ it('应处理自定义属性名的 title', () => {
174
+ const page = {
175
+ properties: {
176
+ '页面标题': {
177
+ type: 'title',
178
+ title: [{ plain_text: '中文标题' }]
179
+ }
180
+ }
181
+ };
182
+ assert.strictEqual(getPageTitle(page), '中文标题');
183
+ });
184
+ });
185
+
105
186
  describe('Notion 属性值提取', () => {
106
187
  describe('title 属性', () => {
107
188
  it('应提取 title 文本', () => {