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/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Every change requires a review from the repository owner.
* @leonardosalasd
66 changes: 66 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion doc_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""doc-engine-cli: Modern Markdown to Typst compiler."""
__version__ = "1.1.0"
__version__ = "1.1.1"
19 changes: 18 additions & 1 deletion doc_engine/converter.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)'
Expand Down Expand Up @@ -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)


Expand Down
6 changes: 5 additions & 1 deletion doc_engine/frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "---"
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand All @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
from doc_engine.converter import (
TypstRenderer,
convert,
convert_document,
extract_title,
strip_first_heading,
)


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")
Expand All @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions tests/test_frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading