diff --git a/.gitignore b/.gitignore index a4a9ce6..7b5c577 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ uv.lock .playwright-mcp/ +tests/.fixture_cache/ diff --git a/README.md b/README.md index 46effea..ee72d9b 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,16 @@ All commands support these options: - `-o, --output DIRECTORY` - output directory (default: writes to temp dir and opens browser) - `-a, --output-auto` - auto-name output subdirectory based on session ID or filename -- `--repo OWNER/NAME` - GitHub repo for commit links (auto-detected from git push output if not specified) +- `--repo PATH|URL|OWNER/NAME` - Git repo for commit links and code viewer. Accepts a local path, GitHub URL, or owner/name format. - `--open` - open the generated `index.html` in your default browser (default if no `-o` specified) - `--gist` - upload the generated HTML files to a GitHub Gist and output a preview URL - `--json` - include the original session file in the output directory +- `--code-view` - generate an interactive code viewer showing all files modified during the session The generated output includes: - `index.html` - an index page with a timeline of prompts and commits - `page-001.html`, `page-002.html`, etc. - paginated transcript pages +- `code.html` - interactive code viewer (when `--code-view` is used) ### Local sessions @@ -102,11 +104,11 @@ claude-code-transcripts json session.json --gist This will output something like: ``` Gist: https://gist.github.com/username/abc123def456 -Preview: https://gistpreview.github.io/?abc123def456/index.html +Preview: https://gisthost.github.io/?abc123def456/index.html Files: /var/folders/.../session-id ``` -The preview URL uses [gistpreview.github.io](https://gistpreview.github.io/) to render your HTML gist. The tool automatically injects JavaScript to fix relative links when served through gistpreview. +The preview URL uses [gisthost.github.io](https://gisthost.github.io/) to render your HTML gist. The tool automatically injects JavaScript to fix relative links when served through gisthost (also works with gistpreview.github.io for backward compatibility). Combine with `-o` to keep a local copy: @@ -116,6 +118,36 @@ claude-code-transcripts json session.json -o ./my-transcript --gist **Requirements:** The `--gist` option requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated (`gh auth login`). +### Code viewer + +Use `--code-view` to generate an interactive three-pane code viewer that shows all files modified during the session: + +```bash +# Generate with code viewer from a local session +claude-code-transcripts --code-view + +# Point to the actual repo for full file content and blame +claude-code-transcripts --code-view --repo /path/to/repo + +# From a URL +claude-code-transcripts json https://example.com/session.jsonl --code-view +``` + +The code viewer (`code.html`) provides: +- **File tree**: Navigate all files that were written or edited during the session +- **File content**: View file contents with git blame-style annotations showing which prompt modified each line +- **Transcript pane**: Browse the full conversation with links to jump to specific file operations + +When you provide `--repo` pointing to the local git repository that was being modified, the code viewer can show the complete file content with accurate blame attribution. Without a repo path, it shows a diff-only view of the changes. + +Use `--exclude-deleted-files` to filter out files that no longer exist on disk: + +```bash +claude-code-transcripts --code-view --exclude-deleted-files +``` + +This is useful when files were deleted after the session (either manually or by commands not captured in the transcript). + ### Auto-naming output directories Use `-a/--output-auto` to automatically create a subdirectory named after the session: @@ -145,11 +177,14 @@ This is useful for archiving the source data alongside the HTML output. ### Converting from JSON/JSONL files -Convert a specific session file directly: +Convert a specific session file or URL directly: ```bash claude-code-transcripts json session.json -o output-directory/ claude-code-transcripts json session.jsonl --open + +# Fetch and convert from a URL +claude-code-transcripts json https://example.com/session.jsonl --open ``` When using [Claude Code for web](https://claude.ai/code) you can export your session as a `session.json` file using the `teleport` command. diff --git a/pyproject.toml b/pyproject.toml index afc552b..d04be85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,9 +11,11 @@ requires-python = ">=3.10" dependencies = [ "click", "click-default-group", + "gitpython", "httpx", "jinja2", "markdown", + "nh3>=0.3.2", "questionary", ] diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 8729fad..165c2ba 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -9,14 +9,19 @@ import subprocess import tempfile import webbrowser +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path +from typing import Optional, List, Tuple, Dict, Any import click from click_default_group import DefaultGroup +from git import Repo +from git.exc import InvalidGitRepositoryError import httpx from jinja2 import Environment, PackageLoader import markdown +import nh3 import questionary # Set up Jinja2 environment @@ -49,6 +54,101 @@ def get_template(name): ) +# Import code viewer functionality from separate module +from claude_code_transcripts.code_view import ( + FileOperation, + FileState, + CodeViewData, + BlameRange, + OP_WRITE, + OP_EDIT, + OP_DELETE, + extract_file_operations, + filter_deleted_files, + normalize_file_paths, + find_git_repo_root, + find_commit_before_timestamp, + build_file_history_repo, + get_file_blame_ranges, + get_file_content_from_repo, + build_file_tree, + reconstruct_file_with_blame, + build_file_states, + render_file_tree_html, + file_state_to_dict, + generate_code_view_html, + build_msg_to_user_html, +) + + +def extract_github_repo_from_url(url: str) -> Optional[str]: + """Extract 'owner/name' from various GitHub URL formats. + + Handles: + - https://github.com/owner/repo + - https://github.com/owner/repo.git + - git@github.com:owner/repo.git + + Args: + url: GitHub URL or git remote URL. + + Returns: + Repository identifier as 'owner/name', or None if not found. + """ + match = re.search(r"github\.com[:/]([^/]+/[^/?#.]+)", url) + if match: + repo = match.group(1) + return repo[:-4] if repo.endswith(".git") else repo + return None + + +def parse_repo_value(repo: Optional[str]) -> Tuple[Optional[str], Optional[Path]]: + """Parse --repo value to extract GitHub repo name and/or local path. + + Args: + repo: The --repo value (could be path, URL, or owner/name). + + Returns: + Tuple of (github_repo, local_path): + - github_repo: "owner/name" string for commit links, or None + - local_path: Path to local git repo for file history, or None + """ + if not repo: + return None, None + + # Check if it's a local path that exists + repo_path = Path(repo) + if repo_path.exists() and (repo_path / ".git").exists(): + # Try to extract GitHub remote URL + github_repo = None + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=repo_path, + capture_output=True, + text=True, + ) + if result.returncode == 0: + github_repo = extract_github_repo_from_url(result.stdout.strip()) + except Exception: + pass + return github_repo, repo_path + + # Check if it's a GitHub URL + if is_url(repo): + github_repo = extract_github_repo_from_url(repo) + if github_repo: + return github_repo, None + # Not a GitHub URL, ignore + return None, None + + # Assume it's owner/name format + if "/" in repo and not repo.startswith("/"): + return repo, None + + return None, None + + def extract_text_from_content(content): """Extract plain text from message content. @@ -401,8 +501,6 @@ def _generate_project_index(project, output_dir): project_name=project["name"], sessions=sessions_data, session_count=len(sessions_data), - css=CSS, - js=JS, ) output_path = output_dir / "index.html" @@ -440,8 +538,6 @@ def _generate_master_index(projects, output_dir): projects=projects_data, total_projects=len(projects), total_sessions=total_sessions, - css=CSS, - js=JS, ) output_path = output_dir / "index.html" @@ -492,6 +588,14 @@ def _parse_jsonl_file(filepath): if obj.get("isCompactSummary"): entry["isCompactSummary"] = True + # Preserve isMeta if present (skill expansions, not real user prompts) + if obj.get("isMeta"): + entry["isMeta"] = True + + # Preserve toolUseResult if present (needed for originalFile content) + if "toolUseResult" in obj: + entry["toolUseResult"] = obj["toolUseResult"] + loglines.append(entry) except json.JSONDecodeError: continue @@ -629,10 +733,58 @@ def format_json(obj): return f"
{html.escape(str(obj))}"
+# Allowed HTML tags for markdown content - anything else gets escaped
+ALLOWED_TAGS = {
+ # Block elements
+ "p",
+ "div",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "blockquote",
+ "pre",
+ "hr",
+ # Lists
+ "ul",
+ "ol",
+ "li",
+ # Inline elements
+ "a",
+ "strong",
+ "b",
+ "em",
+ "i",
+ "code",
+ "br",
+ "span",
+ # Tables
+ "table",
+ "thead",
+ "tbody",
+ "tr",
+ "th",
+ "td",
+}
+
+ALLOWED_ATTRIBUTES = {
+ "a": {"href", "title"},
+ "code": {"class"}, # For syntax highlighting
+ "pre": {"class"},
+ "span": {"class"},
+ "td": {"align"},
+ "th": {"align"},
+}
+
+
def render_markdown_text(text):
if not text:
return ""
- return markdown.markdown(text, extensions=["fenced_code", "tables"])
+ raw_html = markdown.markdown(text, extensions=["fenced_code", "tables"])
+ # Sanitize HTML to only allow safe tags - escapes everything else
+ return nh3.clean(raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
def is_json_like(text):
@@ -852,7 +1004,7 @@ def is_tool_result_message(message_data):
)
-def render_message(log_type, message_json, timestamp):
+def render_message(log_type, message_json, timestamp, prompt_num=None):
if not message_json:
return ""
try:
@@ -865,7 +1017,8 @@ def render_message(log_type, message_json, timestamp):
if is_tool_result_message(message_data):
role_class, role_label = "tool-reply", "Tool reply"
else:
- role_class, role_label = "user", "User"
+ role_class = "user"
+ role_label = f"User Prompt #{prompt_num}" if prompt_num else "User"
elif log_type == "assistant":
content_html = render_assistant_message(message_data)
role_class, role_label = "assistant", "Assistant"
@@ -877,197 +1030,75 @@ def render_message(log_type, message_json, timestamp):
return _macros.message(role_class, role_label, msg_id, timestamp, content_html)
-CSS = """
-:root { --bg-color: #f5f5f5; --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; --assistant-bg: #f5f5f5; --assistant-border: #9e9e9e; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; --tool-bg: #f3e5f5; --tool-border: #9c27b0; --tool-result-bg: #e8f5e9; --tool-error-bg: #ffebee; --text-color: #212121; --text-muted: #757575; --code-bg: #263238; --code-text: #aed581; }
-* { box-sizing: border-box; }
-body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; }
-.container { max-width: 800px; margin: 0 auto; }
-h1 { font-size: 1.5rem; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid var(--user-border); }
-.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; border-bottom: 2px solid var(--user-border); padding-bottom: 8px; margin-bottom: 24px; }
-.header-row h1 { border-bottom: none; padding-bottom: 0; margin-bottom: 0; flex: 1; min-width: 200px; }
-.message { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
-.message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); }
-.message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); }
-.message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; }
-.tool-reply .role-label { color: #e65100; }
-.tool-reply .tool-result { background: transparent; padding: 0; margin: 0; }
-.tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); }
-.message-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; }
-.role-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
-.user .role-label { color: var(--user-border); }
-time { color: var(--text-muted); font-size: 0.8rem; }
-.timestamp-link { color: inherit; text-decoration: none; }
-.timestamp-link:hover { text-decoration: underline; }
-.message:target { animation: highlight 2s ease-out; }
-@keyframes highlight { 0% { background-color: rgba(25, 118, 210, 0.2); } 100% { background-color: transparent; } }
-.message-content { padding: 16px; }
-.message-content p { margin: 0 0 12px 0; }
-.message-content p:last-child { margin-bottom: 0; }
-.thinking { background: var(--thinking-bg); border: 1px solid var(--thinking-border); border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 0.9rem; color: var(--thinking-text); }
-.thinking-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: #f57c00; margin-bottom: 8px; }
-.thinking p { margin: 8px 0; }
-.assistant-text { margin: 8px 0; }
-.tool-use { background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 8px; padding: 12px; margin: 12px 0; }
-.tool-header { font-weight: 600; color: var(--tool-border); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
-.tool-icon { font-size: 1.1rem; }
-.tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; }
-.tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; }
-.tool-result.tool-error { background: var(--tool-error-bg); }
-.file-tool { border-radius: 8px; padding: 12px; margin: 12px 0; }
-.write-tool { background: linear-gradient(135deg, #e3f2fd 0%, #e8f5e9 100%); border: 1px solid #4caf50; }
-.edit-tool { background: linear-gradient(135deg, #fff3e0 0%, #fce4ec 100%); border: 1px solid #ff9800; }
-.file-tool-header { font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; }
-.write-header { color: #2e7d32; }
-.edit-header { color: #e65100; }
-.file-tool-icon { font-size: 1rem; }
-.file-tool-path { font-family: monospace; background: rgba(0,0,0,0.08); padding: 2px 8px; border-radius: 4px; }
-.file-tool-fullpath { font-family: monospace; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; word-break: break-all; }
-.file-content { margin: 0; }
-.edit-section { display: flex; margin: 4px 0; border-radius: 4px; overflow: hidden; }
-.edit-label { padding: 8px 12px; font-weight: bold; font-family: monospace; display: flex; align-items: flex-start; }
-.edit-old { background: #fce4ec; }
-.edit-old .edit-label { color: #b71c1c; background: #f8bbd9; }
-.edit-old .edit-content { color: #880e4f; }
-.edit-new { background: #e8f5e9; }
-.edit-new .edit-label { color: #1b5e20; background: #a5d6a7; }
-.edit-new .edit-content { color: #1b5e20; }
-.edit-content { margin: 0; flex: 1; background: transparent; font-size: 0.85rem; }
-.edit-replace-all { font-size: 0.75rem; font-weight: normal; color: var(--text-muted); }
-.write-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #e6f4ea); }
-.edit-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff0e5); }
-.todo-list { background: linear-gradient(135deg, #e8f5e9 0%, #f1f8e9 100%); border: 1px solid #81c784; border-radius: 8px; padding: 12px; margin: 12px 0; }
-.todo-header { font-weight: 600; color: #2e7d32; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; }
-.todo-items { list-style: none; margin: 0; padding: 0; }
-.todo-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.06); font-size: 0.9rem; }
-.todo-item:last-child { border-bottom: none; }
-.todo-icon { flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-weight: bold; border-radius: 50%; }
-.todo-completed .todo-icon { color: #2e7d32; background: rgba(46, 125, 50, 0.15); }
-.todo-completed .todo-content { color: #558b2f; text-decoration: line-through; }
-.todo-in-progress .todo-icon { color: #f57c00; background: rgba(245, 124, 0, 0.15); }
-.todo-in-progress .todo-content { color: #e65100; font-weight: 500; }
-.todo-pending .todo-icon { color: #757575; background: rgba(0,0,0,0.05); }
-.todo-pending .todo-content { color: #616161; }
-pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; }
-pre.json { color: #e0e0e0; }
-code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
-pre code { background: none; padding: 0; }
-.user-content { margin: 0; }
-.truncatable { position: relative; }
-.truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; }
-.truncatable.truncated::after { content: ''; position: absolute; bottom: 32px; left: 0; right: 0; height: 60px; background: linear-gradient(to bottom, transparent, var(--card-bg)); pointer-events: none; }
-.message.user .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--user-bg)); }
-.message.tool-reply .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); }
-.tool-use .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-bg)); }
-.tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-result-bg)); }
-.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); }
-.expand-btn:hover { background: rgba(0,0,0,0.1); }
-.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; }
-.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; }
-.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; }
-.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); }
-.pagination a:hover { background: var(--user-bg); }
-.pagination .current { background: var(--user-border); color: white; }
-.pagination .disabled { color: var(--text-muted); border: 1px solid #ddd; }
-.pagination .index-link { background: var(--user-border); color: white; }
-details.continuation { margin-bottom: 16px; }
-details.continuation summary { cursor: pointer; padding: 12px 16px; background: var(--user-bg); border-left: 4px solid var(--user-border); border-radius: 12px; font-weight: 500; color: var(--text-muted); }
-details.continuation summary:hover { background: rgba(25, 118, 210, 0.15); }
-details.continuation[open] summary { border-radius: 12px 12px 0 0; margin-bottom: 0; }
-.index-item { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); background: var(--user-bg); border-left: 4px solid var(--user-border); }
-.index-item a { display: block; text-decoration: none; color: inherit; }
-.index-item a:hover { background: rgba(25, 118, 210, 0.1); }
-.index-item-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; }
-.index-item-number { font-weight: 600; color: var(--user-border); }
-.index-item-content { padding: 16px; }
-.index-item-stats { padding: 8px 16px 12px 32px; font-size: 0.85rem; color: var(--text-muted); border-top: 1px solid rgba(0,0,0,0.06); }
-.index-item-commit { margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-radius: 4px; font-size: 0.85rem; color: #e65100; }
-.index-item-commit code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; margin-right: 6px; }
-.commit-card { margin: 8px 0; padding: 10px 14px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 6px; }
-.commit-card a { text-decoration: none; color: #5d4037; display: block; }
-.commit-card a:hover { color: #e65100; }
-.commit-card-hash { font-family: monospace; color: #e65100; font-weight: 600; margin-right: 8px; }
-.index-commit { margin-bottom: 12px; padding: 10px 16px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
-.index-commit a { display: block; text-decoration: none; color: inherit; }
-.index-commit a:hover { background: rgba(255, 152, 0, 0.1); margin: -10px -16px; padding: 10px 16px; border-radius: 8px; }
-.index-commit-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; margin-bottom: 4px; }
-.index-commit-hash { font-family: monospace; color: #e65100; font-weight: 600; }
-.index-commit-msg { color: #5d4037; }
-.index-item-long-text { margin-top: 8px; padding: 12px; background: var(--card-bg); border-radius: 8px; border-left: 3px solid var(--assistant-border); }
-.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); }
-.index-item-long-text-content { color: var(--text-color); }
-#search-box { display: none; align-items: center; gap: 8px; }
-#search-box input { padding: 6px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; width: 180px; }
-#search-box button, #modal-search-btn, #modal-close-btn { background: var(--user-border); color: white; border: none; border-radius: 6px; padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
-#search-box button:hover, #modal-search-btn:hover { background: #1565c0; }
-#modal-close-btn { background: var(--text-muted); margin-left: 8px; }
-#modal-close-btn:hover { background: #616161; }
-#search-modal[open] { border: none; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.2); padding: 0; width: 90vw; max-width: 900px; height: 80vh; max-height: 80vh; display: flex; flex-direction: column; }
-#search-modal::backdrop { background: rgba(0,0,0,0.5); }
-.search-modal-header { display: flex; align-items: center; gap: 8px; padding: 16px; border-bottom: 1px solid var(--assistant-border); background: var(--bg-color); border-radius: 12px 12px 0 0; }
-.search-modal-header input { flex: 1; padding: 8px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; }
-#search-status { padding: 8px 16px; font-size: 0.85rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); }
-#search-results { flex: 1; overflow-y: auto; padding: 16px; }
-.search-result { margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
-.search-result a { display: block; text-decoration: none; color: inherit; }
-.search-result a:hover { background: rgba(25, 118, 210, 0.05); }
-.search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); }
-.search-result-content { padding: 12px; }
-.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
-@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } }
-"""
-
-JS = """
-document.querySelectorAll('time[data-timestamp]').forEach(function(el) {
- const timestamp = el.getAttribute('data-timestamp');
- const date = new Date(timestamp);
- const now = new Date();
- const isToday = date.toDateString() === now.toDateString();
- const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
- if (isToday) { el.textContent = timeStr; }
- else { el.textContent = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr; }
-});
-document.querySelectorAll('pre.json').forEach(function(el) {
- let text = el.textContent;
- text = text.replace(/"([^"]+)":/g, '"$1":');
- text = text.replace(/: "([^"]*)"/g, ': "$1"');
- text = text.replace(/: (\\d+)/g, ': $1');
- text = text.replace(/: (true|false|null)/g, ': $1');
- el.innerHTML = text;
-});
-document.querySelectorAll('.truncatable').forEach(function(wrapper) {
- const content = wrapper.querySelector('.truncatable-content');
- const btn = wrapper.querySelector('.expand-btn');
- if (content.scrollHeight > 250) {
- wrapper.classList.add('truncated');
- btn.addEventListener('click', function() {
- if (wrapper.classList.contains('truncated')) { wrapper.classList.remove('truncated'); wrapper.classList.add('expanded'); btn.textContent = 'Show less'; }
- else { wrapper.classList.remove('expanded'); wrapper.classList.add('truncated'); btn.textContent = 'Show more'; }
- });
- }
-});
-"""
-
-# JavaScript to fix relative URLs when served via gistpreview.github.io
+# JavaScript to fix relative URLs when served via gisthost.github.io or gistpreview.github.io
+# Fixes issue #26: Pagination links broken on gisthost.github.io
GIST_PREVIEW_JS = r"""
(function() {
- if (window.location.hostname !== 'gistpreview.github.io') return;
- // URL format: https://gistpreview.github.io/?GIST_ID/filename.html
+ var hostname = window.location.hostname;
+ if (hostname !== 'gisthost.github.io' && hostname !== 'gistpreview.github.io') return;
+ // URL format: https://gisthost.github.io/?GIST_ID/filename.html
var match = window.location.search.match(/^\?([^/]+)/);
if (!match) return;
var gistId = match[1];
- document.querySelectorAll('a[href]').forEach(function(link) {
- var href = link.getAttribute('href');
- // Skip external links and anchors
- if (href.startsWith('http') || href.startsWith('#') || href.startsWith('//')) return;
- // Handle anchor in relative URL (e.g., page-001.html#msg-123)
- var parts = href.split('#');
- var filename = parts[0];
- var anchor = parts.length > 1 ? '#' + parts[1] : '';
- link.setAttribute('href', '?' + gistId + '/' + filename + anchor);
+
+ function rewriteLinks(root) {
+ (root || document).querySelectorAll('a[href]').forEach(function(link) {
+ var href = link.getAttribute('href');
+ // Skip already-rewritten links (issue #26 fix)
+ if (href.startsWith('?')) return;
+ // Skip external links and anchors
+ if (href.startsWith('http') || href.startsWith('#') || href.startsWith('//')) return;
+ // Handle anchor in relative URL (e.g., page-001.html#msg-123)
+ var parts = href.split('#');
+ var filename = parts[0];
+ var anchor = parts.length > 1 ? '#' + parts[1] : '';
+ link.setAttribute('href', '?' + gistId + '/' + filename + anchor);
+ });
+ }
+
+ // Run immediately
+ rewriteLinks();
+
+ // Also run on DOMContentLoaded in case DOM isn't ready yet
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', function() { rewriteLinks(); });
+ }
+
+ // Use MutationObserver to catch dynamically added content
+ // gisthost/gistpreview may add content after initial load
+ var observer = new MutationObserver(function(mutations) {
+ mutations.forEach(function(mutation) {
+ mutation.addedNodes.forEach(function(node) {
+ if (node.nodeType === 1) { // Element node
+ rewriteLinks(node);
+ // Also check if the node itself is a link
+ if (node.tagName === 'A' && node.getAttribute('href')) {
+ var href = node.getAttribute('href');
+ if (!href.startsWith('?') && !href.startsWith('http') &&
+ !href.startsWith('#') && !href.startsWith('//')) {
+ var parts = href.split('#');
+ var filename = parts[0];
+ var anchor = parts.length > 1 ? '#' + parts[1] : '';
+ node.setAttribute('href', '?' + gistId + '/' + filename + anchor);
+ }
+ }
+ }
+ });
+ });
});
+ // Start observing once body exists
+ function startObserving() {
+ if (document.body) {
+ observer.observe(document.body, { childList: true, subtree: true });
+ } else {
+ setTimeout(startObserving, 10);
+ }
+ }
+ startObserving();
+
// Handle fragment navigation after dynamic content loads
- // gistpreview.github.io loads content dynamically, so the browser's
+ // gisthost/gistpreview loads content dynamically, so the browser's
// native fragment navigation fails because the element doesn't exist yet
function scrollToFragment() {
var hash = window.location.hash;
@@ -1094,10 +1125,29 @@ def render_message(log_type, message_json, timestamp):
def inject_gist_preview_js(output_dir):
- """Inject gist preview JavaScript into all HTML files in the output directory."""
+ """Inject gist preview JavaScript into all HTML files in the output directory.
+
+ Also removes inline CODE_DATA from code.html since gist version fetches it separately.
+
+ Args:
+ output_dir: Path to the output directory containing HTML files.
+ """
output_dir = Path(output_dir)
for html_file in output_dir.glob("*.html"):
content = html_file.read_text(encoding="utf-8")
+
+ # For code.html, remove the inline CODE_DATA script
+ # (gist version fetches code-data.json instead)
+ if html_file.name == "code.html":
+ import re
+
+ content = re.sub(
+ r"\s*",
+ "",
+ content,
+ flags=re.DOTALL,
+ )
+
# Insert the gist preview JS before the closing