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.
Files changed (106) hide show
  1. package/README.md +4 -3
  2. package/catalogue.json +7 -8
  3. package/model-routing.json +1 -17
  4. package/package.json +1 -1
  5. package/packs/ai-engineering/skills/ai-usage-report/examples/weekly-engineering-team.md +1 -1
  6. package/packs/ai-engineering/skills/ai-usage-report/templates/usage-event-schema.md +2 -2
  7. package/packs/converters/pack.json +3 -14
  8. package/packs/converters/skills/file-to-markdown/SKILL.md +28 -11
  9. package/packs/converters/skills/file-to-markdown/evals/evals.json +30 -0
  10. package/packs/converters/skills/file-to-markdown/evals/files/5/supplier_agreement_v3.docx +0 -0
  11. package/packs/converters/skills/file-to-markdown/evals/files/6/numbers.xlsx +0 -0
  12. package/packs/converters/skills/file-to-markdown/evals/files/6/offsite_q3.pptx +0 -0
  13. package/packs/converters/skills/file-to-markdown/examples/inputs/acme-brief.docx +0 -0
  14. package/packs/converters/skills/file-to-markdown/examples/inputs/northwind-roadmap.pptx +0 -0
  15. package/packs/converters/skills/file-to-markdown/examples/mixed-inputs.md +24 -16
  16. package/packs/converters/skills/file-to-markdown/manifest.json +2 -3
  17. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +103 -10
  18. package/packs/converters/skills/md-to-confluence/SKILL.md +2 -2
  19. package/packs/converters/skills/md-to-confluence/manifest.json +2 -9
  20. package/packs/converters/skills/md-to-docx/SKILL.md +3 -4
  21. package/packs/converters/skills/md-to-docx/evals/evals.json +1 -1
  22. package/packs/converters/skills/md-to-docx/evals/files/1/brand/brand.json +1 -1
  23. package/packs/converters/skills/md-to-docx/manifest.json +4 -11
  24. package/packs/converters/skills/md-to-docx/reference.md +1 -1
  25. package/packs/converters/skills/md-to-docx/scripts/md_to_docx.py +1 -1
  26. package/packs/converters/skills/md-to-pptx/SKILL.md +3 -5
  27. package/packs/converters/skills/md-to-pptx/manifest.json +3 -16
  28. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1 -1
  29. package/packs/converters/skills/md-to-xlsx/SKILL.md +1 -1
  30. package/packs/converters/skills/md-to-xlsx/manifest.json +2 -2
  31. package/packs/core/skills/brief-intake/SKILL.md +2 -2
  32. package/packs/core/skills/brief-intake/manifest.json +2 -2
  33. package/packs/core/skills/brief-intake/templates/brief-deck.md +3 -3
  34. package/packs/core/skills/doc-critique/rubrics/deck.md +1 -1
  35. package/packs/design/pack.json +11 -21
  36. package/packs/design/skills/brand-kit/SKILL.md +6 -8
  37. package/packs/design/skills/brand-kit/manifest.json +3 -17
  38. package/packs/design/skills/brand-kit/templates/brand.json +1 -1
  39. package/packs/solution-architect/skills/interactive-diagram/SKILL.md +1 -1
  40. package/packs/solution-architect/skills/interactive-diagram/manifest.json +1 -2
  41. package/packs/stakeholder-comms/skills/audience-profile/SKILL.md +3 -3
  42. package/packs/stakeholder-comms/skills/audience-profile/manifest.json +2 -2
  43. package/packs/stakeholder-comms/skills/audience-profile/reference.md +1 -1
  44. package/packs/converters/skills/md-to-html/SKILL.md +0 -108
  45. package/packs/converters/skills/md-to-html/evals/eval_queries.json +0 -34
  46. package/packs/converters/skills/md-to-html/evals/evals.json +0 -44
  47. package/packs/converters/skills/md-to-html/evals/files/1/brand/brand.json +0 -80
  48. package/packs/converters/skills/md-to-html/evals/files/1/brand/logo-white.png +0 -0
  49. package/packs/converters/skills/md-to-html/evals/files/1/brand/logo.png +0 -0
  50. package/packs/converters/skills/md-to-html/evals/files/1/brand/mark.png +0 -0
  51. package/packs/converters/skills/md-to-html/evals/files/1/docs/q3-review.md +0 -35
  52. package/packs/converters/skills/md-to-html/evals/files/1/img/dashboard.png +0 -0
  53. package/packs/converters/skills/md-to-html/evals/files/2/brand.json +0 -13
  54. package/packs/converters/skills/md-to-html/evals/files/2/img/kick-net.png +0 -0
  55. package/packs/converters/skills/md-to-html/evals/files/2/river-survey-guide.md +0 -53
  56. package/packs/converters/skills/md-to-html/evals/files/3/nanas-lentil-soup.md +0 -21
  57. package/packs/converters/skills/md-to-html/evals/files/3/pot.png +0 -0
  58. package/packs/converters/skills/md-to-html/examples/inputs/chart.svg +0 -8
  59. package/packs/converters/skills/md-to-html/examples/inputs/ops-review.md +0 -48
  60. package/packs/converters/skills/md-to-html/examples/ops-review.md +0 -83
  61. package/packs/converters/skills/md-to-html/manifest.json +0 -43
  62. package/packs/converters/skills/md-to-html/reference.md +0 -70
  63. package/packs/converters/skills/md-to-html/scripts/md_to_html.py +0 -662
  64. package/packs/converters/skills/mermaid-render/SKILL.md +0 -117
  65. package/packs/converters/skills/mermaid-render/evals/eval_queries.json +0 -30
  66. package/packs/converters/skills/mermaid-render/evals/evals.json +0 -43
  67. package/packs/converters/skills/mermaid-render/evals/files/1/docs/contoso-orders.md +0 -23
  68. package/packs/converters/skills/mermaid-render/evals/files/2/dispensing-diagrams.md +0 -39
  69. package/packs/converters/skills/mermaid-render/examples/broken-diagrams.md +0 -147
  70. package/packs/converters/skills/mermaid-render/examples/inputs/broken-flowchart.mmd +0 -8
  71. package/packs/converters/skills/mermaid-render/examples/inputs/broken-sequence.mmd +0 -11
  72. package/packs/converters/skills/mermaid-render/examples/inputs/checkout-flow.mmd +0 -9
  73. package/packs/converters/skills/mermaid-render/examples/inputs/design-notes.md +0 -35
  74. package/packs/converters/skills/mermaid-render/examples/inputs/fixed-flowchart.mmd +0 -9
  75. package/packs/converters/skills/mermaid-render/examples/inputs/typo.mmd +0 -2
  76. package/packs/converters/skills/mermaid-render/manifest.json +0 -50
  77. package/packs/converters/skills/mermaid-render/reference.md +0 -70
  78. package/packs/converters/skills/mermaid-render/scripts/render_mermaid.py +0 -487
  79. package/packs/design/skills/deck-builder/SKILL.md +0 -209
  80. package/packs/design/skills/deck-builder/evals/eval_queries.json +0 -14
  81. package/packs/design/skills/deck-builder/evals/evals.json +0 -67
  82. package/packs/design/skills/deck-builder/evals/files/4/ashby-vale-board-notes.md +0 -36
  83. package/packs/design/skills/deck-builder/examples/exec-board-update.md +0 -108
  84. package/packs/design/skills/deck-builder/examples/templated-brand-deck.md +0 -148
  85. package/packs/design/skills/deck-builder/manifest.json +0 -18
  86. package/packs/design/skills/deck-builder/reference.md +0 -180
  87. package/packs/design/skills/deck-builder/requirements.txt +0 -3
  88. package/packs/design/skills/deck-builder/scripts/build_deck.py +0 -1144
  89. package/packs/design/skills/deck-builder/templates/deck-spec.json +0 -168
  90. package/packs/design/skills/deck-builder/templates/palettes.json +0 -59
  91. package/packs/design/skills/marketing-flyer/SKILL.md +0 -113
  92. package/packs/design/skills/marketing-flyer/evals/eval_queries.json +0 -30
  93. package/packs/design/skills/marketing-flyer/evals/evals.json +0 -45
  94. package/packs/design/skills/marketing-flyer/evals/files/1/brand/brand.json +0 -80
  95. package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo-white.png +0 -0
  96. package/packs/design/skills/marketing-flyer/evals/files/1/brand/logo.png +0 -0
  97. package/packs/design/skills/marketing-flyer/evals/files/1/brand/mark.png +0 -0
  98. package/packs/design/skills/marketing-flyer/evals/files/2/brand.json +0 -14
  99. package/packs/design/skills/marketing-flyer/evals/files/2/larchfield-logo.svg +0 -4
  100. package/packs/design/skills/marketing-flyer/evals/files/2/manager-email.txt +0 -6
  101. package/packs/design/skills/marketing-flyer/evals/files/2/open-day-brief.md +0 -15
  102. package/packs/design/skills/marketing-flyer/examples/community-health-fair.md +0 -68
  103. package/packs/design/skills/marketing-flyer/manifest.json +0 -40
  104. package/packs/design/skills/marketing-flyer/reference.md +0 -54
  105. package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +0 -332
  106. package/packs/design/skills/marketing-flyer/templates/flyer-spec.json +0 -26
@@ -1,487 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Lint Mermaid diagrams for the errors that stop them rendering, then render them.
3
-
4
- Stdlib only, Python 3.9+.
5
-
6
- Lint catches, with line numbers: an unknown diagram type on the first line, unbalanced
7
- brackets or quotes in node labels, unquoted parentheses or brackets inside a label, the
8
- reserved word `end` used as a flowchart node id, a missing or single-dash arrow, and a
9
- subgraph (or sequence `alt`/`loop`/... block) with no `end`.
10
-
11
- Render uses `mmdc` (mermaid-cli) for .svg/.png/.pdf when it is on PATH. Without it, the
12
- script writes a standalone .html that draws the diagrams with a pinned Mermaid from
13
- jsDelivr, which needs network when the file is opened.
14
-
15
- Usage:
16
- python3 render_mermaid.py diagram.mmd # lint only
17
- python3 render_mermaid.py diagram.mmd -o diagram.svg # lint, then render
18
- python3 render_mermaid.py design.md -o figs/arch.svg # every ```mermaid fence -> arch-1.svg, arch-2.svg ...
19
- python3 render_mermaid.py design.md -o design.html # one HTML page with every diagram
20
- """
21
-
22
- import argparse
23
- import base64
24
- import difflib
25
- import hashlib
26
- import html
27
- import re
28
- import shutil
29
- import subprocess
30
- import sys
31
- import tempfile
32
- from pathlib import Path
33
-
34
- MERMAID_VERSION = "11.4.1"
35
- MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@%s/dist/mermaid.esm.min.mjs" % MERMAID_VERSION
36
- MERMAID_INIT = ('import mermaid from "%s";'
37
- 'mermaid.initialize({startOnLoad:true,securityLevel:"strict"});' % MERMAID_URL)
38
- MERMAID_INIT_HASH = base64.b64encode(hashlib.sha256(MERMAID_INIT.encode("utf-8")).digest()).decode("ascii")
39
-
40
- # Diagram keywords Mermaid 11.4 recognises on the first line.
41
- DIAGRAM_TYPES = [
42
- "flowchart", "graph", "sequenceDiagram", "classDiagram", "classDiagram-v2", "stateDiagram",
43
- "stateDiagram-v2", "erDiagram", "journey", "gantt", "pie", "quadrantChart", "requirementDiagram",
44
- "gitGraph", "C4Context", "C4Container", "C4Component", "C4Dynamic", "C4Deployment", "mindmap",
45
- "timeline", "zenuml", "sankey-beta", "xychart-beta", "block-beta", "packet-beta",
46
- "architecture-beta", "kanban",
47
- ]
48
- DIRECTIONS = {"TB", "TD", "BT", "RL", "LR"}
49
- FLOW_KEYWORDS = ("classDef", "class", "style", "linkStyle", "click", "direction", "accTitle",
50
- "accDescr", "title")
51
- SEQ_OPEN = ("loop", "alt", "opt", "par", "critical", "break", "rect", "box")
52
- SEQ_MIDDLE = {"else": ("alt",), "and": ("par",), "option": ("critical",)}
53
-
54
- # Node shapes, longest opener first. Each maps to the closers that may end it.
55
- SHAPES = [("(((", (")))",)), ("((", ("))",)), ("([", ("])",)), ("[[", ("]]",)), ("[(", (")]",)),
56
- ("[/", ("/]", "\\]")), ("[\\", ("\\]", "/]")), ("{{", ("}}",)), ("[", ("]",)),
57
- ("(", (")",)), ("{", ("}",)), (">", ("]",))]
58
- ID_RE = re.compile(r"[^\W](?:[\w.]|-(?=\w))*", re.U)
59
- LINK_RE = re.compile(r"\s*(?:<|(?<=\s)[ox](?=[-=]))?(?:-{2,}|={2,}|-\.+-|~{3,})(?:[->ox](?!\w))?>?\s*")
60
-
61
-
62
- class Issue:
63
- def __init__(self, line, level, msg):
64
- self.line, self.level, self.msg = line, level, msg
65
-
66
-
67
- def strip_edge_labels(s):
68
- s = re.sub(r"\|[^|]*\|", " ", s) # -->|label|
69
- s = re.sub(r"-\.(?=[^.\->\s]|\s+[^.\->])\s*([^\n]*?)\s*\.-+>?", " -.-> ", s) # -. label .->
70
- s = re.sub(r"(?<![-<.=])--(?!-|>|[ox]\s)\s*([^-|>\n][^|>\n]*?)\s*(?:-{2,}>|-{3,})", " --> ", s)
71
- s = re.sub(r"(?<![=<])==(?!=|>)\s*([^=|>\n][^|>\n]*?)\s*(?:={2,}>|={3,})", " ==> ", s)
72
- return s
73
-
74
-
75
- def split_statements(line):
76
- """Split on ; outside quotes."""
77
- out, cur, q = [], "", False
78
- for ch in line:
79
- if ch == '"':
80
- q = not q
81
- if ch == ";" and not q:
82
- out.append(cur)
83
- cur = ""
84
- else:
85
- cur += ch
86
- out.append(cur)
87
- return [s for s in out if s.strip()]
88
-
89
-
90
- def scan_nodes(part, ln, issues):
91
- """Walk one link-free chunk: node ids, their shapes, & separators. Returns node ids."""
92
- i, n = 0, len(part)
93
- ids, need_sep = [], False
94
- while i < n:
95
- c = part[i]
96
- if c.isspace():
97
- i += 1
98
- continue
99
- if c == "&":
100
- need_sep = False
101
- i += 1
102
- continue
103
- m = ID_RE.match(part, i)
104
- if not m:
105
- if c in ")]}":
106
- issues.append(Issue(ln, "error", "closing `%s` with no matching opening bracket" % c))
107
- elif c in "([{":
108
- issues.append(Issue(ln, "error", "a shape `%s` with no node id before it" % c))
109
- elif c == '"':
110
- issues.append(Issue(ln, "error", "a quoted label must sit inside a shape, as id[\"label\"]"))
111
- elif part.startswith("->", i) or part.startswith("=>", i):
112
- issues.append(Issue(ln, "error", "`%s` is not a flowchart arrow; use `-->` (or `==>`)"
113
- % part[i:i + 2]))
114
- i += 2
115
- need_sep = False
116
- continue
117
- else:
118
- issues.append(Issue(ln, "error", "unexpected `%s`" % c))
119
- return ids
120
- nid = m.group(0)
121
- if need_sep:
122
- issues.append(Issue(ln, "error", "no arrow between `%s` and `%s`; join them with `-->`"
123
- % (ids[-1], nid)))
124
- ids.append(nid)
125
- if nid == "end":
126
- issues.append(Issue(ln, "error", "`end` is reserved in flowcharts and breaks the diagram; "
127
- "use `End`, `END` or `done` as the node id"))
128
- i = m.end()
129
- if part.startswith("@{", i):
130
- j = part.find("}", i)
131
- if j < 0:
132
- issues.append(Issue(ln, "error", "`@{` shape block is never closed with `}`"))
133
- return ids
134
- i = j + 1
135
- else:
136
- for opener, closers in SHAPES:
137
- if not part.startswith(opener, i):
138
- continue
139
- i += len(opener)
140
- if part[i:i + 1] == '"':
141
- j = part.find('"', i + 1)
142
- if j < 0:
143
- issues.append(Issue(ln, "error", "unbalanced quote in the label of `%s`" % nid))
144
- return ids
145
- i = j + 1
146
- while i < n and part[i] == " ":
147
- i += 1
148
- hit = [cl for cl in closers if part.startswith(cl, i)]
149
- if not hit:
150
- issues.append(Issue(ln, "error", "label of `%s` opens with `%s` but does not close "
151
- "with `%s` after the quotes" % (nid, opener, closers[0])))
152
- return ids
153
- i += len(hit[0])
154
- else:
155
- found = [(part.find(cl, i), cl) for cl in closers if part.find(cl, i) >= 0]
156
- if not found:
157
- issues.append(Issue(ln, "error", "unbalanced brackets: `%s%s` is never closed with `%s`"
158
- % (nid, opener, closers[0])))
159
- return ids
160
- pos, cl = min(found)
161
- label = part[i:pos]
162
- bad = sorted({ch for ch in label if ch in '()[]{}"'})
163
- if bad:
164
- issues.append(Issue(ln, "error", "label of `%s` has unquoted %s; wrap the label in "
165
- "quotes: %s%s\"%s\"%s" % (nid, " ".join("`%s`" % b for b in bad),
166
- nid, opener, label.replace('"', "'"), cl)))
167
- i = pos + len(cl)
168
- break
169
- m2 = re.match(r":::[\w-]+", part[i:])
170
- if m2:
171
- i += m2.end()
172
- need_sep = True
173
- return ids
174
-
175
-
176
- def lint_flowchart(lines, issues):
177
- stack = []
178
- for ln, raw in lines:
179
- s = raw.strip()
180
- if not s or s.startswith("%%"):
181
- continue
182
- word = s.split()[0]
183
- if word == "subgraph":
184
- stack.append(ln)
185
- rest = s[len("subgraph"):].strip()
186
- if rest.count('"') % 2:
187
- issues.append(Issue(ln, "error", "unbalanced quote in the subgraph title"))
188
- elif re.match(r'^"[^"]*"$', rest):
189
- pass
190
- elif re.match(r"^[^\W][\w.-]*\s*[\[({>]", rest, re.U):
191
- # subgraph id[label], or id [label] as the Mermaid docs write it
192
- scan_nodes(re.sub(r"^([^\W][\w.-]*)\s+", r"\1", rest, flags=re.U), ln, issues)
193
- elif re.search(r"[\[\](){}]", rest):
194
- issues.append(Issue(ln, "error", "subgraph title has unquoted brackets; write "
195
- "subgraph id[\"%s\"]" % rest.replace('"', "'")))
196
- continue
197
- if s == "end":
198
- if stack:
199
- stack.pop()
200
- else:
201
- issues.append(Issue(ln, "error", "`end` with no open subgraph"))
202
- continue
203
- if word in FLOW_KEYWORDS or word.startswith(("accTitle:", "accDescr")):
204
- continue
205
- for stmt in split_statements(s):
206
- if stmt.count('"') % 2:
207
- issues.append(Issue(ln, "error", "unbalanced double quote"))
208
- continue
209
- if re.search(r"(?:-{2,}|={2,})[ox][A-Za-z0-9_]", stmt):
210
- issues.append(Issue(ln, "warning", "`--o`/`--x` followed by a letter is read as a circle or "
211
- "cross arrowhead; add a space if the node id starts with o or x"))
212
- for lab in re.findall(r"\|([^|]*)\|", stmt):
213
- inner = lab.strip()
214
- bad = sorted({ch for ch in inner if ch in "()[]{}"})
215
- if bad and not (inner.startswith('"') and inner.endswith('"')):
216
- issues.append(Issue(ln, "error", "arrow label |%s| has unquoted %s; quote it: |\"%s\"|"
217
- % (inner, " ".join("`%s`" % b for b in bad), inner)))
218
- body = strip_edge_labels(stmt)
219
- parts = LINK_RE.split(body)
220
- if len(parts) > 1 and (not parts[0].strip() or not parts[-1].strip()):
221
- issues.append(Issue(ln, "error", "an arrow with no node on one side"))
222
- for part in parts:
223
- if part.strip():
224
- scan_nodes(part, ln, issues)
225
- for ln in stack:
226
- issues.append(Issue(ln, "error", "subgraph opened here has no matching `end`"))
227
-
228
-
229
- def lint_sequence(lines, issues):
230
- stack = []
231
- for ln, raw in lines:
232
- s = raw.strip()
233
- if not s or s.startswith("%%"):
234
- continue
235
- word = s.split()[0]
236
- if word in SEQ_OPEN:
237
- stack.append((word, ln))
238
- elif word in SEQ_MIDDLE:
239
- if not stack or stack[-1][0] not in SEQ_MIDDLE[word]:
240
- issues.append(Issue(ln, "error", "`%s` outside a `%s` block" % (word, SEQ_MIDDLE[word][0])))
241
- elif s == "end":
242
- if stack:
243
- stack.pop()
244
- else:
245
- issues.append(Issue(ln, "error", "`end` with no open loop/alt/opt/par/critical/break/rect/box"))
246
- elif re.search(r"(?:-{1,2}>>?|-{1,2}[x)])", s) and ":" not in s and not s.startswith(("participant", "actor", "Note", "note", "autonumber", "activate", "deactivate", "create", "destroy", "title", "link", "links")):
247
- issues.append(Issue(ln, "error", "message without `: text`; write `A->>B: what is sent`"))
248
- for word, ln in stack:
249
- issues.append(Issue(ln, "error", "`%s` block opened here has no matching `end`" % word))
250
-
251
-
252
- def lint_braces(lines, issues):
253
- depth, opened = 0, []
254
- for ln, raw in lines:
255
- s = re.sub(r'"[^"]*"', "", raw)
256
- # erDiagram cardinality markers such as ||--o{ and }|..|{ are not braces
257
- s = re.sub(r"(?:\|o|\|\||\}o|\}\|)(?:--|\.\.)(?:o\||\|\||o\{|\|\{)", " ", s)
258
- if s.strip().startswith("%%"):
259
- continue
260
- for ch in s:
261
- if ch == "{":
262
- depth += 1
263
- opened.append(ln)
264
- elif ch == "}":
265
- if depth == 0:
266
- issues.append(Issue(ln, "error", "`}` with no matching `{`"))
267
- else:
268
- depth -= 1
269
- opened.pop()
270
- for ln in opened:
271
- issues.append(Issue(ln, "error", "`{` opened here is never closed"))
272
-
273
-
274
- def lint(text, first_line=1):
275
- """Lint one diagram. first_line is the file line number of the diagram's first line."""
276
- issues = []
277
- lines = [(first_line + k, l) for k, l in enumerate(text.split("\n"))]
278
- k = 0
279
- # skip front matter (--- ... ---), %%{init}%% directives and comments
280
- while k < len(lines) and not lines[k][1].strip():
281
- k += 1
282
- if k < len(lines) and lines[k][1].strip() == "---":
283
- k += 1
284
- while k < len(lines) and lines[k][1].strip() != "---":
285
- k += 1
286
- k += 1
287
- while k < len(lines) and (not lines[k][1].strip() or lines[k][1].strip().startswith("%%")):
288
- k += 1
289
- if k >= len(lines):
290
- return [Issue(first_line, "error", "the diagram is empty")], None
291
- ln, head = lines[k]
292
- words = head.strip().split()
293
- kind = words[0].rstrip(";:")
294
- if kind not in DIAGRAM_TYPES:
295
- guess = difflib.get_close_matches(kind, DIAGRAM_TYPES, n=1, cutoff=0.6)
296
- hint = " Did you mean `%s`?" % guess[0] if guess else " Start with one of: flowchart, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, gantt."
297
- issues.append(Issue(ln, "error", "unknown diagram type `%s` on the first line.%s" % (kind, hint)))
298
- return issues, None
299
- body = lines[k + 1:]
300
- if kind in ("flowchart", "graph"):
301
- if len(words) > 1 and words[1].rstrip(";") not in DIRECTIONS:
302
- issues.append(Issue(ln, "error", "unknown direction `%s`; use TD, TB, BT, LR or RL" % words[1]))
303
- lint_flowchart(body, issues)
304
- elif kind == "sequenceDiagram":
305
- lint_sequence(body, issues)
306
- elif kind in ("classDiagram", "classDiagram-v2", "stateDiagram", "stateDiagram-v2", "erDiagram"):
307
- lint_braces(body, issues)
308
- if not any(l.strip() and not l.strip().startswith("%%") for _, l in body) and kind not in ("pie",):
309
- issues.append(Issue(ln, "warning", "the diagram has a type but no content"))
310
- return issues, kind
311
-
312
-
313
- # ---------------------------------------------------------------- input
314
-
315
- def extract_fences(text):
316
- """Return [(first_line_number, diagram_text)] for every ```mermaid / ~~~mermaid fence."""
317
- out = []
318
- lines = text.split("\n")
319
- i = 0
320
- while i < len(lines):
321
- m = re.match(r"^\s{0,3}(`{3,}|~{3,})\s*mermaid\b", lines[i])
322
- if m:
323
- fence = m.group(1)
324
- start = i + 2 # 1-based line number of the first diagram line
325
- body = []
326
- i += 1
327
- while i < len(lines) and not re.match(r"^\s{0,3}%s%s*\s*$" % (re.escape(fence), re.escape(fence[0])), lines[i]):
328
- body.append(lines[i])
329
- i += 1
330
- if i >= len(lines):
331
- out.append((start, "\n".join(body), "unclosed"))
332
- else:
333
- out.append((start, "\n".join(body), None))
334
- i += 1
335
- return out
336
-
337
-
338
- # ---------------------------------------------------------------- render
339
-
340
- def html_page(diagrams, title):
341
- csp = ("default-src 'none'; script-src 'sha256-%s' https://cdn.jsdelivr.net; "
342
- "style-src 'unsafe-inline'; img-src data: https:; font-src data: https:; "
343
- "connect-src https://cdn.jsdelivr.net" % MERMAID_INIT_HASH)
344
- figs = []
345
- for k, (label, text) in enumerate(diagrams, 1):
346
- figs.append('<figure><pre class="mermaid">%s</pre><figcaption>%s</figcaption></figure>'
347
- % (html.escape(text), html.escape(label)))
348
- return """<!DOCTYPE html>
349
- <html lang="en">
350
- <head>
351
- <meta charset="utf-8">
352
- <meta name="viewport" content="width=device-width, initial-scale=1">
353
- <meta http-equiv="Content-Security-Policy" content="%s">
354
- <title>%s</title>
355
- <style>
356
- body{font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;margin:2rem auto;max-width:72rem;padding:0 1rem;color:#222}
357
- figure{margin:0 0 2.5rem;border:1px solid #e3e3e3;border-radius:6px;padding:1rem}
358
- figcaption{font-size:.85rem;color:#666;margin-top:.5rem}
359
- pre.mermaid{text-align:center;background:#fff;margin:0}
360
- .note{font-size:.85rem;color:#666}
361
- @media print{figure{break-inside:avoid;border:0}.note{display:none}}
362
- </style>
363
- </head>
364
- <body>
365
- <p class="note">Drawn by Mermaid %s loaded from cdn.jsdelivr.net; this page needs network when opened.</p>
366
- %s
367
- <script type="module">%s</script>
368
- </body>
369
- </html>
370
- """ % (csp, html.escape(title), MERMAID_VERSION, "\n".join(figs), MERMAID_INIT)
371
-
372
-
373
- def run_mmdc(exe, text, out_path):
374
- with tempfile.TemporaryDirectory() as tmp:
375
- src = Path(tmp) / "diagram.mmd"
376
- src.write_text(text, encoding="utf-8")
377
- cmd = [exe, "-i", str(src), "-o", str(out_path)]
378
- if out_path.suffix.lower() == ".png":
379
- cmd += ["-b", "white", "-s", "2"]
380
- try:
381
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False)
382
- except subprocess.TimeoutExpired:
383
- return "mmdc timed out after 180 s"
384
- if proc.returncode != 0:
385
- return mmdc_error((proc.stderr or proc.stdout).decode("utf-8", "replace"))
386
- return None
387
-
388
-
389
- def mmdc_error(raw):
390
- """Mermaid's own message (from "Error:" up to the stack trace), not the stack trace:
391
- mmdc prints a browser-library trace after the parse error, and the parse error is the
392
- part a person can act on."""
393
- lines = raw.strip().splitlines()
394
- start = next((k for k, l in enumerate(lines) if l.lstrip().startswith("Error")), 0)
395
- keep = []
396
- for l in lines[start:]:
397
- if re.match(r"^\s+at\s", l) or re.search(r"\((https?|file)://[^)]*\)\s*$", l):
398
- break
399
- keep.append(l.rstrip())
400
- msg = "\n".join(keep[:8]).strip()
401
- return msg or raw.strip()[-800:]
402
-
403
-
404
- def main(argv=None):
405
- ap = argparse.ArgumentParser(description="Lint Mermaid diagrams, then render to svg/png/pdf (mmdc) or html.")
406
- ap.add_argument("input", help=".mmd/.mermaid file with one diagram, or .md with ```mermaid fences")
407
- ap.add_argument("-o", "--output", help="out.svg | out.png | out.pdf | out.html. Leave out to lint only.")
408
- ap.add_argument("--force", action="store_true", help="render even when lint finds errors")
409
- a = ap.parse_args(argv)
410
-
411
- src = Path(a.input)
412
- if not src.is_file():
413
- sys.exit("error: input not found: %s" % a.input)
414
- try:
415
- text = src.read_text(encoding="utf-8-sig").replace("\r\n", "\n")
416
- except UnicodeDecodeError:
417
- sys.exit("error: %s is not UTF-8 text" % a.input)
418
-
419
- if src.suffix.lower() in (".md", ".markdown"):
420
- fences = extract_fences(text)
421
- if not fences:
422
- sys.exit("error: no ```mermaid fences found in %s" % a.input)
423
- diagrams = fences
424
- else:
425
- diagrams = [(1, text, None)]
426
-
427
- errors = warnings = 0
428
- for k, (start, body, flag) in enumerate(diagrams, 1):
429
- issues, kind = lint(body, start)
430
- if flag == "unclosed":
431
- issues.insert(0, Issue(start - 1, "error", "the ```mermaid fence is never closed"))
432
- label = "diagram %d (%s)" % (k, kind or "unknown type") if len(diagrams) > 1 else (kind or "diagram")
433
- if not issues:
434
- print("%s: %s: ok" % (src.name, label))
435
- for it in sorted(issues, key=lambda x: x.line):
436
- print("%s:%d: %s: %s" % (src.name, it.line, it.level, it.msg))
437
- if it.level == "error":
438
- errors += 1
439
- else:
440
- warnings += 1
441
- print("lint: %d diagram(s), %d error(s), %d warning(s)" % (len(diagrams), errors, warnings))
442
-
443
- if not a.output:
444
- return 1 if errors else 0
445
- if errors and not a.force:
446
- sys.stdout.flush()
447
- print("not rendering: fix the errors above, or rerun with --force", file=sys.stderr)
448
- return 1
449
-
450
- out = Path(a.output)
451
- if not out.parent.exists():
452
- sys.exit("error: output folder does not exist: %s" % out.parent)
453
- ext = out.suffix.lower()
454
- if ext not in (".svg", ".png", ".pdf", ".html", ".htm"):
455
- sys.exit("error: output must end in .svg, .png, .pdf or .html")
456
- many = len(diagrams) > 1
457
- labels = ["%s, diagram %d (line %d)" % (src.name, k, d[0]) if many else src.name
458
- for k, d in enumerate(diagrams, 1)]
459
-
460
- exe = shutil.which("mmdc") if ext in (".svg", ".png", ".pdf") else None
461
- if exe:
462
- failed = 0
463
- for k, (start, body, _) in enumerate(diagrams, 1):
464
- target = out.with_name("%s-%d%s" % (out.stem, k, out.suffix)) if many else out
465
- err = run_mmdc(exe, body, target)
466
- if err:
467
- failed += 1
468
- print("mmdc failed on %s:\n%s" % (labels[k - 1], err), file=sys.stderr)
469
- else:
470
- print("wrote %s" % target)
471
- return 1 if failed else 0
472
-
473
- target = out if ext in (".html", ".htm") else out.with_suffix(".html")
474
- target.write_text(html_page([(labels[k], d[1]) for k, d in enumerate(diagrams)], src.name), encoding="utf-8")
475
- if ext not in (".html", ".htm"):
476
- print("mmdc (mermaid-cli) is not on PATH, so no %s was written. Wrote %s instead: it draws "
477
- "the diagram(s) with Mermaid %s from cdn.jsdelivr.net and needs network when opened. "
478
- "For %s output: npm install -g @mermaid-js/mermaid-cli, then rerun."
479
- % (ext, target, MERMAID_VERSION, ext))
480
- else:
481
- print("wrote %s (%d diagram(s); draws with Mermaid %s from cdn.jsdelivr.net, needs network when opened)"
482
- % (target, len(diagrams), MERMAID_VERSION))
483
- return 0
484
-
485
-
486
- if __name__ == "__main__":
487
- sys.exit(main())