@aigne/doc-smith 0.3.1 → 0.4.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.
@@ -0,0 +1,258 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Get action-specific text based on isTranslate flag
6
+ * @param {boolean} isTranslate - Whether this is a translation action
7
+ * @param {string} baseText - Base text template with {action} placeholder
8
+ * @returns {string} Text with action replaced
9
+ */
10
+ export function getActionText(isTranslate, baseText) {
11
+ const action = isTranslate ? "translate" : "update";
12
+ return baseText.replace("{action}", action);
13
+ }
14
+
15
+ /**
16
+ * Generate filename based on flattened path and locale
17
+ * @param {string} flatName - Flattened path name
18
+ * @param {string} locale - Main language locale (e.g., 'en', 'zh', 'fr')
19
+ * @returns {string} Generated filename
20
+ */
21
+ function generateFileName(flatName, locale) {
22
+ const isEnglish = locale === "en";
23
+ return isEnglish ? `${flatName}.md` : `${flatName}.${locale}.md`;
24
+ }
25
+
26
+ /**
27
+ * Find a single item by path in structure plan result and read its content
28
+ * @param {Array} structurePlanResult - Array of structure plan items
29
+ * @param {string} docPath - Document path to find (supports .md filenames)
30
+ * @param {string} boardId - Board ID for fallback matching
31
+ * @param {string} docsDir - Docs directory path for reading content
32
+ * @param {string} locale - Main language locale (e.g., 'en', 'zh', 'fr')
33
+ * @returns {Promise<Object|null>} Found item with content or null
34
+ */
35
+ export async function findItemByPath(
36
+ structurePlanResult,
37
+ docPath,
38
+ boardId,
39
+ docsDir,
40
+ locale = "en",
41
+ ) {
42
+ let foundItem = null;
43
+ let fileName = null;
44
+
45
+ // Check if docPath is a .md filename
46
+ if (docPath.endsWith(".md")) {
47
+ fileName = docPath;
48
+ const flatName = fileNameToFlatPath(docPath);
49
+ foundItem = findItemByFlatName(structurePlanResult, flatName);
50
+ } else {
51
+ // First try direct path matching
52
+ foundItem = structurePlanResult.find((item) => item.path === docPath);
53
+
54
+ // If not found and boardId is provided, try boardId-flattenedPath format matching
55
+ if (!foundItem && boardId) {
56
+ // Check if path starts with boardId followed by a dash
57
+ if (docPath.startsWith(`${boardId}-`)) {
58
+ // Extract the flattened path part after boardId-
59
+ const flattenedPath = docPath.substring(boardId.length + 1);
60
+
61
+ // Find item by comparing flattened paths
62
+ foundItem = structurePlanResult.find((item) => {
63
+ // Convert item.path to flattened format (replace / with -)
64
+ const itemFlattenedPath = item.path.replace(/^\//, "").replace(/\//g, "-");
65
+ return itemFlattenedPath === flattenedPath;
66
+ });
67
+ }
68
+ }
69
+
70
+ // Generate filename from found item path
71
+ if (foundItem) {
72
+ const itemFlattenedPath = foundItem.path.replace(/^\//, "").replace(/\//g, "-");
73
+ fileName = generateFileName(itemFlattenedPath, locale);
74
+ }
75
+ }
76
+
77
+ if (!foundItem) {
78
+ return null;
79
+ }
80
+
81
+ // Read file content if docsDir is provided
82
+ let content = null;
83
+ if (docsDir && fileName) {
84
+ content = await readFileContent(docsDir, fileName);
85
+ }
86
+
87
+ // Return item with content
88
+ const result = {
89
+ ...foundItem,
90
+ };
91
+
92
+ if (content !== null) {
93
+ result.content = content;
94
+ }
95
+
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Read file content from docs directory
101
+ * @param {string} docsDir - Docs directory path
102
+ * @param {string} fileName - File name to read
103
+ * @returns {Promise<string|null>} File content or null if failed
104
+ */
105
+ export async function readFileContent(docsDir, fileName) {
106
+ try {
107
+ const filePath = join(docsDir, fileName);
108
+ return await readFile(filePath, "utf-8");
109
+ } catch (readError) {
110
+ console.warn(`⚠️ Could not read content from ${fileName}:`, readError.message);
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Get main language markdown files from docs directory
117
+ * @param {string} docsDir - Docs directory path
118
+ * @param {string} locale - Main language locale (e.g., 'en', 'zh', 'fr')
119
+ * @param {Array} structurePlanResult - Array of structure plan items to determine file order
120
+ * @returns {Promise<string[]>} Array of main language .md files ordered by structurePlanResult
121
+ */
122
+ export async function getMainLanguageFiles(docsDir, locale, structurePlanResult = null) {
123
+ const files = await readdir(docsDir);
124
+
125
+ // Filter for main language .md files (exclude _sidebar.md)
126
+ const filteredFiles = files.filter((file) => {
127
+ // Skip non-markdown files and _sidebar.md
128
+ if (!file.endsWith(".md") || file === "_sidebar.md") {
129
+ return false;
130
+ }
131
+
132
+ // If main language is English, return files without language suffix
133
+ if (locale === "en") {
134
+ // Return files that don't have language suffixes (e.g., overview.md, not overview.zh.md)
135
+ return !file.match(/\.\w+(-\w+)?\.md$/);
136
+ } else {
137
+ // For non-English main language, return files with the exact locale suffix
138
+ const localePattern = new RegExp(`\\.${locale}\\.md$`);
139
+ return localePattern.test(file);
140
+ }
141
+ });
142
+
143
+ // If structurePlanResult is provided, sort files according to the order in structurePlanResult
144
+ if (structurePlanResult && Array.isArray(structurePlanResult)) {
145
+ // Create a map from flat file name to structure plan order
146
+ const orderMap = new Map();
147
+ structurePlanResult.forEach((item, index) => {
148
+ const itemFlattenedPath = item.path.replace(/^\//, "").replace(/\//g, "-");
149
+ const expectedFileName = generateFileName(itemFlattenedPath, locale);
150
+ orderMap.set(expectedFileName, index);
151
+ });
152
+
153
+ // Sort filtered files based on their order in structurePlanResult
154
+ return filteredFiles.sort((a, b) => {
155
+ const orderA = orderMap.get(a);
156
+ const orderB = orderMap.get(b);
157
+
158
+ // If both files are in the structure plan, sort by order
159
+ if (orderA !== undefined && orderB !== undefined) {
160
+ return orderA - orderB;
161
+ }
162
+
163
+ // If only one file is in the structure plan, it comes first
164
+ if (orderA !== undefined) return -1;
165
+ if (orderB !== undefined) return 1;
166
+
167
+ // If neither file is in the structure plan, maintain alphabetical order
168
+ return a.localeCompare(b);
169
+ });
170
+ }
171
+
172
+ // If no structurePlanResult provided, return files in alphabetical order
173
+ return filteredFiles.sort();
174
+ }
175
+
176
+ /**
177
+ * Convert filename to flattened path format
178
+ * @param {string} fileName - File name to convert
179
+ * @returns {string} Flattened path without .md extension and language suffix
180
+ */
181
+ export function fileNameToFlatPath(fileName) {
182
+ // Remove .md extension first
183
+ let flatName = fileName.replace(/\.md$/, "");
184
+
185
+ // Remove language suffix if present (e.g., .zh, .zh-CN, .fr, etc.)
186
+ flatName = flatName.replace(/\.\w+(-\w+)?$/, "");
187
+
188
+ return flatName;
189
+ }
190
+
191
+ /**
192
+ * Find structure plan item by flattened file name
193
+ * @param {Array} structurePlanResult - Array of structure plan items
194
+ * @param {string} flatName - Flattened file name
195
+ * @returns {Object|null} Found item or null
196
+ */
197
+ export function findItemByFlatName(structurePlanResult, flatName) {
198
+ return structurePlanResult.find((item) => {
199
+ const itemFlattenedPath = item.path.replace(/^\//, "").replace(/\//g, "-");
200
+ return itemFlattenedPath === flatName;
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Process selected files and convert to found items with content
206
+ * @param {string[]} selectedFiles - Array of selected file names
207
+ * @param {Array} structurePlanResult - Array of structure plan items
208
+ * @param {string} docsDir - Docs directory path
209
+ * @returns {Promise<Object[]>} Array of found items with content
210
+ */
211
+ export async function processSelectedFiles(selectedFiles, structurePlanResult, docsDir) {
212
+ const foundItems = [];
213
+
214
+ for (const selectedFile of selectedFiles) {
215
+ // Read the selected .md file content
216
+ const selectedFileContent = await readFileContent(docsDir, selectedFile);
217
+
218
+ // Convert filename back to path
219
+ const flatName = fileNameToFlatPath(selectedFile);
220
+
221
+ // Try to find matching item by comparing flattened paths
222
+ const foundItemByFile = findItemByFlatName(structurePlanResult, flatName);
223
+
224
+ if (foundItemByFile) {
225
+ const result = {
226
+ ...foundItemByFile,
227
+ };
228
+
229
+ // Add content if we read it from user selection
230
+ if (selectedFileContent !== null) {
231
+ result.content = selectedFileContent;
232
+ }
233
+
234
+ foundItems.push(result);
235
+ } else {
236
+ console.warn(`⚠️ No structure plan item found for file: ${selectedFile}`);
237
+ }
238
+ }
239
+
240
+ return foundItems;
241
+ }
242
+
243
+ /**
244
+ * Add feedback to all items in the array
245
+ * @param {Object[]} items - Array of items to add feedback to
246
+ * @param {string} feedback - Feedback text to add
247
+ * @returns {Object[]} Items with feedback added
248
+ */
249
+ export function addFeedbackToItems(items, feedback) {
250
+ if (!feedback?.trim()) {
251
+ return items;
252
+ }
253
+
254
+ return items.map((item) => ({
255
+ ...item,
256
+ feedback: feedback.trim(),
257
+ }));
258
+ }
@@ -224,10 +224,13 @@ function checkContentStructure(markdown, source, errorMessages) {
224
224
  }
225
225
 
226
226
  // Check if content ends with proper punctuation (indicating completeness)
227
+ const validEndingPunctuation = [".", "。", ")", "|"];
227
228
  const trimmedText = markdown.trim();
228
- if (trimmedText.length > 0 && !trimmedText.endsWith(".") && !trimmedText.endsWith("。")) {
229
+ const hasValidEnding = validEndingPunctuation.some((punct) => trimmedText.endsWith(punct));
230
+
231
+ if (trimmedText.length > 0 && !hasValidEnding) {
229
232
  errorMessages.push(
230
- `Found incomplete content in ${source}: content does not end with proper punctuation (. or 。). Please return the complete content`,
233
+ `Found incomplete content in ${source}: content does not end with proper punctuation (${validEndingPunctuation.join(", ")}). Please return the complete content`,
231
234
  );
232
235
  }
233
236
  }
package/utils/utils.mjs CHANGED
@@ -7,10 +7,11 @@ import {
7
7
  DEFAULT_EXCLUDE_PATTERNS,
8
8
  DEFAULT_INCLUDE_PATTERNS,
9
9
  DOCUMENT_STYLES,
10
- TARGET_AUDIENCES,
11
- READER_KNOWLEDGE_LEVELS,
12
10
  DOCUMENTATION_DEPTH,
11
+ SUPPORTED_FILE_EXTENSIONS,
12
+ READER_KNOWLEDGE_LEVELS,
13
13
  SUPPORTED_LANGUAGES,
14
+ TARGET_AUDIENCES,
14
15
  } from "./constants.mjs";
15
16
 
16
17
  /**
@@ -338,9 +339,140 @@ export async function loadConfigFromFile() {
338
339
  /**
339
340
  * Save value to config.yaml file
340
341
  * @param {string} key - The config key to save
341
- * @param {string} value - The value to save
342
+ * @param {string|Array} value - The value to save (can be string or array)
342
343
  * @param {string} [comment] - Optional comment to add above the key
343
344
  */
345
+ /**
346
+ * Handle array value formatting and updating in YAML config
347
+ * @param {string} key - The configuration key
348
+ * @param {Array} value - The array value to save
349
+ * @param {string} comment - Optional comment
350
+ * @param {string} fileContent - Current file content
351
+ * @returns {string} Updated file content
352
+ */
353
+ function handleArrayValueUpdate(key, value, comment, fileContent) {
354
+ // Format array value
355
+ const formattedValue =
356
+ value.length === 0 ? `${key}: []` : `${key}:\n${value.map((item) => ` - ${item}`).join("\n")}`;
357
+
358
+ const lines = fileContent.split("\n");
359
+
360
+ // Find the start line of the key
361
+ const keyStartIndex = lines.findIndex((line) => line.match(new RegExp(`^${key}:\\s*`)));
362
+
363
+ if (keyStartIndex !== -1) {
364
+ // Find the end of the array (next non-indented line or end of file)
365
+ let keyEndIndex = keyStartIndex;
366
+ for (let i = keyStartIndex + 1; i < lines.length; i++) {
367
+ const line = lines[i].trim();
368
+ // If line is empty, starts with comment, or doesn't start with "- ", it's not part of the array
369
+ if (line === "" || line.startsWith("#") || (!line.startsWith("- ") && !line.match(/^\w+:/))) {
370
+ if (!line.startsWith("- ")) {
371
+ keyEndIndex = i - 1;
372
+ break;
373
+ }
374
+ } else if (line.match(/^\w+:/)) {
375
+ // Found another key, stop here
376
+ keyEndIndex = i - 1;
377
+ break;
378
+ } else if (line.startsWith("- ")) {
379
+ keyEndIndex = i;
380
+ }
381
+ }
382
+
383
+ // If we reached the end of file
384
+ if (keyEndIndex === keyStartIndex) {
385
+ // Check if the value is on the same line
386
+ const keyLine = lines[keyStartIndex];
387
+ if (keyLine.includes("[") || !keyLine.endsWith(":")) {
388
+ keyEndIndex = keyStartIndex;
389
+ } else {
390
+ // Find the actual end of the array
391
+ for (let i = keyStartIndex + 1; i < lines.length; i++) {
392
+ const line = lines[i].trim();
393
+ if (line.startsWith("- ")) {
394
+ keyEndIndex = i;
395
+ } else if (line !== "" && !line.startsWith("#")) {
396
+ break;
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ // Replace the entire array section
403
+ const replacementLines = formattedValue.split("\n");
404
+ lines.splice(keyStartIndex, keyEndIndex - keyStartIndex + 1, ...replacementLines);
405
+
406
+ // Add comment if provided and not already present
407
+ if (comment && keyStartIndex > 0 && !lines[keyStartIndex - 1].trim().startsWith("# ")) {
408
+ lines.splice(keyStartIndex, 0, `# ${comment}`);
409
+ }
410
+
411
+ return lines.join("\n");
412
+ } else {
413
+ // Add new array to end of file
414
+ let updatedContent = fileContent;
415
+ if (updatedContent && !updatedContent.endsWith("\n")) {
416
+ updatedContent += "\n";
417
+ }
418
+
419
+ // Add comment if provided
420
+ if (comment) {
421
+ updatedContent += `# ${comment}\n`;
422
+ }
423
+
424
+ updatedContent += `${formattedValue}\n`;
425
+ return updatedContent;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Handle string value formatting and updating in YAML config
431
+ * @param {string} key - The configuration key
432
+ * @param {string} value - The string value to save
433
+ * @param {string} comment - Optional comment
434
+ * @param {string} fileContent - Current file content
435
+ * @returns {string} Updated file content
436
+ */
437
+ function handleStringValueUpdate(key, value, comment, fileContent) {
438
+ const formattedValue = `${key}: "${value}"`;
439
+ const lines = fileContent.split("\n");
440
+
441
+ // Handle string values (original logic)
442
+ const keyRegex = new RegExp(`^${key}:\\s*.*$`);
443
+ const keyIndex = lines.findIndex((line) => keyRegex.test(line));
444
+
445
+ if (keyIndex !== -1) {
446
+ // Replace existing key line
447
+ lines[keyIndex] = formattedValue;
448
+
449
+ // Add comment if provided and not already present
450
+ if (comment) {
451
+ const hasCommentAbove = keyIndex > 0 && lines[keyIndex - 1].trim().startsWith("# ");
452
+ if (!hasCommentAbove) {
453
+ // Add comment above the key if it doesn't already have one
454
+ lines.splice(keyIndex, 0, `# ${comment}`);
455
+ }
456
+ }
457
+
458
+ return lines.join("\n");
459
+ } else {
460
+ // Add key to the end of file
461
+ let updatedContent = fileContent;
462
+ if (updatedContent && !updatedContent.endsWith("\n")) {
463
+ updatedContent += "\n";
464
+ }
465
+
466
+ // Add comment if provided
467
+ if (comment) {
468
+ updatedContent += `# ${comment}\n`;
469
+ }
470
+
471
+ updatedContent += `${formattedValue}\n`;
472
+ return updatedContent;
473
+ }
474
+ }
475
+
344
476
  export async function saveValueToConfig(key, value, comment) {
345
477
  if (value === undefined) {
346
478
  return; // Skip if value is undefined
@@ -360,39 +492,15 @@ export async function saveValueToConfig(key, value, comment) {
360
492
  fileContent = await fs.readFile(configPath, "utf8");
361
493
  }
362
494
 
363
- // Check if key already exists in the file
364
- const lines = fileContent.split("\n");
365
- const keyRegex = new RegExp(`^${key}:\\s*.*$`);
366
- const newKeyLine = `${key}: "${value}"`;
367
-
368
- const keyIndex = lines.findIndex((line) => keyRegex.test(line));
369
-
370
- if (keyIndex !== -1) {
371
- // Replace existing key line
372
- lines[keyIndex] = newKeyLine;
373
- fileContent = lines.join("\n");
374
-
375
- // Add comment if provided and not already present
376
- if (comment && keyIndex > 0 && !lines[keyIndex - 1].trim().startsWith("# ")) {
377
- // Add comment above the key if it doesn't already have one
378
- lines.splice(keyIndex, 0, `# ${comment}`);
379
- fileContent = lines.join("\n");
380
- }
495
+ // Use extracted helper functions for better maintainability
496
+ let updatedContent;
497
+ if (Array.isArray(value)) {
498
+ updatedContent = handleArrayValueUpdate(key, value, comment, fileContent);
381
499
  } else {
382
- // Add key to the end of file
383
- if (fileContent && !fileContent.endsWith("\n")) {
384
- fileContent += "\n";
385
- }
386
-
387
- // Add comment if provided
388
- if (comment) {
389
- fileContent += `# ${comment}\n`;
390
- }
391
-
392
- fileContent += `${newKeyLine}\n`;
500
+ updatedContent = handleStringValueUpdate(key, value, comment, fileContent);
393
501
  }
394
502
 
395
- await fs.writeFile(configPath, fileContent);
503
+ await fs.writeFile(configPath, updatedContent);
396
504
  } catch (error) {
397
505
  console.warn(`Failed to save ${key} to config.yaml:`, error.message);
398
506
  }
@@ -719,9 +827,21 @@ export function processConfigFields(config) {
719
827
  const allRulesContent = [];
720
828
 
721
829
  // Check if original rules field has content
722
- const existingRules = config.rules?.trim();
723
- if (existingRules) {
724
- allRulesContent.push(existingRules);
830
+ if (config.rules) {
831
+ if (typeof config.rules === "string") {
832
+ const existingRules = config.rules.trim();
833
+ if (existingRules) {
834
+ allRulesContent.push(existingRules);
835
+ }
836
+ } else if (Array.isArray(config.rules)) {
837
+ // Handle array of rules - join them with newlines
838
+ const rulesText = config.rules
839
+ .filter((rule) => typeof rule === "string" && rule.trim())
840
+ .join("\n\n");
841
+ if (rulesText) {
842
+ allRulesContent.push(rulesText);
843
+ }
844
+ }
725
845
  }
726
846
 
727
847
  // Process document purpose (array)
@@ -798,6 +918,101 @@ export function processConfigFields(config) {
798
918
  return processed;
799
919
  }
800
920
 
921
+ /**
922
+ * Recursively resolves file references in a configuration object.
923
+ *
924
+ * This function traverses the input object, array, or string recursively. Any string value that starts
925
+ * with '@' is treated as a file reference, and the file's content is loaded asynchronously. Supported
926
+ * file formats include .txt, .md, .json, .yaml, and .yml. For .json and .yaml/.yml files, the content
927
+ * is parsed into objects; for .txt and .md, the raw string is returned.
928
+ *
929
+ * If a file cannot be loaded (e.g., does not exist, is of unsupported type, or parsing fails), the
930
+ * original string value (with '@' prefix) is returned in place of the file content.
931
+ *
932
+ * The function processes nested arrays and objects recursively, returning a new structure with file
933
+ * contents loaded in place of references. The input object is not mutated.
934
+ *
935
+ * Examples of supported file reference formats:
936
+ * - "@notes.txt"
937
+ * - "@docs/readme.md"
938
+ * - "@config/settings.json"
939
+ * - "@data.yaml"
940
+ *
941
+ * @param {any} obj - The configuration object, array, or string to process.
942
+ * @param {string} basePath - Base path for resolving relative file paths (defaults to process.cwd()).
943
+ * @returns {Promise<any>} - The processed configuration with file content loaded in place of references.
944
+ */
945
+ export async function resolveFileReferences(obj, basePath = process.cwd()) {
946
+ if (typeof obj === "string" && obj.startsWith("@")) {
947
+ return await loadFileContent(obj.slice(1), basePath);
948
+ }
949
+
950
+ if (Array.isArray(obj)) {
951
+ return Promise.all(obj.map((item) => resolveFileReferences(item, basePath)));
952
+ }
953
+
954
+ if (obj && typeof obj === "object") {
955
+ const result = {};
956
+ for (const [key, value] of Object.entries(obj)) {
957
+ result[key] = await resolveFileReferences(value, basePath);
958
+ }
959
+ return result;
960
+ }
961
+
962
+ return obj;
963
+ }
964
+
965
+ /**
966
+ * Load content from a file path
967
+ * @param {string} filePath - The file path to load
968
+ * @param {string} basePath - Base path for resolving relative paths
969
+ * @returns {Promise<any>} - The loaded content or original path if loading fails
970
+ */
971
+ async function loadFileContent(filePath, basePath) {
972
+ try {
973
+ // Resolve path - if absolute, use as is; if relative, resolve from basePath
974
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(basePath, filePath);
975
+
976
+ // Check if file exists
977
+ if (!existsSync(resolvedPath)) {
978
+ return `@${filePath}`; // Return original value if file doesn't exist
979
+ }
980
+
981
+ // Check file extension
982
+ const ext = path.extname(resolvedPath).toLowerCase();
983
+
984
+ if (!SUPPORTED_FILE_EXTENSIONS.includes(ext)) {
985
+ return `@${filePath}`; // Return original value if unsupported file type
986
+ }
987
+
988
+ // Read file content
989
+ const content = await fs.readFile(resolvedPath, "utf-8");
990
+
991
+ // Parse JSON/YAML files
992
+ if (ext === ".json") {
993
+ try {
994
+ return JSON.parse(content);
995
+ } catch {
996
+ return content; // Return raw string if JSON parsing fails
997
+ }
998
+ }
999
+
1000
+ if (ext === ".yaml" || ext === ".yml") {
1001
+ try {
1002
+ return parse(content);
1003
+ } catch {
1004
+ return content; // Return raw string if YAML parsing fails
1005
+ }
1006
+ }
1007
+
1008
+ // Return raw content for .txt and .md files
1009
+ return content;
1010
+ } catch {
1011
+ // Return original value if any error occurs
1012
+ return `@${filePath}`;
1013
+ }
1014
+ }
1015
+
801
1016
  /**
802
1017
  * Detect system language and map to supported language code
803
1018
  * @returns {string} - Supported language code (defaults to 'en' if detection fails or unsupported)