Publish the user guide as PDF and DOCX, and keep the rest of docs/ local
docs/ now holds only the user guide in the repository - Markdown, PDF and the new DOCX. The working notes, the scripts that build the guide and its screenshots are ignored; render-user-guide.py, tracked until now, leaves the repository but stays on disk. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c30ba1a38
commit
7459b1e70b
|
|
@ -44,9 +44,9 @@ Thumbs.db
|
|||
|
||||
# Working notes and local tooling, not part of the published source
|
||||
.claude/
|
||||
docs/project-memory.md
|
||||
docs/assistant-store-implementation-note.md
|
||||
docs/assistant-ui-guide.md
|
||||
docs/bias-impact-experiments-plan.md
|
||||
docs/vault-credential-gate-frontend.md
|
||||
docs/vault-credential-gate-tasks.md
|
||||
# In docs/ only the user guide is published; notes, the scripts that build the guide and its
|
||||
# screenshots stay local.
|
||||
docs/*
|
||||
!docs/user-guide.md
|
||||
!docs/user-guide.pdf
|
||||
!docs/user-guide.docx
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render the user guide to a print-ready HTML page.
|
||||
|
||||
From web/, to regenerate docs/user-guide.pdf after editing docs/user-guide.md:
|
||||
|
||||
python3 docs/render-user-guide.py docs/user-guide.md /tmp/ug.html \
|
||||
"HumAIn Flow" "User Guide" "Version of 16 September 2026"
|
||||
npx playwright pdf file:///tmp/ug.html docs/user-guide.pdf
|
||||
|
||||
Deliberately not a general Markdown implementation: it handles the constructs the
|
||||
guide actually uses, so the output is predictable and the print styling can be
|
||||
tuned for it (page breaks per chapter, a real table of contents, no orphan
|
||||
headings). Pandoc is not installed here and pulling one in for one document costs
|
||||
more than it saves.
|
||||
"""
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
|
||||
INLINE = [
|
||||
(re.compile(r"`([^`]+)`"), r"<code>\1</code>"),
|
||||
(re.compile(r"\*\*([^*]+)\*\*"), r"<strong>\1</strong>"),
|
||||
(re.compile(r"(?<![\w*])\*([^*\n]+)\*(?![\w*])"), r"<em>\1</em>"),
|
||||
(re.compile(r"\[([^\]]+)\]\(([^)]+)\)"), r'<a href="\2">\1</a>'),
|
||||
]
|
||||
|
||||
|
||||
def inline(text):
|
||||
out = html.escape(text)
|
||||
for pattern, replacement in INLINE:
|
||||
out = pattern.sub(replacement, out)
|
||||
return out
|
||||
|
||||
|
||||
def convert(markdown):
|
||||
lines = markdown.split("\n")
|
||||
out, toc = [], []
|
||||
i = 0
|
||||
list_stack = []
|
||||
|
||||
def close_lists(to_depth=0):
|
||||
while len(list_stack) > to_depth:
|
||||
out.append(f"</{list_stack.pop()}>")
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped:
|
||||
close_lists()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("```"):
|
||||
close_lists()
|
||||
i += 1
|
||||
block = []
|
||||
while i < len(lines) and not lines[i].strip().startswith("```"):
|
||||
block.append(html.escape(lines[i]))
|
||||
i += 1
|
||||
i += 1
|
||||
out.append("<pre><code>" + "\n".join(block) + "</code></pre>")
|
||||
continue
|
||||
|
||||
heading = re.match(r"^(#{1,4})\s+(.*)$", stripped)
|
||||
if heading:
|
||||
close_lists()
|
||||
level = len(heading.group(1))
|
||||
text = heading.group(2)
|
||||
anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
# Chapters are h2 and sections h3, so the contents list is those two levels.
|
||||
if 2 <= level <= 3:
|
||||
toc.append((level - 1, text, anchor))
|
||||
out.append(f'<h{level} id="{anchor}">{inline(text)}</h{level}>')
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("|") and i + 1 < len(lines) and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
|
||||
close_lists()
|
||||
header = [c.strip() for c in stripped.strip("|").split("|")]
|
||||
i += 2
|
||||
rows = []
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
||||
i += 1
|
||||
head = "".join(f"<th>{inline(c)}</th>" for c in header)
|
||||
body = "".join("<tr>" + "".join(f"<td>{inline(c)}</td>" for c in r) + "</tr>" for r in rows)
|
||||
out.append(f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>")
|
||||
continue
|
||||
|
||||
if stripped in ("---", "***"):
|
||||
close_lists()
|
||||
out.append('<hr class="page-break">')
|
||||
i += 1
|
||||
continue
|
||||
|
||||
bullet = re.match(r"^(\s*)[-*]\s+(.*)$", line)
|
||||
number = re.match(r"^(\s*)\d+\.\s+(.*)$", line)
|
||||
if bullet or number:
|
||||
match = bullet or number
|
||||
tag = "ul" if bullet else "ol"
|
||||
depth = len(match.group(1)) // 2 + 1
|
||||
while len(list_stack) > depth:
|
||||
out.append(f"</{list_stack.pop()}>")
|
||||
while len(list_stack) < depth:
|
||||
out.append(f"<{tag}>")
|
||||
list_stack.append(tag)
|
||||
out.append(f"<li>{inline(match.group(2))}</li>")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("> "):
|
||||
close_lists()
|
||||
out.append(f'<p class="note">{inline(stripped[2:])}</p>')
|
||||
i += 1
|
||||
continue
|
||||
|
||||
close_lists()
|
||||
paragraph = [stripped]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip() and not re.match(r"^\s*([-*]|\d+\.|#|\||>|```)", lines[i]):
|
||||
paragraph.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append(f"<p>{inline(' '.join(paragraph))}</p>")
|
||||
|
||||
close_lists()
|
||||
return "\n".join(out), toc
|
||||
|
||||
|
||||
CSS = """
|
||||
@page { size: A4; margin: 18mm 16mm 20mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 10.5pt/1.55 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: #1e293b; margin: 0; }
|
||||
h1 { font-size: 21pt; color: #0f172a; margin: 0 0 6pt; line-height: 1.25; }
|
||||
h2 { font-size: 19pt; color: #0f172a; page-break-before: always; page-break-after: avoid;
|
||||
border-top: 2px solid #2563eb; padding-top: 6pt; margin: 0 0 10pt; line-height: 1.25; }
|
||||
h3 { font-size: 12.5pt; color: #1d4ed8; margin: 16pt 0 5pt; page-break-after: avoid; }
|
||||
h4 { font-size: 10.8pt; color: #0f172a; margin: 12pt 0 3pt; page-break-after: avoid; }
|
||||
p { margin: 0 0 7pt; orphans: 2; widows: 2; }
|
||||
ul, ol { margin: 0 0 8pt; padding-left: 18pt; }
|
||||
li { margin-bottom: 2.5pt; }
|
||||
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9pt;
|
||||
background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 3px; padding: 0 3px; }
|
||||
pre { background: #f8fafc; border: 1px solid #e2e8f0; border-left: 3px solid #94a3b8;
|
||||
border-radius: 4px; padding: 8pt 10pt; overflow-x: auto; page-break-inside: avoid; }
|
||||
pre code { background: none; border: none; padding: 0; font-size: 8.5pt; line-height: 1.45; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 0 0 10pt; font-size: 9.5pt;
|
||||
page-break-inside: avoid; }
|
||||
th { background: #eff6ff; color: #1e3a8a; text-align: left; font-weight: 600; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 4pt 6pt; vertical-align: top; }
|
||||
.note { border-left: 3px solid #f59e0b; background: #fffbeb; padding: 6pt 9pt;
|
||||
margin: 0 0 8pt; page-break-inside: avoid; }
|
||||
hr.page-break { border: 0; page-break-after: always; }
|
||||
.cover { height: 247mm; display: flex; flex-direction: column; justify-content: center; }
|
||||
.cover-title { font-size: 34pt; border: 0; padding: 0; }
|
||||
.cover-sub { font-size: 13pt; color: #475569; margin-top: 2pt; }
|
||||
.cover-meta { margin-top: 26pt; font-size: 10pt; color: #64748b; }
|
||||
.toc { page-break-after: always; }
|
||||
.toc-title { font-size: 19pt; color: #0f172a; border-top: 2px solid #2563eb;
|
||||
padding-top: 6pt; margin: 0 0 10pt; }
|
||||
.toc ol { list-style: none; padding-left: 0; counter-reset: chapter; }
|
||||
.toc li.l1 { counter-increment: chapter; font-weight: 600; margin-top: 6pt; }
|
||||
.toc li.l1::before { content: counter(chapter) ". "; color: #2563eb; }
|
||||
.toc li.l2 { padding-left: 16pt; font-weight: 400; font-size: 9.5pt; color: #475569; }
|
||||
.toc a { color: inherit; text-decoration: none; }
|
||||
a { color: #1d4ed8; }
|
||||
"""
|
||||
|
||||
|
||||
def main(md_path, html_path, title, subtitle, meta):
|
||||
# The cover carries the document title, so the file's own leading H1 would only repeat it.
|
||||
source = re.sub(r"\A\s*#\s+.*\n", "", open(md_path).read())
|
||||
body, toc = convert(source)
|
||||
items = "".join(
|
||||
f'<li class="l{level}"><a href="#{anchor}">{html.escape(text)}</a></li>'
|
||||
for level, text, anchor in toc
|
||||
)
|
||||
page = f"""<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>
|
||||
<style>{CSS}</style></head><body>
|
||||
<div class="cover"><h1 class="cover-title">{html.escape(title)}</h1>
|
||||
<div class="cover-sub">{html.escape(subtitle)}</div>
|
||||
<div class="cover-meta">{html.escape(meta)}</div></div>
|
||||
<div class="toc"><div class="toc-title">Contents</div><ol>{items}</ol></div>
|
||||
{body}</body></html>"""
|
||||
open(html_path, "w").write(page)
|
||||
print(f"html written: {html_path} ({len(page)} bytes, {len(toc)} toc entries)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(*sys.argv[1:])
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue