codex-transcript-viewer 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,156 @@
1
+ """Lightweight markdown-to-HTML conversion for session transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+
8
+
9
+ def escape(text: str | None) -> str:
10
+ """HTML-escape text, returning empty string for None."""
11
+ return html.escape(str(text)) if text else ""
12
+
13
+
14
+ # Rendered pieces are parked behind placeholders so later passes (emphasis,
15
+ # links, tables) never look inside code or inside markup already produced.
16
+ _SLOT = "\x00{}\x00"
17
+ _SLOT_RE = re.compile("\x00(\\d+)\x00")
18
+
19
+ _LINK_RE = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)")
20
+ _AUTOLINK_RE = re.compile(r"<(https?://[^\s&]+)>")
21
+ _BARE_URL_RE = re.compile(r"(?<![\w/=\"'])(https?://[^\s<\x00]*[^\s<\x00.,;:!?)\]'\"])")
22
+ _WEB_URL_RE = re.compile(r"^(https?://|mailto:)", re.IGNORECASE)
23
+
24
+ _TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
25
+
26
+
27
+ def render_markdown(text: str) -> str:
28
+ """Convert the markdown Codex writes to HTML.
29
+
30
+ Handles fenced code blocks, inline code, bold, italic, headers, unordered
31
+ lists, links and pipe tables. Intended for session transcript content
32
+ where full CommonMark compliance is unnecessary.
33
+ """
34
+ slots: list[str] = []
35
+
36
+ def park(markup: str) -> str:
37
+ slots.append(markup)
38
+ return _SLOT.format(len(slots) - 1)
39
+
40
+ escaped = escape(text)
41
+
42
+ # Fenced code blocks (```lang ... ```)
43
+ escaped = re.sub(
44
+ r"```(\w*)\n(.*?)```",
45
+ lambda m: park(f'<pre><code class="language-{m.group(1)}">{m.group(2)}</code></pre>'),
46
+ escaped,
47
+ flags=re.DOTALL,
48
+ )
49
+
50
+ # Inline code
51
+ escaped = re.sub(r"`([^`\n]+)`", lambda m: park(f"<code>{m.group(1)}</code>"), escaped)
52
+
53
+ # Links: [text](target), <https://...>, and bare web addresses
54
+ escaped = _LINK_RE.sub(lambda m: park(_link(m.group(1), m.group(2))), escaped)
55
+ escaped = _AUTOLINK_RE.sub(lambda m: park(_link(m.group(1), m.group(1))), escaped)
56
+ escaped = _BARE_URL_RE.sub(lambda m: park(_link(m.group(1), m.group(1))), escaped)
57
+
58
+ # Bold
59
+ escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
60
+
61
+ # Italic (single asterisk, not adjacent to another asterisk)
62
+ escaped = re.sub(
63
+ r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", escaped
64
+ )
65
+
66
+ # Headers (h3 before h2 before h1 to avoid prefix conflicts)
67
+ escaped = re.sub(
68
+ r"^### (.+)$", r"<h3>\1</h3>", escaped, flags=re.MULTILINE
69
+ )
70
+ escaped = re.sub(
71
+ r"^## (.+)$", r"<h2>\1</h2>", escaped, flags=re.MULTILINE
72
+ )
73
+ escaped = re.sub(
74
+ r"^# (.+)$", r"<h1>\1</h1>", escaped, flags=re.MULTILINE
75
+ )
76
+
77
+ # Unordered list items
78
+ escaped = re.sub(r"^- (.+)$", r"• \1", escaped, flags=re.MULTILINE)
79
+
80
+ escaped = _render_tables(escaped)
81
+
82
+ # Restore parked markup; link text may itself hold parked inline code.
83
+ while _SLOT_RE.search(escaped):
84
+ escaped = _SLOT_RE.sub(lambda m: slots[int(m.group(1))], escaped)
85
+ return escaped
86
+
87
+
88
+ def _link(label: str, target: str) -> str:
89
+ """Web links open in a new tab; file paths can't be followed from a saved
90
+ page, so they show their label with the full path on hover."""
91
+ if _WEB_URL_RE.match(target):
92
+ return f'<a href="{target}" target="_blank" rel="noopener noreferrer">{label}</a>'
93
+ return f'<span class="md-path" title="{target}">{label}</span>'
94
+
95
+
96
+ def _split_row(line: str) -> list[str]:
97
+ row = line.strip()
98
+ if row.startswith("|"):
99
+ row = row[1:]
100
+ if row.endswith("|") and not row.endswith("\\|"):
101
+ row = row[:-1]
102
+ cells = re.split(r"(?<!\\)\|", row)
103
+ return [cell.strip().replace("\\|", "|") for cell in cells]
104
+
105
+
106
+ def _alignment(spec: str) -> str:
107
+ spec = spec.strip()
108
+ if spec.startswith(":") and spec.endswith(":"):
109
+ return "center"
110
+ if spec.endswith(":"):
111
+ return "right"
112
+ return ""
113
+
114
+
115
+ def _render_tables(text: str) -> str:
116
+ """Turn pipe tables (a header row, a --- separator, then rows) into HTML."""
117
+ lines = text.split("\n")
118
+ out: list[str] = []
119
+ i = 0
120
+ while i < len(lines):
121
+ header = lines[i]
122
+ if (
123
+ "|" in header
124
+ and i + 1 < len(lines)
125
+ and "-" in lines[i + 1]
126
+ and _TABLE_SEPARATOR_RE.match(lines[i + 1])
127
+ and len(_split_row(lines[i + 1])) == len(_split_row(header))
128
+ ):
129
+ heads = _split_row(header)
130
+ aligns = [_alignment(spec) for spec in _split_row(lines[i + 1])]
131
+ rows = []
132
+ i += 2
133
+ while i < len(lines) and "|" in lines[i] and lines[i].strip():
134
+ cells = _split_row(lines[i])
135
+ cells = (cells + [""] * len(heads))[: len(heads)]
136
+ rows.append(cells)
137
+ i += 1
138
+ out.append(_table_html(heads, aligns, rows))
139
+ continue
140
+ out.append(header)
141
+ i += 1
142
+ # A table is a block, so drop the line break that would follow it.
143
+ return re.sub("(</table>)\n", r"\1", "\n".join(out))
144
+
145
+
146
+ def _table_html(heads: list[str], aligns: list[str], rows: list[list[str]]) -> str:
147
+ def cell(tag: str, content: str, align: str) -> str:
148
+ style = f' style="text-align:{align}"' if align else ""
149
+ return f"<{tag}{style}>{content}</{tag}>"
150
+
151
+ head = "".join(cell("th", h, a) for h, a in zip(heads, aligns))
152
+ body = "".join(
153
+ "<tr>" + "".join(cell("td", c, a) for c, a in zip(row, aligns)) + "</tr>"
154
+ for row in rows
155
+ )
156
+ return f'<table class="md-table"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>'