From 54a258e8f9f7211c79f684c82524a38ed30768f2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 26 May 2026 12:48:01 -0400 Subject: [PATCH 1/6] Remove VHS tape importer and parser --- great_docs/_term_player/importer.py | 150 +--------------------------- 1 file changed, 1 insertion(+), 149 deletions(-) diff --git a/great_docs/_term_player/importer.py b/great_docs/_term_player/importer.py index 34a5ffaa..fb811d80 100644 --- a/great_docs/_term_player/importer.py +++ b/great_docs/_term_player/importer.py @@ -1,9 +1,8 @@ -"""Importers for asciicast and VHS tape formats.""" +"""Importer for asciicast format.""" from __future__ import annotations import json -import re from pathlib import Path from .parser import Recording, parse_asciicast @@ -31,153 +30,6 @@ def import_asciicast(source: str | Path, output: str | Path) -> Recording: return recording -def import_tape(source: str | Path, output: str | Path) -> Recording: - """Import a VHS .tape file and convert to .termshow. - - Parses the VHS tape DSL and generates synthetic terminal events. - This produces a scripted recording rather than a real capture. - - Parameters - ---------- - source - Path to .tape file. - output - Path to write .termshow file. - - Returns - ------- - Recording - The generated recording. - """ - path = Path(source) - text = path.read_text(encoding="utf-8") - - recording = _parse_tape(text) - _write_termshow(recording, output) - return recording - - -def _parse_tape(text: str) -> Recording: - """Parse VHS tape DSL into a Recording with synthetic events.""" - from .parser import Event, Recording, TermInfo - - cols = 80 - rows = 24 - typing_speed = 0.05 # seconds per character - events: list[Event] = [] - current_time = 0.0 - - lines = text.strip().splitlines() - - for line in lines: - line = line.strip() - if not line or line.startswith("#"): - continue - - # Parse Set commands - set_match = re.match(r"Set\s+(\w+)\s+(.+)", line) - if set_match: - key, value = set_match.group(1), set_match.group(2).strip().strip('"') - if key == "Width": - cols = int(value) - elif key == "Height": - rows = int(value) - elif key == "TypingSpeed": - typing_speed = _parse_duration(value) - continue - - # Parse Type command - type_match = re.match(r"Type(?:@(\S+))?\s+(.+)", line) - if type_match: - speed_override = type_match.group(1) - text_content = type_match.group(2).strip().strip('"').strip("`") - speed = _parse_duration(speed_override) if speed_override else typing_speed - - for ch in text_content: - current_time += speed - events.append(Event(time=current_time, code="o", data=ch)) - continue - - # Parse Sleep command - sleep_match = re.match(r"Sleep\s+(.+)", line) - if sleep_match: - duration = _parse_duration(sleep_match.group(1).strip()) - current_time += duration - continue - - # Parse Enter - if re.match(r"Enter(\s+\d+)?", line): - count_match = re.search(r"\d+", line) - count = int(count_match.group()) if count_match else 1 - for _ in range(count): - current_time += 0.05 - events.append(Event(time=current_time, code="o", data="\r\n")) - continue - - # Parse key commands - key_match = re.match(r"(Backspace|Tab|Space|Up|Down|Left|Right|Escape)(\s+\d+)?", line) - if key_match: - key_name = key_match.group(1) - count_str = key_match.group(2) - count = int(count_str.strip()) if count_str else 1 - char_map = { - "Backspace": "\x08", - "Tab": "\t", - "Space": " ", - "Up": "\x1b[A", - "Down": "\x1b[B", - "Right": "\x1b[C", - "Left": "\x1b[D", - "Escape": "\x1b", - } - char = char_map.get(key_name, "") - for _ in range(count): - current_time += 0.05 - events.append(Event(time=current_time, code="o", data=char)) - continue - - # Parse Ctrl+key - ctrl_match = re.match(r"Ctrl\+(\w)", line) - if ctrl_match: - ch = ctrl_match.group(1).upper() - ctrl_char = chr(ord(ch) - 64) - current_time += 0.05 - events.append(Event(time=current_time, code="o", data=ctrl_char)) - continue - - # Hide/Show are noted as markers - if line == "Hide": - events.append(Event(time=current_time, code="m", data="[hidden]")) - continue - if line == "Show": - events.append(Event(time=current_time, code="m", data="[visible]")) - continue - - recording = Recording( - version=1, - format="termshow", - term=TermInfo(cols=cols, rows=rows), - title="", - events=events, - ) - - return recording - - -def _parse_duration(s: str) -> float: - """Parse a VHS duration string (e.g., '500ms', '1s', '2.5').""" - s = s.strip() - if s.endswith("ms"): - return float(s[:-2]) / 1000.0 - elif s.endswith("s"): - return float(s[:-1]) - else: - try: - return float(s) - except ValueError: - return 0.1 - - def _write_termshow(recording: Recording, output: str | Path) -> None: """Write a Recording to .termshow format.""" path = Path(output) From b34ff03fc4308b602e26337705d64df2377f1a73 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 26 May 2026 12:48:32 -0400 Subject: [PATCH 2/6] Remove import-tape CLI command --- great_docs/cli.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/great_docs/cli.py b/great_docs/cli.py index 3487167a..108293c3 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -3339,25 +3339,9 @@ def term_import_cast(source: str, output: str) -> None: click.echo(f" Duration: {recording.duration:.1f}s | Events: {len(recording.events)}") -@click.command("import-tape") -@click.argument("source", type=click.Path(exists=True)) -@click.argument("output", type=click.Path()) -def term_import_tape(source: str, output: str) -> None: - """Import a VHS .tape file to .termshow format.""" - from ._term_player.importer import import_tape - - if not output.endswith(".termshow"): - output += ".termshow" - - recording = import_tape(source, output) - click.echo(f"✓ Imported {source} → {output}") - click.echo(f" Duration: {recording.duration:.1f}s | Events: {len(recording.events)}") - - termshow.add_command(term_record) termshow.add_command(term_render) termshow.add_command(term_import_cast) -termshow.add_command(term_import_tape) @click.command("edit") From 052db01f7fd20d55649b39cccfcdba527a87f98c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 26 May 2026 12:48:43 -0400 Subject: [PATCH 3/6] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 09d79cf0..3e190706 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,6 @@ Beyond `init`, `build`, and `preview`, Great Docs includes a full suite of quali | `great-docs termshow play` | Preview a recording in the terminal | | `great-docs termshow render` | Render SVG frames without a full site build | | `great-docs termshow import-cast` | Import an asciinema `.cast` file | -| `great-docs termshow import-tape` | Import a VHS `.tape` file | ## Recipes From 7845ee4ab03e546fd72c7964a199fee0160a27e8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 26 May 2026 12:49:00 -0400 Subject: [PATCH 4/6] Remove VHS import references from termshow docs --- test-packages/synthetic/specs/gdtest_termshow.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test-packages/synthetic/specs/gdtest_termshow.py b/test-packages/synthetic/specs/gdtest_termshow.py index a2b0e5fd..a33ac1ea 100644 --- a/test-packages/synthetic/specs/gdtest_termshow.py +++ b/test-packages/synthetic/specs/gdtest_termshow.py @@ -642,14 +642,11 @@ "\n" "### Importing Existing Recordings\n" "\n" - "Already have recordings from asciinema or VHS?\n" + "Already have recordings from asciinema?\n" "\n" "```bash\n" "# Import from asciinema\n" "great-docs termshow import-cast recording.cast demos/my-demo\n" - "\n" - "# Import from VHS tape\n" - "great-docs termshow import-tape demo.tape demos/my-demo\n" "```\n" ), # ── Detailed termshow guide ─────────────────────────────────── @@ -990,14 +987,11 @@ "\n" "## Importing Existing Recordings\n" "\n" - "Already have terminal recordings from other tools? Import them:\n" + "Already have terminal recordings from asciinema? Import them:\n" "\n" "```bash\n" "# From asciinema (.cast files)\n" "great-docs termshow import-cast recording.cast demos/my-demo\n" - "\n" - "# From VHS (.tape files)\n" - "great-docs termshow import-tape demo.tape demos/my-demo\n" "```\n" "\n" "The import preserves timing and terminal dimensions. You'll still\n" From 66a3a2d919419ed1dfaad5d06684f624440c1e85 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 26 May 2026 12:49:21 -0400 Subject: [PATCH 5/6] Remove tape-import tests and helpers --- tests/test_term_cli.py | 47 --------- tests/test_term_importer.py | 193 ------------------------------------ 2 files changed, 240 deletions(-) diff --git a/tests/test_term_cli.py b/tests/test_term_cli.py index 25d8aa9b..c92ddbfc 100644 --- a/tests/test_term_cli.py +++ b/tests/test_term_cli.py @@ -45,14 +45,6 @@ def _make_asciicast_file(tmp_path: Path, name: str = "test.cast") -> Path: return f -def _make_tape_file(tmp_path: Path, name: str = "test.tape") -> Path: - """Create a minimal VHS tape file.""" - content = 'Set Width 80\nSet Height 24\nType "hello"\nEnter\n' - f = tmp_path / name - f.write_text(content, encoding="utf-8") - return f - - # --------------------------------------------------------------------------- # term render # --------------------------------------------------------------------------- @@ -173,44 +165,6 @@ def test_import_cast_shows_stats(self, tmp_path: Path): assert "Events:" in result.output -# --------------------------------------------------------------------------- -# term import-tape -# --------------------------------------------------------------------------- - - -class TestTermImportTape: - def test_import_tape(self, tmp_path: Path): - source = _make_tape_file(tmp_path) - output = tmp_path / "imported.termshow" - - runner = CliRunner() - result = runner.invoke(cli, ["termshow", "import-tape", str(source), str(output)]) - - assert result.exit_code == 0 - assert "Imported" in result.output - assert output.exists() - - def test_import_tape_appends_extension(self, tmp_path: Path): - source = _make_tape_file(tmp_path) - output = tmp_path / "imported" - - runner = CliRunner() - result = runner.invoke(cli, ["termshow", "import-tape", str(source), str(output)]) - - assert result.exit_code == 0 - assert (tmp_path / "imported.termshow").exists() - - def test_import_tape_shows_stats(self, tmp_path: Path): - source = _make_tape_file(tmp_path) - output = tmp_path / "out.termshow" - - runner = CliRunner() - result = runner.invoke(cli, ["termshow", "import-tape", str(source), str(output)]) - - assert "Duration:" in result.output - assert "Events:" in result.output - - # --------------------------------------------------------------------------- # term (group help) # --------------------------------------------------------------------------- @@ -224,7 +178,6 @@ def test_help(self): assert "record" in result.output assert "render" in result.output assert "import-cast" in result.output - assert "import-tape" in result.output assert "edit" in result.output diff --git a/tests/test_term_importer.py b/tests/test_term_importer.py index c1ee1e64..46b05a73 100644 --- a/tests/test_term_importer.py +++ b/tests/test_term_importer.py @@ -8,173 +8,12 @@ import pytest from great_docs._term_player.importer import ( - _parse_duration, - _parse_tape, _write_termshow, import_asciicast, - import_tape, ) from great_docs._term_player.parser import Event, Recording, TermInfo, parse_termshow_str -# --------------------------------------------------------------------------- -# _parse_duration -# --------------------------------------------------------------------------- - - -class TestParseDuration: - def test_milliseconds(self): - assert _parse_duration("500ms") == pytest.approx(0.5) - - def test_seconds(self): - assert _parse_duration("2s") == pytest.approx(2.0) - - def test_fractional_seconds(self): - assert _parse_duration("1.5s") == pytest.approx(1.5) - - def test_plain_number(self): - assert _parse_duration("0.1") == pytest.approx(0.1) - - def test_invalid_returns_default(self): - assert _parse_duration("abc") == pytest.approx(0.1) - - def test_with_whitespace(self): - assert _parse_duration(" 200ms ") == pytest.approx(0.2) - - -# --------------------------------------------------------------------------- -# _parse_tape -# --------------------------------------------------------------------------- - - -class TestParseTape: - def test_basic_type_command(self): - tape = 'Type "hello"' - rec = _parse_tape(tape) - # Should produce one event per character - assert len(rec.events) == 5 - chars = "".join(e.data for e in rec.events) - assert chars == "hello" - - def test_type_with_backtick_quotes(self): - tape = "Type `world`" - rec = _parse_tape(tape) - chars = "".join(e.data for e in rec.events) - assert chars == "world" - - def test_enter_command(self): - tape = "Enter" - rec = _parse_tape(tape) - assert len(rec.events) == 1 - assert rec.events[0].data == "\r\n" - - def test_enter_count(self): - tape = "Enter 3" - rec = _parse_tape(tape) - assert len(rec.events) == 3 - assert all(e.data == "\r\n" for e in rec.events) - - def test_sleep_advances_time(self): - tape = 'Type "a"\nSleep 2s\nType "b"' - rec = _parse_tape(tape) - first_time = rec.events[0].time - last_time = rec.events[-1].time - assert last_time - first_time >= 2.0 - - def test_set_width_height(self): - tape = 'Set Width 120\nSet Height 40\nType "x"' - rec = _parse_tape(tape) - assert rec.term.cols == 120 - assert rec.term.rows == 40 - - def test_set_typing_speed(self): - tape = 'Set TypingSpeed 100ms\nType "ab"' - rec = _parse_tape(tape) - # Two chars at 100ms each - gap = rec.events[1].time - rec.events[0].time - assert gap == pytest.approx(0.1) - - def test_type_speed_override(self): - tape = 'Type@200ms "ab"' - rec = _parse_tape(tape) - gap = rec.events[1].time - rec.events[0].time - assert gap == pytest.approx(0.2) - - def test_backspace(self): - tape = "Backspace" - rec = _parse_tape(tape) - assert rec.events[0].data == "\x08" - - def test_backspace_count(self): - tape = "Backspace 3" - rec = _parse_tape(tape) - assert len(rec.events) == 3 - assert all(e.data == "\x08" for e in rec.events) - - def test_tab(self): - tape = "Tab" - rec = _parse_tape(tape) - assert rec.events[0].data == "\t" - - def test_space(self): - tape = "Space" - rec = _parse_tape(tape) - assert rec.events[0].data == " " - - def test_arrow_keys(self): - tape = "Up\nDown\nLeft\nRight" - rec = _parse_tape(tape) - assert rec.events[0].data == "\x1b[A" - assert rec.events[1].data == "\x1b[B" - assert rec.events[2].data == "\x1b[D" - assert rec.events[3].data == "\x1b[C" - - def test_escape(self): - tape = "Escape" - rec = _parse_tape(tape) - assert rec.events[0].data == "\x1b" - - def test_ctrl_key(self): - tape = "Ctrl+C" - rec = _parse_tape(tape) - # Ctrl+C = chr(3) - assert rec.events[0].data == "\x03" - - def test_ctrl_d(self): - tape = "Ctrl+D" - rec = _parse_tape(tape) - assert rec.events[0].data == "\x04" - - def test_hide_show_markers(self): - tape = "Hide\nShow" - rec = _parse_tape(tape) - assert rec.events[0].code == "m" - assert rec.events[0].data == "[hidden]" - assert rec.events[1].code == "m" - assert rec.events[1].data == "[visible]" - - def test_comments_ignored(self): - tape = '# This is a comment\nType "x"' - rec = _parse_tape(tape) - assert len(rec.events) == 1 - - def test_blank_lines_ignored(self): - tape = '\n\nType "x"\n\n' - rec = _parse_tape(tape) - assert len(rec.events) == 1 - - def test_events_have_output_code(self): - tape = 'Type "abc"' - rec = _parse_tape(tape) - assert all(e.code == "o" for e in rec.events) - - def test_times_are_monotonic(self): - tape = 'Type "hello"\nSleep 1s\nEnter\nType "world"' - rec = _parse_tape(tape) - times = [e.time for e in rec.events] - assert times == sorted(times) - - # --------------------------------------------------------------------------- # _write_termshow # --------------------------------------------------------------------------- @@ -269,35 +108,3 @@ def test_imports_and_writes(self, tmp_path: Path): # Verify the output is parseable parsed = parse_termshow_str(output.read_text()) assert len(parsed.events) == 2 - - -# --------------------------------------------------------------------------- -# import_tape (integration) -# --------------------------------------------------------------------------- - - -class TestImportTape: - def test_imports_and_writes(self, tmp_path: Path): - tape_content = """\ -Set Width 100 -Set Height 30 -Type "ls -la" -Enter -Sleep 500ms -Type "exit" -Enter -""" - source = tmp_path / "test.tape" - source.write_text(tape_content, encoding="utf-8") - - output = tmp_path / "result.termshow" - rec = import_tape(source, output) - - assert output.exists() - assert rec.term.cols == 100 - assert rec.term.rows == 30 - assert len(rec.events) > 0 - - # Verify the output is parseable - parsed = parse_termshow_str(output.read_text()) - assert parsed.term.cols == 100 From ad69bb5c7758cecdd68275aa1328c303d959d48f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 26 May 2026 12:49:41 -0400 Subject: [PATCH 6/6] Clarify terminal import docs for asciinema --- user_guide/41-terminal-recordings.qmd | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/user_guide/41-terminal-recordings.qmd b/user_guide/41-terminal-recordings.qmd index 1669e62d..2fa23346 100644 --- a/user_guide/41-terminal-recordings.qmd +++ b/user_guide/41-terminal-recordings.qmd @@ -563,20 +563,17 @@ the first one wins and subsequent ones are hidden. ## Importing Existing Recordings -Already have terminal recordings from other tools? Import them: +Already have terminal recordings from asciinema? Import them: ```bash # From asciinema (.cast files) great-docs termshow import-cast recording.cast assets/my-demo - -# From VHS (.tape files) -great-docs termshow import-tape demo.tape assets/my-demo ``` The import preserves timing and terminal dimensions. Create a `.termshow.yml` script afterward to add chapters and annotations. -Both importers produce the same `.termshow` format, so all editing and rendering features work +The importer produces the same `.termshow` format, so all editing and rendering features work identically regardless of the original source. ## Rendering & Preview