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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
run: "uv run mypy --show-error-codes ."
- name: "Pylint Tests"
run: "uv run pylint --ignore .venv ."
- name: "Unit Tests"
run: "uv run pytest"

yaml-lint:
name: Run yaml lint
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ dev = [
"pylint>=4.0.4",
"vale>=3.13.0.0",
"types-pyyaml>=6.0.12.20250915",
# Covers the docs-generation helpers in tasks/, whose escaping rules are
# subtle enough to regress without being noticed until a docs build fails.
"pytest>=8.3.0",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
# `tasks` is a top-level package in the repo root rather than an installed
# distribution, so the root has to be on the import path for the tests.
pythonpath = ["."]

[tool.pylint.messages_control]
# Line length is enforced by Black, so pylint doesn't need to check it.
# Pylint and Black disagree about how to format multi-line arrays; Black wins.
Expand Down
71 changes: 69 additions & 2 deletions tasks/docs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any

import jinja2
import yaml # type: ignore
Expand All @@ -12,13 +14,70 @@
TEMPLATE_DIRECTORY = DOCUMENTATION_DIRECTORY / "_templates"
REFERENCE_DIRECTORY = DOCUMENTATION_DIRECTORY / "docs" / "reference"

# Matches an inline code span: a run of backticks, the shortest content that
# reaches a matching run, and the closing run. DOTALL so a span may wrap lines.
INLINE_CODE_PATTERN = re.compile(r"(?P<fence>`+).*?(?P=fence)", re.DOTALL)


def _sanitize_description(desc):
if not isinstance(desc, str):
return desc
return desc.replace("\n", " ").replace(" ", " ").strip()


def _escape_mdx_braces(text: str) -> str:
"""Escape MDX expression braces that sit outside inline code spans.

Docusaurus parses the reference pages as MDX, where a bare ``{...}`` is a
JSX expression rather than literal text. A schema description carrying
braces -- NetBox's ``Ethernet{module}/1`` port-name token, say -- therefore
breaks the docs build with "Objects are not valid as a React child", because
``module`` resolves to the MDX module object.

Braces inside an inline code span are already inert, and a backslash there
would render literally rather than escaping, so those spans are left alone.

Args:
text: Text destined for MDX prose or a Markdown table cell.

Returns:
The text with braces outside inline code spans backslash-escaped.
"""
if not isinstance(text, str) or ("{" not in text and "}" not in text):
return text

def escape(segment: str) -> str:
return segment.replace("{", "\\{").replace("}", "\\}")

parts: list[str] = []
position = 0
for match in INLINE_CODE_PATTERN.finditer(text):
parts.append(escape(text[position : match.start()]))
parts.append(match.group(0))
position = match.end()
parts.append(escape(text[position:]))

return "".join(parts)


def _escape_mdx_structure(value: Any) -> Any:
"""Recursively apply :func:`_escape_mdx_braces` to every string in a value.

Args:
value: Any nested combination of dicts, lists and scalars.

Returns:
The same shape, with every string escaped for MDX.
"""
if isinstance(value, str):
return _escape_mdx_braces(value)
if isinstance(value, list):
return [_escape_mdx_structure(item) for item in value]
if isinstance(value, dict):
return {key: _escape_mdx_structure(item) for key, item in value.items()}
return value


def _generate_toc_content(metadata) -> defaultdict[str, list]:
"""Based on the metadata, generate the Table of Content for the reference page."""

Expand All @@ -30,9 +89,11 @@ def _generate_toc_content(metadata) -> defaultdict[str, list]:
for key in metadata:
# TODO: Handle the case where we can't split the key
current_item = {
"name": metadata[key].get("name"),
"name": _escape_mdx_braces(metadata[key].get("name")),
"link": "./reference/" + key.split("/")[1] + ".mdx",
"description": _sanitize_description(metadata[key].get("description", "")),
"description": _escape_mdx_braces(
_sanitize_description(metadata[key].get("description", ""))
),
}

result[key.split("/")[0]].append(current_item)
Expand Down Expand Up @@ -92,6 +153,12 @@ def _generate_schema_reference_content(schema_key: str, schema_metadata: dict) -
for section in ["nodes", "generics", "extensions"]:
schema_data[section] = schema_definition.get(section, [])

# Everything above is rendered as MDX prose or into a Markdown table,
# so braces in it have to be escaped. The code section below is not:
# it lands inside a fenced block, where MDX leaves braces alone and an
# escape would show up verbatim in the rendered YAML.
schema_data = _escape_mdx_structure(schema_data)

# Add the code section
schema_data["code"] = yaml.dump(schema_definition, sort_keys=False)

Expand Down
80 changes: 80 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Tests for the documentation generation helpers in ``tasks.docs``."""

import pytest

from tasks.docs import _escape_mdx_braces, _escape_mdx_structure


@pytest.mark.parametrize(
("text", "expected"),
[
pytest.param("no braces here", "no braces here", id="untouched"),
pytest.param(
"e.g. 'Ethernet{module}/1'.",
"e.g. 'Ethernet\\{module\\}/1'.",
id="bare-braces-escaped",
),
pytest.param(
"e.g. `Ethernet{module}/1`.",
"e.g. `Ethernet{module}/1`.",
id="inline-code-left-alone",
),
pytest.param(
"{a} then `{b}` then {c}",
"\\{a\\} then `{b}` then \\{c\\}",
id="mixed-inside-and-outside",
),
pytest.param(
"``a ` b {c}``",
"``a ` b {c}``",
id="double-backtick-span-with-embedded-backtick",
),
pytest.param(
"{{ device__name__value }}",
"\\{\\{ device__name__value \\}\\}",
id="jinja-style-double-braces",
),
pytest.param(
"unterminated `code {x}",
"unterminated `code \\{x\\}",
id="unclosed-span-still-escaped",
),
],
)
def test_escape_mdx_braces(text: str, expected: str) -> None:
assert _escape_mdx_braces(text) == expected


@pytest.mark.parametrize("value", [None, 42, True])
def test_escape_mdx_braces_passes_through_non_strings(value: object) -> None:
assert _escape_mdx_braces(value) is value # type: ignore[arg-type]


def test_escape_mdx_structure_walks_nested_containers() -> None:
payload = {
"description": "a {token}",
"nodes": [
{"name": "Node", "attributes": [{"description": "b {token}"}]},
{"name": "Safe", "attributes": [{"description": "c `{token}`"}]},
],
"optional": True,
"order_weight": 1000,
}

result = _escape_mdx_structure(payload)

assert result["description"] == "a \\{token\\}"
assert result["nodes"][0]["attributes"][0]["description"] == "b \\{token\\}"
# Inline code spans survive the walk unchanged.
assert result["nodes"][1]["attributes"][0]["description"] == "c `{token}`"
# Non-string scalars are preserved, not stringified.
assert result["optional"] is True
assert result["order_weight"] == 1000


def test_escape_mdx_structure_does_not_mutate_input() -> None:
payload = {"description": "a {token}"}

_escape_mdx_structure(payload)

assert payload["description"] == "a {token}"
4 changes: 3 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading