dsh-aris-panel 0.1.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 (442) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/README_CN.md +87 -0
  4. package/dsh/checkout.patch.yml +38 -0
  5. package/dsh/client.js +634 -0
  6. package/dsh/cordis.patch.yml +44 -0
  7. package/dsh/index.mjs +76 -0
  8. package/dsh/run-status.mjs +182 -0
  9. package/dsh/scope-limits.mjs +50 -0
  10. package/dsh/workbench.mjs +291 -0
  11. package/mcp-servers/claude-review/README.md +93 -0
  12. package/mcp-servers/claude-review/run_with_claude_aws.sh +49 -0
  13. package/mcp-servers/claude-review/server.py +718 -0
  14. package/mcp-servers/codex-image2/README.md +65 -0
  15. package/mcp-servers/codex-image2/server.py +893 -0
  16. package/mcp-servers/feishu-bridge/requirements.txt +1 -0
  17. package/mcp-servers/feishu-bridge/server.py +240 -0
  18. package/mcp-servers/gemini-review/README.md +171 -0
  19. package/mcp-servers/gemini-review/server.py +1856 -0
  20. package/mcp-servers/llm-chat/requirements.txt +1 -0
  21. package/mcp-servers/llm-chat/server.py +664 -0
  22. package/mcp-servers/manual-review/README.md +133 -0
  23. package/mcp-servers/manual-review/server.py +910 -0
  24. package/mcp-servers/manual-review/ui.html +279 -0
  25. package/mcp-servers/minimax-chat/requirements.txt +1 -0
  26. package/mcp-servers/minimax-chat/server.py +381 -0
  27. package/package.json +51 -0
  28. package/skills/ablation-planner/SKILL.md +123 -0
  29. package/skills/alphaxiv/SKILL.md +196 -0
  30. package/skills/analyze-results/SKILL.md +46 -0
  31. package/skills/arxiv/SKILL.md +248 -0
  32. package/skills/auto-paper-improvement-loop/SKILL.md +651 -0
  33. package/skills/auto-review-loop/SKILL.md +1137 -0
  34. package/skills/auto-review-loop-llm/SKILL.md +259 -0
  35. package/skills/auto-review-loop-minimax/SKILL.md +302 -0
  36. package/skills/citation-audit/SKILL.md +502 -0
  37. package/skills/claims-drafting/SKILL.md +227 -0
  38. package/skills/comm-lit-review/SKILL.md +297 -0
  39. package/skills/deepxiv/SKILL.md +263 -0
  40. package/skills/dse-loop/SKILL.md +296 -0
  41. package/skills/embodiment-description/SKILL.md +129 -0
  42. package/skills/exa-search/SKILL.md +205 -0
  43. package/skills/experiment-audit/SKILL.md +311 -0
  44. package/skills/experiment-bridge/SKILL.md +376 -0
  45. package/skills/experiment-plan/SKILL.md +249 -0
  46. package/skills/experiment-queue/SKILL.md +431 -0
  47. package/skills/experiment-queue/scripts/build_manifest.py +142 -0
  48. package/skills/experiment-queue/scripts/queue_manager.py +433 -0
  49. package/skills/feishu-notify/SKILL.md +156 -0
  50. package/skills/figure-description/SKILL.md +138 -0
  51. package/skills/figure-spec/SKILL.md +262 -0
  52. package/skills/figure-spec/scripts/figure_renderer.py +799 -0
  53. package/skills/formula-derivation/SKILL.md +280 -0
  54. package/skills/gemini-search/SKILL.md +231 -0
  55. package/skills/grant-proposal/SKILL.md +698 -0
  56. package/skills/idea-creator/SKILL.md +542 -0
  57. package/skills/idea-discovery/SKILL.md +521 -0
  58. package/skills/idea-discovery-robot/SKILL.md +363 -0
  59. package/skills/integrity-forensics/SKILL.md +284 -0
  60. package/skills/interview-cheatsheet/SKILL.md +245 -0
  61. package/skills/invention-structuring/SKILL.md +188 -0
  62. package/skills/jurisdiction-format/SKILL.md +192 -0
  63. package/skills/kill-argument/SKILL.md +437 -0
  64. package/skills/mermaid-diagram/SKILL.md +419 -0
  65. package/skills/meta-apply/SKILL.md +141 -0
  66. package/skills/meta-optimize/SKILL.md +437 -0
  67. package/skills/monitor-experiment/SKILL.md +140 -0
  68. package/skills/novelty-check/SKILL.md +101 -0
  69. package/skills/openalex/SKILL.md +237 -0
  70. package/skills/overleaf-sync/SKILL.md +220 -0
  71. package/skills/paper-claim-audit/SKILL.md +348 -0
  72. package/skills/paper-compile/SKILL.md +266 -0
  73. package/skills/paper-figure/SKILL.md +312 -0
  74. package/skills/paper-illustration/SKILL.md +736 -0
  75. package/skills/paper-illustration-image2/SKILL.md +391 -0
  76. package/skills/paper-illustration-image2/scripts/paper_illustration_image2.py +255 -0
  77. package/skills/paper-plan/SKILL.md +386 -0
  78. package/skills/paper-poster/SKILL.md +19 -0
  79. package/skills/paper-poster-html/DESIGN_FINAL.md +176 -0
  80. package/skills/paper-poster-html/IMPLEMENTATION_CONVENTIONS.md +161 -0
  81. package/skills/paper-poster-html/LICENSES/posterly-MIT.txt +21 -0
  82. package/skills/paper-poster-html/NOTICE.md +57 -0
  83. package/skills/paper-poster-html/SKILL.md +323 -0
  84. package/skills/paper-poster-html/scripts/_posterly/__init__.py +0 -0
  85. package/skills/paper-poster-html/scripts/_posterly/canvas.py +200 -0
  86. package/skills/paper-poster-html/scripts/_posterly/measure.py +588 -0
  87. package/skills/paper-poster-html/scripts/_posterly/polish.py +498 -0
  88. package/skills/paper-poster-html/scripts/_posterly/preflight.py +489 -0
  89. package/skills/paper-poster-html/scripts/_posterly/render.py +215 -0
  90. package/skills/paper-poster-html/scripts/_posterly/textutil.py +16 -0
  91. package/skills/paper-poster-html/scripts/_posterly/verify_final.py +171 -0
  92. package/skills/paper-poster-html/scripts/asset_check.py +897 -0
  93. package/skills/paper-poster-html/scripts/extract_pdf_figures.py +666 -0
  94. package/skills/paper-poster-html/scripts/poster_check.py +251 -0
  95. package/skills/paper-poster-html/scripts/preprocess_figures.py +238 -0
  96. package/skills/paper-poster-html/scripts/render_preview.py +217 -0
  97. package/skills/paper-poster-html/scripts/run_gates.py +556 -0
  98. package/skills/paper-poster-html/scripts/style_check.py +1324 -0
  99. package/skills/paper-poster-html/templates/COMPONENTS.md +462 -0
  100. package/skills/paper-poster-html/templates/README.md +170 -0
  101. package/skills/paper-poster-html/templates/landscape_4col.html +1032 -0
  102. package/skills/paper-poster-html/templates/landscape_hero.html +1046 -0
  103. package/skills/paper-poster-html/templates/portrait_2col.html +947 -0
  104. package/skills/paper-poster-html/templates/tokens/acl.json +9 -0
  105. package/skills/paper-poster-html/templates/tokens/cvpr.json +9 -0
  106. package/skills/paper-poster-html/templates/tokens/generic.json +9 -0
  107. package/skills/paper-poster-html/templates/tokens/iclr.json +9 -0
  108. package/skills/paper-poster-html/templates/tokens/icml.json +9 -0
  109. package/skills/paper-poster-html/templates/tokens/neurips.json +9 -0
  110. package/skills/paper-slides/SKILL.md +635 -0
  111. package/skills/paper-talk/SKILL.md +381 -0
  112. package/skills/paper-write/SKILL.md +604 -0
  113. package/skills/paper-write/templates/IEEEtran.bst +2409 -0
  114. package/skills/paper-write/templates/IEEEtran.cls +6347 -0
  115. package/skills/paper-write/templates/iclr2026.tex +84 -0
  116. package/skills/paper-write/templates/icml2025.tex +87 -0
  117. package/skills/paper-write/templates/ieee_conference.tex +89 -0
  118. package/skills/paper-write/templates/ieee_journal.tex +93 -0
  119. package/skills/paper-write/templates/math_commands.tex +48 -0
  120. package/skills/paper-write/templates/neurips2025.tex +80 -0
  121. package/skills/paper-writing/SKILL.md +916 -0
  122. package/skills/patent-novelty-check/SKILL.md +153 -0
  123. package/skills/patent-pipeline/SKILL.md +344 -0
  124. package/skills/patent-review/SKILL.md +203 -0
  125. package/skills/pixel-art/SKILL.md +137 -0
  126. package/skills/prior-art-search/SKILL.md +146 -0
  127. package/skills/proof-checker/SKILL.md +866 -0
  128. package/skills/proof-orchestrator/NOTICE.md +24 -0
  129. package/skills/proof-orchestrator/SKILL.md +254 -0
  130. package/skills/proof-orchestrator/references/audit-output-contract.md +126 -0
  131. package/skills/proof-orchestrator/references/deepseek-routing.md +74 -0
  132. package/skills/proof-orchestrator/references/dispatch-prompts.md +227 -0
  133. package/skills/proof-orchestrator/references/notation-audit.md +135 -0
  134. package/skills/proof-orchestrator/references/proof-audit-rubric.md +70 -0
  135. package/skills/proof-orchestrator/references/stress-tests.md +38 -0
  136. package/skills/proof-writer/SKILL.md +223 -0
  137. package/skills/qzcli/SKILL.md +324 -0
  138. package/skills/rebuttal/SKILL.md +376 -0
  139. package/skills/render-html/SKILL.md +316 -0
  140. package/skills/render-html/scripts/render_html.py +1006 -0
  141. package/skills/render-html/scripts/templates/academic.html +703 -0
  142. package/skills/render-html/scripts/templates/dashboard.html +333 -0
  143. package/skills/research-lit/SKILL.md +756 -0
  144. package/skills/research-pipeline/SKILL.md +384 -0
  145. package/skills/research-refine/SKILL.md +770 -0
  146. package/skills/research-refine-pipeline/SKILL.md +186 -0
  147. package/skills/research-review/SKILL.md +198 -0
  148. package/skills/research-wiki/SKILL.md +461 -0
  149. package/skills/resubmit-pipeline/SKILL.md +447 -0
  150. package/skills/result-to-claim/SKILL.md +311 -0
  151. package/skills/run-experiment/SKILL.md +313 -0
  152. package/skills/semantic-scholar/SKILL.md +236 -0
  153. package/skills/serverless-modal/SKILL.md +335 -0
  154. package/skills/shared-references/acceptance-gate.md +324 -0
  155. package/skills/shared-references/assurance-contract.md +248 -0
  156. package/skills/shared-references/capture-antipatterns.md +78 -0
  157. package/skills/shared-references/citation-discipline.md +583 -0
  158. package/skills/shared-references/compute-env-contract.md +163 -0
  159. package/skills/shared-references/effort-contract.md +183 -0
  160. package/skills/shared-references/evidence-precheck.md +65 -0
  161. package/skills/shared-references/experiment-integrity.md +49 -0
  162. package/skills/shared-references/external-cadence.md +326 -0
  163. package/skills/shared-references/fan-out-pattern.md +366 -0
  164. package/skills/shared-references/injection-hygiene.md +127 -0
  165. package/skills/shared-references/integration-contract.md +461 -0
  166. package/skills/shared-references/output-composition.md +93 -0
  167. package/skills/shared-references/output-language.md +45 -0
  168. package/skills/shared-references/output-manifest.md +49 -0
  169. package/skills/shared-references/output-versioning.md +111 -0
  170. package/skills/shared-references/patent-format-cn.md +199 -0
  171. package/skills/shared-references/patent-format-ep.md +173 -0
  172. package/skills/shared-references/patent-format-us.md +161 -0
  173. package/skills/shared-references/patent-writing-principles.md +197 -0
  174. package/skills/shared-references/prior-art-databases.md +141 -0
  175. package/skills/shared-references/resumable-runs.md +109 -0
  176. package/skills/shared-references/review-scope-limits.md +81 -0
  177. package/skills/shared-references/review-tracing.md +391 -0
  178. package/skills/shared-references/reviewer-independence.md +79 -0
  179. package/skills/shared-references/reviewer-routing.md +852 -0
  180. package/skills/shared-references/skill-governance.md +104 -0
  181. package/skills/shared-references/taste-calibration.md +85 -0
  182. package/skills/shared-references/venue-checklists.md +114 -0
  183. package/skills/shared-references/wiki-helper-resolution.md +134 -0
  184. package/skills/shared-references/writing-principles.md +525 -0
  185. package/skills/skills-codex/README.md +102 -0
  186. package/skills/skills-codex/README_CN.md +100 -0
  187. package/skills/skills-codex/ablation-planner/SKILL.md +126 -0
  188. package/skills/skills-codex/alphaxiv/SKILL.md +186 -0
  189. package/skills/skills-codex/analyze-results/SKILL.md +45 -0
  190. package/skills/skills-codex/arxiv/SKILL.md +210 -0
  191. package/skills/skills-codex/auto-paper-improvement-loop/SKILL.md +574 -0
  192. package/skills/skills-codex/auto-review-loop/SKILL.md +500 -0
  193. package/skills/skills-codex/auto-review-loop-llm/SKILL.md +247 -0
  194. package/skills/skills-codex/auto-review-loop-minimax/SKILL.md +290 -0
  195. package/skills/skills-codex/citation-audit/SKILL.md +504 -0
  196. package/skills/skills-codex/claims-drafting/SKILL.md +239 -0
  197. package/skills/skills-codex/comm-lit-review/SKILL.md +299 -0
  198. package/skills/skills-codex/comm-lit-review/references/domain-taxonomy.md +57 -0
  199. package/skills/skills-codex/comm-lit-review/references/output-template.md +37 -0
  200. package/skills/skills-codex/comm-lit-review/references/source-policy.md +99 -0
  201. package/skills/skills-codex/comm-lit-review/references/venue-tiering.md +112 -0
  202. package/skills/skills-codex/deepxiv/SKILL.md +142 -0
  203. package/skills/skills-codex/dse-loop/SKILL.md +285 -0
  204. package/skills/skills-codex/embodiment-description/SKILL.md +129 -0
  205. package/skills/skills-codex/exa-search/SKILL.md +192 -0
  206. package/skills/skills-codex/experiment-audit/SKILL.md +286 -0
  207. package/skills/skills-codex/experiment-bridge/SKILL.md +356 -0
  208. package/skills/skills-codex/experiment-plan/SKILL.md +249 -0
  209. package/skills/skills-codex/experiment-queue/SKILL.md +401 -0
  210. package/skills/skills-codex/feishu-notify/SKILL.md +155 -0
  211. package/skills/skills-codex/figure-description/SKILL.md +138 -0
  212. package/skills/skills-codex/figure-spec/SKILL.md +252 -0
  213. package/skills/skills-codex/formula-derivation/SKILL.md +280 -0
  214. package/skills/skills-codex/gemini-search/SKILL.md +205 -0
  215. package/skills/skills-codex/grant-proposal/SKILL.md +626 -0
  216. package/skills/skills-codex/idea-creator/SKILL.md +405 -0
  217. package/skills/skills-codex/idea-discovery/SKILL.md +475 -0
  218. package/skills/skills-codex/idea-discovery-robot/SKILL.md +362 -0
  219. package/skills/skills-codex/integrity-forensics/SKILL.md +106 -0
  220. package/skills/skills-codex/interview-cheatsheet/SKILL.md +245 -0
  221. package/skills/skills-codex/invention-structuring/SKILL.md +188 -0
  222. package/skills/skills-codex/jurisdiction-format/SKILL.md +192 -0
  223. package/skills/skills-codex/kill-argument/SKILL.md +403 -0
  224. package/skills/skills-codex/mermaid-diagram/SKILL.md +379 -0
  225. package/skills/skills-codex/meta-apply/SKILL.md +154 -0
  226. package/skills/skills-codex/meta-optimize/SKILL.md +348 -0
  227. package/skills/skills-codex/monitor-experiment/SKILL.md +98 -0
  228. package/skills/skills-codex/novelty-check/SKILL.md +89 -0
  229. package/skills/skills-codex/openalex/SKILL.md +228 -0
  230. package/skills/skills-codex/overleaf-sync/SKILL.md +220 -0
  231. package/skills/skills-codex/paper-claim-audit/SKILL.md +350 -0
  232. package/skills/skills-codex/paper-compile/SKILL.md +253 -0
  233. package/skills/skills-codex/paper-figure/SKILL.md +311 -0
  234. package/skills/skills-codex/paper-illustration/SKILL.md +690 -0
  235. package/skills/skills-codex/paper-illustration-image2/SKILL.md +383 -0
  236. package/skills/skills-codex/paper-illustration-image2/scripts/paper_illustration_image2.py +255 -0
  237. package/skills/skills-codex/paper-plan/SKILL.md +278 -0
  238. package/skills/skills-codex/paper-poster/SKILL.md +19 -0
  239. package/skills/skills-codex/paper-poster-html/SKILL.md +377 -0
  240. package/skills/skills-codex/paper-slides/SKILL.md +571 -0
  241. package/skills/skills-codex/paper-talk/SKILL.md +381 -0
  242. package/skills/skills-codex/paper-write/SKILL.md +411 -0
  243. package/skills/skills-codex/paper-write/templates/IEEEtran.bst +2409 -0
  244. package/skills/skills-codex/paper-write/templates/IEEEtran.cls +6347 -0
  245. package/skills/skills-codex/paper-write/templates/aaai2026.bst +1493 -0
  246. package/skills/skills-codex/paper-write/templates/aaai2026.sty +315 -0
  247. package/skills/skills-codex/paper-write/templates/aaai2026.tex +952 -0
  248. package/skills/skills-codex/paper-write/templates/acl.sty +312 -0
  249. package/skills/skills-codex/paper-write/templates/acl2026.tex +377 -0
  250. package/skills/skills-codex/paper-write/templates/acl_natbib.bst +1940 -0
  251. package/skills/skills-codex/paper-write/templates/acm.bst +3081 -0
  252. package/skills/skills-codex/paper-write/templates/acm_mm2026.tex +204 -0
  253. package/skills/skills-codex/paper-write/templates/acmart.cls +3520 -0
  254. package/skills/skills-codex/paper-write/templates/cvpr.bst +1448 -0
  255. package/skills/skills-codex/paper-write/templates/cvpr.sty +508 -0
  256. package/skills/skills-codex/paper-write/templates/cvpr2026.tex +63 -0
  257. package/skills/skills-codex/paper-write/templates/iclr2026.tex +84 -0
  258. package/skills/skills-codex/paper-write/templates/iclr2026_conference.bst +1440 -0
  259. package/skills/skills-codex/paper-write/templates/iclr2026_conference.sty +246 -0
  260. package/skills/skills-codex/paper-write/templates/icml2026.sty +767 -0
  261. package/skills/skills-codex/paper-write/templates/icml2026.tex +662 -0
  262. package/skills/skills-codex/paper-write/templates/ieee_conference.tex +89 -0
  263. package/skills/skills-codex/paper-write/templates/ieee_journal.tex +93 -0
  264. package/skills/skills-codex/paper-write/templates/math_commands.tex +48 -0
  265. package/skills/skills-codex/paper-write/templates/neurips2026.tex +493 -0
  266. package/skills/skills-codex/paper-write/templates/neurips_2026.sty +437 -0
  267. package/skills/skills-codex/paper-writing/SKILL.md +731 -0
  268. package/skills/skills-codex/patent-novelty-check/SKILL.md +153 -0
  269. package/skills/skills-codex/patent-pipeline/SKILL.md +344 -0
  270. package/skills/skills-codex/patent-review/SKILL.md +202 -0
  271. package/skills/skills-codex/pixel-art/SKILL.md +139 -0
  272. package/skills/skills-codex/prior-art-search/SKILL.md +146 -0
  273. package/skills/skills-codex/proof-checker/SKILL.md +554 -0
  274. package/skills/skills-codex/proof-orchestrator/SKILL.md +260 -0
  275. package/skills/skills-codex/proof-orchestrator/references/audit-output-contract.md +126 -0
  276. package/skills/skills-codex/proof-orchestrator/references/deepseek-routing.md +76 -0
  277. package/skills/skills-codex/proof-orchestrator/references/dispatch-prompts.md +227 -0
  278. package/skills/skills-codex/proof-orchestrator/references/notation-audit.md +135 -0
  279. package/skills/skills-codex/proof-orchestrator/references/proof-audit-rubric.md +70 -0
  280. package/skills/skills-codex/proof-orchestrator/references/stress-tests.md +38 -0
  281. package/skills/skills-codex/proof-writer/SKILL.md +222 -0
  282. package/skills/skills-codex/qzcli/SKILL.md +324 -0
  283. package/skills/skills-codex/rebuttal/SKILL.md +305 -0
  284. package/skills/skills-codex/render-html/SKILL.md +305 -0
  285. package/skills/skills-codex/render-html/scripts/__pycache__/render_html.cpython-314.pyc +0 -0
  286. package/skills/skills-codex/render-html/scripts/render_html.py +909 -0
  287. package/skills/skills-codex/render-html/scripts/templates/academic.html +342 -0
  288. package/skills/skills-codex/render-html/scripts/templates/dashboard.html +333 -0
  289. package/skills/skills-codex/research-lit/SKILL.md +464 -0
  290. package/skills/skills-codex/research-pipeline/SKILL.md +340 -0
  291. package/skills/skills-codex/research-refine/SKILL.md +721 -0
  292. package/skills/skills-codex/research-refine-pipeline/SKILL.md +186 -0
  293. package/skills/skills-codex/research-review/SKILL.md +135 -0
  294. package/skills/skills-codex/research-wiki/SKILL.md +421 -0
  295. package/skills/skills-codex/resubmit-pipeline/SKILL.md +444 -0
  296. package/skills/skills-codex/result-to-claim/SKILL.md +246 -0
  297. package/skills/skills-codex/run-experiment/SKILL.md +236 -0
  298. package/skills/skills-codex/semantic-scholar/SKILL.md +219 -0
  299. package/skills/skills-codex/serverless-modal/SKILL.md +335 -0
  300. package/skills/skills-codex/shared-references/acceptance-gate.md +336 -0
  301. package/skills/skills-codex/shared-references/assurance-contract.md +139 -0
  302. package/skills/skills-codex/shared-references/capture-antipatterns.md +84 -0
  303. package/skills/skills-codex/shared-references/citation-discipline.md +452 -0
  304. package/skills/skills-codex/shared-references/compute-env-contract.md +163 -0
  305. package/skills/skills-codex/shared-references/effort-contract.md +143 -0
  306. package/skills/skills-codex/shared-references/evidence-precheck.md +73 -0
  307. package/skills/skills-codex/shared-references/experiment-integrity.md +49 -0
  308. package/skills/skills-codex/shared-references/external-cadence.md +334 -0
  309. package/skills/skills-codex/shared-references/fan-out-pattern.md +375 -0
  310. package/skills/skills-codex/shared-references/injection-hygiene.md +132 -0
  311. package/skills/skills-codex/shared-references/integration-contract.md +372 -0
  312. package/skills/skills-codex/shared-references/output-composition.md +98 -0
  313. package/skills/skills-codex/shared-references/output-language.md +45 -0
  314. package/skills/skills-codex/shared-references/output-manifest.md +40 -0
  315. package/skills/skills-codex/shared-references/output-versioning.md +111 -0
  316. package/skills/skills-codex/shared-references/patent-format-cn.md +199 -0
  317. package/skills/skills-codex/shared-references/patent-format-ep.md +173 -0
  318. package/skills/skills-codex/shared-references/patent-format-us.md +161 -0
  319. package/skills/skills-codex/shared-references/patent-writing-principles.md +197 -0
  320. package/skills/skills-codex/shared-references/prior-art-databases.md +141 -0
  321. package/skills/skills-codex/shared-references/resumable-runs.md +125 -0
  322. package/skills/skills-codex/shared-references/review-scope-limits.md +81 -0
  323. package/skills/skills-codex/shared-references/review-tracing.md +144 -0
  324. package/skills/skills-codex/shared-references/reviewer-independence.md +66 -0
  325. package/skills/skills-codex/shared-references/reviewer-routing.md +128 -0
  326. package/skills/skills-codex/shared-references/skill-governance.md +119 -0
  327. package/skills/skills-codex/shared-references/taste-calibration.md +90 -0
  328. package/skills/skills-codex/shared-references/venue-checklists.md +73 -0
  329. package/skills/skills-codex/shared-references/wiki-helper-resolution.md +69 -0
  330. package/skills/skills-codex/shared-references/writing-principles.md +525 -0
  331. package/skills/skills-codex/slides-polish/SKILL.md +563 -0
  332. package/skills/skills-codex/specification-writing/SKILL.md +211 -0
  333. package/skills/skills-codex/system-profile/SKILL.md +103 -0
  334. package/skills/skills-codex/training-check/SKILL.md +83 -0
  335. package/skills/skills-codex/vast-gpu/SKILL.md +394 -0
  336. package/skills/skills-codex/web-debug-search/SKILL.md +334 -0
  337. package/skills/skills-codex/wiki-enrich/SKILL.md +255 -0
  338. package/skills/skills-codex/writing-systems-papers/SKILL.md +184 -0
  339. package/skills/skills-codex-claude-review/README.md +79 -0
  340. package/skills/skills-codex-claude-review/README_CN.md +78 -0
  341. package/skills/skills-codex-claude-review/auto-paper-improvement-loop/SKILL.md +581 -0
  342. package/skills/skills-codex-claude-review/auto-review-loop/SKILL.md +510 -0
  343. package/skills/skills-codex-claude-review/novelty-check/SKILL.md +102 -0
  344. package/skills/skills-codex-claude-review/paper-figure/SKILL.md +319 -0
  345. package/skills/skills-codex-claude-review/paper-plan/SKILL.md +287 -0
  346. package/skills/skills-codex-claude-review/paper-write/SKILL.md +420 -0
  347. package/skills/skills-codex-claude-review/research-refine/SKILL.md +732 -0
  348. package/skills/skills-codex-claude-review/research-review/SKILL.md +149 -0
  349. package/skills/skills-codex-gemini-review/README.md +176 -0
  350. package/skills/skills-codex-gemini-review/README_CN.md +175 -0
  351. package/skills/skills-codex-gemini-review/auto-paper-improvement-loop/SKILL.md +331 -0
  352. package/skills/skills-codex-gemini-review/auto-review-loop/SKILL.md +304 -0
  353. package/skills/skills-codex-gemini-review/grant-proposal/SKILL.md +630 -0
  354. package/skills/skills-codex-gemini-review/idea-creator/SKILL.md +263 -0
  355. package/skills/skills-codex-gemini-review/idea-discovery/SKILL.md +275 -0
  356. package/skills/skills-codex-gemini-review/idea-discovery-robot/SKILL.md +365 -0
  357. package/skills/skills-codex-gemini-review/novelty-check/SKILL.md +92 -0
  358. package/skills/skills-codex-gemini-review/paper-figure/SKILL.md +289 -0
  359. package/skills/skills-codex-gemini-review/paper-plan/SKILL.md +265 -0
  360. package/skills/skills-codex-gemini-review/paper-poster-html/SKILL.md +102 -0
  361. package/skills/skills-codex-gemini-review/paper-slides/SKILL.md +582 -0
  362. package/skills/skills-codex-gemini-review/paper-write/SKILL.md +346 -0
  363. package/skills/skills-codex-gemini-review/paper-writing/SKILL.md +312 -0
  364. package/skills/skills-codex-gemini-review/research-refine/SKILL.md +674 -0
  365. package/skills/skills-codex-gemini-review/research-review/SKILL.md +112 -0
  366. package/skills/slides-polish/SKILL.md +565 -0
  367. package/skills/specification-writing/SKILL.md +211 -0
  368. package/skills/system-profile/SKILL.md +103 -0
  369. package/skills/training-check/SKILL.md +132 -0
  370. package/skills/vast-gpu/SKILL.md +394 -0
  371. package/skills/web-debug-search/SKILL.md +334 -0
  372. package/skills/wiki-enrich/SKILL.md +257 -0
  373. package/skills/writing-systems-papers/SKILL.md +184 -0
  374. package/templates/CLAUDE_MD_TEMPLATE.md +29 -0
  375. package/templates/EXPERIMENT_LOG_TEMPLATE.md +47 -0
  376. package/templates/EXPERIMENT_PLAN_TEMPLATE.md +51 -0
  377. package/templates/EXPERIMENT_PLAN_TEMPLATE_CN.md +53 -0
  378. package/templates/FINDINGS_TEMPLATE.md +52 -0
  379. package/templates/IDEA_CANDIDATES_TEMPLATE.md +47 -0
  380. package/templates/IDEA_CANDIDATES_TEMPLATE_CN.md +47 -0
  381. package/templates/INVENTION_BRIEF_TEMPLATE.md +87 -0
  382. package/templates/MANIFEST_TEMPLATE.md +7 -0
  383. package/templates/NARRATIVE_REPORT_TEMPLATE.md +49 -0
  384. package/templates/PAPER_PLAN_TEMPLATE.md +47 -0
  385. package/templates/PATENT_CLAIMS_TEMPLATE.md +78 -0
  386. package/templates/PATENT_SPECIFICATION_TEMPLATE.md +68 -0
  387. package/templates/README.md +57 -0
  388. package/templates/RESEARCH_BRIEF_TEMPLATE.md +35 -0
  389. package/templates/RESEARCH_BRIEF_TEMPLATE_CN.md +41 -0
  390. package/templates/RESEARCH_CONTRACT_TEMPLATE.md +60 -0
  391. package/templates/claude-hooks/corpus_write_guard.json +16 -0
  392. package/templates/claude-hooks/corpus_write_guard.py +85 -0
  393. package/templates/claude-hooks/meta_logging.json +74 -0
  394. package/templates/gitignore-trace.txt +3 -0
  395. package/tools/__pycache__/check_skills_inventory.cpython-314.pyc +0 -0
  396. package/tools/arxiv_fetch.py +311 -0
  397. package/tools/capture_filter.py +126 -0
  398. package/tools/check_skills_inventory.py +273 -0
  399. package/tools/convert_skills_to_llm_chat.py +282 -0
  400. package/tools/copilot_native_evidence.py +818 -0
  401. package/tools/deepxiv_fetch.py +213 -0
  402. package/tools/evidence_check.py +212 -0
  403. package/tools/exa_search.py +425 -0
  404. package/tools/experiment_queue/README.md +118 -0
  405. package/tools/experiment_queue/build_manifest.py +44 -0
  406. package/tools/experiment_queue/queue_manager.py +44 -0
  407. package/tools/extract_paper_style.py +560 -0
  408. package/tools/figure_renderer.py +69 -0
  409. package/tools/forensics_gate.py +669 -0
  410. package/tools/generate_codex_claude_review_overrides.py +299 -0
  411. package/tools/idea_discovery_gate.py +256 -0
  412. package/tools/install_aris.ps1 +1372 -0
  413. package/tools/install_aris.sh +1370 -0
  414. package/tools/install_aris_codex.sh +1023 -0
  415. package/tools/install_aris_copilot.sh +1052 -0
  416. package/tools/iteration_log.py +143 -0
  417. package/tools/lint_skills_helpers.sh +84 -0
  418. package/tools/meta_opt/check_ready.sh +80 -0
  419. package/tools/meta_opt/log_event.sh +91 -0
  420. package/tools/meta_opt/trigger_eval.py +280 -0
  421. package/tools/meta_opt/trigger_evals.sample.json +28 -0
  422. package/tools/openalex_fetch.py +326 -0
  423. package/tools/overleaf_audit.sh +104 -0
  424. package/tools/overleaf_setup.sh +150 -0
  425. package/tools/paper_illustration_image2.py +62 -0
  426. package/tools/provenance.py +294 -0
  427. package/tools/research_wiki.py +1720 -0
  428. package/tools/review_gate.py +502 -0
  429. package/tools/run_state.py +399 -0
  430. package/tools/save_trace.sh +477 -0
  431. package/tools/semantic_scholar_fetch.py +438 -0
  432. package/tools/skill-groups.tsv +116 -0
  433. package/tools/skill_picker.py +238 -0
  434. package/tools/smart_update.ps1 +521 -0
  435. package/tools/smart_update.sh +591 -0
  436. package/tools/smart_update_codex.sh +419 -0
  437. package/tools/smart_update_copilot.sh +605 -0
  438. package/tools/threat_scan.py +222 -0
  439. package/tools/verify_paper_audits.sh +487 -0
  440. package/tools/verify_papers.py +613 -0
  441. package/tools/verify_wiki_coverage.sh +176 -0
  442. package/tools/watchdog.py +485 -0
@@ -0,0 +1,909 @@
1
+ #!/usr/bin/env python3
2
+ """render_html.py — convert ARIS Markdown / JSON artifacts to single-file HTML.
3
+
4
+ Pure-stdlib Python. No external pip deps.
5
+
6
+ Usage:
7
+ render_html.py <input.md> [--template academic|dashboard]
8
+ [--out <output.html>]
9
+ [--title "..."] [--subtitle "..."]
10
+ [--eyebrow "..."]
11
+ [--state <state.json>]
12
+ [--json <sidecar.json>]
13
+ [--offline]
14
+ [--no-toc]
15
+
16
+ See skills/render-html/SKILL.md for the full contract.
17
+
18
+ Design invariants (see codex review consultation in commit message):
19
+ - Markdown / JSON is canonical source, HTML is generated view.
20
+ - HTML embeds source path + SHA256 + generated timestamp (drift detection).
21
+ - Single-file output. MathJax + highlight.js loaded from CDN unless --offline.
22
+ - Pure stdlib: re, html, hashlib, json, datetime, pathlib, argparse, sys.
23
+ - Conservative Markdown subset matching what ARIS artifacts actually emit.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import hashlib
29
+ import html as html_lib
30
+ import json
31
+ import re
32
+ import sys
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+
36
+ TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Inline parsing
40
+ # ---------------------------------------------------------------------------
41
+
42
+ # Placeholders used to protect inline content during multi-pass rewriting.
43
+ # Two non-printable PUA chars + index to avoid collisions with real content.
44
+ _PH_OPEN = ""
45
+ _PH_CLOSE = ""
46
+
47
+
48
+ def _ph(idx: int) -> str:
49
+ return f"{_PH_OPEN}{idx}{_PH_CLOSE}"
50
+
51
+
52
+ _RE_CODE_INLINE = re.compile(r"`([^`\n]+)`")
53
+ _RE_MATH_DISPLAY = re.compile(r"\$\$([^\n][\s\S]*?)\$\$")
54
+ _RE_MATH_INLINE = re.compile(r"(?<!\\)\$([^\$\n]+?)\$")
55
+ _RE_IMG = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)")
56
+ _RE_LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)")
57
+ _RE_BOLD = re.compile(r"\*\*([^\*\n]+)\*\*")
58
+ _RE_ITALIC = re.compile(r"(?<!\*)\*([^\*\n]+)\*(?!\*)")
59
+ _RE_ITALIC_UNDERSCORE = re.compile(r"(?<!\w)_([^_\n]+)_(?!\w)")
60
+ _RE_STRIKE = re.compile(r"~~([^~\n]+)~~")
61
+
62
+ # Inline HTML tags that should pass through inline (commonly used in ARIS docs).
63
+ _INLINE_HTML_TAGS = ("br", "img", "a", "span", "sub", "sup", "code", "kbd", "b", "i", "u", "strong", "em")
64
+
65
+ # URL schemes considered safe for href/src. javascript:, data:, vbscript: blocked.
66
+ _SAFE_URL_SCHEMES = ("http:", "https:", "mailto:", "ftp:", "tel:", "#", "/", "./", "../")
67
+
68
+
69
+ def _safe_url(url: str) -> str:
70
+ """Return url if scheme is safe, else '#'. Defensive against javascript: links."""
71
+ s = url.strip().lower()
72
+ # Relative paths and fragments are fine
73
+ if s.startswith(("#", "/", "./", "../")) or s == "":
74
+ return url
75
+ if s.startswith(_SAFE_URL_SCHEMES):
76
+ return url
77
+ # Bare path like "foo/bar.md" or "page.html" — no scheme prefix, safe.
78
+ if ":" not in s.split("/", 1)[0]:
79
+ return url
80
+ # Block javascript:, data:, vbscript:, file:, etc.
81
+ return "#blocked-unsafe-url"
82
+
83
+
84
+ # Tags stripped wholesale from HTML passthrough (block and inline).
85
+ # Even if the workflow LLM hallucinates these, they never reach output.
86
+ _RE_STRIP_TAG = re.compile(
87
+ r"<\s*(script|style|iframe|object|embed|form|input|button|link|meta|base)\b[^>]*>.*?</\s*\1\s*>",
88
+ re.IGNORECASE | re.DOTALL,
89
+ )
90
+ _RE_STRIP_TAG_SELF = re.compile(
91
+ r"<\s*(script|style|iframe|object|embed|form|input|button|link|meta|base)\b[^>]*/?\s*>",
92
+ re.IGNORECASE,
93
+ )
94
+ # Event-handler attributes like onclick=, onload=, etc. — strip these.
95
+ _RE_STRIP_EVENT_ATTR = re.compile(
96
+ r"\s+on[a-z]+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)",
97
+ re.IGNORECASE,
98
+ )
99
+ # javascript:/vbscript:/data: in href/src — replace with #blocked.
100
+ _RE_STRIP_DANGEROUS_URL_ATTR = re.compile(
101
+ r"""(\b(?:href|src|action|formaction|poster)\s*=\s*["']?)\s*(?:javascript|vbscript|data)\s*:""",
102
+ re.IGNORECASE,
103
+ )
104
+
105
+
106
+ def sanitize_html(s: str) -> str:
107
+ """Strip dangerous tags / event handlers / javascript: URLs from raw HTML.
108
+
109
+ Applied to: (a) inline-HTML spans we pass through, (b) block-HTML
110
+ passthrough content. Markdown text content is HTML-escaped separately
111
+ and never reaches this function. ARIS workflow artifacts should not
112
+ contain these tags; this is defense-in-depth in case an LLM hallucinates
113
+ one.
114
+ """
115
+ s = _RE_STRIP_TAG.sub("", s)
116
+ s = _RE_STRIP_TAG_SELF.sub("", s)
117
+ s = _RE_STRIP_EVENT_ATTR.sub("", s)
118
+ s = _RE_STRIP_DANGEROUS_URL_ATTR.sub(r"\1#blocked-unsafe-url:", s)
119
+ return s
120
+
121
+
122
+ def render_inline(text: str) -> str:
123
+ """Convert Markdown inline syntax to HTML. Escape everything else.
124
+
125
+ Order matters:
126
+ 1. Stash inline code, math (display+inline), and raw HTML tag-shaped
127
+ spans into placeholders (so we don't expand markdown inside them).
128
+ 2. HTML-escape the remaining text.
129
+ 3. Apply images, links, bold, italic, strike.
130
+ 4. Restore placeholders.
131
+ """
132
+ stash: list[str] = []
133
+
134
+ def store(replacement: str) -> str:
135
+ idx = len(stash)
136
+ stash.append(replacement)
137
+ return _ph(idx)
138
+
139
+ # 1a. Inline code -- escape inner content fully.
140
+ def _code_sub(m: re.Match[str]) -> str:
141
+ return store(f"<code>{html_lib.escape(m.group(1))}</code>")
142
+
143
+ text = _RE_CODE_INLINE.sub(_code_sub, text)
144
+
145
+ # HTML-escape <, >, & inside math bodies: a bare "<t" (e.g. y_{<t}) is
146
+ # otherwise parsed as an HTML start tag and eats the rest of the formula
147
+ # before MathJax runs. MathJax v3 reads the decoded DOM text, so the escape
148
+ # is transparent to the TeX it sees (and & for cases/align stays intact).
149
+ # 1b. Display math (passthrough; MathJax will render).
150
+ def _md_sub(m: re.Match[str]) -> str:
151
+ body = html_lib.escape(m.group(1), quote=False)
152
+ return store(f"$${body}$$")
153
+
154
+ text = _RE_MATH_DISPLAY.sub(_md_sub, text)
155
+
156
+ # 1c. Inline math (passthrough).
157
+ def _mi_sub(m: re.Match[str]) -> str:
158
+ body = html_lib.escape(m.group(1), quote=False)
159
+ return store(f"${body}$")
160
+
161
+ text = _RE_MATH_INLINE.sub(_mi_sub, text)
162
+
163
+ # 1d. Inline HTML spans (very limited allowlist + sanitize before stash).
164
+ _re_tag = re.compile(
165
+ r"<(/?)(" + "|".join(_INLINE_HTML_TAGS) + r")(\s[^<>]*)?>",
166
+ re.IGNORECASE,
167
+ )
168
+
169
+ def _tag_sub(m: re.Match[str]) -> str:
170
+ return store(sanitize_html(m.group(0)))
171
+
172
+ text = _re_tag.sub(_tag_sub, text)
173
+
174
+ # 2. HTML-escape remainder.
175
+ text = html_lib.escape(text, quote=False)
176
+
177
+ # 3. Apply markdown emphasis & links.
178
+ def _img_sub(m: re.Match[str]) -> str:
179
+ alt = html_lib.escape(m.group(1), quote=True)
180
+ src = html_lib.escape(_safe_url(m.group(2)), quote=True)
181
+ title = m.group(3)
182
+ title_attr = f' title="{html_lib.escape(title, quote=True)}"' if title else ""
183
+ return f'<img src="{src}" alt="{alt}"{title_attr} />'
184
+
185
+ text = _RE_IMG.sub(_img_sub, text)
186
+
187
+ def _link_sub(m: re.Match[str]) -> str:
188
+ label = m.group(1) # inner label can still contain code placeholders; ok.
189
+ href = html_lib.escape(_safe_url(m.group(2)), quote=True)
190
+ title = m.group(3)
191
+ title_attr = f' title="{html_lib.escape(title, quote=True)}"' if title else ""
192
+ return f'<a href="{href}"{title_attr}>{label}</a>'
193
+
194
+ text = _RE_LINK.sub(_link_sub, text)
195
+ text = _RE_BOLD.sub(r"<strong>\1</strong>", text)
196
+ text = _RE_ITALIC.sub(r"<em>\1</em>", text)
197
+ text = _RE_ITALIC_UNDERSCORE.sub(r"<em>\1</em>", text)
198
+ text = _RE_STRIKE.sub(r"<del>\1</del>", text)
199
+
200
+ # 4. Restore placeholders.
201
+ def _restore(m: re.Match[str]) -> str:
202
+ idx = int(m.group(1))
203
+ return stash[idx]
204
+
205
+ text = re.sub(rf"{_PH_OPEN}(\d+){_PH_CLOSE}", _restore, text)
206
+ return text
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Block parsing
211
+ # ---------------------------------------------------------------------------
212
+
213
+ _RE_HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
214
+ _RE_HR = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")
215
+ _RE_CODE_FENCE = re.compile(r"^```(\w+)?\s*$")
216
+ _RE_TABLE_DIVIDER = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$")
217
+ _RE_ORDERED = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$")
218
+ _RE_UNORDERED = re.compile(r"^(\s*)[-*+]\s+(.*)$")
219
+ _RE_BLOCKQUOTE = re.compile(r"^>\s?(.*)$")
220
+ _RE_HTML_BLOCK_OPEN = re.compile(
221
+ r"^\s*<(details|div|figure|table|p|ul|ol|nav|section|aside|header|footer|main|article|blockquote)(\s|>|/>)",
222
+ re.IGNORECASE,
223
+ )
224
+
225
+ _CALLOUT_PREFIX_MAP = [
226
+ # (regex on first content, css class, default title)
227
+ (re.compile(r"^[⚠️⚠]️?\s*"), "callout-warn", "Warning"),
228
+ (re.compile(r"^💡\s*"), "callout-info", "Tip"),
229
+ (re.compile(r"^✅\s*"), "callout-good", "OK"),
230
+ (re.compile(r"^✓\s*"), "callout-good", "OK"),
231
+ (re.compile(r"^❌\s*"), "callout-bad", "Blocked"),
232
+ (re.compile(r"^🔒\s*"), "callout-good", "Guarantee"),
233
+ (re.compile(r"^📝\s*"), "callout-info", "Note"),
234
+ (re.compile(r"^🚨\s*"), "callout-bad", "Critical"),
235
+ (re.compile(r"^🛠\s*"), "callout-info", "Note"),
236
+ (re.compile(r"^🆕\s*"), "callout-info", "New"),
237
+ (re.compile(r"^⚙️⚡?\s*"), "callout-info", "Config"),
238
+ (re.compile(r"^🔁\s*"), "callout-info", "Loop"),
239
+ (re.compile(r"^🌱\s*"), "callout-info", "Note"),
240
+ (re.compile(r"^📚\s*"), "callout-info", "Reference"),
241
+ (re.compile(r"^🧬\s*"), "callout-info", "Meta"),
242
+ ]
243
+
244
+
245
+ def _slugify(text: str) -> str:
246
+ text = re.sub(r"[`*_~$]", "", text)
247
+ text = re.sub(r"\s+", "-", text.strip().lower())
248
+ text = re.sub(r"[^a-z0-9一-鿿\-]", "", text)
249
+ return text.strip("-") or "section"
250
+
251
+
252
+ def parse_blocks(lines: list[str]) -> list[dict]:
253
+ blocks: list[dict] = []
254
+ i = 0
255
+ n = len(lines)
256
+ while i < n:
257
+ line = lines[i]
258
+ stripped = line.rstrip()
259
+
260
+ # Blank line — skip.
261
+ if not stripped.strip():
262
+ i += 1
263
+ continue
264
+
265
+ # Code fence.
266
+ m = _RE_CODE_FENCE.match(stripped)
267
+ if m:
268
+ lang = (m.group(1) or "").strip()
269
+ body: list[str] = []
270
+ i += 1
271
+ while i < n and not _RE_CODE_FENCE.match(lines[i].rstrip()):
272
+ body.append(lines[i])
273
+ i += 1
274
+ if i < n: # consume closing fence
275
+ i += 1
276
+ blocks.append({"type": "code", "lang": lang, "content": "\n".join(body)})
277
+ continue
278
+
279
+ # ATX heading.
280
+ m = _RE_HEADING.match(stripped)
281
+ if m:
282
+ level = len(m.group(1))
283
+ text = m.group(2).strip()
284
+ blocks.append({"type": "heading", "level": level, "text": text})
285
+ i += 1
286
+ continue
287
+
288
+ # Horizontal rule.
289
+ if _RE_HR.match(stripped):
290
+ blocks.append({"type": "hr"})
291
+ i += 1
292
+ continue
293
+
294
+ # Table: current line has at least one `|`, AND next line is a divider.
295
+ if "|" in stripped and i + 1 < n and _RE_TABLE_DIVIDER.match(lines[i + 1].rstrip()):
296
+ header = stripped
297
+ divider = lines[i + 1].rstrip()
298
+ rows: list[str] = []
299
+ j = i + 2
300
+ while j < n and "|" in lines[j] and lines[j].strip():
301
+ rows.append(lines[j].rstrip())
302
+ j += 1
303
+ blocks.append({"type": "table", "header": header, "divider": divider, "rows": rows})
304
+ i = j
305
+ continue
306
+
307
+ # HTML block — passthrough until the closing tag is reached. Per
308
+ # CommonMark §4.6 (loosely): treat the block as ending on the first
309
+ # line that *starts* with the matching </tag>. We track depth only
310
+ # for opening/closing tags that appear at line start (after optional
311
+ # whitespace), so inline mentions like `<details>` inside backticks
312
+ # in a paragraph don't perturb the counter.
313
+ if _RE_HTML_BLOCK_OPEN.match(line):
314
+ tag_m = _RE_HTML_BLOCK_OPEN.match(line)
315
+ assert tag_m is not None
316
+ tag = tag_m.group(1).lower()
317
+ line_open_re = re.compile(rf"^\s*<{re.escape(tag)}(?=[\s/>])", re.IGNORECASE)
318
+ line_close_re = re.compile(rf"^\s*</{re.escape(tag)}(?=[\s>])", re.IGNORECASE)
319
+ body = [line]
320
+ depth = 1
321
+ # Self-closing tag on the same line: <tag .../>
322
+ if re.search(rf"<{re.escape(tag)}\b[^>]*/\s*>", line, re.IGNORECASE):
323
+ depth = 0
324
+ # Same-line full close: <tag>...</tag> or </tag> on same line as open
325
+ elif re.search(rf"</{re.escape(tag)}(?=[\s>])", line, re.IGNORECASE):
326
+ depth = 0
327
+ i += 1
328
+ while i < n and depth > 0:
329
+ cur = lines[i]
330
+ body.append(cur)
331
+ # Count nested opens/closes at line start only.
332
+ if line_open_re.match(cur):
333
+ depth += 1
334
+ if line_close_re.match(cur):
335
+ depth -= 1
336
+ i += 1
337
+ content = "\n".join(body)
338
+ # Sanitize: strip dangerous tags / event handlers / unsafe URL schemes.
339
+ content = sanitize_html(content)
340
+ blocks.append({"type": "html", "content": content})
341
+ continue
342
+
343
+ # Blockquote.
344
+ if _RE_BLOCKQUOTE.match(stripped):
345
+ quote_lines: list[str] = []
346
+ while i < n:
347
+ m_bq = _RE_BLOCKQUOTE.match(lines[i].rstrip())
348
+ if not m_bq:
349
+ break
350
+ quote_lines.append(m_bq.group(1))
351
+ i += 1
352
+ blocks.append({"type": "blockquote", "lines": quote_lines})
353
+ continue
354
+
355
+ # Ordered or unordered list. Indent-aware: a marker at the same
356
+ # indent as the first item starts a sibling; a marker at deeper
357
+ # indent (or any deeper-indented continuation line) becomes part
358
+ # of the current item's content and is re-parsed recursively in
359
+ # render_list (so nested lists / paragraphs / code blocks work).
360
+ if _RE_ORDERED.match(stripped) or _RE_UNORDERED.match(stripped):
361
+ first_m = _RE_ORDERED.match(lines[i]) or _RE_UNORDERED.match(lines[i])
362
+ base_indent = len(first_m.group(1))
363
+ ordered = bool(_RE_ORDERED.match(lines[i]))
364
+ items: list[str] = []
365
+ current: list[str] = []
366
+ while i < n:
367
+ cur_line = lines[i]
368
+ # Blank line — peek ahead to decide if list continues.
369
+ if cur_line.strip() == "":
370
+ if i + 1 < n:
371
+ nxt = lines[i + 1]
372
+ m_o_next = _RE_ORDERED.match(nxt)
373
+ m_u_next = _RE_UNORDERED.match(nxt)
374
+ if (m_o_next or m_u_next) and len(
375
+ (m_o_next or m_u_next).group(1)
376
+ ) >= base_indent:
377
+ # Continue list; consume blank.
378
+ current.append("")
379
+ i += 1
380
+ continue
381
+ if nxt.startswith(" " * (base_indent + 2)) or nxt.startswith("\t"):
382
+ current.append("")
383
+ i += 1
384
+ continue
385
+ break
386
+ m_o = _RE_ORDERED.match(cur_line)
387
+ m_u = _RE_UNORDERED.match(cur_line)
388
+ if m_o or m_u:
389
+ marker_indent = len((m_o or m_u).group(1))
390
+ if marker_indent == base_indent:
391
+ # Sibling item at same level.
392
+ if current:
393
+ items.append("\n".join(current).rstrip())
394
+ current = []
395
+ current.append((m_o or m_u).group(3 if m_o else 2))
396
+ i += 1
397
+ continue
398
+ if marker_indent > base_indent:
399
+ # Nested item — append the line with reduced indent
400
+ # (strip base_indent + 2 spaces so the nested parse
401
+ # sees the marker at column 0 of its own context).
402
+ strip = base_indent + 2
403
+ current.append(
404
+ cur_line[strip:] if cur_line[:strip].strip() == "" else cur_line.lstrip()
405
+ )
406
+ i += 1
407
+ continue
408
+ # Marker at shallower indent — list ends here.
409
+ break
410
+ # Non-marker line: continuation of current item if indented
411
+ # past base_indent + 2 (or any indent for the "lazy continuation"
412
+ # convention).
413
+ if cur_line.startswith(" " * (base_indent + 2)) or cur_line.startswith("\t"):
414
+ strip = base_indent + 2
415
+ current.append(
416
+ cur_line[strip:] if cur_line[:strip].strip() == "" else cur_line.lstrip()
417
+ )
418
+ i += 1
419
+ continue
420
+ # Anything else at base_indent or shallower → list ends.
421
+ break
422
+ if current:
423
+ items.append("\n".join(current).rstrip())
424
+ blocks.append({"type": "list", "ordered": ordered, "items": items})
425
+ continue
426
+
427
+ # Paragraph: gather lines until blank or block boundary.
428
+ para: list[str] = [stripped]
429
+ i += 1
430
+ while i < n:
431
+ nxt = lines[i].rstrip()
432
+ if not nxt.strip():
433
+ break
434
+ if (_RE_HEADING.match(nxt) or _RE_CODE_FENCE.match(nxt) or _RE_HR.match(nxt) or
435
+ _RE_BLOCKQUOTE.match(nxt) or _RE_ORDERED.match(nxt) or
436
+ _RE_UNORDERED.match(nxt) or _RE_HTML_BLOCK_OPEN.match(lines[i]) or
437
+ ("|" in nxt and i + 1 < n and _RE_TABLE_DIVIDER.match(lines[i + 1].rstrip()))):
438
+ break
439
+ para.append(nxt)
440
+ i += 1
441
+ blocks.append({"type": "paragraph", "text": " ".join(para)})
442
+ return blocks
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Block rendering
447
+ # ---------------------------------------------------------------------------
448
+
449
+
450
+ def render_table(header: str, divider: str, rows: list[str]) -> str:
451
+ def split_row(s: str) -> list[str]:
452
+ s = s.strip()
453
+ if s.startswith("|"):
454
+ s = s[1:]
455
+ if s.endswith("|"):
456
+ s = s[:-1]
457
+ return [c.strip() for c in s.split("|")]
458
+
459
+ header_cells = split_row(header)
460
+ align = []
461
+ for cell in split_row(divider):
462
+ cell = cell.strip()
463
+ if cell.startswith(":") and cell.endswith(":"):
464
+ align.append("center")
465
+ elif cell.endswith(":"):
466
+ align.append("right")
467
+ else:
468
+ align.append("left")
469
+
470
+ def cell_attr(idx: int) -> str:
471
+ if idx < len(align) and align[idx] != "left":
472
+ return f' style="text-align:{align[idx]}"'
473
+ return ""
474
+
475
+ out: list[str] = ["<table>", "<thead><tr>"]
476
+ for idx, cell in enumerate(header_cells):
477
+ out.append(f"<th{cell_attr(idx)}>{render_inline(cell)}</th>")
478
+ out.append("</tr></thead>")
479
+ out.append("<tbody>")
480
+ for row in rows:
481
+ out.append("<tr>")
482
+ for idx, cell in enumerate(split_row(row)):
483
+ out.append(f"<td{cell_attr(idx)}>{render_inline(cell)}</td>")
484
+ out.append("</tr>")
485
+ out.append("</tbody></table>")
486
+ return "".join(out)
487
+
488
+
489
+ def render_blockquote(quote_lines: list[str]) -> str:
490
+ """Detect callout style (emoji prefix) and render with callout class."""
491
+ if not quote_lines:
492
+ return "<blockquote></blockquote>"
493
+ first = quote_lines[0]
494
+ css_class = None
495
+ title = None
496
+ body_lines = list(quote_lines)
497
+ # Try matching callout prefix on the first non-empty line.
498
+ first_nonempty_idx = 0
499
+ while first_nonempty_idx < len(body_lines) and not body_lines[first_nonempty_idx].strip():
500
+ first_nonempty_idx += 1
501
+ if first_nonempty_idx < len(body_lines):
502
+ line0 = body_lines[first_nonempty_idx].lstrip()
503
+ for pattern, klass, default_title in _CALLOUT_PREFIX_MAP:
504
+ m = pattern.match(line0)
505
+ if m:
506
+ css_class = klass
507
+ title = default_title
508
+ # Strip emoji + try to extract a **Bold:** title prefix.
509
+ rest = pattern.sub("", line0, count=1)
510
+ m_title = re.match(r"\*\*([^*\n]+?)\*\*[::\s\-—]+\s*", rest)
511
+ if m_title:
512
+ title = m_title.group(1)
513
+ rest = rest[m_title.end():]
514
+ body_lines[first_nonempty_idx] = rest
515
+ break
516
+
517
+ inner_md = "\n".join(body_lines).strip("\n")
518
+ inner_blocks = parse_blocks(inner_md.split("\n")) if inner_md else []
519
+ inner_html, _ = _render_blocks(inner_blocks, collect_toc=False)
520
+ if css_class:
521
+ title_html = f'<div class="callout-title">{html_lib.escape(title or "")}</div>' if title else ""
522
+ return f'<div class="callout {css_class}">{title_html}{inner_html}</div>'
523
+ return f"<blockquote>{inner_html}</blockquote>"
524
+
525
+
526
+ def render_list(ordered: bool, items: list[str]) -> str:
527
+ tag = "ol" if ordered else "ul"
528
+ out = [f"<{tag}>"]
529
+ for item in items:
530
+ # Nested lists are possible — parse the item body as blocks.
531
+ item_blocks = parse_blocks(item.split("\n"))
532
+ # Common case: single paragraph — emit inline directly.
533
+ if len(item_blocks) == 1 and item_blocks[0]["type"] == "paragraph":
534
+ out.append(f"<li>{render_inline(item_blocks[0]['text'])}</li>")
535
+ else:
536
+ inner, _ = _render_blocks(item_blocks, collect_toc=False)
537
+ out.append(f"<li>{inner}</li>")
538
+ out.append(f"</{tag}>")
539
+ return "".join(out)
540
+
541
+
542
+ def render_code(lang: str, content: str) -> str:
543
+ escaped = html_lib.escape(content)
544
+ if lang:
545
+ return f'<pre><code class="language-{html_lib.escape(lang, quote=True)}">{escaped}</code></pre>'
546
+ # Heuristic: if content looks like an ASCII art diagram (mostly box-drawing
547
+ # chars and pipes), tag the surrounding <pre> with class="diagram".
548
+ diagram_chars = set("│─┌┐└┘├┤┬┴┼▲▼◀▶━┃┏┓┗┛╭╮╰╯═║╔╗╚╝╠╣╦╩╬║▶▼─")
549
+ sample = content[:200]
550
+ if sample and sum(1 for c in sample if c in diagram_chars) >= 4:
551
+ return f'<pre class="diagram"><code>{escaped}</code></pre>'
552
+ return f"<pre><code>{escaped}</code></pre>"
553
+
554
+
555
+ def _render_blocks(
556
+ blocks: list[dict],
557
+ collect_toc: bool,
558
+ used_ids: dict | None = None,
559
+ ) -> tuple[str, list[dict]]:
560
+ if used_ids is None:
561
+ used_ids = {}
562
+ out: list[str] = []
563
+ toc: list[dict] = []
564
+ for b in blocks:
565
+ t = b["type"]
566
+ if t == "heading":
567
+ level = b["level"]
568
+ text = b["text"]
569
+ inline = render_inline(text)
570
+ base_id = _slugify(text)
571
+ uid = base_id
572
+ n = used_ids.get(base_id, 0)
573
+ if n > 0:
574
+ uid = f"{base_id}-{n}"
575
+ used_ids[base_id] = n + 1
576
+ out.append(f'<h{level} id="{uid}">{inline}</h{level}>')
577
+ if collect_toc and 2 <= level <= 3:
578
+ toc.append({"level": level, "id": uid, "text": text})
579
+ elif t == "paragraph":
580
+ out.append(f"<p>{render_inline(b['text'])}</p>")
581
+ elif t == "hr":
582
+ out.append("<hr />")
583
+ elif t == "code":
584
+ out.append(render_code(b.get("lang", ""), b["content"]))
585
+ elif t == "blockquote":
586
+ out.append(render_blockquote(b["lines"]))
587
+ elif t == "list":
588
+ out.append(render_list(b["ordered"], b["items"]))
589
+ elif t == "table":
590
+ out.append(render_table(b["header"], b["divider"], b["rows"]))
591
+ elif t == "html":
592
+ out.append(_render_html_block(b["content"]))
593
+ else:
594
+ out.append(f"<!-- unknown block: {html_lib.escape(t)} -->")
595
+ return "\n".join(out), toc
596
+
597
+
598
+ def _render_html_block(content: str) -> str:
599
+ """For <details>...</details> blocks, parse the inner content as Markdown.
600
+
601
+ Matches GitHub-flavored convention: when a `<details>` block has a blank
602
+ line separating its `<summary>` from the body, the body is parsed as
603
+ markdown (lists, headings, code, callouts all work). For non-<details>
604
+ HTML blocks, content passes through verbatim.
605
+ """
606
+ lines = content.split("\n")
607
+ if not lines or not lines[0].lstrip().lower().startswith("<details"):
608
+ return content
609
+ # Locate opening boundary: after </summary> if present, else after the
610
+ # <details> line itself.
611
+ open_end = 1 # default: just past the <details> line
612
+ summary_close_re = re.compile(r"</summary\s*>", re.IGNORECASE)
613
+ for j in range(1, len(lines)):
614
+ if summary_close_re.search(lines[j]):
615
+ open_end = j + 1
616
+ break
617
+ # If we hit non-summary content first, no `<summary>` block; stop scanning.
618
+ if lines[j].strip() and not re.match(r"\s*<summary", lines[j], re.IGNORECASE):
619
+ break
620
+ # Locate closing boundary: the last </details> line.
621
+ close_start = len(lines) - 1
622
+ close_re = re.compile(r"^\s*</details\s*>", re.IGNORECASE)
623
+ while close_start > 0 and not close_re.match(lines[close_start]):
624
+ close_start -= 1
625
+ if open_end >= close_start:
626
+ # Degenerate or empty body; emit raw.
627
+ return content
628
+ open_part = "\n".join(lines[:open_end])
629
+ inner_md = "\n".join(lines[open_end:close_start]).strip("\n")
630
+ close_part = "\n".join(lines[close_start:])
631
+ if not inner_md.strip():
632
+ return content
633
+ inner_blocks = parse_blocks(inner_md.split("\n"))
634
+ inner_html, _ = _render_blocks(inner_blocks, collect_toc=False)
635
+ return f"{open_part}\n{inner_html}\n{close_part}"
636
+
637
+
638
+ def render_toc(toc: list[dict]) -> str:
639
+ """Render a nested TOC. Groups consecutive H3s under their preceding H2.
640
+
641
+ Orphan H3 (H3 without a preceding H2 in this run) is promoted to a
642
+ top-level <li>. Output is well-formed `<ol><li>...<ul><li>...</li></ul>...</li></ol>`.
643
+ """
644
+ if not toc:
645
+ return ""
646
+ grouped: list[tuple[dict, list[dict]]] = [] # [(h2_entry, [h3_children])]
647
+ current_parent: dict | None = None
648
+ current_children: list[dict] = []
649
+ for entry in toc:
650
+ if entry["level"] == 2:
651
+ if current_parent is not None:
652
+ grouped.append((current_parent, current_children))
653
+ current_parent = entry
654
+ current_children = []
655
+ elif entry["level"] == 3:
656
+ if current_parent is None:
657
+ # Orphan H3 — promote to top level so the TOC stays well-formed.
658
+ grouped.append((entry, []))
659
+ else:
660
+ current_children.append(entry)
661
+ if current_parent is not None:
662
+ grouped.append((current_parent, current_children))
663
+
664
+ out: list[str] = ["<ol>"]
665
+ for parent, children in grouped:
666
+ pid = html_lib.escape(parent["id"], quote=True)
667
+ ptext = html_lib.escape(parent["text"])
668
+ out.append(f'<li><a href="#{pid}">{ptext}</a>')
669
+ if children:
670
+ out.append("<ul>")
671
+ for c in children:
672
+ cid = html_lib.escape(c["id"], quote=True)
673
+ ctext = html_lib.escape(c["text"])
674
+ out.append(f'<li><a href="#{cid}">{ctext}</a></li>')
675
+ out.append("</ul>")
676
+ out.append("</li>")
677
+ out.append("</ol>")
678
+ return "\n".join(out)
679
+
680
+
681
+ def strip_frontmatter(md: str) -> str:
682
+ """Strip leading YAML frontmatter (--- ... ---) if present at start."""
683
+ md = md.lstrip("\ufeff")
684
+ lines = md.split("\n", 1)
685
+ if not lines or lines[0].strip() != "---":
686
+ return md
687
+ # find closing ---
688
+ rest = lines[1] if len(lines) > 1 else ""
689
+ parts = rest.split("\n---\n", 1)
690
+ if len(parts) == 2:
691
+ return parts[1].lstrip("\n")
692
+ # try also \n--- at the very end of a line
693
+ parts = rest.split("\n---", 1)
694
+ if len(parts) == 2 and (parts[1] == "" or parts[1].startswith("\n")):
695
+ return parts[1].lstrip("\n")
696
+ return md
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # Template loading + main
701
+ # ---------------------------------------------------------------------------
702
+
703
+ CDN_BLOCK_FULL = """<!-- MathJax 3 -->
704
+ <script>
705
+ window.MathJax = {
706
+ tex: { inlineMath: [['$', '$'], ['\\\\(', '\\\\)']], displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']], processEscapes: true },
707
+ options: { skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'] }
708
+ };
709
+ </script>
710
+ <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" async></script>
711
+
712
+ <!-- highlight.js -->
713
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-light.min.css">
714
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
715
+ <script>document.addEventListener('DOMContentLoaded', () => hljs.highlightAll());</script>
716
+ """
717
+
718
+ CDN_BLOCK_OFFLINE = "<!-- offline mode: MathJax + highlight.js skipped; math and code blocks render as plain text -->"
719
+
720
+
721
+ def load_template(name: str) -> str:
722
+ path = TEMPLATES_DIR / f"{name}.html"
723
+ if not path.exists():
724
+ raise SystemExit(
725
+ f"error: template '{name}' not found at {path}. "
726
+ f"Available: {[p.stem for p in TEMPLATES_DIR.glob('*.html')]}"
727
+ )
728
+ return path.read_text(encoding="utf-8")
729
+
730
+
731
+ def sha256_of(text: str) -> str:
732
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
733
+
734
+
735
+ def render_json_as_pre(json_path: Path) -> str:
736
+ """Render a sidecar JSON file as a collapsed <details> block of pretty-printed JSON."""
737
+ try:
738
+ obj = json.loads(json_path.read_text(encoding="utf-8"))
739
+ except json.JSONDecodeError as e:
740
+ return f'<div class="callout callout-bad"><div class="callout-title">JSON parse error</div><p>{html_lib.escape(str(e))}</p></div>'
741
+ pretty = json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=False)
742
+ return (
743
+ f'<details><summary>Sidecar JSON: <code>{html_lib.escape(str(json_path))}</code></summary>'
744
+ f'<pre><code class="language-json">{html_lib.escape(pretty)}</code></pre>'
745
+ f'</details>'
746
+ )
747
+
748
+
749
+ def substitute(template: str, vars: dict) -> str:
750
+ out = template
751
+ for k, v in vars.items():
752
+ out = out.replace("{{" + k + "}}", v)
753
+ return out
754
+
755
+
756
+ def _repo_relative(input_path: Path) -> str:
757
+ """Return a display-friendly repo-relative path. Avoids leaking absolute
758
+ /Users/<name>/... in the generated HTML meta + footer.
759
+
760
+ Order: cwd-relative if input is under cwd; else git-root-relative if in
761
+ a git repo; else basename only (parent dirs stripped) so we never
762
+ surface the home directory.
763
+ """
764
+ try:
765
+ return str(input_path.relative_to(Path.cwd()))
766
+ except ValueError:
767
+ pass
768
+ try:
769
+ import subprocess
770
+ result = subprocess.run(
771
+ ["git", "rev-parse", "--show-toplevel"],
772
+ cwd=input_path.parent,
773
+ capture_output=True,
774
+ text=True,
775
+ check=False,
776
+ )
777
+ if result.returncode == 0:
778
+ git_root = Path(result.stdout.strip())
779
+ try:
780
+ return str(input_path.relative_to(git_root))
781
+ except ValueError:
782
+ pass
783
+ except Exception:
784
+ pass
785
+ return input_path.name
786
+
787
+
788
+ def main(argv: list[str] | None = None) -> int:
789
+ ap = argparse.ArgumentParser(
790
+ description="Render an ARIS Markdown artifact to single-file HTML.",
791
+ )
792
+ ap.add_argument("input", help="Path to input .md (or .json — wrapped in a <pre>)")
793
+ ap.add_argument("--template", default="academic", choices=["academic", "dashboard"])
794
+ ap.add_argument("--out", help="Output HTML path (default: <input>.html)")
795
+ ap.add_argument("--title", help="Page title (default: first H1, or filename)")
796
+ ap.add_argument("--subtitle", default="", help="Optional italic subtitle line")
797
+ ap.add_argument("--eyebrow", default="", help="Optional uppercase eyebrow above H1")
798
+ ap.add_argument("--author", default="", help="Optional author byline (e.g., 'Name (姓名), Affiliation')")
799
+ ap.add_argument("--lang", default="zh-CN", help='<html lang="…"> attribute (default zh-CN)')
800
+ ap.add_argument("--state", help="Optional sidecar state JSON to append as <details>")
801
+ ap.add_argument("--json", dest="json_sidecar", help="Optional sidecar JSON to append (e.g., KILL_ARGUMENT.json)")
802
+ ap.add_argument("--offline", action="store_true", help="Skip MathJax / highlight.js CDN blocks")
803
+ ap.add_argument("--no-toc", action="store_true", help="Skip TOC sidebar (forces TOC_LABEL/TOC_HTML to empty)")
804
+ args = ap.parse_args(argv)
805
+
806
+ input_path = Path(args.input).resolve()
807
+ if not input_path.exists():
808
+ print(f"error: input not found: {input_path}", file=sys.stderr)
809
+ return 2
810
+ display_source_path = _repo_relative(input_path)
811
+
812
+ raw = input_path.read_text(encoding="utf-8")
813
+ source_hash = sha256_of(raw)
814
+
815
+ # If the input is JSON, wrap it as a single code block.
816
+ is_json = input_path.suffix.lower() == ".json"
817
+ if is_json:
818
+ try:
819
+ obj = json.loads(raw)
820
+ pretty = json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=False)
821
+ except json.JSONDecodeError:
822
+ pretty = raw
823
+ md_source = f"# {input_path.name}\n\n```json\n{pretty}\n```\n"
824
+ else:
825
+ md_source = strip_frontmatter(raw)
826
+
827
+ blocks = parse_blocks(md_source.split("\n"))
828
+ body_html, toc = _render_blocks(blocks, collect_toc=not args.no_toc)
829
+
830
+ # Title autodetection.
831
+ title = args.title
832
+ if not title:
833
+ # Look for first H1 block.
834
+ for b in blocks:
835
+ if b.get("type") == "heading" and b.get("level") == 1:
836
+ title = b["text"]
837
+ break
838
+ if not title:
839
+ title = input_path.stem.replace("_", " ").replace("-", " ").title()
840
+
841
+ # Append sidecar JSON if requested.
842
+ extra_html_blocks: list[str] = []
843
+ for label, path_str in (("state", args.state), ("json", args.json_sidecar)):
844
+ if path_str:
845
+ p = Path(path_str).resolve()
846
+ if p.exists():
847
+ extra_html_blocks.append(f'<h2 id="sidecar-{label}">Sidecar — <code>{html_lib.escape(p.name)}</code></h2>')
848
+ extra_html_blocks.append(render_json_as_pre(p))
849
+ else:
850
+ extra_html_blocks.append(
851
+ f'<div class="callout callout-warn"><div class="callout-title">Sidecar missing</div>'
852
+ f'<p><code>{html_lib.escape(path_str)}</code> not found.</p></div>'
853
+ )
854
+
855
+ body_html = body_html + ("\n" + "\n".join(extra_html_blocks) if extra_html_blocks else "")
856
+
857
+ template_str = load_template(args.template)
858
+
859
+ toc_html = render_toc(toc) if not args.no_toc else ""
860
+ toc_label = "Contents" if not args.no_toc else ""
861
+ if args.no_toc:
862
+ # Hide TOC entirely by emitting an empty <nav> (template has <nav>, we'd
863
+ # rather just leave structure intact; CSS won't visually break).
864
+ toc_html = ""
865
+
866
+ eyebrow_block = f'<div class="eyebrow">{html_lib.escape(args.eyebrow)}</div>' if args.eyebrow else ""
867
+ subtitle_block = f'<p class="subtitle">{html_lib.escape(args.subtitle)}</p>' if args.subtitle else ""
868
+ byline_block = (
869
+ f'<p class="byline">By <strong>{html_lib.escape(args.author)}</strong></p>'
870
+ if args.author else ""
871
+ )
872
+
873
+ generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
874
+
875
+ vars_ = {
876
+ "LANG": html_lib.escape(args.lang, quote=True),
877
+ "TITLE": html_lib.escape(title),
878
+ "SUBTITLE_BLOCK": subtitle_block,
879
+ "EYEBROW_BLOCK": eyebrow_block,
880
+ "BYLINE_BLOCK": byline_block,
881
+ "SOURCE_PATH": html_lib.escape(display_source_path),
882
+ "SOURCE_SHA256": source_hash,
883
+ "SOURCE_SHA256_SHORT": source_hash[:12],
884
+ "GENERATED_AT": generated_at,
885
+ "HEAD_CDN": CDN_BLOCK_OFFLINE if args.offline else CDN_BLOCK_FULL,
886
+ "TOC_HTML": toc_html,
887
+ "TOC_LABEL": toc_label,
888
+ "BODY_HTML": body_html,
889
+ "EXTRA_META": "",
890
+ }
891
+
892
+ rendered = substitute(template_str, vars_)
893
+
894
+ out_path = Path(args.out).resolve() if args.out else input_path.with_suffix(".html")
895
+ if out_path.is_dir():
896
+ print(
897
+ f"error: --out points to a directory: {out_path}. "
898
+ f"Specify a file path ending in .html.",
899
+ file=sys.stderr,
900
+ )
901
+ return 2
902
+ out_path.parent.mkdir(parents=True, exist_ok=True)
903
+ out_path.write_text(rendered, encoding="utf-8")
904
+ print(f"wrote {out_path} ({len(rendered):,} bytes, {len(toc)} TOC entries, source sha256 {source_hash[:12]}...)")
905
+ return 0
906
+
907
+
908
+ if __name__ == "__main__":
909
+ sys.exit(main())