Skip to content
Merged
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
49 changes: 49 additions & 0 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
pixi.lock
*.mojopkg
__pycache__/
.bench_parse
docs/api.json
docs/api.html
docs/site/
34 changes: 34 additions & 0 deletions bench/bench_parse.mojo
Original file line number Diff line number Diff line change
@@ -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)
165 changes: 165 additions & 0 deletions docs/render_api.py
Original file line number Diff line number Diff line change
@@ -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/<pkg>
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'<code>{esc(s)}</code>'


def render_overload(o):
sig = o.get("signature", "")
doc = o.get("summary") or o.get("description") or ""
raises = o.get("raisesDoc") or ""
parts = [f'<div class="sig">{esc(sig)}</div>']
if doc:
parts.append(f'<p class="doc">{esc(doc)}</p>')
if raises:
parts.append(f'<p class="raises"><span>raises</span> {esc(raises)}</p>')
return "".join(parts)


def render_function(f):
name = f.get("name", "")
body = "".join(render_overload(o) for o in f.get("overloads", []))
return (f'<div class="item" data-name="{esc(name.lower())}">'
f'<h4>{esc(name)}<span class="kind">fn</span></h4>{body}</div>')


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'<li>{label}{d}</li>'


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'<div class="item struct" data-name="{esc(name.lower())}">']
out.append(f'<h3>{esc(name)}<span class="kind">struct</span></h3>')
if traits:
names = [t.get("name", "") if isinstance(t, dict) else str(t) for t in traits]
out.append('<p class="traits">' + " · ".join(code(n) for n in names if n) + "</p>")
if summary:
out.append(f'<p class="doc">{esc(summary)}</p>')
if fields:
out.append('<div class="sub">Fields</div><ul class="fields">')
out.extend(render_field(f) for f in fields)
out.append("</ul>")
if methods:
out.append('<div class="sub">Methods</div>')
out.extend(render_function(m) for m in methods)
out.append("</div>")
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'<li>{code(name)} = {code(sig)}{d}</li>' if sig else f'<li>{code(name)}{d}</li>'


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'<section class="module"><h2 id="{esc(name)}">{esc(name)}</h2>']
if summary:
out.append(f'<p class="doc">{esc(summary)}</p>')
if aliases:
out.append('<div class="sub">Aliases</div><ul class="aliases">')
out.extend(render_alias(a) for a in aliases)
out.append("</ul>")
if structs:
out.extend(render_struct(s) for s in structs)
if functions:
out.append('<div class="sub">Functions</div>')
out.extend(render_function(f) for f in functions)
out.append("</section>")
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"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{esc(pkg)} — API reference</title><style>{CSS}</style></head><body><div class="wrap">
<h1>{esc(pkg)}</h1><p class="ver">API reference{f' · {esc(version)}' if version else ''}</p>
{f'<p class="doc">{esc(summary)}</p>' if summary else ''}
<input id="q" type="search" placeholder="Filter symbols…" autocomplete="off">
{modules}
<footer>Generated from <code>mojo doc</code> JSON by <code>docs/render_api.py</code>. No hand-written HTML.</footer>
</div><script>{JS}</script></body></html>"""
open(out, "w").write(page)
print(f"wrote {out} ({len(page)} bytes)")


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
39 changes: 39 additions & 0 deletions recipe.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/captions/__init__.mojo
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
10 changes: 5 additions & 5 deletions src/captions/captions.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -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^


Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/captions/model.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 10 additions & 5 deletions test/test_captions.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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\n<v Conor Bronsdon>Welcome.</v>\n")
String(
"WEBVTT\n\n00:01.000 --> 00:04.000\n<v Conor"
" Bronsdon>Welcome.</v>\n"
)
)
assert_equal(caps.cues[0].speaker, "Conor Bronsdon")
assert_equal(caps.cues[0].text, "Welcome.")
Expand Down Expand Up @@ -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\n<v Conor Bronsdon>Hello there.</v>\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\n<v Conor"
" Bronsdon>Hello there.</v>\n\n2\n01:02:03.456 -->"
" 01:02:05.789\nTwo lines\nof text.\n"
)
)
var reparsed = parse_captions(to_vtt(original))
Expand Down
Loading