@aigne/doc-smith 0.9.8-alpha.14 → 0.9.8-alpha.16

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.
package/CLAUDE.md CHANGED
@@ -231,6 +231,11 @@ Intent 文档是设计阶段的产物,实施阶段会基于它创建具体的
231
231
  - **文件删除**:必须提供 `dryRun` 选项,允许预览将要删除的内容
232
232
  - **批量操作**:在执行前应验证数据源(如 YAML 文件)的完整性
233
233
 
234
+ ### 代码检查
235
+
236
+ - **每次修改后检查 lint**:执行 `pnpm lint:fix` 检查并自动修复问题
237
+ - **提交前确保通过**:不要提交带有 lint 错误的代码
238
+
234
239
  ## AIGNE 框架开发指南
235
240
 
236
241
  ### Function Agent 基本结构
@@ -438,4 +443,40 @@ agentName.task_render_mode = "collapse";
438
443
 
439
444
  - **入口 Agent**:`skills-entry/doc-smith/index.mjs`
440
445
  - **Function Agent**:`agents/content-checker/index.mjs`
441
- - **共享模块**:`utils/workspace.mjs`、`utils/afs-factory.mjs`
446
+ - **共享模块**:`utils/workspace.mjs`、`utils/afs-factory.mjs`
447
+
448
+ ## 设计决策原则
449
+
450
+ ### KISS 原则应用
451
+
452
+ 在设计功能时,优先考虑最简方案。复杂度不是功能强大的标志,往往是设计不成熟的表现。
453
+
454
+ **案例:Workspace 与主项目的关系**
455
+
456
+ | 方案 | 复杂度 | 问题 |
457
+ |------|--------|------|
458
+ | ❌ 自动添加 submodule | 高 | 本地路径无法共享、需要手动同步远程 |
459
+ | ✅ 添加到 .gitignore | 低 | 简单、无副作用、用户自主选择 |
460
+
461
+ **决策过程**:
462
+ 1. 先问"为什么需要这个功能?"而不是"如何实现这个功能?"
463
+ 2. 识别出 submodule 解决的是"追踪子项目版本",但 doc-smith 不需要这种强耦合
464
+ 3. 最简方案:独立 git 仓库 + gitignore,用户需要时自行决定如何关联
465
+
466
+ ### 问对的问题
467
+
468
+ 遇到技术问题时,先审视问题本身是否正确:
469
+
470
+ | 错误的问题 | 正确的问题 |
471
+ |-----------|-----------|
472
+ | 如何让 submodule URL 自动同步? | 为什么需要 submodule? |
473
+ | 如何处理复杂的边界情况? | 这个功能是否应该存在? |
474
+ | 如何让用户更容易使用? | 用户真正需要什么? |
475
+
476
+ **原则**:如果一个功能需要大量边界处理,可能说明功能本身的抽象层次有问题。
477
+
478
+ ### 默认行为设计
479
+
480
+ - **默认做最少的事**:不自作主张添加可能有问题的配置
481
+ - **复杂操作由用户显式触发**:提供可选命令而非自动执行
482
+ - **松耦合优于强耦合**:让组件独立存在,用户自主决定关联方式
@@ -40,6 +40,7 @@ Content Checker 在文档生成流程的最后阶段执行:
40
40
  - 自动获取:
41
41
  - 从 `PATHS` 常量自动获取文档结构文件路径 (`planning/document-structure.yaml`)
42
42
  - 从 `PATHS` 常量自动获取文档目录路径 (`docs/`)
43
+ - 从 `config.yaml` 自动获取 `translateLanguages` 配置(目标翻译语言列表)
43
44
  - `autoFix` 默认为 `true`
44
45
  - `checkRemoteImages` 默认为 `true`
45
46
 
@@ -72,20 +73,39 @@ Content Checker 在文档生成流程的最后阶段执行:
72
73
  - 文档文件夹必须包含 `.meta.yaml` 文件
73
74
  - `.meta.yaml` 必须包含 `kind`、`source`、`default` 字段
74
75
  - `kind` 字段必须为 `"doc"`
76
+ - **`source` 字段必须与项目 `locale` 一致**(所有文档的源语言必须是项目主语言)
75
77
  - 至少存在一个语言版本文件(如 `zh.md`、`en.md`)
78
+ - `source` 和 `default` 语言文件必须存在
79
+ - 如果 `config.yaml` 中配置了 `translateLanguages`,则所有目标语言文件都必须存在(排除 source 语言本身)
76
80
 
77
81
  3. **内容规范**:
78
82
  - 文档内容不能为空(去除标题后至少 50 字符)
79
83
  - 标题层级不能跳级(H1 → H2 → H3,不能 H1 → H3)
80
84
  - 内部链接必须指向存在的文档
85
+ - 内部链接格式必须正确(详见下方链接格式规范)
81
86
  - 本地图片必须存在于指定路径
82
87
 
83
- 4. **检查规则**:
88
+ 4. **内部链接格式规范**:
89
+ - **正确格式**:
90
+ - 绝对路径:`/agent-types/ai-agent`
91
+ - 相对路径:`../ai-agent` 或 `./sub-doc`
92
+ - 带锚点:`/agent-types/ai-agent#section`
93
+ - **错误格式(Fatal 错误)**:
94
+ - 包含 `.md` 后缀的链接都视为格式错误
95
+ - 示例:`/agent-types/ai-agent/en.md`、`./ai-agent.md`、`../path.md`
96
+ - **智能建议**:
97
+ - 语言后缀模式(`/xx.md`):建议去掉整个 `/xx.md`,如 `/path/en.md` → `/path`
98
+ - 普通 `.md` 后缀:建议只去掉 `.md`,如 `./doc.md` → `./doc`
99
+ - **设计原因**:
100
+ - 内部链接使用文档路径,语言版本由文档系统在运行时根据用户偏好选择
101
+ - 硬编码 `.md` 后缀会导致发布流程异常
102
+
103
+ 5. **检查规则**:
84
104
  - 跳过代码块中的链接和图片
85
105
  - 忽略外部链接的有效性检查
86
106
  - 远程图片通过 HTTP HEAD 请求检查可访问性(3秒超时)
87
107
 
88
- 5. **Sources 绝对路径规则**:
108
+ 6. **Sources 绝对路径规则**:
89
109
  - 识别 `/sources/...` 格式的图片路径
90
110
  - 从 config.yaml 加载 sources 配置
91
111
  - 依次在每个配置的 source 中查找图片:
@@ -124,7 +144,8 @@ Content Checker 在文档生成流程的最后阶段执行:
124
144
  1. **检查通过**:
125
145
  - 所有文档文件和文件夹存在
126
146
  - 所有 `.meta.yaml` 格式正确且包含必需字段
127
- - 所有内部链接有效
147
+ - 所有必需的语言文件存在(source、default、translateLanguages 中的所有语言)
148
+ - 所有内部链接有效且格式正确
128
149
  - 所有本地图片存在
129
150
  - 文档内容充实且标题层级规范
130
151
 
@@ -140,11 +161,12 @@ Content Checker 在文档生成流程的最后阶段执行:
140
161
  1. **文件不存在**:文档结构文件或文档目录不存在
141
162
  2. **文档文件夹缺失**:结构中定义的文档文件夹未生成
142
163
  3. **元数据错误**:`.meta.yaml` 缺失、格式错误或缺少必需字段
143
- 4. **语言文件缺失**:没有任何语言版本文件或缺少 default/source 语言
144
- 5. **内容问题**:空文档、标题跳级
145
- 6. **链接问题**:内部链接死链、路径超出根目录
146
- 7. **图片问题**:本地图片不存在、远程图片无法访问
147
- 8. **Sources 路径问题**:无法解析的 source name、物理路径上图片不存在
164
+ 4. **Source 与 Locale 不一致**:文档的 `source` 字段与项目 `locale` 不一致(应该修改文档的 source 或重新生成文档)
165
+ 5. **语言文件缺失**:没有任何语言版本文件、缺少 default/source 语言、或缺少 translateLanguages 中配置的目标语言
166
+ 6. **内容问题**:空文档、标题跳级
167
+ 7. **链接问题**:内部链接死链、路径超出根目录、链接格式不正确(包含 `.md` 后缀或语言版本后缀如 `/en.md`)
168
+ 8. **图片问题**:本地图片不存在、远程图片无法访问
169
+ 9. **Sources 路径问题**:无法解析的 source name、物理路径上图片不存在
148
170
 
149
171
  ### 处理策略
150
172
 
@@ -3,7 +3,7 @@ import { constants } from "node:fs";
3
3
  import { parse as yamlParse } from "yaml";
4
4
  import path from "node:path";
5
5
  import { collectDocumentPaths } from "../../utils/document-paths.mjs";
6
- import { PATHS } from "../../utils/agent-constants.mjs";
6
+ import { PATHS, ERROR_CODES } from "../../utils/agent-constants.mjs";
7
7
  import { isSourcesAbsolutePath, resolveSourcesPath } from "../../utils/sources-path-resolver.mjs";
8
8
  import { loadConfigFromFile } from "../../utils/config.mjs";
9
9
 
@@ -34,18 +34,33 @@ class DocumentContentValidator {
34
34
  this.documents = [];
35
35
  this.documentPaths = new Set();
36
36
  this.remoteImageCache = new Map();
37
- this.sourcesConfig = null; // 缓存 sources 配置
37
+ this.workspaceConfig = null; // 缓存 workspace 配置
38
+ }
39
+
40
+ /**
41
+ * 加载 workspace 配置(懒加载)
42
+ */
43
+ async loadWorkspaceConfig() {
44
+ if (this.workspaceConfig === null) {
45
+ this.workspaceConfig = (await loadConfigFromFile()) || {};
46
+ }
47
+ return this.workspaceConfig;
38
48
  }
39
49
 
40
50
  /**
41
51
  * 加载 sources 配置(懒加载)
42
52
  */
43
53
  async loadSourcesConfig() {
44
- if (this.sourcesConfig === null) {
45
- const config = await loadConfigFromFile();
46
- this.sourcesConfig = config?.sources || [];
47
- }
48
- return this.sourcesConfig;
54
+ const config = await this.loadWorkspaceConfig();
55
+ return config.sources || [];
56
+ }
57
+
58
+ /**
59
+ * 加载 translateLanguages 配置(懒加载)
60
+ */
61
+ async loadTranslateLanguages() {
62
+ const config = await this.loadWorkspaceConfig();
63
+ return config.translateLanguages || [];
49
64
  }
50
65
 
51
66
  /**
@@ -204,6 +219,22 @@ class DocumentContentValidator {
204
219
  suggestion: "修改为 kind: doc",
205
220
  });
206
221
  }
222
+
223
+ // source 与项目 locale 一致性校验
224
+ if (meta.source) {
225
+ const config = await this.loadWorkspaceConfig();
226
+ const projectLocale = config?.locale;
227
+ if (projectLocale && meta.source !== projectLocale) {
228
+ this.errors.fatal.push({
229
+ type: ERROR_CODES.SOURCE_LOCALE_MISMATCH,
230
+ path: doc.path,
231
+ source: meta.source,
232
+ locale: projectLocale,
233
+ message: `文档 source (${meta.source}) 与项目 locale (${projectLocale}) 不一致: ${doc.path}`,
234
+ suggestion: `修改文档的 source 为 "${projectLocale}",或重新生成该文档的主语言版本`,
235
+ });
236
+ }
237
+ }
207
238
  } catch (error) {
208
239
  this.errors.fatal.push({
209
240
  type: "INVALID_META",
@@ -265,6 +296,26 @@ class DocumentContentValidator {
265
296
  });
266
297
  }
267
298
  }
299
+
300
+ // 检查 translateLanguages 中配置的目标语言文件是否存在
301
+ const translateLanguages = await this.loadTranslateLanguages();
302
+ if (translateLanguages.length > 0) {
303
+ for (const lang of translateLanguages) {
304
+ // 跳过源语言(源语言不需要作为翻译目标)
305
+ if (lang === meta.source) continue;
306
+
307
+ const langFile = `${lang}.md`;
308
+ if (!langFiles.includes(langFile)) {
309
+ this.errors.fatal.push({
310
+ type: ERROR_CODES.MISSING_TRANSLATE_LANGUAGE,
311
+ path: doc.path,
312
+ lang,
313
+ message: `翻译语言文件缺失: ${langFile}`,
314
+ suggestion: `请翻译文档到 ${lang} 语言,或从 config.yaml 的 translateLanguages 中移除 ${lang}`,
315
+ });
316
+ }
317
+ }
318
+ }
268
319
  } catch (_error) {
269
320
  // .meta.yaml 错误已在 validateMetaFile 中报告
270
321
  }
@@ -530,11 +581,36 @@ class DocumentContentValidator {
530
581
  async validateInternalLink(linkUrl, doc, linkText, langFile) {
531
582
  let targetPath;
532
583
 
533
- // 链接预处理:移除锚点、语言文件名和 .md 后缀
534
- const cleanLinkUrl = linkUrl
535
- .split("#")[0] // 移除锚点
536
- .replace(/\/[a-z]{2}(-[A-Z]{2})?\.md$/, "") // 移除 /zh.md
537
- .replace(/\.md$/, ""); // 移除 .md
584
+ // 移除锚点部分用于格式检查
585
+ const urlWithoutAnchor = linkUrl.split("#")[0];
586
+
587
+ // 检查链接格式是否正确:内部链接不应包含 .md 后缀
588
+ const langSuffixPattern = /\/[a-z]{2}(-[A-Z]{2})?\.md$/; // 匹配 /en.md, /zh.md, /en-US.md
589
+ const mdSuffixPattern = /\.md$/;
590
+
591
+ if (mdSuffixPattern.test(urlWithoutAnchor)) {
592
+ // 链接包含 .md 后缀,这是格式错误
593
+ // 如果是语言后缀模式,去掉整个 /xx.md 部分;否则只去掉 .md
594
+ const isLangSuffix = langSuffixPattern.test(urlWithoutAnchor);
595
+ const suggestedLink = isLangSuffix
596
+ ? urlWithoutAnchor.replace(langSuffixPattern, "")
597
+ : urlWithoutAnchor.replace(mdSuffixPattern, "");
598
+
599
+ this.stats.brokenLinks++;
600
+ this.errors.fatal.push({
601
+ type: ERROR_CODES.INVALID_LINK_FORMAT,
602
+ path: doc.path,
603
+ langFile,
604
+ link: linkUrl,
605
+ linkText,
606
+ message: `内部链接格式错误: [${linkText}](${linkUrl})`,
607
+ suggestion: `链接不应包含 .md 后缀,建议改为: ${suggestedLink}`,
608
+ });
609
+ return;
610
+ }
611
+
612
+ // 链接格式正确,继续验证目标是否存在
613
+ const cleanLinkUrl = urlWithoutAnchor;
538
614
 
539
615
  // 如果链接只是锚点(如 #section),cleanLinkUrl 会是空字符串,跳过检查
540
616
  if (!cleanLinkUrl) {
@@ -632,8 +708,8 @@ class DocumentContentValidator {
632
708
 
633
709
  // 检查是否为 /sources/... 绝对路径
634
710
  if (isSourcesAbsolutePath(imageUrl)) {
635
- await this.loadSourcesConfig();
636
- const resolved = await resolveSourcesPath(imageUrl, this.sourcesConfig, PATHS.WORKSPACE_BASE);
711
+ const sourcesConfig = await this.loadSourcesConfig();
712
+ const resolved = await resolveSourcesPath(imageUrl, sourcesConfig, PATHS.WORKSPACE_BASE);
637
713
 
638
714
  if (!resolved) {
639
715
  this.stats.missingImages++;
@@ -12,7 +12,7 @@ skills:
12
12
  skills:
13
13
  - url: ./translate-documents/translate-to-languages.yaml
14
14
  iterate_on: translationTasks
15
- concurrency: 5
15
+ concurrency: 3
16
16
  - url: ./translate-documents/generate-summary.mjs # 生成最终总结报告
17
17
  input_schema:
18
18
  type: object
@@ -21,7 +21,8 @@ DocSmith 生成的文档存储在本地 workspace 目录中,使用特定的目
21
21
  └─ 检查文档内容(checkContent)
22
22
 
23
23
  2. translate-meta.mjs - 翻译元数据(可选)
24
- └─ 将项目名称和描述翻译为多语言
24
+ ├─ 如果只有一种语言,跳过翻译,直接保存到缓存
25
+ └─ 如果有多种语言,将项目名称和描述翻译为目标语言
25
26
 
26
27
  3. publish-docs.mjs - 执行发布
27
28
  ├─ 加载文档结构
@@ -48,6 +49,8 @@ DocSmith 生成的文档存储在本地 workspace 目录中,使用特定的目
48
49
  - 调整相对路径以适应发布后的目录结构
49
50
  4. **多平台支持**:DocSmith Cloud、自有网站、新建网站
50
51
  5. **元数据翻译**:支持将项目信息翻译为多语言
52
+ - 单语言时跳过翻译,直接缓存原始内容
53
+ - 多语言时才调用 AI 进行翻译
51
54
 
52
55
  ## 输入输出
53
56
 
@@ -15,7 +15,7 @@ import { PATHS } from "../../utils/agent-constants.mjs";
15
15
  import { deploy } from "../../utils/deploy.mjs";
16
16
  import { loadConfigFromFile, saveValueToConfig } from "../../utils/config.mjs";
17
17
  import { ensureTmpDir } from "../../utils/files.mjs";
18
- import { getGithubRepoUrl } from "../../utils/git.mjs";
18
+ import { getGithubRepoUrl, isValidGithubUrl } from "../../utils/git.mjs";
19
19
  import updateBranding from "../../utils/branding.mjs";
20
20
  import { generateSidebar, loadDocumentStructure } from "../../utils/docs.mjs";
21
21
  import { copyDocumentsToTemp } from "../../utils/docs-converter.mjs";
@@ -245,9 +245,18 @@ export default async function publishDocs(
245
245
  }
246
246
 
247
247
  // Construct boardMeta object
248
+ // In standalone mode, get GitHub URL from git-clone type source in sources array
249
+ // In project mode, use current git repo URL (even if git-clone sources exist as supplements)
250
+ let githubRepoUrl = getGithubRepoUrl();
251
+ if (config?.mode === "standalone") {
252
+ const gitCloneSource = config?.sources?.find((s) => s.type === "git-clone");
253
+ const configUrl = gitCloneSource?.url;
254
+ // Only use config URL if it's a valid GitHub URL
255
+ githubRepoUrl = isValidGithubUrl(configUrl) ? configUrl : "";
256
+ }
248
257
  const boardMeta = {
249
258
  category: config?.documentPurpose || [],
250
- githubRepoUrl: getGithubRepoUrl(),
259
+ githubRepoUrl,
251
260
  commitSha: config?.lastGitHead || "",
252
261
  languages: [
253
262
  ...(config?.locale ? [config.locale] : []),
@@ -59,36 +59,47 @@ export default async function translateMeta({ config }, options) {
59
59
  const titleTranslation = parsedTranslationCache[projectName] || {};
60
60
  const descTranslation = parsedTranslationCache[projectDesc] || {};
61
61
 
62
- const titleLanguages = languages.filter((lang) => !titleTranslation[lang]);
63
- const descLanguages = languages.filter((lang) => !descTranslation[lang]);
64
- const titleTranslationSchema = z.object(
65
- titleLanguages.reduce((shape, lang) => {
66
- shape[lang] = z.string();
67
- return shape;
68
- }, {}),
69
- );
70
- const descTranslationSchema = z.object(
71
- descLanguages.reduce((shape, lang) => {
72
- shape[lang] = z.string();
73
- return shape;
74
- }, {}),
75
- );
76
-
77
- const agent = AIAgent.from({
78
- name: "translateMeta",
79
- instructions:
80
- "You are an **Elite Polyglot Localization and Translation Specialist** with extensive professional experience across multiple domains. Your core mission is to produce translations that are not only **100% accurate** to the source meaning but are also **natively fluent, highly readable, and culturally appropriate** in the target language.",
81
- inputKey: "message",
82
- outputSchema: z.object({
83
- title: titleTranslationSchema.describe("Translated titles with language codes as keys"),
84
- desc: descTranslationSchema.describe(
85
- "Translated descriptions with language codes as keys. Each description MUST be within 100 characters.",
86
- ),
87
- }),
88
- });
89
- if (titleLanguages.length > 0 || descLanguages.length > 0) {
90
- const translatedMetadata = await options.context.invoke(agent, {
91
- message: `Translate the following title and description into all target languages except the source language. Provide the translations in a JSON object with the language codes as keys. If the project title or description is empty, return an empty string for that field.
62
+ // If only one language, skip translation and cache original content directly
63
+ if (languages.length <= 1) {
64
+ const singleLang = languages[0] || locale || "en";
65
+ if (projectName && !titleTranslation[singleLang]) {
66
+ titleTranslation[singleLang] = projectName;
67
+ }
68
+ if (finalProjectDesc && !descTranslation[singleLang]) {
69
+ descTranslation[singleLang] = finalProjectDesc;
70
+ }
71
+ } else {
72
+ // Multiple languages: need translation
73
+ const titleLanguages = languages.filter((lang) => !titleTranslation[lang]);
74
+ const descLanguages = languages.filter((lang) => !descTranslation[lang]);
75
+ const titleTranslationSchema = z.object(
76
+ titleLanguages.reduce((shape, lang) => {
77
+ shape[lang] = z.string();
78
+ return shape;
79
+ }, {}),
80
+ );
81
+ const descTranslationSchema = z.object(
82
+ descLanguages.reduce((shape, lang) => {
83
+ shape[lang] = z.string();
84
+ return shape;
85
+ }, {}),
86
+ );
87
+
88
+ const agent = AIAgent.from({
89
+ name: "translateMeta",
90
+ instructions:
91
+ "You are an **Elite Polyglot Localization and Translation Specialist** with extensive professional experience across multiple domains. Your core mission is to produce translations that are not only **100% accurate** to the source meaning but are also **natively fluent, highly readable, and culturally appropriate** in the target language.",
92
+ inputKey: "message",
93
+ outputSchema: z.object({
94
+ title: titleTranslationSchema.describe("Translated titles with language codes as keys"),
95
+ desc: descTranslationSchema.describe(
96
+ "Translated descriptions with language codes as keys. Each description MUST be within 100 characters.",
97
+ ),
98
+ }),
99
+ });
100
+ if (titleLanguages.length > 0 || descLanguages.length > 0) {
101
+ const translatedMetadata = await options.context.invoke(agent, {
102
+ message: `Translate the following title and description into all target languages except the source language. Provide the translations in a JSON object with the language codes as keys. If the project title or description is empty, return an empty string for that field.
92
103
 
93
104
  **IMPORTANT**: The description translations MUST be concise and within 100 characters. If the source description is long, extract and translate only the key points or create a brief summary that captures the essence.
94
105
 
@@ -117,23 +128,19 @@ Respond with a JSON object in the following format:
117
128
  - Be concise and capture the core essence of the project
118
129
  - Use natural, fluent language appropriate for the target culture
119
130
  - If the source is very long, create a brief summary instead of a full translation
120
-
121
- If no translation is needed, respond with:
122
- {
123
- "title": {},
124
- "desc": {}
125
- }`,
126
- });
127
- Object.keys(translatedMetadata.title || {}).forEach((lang) => {
128
- if (translatedMetadata.title[lang]) {
129
- titleTranslation[lang] = translatedMetadata.title[lang];
130
- }
131
- });
132
- Object.keys(translatedMetadata.desc || {}).forEach((lang) => {
133
- if (translatedMetadata.desc[lang]) {
134
- descTranslation[lang] = translatedMetadata.desc[lang];
135
- }
136
- });
131
+ `,
132
+ });
133
+ Object.keys(translatedMetadata.title || {}).forEach((lang) => {
134
+ if (translatedMetadata.title[lang]) {
135
+ titleTranslation[lang] = translatedMetadata.title[lang];
136
+ }
137
+ });
138
+ Object.keys(translatedMetadata.desc || {}).forEach((lang) => {
139
+ if (translatedMetadata.desc[lang]) {
140
+ descTranslation[lang] = translatedMetadata.desc[lang];
141
+ }
142
+ });
143
+ }
137
144
  }
138
145
 
139
146
  if (!projectDesc && finalProjectDesc) {
@@ -1,4 +1,5 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
2
3
  import { stringify as yamlStringify } from "yaml";
3
4
  import path from "node:path";
4
5
  import {
@@ -7,6 +8,7 @@ import {
7
8
  isValidDocumentPath,
8
9
  } from "../../utils/document-paths.mjs";
9
10
  import { PATHS, ERROR_CODES, FILE_TYPES, DOC_META_DEFAULTS } from "../../utils/agent-constants.mjs";
11
+ import { loadLocale } from "../../utils/config.mjs";
10
12
 
11
13
  /**
12
14
  * 创建文档文件夹和文件
@@ -124,10 +126,38 @@ export default async function saveDocument({ path: rawPath, content, options = {
124
126
  };
125
127
  }
126
128
 
127
- // 7. 创建文件夹和文件
129
+ // 7. 检查新建文档时 language 必须等于项目 locale
130
+ const docFolder = path.join(PATHS.DOCS_DIR, filePath);
131
+ const metaPath = path.join(docFolder, FILE_TYPES.META);
132
+ const isNewDocument = !existsSync(metaPath);
133
+
134
+ if (isNewDocument) {
135
+ let projectLocale;
136
+ try {
137
+ projectLocale = await loadLocale();
138
+ } catch (_error) {
139
+ return {
140
+ success: false,
141
+ error: ERROR_CODES.MISSING_CONFIG_FILE,
142
+ message: "无法读取项目配置文件",
143
+ suggestion: "请确保 config.yaml 存在且包含 locale 字段",
144
+ };
145
+ }
146
+
147
+ if (language !== projectLocale) {
148
+ return {
149
+ success: false,
150
+ error: ERROR_CODES.INVALID_LANGUAGE,
151
+ message: `新建文档时必须使用项目主语言: ${projectLocale},当前传入: ${language}`,
152
+ suggestion: `请将 language 参数改为 "${projectLocale}"(项目 locale),首先生成主语言版本`,
153
+ };
154
+ }
155
+ }
156
+
157
+ // 8. 创建文件夹和文件
128
158
  const files = await createDocumentFiles(filePath, language, content);
129
159
 
130
- // 8. 返回成功响应
160
+ // 9. 返回成功响应
131
161
  return {
132
162
  success: true,
133
163
  path: displayPath,
@@ -154,7 +184,7 @@ saveDocument.description =
154
184
  `保存文档到 ${PATHS.DOCS_DIR} 目录,自动创建文件夹结构、元信息文件和语言版本文件。` +
155
185
  "【重要限制】此工具仅用于新增文档时调用。编辑已有文档时,请直接使用 Edit 工具修改对应的语言文件。" +
156
186
  `使用前必须确保 ${PATHS.DOCUMENT_STRUCTURE} 已存在且包含目标文档路径。` +
157
- `language 参数必须从 ${PATHS.CONFIG} locale 字段读取并传入。`;
187
+ `【强制要求】新建文档时 language 必须等于项目 locale(config.yaml 中的 locale 字段),系统会自动验证。`;
158
188
 
159
189
  // 定义输入 schema
160
190
  saveDocument.input_schema = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aigne/doc-smith",
3
- "version": "0.9.8-alpha.14",
3
+ "version": "0.9.8-alpha.16",
4
4
  "description": "AI-driven documentation generation tool built on the AIGNE Framework",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,6 +37,7 @@
37
37
 
38
38
  在每个文档中添加导航链接,引导用户在文档之间流畅跳转。
39
39
  只能链接生成的其他文档,不能链接到工作目录中的 markdown 文件,文档发布后会导致无法访问。
40
+ 导航链接应该使用文档结构中文档的 `path`
40
41
 
41
42
  ### 文档开头导航
42
43
 
@@ -104,7 +104,7 @@ find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.g
104
104
  - 文档开头:前置条件(prerequisites)、父主题(parent topic)
105
105
  - 文档结尾:相关主题(related topics)、下一步(next steps)、子文档(child documents)
106
106
  - 只能链接生成的其他文档,不能链接到工作目录中的 markdown 文件,文档发布后会导致无法访问。
107
- - 导航链接可直接使用文件结构中文档的 `path`
107
+ - 导航链接应该使用文档结构中文档的 `path`
108
108
 
109
109
  #### 主体内容
110
110
  - **结构化章节**:逻辑清晰的信息层次
@@ -70,6 +70,11 @@ export const ERROR_CODES = {
70
70
  MISSING_LANGS: "MISSING_LANGS",
71
71
  MISSING_SOURCE_FILE: "MISSING_SOURCE_FILE",
72
72
 
73
+ // 内容校验相关
74
+ SOURCE_LOCALE_MISMATCH: "SOURCE_LOCALE_MISMATCH",
75
+ MISSING_TRANSLATE_LANGUAGE: "MISSING_TRANSLATE_LANGUAGE",
76
+ INVALID_LINK_FORMAT: "INVALID_LINK_FORMAT",
77
+
73
78
  // 其他
74
79
  SAVE_ERROR: "SAVE_ERROR",
75
80
  UNEXPECTED_ERROR: "UNEXPECTED_ERROR",
package/utils/git.mjs CHANGED
@@ -1,5 +1,16 @@
1
1
  import { execSync } from "node:child_process";
2
2
 
3
+ /**
4
+ * Validate if a URL is a valid GitHub repository URL
5
+ * @param {string} url - The URL to validate
6
+ * @returns {boolean} - True if valid GitHub URL
7
+ */
8
+ export function isValidGithubUrl(url) {
9
+ if (!url || typeof url !== "string") return false;
10
+ // Match both HTTPS and SSH GitHub URLs
11
+ return /^(https:\/\/github\.com\/|git@github\.com:)[^/]+\/[^/]+/.test(url);
12
+ }
13
+
3
14
  /**
4
15
  * Get GitHub repository URL
5
16
  * @returns {string} - GitHub repository URL or empty string
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { access, readFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { access, readFile, mkdir, writeFile, appendFile } from "node:fs/promises";
3
3
  import { constants } from "node:fs";
4
4
  import { exec } from "node:child_process";
5
5
  import { promisify } from "node:util";
@@ -24,6 +24,17 @@ export const DOC_SMITH_DIR = ".aigne/doc-smith";
24
24
  export const SOURCES_DIR = "sources";
25
25
  export const WORKSPACE_SUBDIRS = ["intent", "planning", "docs"];
26
26
 
27
+ /**
28
+ * doc-smith workspace 的 .gitignore 内容
29
+ */
30
+ export const GITIGNORE_CONTENT = `\
31
+ # Ignore sources directory
32
+ sources/
33
+
34
+ # Ignore temporary files
35
+ tmp/
36
+ `;
37
+
27
38
  /**
28
39
  * 检查路径是否存在
29
40
  * @param {string} path - 路径
@@ -48,12 +59,13 @@ export function pathExistsSync(path) {
48
59
  }
49
60
 
50
61
  /**
51
- * 检查目录是否是 git 仓库
52
- * @param {string} path - 目录路径
62
+ * 检查是否在 git 仓库内(支持子目录)
63
+ * @param {string} cwd - 工作目录
53
64
  * @returns {Promise<boolean>}
54
65
  */
55
- export async function isGitRepo(path = ".") {
56
- return pathExists(join(path, ".git"));
66
+ export async function isGitRepo(cwd = ".") {
67
+ const result = await gitExec("rev-parse --is-inside-work-tree", cwd);
68
+ return result.success && result.output === "true";
57
69
  }
58
70
 
59
71
  /**
@@ -71,6 +83,49 @@ export async function gitExec(command, cwd = ".") {
71
83
  }
72
84
  }
73
85
 
86
+ /**
87
+ * 获取 git 仓库根目录
88
+ * @param {string} cwd - 起始目录
89
+ * @returns {Promise<string | null>}
90
+ */
91
+ export async function getGitRoot(cwd = ".") {
92
+ const result = await gitExec("rev-parse --show-toplevel", cwd);
93
+ if (result.success) {
94
+ return result.output;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * 向 .gitignore 添加忽略规则(如果不存在)
101
+ * @param {string} gitRoot - git 仓库根目录
102
+ * @param {string} pattern - 要忽略的模式
103
+ * @returns {Promise<boolean>} 是否添加成功
104
+ */
105
+ export async function addToGitignore(gitRoot, pattern) {
106
+ const gitignorePath = join(gitRoot, ".gitignore");
107
+
108
+ try {
109
+ // 检查 .gitignore 是否存在
110
+ if (await pathExists(gitignorePath)) {
111
+ // 读取现有内容,检查是否已包含该模式
112
+ const content = await readFile(gitignorePath, "utf8");
113
+ if (content.includes(pattern)) {
114
+ return true; // 已存在,无需添加
115
+ }
116
+ // 追加到文件末尾(确保换行)
117
+ const prefix = content.endsWith("\n") ? "" : "\n";
118
+ await appendFile(gitignorePath, `${prefix}${pattern}\n`, "utf8");
119
+ } else {
120
+ // 创建新的 .gitignore
121
+ await writeFile(gitignorePath, `${pattern}\n`, "utf8");
122
+ }
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
74
129
  /**
75
130
  * 检测 workspace 模式(同步版本)
76
131
  * 用于需要在模块加载时同步判断的场景
@@ -179,8 +234,7 @@ export async function initProjectMode() {
179
234
  await createDirectoryStructure(DOC_SMITH_DIR);
180
235
 
181
236
  // 创建 .gitignore
182
- const gitignoreContent = "# Ignore sources directory\nsources/\n";
183
- await writeFile(join(DOC_SMITH_DIR, ".gitignore"), gitignoreContent, "utf8");
237
+ await writeFile(join(DOC_SMITH_DIR, ".gitignore"), GITIGNORE_CONTENT, "utf8");
184
238
 
185
239
  // 生成 config.yaml
186
240
  const configContent = generateConfig({
@@ -194,7 +248,7 @@ export async function initProjectMode() {
194
248
  });
195
249
  await writeFile(join(DOC_SMITH_DIR, "config.yaml"), configContent, "utf8");
196
250
 
197
- // 在 doc-smith repo 中创建初始提交(submodule 需要)
251
+ // 在 doc-smith repo 中创建初始提交
198
252
  await gitExec("add .", DOC_SMITH_DIR);
199
253
  const commitResult = await gitExec(
200
254
  'commit -m "Initial commit: doc-smith workspace"',
@@ -204,17 +258,20 @@ export async function initProjectMode() {
204
258
  console.log(`✅ Created initial commit in ${DOC_SMITH_DIR}`);
205
259
  }
206
260
 
207
- // 如果外层是 git 仓库,添加为 submodule
208
- const outerIsGitRepo = await isGitRepo(".");
261
+ // 如果外层是 git 仓库,在其 .gitignore 中添加忽略规则
262
+ const gitRoot = await getGitRoot(".");
209
263
 
210
- if (outerIsGitRepo) {
211
- const submoduleCmd = `submodule add ./${DOC_SMITH_DIR} ${DOC_SMITH_DIR}`;
212
- const result = await gitExec(submoduleCmd);
264
+ if (gitRoot) {
265
+ // 计算相对于 git 根目录的忽略路径
266
+ const cwd = process.cwd();
267
+ const relativePath = cwd.startsWith(gitRoot)
268
+ ? cwd.slice(gitRoot.length + 1) // 去掉 gitRoot 和 /
269
+ : "";
270
+ const ignorePattern = relativePath ? `${relativePath}/${DOC_SMITH_DIR}/` : `${DOC_SMITH_DIR}/`;
213
271
 
214
- if (result.success) {
215
- console.log(`✅ Added ${DOC_SMITH_DIR} as git submodule`);
216
- } else {
217
- console.log(`⚠️ Failed to add submodule: ${result.error}`);
272
+ const added = await addToGitignore(gitRoot, ignorePattern);
273
+ if (added) {
274
+ console.log(`✅ Added ${ignorePattern} to .gitignore`);
218
275
  }
219
276
  }
220
277
 
@@ -239,8 +296,7 @@ export async function initStandaloneMode() {
239
296
  await gitExec("init");
240
297
 
241
298
  // 创建 .gitignore
242
- const gitignoreContent = "# Ignore sources directory\nsources/\n";
243
- await writeFile(".gitignore", gitignoreContent, "utf8");
299
+ await writeFile(".gitignore", GITIGNORE_CONTENT, "utf8");
244
300
 
245
301
  // 创建目录结构(包括 sources/)
246
302
  await createDirectoryStructure(".", true);