skilldrop-cli 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/catalogue.json +7 -8
  2. package/model-routing.json +0 -16
  3. package/package.json +1 -1
  4. package/packs/ai-engineering/skills/ai-usage-report/examples/weekly-engineering-team.md +1 -1
  5. package/packs/ai-engineering/skills/ai-usage-report/templates/usage-event-schema.md +2 -2
  6. package/packs/converters/pack.json +3 -14
  7. package/packs/converters/skills/file-to-markdown/SKILL.md +28 -11
  8. package/packs/converters/skills/file-to-markdown/evals/evals.json +30 -0
  9. package/packs/converters/skills/file-to-markdown/evals/files/5/supplier_agreement_v3.docx +0 -0
  10. package/packs/converters/skills/file-to-markdown/evals/files/6/numbers.xlsx +0 -0
  11. package/packs/converters/skills/file-to-markdown/evals/files/6/offsite_q3.pptx +0 -0
  12. package/packs/converters/skills/file-to-markdown/examples/inputs/acme-brief.docx +0 -0
  13. package/packs/converters/skills/file-to-markdown/examples/inputs/northwind-roadmap.pptx +0 -0
  14. package/packs/converters/skills/file-to-markdown/examples/mixed-inputs.md +24 -16
  15. package/packs/converters/skills/file-to-markdown/manifest.json +2 -3
  16. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +103 -10
  17. package/packs/converters/skills/md-to-confluence/SKILL.md +2 -2
  18. package/packs/converters/skills/md-to-confluence/manifest.json +2 -9
  19. package/packs/converters/skills/md-to-docx/SKILL.md +3 -4
  20. package/packs/converters/skills/md-to-docx/evals/evals.json +1 -1
  21. package/packs/converters/skills/md-to-docx/evals/files/1/brand/brand.json +1 -1
  22. package/packs/converters/skills/md-to-docx/manifest.json +4 -11
  23. package/packs/converters/skills/md-to-docx/reference.md +1 -1
  24. package/packs/converters/skills/md-to-docx/scripts/md_to_docx.py +1 -1
  25. package/packs/converters/skills/md-to-pptx/SKILL.md +3 -5
  26. package/packs/converters/skills/md-to-pptx/manifest.json +3 -16
  27. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1 -1
  28. package/packs/converters/skills/md-to-xlsx/SKILL.md +1 -1
  29. package/packs/converters/skills/md-to-xlsx/manifest.json +2 -2
  30. package/packs/core/skills/brief-intake/SKILL.md +2 -2
  31. package/packs/core/skills/brief-intake/manifest.json +2 -2
  32. package/packs/core/skills/brief-intake/templates/brief-deck.md +3 -3
  33. package/packs/core/skills/doc-critique/rubrics/deck.md +1 -1
  34. package/packs/design/pack.json +11 -21
  35. package/packs/design/skills/brand-kit/SKILL.md +6 -8
  36. package/packs/design/skills/brand-kit/manifest.json +3 -17
  37. package/packs/design/skills/brand-kit/templates/brand.json +1 -1
  38. package/packs/solution-architect/skills/interactive-diagram/SKILL.md +1 -1
  39. package/packs/solution-architect/skills/interactive-diagram/manifest.json +1 -2
  40. package/packs/stakeholder-comms/skills/audience-profile/SKILL.md +3 -3
  41. package/packs/stakeholder-comms/skills/audience-profile/manifest.json +2 -2
  42. package/packs/stakeholder-comms/skills/audience-profile/reference.md +1 -1
  43. package/packs/converters/skills/md-to-html/SKILL.md +0 -108
  44. package/packs/converters/skills/md-to-html/evals/eval_queries.json +0 -34
  45. package/packs/converters/skills/md-to-html/evals/evals.json +0 -44
  46. package/packs/converters/skills/md-to-html/evals/files/1/brand/brand.json +0 -80
  47. package/packs/converters/skills/md-to-html/evals/files/1/brand/logo-white.png +0 -0
  48. package/packs/converters/skills/md-to-html/evals/files/1/brand/logo.png +0 -0
  49. package/packs/converters/skills/md-to-html/evals/files/1/brand/mark.png +0 -0
  50. package/packs/converters/skills/md-to-html/evals/files/1/docs/q3-review.md +0 -35
  51. package/packs/converters/skills/md-to-html/evals/files/1/img/dashboard.png +0 -0
  52. package/packs/converters/skills/md-to-html/evals/files/2/brand.json +0 -13
  53. package/packs/converters/skills/md-to-html/evals/files/2/img/kick-net.png +0 -0
  54. package/packs/converters/skills/md-to-html/evals/files/2/river-survey-guide.md +0 -53
  55. package/packs/converters/skills/md-to-html/evals/files/3/nanas-lentil-soup.md +0 -21
  56. package/packs/converters/skills/md-to-html/evals/files/3/pot.png +0 -0
  57. package/packs/converters/skills/md-to-html/examples/inputs/chart.svg +0 -8
  58. package/packs/converters/skills/md-to-html/examples/inputs/ops-review.md +0 -48
  59. package/packs/converters/skills/md-to-html/examples/ops-review.md +0 -83
  60. package/packs/converters/skills/md-to-html/manifest.json +0 -43
  61. package/packs/converters/skills/md-to-html/reference.md +0 -70
  62. package/packs/converters/skills/md-to-html/scripts/md_to_html.py +0 -662
  63. package/packs/converters/skills/mermaid-render/SKILL.md +0 -117
  64. package/packs/converters/skills/mermaid-render/evals/eval_queries.json +0 -30
  65. package/packs/converters/skills/mermaid-render/evals/evals.json +0 -43
  66. package/packs/converters/skills/mermaid-render/evals/files/1/docs/contoso-orders.md +0 -23
  67. package/packs/converters/skills/mermaid-render/evals/files/2/dispensing-diagrams.md +0 -39
  68. package/packs/converters/skills/mermaid-render/examples/broken-diagrams.md +0 -147
  69. package/packs/converters/skills/mermaid-render/examples/inputs/broken-flowchart.mmd +0 -8
  70. package/packs/converters/skills/mermaid-render/examples/inputs/broken-sequence.mmd +0 -11
  71. package/packs/converters/skills/mermaid-render/examples/inputs/checkout-flow.mmd +0 -9
  72. package/packs/converters/skills/mermaid-render/examples/inputs/design-notes.md +0 -35
  73. package/packs/converters/skills/mermaid-render/examples/inputs/fixed-flowchart.mmd +0 -9
  74. package/packs/converters/skills/mermaid-render/examples/inputs/typo.mmd +0 -2
  75. package/packs/converters/skills/mermaid-render/manifest.json +0 -50
  76. package/packs/converters/skills/mermaid-render/reference.md +0 -70
  77. package/packs/converters/skills/mermaid-render/scripts/render_mermaid.py +0 -487
  78. package/packs/design/skills/deck-builder/SKILL.md +0 -209
  79. package/packs/design/skills/deck-builder/evals/eval_queries.json +0 -14
  80. package/packs/design/skills/deck-builder/evals/evals.json +0 -67
  81. package/packs/design/skills/deck-builder/evals/files/4/ashby-vale-board-notes.md +0 -36
  82. package/packs/design/skills/deck-builder/examples/exec-board-update.md +0 -108
  83. package/packs/design/skills/deck-builder/examples/templated-brand-deck.md +0 -148
  84. package/packs/design/skills/deck-builder/manifest.json +0 -18
  85. package/packs/design/skills/deck-builder/reference.md +0 -180
  86. package/packs/design/skills/deck-builder/requirements.txt +0 -3
  87. package/packs/design/skills/deck-builder/scripts/build_deck.py +0 -1144
  88. package/packs/design/skills/deck-builder/templates/deck-spec.json +0 -168
  89. package/packs/design/skills/deck-builder/templates/palettes.json +0 -59
  90. package/packs/design/skills/marketing-flyer/SKILL.md +0 -113
  91. package/packs/design/skills/marketing-flyer/evals/eval_queries.json +0 -30
  92. package/packs/design/skills/marketing-flyer/evals/evals.json +0 -45
  93. package/packs/design/skills/marketing-flyer/evals/files/1/brand/brand.json +0 -80
  94. package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo-white.png +0 -0
  95. package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo.png +0 -0
  96. package/packs/design/skills/marketing-flyer/evals/files/1/brand/mark.png +0 -0
  97. package/packs/design/skills/marketing-flyer/evals/files/2/brand.json +0 -14
  98. package/packs/design/skills/marketing-flyer/evals/files/2/larchfield-logo.svg +0 -4
  99. package/packs/design/skills/marketing-flyer/evals/files/2/manager-email.txt +0 -6
  100. package/packs/design/skills/marketing-flyer/evals/files/2/open-day-brief.md +0 -15
  101. package/packs/design/skills/marketing-flyer/examples/community-health-fair.md +0 -68
  102. package/packs/design/skills/marketing-flyer/manifest.json +0 -40
  103. package/packs/design/skills/marketing-flyer/reference.md +0 -54
  104. package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +0 -332
  105. package/packs/design/skills/marketing-flyer/templates/flyer-spec.json +0 -26
@@ -1,662 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Convert a Markdown file into one self-contained, print-friendly HTML file.
3
-
4
- Stdlib only, Python 3.9+. Local images are embedded as data URIs so the file
5
- survives being emailed. Raw HTML in the Markdown is escaped by default, because
6
- the output is usually shared with people who did not write the source.
7
-
8
- Usage:
9
- python3 md_to_html.py input.md -o out.html
10
- python3 md_to_html.py input.md -o out.html --brand brand.json --toc
11
- python3 md_to_html.py input.md -o out.html --mermaid cdn
12
-
13
- Supported: ATX headings (with anchor ids), paragraphs, bold, italic,
14
- strikethrough, inline code, fenced and indented code, blockquotes, nested
15
- ordered/unordered lists, task lists, GFM pipe tables, horizontal rules, links,
16
- autolinks, images. Not supported: setext headings, footnotes, reference-style
17
- links, definition lists. Those come through as plain text.
18
- """
19
-
20
- import argparse
21
- import base64
22
- import hashlib
23
- import html
24
- import json
25
- import mimetypes
26
- import re
27
- import sys
28
- from pathlib import Path
29
-
30
- # Pinned so the rendered output never changes under the reader. Bump deliberately.
31
- MERMAID_VERSION = "11.4.1"
32
- MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@%s/dist/mermaid.esm.min.mjs" % MERMAID_VERSION
33
- MERMAID_INIT = ('import mermaid from "%s";'
34
- 'mermaid.initialize({startOnLoad:true,securityLevel:"strict"});' % MERMAID_URL)
35
- MERMAID_INIT_HASH = base64.b64encode(hashlib.sha256(MERMAID_INIT.encode("utf-8")).digest()).decode("ascii")
36
- MAX_IMAGE_BYTES = 5 * 1024 * 1024
37
-
38
- DEFAULT_BRAND = {
39
- "primary": "#1F3A5F",
40
- "secondary": "#4A6A8A",
41
- "accent": "#C8553D",
42
- "background": "#FFFFFF",
43
- "text": "#1E1E1E",
44
- "heading_font": "Georgia, 'Times New Roman', serif",
45
- "body_font": "-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
46
- }
47
-
48
- HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
49
- FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})\s*([^\s`]*)[^`]*$")
50
- HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$")
51
- HR_RE = re.compile(r"^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$")
52
- LIST_RE = re.compile(r"^( *)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*)|[ \t]*$)")
53
- QUOTE_RE = re.compile(r"^ {0,3}> ?(.*)$")
54
- TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
55
- HTML_BLOCK_RE = re.compile(r"^ {0,3}<(?:[A-Za-z][A-Za-z0-9-]*|/[A-Za-z]|!--)")
56
- SAFE_URL_RE = re.compile(r"^(?:https?:|mailto:|tel:|#|/|\./|\.\./|[^:/?#]+(?:[/?#]|$))", re.I)
57
-
58
-
59
- def esc(s):
60
- return html.escape(s, quote=True)
61
-
62
-
63
- class Ctx:
64
- def __init__(self, base_dir, allow_html, mermaid):
65
- self.base_dir = base_dir
66
- self.allow_html = allow_html
67
- self.mermaid = mermaid
68
- self.slugs = {}
69
- self.headings = [] # (level, id, text-html)
70
- self.warnings = []
71
- self.counts = {"images_embedded": 0, "images_remote": 0, "images_missing": 0,
72
- "mermaid": 0, "tables": 0, "code_blocks": 0, "raw_html_escaped": 0}
73
-
74
- def warn(self, msg):
75
- if msg not in self.warnings:
76
- self.warnings.append(msg)
77
-
78
-
79
- # ---------------------------------------------------------------- inline
80
-
81
- def safe_url(url, ctx):
82
- u = url.strip()
83
- if u.lower().startswith("data:image/"):
84
- return u
85
- if SAFE_URL_RE.match(u) and not re.match(r"^\s*(javascript|vbscript|data):", u, re.I):
86
- return u
87
- ctx.warn("dropped an unsafe link target: %s" % u[:60])
88
- return None
89
-
90
-
91
- def embed_image(src, alt, title, ctx):
92
- t = ' title="%s"' % esc(title) if title else ""
93
- if re.match(r"^https?://", src, re.I):
94
- ctx.counts["images_remote"] += 1
95
- ctx.warn("remote image left as a link (it will not show offline): %s" % src)
96
- return '<img src="%s" alt="%s"%s>' % (esc(src), esc(alt), t)
97
- if src.lower().startswith("data:image/"):
98
- ctx.counts["images_embedded"] += 1
99
- return '<img src="%s" alt="%s"%s>' % (esc(src), esc(alt), t)
100
- path = (ctx.base_dir / src.split("#")[0].split("?")[0]).resolve()
101
- if not path.is_file():
102
- ctx.counts["images_missing"] += 1
103
- ctx.warn("image not found, alt text shown instead: %s" % src)
104
- return '<span class="missing-img">[image not found: %s]</span>' % esc(alt or src)
105
- data = path.read_bytes()
106
- if len(data) > MAX_IMAGE_BYTES:
107
- ctx.warn("large image embedded (%.1f MB), the HTML will be heavy to email: %s"
108
- % (len(data) / 1048576.0, src))
109
- mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
110
- if path.suffix.lower() == ".svg":
111
- mime = "image/svg+xml"
112
- if not mime.startswith("image/"):
113
- ctx.counts["images_missing"] += 1
114
- ctx.warn("not an image file, skipped: %s" % src)
115
- return '<span class="missing-img">[not an image: %s]</span>' % esc(alt or src)
116
- ctx.counts["images_embedded"] += 1
117
- b64 = base64.b64encode(data).decode("ascii")
118
- return '<img src="data:%s;base64,%s" alt="%s"%s>' % (mime, b64, esc(alt), t)
119
-
120
-
121
- def emphasis(s):
122
- s = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"<strong>\1</strong>", s)
123
- s = re.sub(r"(?<![\w])__(?=\S)(.+?)(?<=\S)__(?![\w])", r"<strong>\1</strong>", s)
124
- s = re.sub(r"\*(?=\S)(.+?)(?<=\S)\*", r"<em>\1</em>", s)
125
- s = re.sub(r"(?<![\w])_(?=\S)(.+?)(?<=\S)_(?![\w])", r"<em>\1</em>", s)
126
- s = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"<del>\1</del>", s)
127
- return s
128
-
129
-
130
- def inline(text, ctx):
131
- stash = []
132
-
133
- def put(fragment):
134
- stash.append(fragment)
135
- return "\x00%d\x00" % (len(stash) - 1)
136
-
137
- # code spans first: nothing inside them is Markdown
138
- text = re.sub(r"(`+)(.+?)(?<!`)\1(?!`)",
139
- lambda m: put("<code>%s</code>" % esc(m.group(2).strip())), text)
140
- # backslash escapes
141
- text = re.sub(r"\\([\\`*_{}\[\]()#+\-.!|<>~])", lambda m: put(esc(m.group(1))), text)
142
- # autolinks <https://...>
143
- text = re.sub(r"<((?:https?://|mailto:)[^>\s]+)>",
144
- lambda m: put('<a href="%s">%s</a>' % (esc(m.group(1)), esc(m.group(1)))), text)
145
-
146
- def img(m):
147
- return put(embed_image(m.group(2), m.group(1), m.group(3), ctx))
148
-
149
- text = re.sub(r'!\[([^\]]*)\]\(\s*<?((?:[^()\s>]|\([^()\s]*\))+)>?(?:\s+"([^"]*)")?\s*\)', img, text)
150
-
151
- def lnk(m):
152
- label = emphasis(esc(m.group(1)))
153
- url = safe_url(m.group(2), ctx)
154
- if url is None:
155
- return put(label)
156
- t = ' title="%s"' % esc(m.group(3)) if m.group(3) else ""
157
- return put('<a href="%s"%s>%s</a>' % (esc(url), t, label))
158
-
159
- text = re.sub(r'\[([^\]]+)\]\(\s*<?((?:[^()\s>]|\([^()\s]*\))+)>?(?:\s+"([^"]*)")?\s*\)', lnk, text)
160
- # bare URLs
161
- text = re.sub(r"(?<![\w\"'=/])(https?://[^\s<>\x00]+[^\s<>\x00.,;:!?)\]'\"])",
162
- lambda m: put('<a href="%s">%s</a>' % (esc(m.group(1)), esc(m.group(1)))), text)
163
- if "<" in text and re.search(r"<[A-Za-z/!]", text):
164
- ctx.counts["raw_html_escaped"] += 1
165
- text = emphasis(esc(text))
166
- for _ in range(4): # link labels can hold stashed code spans
167
- if "\x00" not in text:
168
- break
169
- text = re.sub(r"\x00(\d+)\x00", lambda m: stash[int(m.group(1))], text)
170
- return text
171
-
172
-
173
- def plain(html_text):
174
- return html.unescape(re.sub(r"<[^>]+>", "", html_text)).strip()
175
-
176
-
177
- def slugify(text, ctx):
178
- base = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.U).strip()
179
- base = re.sub(r"[\s_]+", "-", base).strip("-") or "section"
180
- n = ctx.slugs.get(base, 0)
181
- ctx.slugs[base] = n + 1
182
- return base if n == 0 else "%s-%d" % (base, n)
183
-
184
-
185
- # ---------------------------------------------------------------- blocks
186
-
187
- def split_row(line):
188
- s = line.strip()
189
- if s.startswith("|"):
190
- s = s[1:]
191
- if s.endswith("|") and not s.endswith("\\|"):
192
- s = s[:-1]
193
- cells, cur, in_code, i = [], "", False, 0
194
- while i < len(s):
195
- c = s[i]
196
- if c == "\\" and i + 1 < len(s) and s[i + 1] == "|":
197
- cur += "|"
198
- i += 2
199
- continue
200
- if c == "`":
201
- in_code = not in_code
202
- if c == "|" and not in_code:
203
- cells.append(cur.strip())
204
- cur = ""
205
- else:
206
- cur += c
207
- i += 1
208
- cells.append(cur.strip())
209
- return cells
210
-
211
-
212
- def is_block_start(line, next_line):
213
- return bool(FENCE_RE.match(line) or HEADING_RE.match(line) or HR_RE.match(line)
214
- or QUOTE_RE.match(line) or LIST_RE.match(line)
215
- or ("|" in line and next_line is not None and TABLE_SEP_RE.match(next_line)
216
- and "-" in next_line))
217
-
218
-
219
- def render_table(rows, aligns, ctx):
220
- ctx.counts["tables"] += 1
221
- out = ['<div class="table-wrap"><table>', "<thead><tr>"]
222
- width = len(aligns)
223
-
224
- def cell(tag, text, i):
225
- a = aligns[i] if i < len(aligns) else None
226
- style = ' style="text-align:%s"' % a if a else ""
227
- return "<%s%s>%s</%s>" % (tag, style, inline(text, ctx), tag)
228
-
229
- head = (rows[0] + [""] * width)[:width]
230
- out.append("".join(cell("th", c, i) for i, c in enumerate(head)))
231
- out.append("</tr></thead><tbody>")
232
- for r in rows[1:]:
233
- r = (r + [""] * width)[:width]
234
- out.append("<tr>%s</tr>" % "".join(cell("td", c, i) for i, c in enumerate(r)))
235
- out.append("</tbody></table></div>")
236
- return "".join(out)
237
-
238
-
239
- def render_blocks(lines, ctx, tight=False):
240
- out = []
241
- i, n = 0, len(lines)
242
- while i < n:
243
- line = lines[i]
244
- nxt = lines[i + 1] if i + 1 < n else None
245
- if not line.strip():
246
- i += 1
247
- continue
248
-
249
- m = FENCE_RE.match(line)
250
- if m:
251
- indent, fence, lang = len(m.group(1)), m.group(2), m.group(3).lower()
252
- body, i = [], i + 1
253
- closed = False
254
- while i < n:
255
- if re.match(r"^ {0,3}%s%s*\s*$" % (re.escape(fence[0]) * len(fence), re.escape(fence[0])),
256
- lines[i]):
257
- closed = True
258
- i += 1
259
- break
260
- body.append(lines[i][indent:] if lines[i][:indent].strip() == "" else lines[i])
261
- i += 1
262
- if not closed:
263
- ctx.warn("a code fence was never closed; it runs to the end of the file")
264
- code = "\n".join(body)
265
- if lang == "mermaid":
266
- ctx.counts["mermaid"] += 1
267
- out.append('<pre class="mermaid">%s</pre>' % esc(code))
268
- else:
269
- ctx.counts["code_blocks"] += 1
270
- cls = ' class="language-%s"' % esc(lang) if lang else ""
271
- out.append("<pre><code%s>%s</code></pre>" % (cls, esc(code)))
272
- continue
273
-
274
- if line.startswith(" ") or line.startswith("\t"):
275
- body = []
276
- while i < n and (lines[i].startswith(" ") or lines[i].startswith("\t") or not lines[i].strip()):
277
- body.append(lines[i][4:] if lines[i].startswith(" ") else lines[i][1:])
278
- i += 1
279
- while body and not body[-1].strip():
280
- body.pop()
281
- ctx.counts["code_blocks"] += 1
282
- out.append("<pre><code>%s</code></pre>" % esc("\n".join(body)))
283
- continue
284
-
285
- m = HEADING_RE.match(line)
286
- if m:
287
- level = len(m.group(1))
288
- content = inline(m.group(2) or "", ctx)
289
- hid = slugify(plain(content), ctx)
290
- ctx.headings.append((level, hid, content))
291
- out.append('<h%d id="%s">%s<a class="anchor" href="#%s" aria-hidden="true">#</a></h%d>'
292
- % (level, hid, content, hid, level))
293
- i += 1
294
- continue
295
-
296
- if HR_RE.match(line):
297
- out.append("<hr>")
298
- i += 1
299
- continue
300
-
301
- if "|" in line and nxt is not None and TABLE_SEP_RE.match(nxt) and "-" in nxt:
302
- aligns = []
303
- for c in split_row(nxt):
304
- c = c.strip()
305
- if c.startswith(":") and c.endswith(":"):
306
- aligns.append("center")
307
- elif c.endswith(":"):
308
- aligns.append("right")
309
- elif c.startswith(":"):
310
- aligns.append("left")
311
- else:
312
- aligns.append(None)
313
- rows = [split_row(line)]
314
- i += 2
315
- while i < n and lines[i].strip() and "|" in lines[i]:
316
- rows.append(split_row(lines[i]))
317
- i += 1
318
- out.append(render_table(rows, aligns, ctx))
319
- continue
320
-
321
- if QUOTE_RE.match(line):
322
- body = []
323
- while i < n and lines[i].strip():
324
- q = QUOTE_RE.match(lines[i])
325
- body.append(q.group(1) if q else lines[i])
326
- i += 1
327
- out.append("<blockquote>%s</blockquote>" % render_blocks(body, ctx))
328
- continue
329
-
330
- m = LIST_RE.match(line)
331
- if m:
332
- html_list, i = render_list(lines, i, ctx)
333
- out.append(html_list)
334
- continue
335
-
336
- if HTML_BLOCK_RE.match(line):
337
- body = []
338
- while i < n and lines[i].strip():
339
- body.append(lines[i])
340
- i += 1
341
- if ctx.allow_html:
342
- out.append("\n".join(body))
343
- else:
344
- ctx.counts["raw_html_escaped"] += 1
345
- out.append('<pre class="raw-html">%s</pre>' % esc("\n".join(body)))
346
- continue
347
-
348
- para = []
349
- while i < n and lines[i].strip():
350
- if para and is_block_start(lines[i], lines[i + 1] if i + 1 < n else None):
351
- break
352
- para.append(lines[i])
353
- i += 1
354
- text = "\n".join(p.lstrip() for p in para).rstrip()
355
- text = re.sub(r"(?: +|\\)\n", "\x01", text)
356
- rendered = inline(text, ctx).replace("\x01", "<br>\n")
357
- out.append(rendered if tight else "<p>%s</p>" % rendered)
358
- return "\n".join(out)
359
-
360
-
361
- def render_list(lines, i, ctx):
362
- first = LIST_RE.match(lines[i])
363
- base_indent = len(first.group(1))
364
- ordered = first.group(2)[0].isdigit()
365
- start = int(first.group(2)[:-1]) if ordered else 1
366
- items, loose = [], False
367
- n = len(lines)
368
- while i < n:
369
- m = LIST_RE.match(lines[i])
370
- if not m or len(m.group(1)) != base_indent or m.group(2)[0].isdigit() != ordered:
371
- break
372
- content_offset = len(m.group(1)) + len(m.group(2)) + 1
373
- body = [m.group(3) or ""]
374
- i += 1
375
- while i < n:
376
- ln = lines[i]
377
- if not ln.strip():
378
- # blank line: item continues only if the next non-blank line is indented
379
- j = i
380
- while j < n and not lines[j].strip():
381
- j += 1
382
- if j < n and (len(lines[j]) - len(lines[j].lstrip(" "))) >= content_offset:
383
- body.extend([""] * (j - i))
384
- loose = True
385
- i = j
386
- continue
387
- break
388
- ind = len(ln) - len(ln.lstrip(" "))
389
- if ind >= content_offset:
390
- body.append(ln[content_offset:])
391
- elif ind > base_indent and LIST_RE.match(ln):
392
- body.append(ln[min(ind, content_offset):])
393
- elif LIST_RE.match(ln) or is_block_start(ln, lines[i + 1] if i + 1 < n else None):
394
- break
395
- else:
396
- body.append(ln.strip()) # lazy continuation
397
- i += 1
398
- items.append(body)
399
- # blank line between items makes the list loose
400
- if i < n and not lines[i].strip():
401
- j = i
402
- while j < n and not lines[j].strip():
403
- j += 1
404
- m2 = LIST_RE.match(lines[j]) if j < n else None
405
- if m2 and len(m2.group(1)) == base_indent and m2.group(2)[0].isdigit() == ordered:
406
- loose = True
407
- i = j
408
- tag = "ol" if ordered else "ul"
409
- attr = ' start="%d"' % start if ordered and start != 1 else ""
410
- parts = []
411
- for body in items:
412
- check = ""
413
- tm = re.match(r"^\[([ xX])\]\s+(.*)$", body[0])
414
- if tm:
415
- body = [tm.group(2)] + body[1:]
416
- check = '<input type="checkbox" disabled%s> ' % (" checked" if tm.group(1) != " " else "")
417
- parts.append("<li%s>%s%s</li>" % (' class="task"' if check else "", check,
418
- render_blocks(body, ctx, tight=not loose)))
419
- return "<%s%s>\n%s\n</%s>" % (tag, attr, "\n".join(parts), tag), i
420
-
421
-
422
- # ---------------------------------------------------------------- brand + page
423
-
424
- def css_font(s):
425
- return re.sub(r"[;{}<>\\]", "", s)
426
-
427
-
428
- def load_brand(path, ctx):
429
- b = dict(DEFAULT_BRAND)
430
- b["logo"] = None
431
- b["footer"] = ""
432
- b["name"] = ""
433
- if not path:
434
- return b
435
- p = Path(path)
436
- if not p.is_file():
437
- sys.exit("error: brand file not found: %s" % path)
438
- try:
439
- data = json.loads(p.read_text(encoding="utf-8"))
440
- except ValueError as e:
441
- sys.exit("error: brand file is not valid JSON: %s (%s)" % (path, e))
442
- if not isinstance(data, dict):
443
- sys.exit("error: brand file must hold a JSON object (brand-kit shape), not a %s: %s"
444
- % (type(data).__name__, path))
445
- colors = data.get("colors") or {}
446
- for key in ("primary", "secondary", "accent", "background", "text"):
447
- v = colors.get(key)
448
- if v:
449
- if HEX_RE.match(str(v)):
450
- b[key] = v
451
- else:
452
- ctx.warn("brand colour %s=%r is not a hex code; using the default" % (key, v))
453
- fonts = data.get("fonts") or {}
454
- for key, out_key in (("heading", "heading_font"), ("body", "body_font")):
455
- f = fonts.get(key) or {}
456
- if f.get("family"):
457
- fam = css_font(str(f["family"]))
458
- fb = css_font(str(f.get("fallback") or "sans-serif"))
459
- b[out_key] = "'%s', %s" % (fam.replace("'", ""), fb)
460
- logo = (data.get("logo") or {}).get("primary")
461
- if logo:
462
- lp = (p.parent / logo).resolve()
463
- if lp.is_file():
464
- b["logo"] = embed_image(str(lp), data.get("name") or "logo", None, ctx)
465
- else:
466
- ctx.warn("brand logo not found, header shown without it: %s" % logo)
467
- b["footer"] = (data.get("legal") or {}).get("footer") or ""
468
- b["name"] = data.get("name") or ""
469
- return b
470
-
471
-
472
- CSS = """
473
- :root{--primary:%(primary)s;--secondary:%(secondary)s;--accent:%(accent)s;--bg:%(background)s;--text:%(text)s;}
474
- *{box-sizing:border-box}
475
- html{background:var(--bg)}
476
- body{margin:0;color:var(--text);background:var(--bg);font-family:%(body_font)s;font-size:16px;line-height:1.6}
477
- main{max-width:46rem;margin:0 auto;padding:2.5rem 1.25rem 4rem}
478
- header.brand{display:flex;align-items:center;gap:.75rem;border-bottom:3px solid var(--primary);padding-bottom:.75rem;margin-bottom:1.5rem}
479
- header.brand img{max-height:40px;width:auto}
480
- h1,h2,h3,h4,h5,h6{font-family:%(heading_font)s;color:var(--primary);line-height:1.25;margin:1.8em 0 .6em}
481
- h1{font-size:2.1rem;margin-top:0}h2{font-size:1.55rem;border-bottom:1px solid #e3e3e3;padding-bottom:.25em}h3{font-size:1.25rem}
482
- h4,h5,h6{font-size:1.05rem}
483
- a{color:var(--secondary)}
484
- a.anchor{color:#bbb;text-decoration:none;margin-left:.4em;font-size:.8em;visibility:hidden}
485
- h1:hover a.anchor,h2:hover a.anchor,h3:hover a.anchor,h4:hover a.anchor{visibility:visible}
486
- code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em;background:#f3f4f6;padding:.1em .3em;border-radius:3px}
487
- pre{background:#f6f8fa;border:1px solid #e3e6ea;border-radius:6px;padding:.9rem 1rem;overflow-x:auto;line-height:1.45}
488
- pre code{background:none;padding:0;font-size:.88em}
489
- pre.mermaid{background:#fff;text-align:center}
490
- pre.raw-html{border-style:dashed}
491
- blockquote{margin:1.2em 0;padding:.4em 1em;border-left:4px solid var(--accent);color:#444;background:#fafafa}
492
- blockquote p{margin:.4em 0}
493
- .table-wrap{overflow-x:auto;margin:1.2em 0}
494
- table{border-collapse:collapse;width:100%%;font-size:.95em}
495
- th,td{border:1px solid #d9dde3;padding:.45em .7em;vertical-align:top;text-align:left}
496
- thead th{background:var(--primary);color:#fff}
497
- tbody tr:nth-child(even){background:#f7f8fa}
498
- img{max-width:100%%;height:auto}
499
- hr{border:0;border-top:1px solid #ddd;margin:2em 0}
500
- li.task{list-style:none}li.task input{margin:0 .4em 0 -1.3em}
501
- .missing-img{color:#a33;font-style:italic}
502
- nav.toc{border:1px solid #e3e3e3;border-radius:6px;padding:.75rem 1.25rem;margin:1.5rem 0;background:#fbfbfb}
503
- nav.toc p{margin:0 0 .4em;font-weight:600}
504
- nav.toc ul{margin:0;padding-left:1.1em}nav.toc ul ul{padding-left:1.2em}
505
- footer{margin-top:3rem;border-top:1px solid #e3e3e3;padding-top:.75rem;font-size:.85em;color:#666}
506
- @media print{
507
- @page{margin:18mm 16mm}
508
- html,body{background:#fff}
509
- body{font-size:11pt}
510
- main{max-width:none;padding:0}
511
- a{color:var(--text);text-decoration:underline}
512
- a[href^="http"]::after{content:" (" attr(href) ")";font-size:.85em;color:#555;word-break:break-all}
513
- a.anchor{display:none}
514
- h1,h2,h3,h4{break-after:avoid;page-break-after:avoid}
515
- pre,blockquote,table,img,figure,nav.toc{break-inside:avoid;page-break-inside:avoid}
516
- pre{white-space:pre-wrap;word-break:break-word;overflow:visible}
517
- .table-wrap{overflow:visible}
518
- thead{display:table-header-group}
519
- thead th,tbody tr:nth-child(even),pre,code{-webkit-print-color-adjust:exact;print-color-adjust:exact}
520
- }
521
- """
522
-
523
-
524
- def build_toc(headings):
525
- items = [(lvl, hid, txt) for (lvl, hid, txt) in headings if lvl in (2, 3)]
526
- if not items:
527
- return ""
528
- out = ['<nav class="toc" aria-label="Contents"><p>Contents</p><ul>']
529
- depth = 2
530
- first = True
531
- seen_h2 = False
532
- for lvl, hid, txt in items:
533
- seen_h2 = seen_h2 or lvl == 2
534
- if not seen_h2:
535
- lvl = 2 # a ### before any ## has no parent to nest under: list it at the top level
536
- link = '<a href="#%s">%s</a>' % (hid, re.sub(r"<a [^>]*>|</a>", "", txt))
537
- if lvl > depth:
538
- out.append("<ul>")
539
- depth = lvl
540
- elif lvl < depth:
541
- out.append("</li></ul></li>")
542
- depth = lvl
543
- elif not first:
544
- out.append("</li>")
545
- out.append("<li>%s" % link)
546
- first = False
547
- out.append("</li>")
548
- if depth == 3:
549
- out.append("</ul></li>")
550
- out.append("</ul></nav>")
551
- return "".join(out)
552
-
553
-
554
- def convert(src_text, base_dir, brand_path=None, toc=False, mermaid="none", allow_html=False):
555
- # \x00 and \x01 mark the converter's own placeholders (code spans, line breaks); a source
556
- # that contains them would collide with those markers, and neither belongs in a document.
557
- src_text = src_text.replace("\x00", "").replace("\x01", "")
558
- ctx = Ctx(base_dir, allow_html, mermaid)
559
- brand = load_brand(brand_path, ctx)
560
- lines = src_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
561
- # drop a leading YAML front-matter block
562
- if lines and lines[0].strip() == "---":
563
- for k in range(1, min(len(lines), 60)):
564
- if lines[k].strip() in ("---", "..."):
565
- lines = lines[k + 1:]
566
- break
567
- body = render_blocks(lines, ctx)
568
- title = plain(ctx.headings[0][2]) if ctx.headings else (brand["name"] or "Document")
569
-
570
- if toc:
571
- toc_html = build_toc(ctx.headings)
572
- if not toc_html:
573
- ctx.warn("--toc asked for, but the document has no level-2 or level-3 headings")
574
- elif body.startswith("<h1"):
575
- cut = body.index("</h1>") + len("</h1>")
576
- body = body[:cut] + "\n" + toc_html + body[cut:]
577
- else:
578
- body = toc_html + "\n" + body
579
-
580
- draw = mermaid == "cdn" and ctx.counts["mermaid"]
581
- # The page may run exactly one script, the pinned Mermaid loader, identified by its hash.
582
- csp_script = ("script-src 'sha256-%s' https://cdn.jsdelivr.net; " % MERMAID_INIT_HASH) if draw else ""
583
- csp = ("default-src 'none'; img-src data: https:; style-src 'unsafe-inline'; font-src data: https:; "
584
- "%sconnect-src https://cdn.jsdelivr.net" % csp_script)
585
- head = [
586
- "<!DOCTYPE html>",
587
- '<html lang="en">',
588
- "<head>",
589
- '<meta charset="utf-8">',
590
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
591
- '<meta http-equiv="Content-Security-Policy" content="%s">' % csp,
592
- '<meta name="generator" content="skilldrop md-to-html">',
593
- "<title>%s</title>" % esc(title),
594
- "<style>%s</style>" % (CSS % brand),
595
- "</head>",
596
- "<body>",
597
- "<main>",
598
- ]
599
- if brand["logo"]:
600
- head.append('<header class="brand">%s</header>' % brand["logo"])
601
- tail = []
602
- if brand["footer"]:
603
- tail.append("<footer>%s</footer>" % esc(brand["footer"]))
604
- tail.append("</main>")
605
- if draw:
606
- tail.append('<script type="module">%s</script>' % MERMAID_INIT)
607
- elif ctx.counts["mermaid"]:
608
- ctx.warn("%d Mermaid diagram(s) kept as text; use --mermaid cdn to draw them "
609
- "(needs network when opened), or render them to SVG with mermaid-render"
610
- % ctx.counts["mermaid"])
611
- tail += ["</body>", "</html>", ""]
612
- return "\n".join(head + [body] + tail), ctx
613
-
614
-
615
- def main(argv=None):
616
- ap = argparse.ArgumentParser(description="Markdown to one self-contained, print-friendly HTML file.")
617
- ap.add_argument("input", help="Markdown file, or - for stdin")
618
- ap.add_argument("-o", "--output", required=True, help="HTML file to write")
619
- ap.add_argument("--brand", help="brand.json (brand-kit shape): colours, fonts, logo, footer")
620
- ap.add_argument("--toc", action="store_true", help="add a table of contents from level-2 and level-3 headings")
621
- ap.add_argument("--mermaid", choices=("none", "cdn"), default="none",
622
- help="none (default): keep diagrams as text. cdn: draw them with mermaid %s "
623
- "from jsDelivr, which needs network when the file is opened" % MERMAID_VERSION)
624
- ap.add_argument("--allow-html", action="store_true",
625
- help="pass raw HTML blocks through instead of escaping them (only for your own trusted source)")
626
- a = ap.parse_args(argv)
627
-
628
- if a.input == "-":
629
- text, base = sys.stdin.read(), Path.cwd()
630
- else:
631
- p = Path(a.input)
632
- if not p.is_file():
633
- sys.exit("error: input not found: %s" % a.input)
634
- try:
635
- text = p.read_text(encoding="utf-8-sig")
636
- except UnicodeDecodeError:
637
- sys.exit("error: %s is not UTF-8 text; is it really Markdown?" % a.input)
638
- base = p.resolve().parent
639
- if not text.strip():
640
- sys.exit("error: input is empty")
641
-
642
- out_html, ctx = convert(text, base, a.brand, a.toc, a.mermaid, a.allow_html)
643
- out = Path(a.output)
644
- if out.parent and not out.parent.exists():
645
- sys.exit("error: output folder does not exist: %s" % out.parent)
646
- out.write_text(out_html, encoding="utf-8")
647
-
648
- c = ctx.counts
649
- print("wrote %s (%d KB): %d headings, %d tables, %d code blocks, %d Mermaid, "
650
- "%d images embedded, %d remote, %d missing"
651
- % (out, len(out_html.encode("utf-8")) // 1024, len(ctx.headings), c["tables"], c["code_blocks"],
652
- c["mermaid"], c["images_embedded"], c["images_remote"], c["images_missing"]))
653
- if c["raw_html_escaped"] and not a.allow_html:
654
- ctx.warn("raw HTML found and shown as text (%d place(s)); this is deliberate, see --allow-html"
655
- % c["raw_html_escaped"])
656
- for w in ctx.warnings:
657
- print("warning: %s" % w, file=sys.stderr)
658
- return 0
659
-
660
-
661
- if __name__ == "__main__":
662
- sys.exit(main())