Source code for jamb.publish.formats.html

"""HTML document output for publishing."""

from __future__ import annotations

import html

from jamb.core.models import Item, TraceabilityGraph

_CSS = """\
:root {
    --color-primary: #1a365d;
    --color-secondary: #2c5282;
    --color-accent: #3182ce;
    --color-text: #1a202c;
    --color-text-muted: #4a5568;
    --color-border: #e2e8f0;
    --color-bg-card: #f7fafc;
    --color-bg-heading: #ebf4ff;
    --color-bg-info: #fefcbf;
    --color-badge-req: #48bb78;
    --color-badge-heading: #805ad5;
    --color-badge-info: #ed8936;
}
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
                 "Helvetica Neue", Arial, sans-serif;
    max-width: 1000px;
    margin: 0 auto;
    padding: 2.5rem;
    color: var(--color-text);
    line-height: 1.7;
    font-size: 16px;
    background: #fff;
}
h1 {
    border-bottom: 3px solid var(--color-primary);
    padding-bottom: 0.75rem;
    color: var(--color-primary);
    font-size: 2.25rem;
    margin-bottom: 0.5rem;
}
h2 {
    border-bottom: 2px solid var(--color-border);
    padding-bottom: 0.4rem;
    color: var(--color-secondary);
    margin-top: 2.5rem;
    font-size: 1.5rem;
}
h3 {
    color: var(--color-primary);
    font-size: 1.15rem;
    margin-bottom: 0.5rem;
}
h4, h5, h6 { color: var(--color-secondary); }
p { margin: 0.75rem 0; }
a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.summary {
    color: var(--color-text-muted);
    font-size: 0.95rem;
    margin-bottom: 2rem;
    padding: 0.75rem 1rem;
    background: var(--color-bg-card);
    border-radius: 6px;
    border-left: 4px solid var(--color-primary);
}
.item {
    margin-bottom: 1.75rem;
    padding: 1.25rem;
    background: var(--color-bg-card);
    border: 1px solid var(--color-border);
    border-radius: 8px;
    border-left: 4px solid var(--color-badge-req);
}
.item h3 { margin-top: 0; }
.item-heading {
    background: var(--color-bg-heading);
    border-left-color: var(--color-badge-heading);
}
.item-heading h2 {
    border-bottom: none;
    font-weight: 700;
    margin-top: 0;
    margin-bottom: 0.5rem;
    color: var(--color-primary);
}
.item-info {
    background: var(--color-bg-info);
    border-left-color: var(--color-badge-info);
}
.item-info p { color: var(--color-text-muted); }
.item-type-badge {
    display: inline-block;
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    padding: 0.15rem 0.5rem;
    border-radius: 4px;
    margin-left: 0.75rem;
    vertical-align: middle;
}
.badge-requirement {
    background: var(--color-badge-req);
    color: white;
}
.badge-heading {
    background: var(--color-badge-heading);
    color: white;
}
.badge-info {
    background: var(--color-badge-info);
    color: white;
}
.links, .child-links {
    font-size: 0.9rem;
    color: var(--color-text-muted);
    margin-top: 0.75rem;
    padding-top: 0.75rem;
    border-top: 1px dashed var(--color-border);
}
.links strong, .child-links strong {
    color: var(--color-text);
    font-weight: 600;
}
/* Document section headers */
h2[id^="doc-"] {
    background: var(--color-primary);
    color: white;
    padding: 0.75rem 1rem;
    border-radius: 6px;
    border-bottom: none;
    font-size: 1.25rem;
    margin-top: 3rem;
}
/* Print styles */
@media print {
    body {
        max-width: none;
        padding: 1rem;
        font-size: 11pt;
    }
    .item {
        break-inside: avoid;
        border: 1px solid #ccc;
        box-shadow: none;
    }
    h1, h2, h3 {
        break-after: avoid;
    }
    h2[id^="doc-"] {
        background: none;
        color: var(--color-primary);
        border-bottom: 2px solid var(--color-primary);
    }
    a { color: var(--color-text); }
    a::after { content: " (" attr(href) ")"; font-size: 0.8em; }
    a[href^="#"]::after { content: ""; }
}
"""


[docs] def render_html( items: list[Item], title: str, include_links: bool = True, document_order: list[str] | None = None, graph: TraceabilityGraph | None = None, ) -> str: """Render items as a standalone HTML document. Args: items: List of Item objects to include. title: The document title. include_links: Whether to include parent and child link sections. document_order: Optional list of document prefixes in hierarchy order. graph: Optional traceability graph for child link lookup. Returns: HTML string. """ all_uids = {item.uid for item in items} # Build document order index for sorting if document_order: doc_order_index = {prefix: i for i, prefix in enumerate(document_order)} else: doc_order_index = {} fallback_order = len(document_order) if document_order else 0 def get_doc_order(item: Item) -> int: """Return sort index for an item based on document hierarchy order.""" return doc_order_index.get(item.document_prefix, fallback_order) sorted_items = sorted(items, key=lambda x: (get_doc_order(x), x.document_prefix, x.uid)) parts: list[str] = [] parts.append("<!DOCTYPE html>") parts.append('<html lang="en">') parts.append("<head>") parts.append('<meta charset="utf-8">') parts.append(f"<title>{_esc(title)}</title>") parts.append(f"<style>{_CSS}</style>") parts.append("</head>") parts.append("<body>") parts.append(f"<h1>{_esc(title)}</h1>") parts.append(f'<p class="summary">Total items: {len(items)}</p>') current_doc: str | None = None for item in sorted_items: if item.document_prefix != current_doc: current_doc = item.document_prefix parts.append(f'<h2 id="doc-{_esc(current_doc)}">{_esc(current_doc)}</h2>') heading_text = f"{item.uid}: {item.header}" if item.header else item.uid item_type = item.type if item_type == "heading": css_class = "item item-heading" elif item_type == "info": css_class = "item item-info" else: css_class = "item item-requirement" parts.append(f'<div class="{css_class}">') if item_type == "heading": hn = min(item.level, 6) if item.level else 2 heading_display = f"{item.uid}: {item.header}" if item.header else item.uid parts.append( f'<h{hn} id="{_esc(item.uid)}">{_esc(heading_display)}' f'<span class="item-type-badge badge-heading">Heading</span></h{hn}>' ) elif item_type == "info": parts.append( f'<h3 id="{_esc(item.uid)}">{_esc(heading_text)}' f'<span class="item-type-badge badge-info">Info</span></h3>' ) else: parts.append( f'<h3 id="{_esc(item.uid)}">{_esc(heading_text)}' f'<span class="item-type-badge badge-requirement">Req</span></h3>' ) if item.text: parts.append(f"<p>{_esc(item.text)}</p>") if include_links and item.links: link_parts = [] for link_uid in item.links: if link_uid in all_uids: link_parts.append(f'<a href="#{_esc(link_uid)}">{_esc(link_uid)}</a>') else: link_parts.append(_esc(link_uid)) parts.append(f'<p class="links"><strong>Links:</strong> {", ".join(link_parts)}</p>') if include_links and graph is not None: children = graph.item_children.get(item.uid, []) # Only show children that are in the rendered set visible_children = [c for c in children if c in all_uids] if visible_children: child_parts = [] for child_uid in visible_children: child_parts.append(f'<a href="#{_esc(child_uid)}">{_esc(child_uid)}</a>') parts.append(f'<p class="child-links"><strong>Linked from:</strong> {", ".join(child_parts)}</p>') parts.append("</div>") parts.append("</body>") parts.append("</html>") return "\n".join(parts)
def _esc(text: str) -> str: """Escape text for safe inclusion in HTML. Args: text: The plain text to escape. Returns: The HTML-escaped string. """ return html.escape(str(text))