@aigne/doc-smith 0.8.12-beta → 0.8.12-beta.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.
Files changed (54) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/agents/generate/check-diagram.mjs +40 -0
  4. package/agents/generate/draw-diagram.yaml +23 -0
  5. package/agents/generate/generate-structure.yaml +5 -1
  6. package/agents/generate/merge-d2-diagram.yaml +3 -3
  7. package/agents/generate/update-document-structure.yaml +5 -2
  8. package/agents/generate/user-review-document-structure.mjs +7 -0
  9. package/agents/generate/wrap-diagram-code.mjs +35 -0
  10. package/agents/history/index.yaml +6 -0
  11. package/agents/history/view.mjs +75 -0
  12. package/agents/translate/index.yaml +3 -2
  13. package/agents/translate/record-translation-history.mjs +19 -0
  14. package/agents/translate/translate-multilingual.yaml +2 -1
  15. package/agents/update/batch-update-document.yaml +1 -1
  16. package/agents/update/check-document.mjs +5 -1
  17. package/agents/update/generate-document.yaml +31 -25
  18. package/agents/update/{generate-and-translate-document.yaml → handle-document-update.yaml} +2 -11
  19. package/agents/update/index.yaml +1 -0
  20. package/agents/update/save-and-translate-document.mjs +106 -0
  21. package/agents/update/update-document-detail.yaml +5 -1
  22. package/agents/update/update-single-document.yaml +1 -10
  23. package/agents/update/user-review-document.mjs +10 -1
  24. package/aigne.yaml +8 -1
  25. package/package.json +1 -1
  26. package/prompts/detail/d2-diagram/guide.md +19 -0
  27. package/prompts/detail/d2-diagram/role-and-personality.md +2 -0
  28. package/prompts/detail/d2-diagram/rules.md +24 -0
  29. package/prompts/detail/d2-diagram/{rules-system.md → system-prompt.md} +3 -9
  30. package/prompts/detail/{document-rules.md → generate/document-rules.md} +1 -1
  31. package/prompts/detail/generate/system-prompt.md +72 -0
  32. package/prompts/detail/generate/user-prompt.md +54 -0
  33. package/prompts/detail/{update-document.md → update/system-prompt.md} +43 -67
  34. package/prompts/detail/update/user-prompt.md +33 -0
  35. package/prompts/structure/{generate-structure-system.md → generate/system-prompt.md} +7 -40
  36. package/prompts/structure/{generate-structure-user.md → generate/user-prompt.md} +17 -13
  37. package/prompts/structure/{update-document-structure.md → update/system-prompt.md} +16 -27
  38. package/prompts/structure/update/user-prompt.md +19 -0
  39. package/tests/agents/generate/user-review-document-structure.test.mjs +2 -0
  40. package/tests/agents/update/check-document.test.mjs +1 -1
  41. package/tests/agents/update/save-and-translate-document.test.mjs +369 -0
  42. package/tests/agents/utils/check-detail-result.test.mjs +13 -0
  43. package/tests/utils/d2-utils.test.mjs +14 -0
  44. package/tests/utils/docs-finder-utils.test.mjs +13 -0
  45. package/tests/utils/history-utils.test.mjs +178 -0
  46. package/utils/d2-utils.mjs +9 -0
  47. package/utils/docs-finder-utils.mjs +10 -1
  48. package/utils/history-utils.mjs +191 -0
  49. package/utils/markdown-checker.mjs +20 -0
  50. package/agents/generate/check-d2-diagram-valid.mjs +0 -26
  51. package/agents/generate/generate-d2-diagram.yaml +0 -23
  52. package/prompts/detail/generate-document.md +0 -125
  53. /package/prompts/detail/d2-diagram/{rules-user.md → user-prompt.md} +0 -0
  54. /package/prompts/detail/{detail-example.md → generate/detail-example.md} +0 -0
@@ -0,0 +1,369 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
2
+ import saveAndTranslateDocument from "../../../agents/update/save-and-translate-document.mjs";
3
+ import * as historyUtils from "../../../utils/history-utils.mjs";
4
+
5
+ describe("save-and-translate-document", () => {
6
+ let mockOptions;
7
+ let consoleErrorSpy;
8
+ let recordUpdateSpy;
9
+
10
+ beforeEach(() => {
11
+ // Reset all mocks
12
+ mock.restore();
13
+
14
+ mockOptions = {
15
+ prompts: {
16
+ select: mock(async () => "no"),
17
+ },
18
+ context: {
19
+ agents: {
20
+ saveSingleDoc: { mockSaveAgent: true },
21
+ translateMultilingual: { mockTranslateAgent: true },
22
+ },
23
+ invoke: mock(async () => ({ mockResult: true })),
24
+ },
25
+ };
26
+
27
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
28
+ recordUpdateSpy = spyOn(historyUtils, "recordUpdate").mockImplementation(() => {});
29
+
30
+ // Clear context mock call history
31
+ mockOptions.prompts.select.mockClear();
32
+ mockOptions.context.invoke.mockClear();
33
+ });
34
+
35
+ afterEach(() => {
36
+ consoleErrorSpy?.mockRestore();
37
+ recordUpdateSpy?.mockRestore();
38
+ });
39
+
40
+ // INPUT VALIDATION TESTS
41
+ test("should handle empty or invalid selectedDocs", async () => {
42
+ const testCases = [
43
+ { selectedDocs: [], description: "empty array" },
44
+ { selectedDocs: null, description: "null" },
45
+ { selectedDocs: undefined, description: "undefined" },
46
+ { selectedDocs: "not-array", description: "non-array" },
47
+ ];
48
+
49
+ for (const testCase of testCases) {
50
+ const input = {
51
+ selectedDocs: testCase.selectedDocs,
52
+ docsDir: "./docs",
53
+ translateLanguages: ["en", "zh"],
54
+ locale: "en",
55
+ };
56
+
57
+ const result = await saveAndTranslateDocument(input, mockOptions);
58
+
59
+ expect(result).toEqual({});
60
+ expect(mockOptions.context.invoke).not.toHaveBeenCalled();
61
+ expect(mockOptions.prompts.select).not.toHaveBeenCalled();
62
+ }
63
+ });
64
+
65
+ // SCENARIO 1: NO TRANSLATION CONFIGURATION
66
+ test("should skip translation when no translation languages configured", async () => {
67
+ const testCases = [
68
+ { translateLanguages: null, description: "null" },
69
+ { translateLanguages: undefined, description: "undefined" },
70
+ { translateLanguages: [], description: "empty array" },
71
+ { translateLanguages: ["en"], description: "only current locale" },
72
+ ];
73
+
74
+ for (const testCase of testCases) {
75
+ const input = {
76
+ selectedDocs: [
77
+ {
78
+ path: "/docs/test.md",
79
+ content: "# Test Document",
80
+ translates: {},
81
+ labels: {},
82
+ feedback: "Good content",
83
+ },
84
+ ],
85
+ docsDir: "./docs",
86
+ translateLanguages: testCase.translateLanguages,
87
+ locale: "en",
88
+ };
89
+
90
+ const result = await saveAndTranslateDocument(input, mockOptions);
91
+
92
+ expect(result).toEqual({});
93
+ expect(mockOptions.prompts.select).not.toHaveBeenCalled();
94
+ expect(mockOptions.context.invoke).toHaveBeenCalledTimes(1);
95
+ expect(recordUpdateSpy).toHaveBeenCalledWith({
96
+ operation: "document_update",
97
+ feedback: "Good content",
98
+ documentPath: "/docs/test.md",
99
+ });
100
+
101
+ // Reset mocks for next iteration
102
+ mockOptions.prompts.select.mockClear();
103
+ mockOptions.context.invoke.mockClear();
104
+ recordUpdateSpy.mockClear();
105
+ }
106
+ });
107
+
108
+ // SCENARIO 2: USER CHOOSES NOT TO TRANSLATE
109
+ test("should save documents and skip translation when user chooses no", async () => {
110
+ const input = {
111
+ selectedDocs: [
112
+ {
113
+ path: "/docs/test1.md",
114
+ content: "# Test Document 1",
115
+ translates: {},
116
+ labels: {},
117
+ feedback: "Update needed",
118
+ },
119
+ {
120
+ path: "/docs/test2.md",
121
+ content: "# Test Document 2",
122
+ translates: {},
123
+ labels: {},
124
+ feedback: "Second feedback",
125
+ },
126
+ {
127
+ path: "/docs/test3.md",
128
+ content: "# Test Document 3",
129
+ translates: {},
130
+ labels: {},
131
+ feedback: " ", // Whitespace only
132
+ },
133
+ ],
134
+ docsDir: "./docs",
135
+ translateLanguages: ["en", "zh", "ja"],
136
+ locale: "en",
137
+ };
138
+
139
+ mockOptions.prompts.select.mockResolvedValue("no");
140
+
141
+ const result = await saveAndTranslateDocument(input, mockOptions);
142
+
143
+ expect(result).toEqual({});
144
+ expect(mockOptions.prompts.select).toHaveBeenCalledWith({
145
+ message: "Document update completed. Would you like to translate these documents now?",
146
+ choices: [
147
+ {
148
+ name: "Review documents first, translate later",
149
+ value: "no",
150
+ },
151
+ {
152
+ name: "Translate now",
153
+ value: "yes",
154
+ },
155
+ ],
156
+ });
157
+ expect(mockOptions.context.invoke).toHaveBeenCalledTimes(3); // Only saveDocument calls
158
+ expect(recordUpdateSpy).toHaveBeenCalledTimes(2); // Only documents with non-empty feedback
159
+ expect(recordUpdateSpy).toHaveBeenCalledWith({
160
+ operation: "document_update",
161
+ feedback: "Update needed",
162
+ documentPath: "/docs/test1.md",
163
+ });
164
+ expect(recordUpdateSpy).toHaveBeenCalledWith({
165
+ operation: "document_update",
166
+ feedback: "Second feedback",
167
+ documentPath: "/docs/test2.md",
168
+ });
169
+ });
170
+
171
+ // SCENARIO 3: USER CHOOSES TO TRANSLATE
172
+ test("should save and translate documents when user chooses yes", async () => {
173
+ const input = {
174
+ selectedDocs: [
175
+ {
176
+ path: "/docs/test1.md",
177
+ content: "# Test Document 1",
178
+ translates: {},
179
+ labels: {},
180
+ feedback: "Translation needed",
181
+ title: "Test Document 1",
182
+ },
183
+ {
184
+ path: "/docs/test2.md",
185
+ content: "# Test Document 2",
186
+ translates: {},
187
+ labels: {},
188
+ title: "Test Document 2",
189
+ },
190
+ ],
191
+ docsDir: "./docs",
192
+ translateLanguages: ["en", "zh"],
193
+ locale: "en",
194
+ };
195
+
196
+ mockOptions.prompts.select.mockResolvedValue("yes");
197
+ mockOptions.context.invoke
198
+ .mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument 1
199
+ .mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument 2
200
+ .mockResolvedValueOnce({ translates: { zh: "# 测试文档 1" } }) // translateMultilingual 1
201
+ .mockResolvedValueOnce({ translates: { zh: "# 测试文档 2" } }) // translateMultilingual 2
202
+ .mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument with translation 1
203
+ .mockResolvedValueOnce({ mockSaveResult: true }); // saveDocument with translation 2
204
+
205
+ const result = await saveAndTranslateDocument(input, mockOptions);
206
+
207
+ expect(result).toEqual({});
208
+ expect(mockOptions.prompts.select).toHaveBeenCalledWith({
209
+ message: "Document update completed. Would you like to translate these documents now?",
210
+ choices: [
211
+ {
212
+ name: "Review documents first, translate later",
213
+ value: "no",
214
+ },
215
+ {
216
+ name: "Translate now",
217
+ value: "yes",
218
+ },
219
+ ],
220
+ });
221
+ expect(mockOptions.context.invoke).toHaveBeenCalledTimes(6);
222
+
223
+ // Verify feedback is cleared before translation
224
+ expect(input.selectedDocs[0].feedback).toBe("");
225
+ expect(input.selectedDocs[1].feedback).toBe("");
226
+
227
+ expect(recordUpdateSpy).toHaveBeenCalledWith({
228
+ operation: "document_update",
229
+ feedback: "Translation needed",
230
+ documentPath: "/docs/test1.md",
231
+ });
232
+ });
233
+
234
+ // ERROR HANDLING TESTS
235
+ test("should handle errors gracefully", async () => {
236
+ // Test saveDocument error
237
+ const saveErrorInput = {
238
+ selectedDocs: [
239
+ {
240
+ path: "/docs/test1.md",
241
+ content: "# Test Document 1",
242
+ translates: {},
243
+ labels: {},
244
+ feedback: "Error test",
245
+ },
246
+ ],
247
+ docsDir: "./docs",
248
+ translateLanguages: ["en", "zh"],
249
+ locale: "en",
250
+ };
251
+
252
+ mockOptions.prompts.select.mockResolvedValue("no");
253
+ mockOptions.context.invoke.mockRejectedValue(new Error("Save failed"));
254
+
255
+ const saveErrorResult = await saveAndTranslateDocument(saveErrorInput, mockOptions);
256
+
257
+ expect(saveErrorResult).toEqual({});
258
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
259
+ "❌ Failed to save document /docs/test1.md:",
260
+ "Save failed",
261
+ );
262
+ expect(recordUpdateSpy).not.toHaveBeenCalled(); // Should not record if save failed
263
+
264
+ // Reset mocks
265
+ mockOptions.prompts.select.mockClear();
266
+ mockOptions.context.invoke.mockClear();
267
+ consoleErrorSpy.mockClear();
268
+ recordUpdateSpy.mockClear();
269
+
270
+ // Test translateMultilingual error
271
+ const translateErrorInput = {
272
+ selectedDocs: [
273
+ {
274
+ path: "/docs/test2.md",
275
+ content: "# Test Document 2",
276
+ translates: {},
277
+ labels: {},
278
+ title: "Test Document 2",
279
+ },
280
+ ],
281
+ docsDir: "./docs",
282
+ translateLanguages: ["en", "zh"],
283
+ locale: "en",
284
+ };
285
+
286
+ mockOptions.prompts.select.mockResolvedValue("yes");
287
+ mockOptions.context.invoke
288
+ .mockResolvedValueOnce({ mockSaveResult: true }) // saveDocument succeeds
289
+ .mockRejectedValueOnce(new Error("Translation failed")); // translateMultilingual fails
290
+
291
+ const translateErrorResult = await saveAndTranslateDocument(translateErrorInput, mockOptions);
292
+
293
+ expect(translateErrorResult).toEqual({});
294
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
295
+ "❌ Failed to translate document /docs/test2.md:",
296
+ "Translation failed",
297
+ );
298
+ expect(mockOptions.context.invoke).toHaveBeenCalledTimes(2);
299
+ });
300
+
301
+ // EDGE CASES AND INTEGRATION
302
+ test("should handle edge cases and complex scenarios", async () => {
303
+ // Test edge cases with different document properties
304
+ const edgeCaseInput = {
305
+ selectedDocs: [
306
+ {
307
+ path: "/docs/test1.md",
308
+ content: "# Test Document 1",
309
+ translates: null, // null translates
310
+ labels: {},
311
+ feedback: "", // empty feedback
312
+ },
313
+ {
314
+ path: "/docs/test2.md",
315
+ content: "# Test Document 2",
316
+ translates: undefined, // undefined translates
317
+ labels: {},
318
+ feedback: " ", // whitespace feedback
319
+ },
320
+ {
321
+ path: "/docs/test3.md",
322
+ content: "# Test Document 3",
323
+ translates: {},
324
+ labels: {},
325
+ // no title
326
+ },
327
+ ],
328
+ docsDir: "./docs",
329
+ translateLanguages: ["en", "zh"],
330
+ locale: "en",
331
+ };
332
+
333
+ mockOptions.prompts.select.mockResolvedValue("no");
334
+
335
+ const edgeCaseResult = await saveAndTranslateDocument(edgeCaseInput, mockOptions);
336
+
337
+ expect(edgeCaseResult).toEqual({});
338
+ expect(mockOptions.context.invoke).toHaveBeenCalledTimes(3);
339
+ expect(recordUpdateSpy).not.toHaveBeenCalled(); // No valid feedback
340
+
341
+ // Reset mocks
342
+ mockOptions.prompts.select.mockClear();
343
+ mockOptions.context.invoke.mockClear();
344
+ recordUpdateSpy.mockClear();
345
+
346
+ // Test batch processing with multiple documents
347
+ const batchInput = {
348
+ selectedDocs: Array.from({ length: 5 }, (_, i) => ({
349
+ path: `/docs/batch${i + 1}.md`,
350
+ content: `# Batch Document ${i + 1}`,
351
+ translates: {},
352
+ labels: {},
353
+ title: `Batch Document ${i + 1}`,
354
+ })),
355
+ docsDir: "./docs",
356
+ translateLanguages: ["en", "zh"],
357
+ locale: "en",
358
+ };
359
+
360
+ mockOptions.prompts.select.mockResolvedValue("yes");
361
+ mockOptions.context.invoke.mockResolvedValue({ mockResult: true });
362
+
363
+ const batchResult = await saveAndTranslateDocument(batchInput, mockOptions);
364
+
365
+ expect(batchResult).toEqual({});
366
+ // 5 documents * 3 calls each (save, translate, save) = 15 calls
367
+ expect(mockOptions.context.invoke).toHaveBeenCalledTimes(15);
368
+ });
369
+ });
@@ -337,6 +337,19 @@ This document demonstrates various programming language code blocks.`;
337
337
  });
338
338
  });
339
339
 
340
+ describe("D2 syntax validation", () => {
341
+ test("should handle D2 syntax errors", async () => {
342
+ const documentStructure = [];
343
+ const reviewContent =
344
+ "```d2\n" +
345
+ "invalid d2 syntax {{\n" + // Malformed D2
346
+ "```\n\n" +
347
+ "This has proper structure.";
348
+ const result = await checkDetailResult({ documentStructure, reviewContent });
349
+ expect(result.isApproved).toBe(false);
350
+ });
351
+ });
352
+
340
353
  describe("Advanced table edge cases", () => {
341
354
  test("should handle empty table cells correctly", async () => {
342
355
  const documentStructure = [];
@@ -14,6 +14,7 @@ import {
14
14
  getChart,
15
15
  isValidCode,
16
16
  saveAssets,
17
+ wrapCode,
17
18
  } from "../../utils/d2-utils.mjs";
18
19
 
19
20
  describe("d2-utils", () => {
@@ -420,4 +421,17 @@ E -> F
420
421
  expect(isValidCode(undefined)).toBe(false);
421
422
  });
422
423
  });
424
+
425
+ describe("wrapCode", () => {
426
+ test("should return original content when D2 block already exists", () => {
427
+ const content = "```d2\nA -> B\n```";
428
+ expect(wrapCode({ content })).toBe(content);
429
+ });
430
+
431
+ test("should wrap plain content in a D2 code block", () => {
432
+ const content = "A -> B";
433
+ const expected = "```d2\nA -> B\n```";
434
+ expect(wrapCode({ content })).toBe(expected);
435
+ });
436
+ });
423
437
  });
@@ -6,6 +6,7 @@ import {
6
6
  fileNameToFlatPath,
7
7
  findItemByFlatName,
8
8
  findItemByPath,
9
+ generateFileName,
9
10
  getActionText,
10
11
  getMainLanguageFiles,
11
12
  processSelectedFiles,
@@ -633,5 +634,17 @@ describe("docs-finder-utils", () => {
633
634
  "⚠️ No documentation structure item found for file: test.md",
634
635
  );
635
636
  });
637
+
638
+ test("fileNameToFlatPath should handle files with multiple language suffixes", () => {
639
+ expect(fileNameToFlatPath("file.zh-CN.md")).toBe("file");
640
+ expect(fileNameToFlatPath("file.en-US.md")).toBe("file");
641
+ expect(fileNameToFlatPath("file.fr-FR.md")).toBe("file");
642
+ });
643
+
644
+ test("generateFileName should handle special characters in flatName", () => {
645
+ expect(generateFileName("api-v1-guide", "en")).toBe("api-v1-guide.md");
646
+ expect(generateFileName("api-v1-guide", "zh")).toBe("api-v1-guide.zh.md");
647
+ expect(generateFileName("test_special-chars", "fr")).toBe("test_special-chars.fr.md");
648
+ });
636
649
  });
637
650
  });
@@ -0,0 +1,178 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DOC_SMITH_DIR } from "../../utils/constants/index.mjs";
5
+ import { getHistory, isGitAvailable, recordUpdate } from "../../utils/history-utils.mjs";
6
+
7
+ const TEST_DIR = join(process.cwd(), `${DOC_SMITH_DIR}-test`);
8
+ const ORIGINAL_CWD = process.cwd();
9
+
10
+ describe("History Utils - Unified", () => {
11
+ beforeEach(() => {
12
+ // Clean up test directory
13
+ if (existsSync(TEST_DIR)) {
14
+ rmSync(TEST_DIR, { recursive: true });
15
+ }
16
+ mkdirSync(TEST_DIR, { recursive: true });
17
+
18
+ // Change to test directory
19
+ process.chdir(TEST_DIR);
20
+ });
21
+
22
+ afterEach(() => {
23
+ // Restore original directory
24
+ process.chdir(ORIGINAL_CWD);
25
+
26
+ // Clean up
27
+ if (existsSync(TEST_DIR)) {
28
+ rmSync(TEST_DIR, { recursive: true });
29
+ }
30
+ });
31
+
32
+ test("detects git availability", () => {
33
+ const hasGit = isGitAvailable();
34
+ expect(typeof hasGit).toBe("boolean");
35
+ });
36
+
37
+ test("skips recording on empty feedback", () => {
38
+ recordUpdate({ operation: "document_update", feedback: "" });
39
+ const history = getHistory();
40
+ expect(history.entries.length).toBe(0);
41
+ });
42
+
43
+ test("skips recording on whitespace-only feedback", () => {
44
+ recordUpdate({ operation: "document_update", feedback: " " });
45
+ const history = getHistory();
46
+ expect(history.entries.length).toBe(0);
47
+ });
48
+
49
+ test("records update in YAML", () => {
50
+ recordUpdate({
51
+ operation: "structure_update",
52
+ feedback: "Test feedback",
53
+ });
54
+
55
+ const history = getHistory();
56
+ expect(history.entries.length).toBe(1);
57
+ expect(history.entries[0].feedback).toBe("Test feedback");
58
+ expect(history.entries[0].operation).toBe("structure_update");
59
+ expect(history.entries[0].timestamp).toBeDefined();
60
+ });
61
+
62
+ test("records document path when provided as string", () => {
63
+ recordUpdate({
64
+ operation: "document_update",
65
+ feedback: "Update document",
66
+ documentPath: "/getting-started",
67
+ });
68
+
69
+ const history = getHistory();
70
+ expect(history.entries.length).toBe(1);
71
+ expect(history.entries[0].documentPath).toBe("/getting-started");
72
+ });
73
+
74
+ test("records single document path for each update", () => {
75
+ recordUpdate({
76
+ operation: "document_update",
77
+ feedback: "Update single document",
78
+ documentPath: "/getting-started",
79
+ });
80
+
81
+ const history = getHistory();
82
+ expect(history.entries.length).toBe(1);
83
+ expect(history.entries[0].feedback).toBe("Update single document");
84
+ expect(history.entries[0].documentPath).toBe("/getting-started");
85
+ });
86
+
87
+ test("does not include documentPath field when documentPath is null", () => {
88
+ recordUpdate({
89
+ operation: "structure_update",
90
+ feedback: "Update structure",
91
+ documentPath: null,
92
+ });
93
+
94
+ const history = getHistory();
95
+ expect(history.entries.length).toBe(1);
96
+ expect(history.entries[0].documentPath).toBeUndefined();
97
+ });
98
+
99
+ test("maintains chronological order (newest first)", () => {
100
+ recordUpdate({ operation: "structure_update", feedback: "First" });
101
+ // Small delay to ensure different timestamps
102
+ const now = Date.now();
103
+ while (Date.now() === now) {
104
+ // Wait for next millisecond
105
+ }
106
+ recordUpdate({ operation: "document_update", feedback: "Second" });
107
+
108
+ const history = getHistory();
109
+ expect(history.entries.length).toBe(2);
110
+ expect(history.entries[0].feedback).toBe("Second");
111
+ expect(history.entries[1].feedback).toBe("First");
112
+ });
113
+
114
+ test("handles multiple updates", () => {
115
+ recordUpdate({ operation: "structure_update", feedback: "Update 1" });
116
+ recordUpdate({ operation: "document_update", feedback: "Update 2", documentPath: "/home" });
117
+ recordUpdate({ operation: "document_update", feedback: "Update 3", documentPath: "/about" });
118
+ recordUpdate({
119
+ operation: "translation_update",
120
+ feedback: "Update 4",
121
+ documentPath: "/api",
122
+ });
123
+
124
+ const history = getHistory();
125
+ expect(history.entries.length).toBe(4);
126
+ expect(history.entries[0].feedback).toBe("Update 4");
127
+ expect(history.entries[0].documentPath).toBe("/api");
128
+ expect(history.entries[1].feedback).toBe("Update 3");
129
+ expect(history.entries[1].documentPath).toBe("/about");
130
+ expect(history.entries[2].feedback).toBe("Update 2");
131
+ expect(history.entries[2].documentPath).toBe("/home");
132
+ expect(history.entries[3].feedback).toBe("Update 1");
133
+ });
134
+
135
+ test("returns empty history when file does not exist", () => {
136
+ const history = getHistory();
137
+ expect(history.entries).toBeDefined();
138
+ expect(history.entries.length).toBe(0);
139
+ });
140
+
141
+ test("handles corrupted history file gracefully", () => {
142
+ // Create corrupted YAML file
143
+ const historyPath = join(process.cwd(), DOC_SMITH_DIR, "history.yaml");
144
+ mkdirSync(join(process.cwd(), DOC_SMITH_DIR), { recursive: true });
145
+ writeFileSync(historyPath, "invalid: yaml: content: [[[", "utf8");
146
+
147
+ const history = getHistory();
148
+ expect(history.entries).toBeDefined();
149
+ expect(history.entries.length).toBe(0);
150
+ });
151
+
152
+ test(`creates ${DOC_SMITH_DIR} directory if not exists`, () => {
153
+ recordUpdate({
154
+ operation: "document_update",
155
+ feedback: "Test",
156
+ });
157
+
158
+ const docSmithDir = join(process.cwd(), DOC_SMITH_DIR);
159
+ expect(existsSync(docSmithDir)).toBe(true);
160
+ });
161
+
162
+ test("timestamp is in ISO 8601 format", () => {
163
+ recordUpdate({
164
+ operation: "structure_update",
165
+ feedback: "Test timestamp",
166
+ });
167
+
168
+ const history = getHistory();
169
+ const timestamp = history.entries[0].timestamp;
170
+
171
+ // Validate ISO 8601 format
172
+ expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
173
+
174
+ // Validate it's a valid date
175
+ const date = new Date(timestamp);
176
+ expect(date.toISOString()).toBe(timestamp);
177
+ });
178
+ });
@@ -195,3 +195,12 @@ export async function ensureTmpDir() {
195
195
  export function isValidCode(lang) {
196
196
  return lang?.toLowerCase() === "d2";
197
197
  }
198
+
199
+ export function wrapCode({ content }) {
200
+ const matches = Array.from(content.matchAll(codeBlockRegex));
201
+ if (matches.length > 0) {
202
+ return content;
203
+ }
204
+
205
+ return `\`\`\`d2\n${content}\n\`\`\``;
206
+ }
@@ -12,13 +12,22 @@ export function getActionText(isTranslate, baseText) {
12
12
  return baseText.replace("{action}", action);
13
13
  }
14
14
 
15
+ /**
16
+ * Convert path to flattened name format
17
+ * @param {string} path - Document path (e.g., "/api/users")
18
+ * @returns {string} Flattened name (e.g., "api-users")
19
+ */
20
+ export function pathToFlatName(path) {
21
+ return path.replace(/^\//, "").replace(/\//g, "-");
22
+ }
23
+
15
24
  /**
16
25
  * Generate filename based on flattened path and locale
17
26
  * @param {string} flatName - Flattened path name
18
27
  * @param {string} locale - Main language locale (e.g., 'en', 'zh', 'fr')
19
28
  * @returns {string} Generated filename
20
29
  */
21
- function generateFileName(flatName, locale) {
30
+ export function generateFileName(flatName, locale) {
22
31
  const isEnglish = locale === "en";
23
32
  return isEnglish ? `${flatName}.md` : `${flatName}.${locale}.md`;
24
33
  }