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.
- codex_transcript_viewer/__init__.py +0 -0
- codex_transcript_viewer/cli.py +81 -0
- codex_transcript_viewer/formatting.py +23 -0
- codex_transcript_viewer/html_builder.py +769 -0
- codex_transcript_viewer/markdown.py +156 -0
- codex_transcript_viewer/parser.py +1204 -0
- codex_transcript_viewer/style.css +589 -0
- codex_transcript_viewer/viewer.js +63 -0
- codex_transcript_viewer-0.4.0.dist-info/METADATA +122 -0
- codex_transcript_viewer-0.4.0.dist-info/RECORD +13 -0
- codex_transcript_viewer-0.4.0.dist-info/WHEEL +4 -0
- codex_transcript_viewer-0.4.0.dist-info/entry_points.txt +2 -0
- codex_transcript_viewer-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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>'
|