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/template
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_template.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_render
docs/api.json
docs/api.html
docs/site/
103 changes: 103 additions & 0 deletions bench/bench_render.mojo
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Throughput benchmark for `render` over the Jinja2 parity corpus.

Times the full parse+render path (autoescape on, strict-undefined) across
every template in `test/data/fixtures/manifest.txt` — the same corpus the
byte-match parity test replays, against the same context. The fixtures are
tiny (~2.3 KB of template source total), so each measurement pass renders
the whole corpus and we run many passes for stable numbers. Run compiled
for meaningful numbers:
`mojo build -I src bench/bench_render.mojo -o .bench_render &&
./.bench_render` (or `pixi run bench`).
"""
from std.time import perf_counter_ns

from template import render, TemplateValue, Context


def bench_context() raises -> Context:
"""Mirror of `CONTEXT` in test/data/gen_fixtures.py (same as the tests)."""
var ctx = Context()
ctx["name"] = TemplateValue("Conor & Kate")
ctx["greeting"] = TemplateValue("hello world")
ctx["count"] = TemplateValue(3)
ctx["price"] = TemplateValue(2.5)
ctx["active"] = TemplateValue(True)
ctx["html"] = TemplateValue("<b>Hi</b>")
ctx["items"] = TemplateValue.list(
[
TemplateValue("apple"),
TemplateValue("banana"),
TemplateValue("cherry"),
]
)
ctx["nums"] = TemplateValue.list(
[TemplateValue(1), TemplateValue(2), TemplateValue(3)]
)
ctx["user"] = TemplateValue.dict(
["name", "role"], [TemplateValue("Conor"), TemplateValue("lead")]
)
ctx["people"] = TemplateValue.list(
[
TemplateValue.dict(
["name", "admin"], [TemplateValue("Ann"), TemplateValue(True)]
),
TemplateValue.dict(
["name", "admin"], [TemplateValue("Bob"), TemplateValue(False)]
),
]
)
ctx["empty"] = TemplateValue.list([])
ctx["word"] = TemplateValue("the QUICK brown fox")
ctx["long"] = TemplateValue("the quick brown fox jumps over the lazy dog")
return ctx^


def _split(s: String, delim: UInt8) -> List[String]:
var b = s.as_bytes()
var out = List[String]()
var start = 0
for i in range(len(b)):
if b[i] == delim:
out.append(String(StringSlice(unsafe_from_utf8=b[start:i])))
start = i + 1
out.append(String(StringSlice(unsafe_from_utf8=b[start : len(b)])))
return out^


def main() raises:
var manifest = open("test/data/fixtures/manifest.txt", "r").read()
var records = _split(manifest, 0x1D)
var templates = List[String]()
var source_bytes = 0
for rec in records:
var fields = _split(rec, 0x1E)
if len(fields) != 3:
continue
templates.append(fields[1])
source_bytes += fields[1].byte_length()
var ctx = bench_context()

# Warmup + correctness anchor: total output bytes must stay stable.
var expected_out = 0
for tmpl in templates:
expected_out += render(tmpl, ctx).byte_length()

comptime PASSES = 2000
var start = perf_counter_ns()
for _ in range(PASSES):
var out_bytes = 0
for tmpl in templates:
out_bytes += render(tmpl, ctx).byte_length()
if out_bytes != expected_out:
raise Error("inconsistent render")
var elapsed_ns = perf_counter_ns() - start
var per_pass_ms = Float64(elapsed_ns) / Float64(PASSES) / 1e6
var per_render_us = (
Float64(elapsed_ns) / Float64(PASSES * len(templates)) / 1e3
)
var mb_per_s = (Float64(source_bytes) / (1024.0 * 1024.0)) / (
per_pass_ms / 1000.0
)
print(t"{len(templates)} templates, {source_bytes} bytes of source")
print(t" {per_pass_ms} ms/corpus pass ({PASSES} passes)")
print(t" {per_render_us} us/render, {mb_per_s} MB/s of template source")
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()
9 changes: 5 additions & 4 deletions examples/render_email.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ from template import render, TemplateValue, Context
def main() raises:
var ctx = Context()
ctx["subject"] = TemplateValue("Weekly digest for <you> & the team")
ctx["user"] = TemplateValue.dict(
["name"], [TemplateValue("Conor")]
)
ctx["user"] = TemplateValue.dict(["name"], [TemplateValue("Conor")])
ctx["episodes"] = TemplateValue.list(
[
TemplateValue.dict(
["title", "guest"],
[TemplateValue("Scaling inference"), TemplateValue("A. Rivera")],
[
TemplateValue("Scaling inference"),
TemplateValue("A. Rivera"),
],
),
TemplateValue.dict(
["title", "guest"],
Expand Down
3 changes: 3 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ version = "0.1.0"
test = "mojo run -I src test/test_template.mojo"
demo = "mojo run -I src examples/render_email.mojo"
fuzz = "mojo run -I src test/fuzz_runner.mojo"
bench = "mojo build -I src bench/bench_render.mojo -o .bench_render && ./.bench_render"
fmt = "mojo format src/ test/ examples/ bench/"
docs = "mojo doc -o docs/api.json -I src src/template && python3 docs/render_api.py docs/api.json docs/api.html"

[dependencies]
mojo = ">=1.0.0b3.dev0,<2"
Loading
Loading