From df8a06a0227c7c272b8e1c6545fdd8402266f20c Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Tue, 7 Jul 2026 20:01:38 -0700 Subject: [PATCH 1/2] style: commit canonical mojo format output --- src/captions/captions.mojo | 10 +++++----- src/captions/model.mojo | 2 +- test/test_captions.mojo | 15 ++++++++++----- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/captions/captions.mojo b/src/captions/captions.mojo index 55abb01..9210f93 100644 --- a/src/captions/captions.mojo +++ b/src/captions/captions.mojo @@ -92,9 +92,7 @@ def _split_lines(source: String) -> List[String]: var end = len(data) if data[end - 1] == _CR: end -= 1 - lines.append( - String(StringSlice(unsafe_from_utf8=data[line_start:end])) - ) + lines.append(String(StringSlice(unsafe_from_utf8=data[line_start:end]))) return lines^ @@ -212,8 +210,10 @@ def _strip_voice_tags(text: String, mut speaker: String) -> String: ): i += 4 continue - if i + 2 < n and bytes[i + 1] == _V and ( - _is_space(bytes[i + 2]) or bytes[i + 2] == _DOT + if ( + i + 2 < n + and bytes[i + 1] == _V + and (_is_space(bytes[i + 2]) or bytes[i + 2] == _DOT) ): var gt = i + 2 while gt < n and bytes[gt] != _GT: diff --git a/src/captions/model.mojo b/src/captions/model.mojo index 33c2404..65c0701 100644 --- a/src/captions/model.mojo +++ b/src/captions/model.mojo @@ -5,7 +5,7 @@ comptime KIND_VTT = "vtt" @fieldwise_init -struct Cue(Copyable, Movable, Writable, Equatable): +struct Cue(Copyable, Equatable, Movable, Writable): """One subtitle cue. Empty string means the field was absent. `index` is the SRT cue number or the numeric WebVTT cue identifier; diff --git a/test/test_captions.mojo b/test/test_captions.mojo index b3d9fe9..0ed7bd9 100644 --- a/test/test_captions.mojo +++ b/test/test_captions.mojo @@ -56,7 +56,9 @@ def test_srt_multiline_text() raises: def test_srt_speaker_colon_convention() raises: var caps = parse_captions( - String("1\n00:00:01,000 --> 00:00:04,000\nConor Bronsdon: Welcome back.\n") + String( + "1\n00:00:01,000 --> 00:00:04,000\nConor Bronsdon: Welcome back.\n" + ) ) assert_equal(caps.cues[0].speaker, "Conor Bronsdon") assert_equal(caps.cues[0].text, "Welcome back.") @@ -81,7 +83,10 @@ def test_vtt_hourless_timestamps() raises: def test_vtt_voice_span_speaker() raises: var caps = parse_captions( - String("WEBVTT\n\n00:01.000 --> 00:04.000\nWelcome.\n") + String( + "WEBVTT\n\n00:01.000 --> 00:04.000\nWelcome.\n" + ) ) assert_equal(caps.cues[0].speaker, "Conor Bronsdon") assert_equal(caps.cues[0].text, "Welcome.") @@ -229,9 +234,9 @@ def test_roundtrip_srt() raises: def test_roundtrip_vtt() raises: var original = parse_captions( String( - "WEBVTT\n\n" - "1\n00:00:01.000 --> 00:00:04.000\nHello there.\n\n" - "2\n01:02:03.456 --> 01:02:05.789\nTwo lines\nof text.\n" + "WEBVTT\n\n1\n00:00:01.000 --> 00:00:04.000\nHello there.\n\n2\n01:02:03.456 -->" + " 01:02:05.789\nTwo lines\nof text.\n" ) ) var reparsed = parse_captions(to_vtt(original)) From 3cdf144a4bf930b9e23c9e7a265610e1b27f5aba Mon Sep 17 00:00:00 2001 From: Conor Bronsdon Date: Tue, 7 Jul 2026 20:05:13 -0700 Subject: [PATCH 2/2] Add API-doc site, format gate, recipe.yaml, bench (suite-wide quick wins) --- .github/workflows/docs.yaml | 49 +++++++++++ .github/workflows/test.yml | 5 ++ .gitignore | 4 + bench/bench_parse.mojo | 34 ++++++++ docs/render_api.py | 165 ++++++++++++++++++++++++++++++++++++ pixi.toml | 3 + recipe.yaml | 39 +++++++++ src/captions/__init__.mojo | 2 +- 8 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yaml create mode 100755 bench/bench_parse.mojo create mode 100644 docs/render_api.py create mode 100755 recipe.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..af208b8 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,49 @@ +name: docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install Mojo nightly + run: | + uv venv + uv pip install mojo \ + --index https://whl.modular.com/nightly/simple/ \ + --prerelease allow + + - name: Generate API reference + run: | + .venv/bin/mojo doc -o docs/api.json -I src src/captions + python3 docs/render_api.py docs/api.json docs/site/index.html + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73741cb..49faa4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,10 @@ jobs: --prerelease allow .venv/bin/mojo --version + - name: Format check + run: | + .venv/bin/mojo format src/ test/ examples/ bench/ + git diff --exit-code || (echo "::error::Run 'pixi run fmt' — sources are not mojo-format clean" && exit 1) + - name: Tests run: .venv/bin/mojo run -I src test/test_captions.mojo diff --git a/.gitignore b/.gitignore index fc1e371..b0df644 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ pixi.lock *.mojopkg __pycache__/ +.bench_parse +docs/api.json +docs/api.html +docs/site/ diff --git a/bench/bench_parse.mojo b/bench/bench_parse.mojo new file mode 100755 index 0000000..0e35d3e --- /dev/null +++ b/bench/bench_parse.mojo @@ -0,0 +1,34 @@ +"""Throughput benchmark for `parse_captions` over the bundled sample files. + +Reports wall-clock per parse and MB/s. Run compiled for meaningful numbers: +`mojo build -I src bench/bench_parse.mojo -o .bench_parse && ./.bench_parse` +(or `pixi run bench`). The inputs are the same SRT/WebVTT fixtures the unit +tests parse, so the benchmark measures the real parse path. +""" +from std.time import perf_counter_ns + +from captions import parse_captions + + +def bench(path: String, iterations: Int) raises: + var source = open(path, "r").read() + var size_mb = Float64(source.byte_length()) / (1024.0 * 1024.0) + # Warmup + correctness anchor: count cues once, require stability. + var warm = parse_captions(source.copy()) + var n = len(warm.cues) + var start = perf_counter_ns() + for _ in range(iterations): + var parsed = parse_captions(source.copy()) + if len(parsed.cues) != n: + raise Error("inconsistent parse") + var elapsed_ns = perf_counter_ns() - start + var per_parse_ms = Float64(elapsed_ns) / Float64(iterations) / 1e6 + var mb_per_s = size_mb / (per_parse_ms / 1000.0) + print(path) + print(t" {source.byte_length()} bytes, {n} cues:") + print(t" {per_parse_ms} ms/parse, {mb_per_s} MB/s") + + +def main() raises: + bench("test/data/sample.srt", 20000) + bench("test/data/sample.vtt", 20000) diff --git a/docs/render_api.py b/docs/render_api.py new file mode 100644 index 0000000..ee8fdc1 --- /dev/null +++ b/docs/render_api.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Render `mojo doc` JSON into a single self-contained HTML API reference. + +`mojo doc` emits JSON, not HTML; this turns that JSON into a searchable, +theme-aware, dependency-free page. Reusable across the pure-Mojo suite — it +reads the package name/version out of the JSON, nothing is hardcoded. + + mojo doc -o docs/api.json -I src src/ + python3 docs/render_api.py docs/api.json docs/api.html +""" +import html +import json +import os +import sys + + +def esc(s): + return html.escape(s or "") + + +def code(s): + return f'{esc(s)}' + + +def render_overload(o): + sig = o.get("signature", "") + doc = o.get("summary") or o.get("description") or "" + raises = o.get("raisesDoc") or "" + parts = [f'
{esc(sig)}
'] + if doc: + parts.append(f'

{esc(doc)}

') + if raises: + parts.append(f'

raises {esc(raises)}

') + return "".join(parts) + + +def render_function(f): + name = f.get("name", "") + body = "".join(render_overload(o) for o in f.get("overloads", [])) + return (f'
' + f'

{esc(name)}fn

{body}
') + + +def render_field(fl): + name = fl.get("name", "") + typ = fl.get("type", "") or fl.get("signature", "") + doc = fl.get("summary") or fl.get("description") or "" + d = f' — {esc(doc)}' if doc else "" + label = f'{code(name)}: {code(typ)}' if typ else code(name) + return f'
  • {label}{d}
  • ' + + +def render_struct(s): + name = s.get("name", "") + summary = s.get("summary") or s.get("description") or "" + traits = s.get("parentTraits") or [] + fields = s.get("fields", []) + methods = s.get("functions", []) + out = [f'
    '] + out.append(f'

    {esc(name)}struct

    ') + if traits: + names = [t.get("name", "") if isinstance(t, dict) else str(t) for t in traits] + out.append('

    ' + " · ".join(code(n) for n in names if n) + "

    ") + if summary: + out.append(f'

    {esc(summary)}

    ') + if fields: + out.append('
    Fields
      ') + out.extend(render_field(f) for f in fields) + out.append("
    ") + if methods: + out.append('
    Methods
    ') + out.extend(render_function(m) for m in methods) + out.append("
    ") + return "".join(out) + + +def render_alias(a): + name = a.get("name", "") + sig = a.get("signature", "") + doc = a.get("summary") or a.get("description") or "" + d = f' — {esc(doc)}' if doc else "" + return f'
  • {code(name)} = {code(sig)}{d}
  • ' if sig else f'
  • {code(name)}{d}
  • ' + + +def render_module(m): + name = m.get("name", "") + summary = m.get("summary") or m.get("description") or "" + aliases = m.get("aliases", []) + functions = m.get("functions", []) + structs = m.get("structs", []) + if not (aliases or functions or structs): + return "" + out = [f'

    {esc(name)}

    '] + if summary: + out.append(f'

    {esc(summary)}

    ') + if aliases: + out.append('
    Aliases
      ') + out.extend(render_alias(a) for a in aliases) + out.append("
    ") + if structs: + out.extend(render_struct(s) for s in structs) + if functions: + out.append('
    Functions
    ') + out.extend(render_function(f) for f in functions) + out.append("
    ") + return "".join(out) + + +CSS = """ +:root{color-scheme:light dark;--bg:#fcfcfb;--fg:#0b0b0b;--muted:#57564f;--panel:#f3f3ef;--border:rgba(0,0,0,.1);--accent:#2a78d6;--code:#0b7285} +@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8b949e;--panel:#161b22;--border:rgba(255,255,255,.1);--accent:#57c5bb;--code:#7ee0d6}} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif} +.wrap{max-width:860px;margin:0 auto;padding:32px 20px 80px} +h1{font-size:1.9rem;margin:0 0 4px}.ver{color:var(--muted);margin:0 0 20px} +code{font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85em;color:var(--code)} +#q{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--panel);color:var(--fg);font-size:1rem;margin-bottom:24px} +.module{border-top:1px solid var(--border);padding-top:8px;margin-top:24px} +h2{font-size:1.3rem;color:var(--accent)} +.item{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:12px 0} +.item h3,.item h4{margin:0 0 8px;font-size:1.05rem} +.kind{font-size:.7rem;font-weight:600;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:5px;padding:1px 6px;margin-left:8px;vertical-align:middle} +.sig{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.82rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow-x:auto;white-space:pre;margin:6px 0} +.doc{color:var(--fg);margin:6px 0}.sub{font-size:.75rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin:14px 0 4px} +.traits{color:var(--muted);margin:2px 0 8px}.raises{color:var(--muted);font-size:.9em;margin:4px 0}.raises span{color:#d03b3b;font-weight:600} +ul{margin:4px 0;padding-left:20px}li{margin:3px 0}.hidden{display:none} +footer{margin-top:40px;color:var(--muted);font-size:.85rem;border-top:1px solid var(--border);padding-top:16px} +a{color:var(--accent)} +""" + +JS = """ +const q=document.getElementById('q'); +q.addEventListener('input',()=>{const v=q.value.toLowerCase(); +document.querySelectorAll('.item').forEach(el=>{ + el.classList.toggle('hidden', v && !(el.dataset.name||'').includes(v) && !el.textContent.toLowerCase().includes(v));}); +document.querySelectorAll('.module').forEach(m=>{ + const any=[...m.querySelectorAll('.item')].some(i=>!i.classList.contains('hidden')); + m.style.display=any||!v?'':'none';});}); +""" + + +def main(): + src, out = sys.argv[1], sys.argv[2] + d = json.load(open(src)) + decl = d.get("decl", {}) + pkg = decl.get("name", "package") + version = d.get("version", "") + summary = decl.get("summary") or decl.get("description") or "" + if os.path.dirname(out): + os.makedirs(os.path.dirname(out), exist_ok=True) + modules = "".join(render_module(m) for m in decl.get("modules", [])) + page = f""" + +{esc(pkg)} — API reference
    +

    {esc(pkg)}

    API reference{f' · {esc(version)}' if version else ''}

    +{f'

    {esc(summary)}

    ' if summary else ''} + +{modules} +
    Generated from mojo doc JSON by docs/render_api.py. No hand-written HTML.
    +
    """ + open(out, "w").write(page) + print(f"wrote {out} ({len(page)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/pixi.toml b/pixi.toml index ba9d3d6..698bc12 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,6 +9,9 @@ version = "0.1.0" [tasks] test = "mojo run -I src test/test_captions.mojo" demo = "mojo run -I src examples/clip_transcript.mojo test/data/sample.vtt 0 20000" +bench = "mojo build -I src bench/bench_parse.mojo -o .bench_parse && ./.bench_parse" +fmt = "mojo format src/ test/ examples/ bench/" +docs = "mojo doc -o docs/api.json -I src src/captions && python3 docs/render_api.py docs/api.json docs/api.html" [dependencies] mojo = ">=1.0.0b3.dev0,<2" diff --git a/recipe.yaml b/recipe.yaml new file mode 100755 index 0000000..253636a --- /dev/null +++ b/recipe.yaml @@ -0,0 +1,39 @@ +# rattler-build recipe for publishing mojo-captions to a conda channel +# (e.g. modular-community). Not wired to CI yet — this documents the intended +# distribution shape and is ready to build the day we decide to publish. +# +# Distributes the pure-Mojo SOURCE TREE, not a compiled `.mojopkg`: a .mojopkg +# embeds the exact compiler version and refuses to load against any other, so a +# benign Mojo nightly bump would break every consumer. Shipping source + a +# runtime `mojo` pin gets reproducibility without ABI lock-in. (Pattern follows +# ehsanmok/flare's recipe.) +# +# Build locally with: rattler-build build --recipe recipe.yaml + +context: + version: "0.1.0" + +package: + name: mojo-captions + version: ${{ version }} + +source: + path: . + +build: + number: 0 + noarch: generic + script: + - mkdir -p ${{ PREFIX }}/lib/mojo + - cp -r src/captions ${{ PREFIX }}/lib/mojo/captions + +requirements: + run: + - mojo >=1.0.0b3.dev0,<2 + +about: + homepage: https://github.com/conorbronsdon/mojo-captions + repository: https://github.com/conorbronsdon/mojo-captions + license: MIT + license_file: LICENSE + summary: SRT and WebVTT subtitle/transcript parser in pure Mojo diff --git a/src/captions/__init__.mojo b/src/captions/__init__.mojo index dff871b..ea6796e 100644 --- a/src/captions/__init__.mojo +++ b/src/captions/__init__.mojo @@ -1,4 +1,4 @@ -"""mojo-captions: SRT and WebVTT subtitle/transcript parsing for Mojo.""" +"""SRT and WebVTT subtitle/transcript parsing for Mojo (mojo-captions).""" from captions.model import Cue, Captions, KIND_SRT, KIND_VTT from captions.captions import (