Skip to content

Commit 7a5c2cf

Browse files
committed
✨: 新增多語言支援,包含繁體中文的區段標題和驗證訊息
1 parent 71b25de commit 7a5c2cf

5 files changed

Lines changed: 212 additions & 90 deletions

File tree

src/core/i18n/section-titles.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* 多語言區段標題映射
3+
* 支援英文和繁體中文的標題識別
4+
*/
5+
6+
/**
7+
* 規範文件的區段標題
8+
*/
9+
export const SPEC_SECTIONS = {
10+
/** 目的區段 */
11+
PURPOSE: ['Purpose', '目的'],
12+
/** 需求區段 */
13+
REQUIREMENTS: ['Requirements', '需求'],
14+
} as const;
15+
16+
/**
17+
* 變更文件的區段標題
18+
*/
19+
export const CHANGE_SECTIONS = {
20+
/** 為什麼區段 */
21+
WHY: ['Why', '為什麼'],
22+
/** 變更內容區段 */
23+
WHAT_CHANGES: ['What Changes', '變更內容'],
24+
} as const;
25+
26+
/**
27+
* 差異類型的區段標題
28+
*/
29+
export const DELTA_SECTIONS = {
30+
/** 新增需求 */
31+
ADDED: ['ADDED Requirements', '新增需求'],
32+
/** 修改需求 */
33+
MODIFIED: ['MODIFIED Requirements', '修改需求'],
34+
/** 移除需求 */
35+
REMOVED: ['REMOVED Requirements', '移除需求'],
36+
/** 重新命名需求 */
37+
RENAMED: ['RENAMED Requirements', '重新命名需求'],
38+
} as const;
39+
40+
/**
41+
* 需求區塊的標題前綴
42+
*/
43+
export const REQUIREMENT_PREFIXES = ['Requirement:', '需求:'] as const;
44+
45+
/**
46+
* 情境的標題前綴
47+
*/
48+
export const SCENARIO_PREFIXES = ['Scenario:', '情境:'] as const;
49+
50+
/**
51+
* 將標題陣列轉換為小寫,用於 case-insensitive 比對
52+
*/
53+
export function normalizeTitle(title: string): string {
54+
return title.toLowerCase().trim();
55+
}
56+
57+
/**
58+
* 檢查標題是否匹配多語言標題列表
59+
* @param title 要檢查的標題
60+
* @param titleVariants 支援的標題變體(英文、繁體中文)
61+
* @returns 是否匹配
62+
*/
63+
export function matchesTitle(title: string, titleVariants: readonly string[]): boolean {
64+
const normalizedTitle = normalizeTitle(title);
65+
return titleVariants.some(variant => normalizeTitle(variant) === normalizedTitle);
66+
}
67+
68+
/**
69+
* 從標題列表中找到匹配的標題(遞迴搜尋包含 children)
70+
* @param sections 區段列表
71+
* @param titleVariants 支援的標題變體
72+
* @returns 找到的區段或 undefined
73+
*/
74+
export function findSectionByTitleVariants<T extends { title: string; children?: T[] }>(
75+
sections: T[],
76+
titleVariants: readonly string[]
77+
): T | undefined {
78+
for (const section of sections) {
79+
if (matchesTitle(section.title, titleVariants)) {
80+
return section;
81+
}
82+
// 遞迴搜尋子區段
83+
if (section.children && section.children.length > 0) {
84+
const child = findSectionByTitleVariants(section.children, titleVariants);
85+
if (child) {
86+
return child;
87+
}
88+
}
89+
}
90+
return undefined;
91+
}

src/core/parsers/change-parser.ts

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MarkdownParser, Section } from './markdown-parser.js';
22
import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js';
3+
import { CHANGE_SECTIONS, DELTA_SECTIONS, findSectionByTitleVariants } from '../i18n/section-titles.js';
34
import path from 'path';
45
import { promises as fs } from 'fs';
56

@@ -19,24 +20,27 @@ export class ChangeParser extends MarkdownParser {
1920

2021
async parseChangeWithDeltas(name: string): Promise<Change> {
2122
const sections = this.parseSections();
22-
const why = this.findSection(sections, 'Why')?.content || '';
23-
const whatChanges = this.findSection(sections, 'What Changes')?.content || '';
24-
23+
const whySection = findSectionByTitleVariants(sections, CHANGE_SECTIONS.WHY);
24+
const whatChangesSection = findSectionByTitleVariants(sections, CHANGE_SECTIONS.WHAT_CHANGES);
25+
26+
const why = whySection?.content || '';
27+
const whatChanges = whatChangesSection?.content || '';
28+
2529
if (!why) {
26-
throw new Error('Change must have a Why section');
30+
throw new Error('變更必須包含為什麼區段');
2731
}
28-
32+
2933
if (!whatChanges) {
30-
throw new Error('Change must have a What Changes section');
34+
throw new Error('變更必須包含變更內容區段');
3135
}
3236

3337
// Parse deltas from the What Changes section (simple format)
3438
const simpleDeltas = this.parseDeltas(whatChanges);
35-
39+
3640
// Check if there are spec files with delta format
3741
const specsDir = path.join(this.changeDir, 'specs');
3842
const deltaDeltas = await this.parseDeltaSpecs(specsDir);
39-
43+
4044
// Combine both types of deltas, preferring delta format if available
4145
const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;
4246

@@ -84,85 +88,86 @@ export class ChangeParser extends MarkdownParser {
8488
private parseSpecDeltas(specName: string, content: string): Delta[] {
8589
const deltas: Delta[] = [];
8690
const sections = this.parseSectionsFromContent(content);
87-
88-
// Parse ADDED requirements
89-
const addedSection = this.findSection(sections, 'ADDED Requirements');
91+
92+
// Parse ADDED requirements (支援多語言)
93+
const addedSection = findSectionByTitleVariants(sections, DELTA_SECTIONS.ADDED);
9094
if (addedSection) {
9195
const requirements = this.parseRequirements(addedSection);
9296
requirements.forEach(req => {
9397
deltas.push({
9498
spec: specName,
9599
operation: 'ADDED' as DeltaOperation,
96-
description: `Add requirement: ${req.text}`,
100+
description: `新增需求:${req.text}`,
97101
// Provide both single and plural forms for compatibility
98102
requirement: req,
99103
requirements: [req],
100104
});
101105
});
102106
}
103-
104-
// Parse MODIFIED requirements
105-
const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');
107+
108+
// Parse MODIFIED requirements (支援多語言)
109+
const modifiedSection = findSectionByTitleVariants(sections, DELTA_SECTIONS.MODIFIED);
106110
if (modifiedSection) {
107111
const requirements = this.parseRequirements(modifiedSection);
108112
requirements.forEach(req => {
109113
deltas.push({
110114
spec: specName,
111115
operation: 'MODIFIED' as DeltaOperation,
112-
description: `Modify requirement: ${req.text}`,
116+
description: `修改需求:${req.text}`,
113117
requirement: req,
114118
requirements: [req],
115119
});
116120
});
117121
}
118-
119-
// Parse REMOVED requirements
120-
const removedSection = this.findSection(sections, 'REMOVED Requirements');
122+
123+
// Parse REMOVED requirements (支援多語言)
124+
const removedSection = findSectionByTitleVariants(sections, DELTA_SECTIONS.REMOVED);
121125
if (removedSection) {
122126
const requirements = this.parseRequirements(removedSection);
123127
requirements.forEach(req => {
124128
deltas.push({
125129
spec: specName,
126130
operation: 'REMOVED' as DeltaOperation,
127-
description: `Remove requirement: ${req.text}`,
131+
description: `移除需求:${req.text}`,
128132
requirement: req,
129133
requirements: [req],
130134
});
131135
});
132136
}
133-
134-
// Parse RENAMED requirements
135-
const renamedSection = this.findSection(sections, 'RENAMED Requirements');
137+
138+
// Parse RENAMED requirements (支援多語言)
139+
const renamedSection = findSectionByTitleVariants(sections, DELTA_SECTIONS.RENAMED);
136140
if (renamedSection) {
137141
const renames = this.parseRenames(renamedSection.content);
138142
renames.forEach(rename => {
139143
deltas.push({
140144
spec: specName,
141145
operation: 'RENAMED' as DeltaOperation,
142-
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
146+
description: `重新命名需求:從「${rename.from}」改為「${rename.to}`,
143147
rename,
144148
});
145149
});
146150
}
147-
151+
148152
return deltas;
149153
}
150154

151155
private parseRenames(content: string): Array<{ from: string; to: string }> {
152156
const renames: Array<{ from: string; to: string }> = [];
153157
const lines = ChangeParser.normalizeContent(content).split('\n');
154-
158+
155159
let currentRename: { from?: string; to?: string } = {};
156-
160+
157161
for (const line of lines) {
158-
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
159-
const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
160-
162+
// 支援多語言:FROM: 或 從:, ### Requirement: 或 ### 需求:
163+
const fromMatch = line.match(/^\s*-?\s*(?:FROM:|)\s*`?###\s*(?:Requirement:|)\s*(.+?)`?\s*$/);
164+
const toMatch = line.match(/^\s*-?\s*(?:TO:|)\s*`?###\s*(?:Requirement:|)\s*(.+?)`?\s*$/);
165+
161166
if (fromMatch) {
162167
currentRename.from = fromMatch[1].trim();
163168
} else if (toMatch) {
164169
currentRename.to = toMatch[1].trim();
165-
170+
166171
if (currentRename.from && currentRename.to) {
167172
renames.push({
168173
from: currentRename.from,
@@ -172,7 +177,7 @@ export class ChangeParser extends MarkdownParser {
172177
}
173178
}
174179
}
175-
180+
176181
return renames;
177182
}
178183

src/core/parsers/markdown-parser.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
2+
import { SPEC_SECTIONS, CHANGE_SECTIONS, findSectionByTitleVariants } from '../i18n/section-titles.js';
23

34
export interface Section {
45
level: number;
@@ -23,16 +24,17 @@ export class MarkdownParser {
2324

2425
parseSpec(name: string): Spec {
2526
const sections = this.parseSections();
26-
const purpose = this.findSection(sections, 'Purpose')?.content || '';
27-
28-
const requirementsSection = this.findSection(sections, 'Requirements');
29-
27+
const purposeSection = findSectionByTitleVariants(sections, SPEC_SECTIONS.PURPOSE);
28+
const requirementsSection = findSectionByTitleVariants(sections, SPEC_SECTIONS.REQUIREMENTS);
29+
30+
const purpose = purposeSection?.content || '';
31+
3032
if (!purpose) {
31-
throw new Error('Spec must have a Purpose section');
33+
throw new Error('規範必須包含目的區段');
3234
}
33-
35+
3436
if (!requirementsSection) {
35-
throw new Error('Spec must have a Requirements section');
37+
throw new Error('規範必須包含需求區段');
3638
}
3739

3840
const requirements = this.parseRequirements(requirementsSection);
@@ -50,15 +52,18 @@ export class MarkdownParser {
5052

5153
parseChange(name: string): Change {
5254
const sections = this.parseSections();
53-
const why = this.findSection(sections, 'Why')?.content || '';
54-
const whatChanges = this.findSection(sections, 'What Changes')?.content || '';
55-
55+
const whySection = findSectionByTitleVariants(sections, CHANGE_SECTIONS.WHY);
56+
const whatChangesSection = findSectionByTitleVariants(sections, CHANGE_SECTIONS.WHAT_CHANGES);
57+
58+
const why = whySection?.content || '';
59+
const whatChanges = whatChangesSection?.content || '';
60+
5661
if (!why) {
57-
throw new Error('Change must have a Why section');
62+
throw new Error('變更必須包含為什麼區段');
5863
}
59-
64+
6065
if (!whatChanges) {
61-
throw new Error('Change must have a What Changes section');
66+
throw new Error('變更必須包含變更內容區段');
6267
}
6368

6469
const deltas = this.parseDeltas(whatChanges);

0 commit comments

Comments
 (0)