diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..237a554 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change requires a review from the repository owner. +* @leonardosalasd diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d5886b0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: ${{ matrix.os }} · Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + include: + - os: ubuntu-latest + python-version: "3.14" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev extras + run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + + - name: Run the test suite + run: python -m pytest tests/ -v + + smoke: + name: Installed CLI · ${{ matrix.os }} · Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install the package as a user would + run: python -m pip install --upgrade pip && python -m pip install . + + - name: Run the CLI from a clean directory + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/smoke" && cd "$RUNNER_TEMP/smoke" + doc-engine --version + doc-engine info + printf -- '---\ntitle: Smoke\naccent: teal\n---\n\n# Smoke\n\nText with a footnote[^1], a bracket ], and a task.\n\n- [x] done\n\n[^1]: note\n' > README.md + doc-engine build + test -f README_doc.pdf diff --git a/README.md b/README.md index d517147..b5eb67e 100644 --- a/README.md +++ b/README.md @@ -497,7 +497,9 @@ docker run --rm -v "$PWD:/workspace" ghcr.io/leonardosalasd/doc-engine-cli build - [x] YAML front-matter support for metadata override - [x] Local image embedding - [x] Watch mode for continuous rebuilds +- [ ] Math expressions (LaTeX-style `$...$`) - [ ] Multi-file documentation merge +- [ ] Mermaid diagram rendering - [ ] Image downloading and embedding for remote URLs - [ ] PDF/A compliance for archival diff --git a/doc_engine/__init__.py b/doc_engine/__init__.py index dbc1555..840d2b0 100644 --- a/doc_engine/__init__.py +++ b/doc_engine/__init__.py @@ -1,2 +1,2 @@ """doc-engine-cli: Modern Markdown to Typst compiler.""" -__version__ = "1.1.0" +__version__ = "1.1.1" diff --git a/doc_engine/converter.py b/doc_engine/converter.py index 26860d2..c71c613 100644 --- a/doc_engine/converter.py +++ b/doc_engine/converter.py @@ -1,3 +1,15 @@ +"""Markdown to Typst transpiler built on a mistune renderer. + +`from __future__ import annotations` is required, not cosmetic: mistune resolves +tokens to methods by name, so this renderer must define one called `list`, which +shadows the builtin inside the class body. Any annotation written there — such as +`tokens: list[dict]` — would otherwise be evaluated against that method and raise +`TypeError: 'function' object is not subscriptable` on import. Python 3.14 defers +annotations by default and hides the problem; every earlier version does not. +""" + +from __future__ import annotations + import re from dataclasses import dataclass, field from pathlib import Path @@ -16,12 +28,17 @@ "~": "\\~", "<": "\\<", ">": "\\>", + "[": "\\[", + "]": "\\]", } _PLUGINS = ["table", "strikethrough", "task_lists", "footnotes"] _REMOTE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:)?//", re.IGNORECASE) _UNSAFE = re.compile(r"[^A-Za-z0-9._-]") +# Pandoc-style [@key] survives escaping as \[\@key\]; restore it as a Typst @key. +_CITATION = re.compile(r"\\\[\\@([a-zA-Z0-9_\-]+)\\\]") + _UNCHECKED = ( '#box(width: 0.85em, height: 0.85em, radius: 2pt, ' 'stroke: 1pt + rgb("#94a3b8"), baseline: 0.15em)' @@ -255,7 +272,7 @@ def convert_document(markdown: str, base_dir: Path | None = None) -> Conversion: tokens, state = md.parse(markdown) renderer.load_footnotes(tokens, state) body = renderer.render_tokens(tokens, state) - body = re.sub(r"\[\\@([a-zA-Z0-9_\-]+)\]", r"@\1", body) + body = _CITATION.sub(r"@\1", body) return Conversion(body=body, assets=renderer.assets) diff --git a/doc_engine/frontmatter.py b/doc_engine/frontmatter.py index 93cc6c6..88a1db6 100644 --- a/doc_engine/frontmatter.py +++ b/doc_engine/frontmatter.py @@ -11,7 +11,9 @@ --- Only flat `key: value` pairs are supported — enough for document metadata, -without pulling in a YAML dependency. +without pulling in a YAML dependency. A leading block that carries no pairs is +left alone, so a document that simply opens with a `---` horizontal rule keeps +its content. """ _FENCE = "---" @@ -27,6 +29,8 @@ def parse(text: str) -> tuple[dict[str, str], str]: for index in range(1, len(lines)): line = lines[index] if line.strip() == _FENCE: + if not meta: + break body = "\n".join(lines[index + 1 :]) return meta, body.lstrip("\n") key, value = _split(line) diff --git a/pyproject.toml b/pyproject.toml index 4304a9e..052a789 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "doc-engine-cli" -version = "1.1.0" +version = "1.1.1" authors = [ { name = "Leonardo Salas", email = "leonardo.salas01@outlook.com" }, ] @@ -18,6 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Documentation", diff --git a/tests/test_converter.py b/tests/test_converter.py index c52687c..91cf2b7 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,4 +1,5 @@ from doc_engine.converter import ( + TypstRenderer, convert, convert_document, extract_title, @@ -6,6 +7,17 @@ ) +class TestPythonCompatibility: + """Guards the regression that made 1.1.0 unimportable below Python 3.14.""" + + def test_class_body_annotations_are_not_evaluated(self) -> None: + annotations = TypstRenderer.load_footnotes.__annotations__ + assert annotations["tokens"] == "list[dict]" + + def test_list_is_shadowed_inside_the_class(self) -> None: + assert callable(TypstRenderer.list) + + class TestEscaping: def test_hash_is_escaped(self) -> None: assert "\\#" in convert("Use C# for development") @@ -17,6 +29,22 @@ def test_at_is_escaped(self) -> None: assert "\\@" in convert("Email user@example.com") +class TestBrackets: + def test_square_brackets_are_escaped(self) -> None: + assert "\\[42\\]" in convert("See item [42] here.") + + def test_lone_closing_bracket_is_escaped(self) -> None: + assert "\\]" in convert("Close it with ] here.") + + +class TestCitations: + def test_pandoc_citation_becomes_typst_reference(self) -> None: + assert "@smith2020" in convert("As shown in [@smith2020].") + + def test_citation_loses_its_brackets(self) -> None: + assert "\\[" not in convert("As shown in [@smith2020].") + + class TestExtractTitle: def test_simple_title(self) -> None: assert extract_title("# My Project\n\nDescription") == "My Project" diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py index 1d3f12b..89916fb 100644 --- a/tests/test_frontmatter.py +++ b/tests/test_frontmatter.py @@ -21,6 +21,22 @@ def test_keys_are_lowercased(self) -> None: meta, _ = frontmatter.parse("---\nTitle: X\n---\ny") assert meta["title"] == "X" + def test_leading_horizontal_rule_keeps_content(self) -> None: + text = "---\n\nIntro that matters.\n\n---\n\nRest.\n" + meta, body = frontmatter.parse(text) + assert meta == {} + assert "Intro that matters." in body + + def test_block_without_pairs_is_not_front_matter(self) -> None: + meta, body = frontmatter.parse("---\n---\n# Title\n") + assert meta == {} + assert "# Title" in body + + def test_crlf_line_endings_are_handled(self) -> None: + meta, body = frontmatter.parse("---\r\ntitle: Win\r\n---\r\n\r\nBody\r\n") + assert meta == {"title": "Win"} + assert "Body" in body + def test_unterminated_block_is_ignored(self) -> None: text = "---\ntitle: X\n\nno closing fence\n" meta, body = frontmatter.parse(text)