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
52 changes: 52 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
// oxfmt formats the JS, CSS, JSON and YAML; `just fmt-js` runs it, and
// `just fmt-check` checks. See "Formatting" in CONTRIBUTING.md.
// Comments here must sit on lines of their own: scripts/fmt_templates.py
// reads this file too.
"printWidth": 120,
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"overrides": [
{
"files": ["*.css"],
"options": { "singleQuote": false }
},
{
"files": ["*.yml", "*.yaml"],
"options": { "tabWidth": 2, "singleQuote": false }
},
{
"files": ["*.json", "*.jsonc"],
"options": { "tabWidth": 2 }
},
{
"files": ["**/.vscode/settings.json"],
"options": { "tabWidth": 4 }
}
],
"ignorePatterns": [
// Markdown and TOML stay as written (oxfmt collapses TOML arrays that
// `uv add` expands again).
"**/*.md",
"**/*.toml",
// Data, generated output and plans.
"test/test_data/**",
"test/__snapshots__/**",
"dev-docs/messages/**",
"site/**",
"work/**",
"scripts/style_guide_output/**",
// Read line by line by scripts/generate_minimal_pygments_css.py, which
// generates the second one.
"claude_code_log/html/templates/components/pygments_styles.css",
"claude_code_log/html/templates/components/minimal/pygments_dark.css",
// Jinja inside: formatted by scripts/fmt_templates.py instead.
"claude_code_log/html/templates/components/minimal/chrome.css",
"claude_code_log/html/templates/components/minimal/layout.css",
"claude_code_log/html/templates/components/minimal/minimal.js",
"claude_code_log/html/templates/components/minimal/pages.js",
"claude_code_log/html/templates/components/minimal/theme_init.js"
]
}
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ uv run ty check

# Lint the templates' JavaScript (installs the optional `js` group)
just lint-js

# Format JS, CSS, JSON and YAML, or only check (same group)
just fmt-js
just fmt-check
```

The HTML pages carry a fair amount of JavaScript: the files under
Expand Down Expand Up @@ -268,6 +272,28 @@ generated — don't edit it by hand. After a Pygments upgrade or a change to
`uv run python scripts/generate_minimal_pygments_css.py`; a unit test fails
while the committed file differs from the script's output.

### Formatting JS, CSS, JSON and YAML

[oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), oxlint's sibling,
formats the JavaScript, CSS, JSON and YAML; it comes from the same `js`
group, pinned to an exact version because its output changes between
releases. `.oxfmtrc.json` holds the options, and its `ignorePatterns`
leave out, each with its reason: Markdown and TOML, data and generated
files, and the two Pygments sheets, which
`scripts/generate_minimal_pygments_css.py` reads line by line.

A few component files hold Jinja (an `{% include %}`, a `{# comment #}`),
which oxfmt cannot parse. `just fmt-js` formats them through
`scripts/fmt_templates.py`, which masks each Jinja construct as a
`/* jinja:N */` comment, formats the masked copy, and puts the constructs
back verbatim. Each construct must sit on lines of its own; the script
refuses anything else. Such files are found by their content, but they must
also be listed in `ignorePatterns`, which a unit test checks.

When upgrading oxfmt, look at what a run *covers* as well as whether it
passes: a release can start formatting a new language (Markdown arrived
after 0.69), and then a bare run rewrites files nobody chose to format.

### Whitespace

An [`.editorconfig`](.editorconfig) at the repo root defines the baseline —
Expand Down
16 changes: 16 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ ty:
lint-js *ARGS:
uv run --group js python scripts/lint_js.py {{ ARGS }}

# Format the JS, CSS, JSON and YAML with oxfmt (installs the optional `js`
# group); the template components holding Jinja go through
# scripts/fmt_templates.py. Configured in .oxfmtrc.json.
fmt-js:
uv run --group js oxfmt
uv run --group js python scripts/fmt_templates.py

# Check that formatting, writing nothing. Both checks run, so every file
# that needs formatting is listed.
fmt-check:
#!/usr/bin/env bash
status=0
uv run --group js oxfmt --check || status=1
uv run --group js python scripts/fmt_templates.py --check || status=1
exit $status

# Fail-fast order: format and lint take seconds, ty is faster than pyright, the full test suite runs last
ci: format lint lint-js ty typecheck test-all

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ dev = [
# official oxc-project release (packaging: github.com/dhruvkb/oxc-py), so
# it is pinned exactly and bumped deliberately.
js = [
"oxfmt==0.69.0",
"oxlint==1.84.0",
]

Expand Down
176 changes: 176 additions & 0 deletions scripts/fmt_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Format the template components that carry Jinja, with oxfmt.

oxfmt formats the rest of the JS and CSS directly (``just fmt-js``), but a
few files under ``html/templates/components/`` hold a Jinja ``{% include %}``
or ``{# comment #}``, which no JS or CSS parser accepts. ``.oxfmtrc.json``
ignores them, and this script formats them instead:

1. **mask** — each Jinja construct becomes a ``/* jinja:N */`` placeholder,
a comment in both JS and CSS;
2. **format** — oxfmt formats the masked copy in a staging directory, with
the repo's configuration;
3. **unmask** — each placeholder is replaced by its construct, verbatim and
at its original column.

A construct must sit on lines of its own; anything else is refused rather
than guessed at. The files are found by content, so a new include is picked
up without a list to update, but it must also be added to ``ignorePatterns``
(the plain oxfmt run cannot parse it), which this script checks.

``--check`` writes nothing, lists the files that would change and exits 1
if there are any. It cannot use ``oxfmt --check`` on the masked copy:
oxfmt indents a placeholder that the file itself keeps at column 0.

oxfmt comes from the optional ``js`` dependency group
(``uv sync --group js``); ``just fmt-js`` and ``just fmt-check`` run this
script with it.
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any

REPO = Path(__file__).resolve().parent.parent
COMPONENTS = REPO / "claude_code_log" / "html" / "templates" / "components"
CONFIG = REPO / ".oxfmtrc.json"

JINJA = re.compile(r"\{%.*?%\}|\{#.*?#\}|\{\{.*?\}\}", re.DOTALL)
PLACEHOLDER = re.compile(r"^[ \t]*/\* jinja:(\d+) \*/[ \t]*$", re.MULTILINE)


class MaskError(ValueError):
pass


def mask(text: str) -> tuple[str, list[str]]:
"""Replace each Jinja construct's lines by a placeholder comment line.

Returns the masked text and the replaced lines (without their final
newline), indexed by placeholder number.
"""
blocks: list[str] = []
parts: list[str] = []
pos = 0
for match in JINJA.finditer(text):
start = text.rfind("\n", 0, match.start()) + 1
end = text.find("\n", match.end())
end = len(text) if end < 0 else end
before, after = text[start : match.start()], text[match.end() : end]
if before.strip() or after.strip() or start < pos:
raise MaskError(
f"Jinja must sit on lines of its own: {match.group()[:60]!r}"
)
parts += [text[pos:start], f"/* jinja:{len(blocks)} */"]
blocks.append(text[start:end])
pos = end
parts.append(text[pos:])
return "".join(parts), blocks


def unmask(text: str, blocks: list[str]) -> str:
"""Put back the lines :func:`mask` replaced, whatever their indent now."""
seen: list[int] = []

def restore(match: re.Match[str]) -> str:
seen.append(int(match.group(1)))
return blocks[seen[-1]]

text = PLACEHOLDER.sub(restore, text)
if sorted(seen) != list(range(len(blocks))):
raise MaskError(f"placeholders lost or duplicated: {seen}")
return text


def read_config() -> dict[str, Any]:
"""Parse ``.oxfmtrc.json``, whose comments sit on lines of their own."""
text = CONFIG.read_text(encoding="utf-8")
return json.loads(re.sub(r"^\s*//.*$", "", text, flags=re.MULTILINE))


def jinja_components() -> list[str]:
"""The component JS/CSS files that hold Jinja, as repo-relative paths."""
found: list[str] = []
for path in sorted([*COMPONENTS.rglob("*.js"), *COMPONENTS.rglob("*.css")]):
if JINJA.search(path.read_text(encoding="utf-8")):
found.append(path.relative_to(REPO).as_posix())
return found


def _read(path: Path) -> str:
return path.read_bytes().decode("utf-8")


def _write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(text.encode("utf-8"))


def main(argv: list[str]) -> int:
check = "--check" in argv
oxfmt = shutil.which("oxfmt")
if oxfmt is None:
print(
"oxfmt not found: install the optional `js` group with"
" `uv sync --group js`, or run `just fmt-js`.",
file=sys.stderr,
)
return 2

config = read_config()
files = jinja_components()
unlisted = [f for f in files if f not in config.get("ignorePatterns", [])]
if unlisted:
print(
"fmt-templates: add these to ignorePatterns in .oxfmtrc.json"
" (they hold Jinja, which plain oxfmt cannot parse):",
*unlisted,
sep="\n ",
file=sys.stderr,
)
return 1

# The staged copy is formatted with the repo's options, but not its
# ignorePatterns, which name exactly these files.
del config["ignorePatterns"]
with tempfile.TemporaryDirectory(prefix="fmt-templates-") as tmp:
stage = Path(tmp)
_write(stage / "oxfmtrc.json", json.dumps(config))
blocks: dict[str, list[str]] = {}
for f in files:
masked, blocks[f] = mask(_read(REPO / f))
_write(stage / f, masked)
result = subprocess.run(
[oxfmt, "-c", "oxfmtrc.json", "--write", *files],
cwd=stage,
capture_output=True,
text=True,
)
if result.returncode:
print(result.stdout + result.stderr, file=sys.stderr)
return result.returncode

changed: list[str] = []
for f in files:
formatted = unmask(_read(stage / f), blocks[f])
if formatted != _read(REPO / f):
changed.append(f)
if not check:
_write(REPO / f, formatted)

verb = "would reformat" if check else "reformatted"
for f in changed:
print(f"fmt-templates: {verb} {f}")
print(f"fmt-templates: {len(files)} files with Jinja, {len(changed)} {verb}")
return 1 if check and changed else 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
88 changes: 88 additions & 0 deletions test/test_fmt_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Tests for scripts/fmt_templates.py, which formats the template components
holding Jinja by masking it (`just fmt-js`, `just fmt-check`). Only the
masking is tested here: oxfmt is in the optional `js` group."""

import importlib.util
from pathlib import Path
from typing import Any

import pytest

_SCRIPT = Path(__file__).parent.parent / "scripts" / "fmt_templates.py"


def _load_script() -> Any:
spec = importlib.util.spec_from_file_location("fmt_templates", _SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


fmt_templates = _load_script()

_SOURCE = """{# A header comment
on two lines. -#}
(function () {
'use strict';
{% include 'components/minimal/scheme.js' %}
run();
})();
"""


def test_mask_replaces_each_construct_by_one_placeholder_line():
masked, blocks = fmt_templates.mask(_SOURCE)
assert masked.splitlines()[0] == "/* jinja:0 */"
assert masked.splitlines()[3] == "/* jinja:1 */"
assert "{" + "%" not in masked and "{" + "#" not in masked
assert blocks == [
"{# A header comment\n on two lines. -#}",
"{% include 'components/minimal/scheme.js' %}",
]


def test_unmask_restores_verbatim_whatever_the_new_indent():
masked, blocks = fmt_templates.mask(_SOURCE)
# What a formatter does to the masked copy: dedent, re-indent the
# placeholder inside the function.
formatted = masked.replace(" ", "").replace(
"/* jinja:1 */", " /* jinja:1 */"
)
restored = fmt_templates.unmask(formatted, blocks)
assert "\n{% include 'components/minimal/scheme.js' %}\n" in restored
assert restored.startswith(blocks[0] + "\n")
assert fmt_templates.unmask(masked, blocks) == _SOURCE


@pytest.mark.parametrize(
"source",
[
"const theme = '{{ theme }}';\n",
"{% if a %}{% include 'x.js' %}\n",
"{% if a %}\n.x { color: red; } {# trailing #}\n",
],
)
def test_mask_refuses_jinja_sharing_a_line(source: str):
with pytest.raises(fmt_templates.MaskError):
fmt_templates.mask(source)


def test_unmask_refuses_a_lost_or_duplicated_placeholder():
masked, blocks = fmt_templates.mask(_SOURCE)
with pytest.raises(fmt_templates.MaskError):
fmt_templates.unmask(masked.replace("/* jinja:1 */", ""), blocks)
with pytest.raises(fmt_templates.MaskError):
fmt_templates.unmask(masked + "/* jinja:0 */\n", blocks)


def test_every_jinja_component_masks_and_is_ignored_by_plain_oxfmt():
files = fmt_templates.jinja_components()
assert files, "no component holds Jinja: is the scan looking in the wrong place?"
ignored = fmt_templates.read_config()["ignorePatterns"]
for f in files:
source = (fmt_templates.REPO / f).read_text(encoding="utf-8")
masked, blocks = fmt_templates.mask(source)
assert blocks, f
assert fmt_templates.unmask(masked, blocks) == source, f
assert f in ignored, f"{f} holds Jinja but plain oxfmt would parse it"
Loading
Loading