skilldrop-cli 0.16.35 → 0.16.36

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 (212) hide show
  1. package/README.md +1 -0
  2. package/agents/README.md +4 -1
  3. package/agents/docs-reviewer.md +47 -0
  4. package/agents/finding-adjudicator.md +49 -0
  5. package/agents/test-reviewer.md +52 -0
  6. package/catalogue.json +17 -7
  7. package/model-routing.json +40 -0
  8. package/package.json +1 -1
  9. package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
  10. package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
  11. package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
  12. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
  13. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
  14. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
  15. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
  16. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
  17. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
  18. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
  19. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
  20. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
  21. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
  22. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
  23. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
  24. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
  25. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
  26. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
  27. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
  28. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
  29. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
  30. package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
  31. package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
  32. package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
  33. package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
  34. package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
  35. package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
  36. package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
  37. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
  38. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
  39. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
  40. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
  41. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
  42. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
  43. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
  44. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
  45. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
  46. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
  47. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
  48. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
  49. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
  50. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
  51. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
  52. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
  53. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
  54. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
  55. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
  56. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
  57. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
  58. package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
  59. package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
  60. package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
  61. package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
  62. package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
  63. package/packs/converters/skills/file-to-markdown/SKILL.md +2 -1
  64. package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
  65. package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
  66. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +253 -3
  67. package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
  68. package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
  69. package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
  70. package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
  71. package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
  72. package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
  73. package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
  74. package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
  75. package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
  76. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
  77. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
  78. package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
  79. package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
  80. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
  81. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
  82. package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
  83. package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
  84. package/packs/converters/skills/md-to-confluence/reference.md +92 -0
  85. package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
  86. package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
  87. package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
  88. package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
  89. package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
  90. package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
  91. package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
  92. package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
  93. package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
  94. package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
  95. package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
  96. package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
  97. package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
  98. package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
  99. package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
  100. package/packs/converters/skills/md-to-pptx/reference.md +139 -0
  101. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
  102. package/packs/dev-team/loops/modernize/LOOP.md +124 -0
  103. package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
  104. package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
  105. package/packs/dev-team/loops/modernize/loop.json +60 -0
  106. package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
  107. package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
  108. package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
  109. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
  110. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
  111. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
  112. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
  113. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
  114. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
  115. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
  116. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
  117. package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
  118. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
  119. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
  120. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
  121. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
  122. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
  123. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
  124. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
  125. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
  126. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
  127. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
  128. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
  129. package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
  130. package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
  131. package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
  132. package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
  133. package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
  134. package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
  135. package/packs/frontend/pack.json +55 -0
  136. package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
  137. package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
  138. package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
  139. package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
  140. package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
  141. package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
  142. package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
  143. package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
  144. package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
  145. package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
  146. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
  147. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
  148. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
  149. package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
  150. package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
  151. package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
  152. package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
  153. package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
  154. package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
  155. package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
  156. package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
  157. package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
  158. package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
  159. package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
  160. package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
  161. package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
  162. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
  163. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
  164. package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
  165. package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
  166. package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
  167. package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
  168. package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
  169. package/packs/frontend/skills/web-performance-review/reference.md +105 -0
  170. package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
  171. package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
  172. package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
  173. package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
  174. package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
  175. package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
  176. package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
  177. package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
  178. package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
  179. package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
  180. package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
  181. package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
  182. package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
  183. package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
  184. package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
  185. package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
  186. package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
  187. package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
  188. package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
  189. package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
  190. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
  191. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
  192. package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
  193. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
  194. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
  195. package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
  196. package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
  197. package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
  198. package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
  199. package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
  200. package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
  201. package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
  202. package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
  203. package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
  204. package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
  205. package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
  206. package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
  207. package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
  208. package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
  209. package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
  210. package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
  211. package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
  212. package/packs/sre-oncall/skills/slo-spec/templates/slo-spec.md +35 -0
@@ -0,0 +1,821 @@
1
+ #!/usr/bin/env python3
2
+ """Convert Markdown to Confluence storage format (XHTML), and optionally publish it.
3
+
4
+ Stdlib only, Python 3.9+.
5
+
6
+ Convert (no network):
7
+ python3 md_to_confluence.py runbook.md -o runbook.xhtml [--toc] [--keep-h1] [--title "..."]
8
+
9
+ Check the target page before writing (read-only GET):
10
+ ... --inspect --base-url https://<site>.atlassian.net/wiki --space SRE [--page-id N | --title "..."]
11
+
12
+ Publish (create, or update with --page-id):
13
+ ... --publish --base-url https://<site>.atlassian.net/wiki --space SRE --title "..."
14
+ [--parent-id N] [--page-id N --expected-version V] [--dry-run]
15
+
16
+ Credentials come from the environment only: CONFLUENCE_EMAIL + CONFLUENCE_API_TOKEN
17
+ (Cloud, basic auth) or CONFLUENCE_PAT (Data Center, bearer). Never from arguments or files.
18
+
19
+ Supported: ATX and setext headings, paragraphs, bold, italic, strikethrough, inline code,
20
+ links (inline, reference-style, autolinks, bare URLs), nested ordered/unordered lists, task
21
+ lists (ac:task-list), GFM pipe tables with a header row, blockquotes, callouts
22
+ (> **Note:** / > **Warning:** / > [!NOTE] ...) as info/tip/note/warning macros, fenced and
23
+ indented code as the code macro, images as page attachments, horizontal rules, and an
24
+ optional table-of-contents macro. Dropped with a WARN line: raw HTML blocks, inline HTML
25
+ tags (text kept), footnotes, in-page #anchor links (text kept), data: images, YAML front
26
+ matter (its title: is used).
27
+
28
+ Exit codes: 0 done; 1 error; 2 refused for safety (nothing was written to Confluence).
29
+ """
30
+
31
+ import argparse
32
+ import base64
33
+ import html
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import urllib.error
39
+ import urllib.parse
40
+ import urllib.request
41
+ import xml.etree.ElementTree as ET
42
+ from pathlib import Path
43
+
44
+ FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})\s*([^\s`]*)[^`]*$")
45
+ HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$")
46
+ HR_RE = re.compile(r"^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$")
47
+ SETEXT_RE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
48
+ LIST_RE = re.compile(r"^( *)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*)|[ \t]*$)")
49
+ QUOTE_RE = re.compile(r"^ {0,3}> ?(.*)$")
50
+ TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
51
+ HTML_BLOCK_RE = re.compile(r"^ {0,3}<(?:[A-Za-z][A-Za-z0-9-]*|/[A-Za-z]|!--)")
52
+ FOOTNOTE_DEF_RE = re.compile(r"^ {0,3}\[\^[^\]]+\]:")
53
+ REF_DEF_RE = re.compile(r"""^ {0,3}\[([^\]^][^\]]*)\]:\s*<?(\S+?)>?(?:\s+["'(](.*)["')])?\s*$""")
54
+ INLINE_TAG_RE = re.compile(r"<!--.*?-->|</?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?/?>", re.S)
55
+ CALLOUT_RE = re.compile(
56
+ r"^\s*(?:\*\*(note|info|tip|important|warning|caution)\s*:\s*\*\*|"
57
+ r"\*\*(note|info|tip|important|warning|caution)\*\*\s*:|"
58
+ r"\[!(note|tip|important|warning|caution)\])\s*", re.I)
59
+ # Markdown label -> Confluence macro. Confluence's own "note" macro is the yellow one.
60
+ CALLOUT_MACRO = {"note": "info", "info": "info", "tip": "tip", "important": "note",
61
+ "warning": "warning", "caution": "warning"}
62
+ # Languages the code macro highlights on both Cloud and Data Center, plus common aliases.
63
+ CODE_LANGS = {"actionscript3", "applescript", "bash", "c#", "cpp", "css", "coldfusion", "delphi",
64
+ "diff", "erl", "groovy", "xml", "java", "javafx", "js", "php", "perl", "text",
65
+ "powershell", "py", "ruby", "sql", "sass", "scala", "vb", "yml", "go", "json",
66
+ "kotlin", "typescript"}
67
+ CODE_ALIASES = {"python": "py", "python3": "py", "javascript": "js", "node": "js", "sh": "bash",
68
+ "shell": "bash", "zsh": "bash", "console": "bash", "yaml": "yml", "html": "xml",
69
+ "xhtml": "xml", "csharp": "c#", "cs": "c#", "c++": "cpp", "c": "cpp", "ts": "typescript",
70
+ "ps1": "powershell", "pwsh": "powershell", "rb": "ruby", "erlang": "erl", "golang": "go",
71
+ "kt": "kotlin", "patch": "diff", "plaintext": "text", "txt": "text", "jsonc": "json"}
72
+ # Characters XML 1.0 can't hold, and the two private-use stash markers, removed by translate().
73
+ XML_BAD = {c: None for c in [*range(0, 9), 11, 12, *range(14, 32), 0xFFFE, 0xFFFF, 0xE000, 0xE001]}
74
+ STASH_OPEN, STASH_CLOSE = "\ue000", "\ue001"
75
+ ENTITY_RE = re.compile(r"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});")
76
+
77
+
78
+ class L(str):
79
+ """A source line that remembers its line number through slicing in nested blocks."""
80
+
81
+ def __new__(cls, s, ln):
82
+ o = str.__new__(cls, s)
83
+ o.ln = ln
84
+ return o
85
+
86
+
87
+ def sub(line, text):
88
+ return L(text, getattr(line, "ln", 0))
89
+
90
+
91
+ def esc(s):
92
+ return html.escape(s, quote=True)
93
+
94
+
95
+ def cdata(text):
96
+ # "]]>" cannot appear inside CDATA: close the section after "]]" and reopen it before ">".
97
+ return "<![CDATA[%s]]>" % text.replace("]]>", "]]]]><![CDATA[>")
98
+
99
+
100
+ class Ctx:
101
+ def __init__(self, base_dir, refs):
102
+ self.base_dir = base_dir
103
+ self.refs = refs
104
+ self.line = 0
105
+ self.headings = [] # (level, plain text)
106
+ self.attachments = {} # filename -> {"src", "path", "found"}
107
+ self.task_id = 0
108
+ self.warnings = []
109
+ self.counts = {"tables": 0, "code": 0, "callouts": 0, "task_lists": 0, "images_remote": 0}
110
+
111
+ def warn(self, msg, line=None):
112
+ where = line if line is not None else self.line
113
+ text = ("line %d: %s" % (where, msg)) if where else msg
114
+ if text not in self.warnings:
115
+ self.warnings.append(text)
116
+
117
+
118
+ # ---------------------------------------------------------------- inline
119
+
120
+ def emphasis(s):
121
+ s = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"<strong>\1</strong>", s)
122
+ s = re.sub(r"(?<![\w])__(?=\S)(.+?)(?<=\S)__(?![\w])", r"<strong>\1</strong>", s)
123
+ s = re.sub(r"\*(?=\S)(.+?)(?<=\S)\*", r"<em>\1</em>", s)
124
+ s = re.sub(r"(?<![\w])_(?=\S)(.+?)(?<=\S)_(?![\w])", r"<em>\1</em>", s)
125
+ s = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r'<span style="text-decoration: line-through;">\1</span>', s)
126
+ return s
127
+
128
+
129
+ def image(src, alt, title, ctx):
130
+ attrs = (' ac:alt="%s"' % esc(alt)) if alt else ""
131
+ if title:
132
+ attrs += ' ac:title="%s"' % esc(title)
133
+ if re.match(r"^https?://", src, re.I):
134
+ ctx.counts["images_remote"] += 1
135
+ ctx.warn("remote image shown from its URL, not attached (it breaks if that site moves or "
136
+ "needs a login): %s" % src[:80])
137
+ return '<ac:image%s><ri:url ri:value="%s" /></ac:image>' % (attrs, esc(src))
138
+ if re.match(r"^[a-z][a-z0-9+.-]*:", src, re.I):
139
+ ctx.warn("image with a %s URL dropped, alt text kept" % src.split(":", 1)[0])
140
+ return esc(alt)
141
+ rel = urllib.parse.unquote(src.split("#")[0].split("?")[0])
142
+ path = (ctx.base_dir / rel).resolve()
143
+ name = path.name
144
+ seen = ctx.attachments.get(name)
145
+ if seen and seen["path"] != str(path):
146
+ ctx.warn("two different images are both named %s (%s and %s); attachments are matched by "
147
+ "file name, so both would show the same picture. Rename one" % (name, seen["src"], src))
148
+ elif not seen:
149
+ ctx.attachments[name] = {"src": src, "path": str(path), "found": path.is_file()}
150
+ if not path.is_file():
151
+ ctx.warn("image not found next to the Markdown: %s" % src)
152
+ return '<ac:image%s><ri:attachment ri:filename="%s" /></ac:image>' % (attrs, esc(name))
153
+
154
+
155
+ def link(label_html, url, title, ctx):
156
+ u = url.strip()
157
+ if u.startswith("#"):
158
+ ctx.warn("in-page link to %s kept as plain text (Confluence heading anchors are named "
159
+ "differently); the table of contents covers navigation" % u[:40])
160
+ return label_html
161
+ if re.match(r"^\s*(javascript|vbscript|data|file):", u, re.I):
162
+ ctx.warn("unsafe link target dropped, text kept: %s" % u[:60])
163
+ return label_html
164
+ t = (' title="%s"' % esc(title)) if title else ""
165
+ return '<a href="%s"%s>%s</a>' % (esc(u), t, label_html)
166
+
167
+
168
+ def inline(text, ctx):
169
+ stash = []
170
+
171
+ def put(fragment):
172
+ stash.append(fragment)
173
+ return "%s%d%s" % (STASH_OPEN, len(stash) - 1, STASH_CLOSE)
174
+
175
+ text = re.sub(r"(`+)(.+?)(?<!`)\1(?!`)", lambda m: put("<code>%s</code>" % esc(m.group(2).strip())), text)
176
+ text = re.sub(r"\\([\\`*_{}\[\]()#+\-.!|<>~])", lambda m: put(esc(m.group(1))), text)
177
+
178
+ def footnote(m):
179
+ ctx.warn("footnote reference %s dropped (footnotes are not supported)" % m.group(0))
180
+ return ""
181
+
182
+ text = re.sub(r"\[\^[^\]]+\]", footnote, text)
183
+ text = re.sub(r"<((?:https?://|mailto:)[^>\s]+)>",
184
+ lambda m: put('<a href="%s">%s</a>' % (esc(m.group(1)), esc(m.group(1)))), text)
185
+ text = re.sub(r'!\[([^\]]*)\]\(\s*<?((?:[^()\s>]|\([^()\s]*\))+)>?(?:\s+"([^"]*)")?\s*\)',
186
+ lambda m: put(image(m.group(2), m.group(1), m.group(3), ctx)), text)
187
+ text = re.sub(r'\[([^\]]+)\]\(\s*<?((?:[^()\s>]|\([^()\s]*\))+)>?(?:\s+"([^"]*)")?\s*\)',
188
+ lambda m: put(link(emphasis(esc(m.group(1))), m.group(2), m.group(3), ctx)), text)
189
+
190
+ def ref_link(m):
191
+ key = (m.group(2) or m.group(1)).strip().lower()
192
+ if key not in ctx.refs:
193
+ return m.group(0)
194
+ url, title = ctx.refs[key]
195
+ return put(link(emphasis(esc(m.group(1))), url, title, ctx))
196
+
197
+ text = re.sub(r"\[([^\]]+)\]\[([^\]]*)\]", ref_link, text)
198
+ text = re.sub(r"(?<![\w\"'=/])(https?://[^\s<>%s]+[^\s<>%s.,;:!?)\]'\"])" % (STASH_OPEN, STASH_OPEN),
199
+ lambda m: put('<a href="%s">%s</a>' % (esc(m.group(1)), esc(m.group(1)))), text)
200
+
201
+ def tag(m):
202
+ t = m.group(0)
203
+ if re.match(r"^<br\s*/?>$", t, re.I):
204
+ return put("<br />")
205
+ ctx.warn("inline HTML %s removed, text kept" % t[:30])
206
+ return ""
207
+
208
+ text = INLINE_TAG_RE.sub(tag, text)
209
+ # Decode character references (&amp; &mdash; &#8212;) outside the stashed pieces, then escape
210
+ # everything once. Only semicolon-terminated ones, so "?a=1&para=2" stays as typed.
211
+ parts = re.split("(%s\\d+%s)" % (STASH_OPEN, STASH_CLOSE), text)
212
+ text = "".join(pt if k % 2 else ENTITY_RE.sub(lambda m: html.unescape(m.group(0)), pt).translate(XML_BAD)
213
+ for k, pt in enumerate(parts))
214
+ text = emphasis(esc(text))
215
+ pat = re.compile("%s(\\d+)%s" % (STASH_OPEN, STASH_CLOSE))
216
+ for _ in range(4): # link labels can hold stashed code spans
217
+ if STASH_OPEN not in text:
218
+ break
219
+ text = pat.sub(lambda m: stash[int(m.group(1))], text)
220
+ return text
221
+
222
+
223
+ def plain(fragment):
224
+ return html.unescape(re.sub(r"<[^>]+>", "", fragment)).strip()
225
+
226
+
227
+ # ---------------------------------------------------------------- blocks
228
+
229
+ def split_row(line):
230
+ s = line.strip()
231
+ if s.startswith("|"):
232
+ s = s[1:]
233
+ if s.endswith("|") and not s.endswith("\\|"):
234
+ s = s[:-1]
235
+ cells, cur, in_code, i = [], "", False, 0
236
+ while i < len(s):
237
+ c = s[i]
238
+ if c == "\\" and i + 1 < len(s) and s[i + 1] == "|":
239
+ cur += "|"
240
+ i += 2
241
+ continue
242
+ if c == "`":
243
+ in_code = not in_code
244
+ if c == "|" and not in_code:
245
+ cells.append(cur.strip())
246
+ cur = ""
247
+ else:
248
+ cur += c
249
+ i += 1
250
+ cells.append(cur.strip())
251
+ return cells
252
+
253
+
254
+ def is_table_start(line, nxt):
255
+ return "|" in line and nxt is not None and bool(TABLE_SEP_RE.match(nxt)) and "-" in nxt
256
+
257
+
258
+ def is_block_start(line, nxt):
259
+ return bool(FENCE_RE.match(line) or HEADING_RE.match(line) or HR_RE.match(line)
260
+ or QUOTE_RE.match(line) or LIST_RE.match(line) or is_table_start(line, nxt))
261
+
262
+
263
+ def code_macro(code, lang, ctx):
264
+ ctx.counts["code"] += 1
265
+ lang = (lang or "").lower()
266
+ params = ""
267
+ if lang == "mermaid":
268
+ ctx.warn("Mermaid diagram kept as a code block; Confluence draws it only with a diagram "
269
+ "app, or embed a rendered image instead")
270
+ elif lang:
271
+ mapped = CODE_ALIASES.get(lang, lang)
272
+ if mapped in CODE_LANGS:
273
+ params = '<ac:parameter ac:name="language">%s</ac:parameter>' % esc(mapped)
274
+ else:
275
+ ctx.warn("code language '%s' has no Confluence highlighter; shown without highlighting" % lang)
276
+ return ('<ac:structured-macro ac:name="code" ac:schema-version="1">%s'
277
+ "<ac:plain-text-body>%s</ac:plain-text-body></ac:structured-macro>" % (params, cdata(code)))
278
+
279
+
280
+ def render_table(rows, aligns, ctx):
281
+ ctx.counts["tables"] += 1
282
+ width = len(aligns)
283
+
284
+ def cell(tag, text, i):
285
+ a = aligns[i] if i < len(aligns) else None
286
+ body = inline(text, ctx)
287
+ if a:
288
+ body = '<p style="text-align: %s;">%s</p>' % (a, body)
289
+ return "<%s>%s</%s>" % (tag, body, tag)
290
+
291
+ out = ["<table><tbody>"]
292
+ for r_i, r in enumerate(rows):
293
+ if len(r) != width:
294
+ ctx.warn("table row %d has %d cells, the header has %d; padded or cut" % (r_i, len(r), width))
295
+ r = (r + [""] * width)[:width]
296
+ tag = "th" if r_i == 0 else "td"
297
+ out.append("<tr>%s</tr>" % "".join(cell(tag, c, i) for i, c in enumerate(r)))
298
+ out.append("</tbody></table>")
299
+ return "".join(out)
300
+
301
+
302
+ def render_blocks(lines, ctx, tight=False):
303
+ out = []
304
+ i, n = 0, len(lines)
305
+ while i < n:
306
+ line = lines[i]
307
+ nxt = lines[i + 1] if i + 1 < n else None
308
+ if not line.strip():
309
+ i += 1
310
+ continue
311
+ ctx.line = getattr(line, "ln", 0)
312
+
313
+ m = FENCE_RE.match(line)
314
+ if m:
315
+ indent, fence, lang = len(m.group(1)), m.group(2), m.group(3)
316
+ body, i, closed = [], i + 1, False
317
+ close_re = re.compile(r"^ {0,3}%s+\s*$" % (re.escape(fence[0]) * len(fence)))
318
+ while i < n:
319
+ if close_re.match(lines[i]):
320
+ closed, i = True, i + 1
321
+ break
322
+ body.append(lines[i][indent:] if lines[i][:indent].strip() == "" else lines[i])
323
+ i += 1
324
+ if not closed:
325
+ ctx.warn("a code fence was never closed; it runs to the end of the file")
326
+ out.append(code_macro("\n".join(body), lang, ctx))
327
+ continue
328
+
329
+ if line.startswith(" ") or line.startswith("\t"):
330
+ body = []
331
+ while i < n and (lines[i].startswith(" ") or lines[i].startswith("\t") or not lines[i].strip()):
332
+ body.append(lines[i][4:] if lines[i].startswith(" ") else lines[i][1:])
333
+ i += 1
334
+ while body and not body[-1].strip():
335
+ body.pop()
336
+ out.append(code_macro("\n".join(body), "", ctx))
337
+ continue
338
+
339
+ m = HEADING_RE.match(line)
340
+ if m:
341
+ level = len(m.group(1))
342
+ content = inline(m.group(2) or "", ctx)
343
+ ctx.headings.append((level, plain(content)))
344
+ out.append("<h%d>%s</h%d>" % (level, content, level))
345
+ i += 1
346
+ continue
347
+
348
+ if HR_RE.match(line):
349
+ out.append("<hr />")
350
+ i += 1
351
+ continue
352
+
353
+ if is_table_start(line, nxt):
354
+ aligns = []
355
+ for c in split_row(nxt):
356
+ c = c.strip()
357
+ aligns.append("center" if c.startswith(":") and c.endswith(":") else
358
+ "right" if c.endswith(":") else "left" if c.startswith(":") else None)
359
+ rows = [split_row(line)]
360
+ i += 2
361
+ while i < n and lines[i].strip() and "|" in lines[i]:
362
+ ctx.line = getattr(lines[i], "ln", 0)
363
+ rows.append(split_row(lines[i]))
364
+ i += 1
365
+ out.append(render_table(rows, aligns, ctx))
366
+ continue
367
+
368
+ if QUOTE_RE.match(line):
369
+ body = []
370
+ while i < n and lines[i].strip():
371
+ q = QUOTE_RE.match(lines[i])
372
+ body.append(sub(lines[i], q.group(1) if q else lines[i]))
373
+ i += 1
374
+ cm = CALLOUT_RE.match(body[0]) if body else None
375
+ if cm:
376
+ kind = (cm.group(1) or cm.group(2) or cm.group(3)).lower()
377
+ body[0] = sub(body[0], body[0][cm.end():])
378
+ ctx.counts["callouts"] += 1
379
+ out.append('<ac:structured-macro ac:name="%s" ac:schema-version="1"><ac:rich-text-body>%s'
380
+ "</ac:rich-text-body></ac:structured-macro>"
381
+ % (CALLOUT_MACRO[kind], render_blocks(body, ctx)))
382
+ else:
383
+ out.append("<blockquote>%s</blockquote>" % render_blocks(body, ctx))
384
+ continue
385
+
386
+ if LIST_RE.match(line):
387
+ html_list, i = render_list(lines, i, ctx)
388
+ out.append(html_list)
389
+ continue
390
+
391
+ if FOOTNOTE_DEF_RE.match(line):
392
+ start = ctx.line
393
+ i += 1
394
+ while i < n and lines[i].strip() and (lines[i].startswith(" ") or lines[i].startswith("\t")):
395
+ i += 1
396
+ ctx.warn("footnote definition dropped (footnotes are not supported)", start)
397
+ continue
398
+
399
+ if HTML_BLOCK_RE.match(line):
400
+ start = ctx.line
401
+ body = []
402
+ while i < n and lines[i].strip():
403
+ body.append(lines[i])
404
+ i += 1
405
+ end = getattr(body[-1], "ln", start)
406
+ if re.match(r"^\s*<!--.*-->\s*$", "\n".join(body), re.S):
407
+ ctx.warn("HTML comment dropped", start)
408
+ else:
409
+ ctx.warn("raw HTML block dropped%s; rewrite it as Markdown to keep it"
410
+ % ((" (lines %d-%d)" % (start, end)) if end > start else ""), start)
411
+ continue
412
+
413
+ para = []
414
+ while i < n and lines[i].strip():
415
+ if para and SETEXT_RE.match(lines[i]):
416
+ break
417
+ if para and (is_block_start(lines[i], lines[i + 1] if i + 1 < n else None)
418
+ or FOOTNOTE_DEF_RE.match(lines[i]) or HTML_BLOCK_RE.match(lines[i])):
419
+ break
420
+ para.append(lines[i])
421
+ i += 1
422
+ text = "\n".join(p.lstrip() for p in para).rstrip()
423
+ if i < n and para and SETEXT_RE.match(lines[i]):
424
+ level = 1 if lines[i].strip().startswith("=") else 2
425
+ content = inline(text.replace("\n", " "), ctx)
426
+ ctx.headings.append((level, plain(content)))
427
+ out.append("<h%d>%s</h%d>" % (level, content, level))
428
+ i += 1
429
+ continue
430
+ text = re.sub(r"(?: +|\\)\n", "\x01", text)
431
+ rendered = inline(text, ctx).replace("\x01", "<br />")
432
+ out.append(rendered if tight else "<p>%s</p>" % rendered)
433
+ return "".join(out)
434
+
435
+
436
+ def render_list(lines, i, ctx):
437
+ first = LIST_RE.match(lines[i])
438
+ base_indent = len(first.group(1))
439
+ ordered = first.group(2)[0].isdigit()
440
+ start = int(first.group(2)[:-1]) if ordered else 1
441
+ if ordered and start != 1:
442
+ ctx.warn("numbered list starts at %d but Confluence restarts it at 1; indent the code block or "
443
+ "text that split the list under the previous item so it stays one list" % start)
444
+ items, loose, n = [], False, len(lines)
445
+ while i < n:
446
+ m = LIST_RE.match(lines[i])
447
+ if not m or len(m.group(1)) != base_indent or m.group(2)[0].isdigit() != ordered:
448
+ break
449
+ content_offset = len(m.group(1)) + len(m.group(2)) + 1
450
+ body = [sub(lines[i], m.group(3) or "")]
451
+ i += 1
452
+ while i < n:
453
+ ln = lines[i]
454
+ if not ln.strip():
455
+ j = i
456
+ while j < n and not lines[j].strip():
457
+ j += 1
458
+ if j < n and (len(lines[j]) - len(lines[j].lstrip(" "))) >= content_offset:
459
+ body.extend([sub(ln, "")] * (j - i))
460
+ loose, i = True, j
461
+ continue
462
+ break
463
+ ind = len(ln) - len(ln.lstrip(" "))
464
+ if ind >= content_offset:
465
+ body.append(sub(ln, ln[content_offset:]))
466
+ elif ind > base_indent and LIST_RE.match(ln):
467
+ body.append(sub(ln, ln[min(ind, content_offset):]))
468
+ elif LIST_RE.match(ln) or is_block_start(ln, lines[i + 1] if i + 1 < n else None):
469
+ break
470
+ else:
471
+ body.append(sub(ln, ln.strip())) # lazy continuation
472
+ i += 1
473
+ items.append(body)
474
+ if i < n and not lines[i].strip():
475
+ j = i
476
+ while j < n and not lines[j].strip():
477
+ j += 1
478
+ m2 = LIST_RE.match(lines[j]) if j < n else None
479
+ if m2 and len(m2.group(1)) == base_indent and m2.group(2)[0].isdigit() == ordered:
480
+ loose, i = True, j
481
+
482
+ tasks = [re.match(r"^\[([ xX])\]\s+(.*)$", b[0]) for b in items]
483
+ if all(tasks) and not ordered:
484
+ ctx.counts["task_lists"] += 1
485
+ parts = []
486
+ for tm, body in zip(tasks, items):
487
+ ctx.task_id += 1
488
+ content = render_blocks([sub(body[0], tm.group(2))] + body[1:], ctx, tight=True)
489
+ parts.append("<ac:task><ac:task-id>%d</ac:task-id><ac:task-status>%s</ac:task-status>"
490
+ "<ac:task-body>%s</ac:task-body></ac:task>"
491
+ % (ctx.task_id, "incomplete" if tm.group(1) == " " else "complete", content))
492
+ return "<ac:task-list>%s</ac:task-list>" % "".join(parts), i
493
+ if any(tasks):
494
+ ctx.warn("list mixes checkboxes with plain items; checkboxes written as text boxes, not "
495
+ "Confluence tasks. Put the tasks in a list of their own")
496
+ tag = "ol" if ordered else "ul"
497
+ parts = []
498
+ for tm, body in zip(tasks, items):
499
+ if tm:
500
+ body = [sub(body[0], ("\u2611 " if tm.group(1) != " " else "\u2610 ") + tm.group(2))] + body[1:]
501
+ parts.append("<li>%s</li>" % render_blocks(body, ctx, tight=not loose))
502
+ return "<%s>%s</%s>" % (tag, "".join(parts), tag), i
503
+
504
+
505
+ # ---------------------------------------------------------------- document
506
+
507
+ def check_well_formed(body):
508
+ doc = ('<root xmlns:ac="http://atlassian.com/content" '
509
+ 'xmlns:ri="http://atlassian.com/resource/identifier">%s</root>' % body)
510
+ ET.fromstring(doc)
511
+
512
+
513
+ def convert(src, base_dir, toc=False, keep_h1=False, title=None):
514
+ text = src.replace("\r\n", "\n").replace("\r", "\n").translate(XML_BAD)
515
+ raw = text.split("\n")
516
+ offset, fm_title = 0, None
517
+ if raw and raw[0].strip() == "---":
518
+ for k in range(1, min(len(raw), 60)):
519
+ if raw[k].strip() in ("---", "..."):
520
+ for fl in raw[1:k]:
521
+ tm = re.match(r"^title:\s*(.+?)\s*$", fl)
522
+ if tm:
523
+ fm_title = tm.group(1).strip("\"'")
524
+ offset = k + 1
525
+ break
526
+ refs, lines = {}, []
527
+ for idx, s in enumerate(raw[offset:], start=offset + 1):
528
+ rm = REF_DEF_RE.match(s)
529
+ if rm and not s.lstrip().startswith("[^"):
530
+ refs[rm.group(1).strip().lower()] = (rm.group(2), rm.group(3))
531
+ continue
532
+ lines.append(L(s, idx))
533
+ ctx = Ctx(base_dir, refs)
534
+ if offset:
535
+ ctx.warn("YAML front matter dropped (lines 1-%d)%s" % (offset, "; its title: is used" if fm_title else ""), 0)
536
+ body = render_blocks(lines, ctx)
537
+
538
+ first_h1 = ctx.headings[0][1] if ctx.headings and ctx.headings[0][0] == 1 and body.startswith("<h1>") else None
539
+ page_title = title or fm_title or first_h1
540
+ notes = []
541
+ if first_h1 and not keep_h1:
542
+ if first_h1 == page_title:
543
+ body = body[body.index("</h1>") + len("</h1>"):]
544
+ notes.append("the first heading became the page title and was removed from the body "
545
+ "(Confluence shows the title above the page); --keep-h1 keeps it")
546
+ else:
547
+ notes.append("the first heading (%s) differs from the page title (%s); kept in the body"
548
+ % (first_h1, page_title))
549
+ if toc:
550
+ body = '<ac:structured-macro ac:name="toc" ac:schema-version="1" />' + body
551
+ if len([h for h in ctx.headings if h[0] >= 2]) < 2:
552
+ notes.append("--toc added, but the page has fewer than two section headings")
553
+ check_well_formed(body)
554
+ return body, page_title, ctx, notes
555
+
556
+
557
+ # ---------------------------------------------------------------- publish
558
+
559
+ class Refused(Exception):
560
+ """A safety refusal: nothing was written to Confluence."""
561
+
562
+
563
+ class PublishError(Exception):
564
+ pass
565
+
566
+
567
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
568
+ # Following a redirect would resend the Authorization header to wherever it points.
569
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
570
+ raise PublishError("Confluence answered with a redirect to %s; not following it with your "
571
+ "credentials. Check --base-url" % newurl)
572
+
573
+
574
+ OPENER = urllib.request.build_opener(NoRedirect)
575
+
576
+ HINTS = {401: "credentials rejected: check the email and API token, or the PAT",
577
+ 403: "these credentials may not view or edit this page or space",
578
+ 404: "no such page or space, or these credentials cannot see it",
579
+ 409: "version conflict: someone saved the page meanwhile; inspect it again"}
580
+
581
+
582
+ def normalize_base(url):
583
+ p = urllib.parse.urlsplit((url or "").strip())
584
+ if p.scheme.lower() != "https":
585
+ raise Refused("refusing a non-https base URL (%s): the credentials would travel in clear text"
586
+ % (url or "none given"))
587
+ if p.username or p.password:
588
+ raise Refused("the base URL must not contain a user name or password; credentials come "
589
+ "from the environment")
590
+ if not p.hostname or p.query or p.fragment:
591
+ raise Refused("base URL should look like https://<site>.atlassian.net/wiki")
592
+ path = p.path.rstrip("/")
593
+ host = p.hostname.lower()
594
+ if host.endswith(".atlassian.net") and not path.startswith("/wiki"):
595
+ path = "/wiki" + path
596
+ return urllib.parse.urlunsplit(("https", p.netloc, path, "", "")), host
597
+
598
+
599
+ def credentials(host):
600
+ """Return (Authorization header value or None, description safe to print)."""
601
+ email = os.environ.get("CONFLUENCE_EMAIL")
602
+ token = os.environ.get("CONFLUENCE_API_TOKEN")
603
+ pat = os.environ.get("CONFLUENCE_PAT")
604
+ if email and token and (host.endswith(".atlassian.net") or not pat):
605
+ basic = base64.b64encode(("%s:%s" % (email, token)).encode("utf-8")).decode("ascii")
606
+ return "Basic " + basic, "basic auth from CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN"
607
+ if pat:
608
+ return "Bearer " + pat, "bearer token from CONFLUENCE_PAT"
609
+ return None, ("none found: set CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN (Cloud) or "
610
+ "CONFLUENCE_PAT (Data Center) in the environment")
611
+
612
+
613
+ def call(method, url, auth, payload=None):
614
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
615
+ req = urllib.request.Request(url, data=data, method=method)
616
+ req.add_header("Authorization", auth)
617
+ req.add_header("Accept", "application/json")
618
+ if data is not None:
619
+ req.add_header("Content-Type", "application/json; charset=utf-8")
620
+ try:
621
+ with OPENER.open(req, timeout=30) as r:
622
+ raw = r.read().decode("utf-8")
623
+ return json.loads(raw) if raw.strip() else {}
624
+ except urllib.error.HTTPError as e:
625
+ detail = ""
626
+ try:
627
+ detail = json.loads(e.read().decode("utf-8", "replace")).get("message", "")
628
+ except (ValueError, AttributeError):
629
+ pass
630
+ raise PublishError("%s %s failed: HTTP %d%s%s" % (method, url, e.code,
631
+ (" (%s)" % HINTS[e.code]) if e.code in HINTS else "",
632
+ (": %s" % detail[:300]) if detail else ""))
633
+ except urllib.error.URLError as e:
634
+ raise PublishError("could not reach %s: %s" % (url, e.reason))
635
+ except ValueError:
636
+ raise PublishError("%s %s answered with something that is not JSON; is --base-url the "
637
+ "Confluence address?" % (method, url))
638
+
639
+
640
+ def page_line(p, base):
641
+ v = p.get("version") or {}
642
+ by = (v.get("by") or {}).get("displayName")
643
+ web = ((p.get("_links") or {}).get("webui")) or ""
644
+ return ('id %s, "%s" in space %s, version %s%s%s%s'
645
+ % (p.get("id"), p.get("title"), (p.get("space") or {}).get("key", "?"), v.get("number", "?"),
646
+ (", last edited by %s" % by) if by else "", (" on %s" % v["when"]) if v.get("when") else "",
647
+ (", %s%s" % (base, web)) if web else ""))
648
+
649
+
650
+ def find_by_title(api, auth, space, title):
651
+ q = urllib.parse.urlencode({"spaceKey": space, "title": title, "type": "page", "expand": "version,space"})
652
+ return (call("GET", "%s?%s" % (api, q), auth).get("results") or [])
653
+
654
+
655
+ def publish(a, body, title, base, host):
656
+ api = base + "/rest/api/content"
657
+ auth, auth_desc = credentials(host)
658
+ storage = {"storage": {"value": body, "representation": "storage"}}
659
+ update = a.page_id is not None
660
+
661
+ if a.dry_run:
662
+ print("dry run: nothing was sent")
663
+ print(" %s %s" % ("PUT" if update else "POST", "%s/%s" % (api, a.page_id) if update else api))
664
+ shown = (a.title or "(kept from the page)") if update else title
665
+ print(' space %s, title "%s", parent %s' % (a.space, shown,
666
+ a.parent_id or ("unchanged" if update else "the space home page")))
667
+ print(" body: %d bytes of storage format" % len(body.encode("utf-8")))
668
+ if update:
669
+ print(" version: read from the page at publish time and sent as current + 1%s"
670
+ % ("; refused if the page is not at version %d" % a.expected_version
671
+ if a.expected_version is not None else "; no --expected-version, so any newer edit is overwritten"))
672
+ else:
673
+ print(" first checks space %s for a page with this title and stops if one exists" % a.space)
674
+ print(" auth: %s" % auth_desc)
675
+ return
676
+ if not auth:
677
+ raise Refused("no Confluence credentials: " + auth_desc.split(": ", 1)[1])
678
+
679
+ if a.inspect:
680
+ if update:
681
+ p = call("GET", "%s/%s?expand=version,space" % (api, a.page_id), auth)
682
+ print("would overwrite: " + page_line(p, base))
683
+ print("to publish over exactly this version, add --expected-version %s" % (p.get("version") or {}).get("number"))
684
+ else:
685
+ found = find_by_title(api, auth, a.space, title)
686
+ if found:
687
+ print("a page with this title already exists: " + page_line(found[0], base))
688
+ print("publishing without --page-id will refuse; to replace it, use --page-id %s" % found[0].get("id"))
689
+ else:
690
+ print('no page titled "%s" in space %s: publishing creates a new page' % (title, a.space))
691
+ if a.parent_id:
692
+ par = call("GET", "%s/%s?expand=space" % (api, a.parent_id), auth)
693
+ print("parent: " + page_line(par, base))
694
+ return
695
+
696
+ if update:
697
+ cur = call("GET", "%s/%s?expand=version,space" % (api, a.page_id), auth)
698
+ cur_space = (cur.get("space") or {}).get("key")
699
+ n = (cur.get("version") or {}).get("number")
700
+ if cur_space and cur_space != a.space:
701
+ raise Refused("page %s is in space %s, not %s; nothing was changed" % (a.page_id, cur_space, a.space))
702
+ if not isinstance(n, int):
703
+ raise PublishError("could not read the current version of page %s" % a.page_id)
704
+ if a.expected_version is not None and n != a.expected_version:
705
+ raise Refused("page %s is at version %d, but --expected-version is %d%s; nothing was changed. "
706
+ "Inspect it again and confirm before overwriting"
707
+ % (a.page_id, n, a.expected_version,
708
+ " (someone edited it since you checked)" if n > a.expected_version else ""))
709
+ keep = cur.get("title")
710
+ if not a.title and title and keep and title != keep:
711
+ print('note: kept the page\'s current title "%s"; the Markdown says "%s". Pass --title to rename it'
712
+ % (keep, title))
713
+ payload = {"id": str(a.page_id), "type": "page", "title": a.title or keep or title,
714
+ "space": {"key": cur_space or a.space}, "version": {"number": n + 1}, "body": storage}
715
+ if a.parent_id:
716
+ payload["ancestors"] = [{"id": str(a.parent_id)}]
717
+ res = call("PUT", "%s/%s" % (api, a.page_id), auth, payload)
718
+ print("updated page: version %d -> %s; %s" % (n, (res.get("version") or {}).get("number", n + 1), page_line(res, base)))
719
+ else:
720
+ found = find_by_title(api, auth, a.space, title)
721
+ if found:
722
+ raise Refused("a page titled \"%s\" already exists in %s (%s). Nothing was created. To replace "
723
+ "it, inspect it and rerun with --page-id %s" % (title, a.space, page_line(found[0], base),
724
+ found[0].get("id")))
725
+ payload = {"type": "page", "title": title, "space": {"key": a.space}, "body": storage}
726
+ if a.parent_id:
727
+ payload["ancestors"] = [{"id": str(a.parent_id)}]
728
+ res = call("POST", api, auth, payload)
729
+ print("created page: " + page_line(res, base))
730
+
731
+
732
+ # ---------------------------------------------------------------- main
733
+
734
+ def main(argv=None):
735
+ ap = argparse.ArgumentParser(description="Markdown to Confluence storage format, with an optional publish.")
736
+ ap.add_argument("input", help="Markdown file, or - for stdin")
737
+ ap.add_argument("-o", "--output", required=True, help="storage-format file to write (.xhtml)")
738
+ ap.add_argument("--toc", action="store_true", help="put a table-of-contents macro at the top")
739
+ ap.add_argument("--keep-h1", action="store_true", help="keep the first # heading in the body even when it is the page title")
740
+ ap.add_argument("--title", help="page title (default: front matter title:, else the first # heading)")
741
+ g = ap.add_argument_group("Confluence (credentials from the environment only)")
742
+ g.add_argument("--publish", action="store_true", help="create the page, or update it with --page-id")
743
+ g.add_argument("--inspect", action="store_true", help="read-only: show the page that publishing would create or overwrite")
744
+ g.add_argument("--dry-run", action="store_true", help="with --publish: print the request and send nothing")
745
+ g.add_argument("--base-url", help="https://<site>.atlassian.net/wiki, or the Data Center address")
746
+ g.add_argument("--space", help="space key, e.g. SRE")
747
+ g.add_argument("--parent-id", type=int, help="create the page under this page")
748
+ g.add_argument("--page-id", type=int, help="update this page instead of creating one")
749
+ g.add_argument("--expected-version", type=int, help="with --page-id: refuse unless the page is at exactly this version")
750
+ a = ap.parse_args(argv)
751
+
752
+ if a.input == "-":
753
+ src, base_dir = sys.stdin.read(), Path.cwd()
754
+ else:
755
+ p = Path(a.input)
756
+ if not p.is_file():
757
+ sys.exit("error: input not found: %s" % a.input)
758
+ try:
759
+ with open(p, encoding="utf-8-sig") as f:
760
+ src = f.read()
761
+ except UnicodeDecodeError:
762
+ sys.exit("error: %s is not UTF-8 text; is it really Markdown?" % a.input)
763
+ base_dir = p.resolve().parent
764
+ if not src.strip():
765
+ sys.exit("error: input is empty")
766
+ online = a.publish or a.inspect
767
+ if a.dry_run and not a.publish:
768
+ sys.exit("error: --dry-run goes with --publish")
769
+ if a.publish and a.inspect:
770
+ sys.exit("error: choose --inspect (read-only) or --publish, not both")
771
+ if online and not (a.base_url and a.space):
772
+ sys.exit("error: --publish and --inspect need --base-url and --space")
773
+ if a.expected_version is not None and a.page_id is None:
774
+ sys.exit("error: --expected-version goes with --page-id")
775
+
776
+ try:
777
+ body, title, ctx, notes = convert(src, base_dir, a.toc, a.keep_h1, a.title)
778
+ except ET.ParseError as e:
779
+ sys.exit("error: internal: the storage format came out malformed (%s); please report it" % e)
780
+ if online and not title and a.page_id is None:
781
+ sys.exit("error: no page title: pass --title, or start the Markdown with a # heading")
782
+
783
+ out = Path(a.output)
784
+ if not out.resolve().parent.is_dir():
785
+ sys.exit("error: output folder does not exist: %s" % out.parent)
786
+ with open(out, "w", encoding="utf-8") as f:
787
+ f.write(body)
788
+
789
+ c = ctx.counts
790
+ print('wrote %s (%d KB storage format): title "%s"; %d headings, %d tables, %d code blocks, '
791
+ "%d callouts, %d task lists, %d images to attach, %d remote images"
792
+ % (out, (len(body.encode("utf-8")) + 1023) // 1024, title or "(none)", len(ctx.headings), c["tables"],
793
+ c["code"], c["callouts"], c["task_lists"], len(ctx.attachments), c["images_remote"]))
794
+ for note in notes:
795
+ print("note: " + note)
796
+ if ctx.attachments:
797
+ print("upload these files to the page as attachments, with exactly these names, or the images show as broken:")
798
+ for name, info in ctx.attachments.items():
799
+ print(" %s (from %s)%s" % (name, info["src"], "" if info["found"] else " NOT FOUND"))
800
+ for w in ctx.warnings:
801
+ print("WARN %s" % w, file=sys.stderr)
802
+ if ctx.warnings:
803
+ print("%d warning(s): the items above were dropped or changed; tell the reader before publishing"
804
+ % len(ctx.warnings), file=sys.stderr)
805
+
806
+ if not online:
807
+ return 0
808
+ try:
809
+ base, host = normalize_base(a.base_url)
810
+ publish(a, body, title, base, host)
811
+ except Refused as e:
812
+ print("REFUSED: %s" % e, file=sys.stderr)
813
+ return 2
814
+ except PublishError as e:
815
+ print("error: %s" % e, file=sys.stderr)
816
+ return 1
817
+ return 0
818
+
819
+
820
+ if __name__ == "__main__":
821
+ sys.exit(main())