skilldrop-cli 1.1.2 → 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.
- package/README.md +4 -3
- package/catalogue.json +7 -8
- package/model-routing.json +1 -17
- package/package.json +1 -1
- package/packs/ai-engineering/skills/ai-usage-report/examples/weekly-engineering-team.md +1 -1
- package/packs/ai-engineering/skills/ai-usage-report/templates/usage-event-schema.md +2 -2
- package/packs/converters/pack.json +3 -14
- package/packs/converters/skills/file-to-markdown/SKILL.md +28 -11
- package/packs/converters/skills/file-to-markdown/evals/evals.json +30 -0
- package/packs/converters/skills/file-to-markdown/evals/files/5/supplier_agreement_v3.docx +0 -0
- package/packs/converters/skills/file-to-markdown/evals/files/6/numbers.xlsx +0 -0
- package/packs/converters/skills/file-to-markdown/evals/files/6/offsite_q3.pptx +0 -0
- package/packs/converters/skills/file-to-markdown/examples/inputs/acme-brief.docx +0 -0
- package/packs/converters/skills/file-to-markdown/examples/inputs/northwind-roadmap.pptx +0 -0
- package/packs/converters/skills/file-to-markdown/examples/mixed-inputs.md +24 -16
- package/packs/converters/skills/file-to-markdown/manifest.json +2 -3
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +103 -10
- package/packs/converters/skills/md-to-confluence/SKILL.md +2 -2
- package/packs/converters/skills/md-to-confluence/manifest.json +2 -9
- package/packs/converters/skills/md-to-docx/SKILL.md +3 -4
- package/packs/converters/skills/md-to-docx/evals/evals.json +1 -1
- package/packs/converters/skills/md-to-docx/evals/files/1/brand/brand.json +1 -1
- package/packs/converters/skills/md-to-docx/manifest.json +4 -11
- package/packs/converters/skills/md-to-docx/reference.md +1 -1
- package/packs/converters/skills/md-to-docx/scripts/md_to_docx.py +1 -1
- package/packs/converters/skills/md-to-pptx/SKILL.md +3 -5
- package/packs/converters/skills/md-to-pptx/manifest.json +3 -16
- package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1 -1
- package/packs/converters/skills/md-to-xlsx/SKILL.md +1 -1
- package/packs/converters/skills/md-to-xlsx/manifest.json +2 -2
- package/packs/core/skills/brief-intake/SKILL.md +2 -2
- package/packs/core/skills/brief-intake/manifest.json +2 -2
- package/packs/core/skills/brief-intake/templates/brief-deck.md +3 -3
- package/packs/core/skills/doc-critique/rubrics/deck.md +1 -1
- package/packs/design/pack.json +11 -21
- package/packs/design/skills/brand-kit/SKILL.md +6 -8
- package/packs/design/skills/brand-kit/manifest.json +3 -17
- package/packs/design/skills/brand-kit/templates/brand.json +1 -1
- package/packs/solution-architect/skills/interactive-diagram/SKILL.md +1 -1
- package/packs/solution-architect/skills/interactive-diagram/manifest.json +1 -2
- package/packs/stakeholder-comms/skills/audience-profile/SKILL.md +3 -3
- package/packs/stakeholder-comms/skills/audience-profile/manifest.json +2 -2
- package/packs/stakeholder-comms/skills/audience-profile/reference.md +1 -1
- package/packs/converters/skills/md-to-html/SKILL.md +0 -108
- package/packs/converters/skills/md-to-html/evals/eval_queries.json +0 -34
- package/packs/converters/skills/md-to-html/evals/evals.json +0 -44
- package/packs/converters/skills/md-to-html/evals/files/1/brand/brand.json +0 -80
- package/packs/converters/skills/md-to-html/evals/files/1/brand/logo-white.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/1/brand/logo.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/1/brand/mark.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/1/docs/q3-review.md +0 -35
- package/packs/converters/skills/md-to-html/evals/files/1/img/dashboard.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/2/brand.json +0 -13
- package/packs/converters/skills/md-to-html/evals/files/2/img/kick-net.png +0 -0
- package/packs/converters/skills/md-to-html/evals/files/2/river-survey-guide.md +0 -53
- package/packs/converters/skills/md-to-html/evals/files/3/nanas-lentil-soup.md +0 -21
- package/packs/converters/skills/md-to-html/evals/files/3/pot.png +0 -0
- package/packs/converters/skills/md-to-html/examples/inputs/chart.svg +0 -8
- package/packs/converters/skills/md-to-html/examples/inputs/ops-review.md +0 -48
- package/packs/converters/skills/md-to-html/examples/ops-review.md +0 -83
- package/packs/converters/skills/md-to-html/manifest.json +0 -43
- package/packs/converters/skills/md-to-html/reference.md +0 -70
- package/packs/converters/skills/md-to-html/scripts/md_to_html.py +0 -662
- package/packs/converters/skills/mermaid-render/SKILL.md +0 -117
- package/packs/converters/skills/mermaid-render/evals/eval_queries.json +0 -30
- package/packs/converters/skills/mermaid-render/evals/evals.json +0 -43
- package/packs/converters/skills/mermaid-render/evals/files/1/docs/contoso-orders.md +0 -23
- package/packs/converters/skills/mermaid-render/evals/files/2/dispensing-diagrams.md +0 -39
- package/packs/converters/skills/mermaid-render/examples/broken-diagrams.md +0 -147
- package/packs/converters/skills/mermaid-render/examples/inputs/broken-flowchart.mmd +0 -8
- package/packs/converters/skills/mermaid-render/examples/inputs/broken-sequence.mmd +0 -11
- package/packs/converters/skills/mermaid-render/examples/inputs/checkout-flow.mmd +0 -9
- package/packs/converters/skills/mermaid-render/examples/inputs/design-notes.md +0 -35
- package/packs/converters/skills/mermaid-render/examples/inputs/fixed-flowchart.mmd +0 -9
- package/packs/converters/skills/mermaid-render/examples/inputs/typo.mmd +0 -2
- package/packs/converters/skills/mermaid-render/manifest.json +0 -50
- package/packs/converters/skills/mermaid-render/reference.md +0 -70
- package/packs/converters/skills/mermaid-render/scripts/render_mermaid.py +0 -487
- package/packs/design/skills/deck-builder/SKILL.md +0 -209
- package/packs/design/skills/deck-builder/evals/eval_queries.json +0 -14
- package/packs/design/skills/deck-builder/evals/evals.json +0 -67
- package/packs/design/skills/deck-builder/evals/files/4/ashby-vale-board-notes.md +0 -36
- package/packs/design/skills/deck-builder/examples/exec-board-update.md +0 -108
- package/packs/design/skills/deck-builder/examples/templated-brand-deck.md +0 -148
- package/packs/design/skills/deck-builder/manifest.json +0 -18
- package/packs/design/skills/deck-builder/reference.md +0 -180
- package/packs/design/skills/deck-builder/requirements.txt +0 -3
- package/packs/design/skills/deck-builder/scripts/build_deck.py +0 -1144
- package/packs/design/skills/deck-builder/templates/deck-spec.json +0 -168
- package/packs/design/skills/deck-builder/templates/palettes.json +0 -59
- package/packs/design/skills/marketing-flyer/SKILL.md +0 -113
- package/packs/design/skills/marketing-flyer/evals/eval_queries.json +0 -30
- package/packs/design/skills/marketing-flyer/evals/evals.json +0 -45
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/brand.json +0 -80
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo-white.png +0 -0
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo.png +0 -0
- package/packs/design/skills/marketing-flyer/evals/files/1/brand/mark.png +0 -0
- package/packs/design/skills/marketing-flyer/evals/files/2/brand.json +0 -14
- package/packs/design/skills/marketing-flyer/evals/files/2/larchfield-logo.svg +0 -4
- package/packs/design/skills/marketing-flyer/evals/files/2/manager-email.txt +0 -6
- package/packs/design/skills/marketing-flyer/evals/files/2/open-day-brief.md +0 -15
- package/packs/design/skills/marketing-flyer/examples/community-health-fair.md +0 -68
- package/packs/design/skills/marketing-flyer/manifest.json +0 -40
- package/packs/design/skills/marketing-flyer/reference.md +0 -54
- package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +0 -332
- 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())
|