@techierathore/techieflow 1.0.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/.claude/commands/TechieFlow/agents/analyst.md +97 -0
- package/.claude/commands/TechieFlow/agents/architect.md +84 -0
- package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
- package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
- package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
- package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
- package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
- package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
- package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
- package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
- package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
- package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
- package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
- package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
- package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
- package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
- package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
- package/.claude/commands/generate-html.md +124 -0
- package/.codex/config.toml +46 -0
- package/.codex/hooks.json +63 -0
- package/.codex/rules/techieflow.rules +8 -0
- package/.opencode/command/generate-html.md +129 -0
- package/.opencode/plugin/techieflow.js +350 -0
- package/.tfcore/TOKEN-GUIDE.md +59 -0
- package/.tfcore/agents/analyst.md +97 -0
- package/.tfcore/agents/architect.md +84 -0
- package/.tfcore/agents/flow-master.md +217 -0
- package/.tfcore/agents/verifier.md +72 -0
- package/.tfcore/checklists/architect-checklist.md +438 -0
- package/.tfcore/core-config.yaml +47 -0
- package/.tfcore/data/brainstorming-techniques.md +36 -0
- package/.tfcore/data/elicitation-methods.md +154 -0
- package/.tfcore/data/techieflow-kb.md +807 -0
- package/.tfcore/data/technical-preferences.md +3 -0
- package/.tfcore/enhanced-ide-development-workflow.md +248 -0
- package/.tfcore/hooks/block-git.sh +301 -0
- package/.tfcore/hooks/codex-adapter.py +248 -0
- package/.tfcore/hooks/guard-artifacts.sh +101 -0
- package/.tfcore/hooks/guard-status-html.sh +102 -0
- package/.tfcore/hooks/guard-status.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +108 -0
- package/.tfcore/hooks/metrics-session.sh +129 -0
- package/.tfcore/hooks/session-pointer.sh +52 -0
- package/.tfcore/hooks/sweep-artifacts.sh +198 -0
- package/.tfcore/install-manifest.yaml +231 -0
- package/.tfcore/routing.yaml +105 -0
- package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
- package/.tfcore/tasks/_smoke-test-policy.md +98 -0
- package/.tfcore/tasks/_status-update-gate.md +78 -0
- package/.tfcore/tasks/_yolo-mode.md +77 -0
- package/.tfcore/tasks/advanced-elicitation.md +117 -0
- package/.tfcore/tasks/amend-docs.md +149 -0
- package/.tfcore/tasks/author-brd.md +79 -0
- package/.tfcore/tasks/build-phase.md +231 -0
- package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
- package/.tfcore/tasks/create-doc.md +101 -0
- package/.tfcore/tasks/day1-brownfield.md +507 -0
- package/.tfcore/tasks/day1-greenfield.md +224 -0
- package/.tfcore/tasks/devguide.md +224 -0
- package/.tfcore/tasks/document-project.md +343 -0
- package/.tfcore/tasks/execute-checklist.md +86 -0
- package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
- package/.tfcore/tasks/fix-issues.md +134 -0
- package/.tfcore/tasks/generate-html.md +115 -0
- package/.tfcore/tasks/handoff-phase.md +126 -0
- package/.tfcore/tasks/index-docs.md +173 -0
- package/.tfcore/tasks/kb-mode-interaction.md +75 -0
- package/.tfcore/tasks/log-miss.md +160 -0
- package/.tfcore/tasks/metrics-report.md +143 -0
- package/.tfcore/tasks/mockups.md +122 -0
- package/.tfcore/tasks/productguide.md +105 -0
- package/.tfcore/tasks/refresh-status.md +135 -0
- package/.tfcore/tasks/render-workflow-docs.md +117 -0
- package/.tfcore/tasks/shard-doc.md +185 -0
- package/.tfcore/tasks/split-brd.md +131 -0
- package/.tfcore/tasks/triage-issues.md +157 -0
- package/.tfcore/tasks/verify-phase.md +579 -0
- package/.tfcore/telemetry/SCHEMA.md +678 -0
- package/.tfcore/telemetry/install-metrics.sh +390 -0
- package/.tfcore/telemetry/pre-commit +94 -0
- package/.tfcore/telemetry/tf-metrics.sh +1415 -0
- package/.tfcore/templates/architecture-tmpl.yaml +650 -0
- package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
- package/.tfcore/templates/market-research-tmpl.yaml +252 -0
- package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
- package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
- package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
- package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
- package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
- package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
- package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
- package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
- package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
- package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
- package/.tfcore/user-guide.md +633 -0
- package/.tfcore/utils/techieflow-doc-template.md +325 -0
- package/.tfcore/utils/tf-assets.sh +310 -0
- package/.tfcore/utils/tf-codex-bind.py +196 -0
- package/.tfcore/utils/tf-codex-telemetry.py +60 -0
- package/.tfcore/utils/tf-emit.sh +1075 -0
- package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
- package/.tfcore/utils/tf-goal.sh +421 -0
- package/.tfcore/utils/tf-harness.sh +160 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
- package/.tfcore/utils/tf-mockup-parity.sh +65 -0
- package/.tfcore/utils/tf-opencode-audit.py +126 -0
- package/.tfcore/utils/tf-perf.sh +241 -0
- package/.tfcore/utils/tf-render-html.py +715 -0
- package/.tfcore/utils/tf-render-html.sh +28 -0
- package/.tfcore/utils/tf-routing-bind.sh +216 -0
- package/.tfcore/utils/tf-routing.sh +247 -0
- package/.tfcore/utils/tf-yolo.sh +119 -0
- package/.tfcore/working-in-the-brownfield.md +606 -0
- package/LICENSE +201 -0
- package/README.md +1212 -0
- package/WORKFLOW.html +1986 -0
- package/docs/TechieFlow-Installation.md +299 -0
- package/opencode.jsonc +929 -0
- package/package.json +60 -0
- package/scripts/install.mjs +992 -0
- package/scripts/test-install.mjs +505 -0
- package/scripts/validate.mjs +155 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tf-render-html — render framework Markdown docs to the shared HTML shell.
|
|
3
|
+
|
|
4
|
+
Implements .tfcore/templates/v4custom/html-render-shell.md. That file stays the
|
|
5
|
+
SPECIFICATION and the single source of truth: the CSS (§2), the flash-free theme
|
|
6
|
+
script (§3) and the JS (§7) are EXTRACTED FROM IT at render time, never copied in
|
|
7
|
+
here, so the shell cannot drift from its own documentation.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 tf-render-html.py <file.md> [more.md ...] [--out DIR] [--quiet]
|
|
11
|
+
|
|
12
|
+
Writes a sibling <file>.html for each input. Exit codes:
|
|
13
|
+
0 all rendered
|
|
14
|
+
1 a render failed
|
|
15
|
+
2 refused (checklist, or missing spec)
|
|
16
|
+
|
|
17
|
+
Dependency-free by design: Python 3 standard library only. The reference machine
|
|
18
|
+
has no pandoc / python-markdown / node markdown library installed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import html as _html
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
SPEC_REL = os.path.join("templates", "v4custom", "html-render-shell.md")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ----------------------------------------------------------------------------
|
|
31
|
+
# Spec extraction — §2 CSS, §3 head theme script, §7 JS
|
|
32
|
+
# ----------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def find_spec(explicit=None):
|
|
35
|
+
"""Locate html-render-shell.md. Framework files live under a hidden,
|
|
36
|
+
gitignored .tfcore/ — never search for them, resolve the known path."""
|
|
37
|
+
if explicit:
|
|
38
|
+
return explicit if os.path.isfile(explicit) else None
|
|
39
|
+
here = os.path.dirname(os.path.abspath(__file__)) # .tfcore/utils
|
|
40
|
+
cand = [os.path.join(os.path.dirname(here), SPEC_REL)] # .tfcore/...
|
|
41
|
+
cwd = os.getcwd()
|
|
42
|
+
cand.append(os.path.join(cwd, ".tfcore", SPEC_REL))
|
|
43
|
+
for c in cand:
|
|
44
|
+
if os.path.isfile(c):
|
|
45
|
+
return c
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fence(spec_text, lang, after=None):
|
|
50
|
+
"""Return the body of the first ```<lang> fence, optionally after a marker."""
|
|
51
|
+
text = spec_text
|
|
52
|
+
if after:
|
|
53
|
+
i = text.find(after)
|
|
54
|
+
if i < 0:
|
|
55
|
+
return None
|
|
56
|
+
text = text[i:]
|
|
57
|
+
m = re.search(r"^```" + lang + r"\s*\n(.*?)^```", text, re.S | re.M)
|
|
58
|
+
return m.group(1) if m else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_shell(spec_path):
|
|
62
|
+
spec = open(spec_path, encoding="utf-8").read()
|
|
63
|
+
css = _fence(spec, "css", after="## 2. CSS")
|
|
64
|
+
js = _fence(spec, "javascript", after="## 7. JavaScript")
|
|
65
|
+
skeleton = _fence(spec, "html", after="## 3. Page skeleton")
|
|
66
|
+
head_js = None
|
|
67
|
+
if skeleton:
|
|
68
|
+
m = re.search(r"<script>\s*(/\* Resolve the theme.*?)</script>",
|
|
69
|
+
skeleton, re.S)
|
|
70
|
+
if m:
|
|
71
|
+
head_js = m.group(1)
|
|
72
|
+
missing = [n for n, v in (("§2 CSS", css), ("§7 JS", js),
|
|
73
|
+
("§3 theme script", head_js)) if not v]
|
|
74
|
+
if missing:
|
|
75
|
+
raise SystemExit("tf-render-html: cannot extract %s from %s"
|
|
76
|
+
% (", ".join(missing), spec_path))
|
|
77
|
+
return css.rstrip(), head_js.strip(), js.rstrip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ----------------------------------------------------------------------------
|
|
81
|
+
# §1 slug rule
|
|
82
|
+
# ----------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class Slugger(object):
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self.seen = {}
|
|
87
|
+
self.n = 0
|
|
88
|
+
|
|
89
|
+
def slug(self, text):
|
|
90
|
+
self.n += 1
|
|
91
|
+
s = re.sub(r"<[^>]+>", "", text) # strip any inline html
|
|
92
|
+
s = re.sub(r"[*_`~]", "", s) # strip md emphasis
|
|
93
|
+
s = s.lower()
|
|
94
|
+
# 2. leading numbering / § / dashes, only before the first letter
|
|
95
|
+
s = re.sub(r"^[\s\d.§#—–\-]+", "", s)
|
|
96
|
+
s = re.sub(r"[^a-z0-9]+", "-", s) # 3.
|
|
97
|
+
s = s.strip("-") # 4.
|
|
98
|
+
if not s: # 5.
|
|
99
|
+
s = "section-%d" % self.n
|
|
100
|
+
c = self.seen.get(s, 0) + 1 # 6.
|
|
101
|
+
self.seen[s] = c
|
|
102
|
+
return s if c == 1 else "%s-%d" % (s, c)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ----------------------------------------------------------------------------
|
|
106
|
+
# Inline conversion
|
|
107
|
+
# ----------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
RAW_TAG = re.compile(
|
|
110
|
+
r"</?(?:a|abbr|b|br|code|em|i|img|kbd|mark|s|small|span|strong|sub|sup|u)\b[^>]*>",
|
|
111
|
+
re.I)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def inline(text):
|
|
115
|
+
"""Markdown inline -> HTML. Code spans and deliberate raw inline HTML are
|
|
116
|
+
protected before escaping, so `<a id="d-req-ui-001"></a>` survives verbatim
|
|
117
|
+
(html-render-shell §4) while stray < and & are still escaped."""
|
|
118
|
+
slots = []
|
|
119
|
+
|
|
120
|
+
def stash(s):
|
|
121
|
+
slots.append(s)
|
|
122
|
+
return "\x00%d\x00" % (len(slots) - 1)
|
|
123
|
+
|
|
124
|
+
# code spans first — their content must never be md-parsed
|
|
125
|
+
def code_span(m):
|
|
126
|
+
return stash("<code>%s</code>" % _html.escape(m.group(2), quote=False))
|
|
127
|
+
text = re.sub(r"(`+)(.+?)\1", code_span, text, flags=re.S)
|
|
128
|
+
|
|
129
|
+
# deliberate raw inline HTML (anchors above all)
|
|
130
|
+
text = RAW_TAG.sub(lambda m: stash(m.group(0)), text)
|
|
131
|
+
|
|
132
|
+
text = _html.escape(text, quote=False)
|
|
133
|
+
|
|
134
|
+
# images before links
|
|
135
|
+
text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)",
|
|
136
|
+
lambda m: '<img src="%s" alt="%s" style="max-width:100%%">'
|
|
137
|
+
% (m.group(2), m.group(1)), text)
|
|
138
|
+
text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)",
|
|
139
|
+
lambda m: '<a href="%s">%s</a>' % (m.group(2), m.group(1)), text)
|
|
140
|
+
text = re.sub(r"<(https?://[^>\s]+)>", r'<a href="\1">\1</a>', text)
|
|
141
|
+
|
|
142
|
+
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<strong><em>\1</em></strong>", text, flags=re.S)
|
|
143
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text, flags=re.S)
|
|
144
|
+
text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text, flags=re.S)
|
|
145
|
+
text = re.sub(r"(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])", r"<em>\1</em>", text)
|
|
146
|
+
text = re.sub(r"(?<![\w_])_(?!\s)([^_]+?)(?<!\s)_(?![\w_])", r"<em>\1</em>", text)
|
|
147
|
+
|
|
148
|
+
for i, s in enumerate(slots):
|
|
149
|
+
text = text.replace("\x00%d\x00" % i, s)
|
|
150
|
+
return text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ----------------------------------------------------------------------------
|
|
154
|
+
# Block parsing
|
|
155
|
+
# ----------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# §6b — visible blockquotes addressed to the DRAFTING AGENT, never to the reader.
|
|
158
|
+
AGENT_NOTE = re.compile(
|
|
159
|
+
r"(depth mandate|mermaid mandate|read (this )?before drafting|"
|
|
160
|
+
r"how to use this template|template instructions?:|"
|
|
161
|
+
r"ai agent:|agent note:|note to the agent)", re.I)
|
|
162
|
+
|
|
163
|
+
FENCE = re.compile(r"^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$")
|
|
164
|
+
HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
165
|
+
HR = re.compile(r"^\s*(?:-{3,}|\*{3,}|_{3,})\s*$")
|
|
166
|
+
LI = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
|
|
167
|
+
TABLE_SEP = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
|
168
|
+
HTML_BLOCK = re.compile(r"^\s*<(/?)([a-zA-Z][\w-]*)")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Renderer(object):
|
|
172
|
+
def __init__(self, slugger):
|
|
173
|
+
self.sl = slugger
|
|
174
|
+
self.out = []
|
|
175
|
+
self.toc = [] # (level, slug, text)
|
|
176
|
+
self.h2 = 0
|
|
177
|
+
self.stripped_notes = 0
|
|
178
|
+
self.diagrams = 0
|
|
179
|
+
self.bad_labels = []
|
|
180
|
+
|
|
181
|
+
# -- helpers ---------------------------------------------------------
|
|
182
|
+
def emit(self, s):
|
|
183
|
+
self.out.append(s)
|
|
184
|
+
|
|
185
|
+
def heading(self, level, text):
|
|
186
|
+
slug = self.sl.slug(text)
|
|
187
|
+
if level in (2, 3, 4):
|
|
188
|
+
self.emit('<h%d id="%s">%s<a class="anchor-link" href="#%s">#</a></h%d>'
|
|
189
|
+
% (level, slug, inline(text), slug, level))
|
|
190
|
+
else:
|
|
191
|
+
self.emit("<h%d>%s</h%d>" % (level, inline(text), level))
|
|
192
|
+
if level == 2:
|
|
193
|
+
self.h2 += 1
|
|
194
|
+
if level in (2, 3):
|
|
195
|
+
self.toc.append((level, slug, re.sub(r"<[^>]+>", "", inline(text))))
|
|
196
|
+
|
|
197
|
+
def mermaid(self, src):
|
|
198
|
+
self.diagrams += 1
|
|
199
|
+
self.check_mermaid(src)
|
|
200
|
+
# Concatenation, not %-formatting: this block legitimately contains
|
|
201
|
+
# "100%" and a % literal makes %-formatting choke on the next char.
|
|
202
|
+
self.emit('<div class="diagram">\n'
|
|
203
|
+
' <div class="dt">\n'
|
|
204
|
+
' <button data-zout title="Zoom out">−</button>\n'
|
|
205
|
+
' <span class="zlabel">100%</span>\n'
|
|
206
|
+
' <button data-zin title="Zoom in">+</button>\n'
|
|
207
|
+
' <button data-fit title="Fit to width">Fit</button>\n'
|
|
208
|
+
' <button data-one title="Actual size">1:1</button>\n'
|
|
209
|
+
' <button data-fs title="Fullscreen">⏎</button>\n'
|
|
210
|
+
' <button data-pop title="Open in new tab">↗</button>\n'
|
|
211
|
+
' </div>\n'
|
|
212
|
+
' <pre class="mermaid">' + src.rstrip() + '</pre>\n'
|
|
213
|
+
'</div>')
|
|
214
|
+
|
|
215
|
+
def check_mermaid(self, src):
|
|
216
|
+
"""§5.5 self-check: report unquoted labels containing special chars, and
|
|
217
|
+
a lowercase `end` node id. Reported, never silently rewritten — the
|
|
218
|
+
renderer does not get to guess what the author meant."""
|
|
219
|
+
# Scope: flowcharts ONLY. §5.5 says sequence diagrams rarely break —
|
|
220
|
+
# their message text is free-form, so `A->>B: SignIn(cookie: id, email)`
|
|
221
|
+
# is legal and scanning it reports pure false positives.
|
|
222
|
+
first = next((l.strip() for l in src.split("\n") if l.strip()), "")
|
|
223
|
+
if not re.match(r"^(flowchart|graph)\b", first):
|
|
224
|
+
return
|
|
225
|
+
for line in src.split("\n"):
|
|
226
|
+
# A quoted label is safe BY DEFINITION (§5.5 rule 1) and may legally
|
|
227
|
+
# contain (), /, & and the rest. Drop every quoted span first, or the
|
|
228
|
+
# scan reports the inside of correctly-quoted labels as broken.
|
|
229
|
+
bare = re.sub(r'"[^"]*"', '""', line)
|
|
230
|
+
# Each shape's label runs to its OWN closing bracket, so an inner
|
|
231
|
+
# "(v2)" stays part of the label instead of matching on its own.
|
|
232
|
+
cands = [m for m in re.finditer(r"\[([^\]]*)\]|\{([^}]*)\}|\(([^)]*)\)", bare)]
|
|
233
|
+
cands += list(re.finditer(r"\|([^|]+)\|", bare)) # rule 6 edge labels
|
|
234
|
+
for m in cands:
|
|
235
|
+
lab = next((g for g in m.groups() if g is not None), "")
|
|
236
|
+
core = re.sub(r'^[\[\({"\s]+|[\]\)}"\s]+$', "", lab.strip())
|
|
237
|
+
if not core or not re.search(r"[A-Za-z0-9]", core):
|
|
238
|
+
continue
|
|
239
|
+
if re.search(r"[()/\\&:,;#@<>|`%]", core):
|
|
240
|
+
self.bad_labels.append(core[:60])
|
|
241
|
+
for m in re.finditer(r"(?m)^\s*end\s*(\[|\(|\{|-->)", src):
|
|
242
|
+
self.bad_labels.append("reserved node id `end`")
|
|
243
|
+
|
|
244
|
+
def code(self, lang, src):
|
|
245
|
+
cls = ' class="language-%s"' % lang if lang else ""
|
|
246
|
+
self.emit("<pre><code%s>%s</code></pre>"
|
|
247
|
+
% (cls, _html.escape(src.rstrip("\n"), quote=False)))
|
|
248
|
+
|
|
249
|
+
def table(self, header, rows, aligns):
|
|
250
|
+
def cell(tag, text, i):
|
|
251
|
+
a = aligns[i] if i < len(aligns) else ""
|
|
252
|
+
st = ' style="text-align:%s"' % a if a else ""
|
|
253
|
+
return "<%s%s>%s</%s>" % (tag, st, inline(text), tag)
|
|
254
|
+
h = "".join(cell("th", c, i) for i, c in enumerate(header))
|
|
255
|
+
body = []
|
|
256
|
+
for r in rows:
|
|
257
|
+
body.append("<tr>%s</tr>"
|
|
258
|
+
% "".join(cell("td", c, i) for i, c in enumerate(r)))
|
|
259
|
+
self.emit("<table>\n<thead><tr>%s</tr></thead>\n<tbody>\n%s\n</tbody>\n</table>"
|
|
260
|
+
% (h, "\n".join(body)))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def split_row(line):
|
|
264
|
+
line = line.strip()
|
|
265
|
+
if line.startswith("|"):
|
|
266
|
+
line = line[1:]
|
|
267
|
+
if line.endswith("|"):
|
|
268
|
+
line = line[:-1]
|
|
269
|
+
parts, buf, esc = [], "", False
|
|
270
|
+
for ch in line:
|
|
271
|
+
if esc:
|
|
272
|
+
buf += ch
|
|
273
|
+
esc = False
|
|
274
|
+
elif ch == "\\":
|
|
275
|
+
esc = True
|
|
276
|
+
buf += "\\"
|
|
277
|
+
elif ch == "|":
|
|
278
|
+
parts.append(buf.strip())
|
|
279
|
+
buf = ""
|
|
280
|
+
else:
|
|
281
|
+
buf += ch
|
|
282
|
+
parts.append(buf.strip())
|
|
283
|
+
return [p.replace("\\|", "|") for p in parts]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse(lines, r):
|
|
287
|
+
i, n = 0, len(lines)
|
|
288
|
+
para = []
|
|
289
|
+
|
|
290
|
+
def flush_para():
|
|
291
|
+
if para:
|
|
292
|
+
r.emit("<p>%s</p>" % inline("\n".join(para).strip()))
|
|
293
|
+
del para[:]
|
|
294
|
+
|
|
295
|
+
while i < n:
|
|
296
|
+
line = lines[i]
|
|
297
|
+
|
|
298
|
+
# fenced block
|
|
299
|
+
m = FENCE.match(line)
|
|
300
|
+
if m:
|
|
301
|
+
flush_para()
|
|
302
|
+
marker, lang = m.group(2), (m.group(3) or "").lower()
|
|
303
|
+
i += 1
|
|
304
|
+
buf = []
|
|
305
|
+
while i < n and not (lines[i].strip().startswith(marker[0] * len(marker))
|
|
306
|
+
and lines[i].strip().rstrip(marker[0]) == ""):
|
|
307
|
+
buf.append(lines[i])
|
|
308
|
+
i += 1
|
|
309
|
+
i += 1
|
|
310
|
+
src = "\n".join(buf)
|
|
311
|
+
if lang == "mermaid":
|
|
312
|
+
r.mermaid(_html.escape(src, quote=False))
|
|
313
|
+
else:
|
|
314
|
+
r.code(lang, src)
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# html comment — invisible by design (§6b)
|
|
318
|
+
if line.lstrip().startswith("<!--"):
|
|
319
|
+
flush_para()
|
|
320
|
+
while i < n and "-->" not in lines[i]:
|
|
321
|
+
i += 1
|
|
322
|
+
i += 1
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
m = HEADING.match(line)
|
|
326
|
+
if m:
|
|
327
|
+
flush_para()
|
|
328
|
+
r.heading(len(m.group(1)), m.group(2))
|
|
329
|
+
i += 1
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
if HR.match(line) and not para:
|
|
333
|
+
flush_para()
|
|
334
|
+
r.emit("<hr>")
|
|
335
|
+
i += 1
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# table
|
|
339
|
+
if "|" in line and i + 1 < n and TABLE_SEP.match(lines[i + 1]):
|
|
340
|
+
flush_para()
|
|
341
|
+
header = split_row(line)
|
|
342
|
+
aligns = []
|
|
343
|
+
for spec in split_row(lines[i + 1]):
|
|
344
|
+
s = spec.strip()
|
|
345
|
+
aligns.append("center" if s.startswith(":") and s.endswith(":")
|
|
346
|
+
else "right" if s.endswith(":")
|
|
347
|
+
else "left" if s.startswith(":") else "")
|
|
348
|
+
i += 2
|
|
349
|
+
rows = []
|
|
350
|
+
while i < n and "|" in lines[i] and lines[i].strip():
|
|
351
|
+
rows.append(split_row(lines[i]))
|
|
352
|
+
i += 1
|
|
353
|
+
r.table(header, rows, aligns)
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# blockquote
|
|
357
|
+
if line.lstrip().startswith(">"):
|
|
358
|
+
flush_para()
|
|
359
|
+
buf = []
|
|
360
|
+
while i < n and (lines[i].lstrip().startswith(">") or
|
|
361
|
+
(buf and lines[i].strip() and not FENCE.match(lines[i])
|
|
362
|
+
and not HEADING.match(lines[i]))):
|
|
363
|
+
buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
|
|
364
|
+
i += 1
|
|
365
|
+
body = "\n".join(buf).strip()
|
|
366
|
+
if AGENT_NOTE.search(body.split("\n")[0]) or AGENT_NOTE.search(body[:160]):
|
|
367
|
+
r.stripped_notes += 1 # §6b — never shown to a reader
|
|
368
|
+
continue
|
|
369
|
+
sub = Renderer(r.sl)
|
|
370
|
+
sub.h2 = -10 ** 6 # quotes never contribute to TOC
|
|
371
|
+
parse(body.split("\n"), sub)
|
|
372
|
+
r.diagrams += sub.diagrams
|
|
373
|
+
r.bad_labels += sub.bad_labels
|
|
374
|
+
r.emit("<blockquote>\n%s\n</blockquote>" % "\n".join(sub.out))
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
# list
|
|
378
|
+
if LI.match(line):
|
|
379
|
+
flush_para()
|
|
380
|
+
i = parse_list(lines, i, r)
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
# raw html block (deliberate anchors, divs, details…)
|
|
384
|
+
if HTML_BLOCK.match(line) and not para:
|
|
385
|
+
flush_para()
|
|
386
|
+
r.emit(line)
|
|
387
|
+
i += 1
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
if not line.strip():
|
|
391
|
+
flush_para()
|
|
392
|
+
i += 1
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
para.append(line)
|
|
396
|
+
i += 1
|
|
397
|
+
|
|
398
|
+
flush_para()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def parse_list(lines, i, r):
|
|
402
|
+
n = len(lines)
|
|
403
|
+
m = LI.match(lines[i])
|
|
404
|
+
base = len(m.group(1))
|
|
405
|
+
ordered = not m.group(2) in ("-", "*", "+")
|
|
406
|
+
tag = "ol" if ordered else "ul"
|
|
407
|
+
r.emit("<%s>" % tag)
|
|
408
|
+
while i < n:
|
|
409
|
+
m = LI.match(lines[i])
|
|
410
|
+
if not m:
|
|
411
|
+
if not lines[i].strip():
|
|
412
|
+
# blank line: continue only if the next line is still this list
|
|
413
|
+
j = i + 1
|
|
414
|
+
while j < n and not lines[j].strip():
|
|
415
|
+
j += 1
|
|
416
|
+
if j < n and LI.match(lines[j]) and len(LI.match(lines[j]).group(1)) >= base:
|
|
417
|
+
i = j
|
|
418
|
+
continue
|
|
419
|
+
break
|
|
420
|
+
indent = len(m.group(1))
|
|
421
|
+
if indent < base:
|
|
422
|
+
break
|
|
423
|
+
if indent > base:
|
|
424
|
+
i = parse_list(lines, i, r)
|
|
425
|
+
continue
|
|
426
|
+
content = [m.group(3)]
|
|
427
|
+
i += 1
|
|
428
|
+
# lazy continuation lines belonging to this item
|
|
429
|
+
while i < n and lines[i].strip() and not LI.match(lines[i]) \
|
|
430
|
+
and not HEADING.match(lines[i]) and not FENCE.match(lines[i]) \
|
|
431
|
+
and len(lines[i]) - len(lines[i].lstrip()) > base:
|
|
432
|
+
content.append(lines[i].strip())
|
|
433
|
+
i += 1
|
|
434
|
+
r.emit("<li>%s" % inline(" ".join(content)))
|
|
435
|
+
if i < n:
|
|
436
|
+
m2 = LI.match(lines[i])
|
|
437
|
+
if m2 and len(m2.group(1)) > base:
|
|
438
|
+
i = parse_list(lines, i, r)
|
|
439
|
+
r.emit("</li>")
|
|
440
|
+
r.emit("</%s>" % tag)
|
|
441
|
+
return i
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ----------------------------------------------------------------------------
|
|
445
|
+
# Document assembly
|
|
446
|
+
# ----------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def frontmatter(lines):
|
|
449
|
+
if not lines or lines[0].strip() != "---":
|
|
450
|
+
return {}, lines
|
|
451
|
+
for j in range(1, len(lines)):
|
|
452
|
+
if lines[j].strip() == "---":
|
|
453
|
+
fm = {}
|
|
454
|
+
for ln in lines[1:j]:
|
|
455
|
+
if ":" in ln:
|
|
456
|
+
k, v = ln.split(":", 1)
|
|
457
|
+
fm[k.strip()] = v.strip().strip('"\'')
|
|
458
|
+
return fm, lines[j + 1:]
|
|
459
|
+
return {}, lines
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def is_requirements_checklist(base, raw):
|
|
463
|
+
"""Is this THE agent working document, or just a file with 'Checklist' in its name?
|
|
464
|
+
|
|
465
|
+
The §0 ban is about `docs/{AppName}-Checklist.md` — the per-REQ Requirements
|
|
466
|
+
Status table agents read in markdown. A deployment runbook, a release checklist
|
|
467
|
+
or a QA checklist is an ordinary human document that happens to share the word,
|
|
468
|
+
and refusing it pushes an agent back toward hand-authoring the HTML, which is
|
|
469
|
+
the exact path tf-render-html exists to remove (TfLens feedback TF-004).
|
|
470
|
+
|
|
471
|
+
So identify the document, do not guess from the suffix: the name must match AND
|
|
472
|
+
the content must carry the checklist's own structure — the `## Requirements
|
|
473
|
+
Status` heading, or the template's SINGLE SOURCE OF TRUTH marker comment. Both
|
|
474
|
+
ship in `app-checklist-tmpl.md`; a runbook has neither."""
|
|
475
|
+
if not re.search(r"-Checklist\.md$", base, re.I):
|
|
476
|
+
return False
|
|
477
|
+
head = raw[:8000]
|
|
478
|
+
return bool(re.search(r"^##\s+Requirements Status\s*$", head, re.M)
|
|
479
|
+
or "SINGLE SOURCE OF TRUTH" in head)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
NEXT_CMD_H2 = re.compile(r"^##\s+Next command to run\s*$", re.I)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def next_command_cta(base, lines):
|
|
486
|
+
"""The PROJECT-STATUS call-to-action box (render-workflow-docs §5).
|
|
487
|
+
|
|
488
|
+
`render-workflow-docs.md` §5 has always required a "NEXT COMMAND TO RUN" box at
|
|
489
|
+
the very top of <main>, and its Output Checklist repeats the requirement — but
|
|
490
|
+
when hand-authoring moved into this script under TF-003, the box did not come
|
|
491
|
+
with it. The fingerprint was left in plain sight: html-render-shell §2 defines
|
|
492
|
+
`--cta-bg` in both palettes and nothing consumed it. A spec that says "always add
|
|
493
|
+
this" beside a renderer that never does is worse than either alone, because it
|
|
494
|
+
makes the checklist item unfalsifiable by reading the task (TfLens TF-010).
|
|
495
|
+
|
|
496
|
+
So it is emitted here, mechanically, from the source the spec already names: the
|
|
497
|
+
first fenced code block under `## Next command to run`. Same shape as the
|
|
498
|
+
frontmatter special case above it.
|
|
499
|
+
|
|
500
|
+
Returns "" for every other document, and for a PROJECT-STATUS whose Next-command
|
|
501
|
+
section is missing or empty — an absent box is honest; an invented command is not.
|
|
502
|
+
"""
|
|
503
|
+
if base.lower() != "project-status.md":
|
|
504
|
+
return ""
|
|
505
|
+
cmd = None
|
|
506
|
+
for i, ln in enumerate(lines):
|
|
507
|
+
if not NEXT_CMD_H2.match(ln):
|
|
508
|
+
continue
|
|
509
|
+
for j in range(i + 1, len(lines)):
|
|
510
|
+
s = lines[j].strip()
|
|
511
|
+
if s.startswith("## "): # next section — nothing to show
|
|
512
|
+
break
|
|
513
|
+
if s.startswith("```"):
|
|
514
|
+
body = []
|
|
515
|
+
for k in range(j + 1, len(lines)):
|
|
516
|
+
if lines[k].strip().startswith("```"):
|
|
517
|
+
break
|
|
518
|
+
body.append(lines[k].rstrip())
|
|
519
|
+
body = [b for b in body if b.strip()]
|
|
520
|
+
if body:
|
|
521
|
+
cmd = body[0].strip()
|
|
522
|
+
break
|
|
523
|
+
break
|
|
524
|
+
if not cmd:
|
|
525
|
+
return ""
|
|
526
|
+
# Theme variables only — never a hardcoded hex, so the box stays legible in both
|
|
527
|
+
# palettes (§5's own instruction, and the reason --cta-bg exists).
|
|
528
|
+
return (' <div class="cta-next" style="padding:20px; background:var(--cta-bg); '
|
|
529
|
+
'border:2px solid var(--accent); border-radius:10px; margin:20px 0;">\n'
|
|
530
|
+
' <div style="font-size:11px; color:var(--muted); letter-spacing:.5px;">'
|
|
531
|
+
'NEXT COMMAND TO RUN</div>\n'
|
|
532
|
+
' <div style="font-family:var(--mono); font-size:18px; margin-top:6px;">'
|
|
533
|
+
'%s</div>\n'
|
|
534
|
+
' </div>\n' % _html.escape(cmd))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def render(md_path, css, head_js, body_js, out_dir=None):
|
|
538
|
+
base = os.path.basename(md_path)
|
|
539
|
+
raw_probe = open(md_path, encoding="utf-8").read().replace("\r\n", "\n")
|
|
540
|
+
if is_requirements_checklist(base, raw_probe):
|
|
541
|
+
raise Refused("%s is the requirements checklist — checklists are AI-agent "
|
|
542
|
+
"working documents and are NEVER rendered to HTML "
|
|
543
|
+
"(html-render-shell §0)." % base)
|
|
544
|
+
|
|
545
|
+
raw = raw_probe
|
|
546
|
+
lines = raw.split("\n")
|
|
547
|
+
fm, lines = frontmatter(lines)
|
|
548
|
+
|
|
549
|
+
title = None
|
|
550
|
+
for k, ln in enumerate(lines):
|
|
551
|
+
m = HEADING.match(ln)
|
|
552
|
+
if m and len(m.group(1)) == 1:
|
|
553
|
+
title = m.group(2).strip()
|
|
554
|
+
lines = lines[:k] + lines[k + 1:]
|
|
555
|
+
break
|
|
556
|
+
if not title:
|
|
557
|
+
title = re.sub(r"\.md$", "", base)
|
|
558
|
+
|
|
559
|
+
# Read the CTA source BEFORE parse() consumes `lines` (§5, TfLens TF-010).
|
|
560
|
+
cta = next_command_cta(base, lines)
|
|
561
|
+
|
|
562
|
+
r = Renderer(Slugger())
|
|
563
|
+
parse(lines, r)
|
|
564
|
+
|
|
565
|
+
today = datetime.date.today().isoformat()
|
|
566
|
+
subtitle = "Rendered %s · source <code>%s</code>" % (today, base)
|
|
567
|
+
|
|
568
|
+
fm_rows = ""
|
|
569
|
+
if fm:
|
|
570
|
+
fm_rows = ("<table>\n<tbody>\n%s\n</tbody>\n</table>" % "\n".join(
|
|
571
|
+
"<tr><th>%s</th><td>%s</td></tr>" % (_html.escape(k), inline(v))
|
|
572
|
+
for k, v in fm.items() if v))
|
|
573
|
+
|
|
574
|
+
toc_items = "\n".join(
|
|
575
|
+
' <li%s><a href="#%s">%s</a></li>'
|
|
576
|
+
% (' class="h3"' if lvl == 3 else "", slug, _html.escape(txt))
|
|
577
|
+
for lvl, slug, txt in r.toc)
|
|
578
|
+
|
|
579
|
+
inline_toc = ""
|
|
580
|
+
if r.h2 >= 2 and toc_items:
|
|
581
|
+
inline_toc = (' <div class="toc-inline">\n <div>Contents</div>\n'
|
|
582
|
+
' <ol>\n%s\n </ol>\n </div>\n' % toc_items)
|
|
583
|
+
|
|
584
|
+
side = ""
|
|
585
|
+
layout_cls = " no-toc"
|
|
586
|
+
if r.h2 > 6:
|
|
587
|
+
layout_cls = ""
|
|
588
|
+
side = (' <nav class="side">\n <h1>%s</h1>\n'
|
|
589
|
+
' <div class="sub">Last rendered: %s</div>\n'
|
|
590
|
+
' <div class="group">Contents</div>\n <ol>\n%s\n </ol>\n'
|
|
591
|
+
' </nav>\n' % (_html.escape(title), today, toc_items))
|
|
592
|
+
|
|
593
|
+
# Token replacement, NOT %-formatting or .format(): the §2 CSS and §7 JS are
|
|
594
|
+
# pasted in verbatim and are full of % (100%, max-width:100%) and { } — both
|
|
595
|
+
# of which those mechanisms would try to interpret.
|
|
596
|
+
skeleton = """<!doctype html>
|
|
597
|
+
<html lang="en">
|
|
598
|
+
<head>
|
|
599
|
+
<meta charset="utf-8">
|
|
600
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
601
|
+
<title>@@TITLE@@</title>
|
|
602
|
+
<script>
|
|
603
|
+
@@HEADJS@@
|
|
604
|
+
</script>
|
|
605
|
+
<style>
|
|
606
|
+
@@CSS@@
|
|
607
|
+
</style>
|
|
608
|
+
</head>
|
|
609
|
+
<body>
|
|
610
|
+
<button id="themeToggle" class="theme-toggle" title="Toggle light / dark">☾ Dark</button>
|
|
611
|
+
<div class="layout@@LAYOUT@@">
|
|
612
|
+
@@SIDE@@ <main>
|
|
613
|
+
<h1>@@TITLEH@@</h1>
|
|
614
|
+
<div class="subtitle">@@SUBTITLE@@</div>
|
|
615
|
+
@@CTA@@@@FM@@
|
|
616
|
+
@@INLINETOC@@@@BODY@@
|
|
617
|
+
</main>
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
|
621
|
+
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
|
|
622
|
+
<script>
|
|
623
|
+
@@JS@@
|
|
624
|
+
</script>
|
|
625
|
+
</body>
|
|
626
|
+
</html>
|
|
627
|
+
"""
|
|
628
|
+
doc = skeleton
|
|
629
|
+
for token, value in (("@@TITLE@@", _html.escape(title)),
|
|
630
|
+
("@@TITLEH@@", inline(title)),
|
|
631
|
+
("@@HEADJS@@", head_js),
|
|
632
|
+
("@@CSS@@", css),
|
|
633
|
+
("@@JS@@", body_js),
|
|
634
|
+
("@@LAYOUT@@", layout_cls),
|
|
635
|
+
("@@SIDE@@", side),
|
|
636
|
+
("@@SUBTITLE@@", subtitle),
|
|
637
|
+
("@@CTA@@", cta),
|
|
638
|
+
("@@FM@@", fm_rows),
|
|
639
|
+
("@@INLINETOC@@", inline_toc),
|
|
640
|
+
("@@BODY@@", "\n".join(r.out))):
|
|
641
|
+
doc = doc.replace(token, value)
|
|
642
|
+
|
|
643
|
+
target_dir = out_dir or os.path.dirname(os.path.abspath(md_path))
|
|
644
|
+
# --out may name a directory that does not exist yet; create it rather than
|
|
645
|
+
# failing with a bare ENOENT on the output path.
|
|
646
|
+
if out_dir:
|
|
647
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
648
|
+
out_path = os.path.join(target_dir, re.sub(r"\.md$", "", base) + ".html")
|
|
649
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
650
|
+
fh.write(doc)
|
|
651
|
+
return out_path, r, len(doc)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class Refused(Exception):
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def main(argv):
|
|
659
|
+
args = [a for a in argv if not a.startswith("--")]
|
|
660
|
+
quiet = "--quiet" in argv
|
|
661
|
+
out_dir = None
|
|
662
|
+
for a in argv:
|
|
663
|
+
if a.startswith("--out="):
|
|
664
|
+
out_dir = a.split("=", 1)[1]
|
|
665
|
+
spec_path = None
|
|
666
|
+
for a in argv:
|
|
667
|
+
if a.startswith("--spec="):
|
|
668
|
+
spec_path = a.split("=", 1)[1]
|
|
669
|
+
|
|
670
|
+
if not args:
|
|
671
|
+
print(__doc__.strip())
|
|
672
|
+
return 2
|
|
673
|
+
|
|
674
|
+
spec = find_spec(spec_path)
|
|
675
|
+
if not spec:
|
|
676
|
+
sys.stderr.write(
|
|
677
|
+
"tf-render-html: cannot find templates/v4custom/html-render-shell.md.\n"
|
|
678
|
+
" It ships in every installation at .tfcore/templates/v4custom/.\n"
|
|
679
|
+
" That path is hidden AND gitignored, so a search will not find it —\n"
|
|
680
|
+
" check the literal path, or run update-framework.sh <repo>.\n")
|
|
681
|
+
return 2
|
|
682
|
+
|
|
683
|
+
css, head_js, body_js = load_shell(spec)
|
|
684
|
+
|
|
685
|
+
rc = 0
|
|
686
|
+
for md in args:
|
|
687
|
+
if not os.path.isfile(md):
|
|
688
|
+
sys.stderr.write("tf-render-html: no such file: %s\n" % md)
|
|
689
|
+
rc = 1
|
|
690
|
+
continue
|
|
691
|
+
try:
|
|
692
|
+
out, r, size = render(md, css, head_js, body_js, out_dir)
|
|
693
|
+
except Refused as e:
|
|
694
|
+
sys.stderr.write("tf-render-html: REFUSED — %s\n" % e)
|
|
695
|
+
rc = 2
|
|
696
|
+
continue
|
|
697
|
+
except Exception as e: # noqa: BLE001
|
|
698
|
+
sys.stderr.write("tf-render-html: failed on %s: %s\n" % (md, e))
|
|
699
|
+
rc = 1
|
|
700
|
+
continue
|
|
701
|
+
if not quiet:
|
|
702
|
+
bits = ["%s (%.1f KB, %d H2" % (out, size / 1024.0, r.h2)]
|
|
703
|
+
bits.append("sidebar" if r.h2 > 6 else "no sidebar")
|
|
704
|
+
if r.diagrams:
|
|
705
|
+
bits.append("%d diagram%s" % (r.diagrams, "" if r.diagrams == 1 else "s"))
|
|
706
|
+
if r.stripped_notes:
|
|
707
|
+
bits.append("%d agent note(s) stripped per §6b" % r.stripped_notes)
|
|
708
|
+
print(" rendered %s)" % ", ".join(bits))
|
|
709
|
+
for lab in dict.fromkeys(r.bad_labels):
|
|
710
|
+
sys.stderr.write(" ⚠ mermaid §5.5: unquoted label or reserved id: %s\n" % lab)
|
|
711
|
+
return rc
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
if __name__ == "__main__":
|
|
715
|
+
sys.exit(main(sys.argv[1:]))
|