@aigne/doc-smith 0.4.5 → 0.6.0

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 (36) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/agents/batch-translate.yaml +3 -0
  3. package/agents/check-detail-result.mjs +2 -1
  4. package/agents/check-detail.mjs +1 -0
  5. package/agents/check-feedback-refiner.mjs +79 -0
  6. package/agents/check-structure-plan.mjs +16 -0
  7. package/agents/detail-generator-and-translate.yaml +3 -0
  8. package/agents/detail-regenerator.yaml +3 -0
  9. package/agents/docs-generator.yaml +3 -0
  10. package/agents/feedback-refiner.yaml +48 -0
  11. package/agents/find-items-by-paths.mjs +5 -1
  12. package/agents/find-user-preferences-by-path.mjs +37 -0
  13. package/agents/input-generator.mjs +8 -9
  14. package/agents/load-sources.mjs +63 -9
  15. package/agents/manage-prefs.mjs +203 -0
  16. package/agents/publish-docs.mjs +3 -1
  17. package/agents/retranslate.yaml +3 -0
  18. package/agents/structure-planning.yaml +3 -0
  19. package/aigne.yaml +4 -0
  20. package/package.json +10 -9
  21. package/prompts/content-detail-generator.md +13 -1
  22. package/prompts/document/detail-generator.md +1 -0
  23. package/prompts/feedback-refiner.md +84 -0
  24. package/prompts/structure-planning.md +8 -0
  25. package/prompts/translator.md +8 -0
  26. package/tests/{test-all-validation-cases.mjs → all-validation-cases.test.mjs} +60 -137
  27. package/tests/check-detail-result.test.mjs +90 -77
  28. package/tests/load-sources.test.mjs +103 -291
  29. package/tests/preferences-utils.test.mjs +369 -0
  30. package/tests/{test-save-docs.mjs → save-docs.test.mjs} +29 -47
  31. package/tests/save-value-to-config.test.mjs +165 -288
  32. package/utils/auth-utils.mjs +1 -1
  33. package/utils/constants.mjs +22 -10
  34. package/utils/markdown-checker.mjs +89 -9
  35. package/utils/preferences-utils.mjs +175 -0
  36. package/utils/utils.mjs +3 -3
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import remarkGfm from "remark-gfm";
2
4
  import remarkLint from "remark-lint";
3
5
  import remarkParse from "remark-parse";
@@ -159,6 +161,72 @@ function checkCodeBlockIndentation(codeBlockContent, codeBlockIndent, source, er
159
161
  }
160
162
  }
161
163
 
164
+ /**
165
+ * Check for local images and verify their existence
166
+ * @param {string} markdown - The markdown content
167
+ * @param {string} source - Source description for error reporting
168
+ * @param {Array} errorMessages - Array to push error messages to
169
+ * @param {string} [markdownFilePath] - Path to the markdown file for resolving relative paths
170
+ * @param {string} [baseDir] - Base directory for resolving relative paths (alternative to markdownFilePath)
171
+ */
172
+ function checkLocalImages(markdown, source, errorMessages, markdownFilePath, baseDir) {
173
+ const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
174
+ let match;
175
+
176
+ while ((match = imageRegex.exec(markdown)) !== null) {
177
+ const imagePath = match[2].trim();
178
+ const altText = match[1];
179
+
180
+ // Skip external URLs (http/https)
181
+ if (/^https?:\/\//.test(imagePath)) continue;
182
+
183
+ // Skip data URLs
184
+ if (/^data:/.test(imagePath)) continue;
185
+
186
+ // Check if it's a local path
187
+ if (!imagePath.startsWith("/") && !imagePath.includes("://")) {
188
+ // It's a relative local path, check if file exists
189
+ try {
190
+ let resolvedPath;
191
+ if (markdownFilePath) {
192
+ // Resolve relative to the markdown file's directory
193
+ const markdownDir = path.dirname(markdownFilePath);
194
+ resolvedPath = path.resolve(markdownDir, imagePath);
195
+ } else if (baseDir) {
196
+ // Resolve relative to the provided base directory
197
+ resolvedPath = path.resolve(baseDir, imagePath);
198
+ } else {
199
+ // Fallback to current working directory
200
+ resolvedPath = path.resolve(imagePath);
201
+ }
202
+
203
+ if (!fs.existsSync(resolvedPath)) {
204
+ errorMessages.push(
205
+ `Found invalid local image in ${source}: ![${altText}](${imagePath}) - only valid media resources can be used`,
206
+ );
207
+ }
208
+ } catch {
209
+ errorMessages.push(
210
+ `Found invalid local image in ${source}: ![${altText}](${imagePath}) - only valid media resources can be used`,
211
+ );
212
+ }
213
+ } else if (imagePath.startsWith("/")) {
214
+ // Absolute local path
215
+ try {
216
+ if (!fs.existsSync(imagePath)) {
217
+ errorMessages.push(
218
+ `Found invalid local image in ${source}: ![${altText}](${imagePath}) - only valid media resources can be used`,
219
+ );
220
+ }
221
+ } catch {
222
+ errorMessages.push(
223
+ `Found invalid local image in ${source}: ![${altText}](${imagePath}) - only valid media resources can be used`,
224
+ );
225
+ }
226
+ }
227
+ }
228
+ }
229
+
162
230
  /**
163
231
  * Check content structure and formatting issues
164
232
  * @param {string} markdown - The markdown content
@@ -224,7 +292,7 @@ function checkContentStructure(markdown, source, errorMessages) {
224
292
  }
225
293
 
226
294
  // Check if content ends with proper punctuation (indicating completeness)
227
- const validEndingPunctuation = [".", "。", ")", "|"];
295
+ const validEndingPunctuation = [".", "。", ")", "|", "*"];
228
296
  const trimmedText = markdown.trim();
229
297
  const hasValidEnding = validEndingPunctuation.some((punct) => trimmedText.endsWith(punct));
230
298
 
@@ -241,6 +309,8 @@ function checkContentStructure(markdown, source, errorMessages) {
241
309
  * @param {string} [source] - Source description for error reporting (e.g., "result")
242
310
  * @param {Object} [options] - Additional options for validation
243
311
  * @param {Array} [options.allowedLinks] - Set of allowed links for link validation
312
+ * @param {string} [options.filePath] - Path to the markdown file for resolving relative image paths
313
+ * @param {string} [options.baseDir] - Base directory for resolving relative image paths (alternative to filePath)
244
314
  * @returns {Promise<Array<string>>} - Array of error messages in check-detail-result format
245
315
  */
246
316
  export async function checkMarkdown(markdown, source = "content", options = {}) {
@@ -248,8 +318,8 @@ export async function checkMarkdown(markdown, source = "content", options = {})
248
318
  const errorMessages = [];
249
319
 
250
320
  try {
251
- // Extract allowed links from options
252
- const { allowedLinks } = options;
321
+ // Extract allowed links, file path, and base directory from options
322
+ const { allowedLinks, filePath, baseDir } = options;
253
323
 
254
324
  // Create unified processor with markdown parsing and linting
255
325
  // Use individual rules instead of presets to have better control
@@ -292,7 +362,10 @@ export async function checkMarkdown(markdown, source = "content", options = {})
292
362
  checkDeadLinks(markdown, source, allowedLinks, errorMessages);
293
363
  }
294
364
 
295
- // 2. Check content structure and formatting issues
365
+ // 2. Check local images existence
366
+ checkLocalImages(markdown, source, errorMessages, filePath, baseDir);
367
+
368
+ // 3. Check content structure and formatting issues
296
369
  checkContentStructure(markdown, source, errorMessages);
297
370
 
298
371
  // Check mermaid code blocks and other custom validations
@@ -319,30 +392,35 @@ export async function checkMarkdown(markdown, source = "content", options = {})
319
392
  // Check for backticks in node labels
320
393
  const nodeLabelRegex = /[A-Za-z0-9_]+\["([^"]*`[^"]*)"\]|[A-Za-z0-9_]+{"([^}]*`[^}]*)"}/g;
321
394
  let match;
322
- while ((match = nodeLabelRegex.exec(mermaidContent)) !== null) {
395
+ match = nodeLabelRegex.exec(mermaidContent);
396
+ while (match !== null) {
323
397
  const label = match[1] || match[2];
324
398
  errorMessages.push(
325
399
  `Found backticks in Mermaid node label in ${source} at line ${line}: "${label}" - backticks in node labels cause rendering issues in Mermaid diagrams`,
326
400
  );
401
+ match = nodeLabelRegex.exec(mermaidContent);
327
402
  }
328
403
 
329
404
  // Check for numbered list format in edge descriptions
330
405
  const edgeDescriptionRegex = /--\s*"([^"]*)"\s*-->/g;
331
406
  let edgeMatch;
332
- while ((edgeMatch = edgeDescriptionRegex.exec(mermaidContent)) !== null) {
407
+ edgeMatch = edgeDescriptionRegex.exec(mermaidContent);
408
+ while (edgeMatch !== null) {
333
409
  const description = edgeMatch[1];
334
410
  if (/^\d+\.\s/.test(description)) {
335
411
  errorMessages.push(
336
412
  `Unsupported markdown: list - Found numbered list format in Mermaid edge description in ${source} at line ${line}: "${description}" - numbered lists in edge descriptions are not supported`,
337
413
  );
338
414
  }
415
+ edgeMatch = edgeDescriptionRegex.exec(mermaidContent);
339
416
  }
340
417
 
341
418
  // Check for numbered list format in node labels (for both [] and {} syntax)
342
419
  const nodeLabelWithNumberRegex =
343
420
  /[A-Za-z0-9_]+\["([^"]*\d+\.\s[^"]*)"\]|[A-Za-z0-9_]+{"([^}]*\d+\.\s[^}]*)"}/g;
344
421
  let numberMatch;
345
- while ((numberMatch = nodeLabelWithNumberRegex.exec(mermaidContent)) !== null) {
422
+ numberMatch = nodeLabelWithNumberRegex.exec(mermaidContent);
423
+ while (numberMatch !== null) {
346
424
  const label = numberMatch[1] || numberMatch[2];
347
425
  // Check if the label contains numbered list format
348
426
  if (/\d+\.\s/.test(label)) {
@@ -350,12 +428,14 @@ export async function checkMarkdown(markdown, source = "content", options = {})
350
428
  `Unsupported markdown: list - Found numbered list format in Mermaid node label in ${source} at line ${line}: "${label}" - numbered lists in node labels cause rendering issues`,
351
429
  );
352
430
  }
431
+ numberMatch = nodeLabelWithNumberRegex.exec(mermaidContent);
353
432
  }
354
433
 
355
434
  // Check for special characters in node labels that should be quoted
356
435
  const nodeWithSpecialCharsRegex = /([A-Za-z0-9_]+)\[([^\]]*[(){}:;,\-\s.][^\]]*)\]/g;
357
436
  let specialCharMatch;
358
- while ((specialCharMatch = nodeWithSpecialCharsRegex.exec(mermaidContent)) !== null) {
437
+ specialCharMatch = nodeWithSpecialCharsRegex.exec(mermaidContent);
438
+ while (specialCharMatch !== null) {
359
439
  const nodeId = specialCharMatch[1];
360
440
  const label = specialCharMatch[2];
361
441
 
@@ -373,6 +453,7 @@ export async function checkMarkdown(markdown, source = "content", options = {})
373
453
  );
374
454
  }
375
455
  }
456
+ specialCharMatch = nodeWithSpecialCharsRegex.exec(mermaidContent);
376
457
  }
377
458
  }
378
459
  });
@@ -431,7 +512,6 @@ export async function checkMarkdown(markdown, source = "content", options = {})
431
512
  // Format messages in check-detail-result style
432
513
  file.messages.forEach((message) => {
433
514
  const line = message.line || "unknown";
434
- const _column = message.column || "unknown";
435
515
  const reason = message.reason || "Unknown markdown issue";
436
516
  const ruleId = message.ruleId || message.source || "markdown";
437
517
 
@@ -0,0 +1,175 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse, stringify } from "yaml";
5
+
6
+ const PREFERENCES_DIR = ".aigne/doc-smith";
7
+ const PREFERENCES_FILE = "preferences.yml";
8
+
9
+ /**
10
+ * Generate a random preference ID
11
+ * @returns {string} Random ID with pref_ prefix
12
+ */
13
+ function generatePreferenceId() {
14
+ return `pref_${randomBytes(8).toString("hex")}`;
15
+ }
16
+
17
+ /**
18
+ * Get the full path to the preferences file
19
+ * @returns {string} Full path to preferences.yml
20
+ */
21
+ function getPreferencesFilePath() {
22
+ return join(process.cwd(), PREFERENCES_DIR, PREFERENCES_FILE);
23
+ }
24
+
25
+ /**
26
+ * Ensure the preferences directory exists
27
+ */
28
+ function ensurePreferencesDir() {
29
+ const preferencesDir = join(process.cwd(), PREFERENCES_DIR);
30
+ if (!existsSync(preferencesDir)) {
31
+ mkdirSync(preferencesDir, { recursive: true });
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Read existing preferences from file
37
+ * @returns {Object} Preferences object with rules array
38
+ */
39
+ export function readPreferences() {
40
+ const filePath = getPreferencesFilePath();
41
+
42
+ if (!existsSync(filePath)) {
43
+ return { rules: [] };
44
+ }
45
+
46
+ try {
47
+ const content = readFileSync(filePath, "utf8");
48
+ const preferences = parse(content);
49
+ return preferences || { rules: [] };
50
+ } catch (error) {
51
+ console.warn(`Warning: Failed to read preferences file at ${filePath}: ${error.message}`);
52
+ return { rules: [] };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Write preferences to file
58
+ * @param {Object} preferences - Preferences object to save
59
+ */
60
+ export function writePreferences(preferences) {
61
+ ensurePreferencesDir();
62
+ const filePath = getPreferencesFilePath();
63
+
64
+ try {
65
+ const yamlContent = stringify(preferences, {
66
+ indent: 2,
67
+ lineWidth: 120,
68
+ });
69
+
70
+ writeFileSync(filePath, yamlContent, "utf8");
71
+ } catch (error) {
72
+ throw new Error(`Failed to write preferences file: ${error.message}`);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Add a new preference rule
78
+ * @param {Object} ruleData - Rule data from feedbackRefiner
79
+ * @param {string} ruleData.rule - The rule text
80
+ * @param {string} ruleData.scope - Rule scope (global, structure, document, translation)
81
+ * @param {boolean} ruleData.limitToInputPaths - Whether to limit to input paths
82
+ * @param {string} feedback - Original user feedback
83
+ * @param {string[]} [paths] - Optional paths to save with the rule
84
+ * @returns {Object} The created preference rule
85
+ */
86
+ export function addPreferenceRule(ruleData, feedback, paths = []) {
87
+ const preferences = readPreferences();
88
+
89
+ const newRule = {
90
+ id: generatePreferenceId(),
91
+ active: true,
92
+ scope: ruleData.scope,
93
+ rule: ruleData.rule,
94
+ feedback: feedback,
95
+ createdAt: new Date().toISOString(),
96
+ };
97
+
98
+ // Add paths if limitToInputPaths is true and paths are provided
99
+ if (ruleData.limitToInputPaths && paths && paths.length > 0) {
100
+ newRule.paths = paths;
101
+ }
102
+
103
+ // Add the new rule to the beginning of the array (newest first)
104
+ preferences.rules.unshift(newRule);
105
+
106
+ writePreferences(preferences);
107
+
108
+ return newRule;
109
+ }
110
+
111
+ /**
112
+ * Get all active preference rules for a specific scope
113
+ * @param {string} scope - The scope to filter by (global, structure, document, translation)
114
+ * @param {string[]} [currentPaths] - Current paths to match against rules with path restrictions
115
+ * @returns {Object[]} Array of matching active rules
116
+ */
117
+ export function getActiveRulesForScope(scope, currentPaths = []) {
118
+ const preferences = readPreferences();
119
+
120
+ return preferences.rules.filter((rule) => {
121
+ // Must be active and match scope
122
+ if (!rule.active || rule.scope !== scope) {
123
+ return false;
124
+ }
125
+
126
+ // If rule has path restrictions, check if any current path matches
127
+ if (rule.paths && rule.paths.length > 0) {
128
+ if (currentPaths.length === 0) {
129
+ return false; // Rule has path restrictions but no current paths provided
130
+ }
131
+
132
+ // Check if any current path matches any rule path pattern
133
+ return currentPaths.some((currentPath) => rule.paths.includes(currentPath));
134
+ }
135
+
136
+ return true; // No path restrictions, include the rule
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Deactivate a preference rule by ID
142
+ * @param {string} ruleId - The ID of the rule to deactivate
143
+ * @returns {boolean} True if rule was found and deactivated
144
+ */
145
+ export function deactivateRule(ruleId) {
146
+ const preferences = readPreferences();
147
+ const rule = preferences.rules.find((r) => r.id === ruleId);
148
+
149
+ if (rule) {
150
+ rule.active = false;
151
+ writePreferences(preferences);
152
+ return true;
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ /**
159
+ * Remove a preference rule by ID
160
+ * @param {string} ruleId - The ID of the rule to remove
161
+ * @returns {boolean} True if rule was found and removed
162
+ */
163
+ export function removeRule(ruleId) {
164
+ const preferences = readPreferences();
165
+ const initialLength = preferences.rules.length;
166
+
167
+ preferences.rules = preferences.rules.filter((r) => r.id !== ruleId);
168
+
169
+ if (preferences.rules.length < initialLength) {
170
+ writePreferences(preferences);
171
+ return true;
172
+ }
173
+
174
+ return false;
175
+ }
package/utils/utils.mjs CHANGED
@@ -8,8 +8,8 @@ import {
8
8
  DEFAULT_INCLUDE_PATTERNS,
9
9
  DOCUMENT_STYLES,
10
10
  DOCUMENTATION_DEPTH,
11
- SUPPORTED_FILE_EXTENSIONS,
12
11
  READER_KNOWLEDGE_LEVELS,
12
+ SUPPORTED_FILE_EXTENSIONS,
13
13
  SUPPORTED_LANGUAGES,
14
14
  TARGET_AUDIENCES,
15
15
  } from "./constants.mjs";
@@ -149,8 +149,8 @@ export function getCurrentGitHead() {
149
149
  * @param {string} gitHead - The current git HEAD commit hash
150
150
  */
151
151
  export async function saveGitHeadToConfig(gitHead) {
152
- if (!gitHead) {
153
- return; // Skip if no git HEAD available
152
+ if (!gitHead || process.env.NODE_ENV === 'test' || process.env.BUN_TEST) {
153
+ return; // Skip if no git HEAD available or in test environment
154
154
  }
155
155
 
156
156
  try {