@aigne/doc-smith 0.2.5 → 0.2.8

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 (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -0
  3. package/agents/check-detail-result.mjs +13 -139
  4. package/agents/check-detail.mjs +4 -6
  5. package/agents/check-structure-plan.mjs +56 -12
  6. package/agents/detail-generator-and-translate.yaml +7 -1
  7. package/agents/detail-regenerator.yaml +3 -1
  8. package/agents/docs-generator.yaml +2 -1
  9. package/agents/find-item-by-path.mjs +64 -15
  10. package/agents/input-generator.mjs +31 -11
  11. package/agents/language-selector.mjs +89 -0
  12. package/agents/load-config.mjs +2 -2
  13. package/agents/load-sources.mjs +13 -40
  14. package/agents/publish-docs.mjs +47 -161
  15. package/agents/retranslate.yaml +74 -0
  16. package/agents/save-docs.mjs +19 -21
  17. package/agents/save-output.mjs +2 -9
  18. package/agents/save-single-doc.mjs +20 -1
  19. package/agents/schema/structure-plan.yaml +1 -1
  20. package/agents/structure-planning.yaml +6 -0
  21. package/agents/transform-detail-datasources.mjs +2 -5
  22. package/agents/translate.yaml +3 -0
  23. package/aigne.yaml +5 -1
  24. package/biome.json +13 -3
  25. package/docs-mcp/get-docs-structure.mjs +1 -1
  26. package/docs-mcp/read-doc-content.mjs +1 -4
  27. package/package.json +20 -7
  28. package/prompts/check-structure-planning-result.md +4 -7
  29. package/prompts/content-detail-generator.md +1 -2
  30. package/prompts/structure-planning.md +7 -2
  31. package/prompts/translator.md +4 -0
  32. package/tests/check-detail-result.test.mjs +8 -19
  33. package/tests/load-sources.test.mjs +65 -161
  34. package/tests/test-all-validation-cases.mjs +741 -0
  35. package/tests/test-save-docs.mjs +6 -17
  36. package/utils/constants.mjs +1 -2
  37. package/utils/markdown-checker.mjs +453 -0
  38. package/utils/mermaid-validator.mjs +153 -0
  39. package/utils/mermaid-worker-pool.mjs +250 -0
  40. package/utils/mermaid-worker.mjs +233 -0
  41. package/utils/utils.mjs +162 -114
package/utils/utils.mjs CHANGED
@@ -1,20 +1,10 @@
1
+ import { execSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
1
3
  import fs from "node:fs/promises";
2
4
  import path from "node:path";
3
- import { execSync } from "node:child_process";
4
- import {
5
- existsSync,
6
- mkdirSync,
7
- readdirSync,
8
- accessSync,
9
- constants,
10
- statSync,
11
- } from "node:fs";
12
- import { parse } from "yaml";
13
5
  import chalk from "chalk";
14
- import {
15
- DEFAULT_INCLUDE_PATTERNS,
16
- DEFAULT_EXCLUDE_PATTERNS,
17
- } from "./constants.mjs";
6
+ import { parse } from "yaml";
7
+ import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_INCLUDE_PATTERNS } from "./constants.mjs";
18
8
 
19
9
  /**
20
10
  * Normalize path to absolute path for consistent comparison
@@ -22,9 +12,7 @@ import {
22
12
  * @returns {string} - Absolute path
23
13
  */
24
14
  export function normalizePath(filePath) {
25
- return path.isAbsolute(filePath)
26
- ? filePath
27
- : path.resolve(process.cwd(), filePath);
15
+ return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
28
16
  }
29
17
 
30
18
  /**
@@ -33,36 +21,31 @@ export function normalizePath(filePath) {
33
21
  * @returns {string} - Relative path
34
22
  */
35
23
  export function toRelativePath(filePath) {
36
- return path.isAbsolute(filePath)
37
- ? path.relative(process.cwd(), filePath)
38
- : filePath;
24
+ return path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) : filePath;
39
25
  }
40
26
 
41
27
  export function processContent({ content }) {
42
28
  // Match markdown regular links [text](link), exclude images ![text](link)
43
- return content.replace(
44
- /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g,
45
- (match, text, link) => {
46
- const trimLink = link.trim();
47
- // Exclude external links and mailto
48
- if (/^(https?:\/\/|mailto:)/.test(trimLink)) return match;
49
- // Preserve anchors
50
- const [path, hash] = trimLink.split("#");
51
- // Skip if already has extension
52
- if (/\.[a-zA-Z0-9]+$/.test(path)) return match;
53
- // Only process relative paths or paths starting with /
54
- if (!path) return match;
55
- // Flatten to ./xxx-yyy.md
56
- let finalPath = path;
57
- if (path.startsWith(".")) {
58
- finalPath = path.replace(/^\./, "");
59
- }
60
- let flatPath = finalPath.replace(/^\//, "").replace(/\//g, "-");
61
- flatPath = `./${flatPath}.md`;
62
- const newLink = hash ? `${flatPath}#${hash}` : flatPath;
63
- return `[${text}](${newLink})`;
29
+ return content.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, (match, text, link) => {
30
+ const trimLink = link.trim();
31
+ // Exclude external links and mailto
32
+ if (/^(https?:\/\/|mailto:)/.test(trimLink)) return match;
33
+ // Preserve anchors
34
+ const [path, hash] = trimLink.split("#");
35
+ // Skip if already has extension
36
+ if (/\.[a-zA-Z0-9]+$/.test(path)) return match;
37
+ // Only process relative paths or paths starting with /
38
+ if (!path) return match;
39
+ // Flatten to ./xxx-yyy.md
40
+ let finalPath = path;
41
+ if (path.startsWith(".")) {
42
+ finalPath = path.replace(/^\./, "");
64
43
  }
65
- );
44
+ let flatPath = finalPath.replace(/^\//, "").replace(/\//g, "-");
45
+ flatPath = `./${flatPath}.md`;
46
+ const newLink = hash ? `${flatPath}#${hash}` : flatPath;
47
+ return `[${text}](${newLink})`;
48
+ });
66
49
  }
67
50
 
68
51
  /**
@@ -83,6 +66,7 @@ export async function saveDocWithTranslations({
83
66
  locale,
84
67
  translates = [],
85
68
  labels,
69
+ isTranslate = false,
86
70
  }) {
87
71
  const results = [];
88
72
  try {
@@ -96,20 +80,22 @@ export async function saveDocWithTranslations({
96
80
  return isEnglish ? `${flatName}.md` : `${flatName}.${language}.md`;
97
81
  };
98
82
 
99
- // Save main content with appropriate filename based on locale
100
- const mainFileName = getFileName(locale);
101
- const mainFilePath = path.join(docsDir, mainFileName);
83
+ // Save main content with appropriate filename based on locale (skip if isTranslate is true)
84
+ if (!isTranslate) {
85
+ const mainFileName = getFileName(locale);
86
+ const mainFilePath = path.join(docsDir, mainFileName);
102
87
 
103
- // Add labels front matter if labels are provided
104
- let finalContent = processContent({ content });
105
- if (labels && labels.length > 0) {
106
- const frontMatter = `---\nlabels: ${JSON.stringify(labels)}\n---\n\n`;
107
- finalContent = frontMatter + finalContent;
108
- }
88
+ // Add labels front matter if labels are provided
89
+ let finalContent = processContent({ content });
90
+ if (labels && labels.length > 0) {
91
+ const frontMatter = `---\nlabels: ${JSON.stringify(labels)}\n---\n\n`;
92
+ finalContent = frontMatter + finalContent;
93
+ }
109
94
 
110
- await fs.writeFile(mainFilePath, finalContent, "utf8");
111
- results.push({ path: mainFilePath, success: true });
112
- console.log(chalk.green(`Saved: ${chalk.cyan(mainFilePath)}`));
95
+ await fs.writeFile(mainFilePath, finalContent, "utf8");
96
+ results.push({ path: mainFilePath, success: true });
97
+ console.log(chalk.green(`Saved: ${chalk.cyan(mainFilePath)}`));
98
+ }
113
99
 
114
100
  // Process all translations
115
101
  for (const translate of translates) {
@@ -187,7 +173,7 @@ export async function saveGitHeadToConfig(gitHead) {
187
173
  if (fileContent && !fileContent.endsWith("\n")) {
188
174
  fileContent += "\n";
189
175
  }
190
- fileContent += newLastGitHeadLine + "\n";
176
+ fileContent += `${newLastGitHeadLine}\n`;
191
177
  }
192
178
 
193
179
  await fs.writeFile(inputFilePath, fileContent);
@@ -203,20 +189,13 @@ export async function saveGitHeadToConfig(gitHead) {
203
189
  * @param {Array<string>} filePaths - Array of file paths to check
204
190
  * @returns {Array<string>} - Array of modified file paths
205
191
  */
206
- export function getModifiedFilesBetweenCommits(
207
- fromCommit,
208
- toCommit = "HEAD",
209
- filePaths = []
210
- ) {
192
+ export function getModifiedFilesBetweenCommits(fromCommit, toCommit = "HEAD", filePaths = []) {
211
193
  try {
212
194
  // Get all modified files between commits
213
- const modifiedFiles = execSync(
214
- `git diff --name-only ${fromCommit}..${toCommit}`,
215
- {
216
- encoding: "utf8",
217
- stdio: ["pipe", "pipe", "ignore"],
218
- }
219
- )
195
+ const modifiedFiles = execSync(`git diff --name-only ${fromCommit}..${toCommit}`, {
196
+ encoding: "utf8",
197
+ stdio: ["pipe", "pipe", "ignore"],
198
+ })
220
199
  .trim()
221
200
  .split("\n")
222
201
  .filter(Boolean);
@@ -231,12 +210,12 @@ export function getModifiedFilesBetweenCommits(
231
210
  const absoluteFile = normalizePath(file);
232
211
  const absoluteTarget = normalizePath(targetPath);
233
212
  return absoluteFile === absoluteTarget;
234
- })
213
+ }),
235
214
  );
236
215
  } catch (error) {
237
216
  console.warn(
238
217
  `Failed to get modified files between ${fromCommit} and ${toCommit}:`,
239
- error.message
218
+ error.message,
240
219
  );
241
220
  return [];
242
221
  }
@@ -258,7 +237,7 @@ export function hasSourceFilesChanged(sourceIds, modifiedFiles) {
258
237
  const absoluteModifiedFile = normalizePath(modifiedFile);
259
238
  const absoluteSourceId = normalizePath(sourceId);
260
239
  return absoluteModifiedFile === absoluteSourceId;
261
- })
240
+ }),
262
241
  );
263
242
  }
264
243
 
@@ -274,17 +253,14 @@ export function hasFileChangesBetweenCommits(
274
253
  fromCommit,
275
254
  toCommit = "HEAD",
276
255
  includePatterns = DEFAULT_INCLUDE_PATTERNS,
277
- excludePatterns = DEFAULT_EXCLUDE_PATTERNS
256
+ excludePatterns = DEFAULT_EXCLUDE_PATTERNS,
278
257
  ) {
279
258
  try {
280
259
  // Get file changes with status (A=added, D=deleted, M=modified)
281
- const changes = execSync(
282
- `git diff --name-status ${fromCommit}..${toCommit}`,
283
- {
284
- encoding: "utf8",
285
- stdio: ["pipe", "pipe", "ignore"],
286
- }
287
- )
260
+ const changes = execSync(`git diff --name-status ${fromCommit}..${toCommit}`, {
261
+ encoding: "utf8",
262
+ stdio: ["pipe", "pipe", "ignore"],
263
+ })
288
264
  .trim()
289
265
  .split("\n")
290
266
  .filter(Boolean);
@@ -306,10 +282,7 @@ export function hasFileChangesBetweenCommits(
306
282
  // Check if file matches any include pattern
307
283
  const matchesInclude = includePatterns.some((pattern) => {
308
284
  // Convert glob pattern to regex for matching
309
- const regexPattern = pattern
310
- .replace(/\./g, "\\.")
311
- .replace(/\*/g, ".*")
312
- .replace(/\?/g, ".");
285
+ const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".");
313
286
  const regex = new RegExp(regexPattern);
314
287
  return regex.test(filePath);
315
288
  });
@@ -321,10 +294,7 @@ export function hasFileChangesBetweenCommits(
321
294
  // Check if file matches any exclude pattern
322
295
  const matchesExclude = excludePatterns.some((pattern) => {
323
296
  // Convert glob pattern to regex for matching
324
- const regexPattern = pattern
325
- .replace(/\./g, "\\.")
326
- .replace(/\*/g, ".*")
327
- .replace(/\?/g, ".");
297
+ const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".");
328
298
  const regex = new RegExp(regexPattern);
329
299
  return regex.test(filePath);
330
300
  });
@@ -334,7 +304,7 @@ export function hasFileChangesBetweenCommits(
334
304
  } catch (error) {
335
305
  console.warn(
336
306
  `Failed to check file changes between ${fromCommit} and ${toCommit}:`,
337
- error.message
307
+ error.message,
338
308
  );
339
309
  return false;
340
310
  }
@@ -345,11 +315,7 @@ export function hasFileChangesBetweenCommits(
345
315
  * @returns {Promise<Object|null>} - The config object or null if file doesn't exist
346
316
  */
347
317
  export async function loadConfigFromFile() {
348
- const configPath = path.join(
349
- process.cwd(),
350
- "./.aigne/doc-smith",
351
- "config.yaml"
352
- );
318
+ const configPath = path.join(process.cwd(), "./.aigne/doc-smith", "config.yaml");
353
319
 
354
320
  try {
355
321
  if (!existsSync(configPath)) {
@@ -368,10 +334,11 @@ export async function loadConfigFromFile() {
368
334
  * Save value to config.yaml file
369
335
  * @param {string} key - The config key to save
370
336
  * @param {string} value - The value to save
337
+ * @param {string} [comment] - Optional comment to add above the key
371
338
  */
372
- export async function saveValueToConfig(key, value) {
373
- if (!value) {
374
- return; // Skip if no value provided
339
+ export async function saveValueToConfig(key, value, comment) {
340
+ if (value === undefined) {
341
+ return; // Skip if value is undefined
375
342
  }
376
343
 
377
344
  try {
@@ -389,18 +356,35 @@ export async function saveValueToConfig(key, value) {
389
356
  }
390
357
 
391
358
  // Check if key already exists in the file
392
- const keyRegex = new RegExp(`^${key}:\\s*.*$`, "m");
393
- const newKeyLine = `${key}: ${value}`;
359
+ const lines = fileContent.split("\n");
360
+ const keyRegex = new RegExp(`^${key}:\\s*.*$`);
361
+ const newKeyLine = `${key}: "${value}"`;
394
362
 
395
- if (keyRegex.test(fileContent)) {
363
+ const keyIndex = lines.findIndex((line) => keyRegex.test(line));
364
+
365
+ if (keyIndex !== -1) {
396
366
  // Replace existing key line
397
- fileContent = fileContent.replace(keyRegex, newKeyLine);
367
+ lines[keyIndex] = newKeyLine;
368
+ fileContent = lines.join("\n");
369
+
370
+ // Add comment if provided and not already present
371
+ if (comment && keyIndex > 0 && !lines[keyIndex - 1].trim().startsWith("# ")) {
372
+ // Add comment above the key if it doesn't already have one
373
+ lines.splice(keyIndex, 0, `# ${comment}`);
374
+ fileContent = lines.join("\n");
375
+ }
398
376
  } else {
399
377
  // Add key to the end of file
400
378
  if (fileContent && !fileContent.endsWith("\n")) {
401
379
  fileContent += "\n";
402
380
  }
403
- fileContent += newKeyLine + "\n";
381
+
382
+ // Add comment if provided
383
+ if (comment) {
384
+ fileContent += `# ${comment}\n`;
385
+ }
386
+
387
+ fileContent += `${newKeyLine}\n`;
404
388
  }
405
389
 
406
390
  await fs.writeFile(configPath, fileContent);
@@ -429,7 +413,7 @@ export function validatePath(filePath) {
429
413
  // Check if path is accessible (readable)
430
414
  try {
431
415
  accessSync(absolutePath, constants.R_OK);
432
- } catch (accessError) {
416
+ } catch (_accessError) {
433
417
  return {
434
418
  isValid: false,
435
419
  error: `Path is not accessible: ${filePath}`,
@@ -440,7 +424,7 @@ export function validatePath(filePath) {
440
424
  isValid: true,
441
425
  error: null,
442
426
  };
443
- } catch (error) {
427
+ } catch (_error) {
444
428
  return {
445
429
  isValid: false,
446
430
  error: `Invalid path format: ${filePath}`,
@@ -563,10 +547,7 @@ export function getAvailablePaths(userInput = "") {
563
547
 
564
548
  return uniqueResults;
565
549
  } catch (error) {
566
- console.warn(
567
- `Failed to get available paths for "${userInput}":`,
568
- error.message
569
- );
550
+ console.warn(`Failed to get available paths for "${userInput}":`, error.message);
570
551
  return [];
571
552
  }
572
553
  }
@@ -607,10 +588,7 @@ function getDirectoryContents(dirPath, searchTerm = "") {
607
588
  }
608
589
 
609
590
  // Filter by search term if provided
610
- if (
611
- searchTerm &&
612
- !entryName.toLowerCase().includes(searchTerm.toLowerCase())
613
- ) {
591
+ if (searchTerm && !entryName.toLowerCase().includes(searchTerm.toLowerCase())) {
614
592
  continue;
615
593
  }
616
594
 
@@ -648,10 +626,80 @@ function getDirectoryContents(dirPath, searchTerm = "") {
648
626
 
649
627
  return items;
650
628
  } catch (error) {
651
- console.warn(
652
- `Failed to get directory contents from ${dirPath}:`,
653
- error.message
654
- );
629
+ console.warn(`Failed to get directory contents from ${dirPath}:`, error.message);
655
630
  return [];
656
631
  }
657
632
  }
633
+
634
+ /**
635
+ * Get GitHub repository information
636
+ * @param {string} repoUrl - The repository URL
637
+ * @returns {Promise<Object>} - Repository information
638
+ */
639
+ export async function getGitHubRepoInfo(repoUrl) {
640
+ try {
641
+ // Extract owner and repo from GitHub URL
642
+ const match = repoUrl.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
643
+ if (!match) return null;
644
+
645
+ const [, owner, repo] = match;
646
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
647
+
648
+ const response = await fetch(apiUrl);
649
+ if (!response.ok) return null;
650
+
651
+ const data = await response.json();
652
+ return {
653
+ name: data.name,
654
+ description: data.description || "",
655
+ icon: data.owner?.avatar_url || "",
656
+ };
657
+ } catch (error) {
658
+ console.warn("Failed to fetch GitHub repository info:", error.message);
659
+ return null;
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Get project information automatically without user confirmation
665
+ * @returns {Promise<Object>} - Project information including name, description, icon, and fromGitHub flag
666
+ */
667
+ export async function getProjectInfo() {
668
+ let repoInfo = null;
669
+ let defaultName = path.basename(process.cwd());
670
+ let defaultDescription = "";
671
+ let defaultIcon = "";
672
+ let fromGitHub = false;
673
+
674
+ // Check if we're in a git repository
675
+ try {
676
+ const gitRemote = execSync("git remote get-url origin", {
677
+ encoding: "utf8",
678
+ stdio: ["pipe", "pipe", "ignore"],
679
+ }).trim();
680
+
681
+ // Extract repository name from git remote URL
682
+ const repoName = gitRemote.split("/").pop().replace(".git", "");
683
+ defaultName = repoName;
684
+
685
+ // If it's a GitHub repository, try to get additional info
686
+ if (gitRemote.includes("github.com")) {
687
+ repoInfo = await getGitHubRepoInfo(gitRemote);
688
+ if (repoInfo) {
689
+ defaultDescription = repoInfo.description;
690
+ defaultIcon = repoInfo.icon;
691
+ fromGitHub = true;
692
+ }
693
+ }
694
+ } catch (_error) {
695
+ // Not in git repository or no origin remote, use current directory name
696
+ console.warn("No git repository found, using current directory name");
697
+ }
698
+
699
+ return {
700
+ name: defaultName,
701
+ description: defaultDescription,
702
+ icon: defaultIcon,
703
+ fromGitHub,
704
+ };
705
+ }