create-dwy 0.16.1 → 0.17.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 (104) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/index.js +1 -1
  3. package/package.json +1 -1
  4. package/src/sync-all.js +303 -59
  5. package/src/sync-global-skills.js +167 -0
  6. package/src/sync.js +21 -11
  7. package/templates/ai-tools/CHANGELOG.md +134 -0
  8. package/templates/ai-tools/VERSION +1 -1
  9. package/templates/ai-tools/rules/Android/dwy-android-core.md +65 -1
  10. package/templates/ai-tools/rules/Python/dwy-python-core.md +47 -0
  11. package/templates/ai-tools/rules/Vue/dwy-vue-core.md +55 -0
  12. package/templates/ai-tools/rules/iOS/dwy-swift-core.md +46 -0
  13. package/templates/ai-tools/rules//345/274/200/345/217/221/346/265/201/347/250/213/dwy-oss-commercial-license.md +128 -0
  14. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/SKILL.md +65 -0
  15. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/assets/appstore-submission.yaml +43 -0
  16. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/references/api-workflow.md +17 -0
  17. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/references/in-app-purchase-review.md +16 -0
  18. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/references/screenshot-workflow.md +16 -0
  19. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-ios-app-store-release/scripts/appstore_config.sh +140 -0
  20. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/SKILL.md +11 -8
  21. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/android.md +2 -1
  22. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/backend-deploy.md +2 -1
  23. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/changelog.md +24 -4
  24. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/frontend-deploy.md +2 -1
  25. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/generic-release.md +2 -1
  26. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/git-tag.md +82 -0
  27. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/harmony.md +2 -1
  28. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/ios.md +3 -2
  29. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-publish/references/monitor-notify.md +2 -2
  30. package/templates/ai-tools/skills//345/217/221/345/270/203/345/217/221/347/211/210/dwy-semver/SKILL.md +1 -1
  31. package/templates/ai-tools/skills//345/237/272/347/241/200/345/272/223/dwy-eapi/references/tasks-integration-guide.md +2 -2
  32. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/README.md +57 -0
  33. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/SKILL.md +147 -0
  34. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/references/api.md +43 -0
  35. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/references/config.example.yaml +33 -0
  36. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/scripts/global_config.py +89 -0
  37. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/scripts/setup_config.py +175 -0
  38. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/dwy-doubao-tts/scripts/synthesize.py +339 -0
  39. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/media-platform-packaging/SKILL.md +127 -0
  40. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/media-platform-packaging/assets/cover-style-master-v1.png +0 -0
  41. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/media-platform-packaging/references/platform-copy-examples.md +37 -0
  42. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/media-platform-packaging/references/platform-posting-times.md +193 -0
  43. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0005-ideation/SKILL.md +42 -0
  44. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0005-ideation/templates/topic-backlog.template.md +50 -0
  45. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0005-ideation/templates//351/200/211/351/242/230/346/261/240.template.md +16 -0
  46. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0010-product/SKILL.md +106 -0
  47. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0010-product/templates/channel-profile.template.md +52 -0
  48. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0010-product/templates//344/272/247/345/223/201/345/215/241.template.md +132 -0
  49. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0030-facts/SKILL.md +60 -0
  50. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0030-facts/scripts/build_facts_html.py +430 -0
  51. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0030-facts/scripts/generate_html.py +1287 -0
  52. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0030-facts/scripts/generate_report.py +189 -0
  53. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0030-facts/templates//344/272/213/345/256/236/350/265/204/346/226/231/345/272/223.template.md +55 -0
  54. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0040-script/SKILL.md +36 -0
  55. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0040-script/templates/README.md +10 -0
  56. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0040-script/templates//344/272/213/345/256/236.template.md +82 -0
  57. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0040-script/templates//345/215/225/351/233/206/346/226/207/346/241/243.template.md +175 -0
  58. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0040-script/templates//350/204/232/346/234/254.template.md +69 -0
  59. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0040-script/templates//351/252/250/346/236/266-v1.1.md +174 -0
  60. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0050-design/SKILL.md +55 -0
  61. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0060-assets/SKILL.md +29 -0
  62. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0070-package/SKILL.md +42 -0
  63. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0070-package/scripts/validate_shots.py +207 -0
  64. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0070-package/shots.schema.json +186 -0
  65. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0070-package//344/272/272/345/256/241/345/214/205.template.md +1 -0
  66. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0070-package//344/272/272/345/256/241/350/257/264/346/230/216.md +15 -0
  67. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0070-package//346/210/220/347/211/207/345/214/205.template.md +49 -0
  68. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-0080-factory/SKILL.md +58 -0
  69. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-comic-kit/SKILL.md +424 -0
  70. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-comic-kit/evals.md +41 -0
  71. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-flow/CONTRACT.md +123 -0
  72. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-flow/SKILL.md +81 -0
  73. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-flow//346/211/247/350/241/214/346/270/205/345/215/225.template.md +60 -0
  74. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/SKILL.md +78 -0
  75. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/agents/openai.yaml +4 -0
  76. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/assets/knowledge-town-paper-film-v1.json +80 -0
  77. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/assets/remotion/AudioTracks.tsx +20 -0
  78. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/assets/remotion/CaptionLayer.tsx +18 -0
  79. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/assets/remotion/CueTimeline.ts +24 -0
  80. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/assets/remotion/KnowledgeTownComposition.tsx +21 -0
  81. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/references/cinematic-direction.md +73 -0
  82. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/references/sticker-asset-production.md +30 -0
  83. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/references/style-guide.md +43 -0
  84. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/scripts/build_cue_timeline.py +75 -0
  85. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/scripts/validate_cue_timeline.py +68 -0
  86. package/templates/ai-tools/skills//350/207/252/345/252/222/344/275/223/uc-media-knowledge-town/scripts/validate_style_preset.py +22 -0
  87. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/SKILL.md +0 -511
  88. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/references/checks-data.md +0 -126
  89. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/references/checks-network.md +0 -88
  90. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/references/checks-runtime.md +0 -195
  91. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/references/security-doc-search.md +0 -179
  92. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_base.sh +0 -39
  93. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_capacity.sh +0 -278
  94. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_db.sh +0 -147
  95. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_docker.sh +0 -230
  96. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_env.sh +0 -64
  97. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_https.sh +0 -60
  98. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_logs.sh +0 -209
  99. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_nginx.sh +0 -100
  100. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_resilience.sh +0 -157
  101. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_secrets.sh +0 -265
  102. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_services.sh +0 -94
  103. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/check_ssh.sh +0 -56
  104. package/templates/ai-tools/skills/unitcircle/uc-deploy-audit/scripts/run_all.sh +0 -105
@@ -0,0 +1,1287 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a Markdown report + interactive HTML viewer.
3
+
4
+ Outputs:
5
+ - README.md (clean Markdown, single source of truth)
6
+ - index.html (self-contained viewer: CSS + JS + embedded MD content)
7
+ - sources.json (structured source data)
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import sys
13
+ import argparse
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from html import escape
17
+
18
+
19
+ def slugify(text):
20
+ """Generate a URL-safe slug from heading text."""
21
+ text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
22
+ text = re.sub(r'\*([^*]+)\*', r'\1', text)
23
+ text = re.sub(r'`([^`]+)`', r'\1', text)
24
+ text = re.sub(r'[^\w\u4e00-\u9fff]+', '-', text.strip()).lower().strip('-')
25
+ text = re.sub(r'-+', '-', text)[:60]
26
+ return text
27
+
28
+
29
+ def normalize_source(src):
30
+ """Normalize source entries to {title, url, type, ...}.
31
+
32
+ Supports alternate shapes like {project, urls:[...]} used by some angle
33
+ research dumps. Missing fields must not break the HTML viewer.
34
+ """
35
+ if not isinstance(src, dict):
36
+ return {'title': str(src), 'url': '#', 'type': 'community'}
37
+
38
+ urls = src.get('urls') or []
39
+ if not isinstance(urls, list):
40
+ urls = [urls] if urls else []
41
+
42
+ url = src.get('url') or (urls[0] if urls else '#') or '#'
43
+ title = src.get('title') or src.get('project') or url or '未知'
44
+ out = {
45
+ 'title': title,
46
+ 'url': url,
47
+ 'type': src.get('type') or ('primary' if src.get('project') else 'community'),
48
+ }
49
+ if src.get('note'):
50
+ out['note'] = src['note']
51
+ elif len(urls) > 1:
52
+ out['note'] = '相关链接:' + ' · '.join(str(u) for u in urls[1:])
53
+ return out
54
+
55
+
56
+ def format_project_details(projects):
57
+ """Turn a list of project license research objects into readable Markdown."""
58
+ lines = []
59
+ for p in projects:
60
+ if not isinstance(p, dict):
61
+ lines.append(f'- {p}')
62
+ continue
63
+ name = p.get('project', '未知项目')
64
+ lines += [f'### {name}', '']
65
+ if p.get('repo_url'):
66
+ lines.append(f'- **仓库**:[{p["repo_url"]}]({p["repo_url"]})')
67
+ if p.get('license_name'):
68
+ lines.append(f'- **协议**:{p["license_name"]}')
69
+ meta = p.get('github_license_metadata') or {}
70
+ if isinstance(meta, dict) and (meta.get('spdx_id') or meta.get('name')):
71
+ spdx = meta.get('spdx_id', '')
72
+ mname = meta.get('name', '')
73
+ note = meta.get('note', '')
74
+ meta_line = f'- **GitHub 元数据**:{mname}'
75
+ if spdx:
76
+ meta_line += f' (`{spdx}`)'
77
+ if note:
78
+ meta_line += f' — {note}'
79
+ lines.append(meta_line)
80
+ if 'is_osi_open_source' in p:
81
+ lines.append(f'- **是否 OSI 开源**:{"是" if p["is_osi_open_source"] else "否"}')
82
+ if p.get('license_file'):
83
+ lines.append(f'- **LICENSE 文件**:[{p["license_file"]}]({p["license_file"]})')
84
+ faq = p.get('official_faq_is_remotion_open_source')
85
+ if isinstance(faq, dict):
86
+ q = faq.get('question', '')
87
+ a = faq.get('answer_verbatim_summary', '')
88
+ lines.append(f'- **官方 FAQ**:{q} → {a}')
89
+ if faq.get('source'):
90
+ lines.append(f' - 来源:[{faq["source"]}]({faq["source"]})')
91
+ if p.get('verification_method'):
92
+ lines.append(f'- **核实方式**:{p["verification_method"]}')
93
+ points = p.get('license_points') or []
94
+ if points:
95
+ lines += ['', '**要点**:']
96
+ for pt in points:
97
+ lines.append(f'- {pt}')
98
+ quotes = p.get('quotes') or []
99
+ if quotes:
100
+ lines += ['', '**原文摘录**:']
101
+ for q in quotes:
102
+ if not isinstance(q, dict):
103
+ lines.append(f'> {q}')
104
+ lines.append('')
105
+ continue
106
+ text = (q.get('text') or '').replace('\n', ' ').strip()
107
+ qurl = q.get('url') or ''
108
+ lines.append(f'> {text}')
109
+ if qurl:
110
+ lines.append('>')
111
+ lines.append(f'> — [{qurl}]({qurl})')
112
+ lines.append('')
113
+ lines.append('')
114
+ return '\n'.join(lines).rstrip() + '\n'
115
+
116
+
117
+ def details_to_markdown(details):
118
+ """Coerce angle details (str / list / dict / JSON string) into Markdown."""
119
+ if details is None:
120
+ return '(内容待补充)'
121
+
122
+ if isinstance(details, str):
123
+ s = details.strip()
124
+ if s.startswith('[') or s.startswith('{'):
125
+ try:
126
+ return details_to_markdown(json.loads(s))
127
+ except json.JSONDecodeError:
128
+ return details
129
+ return details
130
+
131
+ if isinstance(details, list):
132
+ if details and isinstance(details[0], dict) and 'project' in details[0]:
133
+ return format_project_details(details)
134
+ parts = []
135
+ for item in details:
136
+ if isinstance(item, str):
137
+ parts.append(item)
138
+ else:
139
+ parts.append(json.dumps(item, ensure_ascii=False, indent=2))
140
+ return '\n\n'.join(parts)
141
+
142
+ if isinstance(details, dict):
143
+ if 'project' in details:
144
+ return format_project_details([details])
145
+ return '```json\n' + json.dumps(details, ensure_ascii=False, indent=2) + '\n```'
146
+
147
+ return str(details)
148
+
149
+
150
+ def generate_markdown(topic, angles_data):
151
+ """Generate clean Markdown report (single source of truth)."""
152
+ today = datetime.now().strftime('%Y-%m-%d')
153
+ angles_list = '、'.join(a['angle'] for a in angles_data)
154
+
155
+ lines = [
156
+ f'# {topic}',
157
+ '',
158
+ f'> 整理时间:{today}',
159
+ f'> 覆盖角度:{angles_list}',
160
+ '',
161
+ '---',
162
+ '',
163
+ '## 目录',
164
+ '1. [概述](#概述)',
165
+ ]
166
+ for i, a in enumerate(angles_data, 2):
167
+ anchor = slugify(a['angle'])
168
+ lines.append(f'{i}. [{a["angle"]}](#{anchor})')
169
+ lines.append(f'{len(angles_data) + 2}. [参考资料](#参考资料)')
170
+ lines += ['', '---', '', '## 概述', '']
171
+
172
+ for a in angles_data:
173
+ lines.append(f'**{a["angle"]}**:{a.get("summary", "")}')
174
+ lines.append('')
175
+
176
+ for i, a in enumerate(angles_data, 1):
177
+ lines += ['', '---', '', f'## {a["angle"]}', '']
178
+ details = details_to_markdown(a.get('details', a.get('summary', '(内容待补充)')))
179
+ # Remove leading H1 if present (it's the angle title)
180
+ details = re.sub(r'^#\s+.+\s*', '', details, count=1, flags=re.MULTILINE)
181
+ lines.append(details)
182
+ lines.append('')
183
+ if a.get('key_points'):
184
+ lines.append('### 关键要点')
185
+ lines.append('')
186
+ for kp in a['key_points']:
187
+ lines.append(f'- {kp}')
188
+ lines.append('')
189
+
190
+ # Sources (normalized so HTML citations never see missing title/url)
191
+ all_sources = []
192
+ for a in angles_data:
193
+ for src in a.get('sources', []):
194
+ all_sources.append(normalize_source(src))
195
+
196
+ lines += ['', '---', '', '## 参考资料', '', '### 来源列表', '']
197
+ for i, src in enumerate(all_sources, 1):
198
+ t = src.get('type', 'community')
199
+ title = src.get('title', src.get('url', '未知'))
200
+ url = src.get('url', '#')
201
+ lines.append(f'[{i}] [{title}]({url}) `[{t}]`')
202
+ lines.append('')
203
+ lines.append('---')
204
+ lines.append('')
205
+ lines.append('*本笔记由 AI 调研生成,所有事实均标注来源。建议独立验证关键信息。*')
206
+ lines.append('')
207
+
208
+ return '\n'.join(lines), all_sources
209
+
210
+
211
+ HTML_TEMPLATE = """\
212
+ <!DOCTYPE html>
213
+ <html lang="zh-CN">
214
+ <head>
215
+ <meta charset="UTF-8">
216
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
217
+ <title>{title}</title>
218
+ <style>
219
+ :root {{
220
+ --bg: #fafafa;
221
+ --card-bg: #ffffff;
222
+ --text: #1a1a1a;
223
+ --text-secondary: #666;
224
+ --border: #e5e5e5;
225
+ --accent: #2563eb;
226
+ --accent-light: #dbeafe;
227
+ --cite-bg: #f0f9ff;
228
+ --cite-border: #93c5fd;
229
+ --popup-bg: #1e293b;
230
+ --popup-text: #f8fafc;
231
+ --popup-link: #93c5fd;
232
+ --code-bg: #f1f5f9;
233
+ --table-stripe: #f8fafc;
234
+ --sidebar-width: 240px;
235
+ }}
236
+
237
+ @media (prefers-color-scheme: dark) {{
238
+ :root {{
239
+ --bg: #0f172a;
240
+ --card-bg: #1e293b;
241
+ --text: #e2e8f0;
242
+ --text-secondary: #94a3b8;
243
+ --border: #334155;
244
+ --accent: #60a5fa;
245
+ --accent-light: #1e3a5f;
246
+ --cite-bg: #1e3a5f;
247
+ --cite-border: #3b82f6;
248
+ --popup-bg: #0f172a;
249
+ --popup-text: #f1f5f9;
250
+ --popup-link: #60a5fa;
251
+ --code-bg: #1e293b;
252
+ --table-stripe: #1a2332;
253
+ }}
254
+ }}
255
+
256
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
257
+
258
+ body {{
259
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
260
+ background: var(--bg);
261
+ color: var(--text);
262
+ line-height: 1.8;
263
+ }}
264
+
265
+ /* Layout: sidebar + main */
266
+ .layout {{
267
+ display: flex;
268
+ max-width: 1120px;
269
+ margin: 0 auto;
270
+ }}
271
+
272
+ .sidebar {{
273
+ width: var(--sidebar-width);
274
+ flex-shrink: 0;
275
+ position: sticky;
276
+ top: 0;
277
+ height: 100vh;
278
+ overflow-y: auto;
279
+ padding: 2rem 1rem 2rem 1.5rem;
280
+ border-right: 1px solid var(--border);
281
+ }}
282
+
283
+ .main-content {{
284
+ flex: 1;
285
+ max-width: 800px;
286
+ padding: 2rem 3rem;
287
+ }}
288
+
289
+ @media (max-width: 900px) {{
290
+ .sidebar {{ display: none; }}
291
+ .main-content {{ padding: 1.5rem; }}
292
+ }}
293
+
294
+ /* Sidebar TOC */
295
+ .sidebar-title {{
296
+ font-size: 0.875rem;
297
+ font-weight: 600;
298
+ text-transform: uppercase;
299
+ letter-spacing: 0.05em;
300
+ color: var(--text-secondary);
301
+ margin-bottom: 1rem;
302
+ }}
303
+
304
+ .toc-nav {{
305
+ list-style: none;
306
+ padding: 0;
307
+ }}
308
+
309
+ .toc-nav li {{
310
+ margin: 0;
311
+ }}
312
+
313
+ .toc-nav a {{
314
+ display: block;
315
+ padding: 0.4rem 0.5rem;
316
+ font-size: 0.9375rem;
317
+ color: var(--text-secondary);
318
+ text-decoration: none;
319
+ border-radius: 4px;
320
+ border-left: 2px solid transparent;
321
+ transition: all 0.15s ease;
322
+ line-height: 1.5;
323
+ }}
324
+
325
+ .toc-nav a:hover {{
326
+ color: var(--text);
327
+ background: var(--accent-light);
328
+ }}
329
+
330
+ .toc-nav a.active {{
331
+ color: var(--accent);
332
+ border-left-color: var(--accent);
333
+ font-weight: 500;
334
+ }}
335
+
336
+ /* TOC indentation levels */
337
+ .toc-level-1 a {{
338
+ font-weight: 600;
339
+ font-size: 1rem;
340
+ padding-left: 0.5rem;
341
+ }}
342
+
343
+ .toc-level-2 a {{
344
+ padding-left: 1.25rem;
345
+ font-size: 0.9375rem;
346
+ }}
347
+
348
+ .toc-level-3 a {{
349
+ padding-left: 2rem;
350
+ font-size: 0.875rem;
351
+ opacity: 0.85;
352
+ }}
353
+
354
+ .toc-level-4 a {{
355
+ padding-left: 2.75rem;
356
+ font-size: 0.8125rem;
357
+ opacity: 0.7;
358
+ }}
359
+
360
+ .toc-nav {{
361
+ list-style: none;
362
+ padding: 0;
363
+ }}
364
+
365
+ .toc-nav li {{
366
+ margin: 0;
367
+ }}
368
+
369
+ .toc-nav a {{
370
+ display: block;
371
+ padding: 0.3rem 0.5rem;
372
+ font-size: 0.8125rem;
373
+ color: var(--text-secondary);
374
+ text-decoration: none;
375
+ border-radius: 4px;
376
+ border-left: 2px solid transparent;
377
+ transition: all 0.15s ease;
378
+ line-height: 1.4;
379
+ }}
380
+
381
+ .toc-nav a:hover {{
382
+ color: var(--text);
383
+ background: var(--accent-light);
384
+ }}
385
+
386
+ .toc-nav a.active {{
387
+ color: var(--accent);
388
+ border-left-color: var(--accent);
389
+ font-weight: 500;
390
+ }}
391
+
392
+ .toc-level-1 a {{
393
+ font-weight: 600;
394
+ font-size: 0.875rem;
395
+ padding-left: 0.5rem;
396
+ }}
397
+
398
+ .toc-level-2 a {{
399
+ padding-left: 1.25rem;
400
+ font-size: 0.8125rem;
401
+ }}
402
+
403
+ .toc-level-3 a {{
404
+ padding-left: 2rem;
405
+ font-size: 0.75rem;
406
+ opacity: 0.85;
407
+ }}
408
+
409
+ .toc-level-4 a {{
410
+ padding-left: 2.75rem;
411
+ font-size: 0.6875rem;
412
+ opacity: 0.7;
413
+ }}
414
+
415
+ /* Typography */
416
+ h1 {{
417
+ font-size: 2rem;
418
+ font-weight: 700;
419
+ margin-bottom: 0.5rem;
420
+ line-height: 1.3;
421
+ }}
422
+
423
+ .meta {{
424
+ color: var(--text-secondary);
425
+ font-size: 0.875rem;
426
+ margin-bottom: 2rem;
427
+ padding-bottom: 1.5rem;
428
+ border-bottom: 1px solid var(--border);
429
+ }}
430
+
431
+ h2 {{
432
+ font-size: 1.5rem;
433
+ font-weight: 600;
434
+ margin: 2.5rem 0 1rem;
435
+ padding-bottom: 0.5rem;
436
+ border-bottom: 2px solid var(--accent-light);
437
+ }}
438
+
439
+ h3 {{
440
+ font-size: 1.125rem;
441
+ font-weight: 600;
442
+ margin: 1.5rem 0 0.75rem;
443
+ }}
444
+
445
+ h4 {{
446
+ font-size: 1rem;
447
+ font-weight: 600;
448
+ margin: 1.25rem 0 0.5rem;
449
+ color: var(--text-secondary);
450
+ }}
451
+
452
+ h5 {{
453
+ font-size: 0.9375rem;
454
+ font-weight: 600;
455
+ margin: 1rem 0 0.5rem;
456
+ color: var(--text-secondary);
457
+ opacity: 0.9;
458
+ }}
459
+
460
+ p {{
461
+ margin-bottom: 1rem;
462
+ }}
463
+
464
+ a {{
465
+ color: var(--accent);
466
+ text-decoration: none;
467
+ }}
468
+ a:hover {{ text-decoration: underline; }}
469
+
470
+ blockquote {{
471
+ border-left: 3px solid var(--accent);
472
+ padding-left: 1rem;
473
+ color: var(--text-secondary);
474
+ margin: 1rem 0;
475
+ }}
476
+
477
+ table {{
478
+ width: 100%;
479
+ border-collapse: collapse;
480
+ margin: 1rem 0;
481
+ font-size: 0.9375rem;
482
+ }}
483
+
484
+ th, td {{
485
+ padding: 0.75rem 1rem;
486
+ border: 1px solid var(--border);
487
+ text-align: left;
488
+ }}
489
+
490
+ th {{
491
+ background: var(--accent-light);
492
+ font-weight: 600;
493
+ }}
494
+
495
+ tr:nth-child(even) td {{
496
+ background: var(--table-stripe);
497
+ }}
498
+
499
+ code {{
500
+ background: var(--code-bg);
501
+ padding: 0.15rem 0.4rem;
502
+ border-radius: 4px;
503
+ font-size: 0.875em;
504
+ font-family: "SF Mono", "Fira Code", "Consolas", monospace;
505
+ }}
506
+
507
+ pre {{
508
+ background: var(--code-bg);
509
+ padding: 1rem 1.25rem;
510
+ border-radius: 8px;
511
+ overflow-x: auto;
512
+ margin: 1rem 0;
513
+ border: 1px solid var(--border);
514
+ }}
515
+
516
+ pre code {{
517
+ background: none;
518
+ padding: 0;
519
+ }}
520
+
521
+ ul, ol {{
522
+ padding-left: 1.5rem;
523
+ margin-bottom: 1rem;
524
+ }}
525
+
526
+ li {{
527
+ margin: 0.35rem 0;
528
+ }}
529
+
530
+ /* Citation link */
531
+ a.cit {{
532
+ display: inline;
533
+ color: var(--accent);
534
+ background: var(--cite-bg);
535
+ border-bottom: 1px dashed var(--cite-border);
536
+ cursor: pointer;
537
+ padding: 0 1px;
538
+ text-decoration: none;
539
+ border-radius: 2px;
540
+ }}
541
+
542
+ a.cit:hover {{
543
+ background: var(--cite-border);
544
+ color: var(--card-bg);
545
+ text-decoration: none;
546
+ }}
547
+
548
+ /* Image zoom */
549
+ .content img {{
550
+ cursor: zoom-in;
551
+ transition: opacity 0.2s ease;
552
+ }}
553
+ .content img:hover {{
554
+ opacity: 0.9;
555
+ }}
556
+
557
+ /* Image lightbox */
558
+ #img-lightbox {{
559
+ display: none;
560
+ position: fixed;
561
+ z-index: 2000;
562
+ top: 0;
563
+ left: 0;
564
+ width: 100%;
565
+ height: 100%;
566
+ background: rgba(0, 0, 0, 0.85);
567
+ justify-content: center;
568
+ align-items: center;
569
+ cursor: zoom-out;
570
+ }}
571
+ #img-lightbox.visible {{
572
+ display: flex;
573
+ }}
574
+ #img-lightbox img {{
575
+ max-width: 90vw;
576
+ max-height: 90vh;
577
+ object-fit: contain;
578
+ border-radius: 4px;
579
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
580
+ }}
581
+
582
+ /* Source popup */
583
+ #source-popup {{
584
+ display: none;
585
+ position: fixed;
586
+ z-index: 1000;
587
+ background: var(--popup-bg);
588
+ color: var(--popup-text);
589
+ padding: 0.75rem 1rem;
590
+ border-radius: 8px;
591
+ font-size: 0.8125rem;
592
+ line-height: 1.5;
593
+ max-width: 320px;
594
+ box-shadow: 0 10px 25px rgba(0,0,0,0.3);
595
+ pointer-events: none;
596
+ opacity: 0;
597
+ transition: opacity 0.15s ease;
598
+ }}
599
+
600
+ #source-popup.visible {{
601
+ display: block;
602
+ opacity: 1;
603
+ }}
604
+
605
+ #source-popup.pinned {{
606
+ pointer-events: auto;
607
+ }}
608
+
609
+ #source-popup a {{
610
+ color: var(--popup-link);
611
+ text-decoration: underline;
612
+ }}
613
+
614
+ #source-popup .popup-title {{
615
+ font-weight: 600;
616
+ margin-bottom: 0.25rem;
617
+ }}
618
+
619
+ #source-popup .popup-url {{
620
+ opacity: 0.7;
621
+ font-size: 0.75rem;
622
+ word-break: break-all;
623
+ }}
624
+
625
+ #source-popup .popup-hint {{
626
+ margin-top: 0.35rem;
627
+ opacity: 0.5;
628
+ font-size: 0.6875rem;
629
+ }}
630
+
631
+ /* Sources section */
632
+ .sources-list {{
633
+ padding-left: 1.25rem;
634
+ }}
635
+
636
+ .sources-list li {{
637
+ margin: 0.5rem 0;
638
+ font-size: 0.9375rem;
639
+ }}
640
+
641
+ .source-type {{
642
+ display: inline-block;
643
+ font-size: 0.6875rem;
644
+ padding: 0.1rem 0.5rem;
645
+ border-radius: 10px;
646
+ margin-left: 0.5rem;
647
+ vertical-align: middle;
648
+ opacity: 0.7;
649
+ }}
650
+
651
+ .source-type.official {{ background: #dcfce7; color: #166534; }}
652
+ .source-type.wikipedia {{ background: #dbeafe; color: #1e40af; }}
653
+ .source-type.academic {{ background: #fef3c7; color: #92400e; }}
654
+ .source-type.community {{ background: #f3e8ff; color: #6b21a8; }}
655
+ .source-type.media {{ background: #ffe4e6; color: #9f1239; }}
656
+
657
+ .footer {{
658
+ margin-top: 3rem;
659
+ padding-top: 1.5rem;
660
+ border-top: 1px solid var(--border);
661
+ color: var(--text-secondary);
662
+ font-size: 0.8125rem;
663
+ text-align: center;
664
+ }}
665
+
666
+ ::selection {{
667
+ background: var(--accent-light);
668
+ }}
669
+ </style>
670
+ </head>
671
+ <body>
672
+
673
+ <div class="layout">
674
+
675
+ <!-- Sidebar TOC -->
676
+ <aside class="sidebar">
677
+ <div class="sidebar-title">目录</div>
678
+ <ol class="toc-nav">
679
+ {toc_html}
680
+ </ol>
681
+ </aside>
682
+
683
+ <!-- Main content -->
684
+ <main class="main-content" id="content">
685
+ <!-- Content rendered from Markdown -->
686
+ </main>
687
+
688
+ </div>
689
+
690
+ <!-- Source popup -->
691
+ <div id="source-popup">
692
+ <div class="popup-title" id="popup-title"></div>
693
+ <div class="popup-url" id="popup-url"></div>
694
+ <div class="popup-hint">点击链接访问原文</div>
695
+ </div>
696
+
697
+ <!-- Image lightbox -->
698
+ <div id="img-lightbox">
699
+ <img src="" alt="Zoomed image" id="lightbox-img">
700
+ </div>
701
+
702
+ <script>
703
+ // === Embedded Markdown content ===
704
+ const MARKDOWN_CONTENT = {md_content_json};
705
+
706
+ // === Source data ===
707
+ const sources = {sources_json};
708
+
709
+ function sourceTitle(s) {{
710
+ if (!s) return '来源';
711
+ return String(s.title || s.project || s.url || (s.urls && s.urls[0]) || '来源');
712
+ }}
713
+ function sourceUrl(s) {{
714
+ if (!s) return '#';
715
+ return s.url || (s.urls && s.urls[0]) || '#';
716
+ }}
717
+
718
+ // === Markdown parser ===
719
+ function parseMarkdown(md) {{
720
+ const lines = md.split('\\n');
721
+ let html = '';
722
+ let inTable = false;
723
+ let inCode = false;
724
+ let inUl = false;
725
+ let inOl = false;
726
+
727
+ function escapeHtml(text) {{
728
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
729
+ }}
730
+
731
+ function slugify(text) {{
732
+ text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '$1');
733
+ text = text.replace(/\\*([^*]+)\\*/g, '$1');
734
+ text = text.replace(/`([^`]+)`/g, '$1');
735
+ return text.replace(/[^\\w\\u4e00-\\u9fff]+/g, '-').toLowerCase().replace(/^-|-$/g, '').replace(/-+/g, '-').substring(0, 60);
736
+ }}
737
+
738
+ function renderInline(text) {{
739
+ // Code
740
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
741
+ // Images: ![alt](url) → <img>
742
+ text = text.replace(/!\\[([^\\]]*)\\]\\(([^)]+)\\)/g, function(m, alt, url) {{
743
+ const isLocal = !url.startsWith('http');
744
+ const src = url;
745
+ return '<img src="' + src + '" alt="' + alt + '" style="max-width:100%;border-radius:8px;margin:1rem 0;border:1px solid var(--border)">';
746
+ }});
747
+ // Links
748
+ text = text.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
749
+ // Bold
750
+ text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
751
+ // Italic
752
+ text = text.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');
753
+ return text;
754
+ }}
755
+
756
+ function renderCitations(text) {{
757
+ return text.replace(/\\[(\\d+)\\]/g, function(m, idx) {{
758
+ const i = parseInt(idx) - 1;
759
+ if (i >= 0 && i < sources.length) {{
760
+ const s = sources[i];
761
+ const title = sourceTitle(s).substring(0, 40).replace(/"/g, '&quot;');
762
+ const url = sourceUrl(s);
763
+ return '<a class="cit" href="' + url + '" target="_blank" rel="noopener" data-source-idx="' + i + '" title="' + title + '">[' + idx + ']</a>';
764
+ }}
765
+ return m;
766
+ }});
767
+ }}
768
+
769
+ function closeLists() {{
770
+ if (inUl) {{ html += '</ul>'; inUl = false; }}
771
+ if (inOl) {{ html += '</ol>'; inOl = false; }}
772
+ if (inTable) {{ html += '</table>'; inTable = false; }}
773
+ }}
774
+
775
+ for (let li = 0; li < lines.length; li++) {{
776
+ const line = lines[li];
777
+ const stripped = line.trim();
778
+
779
+ // Code blocks
780
+ if (stripped.startsWith('```')) {{
781
+ closeLists();
782
+ if (inCode) {{
783
+ html += '</code></pre>';
784
+ inCode = false;
785
+ }} else {{
786
+ const lang = stripped.substring(3).trim();
787
+ html += '<pre><code' + (lang ? ' class="language-' + lang + '"' : '') + '>';
788
+ inCode = true;
789
+ }}
790
+ continue;
791
+ }}
792
+ if (inCode) {{
793
+ html += escapeHtml(line) + '\\n';
794
+ continue;
795
+ }}
796
+
797
+ // Empty line
798
+ if (!stripped) {{
799
+ closeLists();
800
+ continue;
801
+ }}
802
+
803
+ // Horizontal rule
804
+ if (stripped === '---') {{
805
+ closeLists();
806
+ html += '<hr style="border:none;border-top:1px solid var(--border);margin:2rem 0">';
807
+ continue;
808
+ }}
809
+
810
+ // Headings
811
+ const h5Match = stripped.match(/^#####\\s+(.+)$/);
812
+ const h4Match = stripped.match(/^####\\s+(.+)$/);
813
+ const h3Match = stripped.match(/^###\\s+(.+)$/);
814
+ const h2Match = stripped.match(/^##\\s+(.+)$/);
815
+ const h1Match = stripped.match(/^#\\s+(.+)$/);
816
+
817
+ if (h1Match) {{
818
+ closeLists();
819
+ const id = slugify(h1Match[1]);
820
+ html += '<h1 id="' + id + '">' + renderCitations(renderInline(h1Match[1])) + '</h1>';
821
+ continue;
822
+ }}
823
+ if (h2Match) {{
824
+ closeLists();
825
+ const id = slugify(h2Match[1]);
826
+ html += '<h2 id="' + id + '">' + renderCitations(renderInline(h2Match[1])) + '</h2>';
827
+ continue;
828
+ }}
829
+ if (h3Match) {{
830
+ closeLists();
831
+ const id = slugify(h3Match[1]);
832
+ html += '<h3 id="' + id + '">' + renderCitations(renderInline(h3Match[1])) + '</h3>';
833
+ continue;
834
+ }}
835
+ if (h4Match) {{
836
+ closeLists();
837
+ const id = slugify(h4Match[1]);
838
+ html += '<h4 id="' + id + '">' + renderCitations(renderInline(h4Match[1])) + '</h4>';
839
+ continue;
840
+ }}
841
+ if (h5Match) {{
842
+ closeLists();
843
+ const id = slugify(h5Match[1]);
844
+ html += '<h5 id="' + id + '">' + renderCitations(renderInline(h5Match[1])) + '</h5>';
845
+ continue;
846
+ }}
847
+
848
+ // Table
849
+ if (stripped.includes('|') && stripped.startsWith('|')) {{
850
+ const cells = stripped.replace(/^\\||\\|$/g, '').split('|').map(c => c.trim());
851
+ if (cells.every(c => /^[-:]+$/.test(c))) continue; // skip separator
852
+ if (!inTable) {{
853
+ html += '<table><thead><tr>';
854
+ cells.forEach(c => {{ html += '<th>' + renderCitations(renderInline(c)) + '</th>'; }});
855
+ html += '</tr></thead><tbody>';
856
+ inTable = true;
857
+ }} else {{
858
+ html += '<tr>';
859
+ cells.forEach(c => {{ html += '<td>' + renderCitations(renderInline(c)) + '</td>'; }});
860
+ html += '</tr>';
861
+ }}
862
+ continue;
863
+ }} else {{
864
+ if (inTable) {{ html += '</table>'; inTable = false; }}
865
+ }}
866
+
867
+ // Unordered list
868
+ if (/^[-*]\\s+/.test(stripped)) {{
869
+ if (!inUl) {{
870
+ if (inOl) {{ html += '</ol>'; inOl = false; }}
871
+ html += '<ul>';
872
+ inUl = true;
873
+ }}
874
+ html += '<li>' + renderCitations(renderInline(stripped.replace(/^[-*]\\s+/, ''))) + '</li>';
875
+ continue;
876
+ }}
877
+
878
+ // Ordered list
879
+ if (/^\\d+\\.\\s+/.test(stripped)) {{
880
+ if (!inOl) {{
881
+ if (inUl) {{ html += '</ul>'; inUl = false; }}
882
+ html += '<ol>';
883
+ inOl = true;
884
+ }}
885
+ html += '<li>' + renderCitations(renderInline(stripped.replace(/^\\d+\\.\\s+/, ''))) + '</li>';
886
+ continue;
887
+ }}
888
+
889
+ // Blockquote
890
+ if (stripped.startsWith('> ')) {{
891
+ closeLists();
892
+ html += '<blockquote>' + renderCitations(renderInline(stripped.substring(2))) + '</blockquote>';
893
+ continue;
894
+ }}
895
+
896
+ // Paragraph
897
+ closeLists();
898
+ html += '<p>' + renderCitations(renderInline(stripped)) + '</p>';
899
+ }}
900
+ closeLists();
901
+ return html;
902
+ }}
903
+
904
+ // === Render content ===
905
+ document.getElementById('content').innerHTML = parseMarkdown(MARKDOWN_CONTENT);
906
+
907
+ // === Image lightbox ===
908
+ (function() {{
909
+ const lightbox = document.getElementById('img-lightbox');
910
+ const lightboxImg = document.getElementById('lightbox-img');
911
+ document.addEventListener('click', function(evt) {{
912
+ const img = evt.target.closest('.content img');
913
+ if (img) {{
914
+ lightboxImg.src = img.src;
915
+ lightboxImg.alt = img.alt;
916
+ lightbox.classList.add('visible');
917
+ }} else if (evt.target.closest('#img-lightbox')) {{
918
+ lightbox.classList.remove('visible');
919
+ lightboxImg.src = '';
920
+ }}
921
+ }});
922
+ document.addEventListener('keydown', function(evt) {{
923
+ if (evt.key === 'Escape') lightbox.classList.remove('visible');
924
+ }});
925
+ }})();
926
+
927
+ // === Popup interaction ===
928
+ (function() {{
929
+ const popup = document.getElementById('source-popup');
930
+ const popupTitle = document.getElementById('popup-title');
931
+ const popupUrl = document.getElementById('popup-url');
932
+ let pinnedSource = null;
933
+
934
+ function showPopup(el) {{
935
+ const idx = parseInt(el.dataset.sourceIdx);
936
+ const s = sources[idx];
937
+ if (!s) return;
938
+ const title = sourceTitle(s);
939
+ const url = sourceUrl(s);
940
+ popupTitle.textContent = title;
941
+ popupUrl.innerHTML = '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>';
942
+ const rect = el.getBoundingClientRect();
943
+ let x = rect.left;
944
+ let y = rect.bottom + 8;
945
+ if (x + 320 > window.innerWidth) x = window.innerWidth - 330;
946
+ if (y + 100 > window.innerHeight) y = rect.top - 100;
947
+ popup.style.left = x + 'px';
948
+ popup.style.top = y + 'px';
949
+ popup.classList.add('visible');
950
+ pinnedSource = idx;
951
+ }}
952
+
953
+ function hidePopup() {{
954
+ popup.classList.remove('visible');
955
+ popup.classList.remove('pinned');
956
+ pinnedSource = null;
957
+ }}
958
+
959
+ // Click on citation: toggle pin
960
+ document.addEventListener('click', function(evt) {{
961
+ const cit = evt.target.closest('.cit');
962
+ if (cit) {{
963
+ evt.preventDefault();
964
+ evt.stopPropagation();
965
+ const idx = parseInt(cit.dataset.sourceIdx);
966
+ if (pinnedSource === idx) {{
967
+ hidePopup();
968
+ }} else {{
969
+ showPopup(cit);
970
+ popup.classList.add('pinned');
971
+ }}
972
+ return;
973
+ }}
974
+ // Click inside pinned popup → allow link navigation
975
+ if (evt.target.closest('#source-popup')) return;
976
+ // Click elsewhere → close
977
+ hidePopup();
978
+ }});
979
+
980
+ // Hover: preview (only when not pinned)
981
+ document.addEventListener('mouseover', function(evt) {{
982
+ const cit = evt.target.closest('.cit');
983
+ if (cit && !pinnedSource) showPopup(cit);
984
+ }});
985
+ document.addEventListener('mouseout', function(evt) {{
986
+ const cit = evt.target.closest('.cit');
987
+ if (cit && !pinnedSource) hidePopup();
988
+ }});
989
+
990
+ // Selection: detect citation
991
+ document.addEventListener('mouseup', function(evt) {{
992
+ const sel = window.getSelection();
993
+ if (sel.isCollapsed) return;
994
+ const range = sel.getRangeAt(0);
995
+ let node = range.startContainer;
996
+ if (node.nodeType === 3) node = node.parentNode;
997
+ const cit = node.closest('.cit');
998
+ if (cit && !pinnedSource) showPopup(cit);
999
+ }});
1000
+
1001
+ // Escape closes
1002
+ document.addEventListener('keydown', function(evt) {{
1003
+ if (evt.key === 'Escape') hidePopup();
1004
+ }});
1005
+ }})();
1006
+
1007
+ // === Scroll-spy ===
1008
+ (function() {{
1009
+ const tocLinks = document.querySelectorAll('.toc-nav a');
1010
+ const headingElements = new Map();
1011
+ tocLinks.forEach(function(link) {{
1012
+ const targetId = link.getAttribute('href').slice(1);
1013
+ const el = document.getElementById(targetId);
1014
+ if (el) headingElements.set(targetId, el);
1015
+ }});
1016
+
1017
+ function updateActiveHeading() {{
1018
+ let current = '';
1019
+ const scrollPos = window.scrollY + 100;
1020
+ headingElements.forEach(function(el, id) {{
1021
+ if (el.offsetTop <= scrollPos) current = id;
1022
+ }});
1023
+ tocLinks.forEach(function(link) {{
1024
+ link.classList.toggle('active', link.getAttribute('href') === '#' + current);
1025
+ }});
1026
+ }}
1027
+
1028
+ window.addEventListener('scroll', updateActiveHeading, {{ passive: true }});
1029
+ updateActiveHeading();
1030
+ }})();
1031
+ </script>
1032
+
1033
+ </body>
1034
+ </html>
1035
+ """
1036
+
1037
+
1038
+ def extract_headings_for_toc(md_text):
1039
+ """Extract headings from markdown for hierarchical TOC."""
1040
+ headings = []
1041
+ in_code = False
1042
+ for line in md_text.split('\n'):
1043
+ stripped = line.strip()
1044
+ if stripped.startswith('```'):
1045
+ in_code = not in_code
1046
+ continue
1047
+ if in_code:
1048
+ continue
1049
+ if stripped.startswith('##### '):
1050
+ headings.append((5, stripped[6:].strip()))
1051
+ elif stripped.startswith('#### '):
1052
+ headings.append((4, stripped[5:].strip()))
1053
+ elif stripped.startswith('### '):
1054
+ headings.append((3, stripped[4:].strip()))
1055
+ elif stripped.startswith('## '):
1056
+ headings.append((2, stripped[3:].strip()))
1057
+ elif stripped.startswith('# '):
1058
+ headings.append((1, stripped[2:].strip()))
1059
+ return headings
1060
+
1061
+
1062
+ def generate_html(topic, angles_data, md_content=None):
1063
+ """Generate HTML template from angle data.
1064
+ If md_content is provided, uses it; otherwise generates from angles_data.
1065
+ Returns (html_content, all_sources).
1066
+ """
1067
+ if md_content is None:
1068
+ md_content, all_sources = generate_markdown(topic, angles_data)
1069
+ else:
1070
+ all_sources = []
1071
+ for a in angles_data:
1072
+ for src in a.get('sources', []):
1073
+ all_sources.append(normalize_source(src))
1074
+
1075
+ # Extract headings for TOC
1076
+ headings = extract_headings_for_toc(md_content)
1077
+
1078
+ # Build hierarchical TOC HTML
1079
+ toc_html = '<li class="toc-level-1"><a href="#概述">概述</a></li>\n'
1080
+ for level, title in headings:
1081
+ hid = slugify(title)
1082
+ toc_level = min(level + 1, 4)
1083
+ toc_html += f'<li class="toc-level-{toc_level}"><a href="#{hid}">{escape(title)}</a></li>\n'
1084
+
1085
+ # Embed markdown as JSON string
1086
+ md_json = json.dumps(md_content, ensure_ascii=False)
1087
+ sources_json_str = json.dumps(all_sources, ensure_ascii=False)
1088
+
1089
+ html = HTML_TEMPLATE.format(
1090
+ title=escape(topic),
1091
+ toc_html=toc_html,
1092
+ md_content_json=md_json,
1093
+ sources_json=sources_json_str,
1094
+ )
1095
+
1096
+ return html, all_sources
1097
+
1098
+
1099
+ def main():
1100
+ parser = argparse.ArgumentParser(description='Generate Markdown + interactive HTML report')
1101
+ parser.add_argument('--topic', required=True, help='Research topic')
1102
+ parser.add_argument('--input', required=True, help='Path to JSON file with angle data')
1103
+ parser.add_argument('--output-dir', required=True, help='Output directory')
1104
+ args = parser.parse_args()
1105
+
1106
+ with open(args.input, 'r', encoding='utf-8') as f:
1107
+ angles_data = json.load(f)
1108
+
1109
+ if isinstance(angles_data, dict):
1110
+ angles_data = angles_data.get('angles', angles_data.get('results', []))
1111
+
1112
+ topic = args.topic
1113
+ out_dir = Path(args.output_dir)
1114
+ out_dir.mkdir(parents=True, exist_ok=True)
1115
+
1116
+ # Generate initial Markdown
1117
+ md_content, all_sources = generate_markdown(topic, angles_data)
1118
+
1119
+ # Download images and insert into markdown
1120
+ print('Downloading images...')
1121
+ image_map = collect_and_download_images(angles_data, out_dir)
1122
+ local_count = sum(1 for v in image_map.values() if not v.startswith('http'))
1123
+ remote_count = sum(1 for v in image_map.values() if v.startswith('http'))
1124
+ print(f'Images: {local_count} local, {remote_count} remote')
1125
+
1126
+ md_content = insert_images_into_markdown(md_content, angles_data, image_map, out_dir)
1127
+
1128
+ # Write README.md
1129
+ md_path = out_dir / 'README.md'
1130
+ md_path.write_text(md_content, encoding='utf-8')
1131
+ print(f'Markdown saved to: {md_path}')
1132
+
1133
+ # Generate HTML with updated markdown (including image references)
1134
+ html_content, all_sources = generate_html(topic, angles_data, md_content=md_content)
1135
+
1136
+ # Write index.html
1137
+ html_path = out_dir / 'index.html'
1138
+ html_path.write_text(html_content, encoding='utf-8')
1139
+ print(f'HTML viewer saved to: {html_path}')
1140
+
1141
+ # Write sources.json
1142
+ sources_data = {
1143
+ 'generated_at': datetime.now().isoformat(),
1144
+ 'sources': all_sources,
1145
+ }
1146
+ sources_path = out_dir / 'sources.json'
1147
+ sources_path.write_text(json.dumps(sources_data, ensure_ascii=False, indent=2), encoding='utf-8')
1148
+ print(f'Sources saved to: {sources_path}')
1149
+
1150
+
1151
+ def download_image(url, dest_path, timeout=10):
1152
+ """Download an image from URL to local path. Returns (success, local_path_or_url)."""
1153
+ try:
1154
+ import urllib.request
1155
+ import urllib.error
1156
+ # Set a user agent to avoid 403 errors
1157
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
1158
+ with urllib.request.urlopen(req, timeout=timeout) as response:
1159
+ data = response.read()
1160
+ content_type = response.headers.get('Content-Type', '')
1161
+ # Determine extension from content-type or URL
1162
+ ext = '.png'
1163
+ if 'svg' in content_type or url.endswith('.svg'):
1164
+ ext = '.svg'
1165
+ elif 'jpeg' in content_type or 'jpg' in content_type or url.endswith(('.jpg', '.jpeg')):
1166
+ ext = '.jpg'
1167
+ elif 'gif' in content_type or url.endswith('.gif'):
1168
+ ext = '.gif'
1169
+ elif 'webp' in content_type or url.endswith('.webp'):
1170
+ ext = '.webp'
1171
+ elif url.endswith(('.png', '.svg', '.jpg', '.jpeg', '.gif', '.webp')):
1172
+ ext = '.' + url.split('.')[-1].split('?')[0]
1173
+
1174
+ dest_path = dest_path.with_suffix(ext)
1175
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
1176
+ dest_path.write_bytes(data)
1177
+ return True, str(dest_path)
1178
+ except Exception as e:
1179
+ return False, url
1180
+
1181
+
1182
+ def collect_and_download_images(angles_data, out_dir):
1183
+ """Collect all image URLs from angles and download them.
1184
+ Returns (image_map, updated_md_insertions) where image_map is {url: local_path}."""
1185
+ images_dir = out_dir / 'images'
1186
+ images_dir.mkdir(exist_ok=True)
1187
+
1188
+ image_map = {} # url -> local_path or original url
1189
+ all_images = [] # list of (index, image_info)
1190
+
1191
+ for a_idx, a in enumerate(angles_data):
1192
+ for img in a.get('images', []):
1193
+ url = img.get('url', '')
1194
+ if not url or url in image_map:
1195
+ continue
1196
+ all_images.append((a_idx, img))
1197
+
1198
+ # Download images
1199
+ for idx, (a_idx, img) in enumerate(all_images):
1200
+ url = img['url']
1201
+ desc = img.get('description', f'Image {idx+1}')
1202
+
1203
+ # SVG files: use original URL (works in browsers and markdown viewers)
1204
+ if url.endswith('.svg') or 'wikipedia.org/wiki/File:' in url:
1205
+ image_map[url] = url
1206
+ continue
1207
+
1208
+ # Generate a filename
1209
+ safe_name = re.sub(r'[^\w]+', '-', desc.lower()).strip('-')[:50]
1210
+ if not safe_name:
1211
+ safe_name = f'image-{idx+1}'
1212
+ dest_path = images_dir / safe_name
1213
+
1214
+ # Check if already downloaded (from a previous run)
1215
+ existing = list(images_dir.glob(f'{safe_name}.*'))
1216
+ if existing:
1217
+ image_map[url] = str(existing[0].relative_to(out_dir)).replace('\\', '/')
1218
+ continue
1219
+
1220
+ success, result = download_image(url, dest_path)
1221
+ if success:
1222
+ image_map[url] = result.replace('\\', '/')
1223
+ print(f' Downloaded: {desc[:40]} -> {result}')
1224
+ else:
1225
+ image_map[url] = url # Fallback to original URL
1226
+ print(f' Failed to download (using original URL)')
1227
+
1228
+ return image_map
1229
+
1230
+
1231
+ def insert_images_into_markdown(md_content, angles_data, image_map, out_dir):
1232
+ """Insert image references into markdown content at appropriate positions.
1233
+ Returns updated markdown content."""
1234
+ # Build a list of images per angle
1235
+ angle_image_refs = {} # angle_index -> list of markdown image refs
1236
+ for a_idx, a in enumerate(angles_data):
1237
+ for img in a.get('images', []):
1238
+ url = img.get('url', '')
1239
+ if not url:
1240
+ continue
1241
+ local_path = image_map.get(url, url)
1242
+ desc = img.get('description', '')
1243
+ source = img.get('source', url)
1244
+ img_md = f'![{desc}]({local_path})\n\n*来源:[{source})]({source})*'
1245
+ angle_image_refs.setdefault(a_idx, []).append(img_md)
1246
+
1247
+ # Insert images after the first paragraph of each angle section
1248
+ # We'll find each angle's section and insert images after its first paragraph
1249
+ lines = md_content.split('\n')
1250
+ result = []
1251
+ current_angle = -1
1252
+ angle_header_pattern = re.compile(r'^##\s+(.+)')
1253
+ first_para_done = set()
1254
+
1255
+ for i, line in enumerate(lines):
1256
+ result.append(line)
1257
+
1258
+ # Detect angle headers
1259
+ m = angle_header_pattern.match(line)
1260
+ if m:
1261
+ header_text = m.group(1)
1262
+ for a_idx, a in enumerate(angles_data):
1263
+ if a['angle'] == header_text:
1264
+ current_angle = a_idx
1265
+ break
1266
+
1267
+ # After first paragraph in an angle section, insert images
1268
+ if current_angle >= 0 and current_angle not in angle_image_refs:
1269
+ continue
1270
+ if current_angle >= 0 and current_angle not in first_para_done:
1271
+ # Check if this is a paragraph (non-empty, non-header, non-table, non-list)
1272
+ if line and not line.startswith('#') and not line.startswith('|') and not line.startswith(('-', '*', '>', '```')) and not re.match(r'^\d+\.', line):
1273
+ # Check if next line is empty (end of paragraph)
1274
+ next_line = lines[i + 1] if i + 1 < len(lines) else ''
1275
+ if next_line.strip() == '':
1276
+ # Insert images here
1277
+ result.append('')
1278
+ for img_ref in angle_image_refs.get(current_angle, []):
1279
+ result.append(img_ref)
1280
+ result.append('')
1281
+ first_para_done.add(current_angle)
1282
+
1283
+ return '\n'.join(result)
1284
+
1285
+
1286
+ if __name__ == '__main__':
1287
+ main()