diff --git a/README.md b/README.md index decc350..2a4196a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ When you pick a spec from the interactive menu, devloop uses the standard run de ## Specs -A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md). The bundled `devloop-spec` skill can also render a sibling HTML companion with [`skills/devloop-spec/scripts/render.py`](skills/devloop-spec/scripts/render.py). +A good spec is short, concrete, and verifiable. Start from [`skills/devloop-spec/references/spec-template.md`](skills/devloop-spec/references/spec-template.md). The bundled `devloop-spec` skill can also render a sibling HTML companion with [`skills/devloop-spec/scripts/render.sh`](skills/devloop-spec/scripts/render.sh). Strict mode is on by default: specs need `## Acceptance criteria`, and reviews must pass both the spec gate and engineering quality gate. diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh index a289c16..2f73355 100755 --- a/scripts/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -36,7 +36,22 @@ equals() { [[ "$actual" == "$expected" ]] || fail "$label expected [$expected], got [$actual]" } -bash -n "$REPO_ROOT/devloop" "$SCRIPTS_DIR/install.sh" "$SCRIPTS_DIR/uninstall.sh" "$SCRIPTS_DIR/skill_helpers.sh" "$SCRIPTS_DIR/release.sh" "$REMOTE_INSTALLER" "$REPO_ROOT/site/public/install" +count_occurrences() { + local file="$1" + local needle="$2" + awk -v needle="$needle" ' + { + line = $0 + while ((idx = index(line, needle)) > 0) { + count++ + line = substr(line, idx + length(needle)) + } + } + END { print count + 0 } + ' "$file" +} + +bash -n "$REPO_ROOT/devloop" "$SCRIPTS_DIR/install.sh" "$SCRIPTS_DIR/uninstall.sh" "$SCRIPTS_DIR/skill_helpers.sh" "$SCRIPTS_DIR/release.sh" "$REMOTE_INSTALLER" "$REPO_ROOT/site/public/install" "$REPO_ROOT/skills/devloop-spec/scripts/render.sh" ok "bash syntax" DEVLOOP_LIB=1 @@ -131,8 +146,11 @@ ok "skill metadata" work=$(mktemp -d "${TMPDIR:-/tmp}/devloop-test.XXXXXX") trap 'rm -rf "$work"' EXIT -renderer_fixture="$work/spec-render-fixture.md" -cat > "$renderer_fixture" <<'SPEC' +renderer_script="$REPO_ROOT/skills/devloop-spec/scripts/render.sh" +[[ -x "$renderer_script" ]] || fail "missing executable bash spec renderer" + +regular_renderer_fixture="$work/spec-render-fixture.md" +cat > "$regular_renderer_fixture" <<'SPEC' --- status: draft type: feat @@ -141,13 +159,7 @@ pr: null --- # Renderer Fixture -Render the spec with light styling and robust Mermaid labels. - -```mermaid -flowchart LR - Modes[Gmail/Outlook | IMAP] --> Result["Rendered HTML"] - Danger[""] --> Result -``` +Render the spec with light styling and robust escaping. ## Problem Renderer regressions are hard to spot from markdown alone. @@ -155,6 +167,7 @@ Renderer regressions are hard to spot from markdown alone. ```markdown ## This is inside a code fence - not a real section +& ``` The paragraph after the fenced heading still belongs to Problem. @@ -172,14 +185,14 @@ The HTML companion renders the spec sections. 2. HTML is written next to the markdown. ### Edge cases -- Mermaid label contains `|`: renderer quotes the label. +- HTML-sensitive fenced content is escaped. ## Acceptance criteria 1. The generated HTML uses the light theme. ## Test plan - Red: Not applicable for fixture. -- Green: `python3 skills/devloop-spec/scripts/render.py ` +- Green: `skills/devloop-spec/scripts/render.sh ` - Full: `bash scripts/devloop_test.sh` - Coverage: Not applicable for Bash fixture. @@ -191,14 +204,53 @@ The HTML companion renders the spec sections. ## Notes No gaps. SPEC -renderer_output="$(python3 "$REPO_ROOT/skills/devloop-spec/scripts/render.py" "$renderer_fixture")" +if "$renderer_script" >/tmp/devloop-renderer-usage.out 2>&1; then + fail "spec renderer accepted missing argument" +fi +if "$renderer_script" "$regular_renderer_fixture" "$regular_renderer_fixture" >/tmp/devloop-renderer-usage.out 2>&1; then + fail "spec renderer accepted extra argument" +fi +renderer_output="$("$renderer_script" "$regular_renderer_fixture")" [[ -f "$renderer_output" ]] || fail "spec renderer did not create HTML" contains "$(cat "$renderer_output")" "--bg: #ffffff" "spec renderer light theme" -contains "$(cat "$renderer_output")" 'Modes["Gmail/Outlook | IMAP"]' "spec renderer Mermaid quoting" -contains "$(cat "$renderer_output")" 'Danger["</pre>"]' "spec renderer Mermaid escaping" -not_contains "$(cat "$renderer_output")" '

This is inside a code fence

' "spec renderer fenced heading" +not_contains "$(cat "$renderer_output")" 'This is inside a code fence' "spec renderer fenced heading" +contains "$(cat "$renderer_output")" '</pre><script>alert("x")</script>&' "spec renderer escaped fenced HTML" contains "$(cat "$renderer_output")" "The paragraph after the fenced heading still belongs to Problem." "spec renderer fenced content" -contains "$(cat "$renderer_output")" $'
\n

Acceptance criteria

' "spec renderer acceptance open" +contains "$(cat "$renderer_output")" $'
\n Acceptance criteria' "spec renderer acceptance open" +not_contains "$(cat "$renderer_output")" "mermaid.esm.min.mjs" "spec renderer without Mermaid" +expected_renderer_output="$(cd -P "$(dirname "$regular_renderer_fixture")" >/dev/null 2>&1 && pwd)/$(basename "${regular_renderer_fixture%.md}.html")" +equals "$renderer_output" "$expected_renderer_output" "spec renderer output path" + +mermaid_renderer_fixture="$work/spec-render-mermaid-fixture.md" +cat > "$mermaid_renderer_fixture" <<'SPEC' +--- +status: draft +type: feat +created: 2026-06-16 +pr: null +--- + +# Mermaid Renderer Fixture +Render Mermaid fences without requiring local dependencies. + +```mermaid +flowchart LR + Modes["Gmail/Outlook | IMAP"] --> Result["Rendered HTML"] + Danger[""] --> Result +``` + +## Problem +Mermaid should render in the browser when diagrams are present. + +## Acceptance criteria +1. The generated HTML imports Mermaid exactly once. +SPEC +mermaid_renderer_output="$("$renderer_script" "$mermaid_renderer_fixture")" +[[ -f "$mermaid_renderer_output" ]] || fail "Mermaid spec renderer did not create HTML" +contains "$(cat "$mermaid_renderer_output")" '
' "spec renderer Mermaid pre"
+contains "$(cat "$mermaid_renderer_output")" 'Modes["Gmail/Outlook | IMAP"]' "spec renderer Mermaid pass-through"
+contains "$(cat "$mermaid_renderer_output")" 'Danger["</pre>"]' "spec renderer Mermaid escaping"
+equals "$(count_occurrences "$mermaid_renderer_output" "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")" "1" "spec renderer Mermaid import count"
 ok "spec renderer"
 
 equals "$(sed -n '1p' "$REPO_ROOT/site/public/VERSION")" "$version" "site VERSION matches root VERSION"
@@ -1126,11 +1178,13 @@ PATH="$install_path" command -v gum >/dev/null 2>&1 || fail "installer did not m
 PATH="$install_path" command -v fzf >/dev/null 2>&1 || fail "installer did not make fzf available"
 [[ -f "$install_home/.agents/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Codex spec skill"
 [[ -f "$install_home/.agents/skills/devloop-spec/references/spec-template.md" ]] || fail "installer did not install Codex spec template reference"
-[[ -f "$install_home/.agents/skills/devloop-spec/scripts/render.py" ]] || fail "installer did not install Codex spec renderer"
+[[ -x "$install_home/.agents/skills/devloop-spec/scripts/render.sh" ]] || fail "installer did not install Codex spec renderer"
+[[ ! -e "$install_home/.agents/skills/devloop-spec/scripts/render.py" ]] || fail "installer installed removed Codex Python spec renderer"
 [[ -f "$install_home/.agents/skills/devloop-review/SKILL.md" ]] || fail "installer did not install Codex review skill"
 [[ -f "$install_home/.agents/skills/devloop-review/.devloop-checksum" ]] || fail "installer did not write Codex checksum"
 [[ -f "$install_home/.claude/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Claude spec skill"
-[[ -f "$install_home/.claude/skills/devloop-spec/scripts/render.py" ]] || fail "installer did not install Claude spec renderer"
+[[ -x "$install_home/.claude/skills/devloop-spec/scripts/render.sh" ]] || fail "installer did not install Claude spec renderer"
+[[ ! -e "$install_home/.claude/skills/devloop-spec/scripts/render.py" ]] || fail "installer installed removed Claude Python spec renderer"
 [[ -f "$install_home/.claude/skills/devloop-review/SKILL.md" ]] || fail "installer did not install Claude review skill"
 [[ -f "$install_home/.claude/skills/devloop-review/.devloop-checksum" ]] || fail "installer did not write Claude checksum"
 "$bin_dir/devloop" --help >/tmp/devloop-help-test.out
diff --git a/skills/devloop-spec/SKILL.md b/skills/devloop-spec/SKILL.md
index 5f07600..3134db3 100644
--- a/skills/devloop-spec/SKILL.md
+++ b/skills/devloop-spec/SKILL.md
@@ -16,7 +16,7 @@ The markdown spec is the source of truth that `devloop` will use as implementati
 Available resources:
 
 - `references/spec-template.md`: read when drafting or validating the spec shape.
-- `scripts/render.py`: run after writing a markdown spec to create a sibling HTML companion.
+- `scripts/render.sh`: run after writing a markdown spec to create a sibling HTML companion.
 
 ## Scope Guard
 
@@ -162,12 +162,12 @@ Do not wrap the spec in a code fence unless the caller explicitly asks for a fen
 When a markdown spec is written to a file, render the interactive HTML companion if the bundled script is available:
 
 ```bash
-python3 scripts/render.py 
+scripts/render.sh 
 ```
 
-Run the command from the skill directory, or resolve `scripts/render.py` relative to this skill's `SKILL.md`. The script writes `.html` next to the markdown.
+Run the command from the skill directory, or resolve `scripts/render.sh` relative to this skill's `SKILL.md`. The script writes `.html` next to the markdown.
 
-If rendering fails because of Mermaid syntax, fix the markdown source and rerun the renderer. If rendering cannot run in the current environment, keep the markdown spec and say HTML was not generated.
+If Mermaid fails in the browser, fix the markdown source and rerun the renderer. If rendering cannot run in the current environment, keep the markdown spec and say HTML was not generated.
 
 ## Signoff
 
diff --git a/skills/devloop-spec/scripts/render.py b/skills/devloop-spec/scripts/render.py
deleted file mode 100755
index a044e10..0000000
--- a/skills/devloop-spec/scripts/render.py
+++ /dev/null
@@ -1,613 +0,0 @@
-#!/usr/bin/env python3
-"""Render a markdown spec into an interactive HTML companion next to it."""
-
-from __future__ import annotations
-
-import html
-import re
-import sys
-from pathlib import Path
-
-
-TEMPLATE = """
-
-
-
-
-{title}
-
-
-
-
- -
-
{meta}
-

{h1}

- {subtitle} -
- -{sections} - -
- {src_path} - click headings to collapse -
- -
-{mermaid_script} - - - -""" - -MERMAID_SCRIPT = """ -""" - - -def quote_mermaid_labels(src: str) -> str: - """Quote Mermaid node labels containing `|`, which Mermaid reserves for edge labels.""" - def repl(m: re.Match[str]) -> str: - inner = m.group(1) - if inner[:1] == '"' or "|" not in inner: - return m.group(0) - return '["' + inner.replace('"', "'") + '"]' - - return re.sub(r"\[([^\]\n]+)\]", repl, src) - - -def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: - """Return (frontmatter dict, body without frontmatter).""" - if not text.startswith("---\n"): - return {}, text - end = text.find("\n---\n", 4) - if end == -1: - return {}, text - raw = text[4:end] - body = text[end + 5 :] - fm: dict[str, str] = {} - for line in raw.splitlines(): - if ":" in line: - k, v = line.split(":", 1) - fm[k.strip()] = v.strip() - return fm, body - - -def split_sections(body: str) -> tuple[str, list[tuple[str, str]]]: - """Return (preamble, [(h2_title, h2_body), ...]). - - Preamble is anything before the first ## heading (typically the H1 + intro). - """ - lines = body.splitlines() - preamble: list[str] = [] - sections: list[tuple[str, list[str]]] = [] - current: tuple[str, list[str]] | None = None - in_fence = False - for line in lines: - if line.startswith("```"): - in_fence = not in_fence - if not in_fence and line.startswith("## "): - if current is not None: - sections.append(current) - current = (line[3:].strip(), []) - elif current is not None: - current[1].append(line) - else: - preamble.append(line) - if current is not None: - sections.append(current) - return "\n".join(preamble), [(t, "\n".join(b).strip()) for t, b in sections] - - -INLINE_CODE = re.compile(r"`([^`\n]+)`") -BOLD = re.compile(r"\*\*([^*\n]+)\*\*") -ITALIC = re.compile(r"(? str: - """Render inline markdown (code, bold, italic, links) into HTML.""" - placeholders: list[str] = [] - - def stash(match: re.Match[str], wrap: str) -> str: - idx = len(placeholders) - placeholders.append(wrap.format(content=html.escape(match.group(1)))) - return f"\x00{idx}\x00" - - text = INLINE_CODE.sub(lambda m: stash(m, "{content}"), text) - - def link(match: re.Match[str]) -> str: - idx = len(placeholders) - placeholders.append( - f'{html.escape(match.group(1))}' - ) - return f"\x00{idx}\x00" - - text = LINK.sub(link, text) - text = html.escape(text) - text = BOLD.sub(r"\1", text) - text = ITALIC.sub(r"\1", text) - - def restore(match: re.Match[str]) -> str: - return placeholders[int(match.group(1))] - - return re.sub(r"\x00(\d+)\x00", restore, text) - - -def looks_like_tree(content: str) -> bool: - """True if a code block looks like a directory tree.""" - lines = [ln for ln in content.splitlines() if ln.strip()] - if len(lines) < 3: - return False - has_dir = any(ln.rstrip().endswith("/") for ln in lines) - has_indent = any(ln.startswith((" ", "\t")) for ln in lines) - return has_dir and has_indent - - -def render_tree(content: str) -> str: - """Colour a directory-tree-ish code block: dirs in accent, comments in mute.""" - out: list[str] = [] - for line in content.splitlines(): - match = re.match(r"^(\s*)(\S.*?)(\s+#\s.*)?$", line) - if not match: - out.append(html.escape(line)) - continue - indent, body, comment = match.group(1), match.group(2), match.group(3) or "" - if body.endswith("/"): - piece = f'{html.escape(body)}' - else: - piece = f'{html.escape(body)}' - if comment: - piece += f'{html.escape(comment)}' - out.append(f"{indent}{piece}") - return "\n".join(out) - - -UL_RE = re.compile(r"^(\s*)-\s+(?:\[ \]\s+)?(.*)$") -OL_RE = re.compile(r"^(\s*)\d+\.\s+(.*)$") - - -def parse_list(lines: list[str]) -> list[dict]: - """Parse a flat list with optional indented children. Returns nested item tree.""" - items: list[dict] = [] - stack: list[tuple[int, dict]] = [] - pending_continuation: dict | None = None - - for ln in lines: - if not ln.strip(): - continue - ul = UL_RE.match(ln) - ol = OL_RE.match(ln) - if ul or ol: - match = ul or ol - indent = len(match.group(1)) # type: ignore[union-attr] - text = match.group(2) # type: ignore[union-attr] - ordered = bool(ol) - node = {"text": text, "ordered": ordered, "children": []} - while stack and stack[-1][0] >= indent: - stack.pop() - if stack: - stack[-1][1]["children"].append(node) - else: - items.append(node) - stack.append((indent, node)) - pending_continuation = node - elif ln.startswith(" ") and pending_continuation is not None: - pending_continuation["text"] += " " + ln.strip() - return items - - -def render_list_items(items: list[dict], *, list_class: str = "") -> str: - """Render a parsed list tree into nested
    /
      .""" - if not items: - return "" - ordered = items[0]["ordered"] - tag = "ol" if ordered else "ul" - cls = f' class="{list_class}"' if list_class else "" - parts = [] - for item in items: - child_html = "" - if item["children"]: - child_html = render_list_items(item["children"]) - parts.append(f"
    1. {render_inline(item['text'])}{child_html}
    2. ") - return f"<{tag}{cls}>{''.join(parts)}" - - -BEHAVIOR_LABELS = {"happy path:", "edge cases:"} -CORE_OPEN_SECTIONS = { - "problem", - "outcome", - "scope", - "behavior", - "acceptance criteria", -} - - -def render_block( - block: str, - *, - in_acceptance: bool, - in_behavior: bool, - in_steps: bool, - h3_steps: bool, -) -> str: - """Render a single markdown block (paragraph, list, code fence, etc.).""" - block = block.rstrip() - if not block.strip(): - return "" - - if block.startswith("```"): - first_nl = block.find("\n") - lang = block[3:first_nl].strip() if first_nl != -1 else "" - end = block.rfind("```") - content = block[first_nl + 1 : end].rstrip("\n") if first_nl != -1 else "" - if lang == "mermaid": - mermaid = html.escape(quote_mermaid_labels(content), quote=False) - return f'
      {mermaid}
      ' - if looks_like_tree(content): - return f'
      {render_tree(content)}
      ' - return f"
      {html.escape(content)}
      " - - lines = block.splitlines() - first_line = next((ln for ln in lines if ln.strip()), "") - block_label = block.strip().lower() - - if in_behavior and block_label in BEHAVIOR_LABELS: - return f"

      {render_inline(block.strip().rstrip(':'))}

      " - - if UL_RE.match(first_line) or OL_RE.match(first_line): - items = parse_list(lines) - ordered = items[0]["ordered"] if items else False - list_class = "" - if in_acceptance: - list_class = "ac" - elif ordered and (in_steps or h3_steps): - list_class = "steps" - return render_list_items(items, list_class=list_class) - - if block.startswith("### "): - return f"

      {render_inline(block[4:].strip())}

      " - - if block.strip() == "---": - return "
      " - - paragraph = " ".join(ln.strip() for ln in lines) - return f"

      {render_inline(paragraph)}

      " - - -def split_blocks(text: str) -> list[str]: - """Split section body into blocks: paragraphs, lists, code fences.""" - blocks: list[str] = [] - buf: list[str] = [] - in_fence = False - - def flush() -> None: - if buf: - blocks.append("\n".join(buf).strip("\n")) - buf.clear() - - for line in text.splitlines(): - if line.startswith("```"): - if in_fence: - buf.append(line) - in_fence = False - flush() - else: - flush() - buf.append(line) - in_fence = True - continue - if in_fence: - buf.append(line) - continue - if line.startswith("### "): - flush() - buf.append(line) - flush() - continue - if line.strip().lower() in BEHAVIOR_LABELS: - flush() - buf.append(line) - flush() - continue - if not line.strip(): - flush() - else: - buf.append(line) - flush() - return [b for b in blocks if b.strip()] - - -STEP_KEYWORDS = ("build order", "steps", "build plan") - - -def render_section(title: str, body: str, *, idx: int) -> str: - """Render one ## section. Core spec sections open by default.""" - title_lower = title.lower() - in_acceptance = "acceptance" in title_lower - in_behavior = title_lower == "behavior" - in_steps = any(k in title_lower for k in STEP_KEYWORDS) - - blocks = split_blocks(body) - parts: list[str] = [] - h3_steps = False - for b in blocks: - if b.startswith("### "): - h3_title = b[4:].splitlines()[0].strip().lower() - h3_steps = any(k in h3_title for k in STEP_KEYWORDS) - elif not (UL_RE.match(b.splitlines()[0]) or OL_RE.match(b.splitlines()[0])): - h3_steps = False - parts.append( - render_block( - b, - in_acceptance=in_acceptance, - in_behavior=in_behavior, - in_steps=in_steps, - h3_steps=h3_steps, - ) - ) - inner = "\n".join(p for p in parts if p) - - open_cls = "open" if idx < 3 or title_lower in CORE_OPEN_SECTIONS else "" - cls_attr = f' class="{open_cls}"' if open_cls else "" - return ( - f"\n" - f'

      {html.escape(title)}

      \n' - f'
      {inner}
      \n' - f"
" - ) - - -def extract_title_parts(preamble: str) -> tuple[str, str, str]: - """Return (h1_text, subtitle_text, remaining_preamble).""" - lines = preamble.splitlines() - h1 = "" - rest: list[str] = [] - for line in lines: - if not h1 and line.startswith("# "): - h1 = line[2:].strip() - else: - rest.append(line) - - subtitle = "" - subtitle_idx: int | None = None - for idx, line in enumerate(rest): - stripped = line.strip() - if not stripped: - continue - if stripped.startswith(("```", "#", "- ", "* ", ">")) or stripped == "---": - break - if re.match(r"^\d+\.\s+", stripped): - break - subtitle = stripped - subtitle_idx = idx - break - - if subtitle_idx is not None: - del rest[subtitle_idx] - return h1, subtitle, "\n".join(rest).strip() - - -def render_preamble(text: str) -> str: - """Render the bit between the H1 and the first ## (if any).""" - if not text.strip(): - return "" - blocks = split_blocks(text) - parts = [ - render_block( - b, - in_acceptance=False, - in_behavior=False, - in_steps=False, - h3_steps=False, - ) - for b in blocks - ] - return "\n".join(p for p in parts if p) - - -def build_meta_line(fm: dict[str, str]) -> str: - """Build the small uppercase meta line under the title.""" - bits = [] - if "created" in fm: - bits.append(f"created {fm['created']}") - if "pr" in fm: - pr = fm["pr"] - if pr in ("[]", "", "null"): - bits.append("pr: none") - else: - bits.append(f"pr: {pr}") - bits.append("spec") - return " · ".join(bits) - - -def render(md_path: Path) -> Path: - """Render md_path → sibling .html. Return the HTML path.""" - text = md_path.read_text(encoding="utf-8") - fm, body = parse_frontmatter(text) - preamble, sections = split_sections(body) - h1, subtitle, intro = extract_title_parts(preamble) - intro_html = render_preamble(intro) - section_html = "\n\n".join( - render_section(title, sec_body, idx=i) - for i, (title, sec_body) in enumerate(sections) - ) - rendered_sections = (intro_html + "\n\n" + section_html).strip() - - has_mermaid = "```mermaid" in text - out = TEMPLATE.format( - title=html.escape(h1 or md_path.stem), - h1=html.escape(h1 or md_path.stem), - subtitle=( - f'

{render_inline(subtitle)}

' if subtitle else "" - ), - meta=html.escape(build_meta_line(fm)), - sections=rendered_sections, - src_path=html.escape(str(md_path)), - mermaid_script=MERMAID_SCRIPT if has_mermaid else "", - ) - out_path = md_path.with_suffix(".html") - out_path.write_text(out, encoding="utf-8") - return out_path - - -def main() -> int: - if len(sys.argv) != 2: - print("usage: render.py ", file=sys.stderr) - return 2 - md_path = Path(sys.argv[1]).expanduser().resolve() - if not md_path.exists(): - print(f"not found: {md_path}", file=sys.stderr) - return 1 - out = render(md_path) - print(out) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/devloop-spec/scripts/render.sh b/skills/devloop-spec/scripts/render.sh new file mode 100755 index 0000000..f976dac --- /dev/null +++ b/skills/devloop-spec/scripts/render.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + printf 'usage: render.sh \n' >&2 + exit 2 +fi + +src="$1" +if [ ! -f "$src" ]; then + printf 'not found: %s\n' "$src" >&2 + exit 1 +fi + +src_dir="$(cd -P "$(dirname "$src")" >/dev/null 2>&1 && pwd)" +src_base="$(basename "$src")" +src_path="$src_dir/$src_base" +out_path="$src_dir/${src_base%.*}.html" +tmp_path="$out_path.tmp.$$" + +cleanup() { + rm -f "$tmp_path" +} +trap cleanup EXIT + +awk -v src_path="$src_path" -v stem="${src_base%.*}" ' +function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s +} + +function append_line(text, line) { + return text == "" ? line : text "\n" line +} + +function append_html(text, part) { + if (part == "") { + return text + } + return text == "" ? part : text "\n" part +} + +function html_escape(s) { + gsub(/&/, "\\&", s) + gsub(//, "\\>", s) + gsub(/"/, "\\"", s) + return s +} + +function render_inline(s) { + return html_escape(s) +} + +function is_ordered(line) { + return line ~ /^[[:space:]]*[0-9]+[.][[:space:]]+/ +} + +function is_unordered(line) { + return line ~ /^[[:space:]]*-[[:space:]]+/ +} + +function render_list(block, title, lines, n, i, line, text, current, ordered, tag, class_attr, out, title_lower) { + n = split(block, lines, "\n") + ordered = is_ordered(lines[1]) + tag = ordered ? "ol" : "ul" + title_lower = tolower(title) + class_attr = "" + if (index(title_lower, "acceptance") > 0) { + class_attr = " class=\"ac\"" + } else if (ordered) { + class_attr = " class=\"steps\"" + } + out = "<" tag class_attr ">" + current = "" + for (i = 1; i <= n; i++) { + line = lines[i] + if ((ordered && is_ordered(line)) || (!ordered && is_unordered(line))) { + if (current != "") { + out = out "
  • " render_inline(current) "
  • " + } + text = line + if (ordered) { + sub(/^[[:space:]]*[0-9]+[.][[:space:]]+/, "", text) + } else { + sub(/^[[:space:]]*-[[:space:]]+(\[[[:space:]]\][[:space:]]+)?/, "", text) + } + current = trim(text) + } else if (trim(line) != "" && current != "") { + current = current " " trim(line) + } + } + if (current != "") { + out = out "
  • " render_inline(current) "
  • " + } + return out "" +} + +function render_code_block(block, lines, n, first, lang, content, end, i) { + n = split(block, lines, "\n") + first = lines[1] + lang = trim(substr(first, 4)) + content = "" + end = n + if (n > 1 && lines[n] ~ /^```/) { + end = n - 1 + } + for (i = 2; i <= end; i++) { + content = append_line(content, lines[i]) + } + if (lang == "mermaid") { + return "
    " html_escape(content) "
    " + } + return "
    " html_escape(content) "
    " +} + +function render_block(block, title, lines, first, title_text, paragraph, i) { + block = trim(block) + if (block == "") { + return "" + } + split(block, lines, "\n") + first = lines[1] + if (first ~ /^```/) { + return render_code_block(block) + } + if (first ~ /^###[[:space:]]+/) { + title_text = first + sub(/^###[[:space:]]+/, "", title_text) + return "

    " render_inline(trim(title_text)) "

    " + } + if (is_unordered(first) || is_ordered(first)) { + return render_list(block, title) + } + if (trim(block) == "---") { + return "
    " + } + paragraph = "" + for (i = 1; i <= split(block, lines, "\n"); i++) { + paragraph = paragraph == "" ? trim(lines[i]) : paragraph " " trim(lines[i]) + } + return "

    " render_inline(paragraph) "

    " +} + +function render_blocks(text, title, lines, n, i, line, label, block, in_fence, out, title_lower) { + n = split(text, lines, "\n") + block = "" + in_fence = 0 + out = "" + title_lower = tolower(title) + for (i = 1; i <= n; i++) { + line = lines[i] + if (line ~ /^```/) { + if (in_fence) { + block = append_line(block, line) + in_fence = 0 + out = append_html(out, render_block(block, title)) + block = "" + } else { + out = append_html(out, render_block(block, title)) + block = line + in_fence = 1 + } + continue + } + if (in_fence) { + block = append_line(block, line) + continue + } + label = tolower(trim(line)) + if (line ~ /^###[[:space:]]+/ || (title_lower == "behavior" && (label == "happy path:" || label == "edge cases:"))) { + out = append_html(out, render_block(block, title)) + if (title_lower == "behavior" && (label == "happy path:" || label == "edge cases:")) { + sub(/:$/, "", line) + out = append_html(out, "

    " render_inline(trim(line)) "

    ") + } else { + out = append_html(out, render_block(line, title)) + } + block = "" + continue + } + if (trim(line) == "") { + out = append_html(out, render_block(block, title)) + block = "" + } else { + block = append_line(block, line) + } + } + out = append_html(out, render_block(block, title)) + return out +} + +function extract_preamble(text, lines, n, i, line, stripped, h1_idx, subtitle_idx) { + n = split(text, lines, "\n") + doc_h1 = "" + doc_subtitle = "" + doc_intro = "" + h1_idx = 0 + subtitle_idx = 0 + for (i = 1; i <= n; i++) { + line = lines[i] + if (doc_h1 == "" && line ~ /^#[[:space:]]+/) { + doc_h1 = trim(substr(line, 3)) + h1_idx = i + break + } + } + for (i = 1; i <= n; i++) { + if (i == h1_idx) { + continue + } + stripped = trim(lines[i]) + if (stripped == "") { + continue + } + if (stripped ~ /^```/ || stripped ~ /^#/ || stripped ~ /^-/ || stripped ~ /^[*]/ || stripped ~ /^>/ || stripped == "---" || stripped ~ /^[0-9]+[.][[:space:]]+/) { + break + } + doc_subtitle = stripped + subtitle_idx = i + break + } + for (i = 1; i <= n; i++) { + if (i == h1_idx || i == subtitle_idx) { + continue + } + doc_intro = append_line(doc_intro, lines[i]) + } +} + +function meta_line( out) { + out = "" + if (fm_created != "") { + out = "created " fm_created + } + if (fm_pr != "") { + out = out == "" ? "" : out " | " + out = out (fm_pr == "[]" || fm_pr == "null" ? "pr: none" : "pr: " fm_pr) + } + out = out == "" ? "spec" : out " | spec" + return out +} + +function section_is_open(title, idx, lower) { + lower = tolower(title) + return idx < 3 || lower == "problem" || lower == "outcome" || lower == "scope" || lower == "behavior" || lower == "acceptance criteria" +} + +function render_section(title, body, idx, inner, attrs) { + inner = render_blocks(body, title) + attrs = section_is_open(title, idx) ? " class=\"section open\" open" : " class=\"section\"" + return "\n " html_escape(title) "\n
    " inner "
    \n" +} + +function print_css() { + print "" +} + +function print_mermaid_script() { + print "" +} + +{ + lines[NR] = $0 + if ($0 ~ /^```[[:space:]]*mermaid[[:space:]]*$/) { + has_mermaid = 1 + } +} + +END { + body_start = 1 + if (lines[1] == "---") { + for (i = 2; i <= NR; i++) { + if (lines[i] == "---") { + frontmatter_end = i + break + } + } + if (frontmatter_end > 0) { + for (i = 2; i < frontmatter_end; i++) { + colon = index(lines[i], ":") + if (colon > 0) { + key = trim(substr(lines[i], 1, colon - 1)) + value = trim(substr(lines[i], colon + 1)) + if (key == "created") { + fm_created = value + } else if (key == "pr") { + fm_pr = value + } + } + } + body_start = frontmatter_end + 1 + } + } + + in_fence = 0 + current = 0 + for (i = body_start; i <= NR; i++) { + line = lines[i] + if (line ~ /^```/) { + in_fence = !in_fence + } + if (!in_fence && line ~ /^##[[:space:]]+/) { + current++ + section_title[current] = trim(substr(line, 4)) + section_body[current] = "" + } else if (current > 0) { + section_body[current] = append_line(section_body[current], line) + } else { + preamble = append_line(preamble, line) + } + } + + extract_preamble(preamble) + if (doc_h1 == "") { + doc_h1 = stem + } + sections_html = render_blocks(doc_intro, "") + for (i = 1; i <= current; i++) { + sections_html = append_html(sections_html, render_section(section_title[i], section_body[i], i - 1)) + } + + print "" + print "" + print "" + print "" + print "" + print "" html_escape(doc_h1) "" + print_css() + print "" + print "" + print "
    " + print "
    " + print "
    " html_escape(meta_line()) "
    " + print "

    " html_escape(doc_h1) "

    " + if (doc_subtitle != "") { + print "

    " render_inline(doc_subtitle) "

    " + } + print "
    " + print sections_html + print "" + print "
    " + if (has_mermaid) { + print_mermaid_script() + } + print "" + print "" +} +' "$src_path" > "$tmp_path" + +mv "$tmp_path" "$out_path" +trap - EXIT +printf '%s\n' "$out_path"