Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions claude_code_log/html/assets/vendor/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Vendored third-party assets

These files are vendored (checked in) rather than loaded from a CDN so the
generated HTML works fully offline and does not leak a request to a third
party when a user opens a transcript rendered from their private logs
(issue #278).

## vis-timeline

- **Package:** `vis-timeline` (vis-timeline and vis-graph2d)
- **Version:** 8.5.1
- **Homepage:** https://visjs.github.io/vis-timeline/
- **License:** MIT / Apache-2.0 (dual) — see the upstream project.
- **Source URLs (unpkg, pinned):**
- `https://unpkg.com/vis-timeline@8.5.1/standalone/umd/vis-timeline-graph2d.min.js`
- `https://unpkg.com/vis-timeline@8.5.1/styles/vis-timeline-graph2d.min.css`

### SHA-256 checksums

```
484c8f44cb2be213922ca9537022e9a87eb85d5981a2064e369da91bd2150298 vis-timeline-graph2d.min.js
67273f3f1bfc5fee585494e8b8818d7cad98c229737488bc56f140547badc1f5 vis-timeline-graph2d.min.css
```

### Re-verifying / upgrading

```bash
curl -sL https://unpkg.com/vis-timeline@8.5.1/standalone/umd/vis-timeline-graph2d.min.js \
| shasum -a 256 # must match the .js checksum above
curl -sL https://unpkg.com/vis-timeline@8.5.1/styles/vis-timeline-graph2d.min.css \
| shasum -a 256 # must match the .css checksum above
```

To upgrade, bump the version in the URLs, re-download both files into this
directory, and update the version + checksums above.

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions claude_code_log/html/assets/vendor/vis-timeline-graph2d.min.js

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,27 @@ def _generate_inner(
self._output_dir = output_dir
self._image_counter = 0

# Vendored timeline assets (issue #278), fully offline either way:
# - With an output directory, drop vis-timeline's JS/CSS into a
# sibling ``assets/`` dir once and point the template at the
# relative path (small pages; the multi-file default).
# - Without an output directory (bare-string render, e.g. the
# ``generate_html`` convenience API), there is nowhere to put a
# sidecar, so inline the JS/CSS directly into the page so a
# single self-contained file still has a working timeline.
timeline_asset_base: Optional[str] = None
timeline_inline_js: Optional[str] = None
timeline_inline_css: Optional[str] = None
if output_dir is not None:
from .utils import ensure_vendor_assets, VENDOR_ASSETS_DIRNAME

ensure_vendor_assets(output_dir)
timeline_asset_base = VENDOR_ASSETS_DIRNAME
else:
from .utils import read_vendor_timeline_inline

timeline_inline_js, timeline_inline_css = read_vendor_timeline_inline()

if not title:
title = "Claude Transcript"

Expand Down Expand Up @@ -1634,6 +1655,9 @@ def _generate_inner(
is_session_header=is_session_header,
page_info=page_info,
page_stats=page_stats,
timeline_asset_base=timeline_asset_base,
timeline_inline_js=timeline_inline_js,
timeline_inline_css=timeline_inline_css,
)
)

Expand Down
38 changes: 35 additions & 3 deletions claude_code_log/html/templates/components/timeline.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
</div>
</div>

{# Inline vis-timeline (issue #278) only when there is no output directory
to hold a sidecar — keeps a bare-string / single-file render's timeline
working offline. The content is our own vendored asset, not transcript
data, and its </script sequences are neutralised in Python. #}
{%- if timeline_inline_js | default(none) %}
<style id="vis-timeline-inline-css">{{ timeline_inline_css | safe }}</style>
<script id="vis-timeline-inline-js">{{ timeline_inline_js | safe }}</script>
{%- endif %}

<script id="timeline-script">
// Timeline functionality - inline for self-contained HTML
(function () {
Expand Down Expand Up @@ -347,17 +356,39 @@
return;
}

console.log('Loading vis-timeline from CDN...');
// Vendored, offline-first (issue #278) — never a CDN.
// Two shapes depending on how the page was rendered:
// - sidecar: the Python renderer wrote vis-timeline's
// JS/CSS into a sibling `assets/` dir and passes the
// relative base; we load from there.
// - inline: no output directory existed (bare-string
// render), so the JS/CSS were embedded directly below
// and are already present when this runs.
{%- set _asset_base = timeline_asset_base | default(none) %}
{%- set _inline_js = timeline_inline_js | default(none) %}
{% if _inline_js %}
// Inline mode: the vis-timeline <script>/<style> were
// emitted into the page (see below), so the library is
// already defined. Nothing to fetch.
if (window.vis && window.vis.Timeline) {
isTimelineLoaded = true;
resolve();
} else {
reject(new Error('Inlined vis-timeline did not initialise'));
}
{% else %}
const assetBase = {{ ((_asset_base ~ '/') if _asset_base else '') | tojson }};
console.log('Loading vis-timeline from', assetBase || '(no local asset base)');

// Load CSS first
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/vis-timeline/styles/vis-timeline-graph2d.min.css';
link.href = assetBase + 'vis-timeline-graph2d.min.css';
document.head.appendChild(link);

// Load JavaScript
const script = document.createElement('script');
script.src = 'https://unpkg.com/vis-timeline/standalone/umd/vis-timeline-graph2d.min.js';
script.src = assetBase + 'vis-timeline-graph2d.min.js';
script.onload = () => {
console.log('vis-timeline loaded successfully');
isTimelineLoaded = true;
Expand All @@ -368,6 +399,7 @@
reject(new Error('Failed to load vis-timeline'));
};
document.head.appendChild(script);
{% endif %}
});
}

Expand Down
65 changes: 65 additions & 0 deletions claude_code_log/html/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import html
import json
import re
import shutil
from pathlib import Path
from typing import Any, Callable, Optional, TYPE_CHECKING

Expand Down Expand Up @@ -894,3 +895,67 @@ def get_template_environment() -> Environment:
globals_dict: Any = env.globals
globals_dict["starts_with_emoji"] = starts_with_emoji
return env


# Sub-directory (relative to a transcript HTML file) where vendored
# front-end assets are written. All transcript pages for a project live
# flat in one output directory, so this relative path is constant across
# session / combined / paginated pages.
VENDOR_ASSETS_DIRNAME = "assets"

# The vendored files that a timeline-bearing page needs, resolved from
# the package's ``assets/vendor`` directory (see that dir's
# ``PROVENANCE.md``). Vendored rather than CDN-loaded so generated HTML
# stays fully offline and leaks no request to a third party (issue #278).
_VENDOR_ASSET_FILES = (
"vis-timeline-graph2d.min.js",
"vis-timeline-graph2d.min.css",
)


def _vendor_source_dir() -> Path:
return Path(__file__).parent / "assets" / "vendor"


def ensure_vendor_assets(output_dir: Path) -> None:
"""Copy vendored front-end assets into ``output_dir/assets`` once.

Idempotent: skips any file already present with a matching size, so
re-rendering a project doesn't rewrite the 530 KB timeline bundle on
every run. Called by the HTML renderer whenever it renders a
timeline-bearing page into a real output directory; string-only
renders (no output dir) simply don't get the sidecar, and the
timeline's ``script.onerror`` handler degrades gracefully if the
asset is absent.
"""
src_dir = _vendor_source_dir()
dest_dir = output_dir / VENDOR_ASSETS_DIRNAME
dest_dir.mkdir(parents=True, exist_ok=True)
for name in _VENDOR_ASSET_FILES:
src = src_dir / name
dest = dest_dir / name
if dest.exists() and dest.stat().st_size == src.stat().st_size:
continue
shutil.copyfile(src, dest)


@functools.lru_cache(maxsize=1)
def read_vendor_timeline_inline() -> tuple[str, str]:
"""Return the vendored vis-timeline ``(js, css)`` for inlining.

Used only on the no-output-directory render path (e.g. the
``generate_html`` convenience API), where there is nowhere to write
a sidecar so the assets must be embedded directly in the page to
keep a single self-contained file working (issue #278).

The JS has any ``</script`` sequence neutralised to ``<\\/script``
so it can't break out of the inline ``<script>`` tag. The current
vendored build contains none, but a future upgrade might; this makes
the inline path safe regardless. Cached because the 530 KB read
would otherwise repeat on every string render.
"""
src_dir = _vendor_source_dir()
js = (src_dir / "vis-timeline-graph2d.min.js").read_text(encoding="utf-8")
css = (src_dir / "vis-timeline-graph2d.min.css").read_text(encoding="utf-8")
js = js.replace("</script", "<\\/script")
return js, css
Loading