diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..9da89736 --- /dev/null +++ b/.oxfmtrc.json @@ -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" + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c36a039..51557172 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 — diff --git a/justfile b/justfile index 6f9bcbf0..e21d760e 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 3be25b61..caa215e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/scripts/fmt_templates.py b/scripts/fmt_templates.py new file mode 100644 index 00000000..d8a462f4 --- /dev/null +++ b/scripts/fmt_templates.py @@ -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:])) diff --git a/test/test_fmt_templates.py b/test/test_fmt_templates.py new file mode 100644 index 00000000..d1ae6fae --- /dev/null +++ b/test/test_fmt_templates.py @@ -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" diff --git a/uv.lock b/uv.lock index d155eb6c..0fab3a99 100644 --- a/uv.lock +++ b/uv.lock @@ -185,6 +185,7 @@ docs = [ { name = "mkdocs-material" }, ] js = [ + { name = "oxfmt" }, { name = "oxlint" }, ] @@ -227,7 +228,10 @@ docs = [ { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, { name = "mkdocs-material", specifier = ">=9.5.0" }, ] -js = [{ name = "oxlint", specifier = "==1.84.0" }] +js = [ + { name = "oxfmt", specifier = "==0.69.0" }, + { name = "oxlint", specifier = "==1.84.0" }, +] [[package]] name = "claude-code-log-clmail-test" @@ -793,6 +797,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "oxfmt" +version = "0.69.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/14/11dbcb57f22391905fd18007d8113fa9be36540f6be3a53325c28eb670ea/oxfmt-0.69.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aa4e67c29a42908e9971fb2b0a6295dd8567d194f81738d7dfa95149591dba91", size = 2682208, upload-time = "2026-09-22T10:51:27.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a7/dafa3523f74ff1538aec64b96180bdf865254874812f17bf756f7bf76991/oxfmt-0.69.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:27b91eac1b4c3db94c0aca38bd405479bba104fc2132f3634e973f58124302bd", size = 2404020, upload-time = "2026-09-22T10:51:29.11Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/817b20aba5172cdf4f597f6fa686aa89ebcc7f42a565410b6fe8d077c60d/oxfmt-0.69.0-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:338c3c27eee86acaf12c5d8846d795e32be29a8c94d7774d6af4f204f3755549", size = 2589393, upload-time = "2026-09-22T10:51:30.642Z" }, + { url = "https://files.pythonhosted.org/packages/ca/09/f5f403fdd1da7d4103adf5176317f2c8e6dc6e583761acf55f829a616b7b/oxfmt-0.69.0-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:06f0f6a1acf7c7ea083f1ef2a576e66e90cadab9fc5b86f749f43e1aa6147264", size = 2789527, upload-time = "2026-09-22T10:51:31.982Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/52c2758989bc57ec1c250e7fb3a7f2fcfe0b12d78a760138525aa98626e6/oxfmt-0.69.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0a013467b62240418c87253c3581c6ba0a6ce05d11f04208cba5261c7295e07e", size = 2625406, upload-time = "2026-09-22T10:51:33.772Z" }, + { url = "https://files.pythonhosted.org/packages/0e/27/d5e2847c17ebb81fb253d89d124e5a77ad87ff0b6f7c61ea9a85a5663967/oxfmt-0.69.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07a6f8f3a6807e75e0648a86f2be269fa7a2ad4db4a0ba2c7bc9f5a5a55d656c", size = 2847104, upload-time = "2026-09-22T10:51:35.291Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/5259971861dbaae430dd094a96f442cf1a4c911826587fa1a14eeb192d63/oxfmt-0.69.0-py3-none-win_amd64.whl", hash = "sha256:c05119fb81e25d37a81f0a784f39c19fd3e4a857555495189c6f8c6427e751c5", size = 2568650, upload-time = "2026-09-22T10:51:36.707Z" }, +] + [[package]] name = "oxlint" version = "1.84.0"