harveyz-skill 0.1.2 → 0.2.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 (31) hide show
  1. package/bin/cli.js +116 -44
  2. package/lib/bundles.js +97 -22
  3. package/lib/installer.js +126 -5
  4. package/lib/targets.js +1 -0
  5. package/lib/vars.js +34 -0
  6. package/package.json +6 -3
  7. package/skills/harness/diataxis-docs/SKILL.md +128 -0
  8. package/skills/harness/diataxis-docs/references/custom-categories.md +32 -0
  9. package/skills/harness/diataxis-docs/references/index-template.md +29 -0
  10. package/skills/web-fetch/article-fetcher/SKILL.md +625 -0
  11. package/skills/web-fetch/article-fetcher/references/article_utils.py +225 -0
  12. package/skills/web-fetch/article-fetcher/references/file-format.md +73 -0
  13. package/skills/web-fetch/article-fetcher/scripts/import_reading.py +75 -0
  14. package/skills/web-fetch/article-fetcher/scripts/init_db.py +33 -0
  15. package/skills/web-fetch/article-fetcher/scripts/url-index.db +0 -0
  16. package/skills/web-fetch/article-fetcher/scripts/url-index.db.bak +0 -0
  17. package/skills/web-fetch/article-fetcher/vars.json +12 -0
  18. package/skills/writing/mermaid-diagram/SKILL.md +220 -0
  19. package/skills/writing/mermaid-diagram/references/color-templates.md +75 -0
  20. package/skills/writing/mermaid-diagram/references/flowchart.md +88 -0
  21. package/skills/writing/mermaid-diagram/references/gantt-timeline.md +62 -0
  22. package/skills/writing/mermaid-diagram/references/other-charts.md +55 -0
  23. package/skills/writing/mermaid-diagram/references/sequence.md +143 -0
  24. package/skills/writing/mermaid-diagram/references/statediagram.md +54 -0
  25. package/skills/writing/mermaid-diagram/scripts/check_mermaid.py +84 -0
  26. package/tools/p-launch/p-launch.sh +275 -0
  27. package/tools/p-launch/tests/e2e.bats +164 -0
  28. package/tools/p-launch/tests/unit.bats +151 -0
  29. package/tools/p-launch/vars.json +9 -0
  30. package/tools/p-launch/zshrc.snippet +5 -0
  31. package/bundles.json +0 -24
@@ -0,0 +1,225 @@
1
+ """
2
+ 共享工具函数:格式化 block、构建文章、修复 frontmatter
3
+ """
4
+ import re, os, json, sqlite3
5
+ from datetime import datetime, timezone, timedelta
6
+
7
+ # ------------------------------------------------------------
8
+ # 公共常量
9
+ # ------------------------------------------------------------
10
+ HEADING_PREFIX = {'h1': '# ', 'h2': '## ', 'h3': '### ', 'h4': '#### '}
11
+ IMAGE_BASE_URL = 'Image/'
12
+
13
+ FETCH_DATE = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
14
+
15
+ # ------------------------------------------------------------
16
+ # 1. format_block: 将单个 block 转为 Markdown 字符串
17
+ # ------------------------------------------------------------
18
+ def format_block(block):
19
+ """将 {tag, content} 转为 Markdown 格式"""
20
+ tag = block.get('tag', 'p')
21
+ content = block.get('content', '')
22
+ if tag in HEADING_PREFIX:
23
+ return HEADING_PREFIX[tag] + content
24
+ if tag == 'pre':
25
+ return f'```\n{content}\n```'
26
+ if tag == 'li':
27
+ return f'- {content}'
28
+ # 内容以 # 开头且含 / 或 --- = SKILL.md 代码块
29
+ if content.startswith('# ') and ('/' in content or '---' in content):
30
+ return f'```\n{content}\n```'
31
+ return content
32
+
33
+
34
+ # ------------------------------------------------------------
35
+ # 2. infer_ext: 从 URL 或 Content-Type 推断图片扩展名
36
+ # ------------------------------------------------------------
37
+ def infer_ext(url, content_type=''):
38
+ """从 URL 或 Content-Type 推断图片扩展名"""
39
+ ext_map = {'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp'}
40
+ if content_type:
41
+ return ext_map.get(content_type, '.jpg')
42
+ url_lower = url.lower()
43
+ for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
44
+ if ext in url_lower:
45
+ return '.jpg' if ext == '.jpeg' else ext
46
+ return '.jpg'
47
+
48
+
49
+ # ------------------------------------------------------------
50
+ # 3. build_article_from_json: 从统一 JSON 构建文章
51
+ # ------------------------------------------------------------
52
+ def build_article_from_json(json_path, title, source_url, origin_filename,
53
+ tags, description, author='', publish_date='',
54
+ origin_path=None, article_path=None):
55
+ """
56
+ 从 article_combined.json 构建原文/译文文件。
57
+
58
+ JSON 结构:
59
+ blocks: [{tag, content, blockIndex}]
60
+ images: [{src, alt, afterBlock, filename}]
61
+ """
62
+ with open(json_path, encoding='utf-8') as f:
63
+ data = json.load(f)
64
+
65
+ blocks = data.get('blocks', [])
66
+ images = data.get('images') or data.get('imageBlocks') or [] # 兼容 imageBlocks/image 两种 key
67
+
68
+ # 按 afterBlock 插入图片
69
+ body_units = []
70
+ # afterBlock == -1 的图片插入文章最开头
71
+ pre_imgs = [f'![]({IMAGE_BASE_URL}{img["filename"]})'
72
+ for img in images if img.get('afterBlock') == -1]
73
+ if pre_imgs:
74
+ body_units.append('\n'.join(pre_imgs))
75
+
76
+ for i, block in enumerate(blocks):
77
+ unit_parts = [format_block(block)]
78
+ for img in images:
79
+ if img.get('afterBlock') == i:
80
+ unit_parts.append(f'![]({IMAGE_BASE_URL}{img["filename"]})')
81
+ body_units.append('\n'.join(unit_parts))
82
+
83
+ body = '\n\n'.join(body_units)
84
+
85
+ tags_str = ' - ' + '\n - '.join(tags)
86
+
87
+ fm = f"""---
88
+ publish_date: {publish_date}
89
+ fetch_date: {FETCH_DATE}
90
+ author: {author}
91
+ source_url: {source_url}
92
+ origin_title: "{origin_filename.replace('.md', '')}"
93
+ tags:
94
+ {tags_str}
95
+ description: {description}
96
+ ---"""
97
+
98
+ content = f"""{fm}
99
+
100
+ [[Origin/{origin_filename}]]
101
+
102
+ ---
103
+
104
+ # {title}
105
+
106
+ {body}
107
+ """
108
+ return content
109
+
110
+
111
+ # ------------------------------------------------------------
112
+ # 4. repair_frontmatter: 自动修复 frontmatter 字段
113
+ # ------------------------------------------------------------
114
+ def repair_frontmatter(fp, url, defaults=None):
115
+ """
116
+ 尝试自动补全缺失字段,返回 (fm_dict, 修复了哪些字段, 剩余问题列表)
117
+ """
118
+ defaults = defaults or {}
119
+ with open(fp, encoding='utf-8') as f:
120
+ content = f.read()
121
+
122
+ m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
123
+ if not m:
124
+ return {}, [], ['无frontmatter']
125
+
126
+ fm = {}
127
+ for line in m.group(1).split('\n'):
128
+ if ':' in line:
129
+ k, v = line.split(':', 1)
130
+ fm[k.strip()] = v.strip()
131
+
132
+ fixed, remaining = [], []
133
+
134
+ # 自动补全 defaults
135
+ for field, value in defaults.items():
136
+ if not fm.get(field, '').strip():
137
+ fm[field] = value
138
+ fixed.append(f'{field}={value}')
139
+
140
+ # tags 格式修复:逗号分隔 → YAML 列表
141
+ tags_raw = fm.get('tags', '')
142
+ if tags_raw and ',' in tags_raw and not tags_raw.startswith('-'):
143
+ raw_list = [t.strip() for t in tags_raw.split(',') if t.strip()]
144
+ def _norm_tag(t):
145
+ if re.search(r'[\u4e00-\u9fff]', t):
146
+ return t.lower()
147
+ return t.lower().replace(' ', '-')
148
+ fm['tags'] = '\n - ' + '\n - '.join(_norm_tag(t) for t in raw_list)
149
+ fixed.append('tags=YAML列表')
150
+
151
+ # tags 大写修复
152
+ if fm.get('tags', '').startswith('-'):
153
+ tag_lines = [l for l in fm['tags'].strip().split('\n') if l.strip().startswith('-')]
154
+ new_lines = []
155
+ for tl in tag_lines:
156
+ tag_val = tl.lstrip('- ').strip()
157
+ if tag_val and tag_val != tag_val.lower():
158
+ new_lines.append(f' - {tag_val.lower()}')
159
+ fixed.append(f'tag-lowercase={tag_val}')
160
+ else:
161
+ new_lines.append(tl)
162
+ fm['tags'] = '\n'.join(new_lines)
163
+
164
+ # 写回文件
165
+ fm_lines = [f'{k}: {v}' for k, v in fm.items()]
166
+ fm_str = '---\n' + '\n'.join(fm_lines) + '\n---\n'
167
+ with open(fp, 'w', encoding='utf-8') as f:
168
+ f.write(fm_str + content[m.end():])
169
+
170
+ # 检查剩余问题
171
+ for fld in ['publish_date', 'author', 'source_url']:
172
+ if not fm.get(fld, '').strip():
173
+ remaining.append(f'{fld}空')
174
+ if 'origin_title' not in fm or not fm.get('origin_title', '').strip():
175
+ remaining.append('origin_title空')
176
+ if 'description' not in fm or not fm.get('description', '').strip():
177
+ remaining.append('description空')
178
+
179
+ return fm, fixed, remaining
180
+
181
+
182
+ # ------------------------------------------------------------
183
+ # 5. record_issues: 写入 issues 字段
184
+ # ------------------------------------------------------------
185
+ def record_issues(url, issues_text, db_path=None):
186
+ """将 issues 写入 SQLite"""
187
+ if db_path is None:
188
+ db_path = os.path.expanduser(
189
+ '{{SKILL_DIR}}/scripts/url-index.db'
190
+ )
191
+ conn = sqlite3.connect(db_path)
192
+ conn.execute('UPDATE url_index SET issues=? WHERE source_url=?',
193
+ (issues_text, url))
194
+ conn.commit()
195
+
196
+
197
+ # ------------------------------------------------------------
198
+ # 6. validate_and_repair: 完整校验 + 自动修复流程
199
+ # ------------------------------------------------------------
200
+ def validate_and_repair(origin_path, article_path, url, defaults=None):
201
+ """
202
+ 执行保存后校验,自动修复可修复的问题。
203
+ 返回 (是否通过, fixed列表, remaining列表)
204
+ """
205
+ defaults = defaults or {}
206
+ defaults.setdefault('fetch_date', FETCH_DATE)
207
+
208
+ fm_orig, fixed_orig, rem_orig = repair_frontmatter(origin_path, url, defaults)
209
+ fm_art, fixed_art, rem_art = repair_frontmatter(article_path, url, defaults)
210
+
211
+ all_fixed = fixed_orig + fixed_art
212
+ all_remaining = [f'[Origin] {r}' for r in rem_orig] + [f'[Article] {r}' for r in rem_art]
213
+
214
+ passed = len(all_remaining) == 0
215
+ return passed, all_fixed, all_remaining
216
+
217
+
218
+ # ------------------------------------------------------------
219
+ # 7. sanitize_filename: 文件名清理
220
+ # ------------------------------------------------------------
221
+ def sanitize_filename(name):
222
+ """移除文件名中的特殊字符"""
223
+ for ch in ['\\', '/', '*', '?', '<', '>', '|', ':', '"']:
224
+ name = name.replace(ch, '')
225
+ return name.lstrip('.')
@@ -0,0 +1,73 @@
1
+ # 文件格式模板
2
+
3
+ ## 原文文件格式
4
+
5
+ ```markdown
6
+ ---
7
+ publish_date: YYYY-MM-DD
8
+ fetch_date: YYYY-MM-DD
9
+ author: 作者名
10
+ source_url: https://xxx.com/article
11
+ origin_title: Original Title
12
+ category: Category(可选,来源列表页抓取的分类标签)
13
+ tags:
14
+ - tag1
15
+ - tag2
16
+ description: 一两句话摘要
17
+ ---
18
+
19
+ 文章正文...
20
+ ```
21
+
22
+ ## 译文文件格式
23
+
24
+ ```markdown
25
+ ---
26
+ publish_date: YYYY-MM-DD
27
+ fetch_date: YYYY-MM-DD
28
+ author: 作者名
29
+ source_url: https://xxx.com/article
30
+ origin_title: Original Title
31
+ category: Category(可选,来源列表页抓取的分类标签)
32
+ tags:
33
+ - tag1
34
+ - tag2
35
+ description: 一两句话摘要,概括文章核心内容,供快速阅读。
36
+ ---
37
+
38
+ [[Origin/文章原标题.md]]
39
+
40
+ ---
41
+
42
+ 翻译后的正文...
43
+ ```
44
+
45
+ ## frontmatter 字段说明
46
+
47
+ | 字段 | 原文文件 | 译文文件 | 来源 |
48
+ |------|----------|----------|------|
49
+ | `publish_date` | ✅ 必须 | ✅ 必须 | 页面显示日期,ISO 8601 格式取前 10 位(YYYY-MM-DD) |
50
+ | `fetch_date` | ✅ 必须 | ✅ 必须 | 抓取时间,格式 YYYY-MM-DD(北京时间) |
51
+ | `author` | ✅ 必须 | ✅ 必须 | 页面显示作者,若无则留空 |
52
+ | `source_url` | ✅ 必须 | ✅ 必须 | 当前抓取的 URL |
53
+ | `origin_title` | — | ✅ 必须 | 原文标题,用于 obsidian 双向链接 |
54
+ | `category` | 可选 | 可选 | 来源列表页抓取的分类标签,由 cron 任务注入,article-fetcher skill 只做透传 |
55
+ | `tags` | — | ✅ 必须 | YAML 列表格式,非逗号分隔 |
56
+ | `description` | — | ✅ 必须 | 一两句话摘要,供快速阅读,基于译文内容提取 |
57
+
58
+ ## 文件命名规则
59
+
60
+ ```python
61
+ import re
62
+ origin_filename = re.sub(r'[\\/:*?<>|".]', '', title) + '.md'
63
+ ```
64
+
65
+ 移除字符:`\ / : * ? < > | " .`(与 SKILL.md 保持一致)。
66
+
67
+ ## 保存路径
68
+
69
+ | 类型 | 路径 |
70
+ |------|------|
71
+ | 原文 | `Origin/<origin_title>.md` |
72
+ | 译文 | `<title>.md`(无 Origin 子文件夹) |
73
+ | 图片 | `Image/<url_hash>_img_N.ext` |
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 一次性脚本:将 Reading 目录下(不含子文件夹)的 Markdown 文档
4
+ frontmatter 信息解析后存入 url-index.db。
5
+ """
6
+ import sqlite3, os, re, glob
7
+
8
+ BASE_DIR = "{{VAULT_PATH}}"
9
+ DB_PATH = os.path.expanduser("{{SKILL_DIR}}/scripts/url-index.db")
10
+
11
+ def parse_frontmatter(content):
12
+ m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
13
+ if not m:
14
+ return {}
15
+ fm = {}
16
+ for line in m.group(1).split('\n'):
17
+ if ':' in line:
18
+ key, val = line.split(':', 1)
19
+ fm[key.strip()] = val.strip()
20
+ return fm
21
+
22
+ def extract_title(content, filepath):
23
+ # 先从 frontmatter 读 title
24
+ fm = parse_frontmatter(content)
25
+ if fm.get('title'):
26
+ return fm['title']
27
+ # 再从文件名
28
+ name = os.path.splitext(os.path.basename(filepath))[0]
29
+ return name
30
+
31
+ conn = sqlite3.connect(DB_PATH)
32
+ files = glob.glob(os.path.join(BASE_DIR, "*.md"))
33
+
34
+ inserted = 0
35
+ skipped = 0
36
+ errors = []
37
+
38
+ for fp in files:
39
+ name = os.path.basename(fp)
40
+ try:
41
+ with open(fp, encoding='utf-8') as f:
42
+ content = f.read()
43
+ except Exception as e:
44
+ errors.append(f"{name}: 读取失败 {e}")
45
+ continue
46
+
47
+ fm = parse_frontmatter(content)
48
+
49
+ source_url = fm.get('source_url', '')
50
+ publish_date = fm.get('publish_date', '')
51
+ author = fm.get('author', '')
52
+ origin_title = fm.get('origin_title', '')
53
+ title = extract_title(content, fp)
54
+
55
+ if not source_url:
56
+ # 无 source_url 视为无法去重,跳过
57
+ skipped += 1
58
+ print(f" 跳过(无 source_url): {name}")
59
+ continue
60
+
61
+ try:
62
+ conn.execute("""
63
+ INSERT OR IGNORE INTO url_index
64
+ (source_url, title, origin_title, author, publish_date)
65
+ VALUES (?, ?, ?, ?, ?)
66
+ """, (source_url, title, origin_title, author, publish_date))
67
+ inserted += 1
68
+ print(f" ✓ {name}")
69
+ except Exception as e:
70
+ errors.append(f"{name}: 写入失败 {e}")
71
+
72
+ conn.commit()
73
+ print(f"\n完成:插入 {inserted} 条,跳过 {skipped} 条,错误 {len(errors)} 条")
74
+ for e in errors:
75
+ print(f" ✗ {e}")
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+ """初始化 SQLite url_index 表"""
3
+ import sqlite3, os
4
+
5
+ DB_PATH = os.path.expanduser(
6
+ '{{SKILL_DIR}}/scripts/url-index.db'
7
+ )
8
+
9
+ # 确保目录存在
10
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
11
+
12
+ conn = sqlite3.connect(DB_PATH)
13
+ conn.execute("""
14
+ CREATE TABLE IF NOT EXISTS url_index (
15
+ source_url TEXT PRIMARY KEY,
16
+ title TEXT,
17
+ origin_title TEXT,
18
+ author TEXT,
19
+ publish_date TEXT,
20
+ fetch_date TEXT,
21
+ tags TEXT,
22
+ description TEXT,
23
+ issues TEXT,
24
+ origin_path TEXT,
25
+ article_path TEXT
26
+ )
27
+ """)
28
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_publish_date ON url_index(publish_date)")
29
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_author ON url_index(author)")
30
+ conn.commit()
31
+ conn.close()
32
+
33
+ print(f"✅ url_index 表初始化完成: {DB_PATH}")
@@ -0,0 +1,12 @@
1
+ [
2
+ {
3
+ "name": "VAULT_PATH",
4
+ "description": "Obsidian Reading 目录完整路径(例如 /Users/you/Vault/Product/Reading)",
5
+ "default": "{{HOME}}/Vault/Product/Reading"
6
+ },
7
+ {
8
+ "name": "SKILL_DIR",
9
+ "description": "article-fetcher skill 安装后的完整路径(用于定位辅助脚本和数据库文件)",
10
+ "default": "{{HOME}}/.claude/skills/article-fetcher"
11
+ }
12
+ ]
@@ -0,0 +1,220 @@
1
+ ---
2
+ name: mermaid-diagram
3
+ description: >
4
+ Mermaid 专业作图标准。只要用户提到"画图"、"mermaid"、"流程图"、"产业链"、
5
+ "板块地图"、"时序图"、"序列图"、"状态机"、"甘特图"、"象限图"、"思维导图"、
6
+ "接口调用"、"系统交互",或需要在 Markdown 文档中嵌入任何 Mermaid 图表,
7
+ 立即调用本 skill。支持 flowchart / sequenceDiagram / stateDiagram / timeline /
8
+ gantt / quadrantChart / mindmap 全类型。
9
+ user_invocable: true
10
+ version: "1.3.0"
11
+ ---
12
+
13
+ # Mermaid 专业作图标准
14
+
15
+ > **按需读取对应文件,不要一次性全部读入:**
16
+ > - flowchart / 产业链 / 板块地图 → `references/flowchart.md` + `references/color-templates.md`
17
+ > - sequenceDiagram / 时序图 / 接口调用 → `references/sequence.md`
18
+ > - stateDiagram / 状态机 / 周期图 → `references/statediagram.md`
19
+ > - gantt / timeline → `references/gantt-timeline.md`
20
+ > - quadrantChart / mindmap → `references/other-charts.md`
21
+ > - Python 批量检测 → `scripts/check_mermaid.py`(直接运行,无需读入)
22
+
23
+ ---
24
+
25
+ ## 1. 图类型选择矩阵(最关键一步)
26
+
27
+ ### 按语义选型
28
+
29
+ | 要表达的语义 | 正确图类型 | 错误选择 | 理由 |
30
+ |------------|----------|---------|------|
31
+ | 产业链 / 供应链(有方向的流动) | `flowchart TD` | `mindmap` | mindmap 是放射状从属关系,无法表达方向性流动 |
32
+ | 层级树形结构(无流向) | `mindmap` | `flowchart` | mindmap 的分支布局更自然 |
33
+ | 循环状态转换 | `stateDiagram-v2` | `flowchart` | stateDiagram 语义最精确 |
34
+ | 时间轴事件 | `timeline` | `gantt` | timeline 按时间节点列举,无时段重叠 |
35
+ | 并行时间段 / 甘特 | `gantt` | `timeline` | gantt 能展示多条目时间段重叠 |
36
+ | 二维象限定位 | `quadrantChart` | 自制坐标 | 原生支持,配置简单 |
37
+ | 系统 / 服务间有时序的调用链 | `sequenceDiagram` | `flowchart` | sequenceDiagram 原生支持激活框、条件块、并行 |
38
+ | 资金 / 数据流(有环路) | `flowchart TD` | `sequenceDiagram` | flowchart 支持循环箭头 |
39
+ | 板块地图(股票/行业) | `flowchart TD` + subgraph | `mindmap` / 表格 | subgraph 支持分组+颜色+箭头 |
40
+ | 时间轮动 / 波浪 | `timeline` | `graph LR` | timeline 语义直接对应时序 |
41
+
42
+ ### 图类型速查
43
+
44
+ ```
45
+ flowchart TD → 产业链、流程、板块地图、资本流
46
+ flowchart LR → 仅当内容天然横向(如横向对比)才用,否则用 TD
47
+ sequenceDiagram → 系统/服务间交互、API 调用时序、接口联调
48
+ mindmap → 纯树形知识结构,无需表达流向
49
+ timeline → 历史事件/里程碑时间轴,每个时间点有多个条目
50
+ gantt → 多轨道并行时间段(如技术迭代、项目计划)
51
+ stateDiagram-v2 → 状态机、周期轮动(如估值周期)
52
+ quadrantChart → 2×2 矩阵定位(如确定性 vs 弹性)
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 2. 布局原则
58
+
59
+ ### 宽度控制(窄页优先)
60
+
61
+ Markdown 渲染器页面宽度通常 700–900px,图表必须适配:
62
+
63
+ | 场景 | 规则 |
64
+ |------|------|
65
+ | 主流方向 | 优先 `flowchart TD`(纵向),避免 `flowchart LR` |
66
+ | subgraph 内节点 | **最多 2–3 个节点并排**;超过则用 `---` 配对 |
67
+ | stateDiagram | 使用 `direction TB`,不用 `direction LR` |
68
+ | 不可用横向LR的场景 | 产业链、板块地图、状态机、节奏图 |
69
+
70
+ ### 禁止的布局写法
71
+
72
+ ```
73
+ ❌ direction TB in flowchart subgraph # 仅 v10+ 支持,兼容性差
74
+ ❌ 4+ 孤立节点在同一 subgraph # 全部横向排开,过宽
75
+ ❌ flowchart LR 用于产业链 # 三列并排超出页面
76
+ ❌ nested subgraph(嵌套子图) # Mermaid 渲染不稳定
77
+ ```
78
+
79
+ > 矩阵布局 `---` 配对技法、subgraph 间箭头写法见 `references/flowchart.md 或对应图类型文件`。
80
+
81
+ ---
82
+
83
+ ## 3. Roland Berger 配色体系
84
+
85
+ ### 核心色板
86
+
87
+ ```
88
+ 主色 Deep Navy #00205B 用于最重要/最底层/上游 subgraph
89
+ 主色 RB Blue #003E96 用于中间层/平台层 subgraph
90
+ 中蓝 Medium Blue #1E5C9E 用于应用层/下游 subgraph
91
+ 节点 Navy Node #0A2E7A 上游 subgraph 内节点
92
+ 节点 Blue Node #0050B8 中游 subgraph 内节点
93
+ 节点 Mid Node #2A6EAE 下游 subgraph 内节点
94
+ ```
95
+
96
+ ### 语义辅助色
97
+
98
+ ```
99
+ 预警 / 高风险 fill:#7B1010 stroke:#B52020
100
+ 机会 / 买入 fill:#1A5E3A stroke:#2A7E50
101
+ 等待 / 持有 fill:#003E96 stroke:#1A6AC4
102
+ 投机 / 主题 fill:#2E0078 stroke:#5A20A0
103
+ 价值 / 配置 fill:#004060 stroke:#1A5E80
104
+ ```
105
+
106
+ ### 配色规则
107
+
108
+ - 深色背景(fill 暗于 `#4A4A4A`)**必须**加 `color:#fff`
109
+ - subgraph 背景比节点背景**深 10–15%**(提供层次感)
110
+ - 同一图内最多使用 **3 种主色** + 语义辅助色
111
+ - `quadrantChart` 的 quadrant 标签不加颜色(库自动渲染)
112
+ - `timeline` / `gantt` / `stateDiagram` 使用库默认颜色,不强制 RB 色
113
+
114
+ > 完整配色模板(含三层产业链示例)→ `references/color-templates.md`
115
+
116
+ ---
117
+
118
+ ## 4. 语法禁区(Syntax Forbidden Zone)
119
+
120
+ ### 禁用字符(Fatal Characters)
121
+
122
+ | 字符 | Unicode | 替换方案 |
123
+ |------|---------|---------|
124
+ | `·` 中点 | U+00B7 | 用 `/` 或空格 |
125
+ | `→` 右箭头 | U+2192 | 用 `->` 或 `to` |
126
+ | `—` 破折号 | U+2014 | 用 `-` |
127
+ | `"` `"` | U+201C/D | 用 `"` 标准双引号 |
128
+ | emoji | >U+1F000 | 完全删除 |
129
+
130
+ ### 禁用语法
131
+
132
+ ```
133
+ ❌ \n 换行符在节点标签内 → 改用 <br/>
134
+ ❌ / 在 quadrant 标签内 → 改用空格或删除
135
+ ❌ + 在 timeline key 内 → 改用 "以后" 或文字后缀
136
+ ❌ 中文作为 stateDiagram stateID → 用 ASCII ID + as 别名
137
+ ❌ YYYY-Q 日期格式在 gantt → 改用 YYYY-MM-DD
138
+ ❌ direction TB 在 flowchart subgraph → 改为全局 TD,删除子图内 direction
139
+ ❌ 多行 edge label(|"line1\nline2"|)→ 改为单行短语
140
+ ❌ sequenceDiagram 中文/空格参与者名不加处理 → 用 alias 或引号
141
+ ❌ sequenceDiagram activate 未配对 deactivate → 必须一一对应
142
+ ❌ sequenceDiagram 消息文本内换行 → 不支持,保持单行
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 5. 渲染前强制检查清单
148
+
149
+ 每张图创建完毕后,**逐项检查**以下 12 项,全部通过才能输出:
150
+
151
+ ```
152
+ □ 1. 图类型与语义匹配(见第 1 节选型矩阵)
153
+ □ 2. 主方向为 TD 或纵向(非 LR,除非有充分理由)
154
+ □ 3. subgraph 内每行 ≤ 3 个节点(超过已用 --- 配对)
155
+ □ 4. 无 emoji 字符(在任何标签/子图/边中)
156
+ □ 5. 无 · 中点符(全文替换为 /)
157
+ □ 6. 无 → 箭头字符(替换为 -> 或文字)
158
+ □ 7. 节点标签换行使用 <br/>,不使用 \n
159
+ □ 8. stateDiagram state ID 全为 ASCII
160
+ □ 9. gantt 日期格式为 YYYY-MM-DD
161
+ □ 10. quadrantChart 标签无 /
162
+ □ 11. timeline key 无 + % & 特殊字符
163
+ □ 12. 所有深色节点(fill 深于 #4A4A4A)已设 color:#fff
164
+ □ 13. sequenceDiagram 参与者名含中文/空格时已用 alias 或引号
165
+ □ 14. sequenceDiagram 每个 activate 有对应 deactivate
166
+ ```
167
+
168
+ > 批量自动检测 → 运行 `scripts/check_mermaid.py`
169
+
170
+ ---
171
+
172
+ ## 6. 快速参考卡
173
+
174
+ ### 图类型 → 场景
175
+
176
+ ```
177
+ 产业链/供应链 → flowchart TD + 3个subgraph + UP→MID→DOWN 箭头
178
+ 板块地图 → flowchart TD + subgraph per layer + --- 矩阵节点
179
+ 资本/数据流 → flowchart TD + 有向链 + 反馈环
180
+ 系统交互/时序 → sequenceDiagram + participant alias + activate/deactivate
181
+ 状态周期 → stateDiagram-v2 direction TB + ASCII state ID
182
+ 时间轴 → timeline(事件点)/ gantt(时间段)
183
+ 象限定位 → quadrantChart
184
+ 知识树 → mindmap
185
+ ```
186
+
187
+ ### RB 配色速查
188
+
189
+ ```
190
+ 上游子图 fill:#00205B stroke:#1E4A9A
191
+ 中游子图 fill:#003E96 stroke:#1A6AC4
192
+ 下游子图 fill:#1E5C9E stroke:#3A8ACC
193
+ 上游节点 fill:#0A2E7A stroke:#1E4A9A
194
+ 中游节点 fill:#0050B8 stroke:#1A6AC4
195
+ 下游节点 fill:#2A6EAE stroke:#3A8ACC
196
+ 预警红 fill:#7B1010 stroke:#B52020
197
+ 机会绿 fill:#1A5E3A stroke:#2A7E50
198
+ 投机紫 fill:#2E0078 stroke:#5A20A0
199
+ 价值蓝绿 fill:#004060 stroke:#1A5E80
200
+ 所有深色背景加 color:#fff
201
+ ```
202
+
203
+ ### 禁用字符替换
204
+
205
+ ```
206
+ · (U+00B7) → /
207
+ → (U+2192) → -> 或文字
208
+ — (U+2014) → -
209
+ "" (U+201C/D)→ "
210
+ emoji → 删除
211
+ \n 在节点 → <br/>
212
+ ```
213
+
214
+ ### 矩阵布局模板
215
+
216
+ ```
217
+ A["节点A"] --- B["节点B"] ← 第一行(2列)
218
+ C["节点C"] --- D["节点D"] ← 第二行(2列)
219
+ E["奇数节点"] ← 第三行(居中)
220
+ ```