diff --git a/.gitignore b/.gitignore index 63e4924..aa6520a 100644 --- a/.gitignore +++ b/.gitignore @@ -407,4 +407,7 @@ marimo/_static/ marimo/_lsp/ __marimo__/ -refs.bib \ No newline at end of file +refs.bib + +# A parked VS Code extension, kept locally. See vscode/NOT-PUBLISHED.md +vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bcfc80..3bf2cd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,8 @@ locally is faster. If you have [uv](https://github.com/astral-sh/uv): ```bash uv run --python 3.10 --no-project --with mistune --with click --with rich \ - --with typst --with pytest python -m pytest tests/ -q + --with typst --with mermaidx --with pillow --with tomli --with pytest \ + python -m pytest tests/ -q ``` Swap `3.10` for any version in the supported range. @@ -100,7 +101,7 @@ feat: add support for A4 page size fix: prevent crash on legacy Windows consoles docs: document the --watch flag ci: test on Python 3.14 -chore: bump version to 1.1.1 +chore: bump version to 2.0.0 ``` Write the subject in the imperative mood, under ~72 characters. If the change @@ -118,9 +119,9 @@ anything a future reader would need in order to not undo it by accident. Every pull request is reviewed by the repository owner ([CODEOWNERS](.github/CODEOWNERS)), and CI must be green before merging. CI runs -the test suite across every supported Python version and operating system, plus -a smoke job that installs the built package and drives the CLI from a clean -directory. +the test suite across every supported Python version and operating system, a +smoke job that installs the built package and drives the CLI from a clean +directory, a lint and format check, and a build of the Docker image. In the description, explain what changed and why. If it fixes an open issue, reference it with `Fixes #123`. @@ -143,6 +144,8 @@ passes it the document metadata: accent: none, branding: true, version: "", + paper: "a4", + ..options, body, ) = { ... } ``` @@ -152,8 +155,14 @@ should: - Honor `accent` when it is not `none`, and fall back to its own default color. - Honor `branding: false` by hiding the `doc-engine` attribution. +- Pass `paper` through to `set page`, rather than hardcoding a size. - Render `subtitle` only when it is not empty. - Render the bibliography when `bibliography_file` is not `none`. +- Keep the `..options` sink, so a later release can add an option without + breaking templates that have not been updated. +- End every font stack in one Typst ships — `New Computer Modern`, + `Libertinus Serif`, or `DejaVu Sans Mono` — so the document looks the same + inside a bare container as it does on a laptop full of fonts. Templates are discovered automatically, so dropping the file in is enough for `--template ` to find it. Add it to the templates table in the README and @@ -166,9 +175,17 @@ to the test in `tests/test_cli.py` that asserts the shipped set. | Module | Responsibility | |---|---| | `cli.py` | Argument parsing, Git detection, watch loop, output naming | +| `help.py` | The Rich help screens | +| `settings.py` | Deciding between a flag, front matter, and the project file | +| `config.py` | Reading `.doc-engine.toml` | | `frontmatter.py` | Leading `---` metadata block | +| `manifest.py` | Building one document out of several files | | `linter.py` | Source checks reported with line and column | | `converter.py` | Markdown AST → Typst markup | +| `latex.py` | LaTeX math → Typst math | +| `diagrams.py` | Mermaid and SVG blocks | +| `images.py` | Cutting pictures taller than a page | +| `remote.py` | Downloading linked images | | `compiler.py` | Template injection and PDF compilation | Changes usually belong in exactly one of these. If a change needs to touch all diff --git a/README.md b/README.md index 562ffd8..1de47db 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/doc-engine-cli?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/doc-engine-cli) [![Typst](https://img.shields.io/badge/Powered_by-Typst-239DAD.svg?logo=typst&logoColor=white)](https://typst.app/) [![Tests](https://github.com/leonardosalasd/doc-engine-cli/actions/workflows/tests.yml/badge.svg)](https://github.com/leonardosalasd/doc-engine-cli/actions/workflows/tests.yml) +[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-EA4AAA.svg?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/leonardosalasd) Transform any `README.md` into a premium, print-ready PDF report — no configuration, no templates, no LaTeX. @@ -21,34 +22,18 @@ Transform any `README.md` into a premium, print-ready PDF report — no configur pipx install doc-engine-cli ``` +
+ + + Sponsor doc-engine-cli on GitHub Sponsors + + +Built and maintained by one person, in the open. Sponsorship keeps it that way. + --- -> [!NOTE] -> ### v2.0.0 is finished and looking for testers -> -> All of v2.0.0 is written and merged here — diagrams, math, multi-file -> documents, seven layouts, page sizes, and the rest. It is **not on PyPI yet**, -> because I would rather find the rough edges before publishing than after. -> `pipx install doc-engine-cli` still gives you the stable **1.1.1**. -> -> **Try it and help me break it:** -> -> ```bash -> git clone https://github.com/leonardosalasd/doc-engine-cli.git -> cd doc-engine-cli -> pip install -e . -> doc-engine build -> ``` -> -> Everything in this README describes v2.0.0 and works on that checkout. If -> something misbehaves, an [issue](https://github.com/leonardosalasd/doc-engine-cli/issues/new) -> or a note in [Discussions](https://github.com/leonardosalasd/doc-engine-cli/discussions) -> is genuinely useful — that feedback is what decides when this ships. -> -> The release follows once it has been through real documents on real machines. - ## Overview **doc-engine-cli** is a developer-first CLI tool that converts Markdown files into professionally styled PDF documents using [Typst](https://typst.app/) as its rendering backend. It is designed for teams and individual developers who need high-quality documentation artifacts without the complexity of LaTeX or manual typesetting. @@ -72,6 +57,7 @@ That's it. Zero configuration required. | **Front Matter** | An optional `---` metadata block sets the title, subtitle, author, template, and accent right inside the file. | | **Watch Mode** | `--watch` rebuilds the PDF every time you save the source. | | **Diagrams** | ` ```mermaid ` and ` ```svg ` blocks are rendered as real diagrams, in pure Python — no Node, no headless browser. | +| **Alerts** | `> [!NOTE]` and friends render as coloured callouts, the way GitHub shows them. | | **Math** | LaTeX math, inline with `$…$` and display with `$$…$$`, translated into native Typst math. | | **Multi-File** | A `doc-engine.md` manifest builds one PDF from many files, diagrams, figures, and a bibliography. | | **Page Sizes** | A4 by default, plus A3–A6, ISO/JIS B5, and US letter, legal, and tabloid. | @@ -145,7 +131,8 @@ doc-engine build path/to/file.md -o output.pdf -t "Custom Title" -a "Author Name ``` doc-engine build [INPUT_FILE] Convert a Markdown file into a PDF -doc-engine info Show version, repository, and templates +doc-engine info Show version, repository, and what is supported +doc-engine info --json The same, as JSON, for tools that drive the CLI doc-engine --version Print the version and exit doc-engine --help Show all commands and flags ``` @@ -165,6 +152,7 @@ doc-engine --help Show all commands and flags | `--paper` | `a4` | Page size: `a3`–`a6`, `iso-b5`, `jis-b5`, `us-letter`, `us-legal`, `us-tabloid`. | | `--bib` | auto-detect `refs.bib` | Path to a custom `.bib` file for the bibliography. | | `--pdf-standard` | off | Write an archival PDF/A file: `a-2b` or `a-3b`. | +| `--code-theme` | Typst default | Syntax highlighting theme: `github`, `solarized`, `monochrome`, or a path to a `.tmTheme`. | | `--tall-images` | `fit` | What to do with a picture taller than a page: `fit` scales it onto one page, `split` cuts it across several. | | `--fetch-images` | off | Download images linked by URL instead of rendering their alt text. | | `--no-branding` | off | Hide the `doc-engine` attribution from the PDF. | @@ -258,9 +246,9 @@ Pass `--watch` to keep `doc-engine` running and rebuild the PDF whenever you sav the source. It's the fastest way to tweak a template or accent and see the result:
-doc-engine watch mode rebuilding on save +doc-engine watch mode rebuilding on save
-Metadata read from front matter, rebuilt live on every save. +Every file the manifest names is watched, and a save rebuilds the whole document.
```bash @@ -313,6 +301,65 @@ of the time. A `$` that is not math stays untouched, so prices and shell variables survive: `$10`, `$HOME`, and `export $PATH` all render as written. +--- + +## Alerts + +A blockquote that opens with a marker becomes a coloured callout, matching what +GitHub shows on the page: + +```markdown +> [!NOTE] +> Useful information worth knowing. + +> [!WARNING] +> Something that needs attention. +``` + +`NOTE`, `TIP`, `IMPORTANT`, `WARNING`, and `CAUTION` are all recognized. A +blockquote without a marker stays an ordinary quote. + +--- + +## Code Themes + +Code blocks are highlighted with Typst's own colours by default. `--code-theme` +swaps that for something else: + +```bash +doc-engine build --code-theme github +doc-engine build --code-theme monochrome # for printing in black and white +``` + +| Theme | Look | +|---|---| +| `github` | GitHub's light palette | +| `solarized` | Solarized light | +| `monochrome` | Greys only — keeps code legible on a black-and-white printer | + +Any TextMate `.tmTheme` file works too, so a theme from your editor can be +pointed at directly: + +```bash +doc-engine build --code-theme ~/themes/my-editor.tmTheme +``` + +--- + +## Cross-References + +Inside a manifest build, a link from one included file to another becomes a jump +within the PDF rather than a link to a file the reader does not have: + +```markdown +For the full picture see [the data model](model.md). +``` + +That resolves to the place where `model.md` was merged in. Links to anything +outside the build — a URL, a file that is not part of the manifest — are left +exactly as they are. + + --- ## Multi-File Documents @@ -376,6 +423,24 @@ paper = "us-letter" A `[tool.doc-engine]` table in `pyproject.toml` works the same way. Precedence runs command-line flag, then front matter, then this file. +Every key it understands: + +| Key | Values | +|---|---| +| `template` | A built-in layout name, or a path to a `.typ` file | +| `paper` | `a3`–`a6`, `iso-b5`, `jis-b5`, `us-letter`, `us-legal`, `us-tabloid` | +| `accent` | A hex value or a colour name | +| `author` | Author name, used instead of the Git user | +| `bib` | Path to a `.bib` file | +| `branding` | `false` hides the `doc-engine` attribution | +| `code_theme` | `github`, `solarized`, `monochrome`, or a path to a `.tmTheme` | +| `pdf_standard` | `a-2b` or `a-3b` | +| `tall_images` | `fit` or `split` | +| `fetch_images` | `true` downloads images linked by URL | + +Anything else in the table is ignored, so a typo cannot quietly change how a +document is built. + --- ## Large Images @@ -562,6 +627,7 @@ doc-engine-cli/ │ ├── cli.py # Click-based CLI + Git detection │ ├── help.py # Rich help screens │ ├── config.py # .doc-engine.toml project settings +│ ├── settings.py # flag / front matter / project precedence │ ├── frontmatter.py # Leading --- metadata block │ ├── manifest.py # doc-engine.md multi-file builds │ ├── converter.py # Markdown → Typst transpiler @@ -579,6 +645,10 @@ doc-engine-cli/ │ ├── minimal.typ # Compact, no cover page │ ├── technical.typ # Accent banner + section markers │ └── book.typ # Centered title page, chapter breaks +│ └── themes/ +│ ├── github.tmTheme # Syntax highlighting themes +│ ├── solarized.tmTheme +│ └── monochrome.tmTheme ├── tests/ # 181 tests across every module ├── pyproject.toml # Package configuration + dependencies ├── LICENSE # MIT License @@ -664,6 +734,7 @@ docker run --rm -v "$PWD:/workspace" ghcr.io/leonardosalasd/doc-engine-cli build - [x] Local images, and remote ones with `--fetch-images` - [x] Math blocks (LaTeX `$…$` and `$$…$$`) - [x] Mermaid and SVG diagram blocks +- [x] GitHub alerts (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`) --- @@ -683,8 +754,30 @@ docker run --rm -v "$PWD:/workspace" ghcr.io/leonardosalasd/doc-engine-cli build - [x] PDF/A compliance for archival - [x] Page size selection - [x] Project-level configuration file -- [ ] Syntax highlighting themes for code blocks -- [ ] Cross-references between documents +- [x] Syntax highlighting themes for code blocks +- [x] Cross-references between documents + +--- + +## Sponsor + +`doc-engine-cli` is written and maintained by one person, in the open, and it is +free under the MIT licence for everyone — no paid tier, no telemetry, no +upsell. If it saves you the afternoon you would have spent fighting LaTeX, you +can put something behind it: + +
+ + Sponsor on GitHub + +
+ +Sponsorship pays for the time that goes into the parts nobody sees: testing on +Windows, chasing a rendering bug across five Python versions, answering issues, +and keeping the release quality where it is. + +It is entirely optional. Starring the repository, reporting a bug you hit, or +telling someone the tool exists all help too, and cost nothing. --- diff --git a/SECURITY.md b/SECURITY.md index 273783a..9b98be1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,9 +11,9 @@ pipx upgrade doc-engine-cli | Version | Supported | |---|---| -| 1.1.x | Yes | -| 1.0.x | Please upgrade | -| < 1.0 | No | +| 2.0.x | Yes | +| 1.1.x | Please upgrade | +| < 1.1 | No | --- @@ -76,9 +76,14 @@ This is a command-line tool that reads Markdown and templates from your machine and produces a PDF. Reports are most relevant when they involve: - Reading or writing files outside the intended input and output paths, for - example through a crafted image path, bibliography path, or template path. + example through a crafted image path, bibliography path, template path, + syntax theme path, or an entry in a `doc-engine.md` manifest. - Arbitrary code or command execution triggered by a Markdown file, a `.typ` - template, or front-matter values. + template, front-matter values, a `.doc-engine.toml`, or a Mermaid diagram, + which is rendered by running JavaScript in an embedded engine. +- Anything reachable through `--fetch-images`, which is the only feature that + makes a network request. It is off unless asked for, and limited to http and + https with a timeout and a size ceiling. - Crafted input that causes the compiler to hang indefinitely or exhaust memory. Out of scope: @@ -86,6 +91,8 @@ Out of scope: - Vulnerabilities in dependencies — report those upstream to [`typst`](https://github.com/messense/typst-py), [`mistune`](https://github.com/lepture/mistune), + [`mermaidx`](https://github.com/MohammadRaziei/mermaidx), + [`pillow`](https://github.com/python-pillow/Pillow), [`click`](https://github.com/pallets/click), or [`rich`](https://github.com/Textualize/rich). If a dependency issue affects this project specifically, a report here is still useful. @@ -93,5 +100,6 @@ Out of scope: bugs — please [open an issue](https://github.com/leonardosalasd/doc-engine-cli/issues/new). A `.typ` template is executable input by design: pointing `--template` at a file -means running it. Treat templates from untrusted sources the same way you would -treat any other code you download and run. +means running it. The same goes for a `doc-engine.md` manifest, which decides +which files on your machine are read into the document. Treat both from +untrusted sources the way you would treat any other code you download and run. diff --git a/assets/config.gif b/assets/config.gif index 1d9dc39..f939c3c 100644 Binary files a/assets/config.gif and b/assets/config.gif differ diff --git a/assets/doc-engine2.gif b/assets/doc-engine2.gif deleted file mode 100644 index 1304ae2..0000000 Binary files a/assets/doc-engine2.gif and /dev/null differ diff --git a/assets/features-v1.1.gif b/assets/features-v1.1.gif deleted file mode 100644 index 903752b..0000000 Binary files a/assets/features-v1.1.gif and /dev/null differ diff --git a/assets/features-watch.gif b/assets/features-watch.gif new file mode 100644 index 0000000..ba929eb Binary files /dev/null and b/assets/features-watch.gif differ diff --git a/doc_engine/cli.py b/doc_engine/cli.py index 6cc6bf1..154d871 100644 --- a/doc_engine/cli.py +++ b/doc_engine/cli.py @@ -1,3 +1,5 @@ +import contextlib +import json import os import re import shutil @@ -12,12 +14,13 @@ from rich.panel import Panel from rich.table import Table -from doc_engine import __version__, config, frontmatter, images, manifest, settings +from doc_engine import __version__, config, diagrams, frontmatter, images, manifest, settings from doc_engine.compiler import ( DEFAULT_PAPER, PAPER_SIZES, PDF_STANDARDS, available_templates, + available_themes, compile_pdf, ) from doc_engine.converter import convert_document, extract_title, strip_first_heading @@ -139,6 +142,28 @@ def _unique_path(path: Path) -> Path: index += 1 +_MERMAID_BLOCK = re.compile( + r"^\s{0,3}(?:```|~~~)\s*(?:mermaid|mmd)\b", re.MULTILINE | re.IGNORECASE +) + + +def _has_diagram(collected: manifest.Manifest | None, content: str) -> bool: + """Whether this build will render a Mermaid diagram.""" + if collected is None: + return bool(_MERMAID_BLOCK.search(content)) + for entry in collected.entries: + if entry.kind == "diagram": + return True + if entry.kind == "markdown": + try: + text = entry.path.read_text(encoding="utf-8") + except OSError: + continue + if _MERMAID_BLOCK.search(text): + return True + return False + + def _print_issues(issues: list, filename: str) -> None: for issue in issues: color = "red" if issue.severity == "error" else "yellow" @@ -195,6 +220,12 @@ def cli(ctx: click.Context) -> None: type=click.Choice(PDF_STANDARDS, case_sensitive=False), help="Write an archival PDF/A file.", ) +@click.option( + "--code-theme", + "code_theme", + default=None, + help="Syntax highlighting theme: a bundled name or a path to a .tmTheme file.", +) @click.option( "--tall-images", "tall_images", @@ -236,6 +267,7 @@ def build( bib: str | None, no_branding: bool, pdf_standard: str | None, + code_theme: str | None, tall_images: str | None, fetch_images: bool, dry_run: bool, @@ -298,6 +330,7 @@ def build( "pdf_standard": pdf_standard, "no_branding": no_branding, "fetch_images": fetch_images, + "code_theme": code_theme, "tall_images": tall_images, } @@ -370,6 +403,22 @@ def run() -> bool: else None ) + # Booting the Mermaid engine takes several seconds, and it happens + # inside the conversion step, where a silent spinner looks like a hang. + # Doing it here lets the wait explain itself, and it only ever happens + # once per run — under --watch, later rebuilds skip it entirely. + if not diagrams.is_warm() and _has_diagram(collected, content): + # A failure here is not worth reporting: the real diagrams are about + # to be rendered, and they will report their own errors. + with ( + console.status( + "[bold blue]Starting the diagram renderer…[/bold blue] " + "[dim](first diagram only, a few seconds)[/dim]" + ), + contextlib.suppress(DiagramError), + ): + diagrams.warmup() + with console.status("[bold blue]Converting Markdown → Typst…[/bold blue]"): try: if collected is not None: @@ -411,6 +460,7 @@ def run() -> bool: generated=conversion.generated, paper=chosen.paper, pdf_standard=chosen.pdf_standard, + code_theme=chosen.code_theme, ) except Exception as exc: message = getattr(exc, "message", None) or str(exc) @@ -497,14 +547,40 @@ def _mtime(path: Path) -> float: @cli.command(cls=RichCommand) -def info() -> None: +@click.option( + "--json", + "as_json", + is_flag=True, + help="Print the same information as JSON, for tools that drive this CLI.", +) +def info(as_json: bool) -> None: """Show version, repository, and what this build supports.""" + if as_json: + click.echo( + json.dumps( + { + "version": __version__, + "repository": REPO_URL, + "templates": available_templates(), + "themes": available_themes(), + "papers": list(PAPER_SIZES), + "default_paper": DEFAULT_PAPER, + "pdf_standards": list(PDF_STANDARDS), + "accents": _NAMED_ACCENTS, + "tall_image_modes": ["fit", "split"], + }, + indent=2, + ) + ) + return + facts = Table.grid(padding=(0, 2)) facts.add_column(style="dim", no_wrap=True, vertical="top") facts.add_column() facts.add_row("Repository", f"[cyan]{REPO_URL}[/cyan]") facts.add_row("Templates", ", ".join(available_templates())) facts.add_row("Page sizes", f"{', '.join(PAPER_SIZES)} [dim](default {DEFAULT_PAPER})[/dim]") + facts.add_row("Code themes", ", ".join(available_themes())) facts.add_row("Accents", f"{', '.join(sorted(_NAMED_ACCENTS))} [dim]or any hex[/dim]") body = Group( diff --git a/doc_engine/compiler.py b/doc_engine/compiler.py index 3017b95..6253f70 100644 --- a/doc_engine/compiler.py +++ b/doc_engine/compiler.py @@ -5,9 +5,24 @@ import typst _TEMPLATES_DIR = Path(__file__).parent / "templates" +_THEMES_DIR = Path(__file__).parent / "themes" DEFAULT_TEMPLATE = "academic" DEFAULT_PAPER = "a4" + +def available_themes() -> list[str]: + """Syntax highlighting themes that ship with the package, sorted.""" + return sorted(path.stem for path in _THEMES_DIR.glob("*.tmTheme")) + + +def theme_path(name: str) -> Path: + """Resolve a theme identifier, which is a bundled name or a path.""" + candidate = Path(name) + if candidate.suffix == ".tmTheme": + return candidate + return _THEMES_DIR / f"{name}.tmTheme" + + # Archival profiles typst-py accepts. a-2b is the usual choice for documents # that have to be readable decades from now; a-3b additionally allows embedded # attachments. @@ -59,6 +74,7 @@ def compile_pdf( generated: dict[str, str] | None = None, paper: str = DEFAULT_PAPER, pdf_standard: str | None = None, + code_theme: str | None = None, ) -> None: source = template_path(template) if not source.exists(): @@ -87,6 +103,13 @@ def compile_pdf( destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(content, encoding="utf-8") + theme_inject = "none" + if code_theme: + theme = theme_path(code_theme) + if theme.exists(): + shutil.copy(theme, tmp / theme.name) + theme_inject = f'"{theme.name}"' + accent_inject = f'rgb("{accent}")' if accent else "none" main_file = tmp / "main.typ" @@ -102,6 +125,7 @@ def compile_pdf( subtitle, date, paper, + theme_inject, ), encoding="utf-8", ) @@ -136,10 +160,13 @@ def _build_main( subtitle: str = "", date: str | None = None, paper: str = DEFAULT_PAPER, + theme_inject: str = "none", ) -> str: + theme_line = f"#set raw(theme: {theme_inject})\n" if theme_inject != "none" else "" date_line = f' date: "{_escape(date)}",\n' if date else "" return ( '#import "template.typ": setup_doc\n\n' + f"{theme_line}" f"{_FIT_IMAGE}\n" "#show: setup_doc.with(\n" f' title: "{_escape(title)}",\n' diff --git a/doc_engine/config.py b/doc_engine/config.py index a7a4a4a..173e14f 100644 --- a/doc_engine/config.py +++ b/doc_engine/config.py @@ -41,6 +41,7 @@ "pdf_standard", "fetch_images", "tall_images", + "code_theme", ) diff --git a/doc_engine/converter.py b/doc_engine/converter.py index 3905046..ba35611 100644 --- a/doc_engine/converter.py +++ b/doc_engine/converter.py @@ -49,6 +49,20 @@ # Pandoc-style [@key] survives escaping as \[\@key\]; restore it as a Typst @key. _CITATION = re.compile(r"\\\[\\@([a-zA-Z0-9_\-]+)\\\]") +# GitHub renders a blockquote opening with [!NOTE] and friends as a coloured +# callout. The marker reaches this point already escaped, since it is ordinary +# text as far as the parser is concerned. +_ALERT = re.compile(r"^\\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\\]\s*", re.IGNORECASE) + +# Label and colour for each kind, following what GitHub uses. +_ALERT_STYLES = { + "NOTE": ("Note", "#0969da"), + "TIP": ("Tip", "#1a7f37"), + "IMPORTANT": ("Important", "#8250df"), + "WARNING": ("Warning", "#9a6700"), + "CAUTION": ("Caution", "#cf222e"), +} + _UNCHECKED = ( "#box(width: 0.85em, height: 0.85em, radius: 2pt, " 'stroke: 1pt + rgb("#94a3b8"), baseline: 0.15em)' @@ -110,6 +124,7 @@ def __init__( work_dir: Path | None = None, fetch_remote: bool = False, split_tall: float | None = None, + anchors: dict[Path, str] | None = None, ) -> None: super().__init__() self._ordered_stack: list[bool] = [] @@ -118,6 +133,9 @@ def __init__( self._work_dir = work_dir self._fetch_remote = fetch_remote self._split_tall = split_tall + # Resolved paths of the other documents in this build, mapped to the + # anchor each one carries, so links between them can jump internally. + self._anchors = {Path(k).resolve(): v for k, v in (anchors or {}).items()} self.warnings: list[str] = [] self._footnotes: dict[str, str] = {} self._asset_names: dict[str, str] = {} @@ -141,8 +159,29 @@ def codespan(self, token: dict, state: Any) -> str: def link(self, token: dict, state: Any) -> str: children = _render_children(self, token, state) url = token["attrs"]["url"] + anchor = self._anchor_for(url) + if anchor: + return f"#link(<{anchor}>)[{children}]" return f'#link("{url}")[{children}]' + def _anchor_for(self, url: str) -> str | None: + """An anchor when this link points at another file in the same build. + + Linking to a sibling document is normal in a folder of Markdown, but the + reader of the PDF has no such file, so the link jumps within the document + instead. + """ + if not self._anchors or self._base_dir is None or _REMOTE.match(url): + return None + target = url.split("#", 1)[0] + if not target: + return None + try: + resolved = (self._base_dir / target).resolve() + except OSError: + return None + return self._anchors.get(resolved) + def image(self, token: dict, state: Any) -> str: url = token.get("attrs", {}).get("url", "") alt = _render_children(self, token, state) @@ -244,15 +283,35 @@ def _render_diagram(self, language: str, source: str) -> str: def block_quote(self, token: dict, state: Any) -> str: content = _render_children(self, token, state).strip() + alert = _ALERT.match(content) + if alert: + return self._alert(alert.group(1).upper(), content[alert.end() :].strip()) return ( "#block(\n" - " inset: (left: 1.2em, y: 0.6em),\n" - ' stroke: (left: 2.5pt + rgb("#4a90d9")),\n' + " width: 100%,\n" + " inset: (left: 1em, rest: 0.8em),\n" + ' stroke: (left: 3pt + rgb("#4a90d9")),\n' ' fill: rgb("#f0f4f8"),\n' - " radius: 2pt,\n" + " radius: (right: 3pt),\n" f")[{content}]\n\n" ) + def _alert(self, kind: str, body: str) -> str: + label, color = _ALERT_STYLES[kind] + return ( + "#block(\n" + " width: 100%,\n" + " inset: (left: 1em, rest: 0.8em),\n" + f' stroke: (left: 3pt + rgb("{color}")),\n' + f' fill: rgb("{color}").lighten(92%),\n' + " radius: (right: 3pt),\n" + ")[\n" + f' #text(weight: 700, fill: rgb("{color}"))[{label}]\n' + " #v(0.35em, weak: true)\n" + f" {body}\n" + "]\n\n" + ) + def list(self, token: dict, state: Any) -> str: ordered = token.get("attrs", {}).get("ordered", False) self._ordered_stack.append(ordered) @@ -391,6 +450,7 @@ def convert_document( work_dir: Path | None = None, fetch_remote: bool = False, split_tall: float | None = None, + anchors: dict[Path, str] | None = None, ) -> Conversion: """Convert Markdown to Typst. @@ -406,6 +466,7 @@ def convert_document( work_dir=work_dir, fetch_remote=fetch_remote, split_tall=split_tall, + anchors=anchors, ) md = mistune.create_markdown(renderer=None, plugins=[*_PLUGINS, _math_plugin]) tokens, state = md.parse(markdown) diff --git a/doc_engine/diagrams.py b/doc_engine/diagrams.py index cbc8e72..f833f06 100644 --- a/doc_engine/diagrams.py +++ b/doc_engine/diagrams.py @@ -10,6 +10,11 @@ MERMAID_LANGUAGES = ("mermaid", "mmd") SVG_LANGUAGES = ("svg",) +# Rendering the first Mermaid diagram boots a JavaScript engine and loads +# Mermaid into it, which takes several seconds. Every diagram after that is +# roughly a hundred times faster, so the cost is paid once per process. +_warm = False + class DiagramError(Exception): """A diagram source that could not be rendered.""" @@ -25,6 +30,16 @@ def is_diagram(language: str) -> bool: return lang in MERMAID_LANGUAGES or lang in SVG_LANGUAGES +def is_warm() -> bool: + """Whether the Mermaid engine has already started in this process.""" + return _warm + + +def warmup() -> None: + """Start the Mermaid engine, so the caller can say why it is waiting.""" + _render_mermaid("flowchart LR\n A-->B\n") + + def render(language: str, source: str) -> str: """Return the SVG for a diagram block, raising DiagramError if it is invalid.""" lang = language.strip().lower() @@ -45,12 +60,16 @@ def _render_mermaid(source: str) -> str: "doc-engine-cli — reinstall with 'pip install --upgrade doc-engine-cli'", ) from exc + global _warm try: - return mermaidx.render(source).svg() + svg = mermaidx.render(source).svg() except DiagramError: raise except Exception as exc: + _warm = True raise DiagramError("mermaid", _clean(str(exc))) from exc + _warm = True + return svg def _clean(message: str) -> str: diff --git a/doc_engine/manifest.py b/doc_engine/manifest.py index 70c8425..4a69f61 100644 --- a/doc_engine/manifest.py +++ b/doc_engine/manifest.py @@ -131,8 +131,14 @@ def assemble( generated: dict[str, str] = {} warnings: list[str] = [] + # A link from one included file to another should jump inside the PDF rather + # than point at a file the reader does not have. Each entry gets an anchor, + # and the converter rewrites links that resolve to one of them. + anchors = {entry.path: f"entry-{position}" for position, entry in enumerate(manifest.entries)} + for position, entry in enumerate(manifest.entries): namespace = f"e{position}_" + anchor = anchors[entry.path] if entry.kind == "markdown": # An included file may carry its own front matter, from back when it # was built on its own. The manifest owns the metadata now, so only @@ -145,8 +151,9 @@ def assemble( work_dir=work_dir, fetch_remote=fetch_remote, split_tall=split_tall, + anchors=anchors, ) - body.append(piece.body) + body.append(f"#metadata(none) <{anchor}>\n\n{piece.body}") assets.update(piece.assets) generated.update(piece.generated) warnings.extend(piece.warnings) @@ -155,11 +162,11 @@ def assemble( generated[name] = diagrams.render( diagram_language(entry.path), entry.path.read_text(encoding="utf-8") ) - body.append(_figure(name, entry.label)) + body.append(f"#metadata(none) <{anchor}>\n\n" + _figure(name, entry.label)) elif entry.kind == "image": name = f"assets/{namespace}{entry.path.name}" assets[name] = str(entry.path.resolve()) - body.append(_figure(name, entry.label)) + body.append(f"#metadata(none) <{anchor}>\n\n" + _figure(name, entry.label)) return Conversion(body="\n\n".join(body), assets=assets, generated=generated, warnings=warnings) diff --git a/doc_engine/settings.py b/doc_engine/settings.py index a8e0aa2..e224804 100644 --- a/doc_engine/settings.py +++ b/doc_engine/settings.py @@ -28,12 +28,17 @@ class Resolved: author: str | None bib: str | None pdf_standard: str | None + code_theme: str | None branding: bool fetch_images: bool split_tall_images: bool warnings: tuple[str, ...] = () +def _text(value: object) -> str | None: + return str(value) if value else None + + def resolve( flags: dict[str, object], front_matter: dict[str, str], @@ -85,6 +90,7 @@ def flag(name: str): author=flag("author") or setting("author"), bib=flag("bib") or setting("bib"), pdf_standard=str(standard) if standard else None, + code_theme=_text(flag("code_theme") or setting("code_theme")), branding=not flag("no_branding") and setting("branding") != "false", fetch_images=bool(flag("fetch_images")) or setting("fetch_images") == "true", split_tall_images=( diff --git a/doc_engine/themes/github.tmTheme b/doc_engine/themes/github.tmTheme new file mode 100644 index 0000000..b7ca811 --- /dev/null +++ b/doc_engine/themes/github.tmTheme @@ -0,0 +1,110 @@ + + + + + name + GitHub Light + settings + + + settings + + background + #FFFFFF + foreground + #24292F + + + + name + Comment + scope + comment + settings + + foreground + #6E7781 + fontStyle + italic + + + + name + Keyword + scope + keyword, storage, storage.type, keyword.control + settings + + foreground + #CF222E + + + + name + String + scope + string, string.quoted, constant.character + settings + + foreground + #0A3069 + + + + name + Number + scope + constant.numeric, constant.language + settings + + foreground + #0550AE + + + + name + Function + scope + entity.name.function, support.function, meta.function-call + settings + + foreground + #8250DF + + + + name + Type + scope + entity.name.type, entity.name.class, support.type, support.class + settings + + foreground + #953800 + + + + name + Variable + scope + variable, variable.parameter, meta.definition.variable + settings + + foreground + #24292F + + + + name + Punctuation + scope + punctuation, meta.brace + settings + + foreground + #24292F + + + + + diff --git a/doc_engine/themes/monochrome.tmTheme b/doc_engine/themes/monochrome.tmTheme new file mode 100644 index 0000000..b925e24 --- /dev/null +++ b/doc_engine/themes/monochrome.tmTheme @@ -0,0 +1,110 @@ + + + + + name + Monochrome + settings + + + settings + + background + #FFFFFF + foreground + #1A1A1A + + + + name + Comment + scope + comment + settings + + foreground + #8A8A8A + fontStyle + italic + + + + name + Keyword + scope + keyword, storage, storage.type, keyword.control + settings + + foreground + #1A1A1A + + + + name + String + scope + string, string.quoted, constant.character + settings + + foreground + #4A4A4A + + + + name + Number + scope + constant.numeric, constant.language + settings + + foreground + #4A4A4A + + + + name + Function + scope + entity.name.function, support.function, meta.function-call + settings + + foreground + #1A1A1A + + + + name + Type + scope + entity.name.type, entity.name.class, support.type, support.class + settings + + foreground + #4A4A4A + + + + name + Variable + scope + variable, variable.parameter, meta.definition.variable + settings + + foreground + #1A1A1A + + + + name + Punctuation + scope + punctuation, meta.brace + settings + + foreground + #1A1A1A + + + + + diff --git a/doc_engine/themes/solarized.tmTheme b/doc_engine/themes/solarized.tmTheme new file mode 100644 index 0000000..2769692 --- /dev/null +++ b/doc_engine/themes/solarized.tmTheme @@ -0,0 +1,110 @@ + + + + + name + Solarized Light + settings + + + settings + + background + #FDF6E3 + foreground + #586E75 + + + + name + Comment + scope + comment + settings + + foreground + #93A1A1 + fontStyle + italic + + + + name + Keyword + scope + keyword, storage, storage.type, keyword.control + settings + + foreground + #859900 + + + + name + String + scope + string, string.quoted, constant.character + settings + + foreground + #2AA198 + + + + name + Number + scope + constant.numeric, constant.language + settings + + foreground + #D33682 + + + + name + Function + scope + entity.name.function, support.function, meta.function-call + settings + + foreground + #268BD2 + + + + name + Type + scope + entity.name.type, entity.name.class, support.type, support.class + settings + + foreground + #B58900 + + + + name + Variable + scope + variable, variable.parameter, meta.definition.variable + settings + + foreground + #586E75 + + + + name + Punctuation + scope + punctuation, meta.brace + settings + + foreground + #586E75 + + + + + diff --git a/pyproject.toml b/pyproject.toml index f01cd55..16b80af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ doc-engine = "doc_engine.cli:main" include = ["doc_engine*"] [tool.setuptools.package-data] -doc_engine = ["templates/*.typ"] +doc_engine = ["templates/*.typ", "themes/*.tmTheme"] [tool.ruff] line-length = 100 diff --git a/tests/test_cli.py b/tests/test_cli.py index a84a61f..a20cf01 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,5 @@ +from pathlib import Path + from click.testing import CliRunner from doc_engine import __version__ @@ -156,3 +158,82 @@ def test_info_lists_capabilities(self) -> None: flat = " ".join(result.output.split()) assert "mermaid" in flat assert "math" in flat.lower() + + +class TestCodeThemes: + def test_ships_expected_themes(self) -> None: + from doc_engine.compiler import available_themes + + for expected in ("github", "monochrome", "solarized"): + assert expected in available_themes() + + def test_bundled_name_resolves_to_a_file(self) -> None: + from doc_engine.compiler import available_themes, theme_path + + for name in available_themes(): + assert theme_path(name).is_file() + + def test_a_path_is_taken_as_given(self) -> None: + from doc_engine.compiler import theme_path + + assert theme_path("/tmp/mine.tmTheme") == Path("/tmp/mine.tmTheme") + + def test_theme_is_injected_when_asked(self) -> None: + main = _build_main( + "body", "T", "Me", "none", "none", True, "2.0.0", "", None, "a4", '"github.tmTheme"' + ) + assert 'set raw(theme: "github.tmTheme")' in main + + def test_no_theme_leaves_highlighting_alone(self) -> None: + assert "set raw" not in _build_main("body", "T", "Me", "none", "none", True, "2.0.0") + + +class TestOpening: + """--open hands the file to the platform's viewer.""" + + def test_uses_the_right_command_per_platform(self, monkeypatch) -> None: + from doc_engine import cli as cli_module + + calls: list[list[str]] = [] + monkeypatch.setattr(cli_module.subprocess, "run", lambda cmd, **kw: calls.append(cmd)) + + for platform, expected in (("darwin", "open"), ("linux", "xdg-open")): + calls.clear() + monkeypatch.setattr(cli_module.sys, "platform", platform) + cli_module._open_file("out.pdf") + assert calls == [[expected, "out.pdf"]] + + def test_windows_uses_startfile(self, monkeypatch) -> None: + from doc_engine import cli as cli_module + + opened: list[str] = [] + monkeypatch.setattr(cli_module.sys, "platform", "win32") + monkeypatch.setattr(cli_module.os, "startfile", opened.append, raising=False) + cli_module._open_file("out.pdf") + assert opened == ["out.pdf"] + + +class TestInfoJson: + """Machine-readable output, so a tool driving the CLI need not hardcode lists.""" + + def load(self): + import json + + result = CliRunner().invoke(cli, ["info", "--json"]) + assert result.exit_code == 0 + return json.loads(result.output) + + def test_output_is_valid_json(self) -> None: + assert self.load()["version"] == __version__ + + def test_it_lists_what_the_build_supports(self) -> None: + data = self.load() + assert data["templates"] == available_templates() + assert "github" in data["themes"] + assert data["default_paper"] in data["papers"] + assert "teal" in data["accents"] + assert data["tall_image_modes"] == ["fit", "split"] + + def test_plain_info_is_still_human_readable(self) -> None: + result = CliRunner().invoke(cli, ["info"]) + assert "{" not in result.output.split("Markdown support")[0] diff --git a/tests/test_converter.py b/tests/test_converter.py index 294b0dd..648d92e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -175,3 +175,49 @@ def test_body_cells_follow_the_header(self) -> None: def test_single_column_table(self) -> None: result = convert("| Only |\n|---|\n| one |\n") assert "columns: (1fr)" in result + + +class TestAlerts: + """GitHub-style callouts: > [!NOTE] and friends.""" + + def test_note_becomes_a_labelled_callout(self) -> None: + result = convert("> [!NOTE]\n> Something worth knowing.\n") + assert "Note" in result + assert "#0969da" in result + assert "!NOTE" not in result + + def test_every_kind_is_recognized(self) -> None: + for kind, label in ( + ("NOTE", "Note"), + ("TIP", "Tip"), + ("IMPORTANT", "Important"), + ("WARNING", "Warning"), + ("CAUTION", "Caution"), + ): + result = convert(f"> [!{kind}]\n> Body.\n") + assert f"[{label}]" in result + assert "Body." in result + + def test_lowercase_marker_still_works(self) -> None: + assert "Note" in convert("> [!note]\n> Body.\n") + + def test_each_kind_has_its_own_colour(self) -> None: + colours = { + convert(f"> [!{kind}]\n> Body.\n").split('rgb("')[1][:7] + for kind in ("NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION") + } + assert len(colours) == 5 + + def test_a_plain_quote_is_left_alone(self) -> None: + result = convert("> Just a quote.\n") + assert "Just a quote." in result + assert "Note" not in result + + def test_a_bracket_that_is_not_an_alert_is_left_alone(self) -> None: + result = convert("> [!MADEUP]\n> Body.\n") + assert "MADEUP" in result + + def test_body_keeps_its_formatting(self) -> None: + result = convert("> [!TIP]\n> Use **bold** and `code`.\n") + assert "*bold*" in result + assert "`code`" in result diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 9b57b79..701315f 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -124,3 +124,31 @@ def test_empty_label_produces_a_plain_picture(self, tmp_path) -> None: (tmp_path / "doc-engine.md").write_text("- [](p.svg)\n- [D](d.md)\n") body = manifest.assemble(manifest.load(tmp_path / "doc-engine.md")).body assert "caption" not in body + + +class TestCrossReferences: + """A link between two included files should jump inside the PDF.""" + + def make(self, root): + (root / "doc-engine.md").write_text("- [A](a.md)\n- [B](b.md)\n") + (root / "a.md").write_text("# A\n\nGo to [B](b.md) or [out](https://x.test).\n") + (root / "b.md").write_text("# B\n\nBack to [A](a.md).\n") + return manifest.assemble(manifest.load(root / "doc-engine.md")).body + + def test_link_to_another_entry_becomes_internal(self, tmp_path) -> None: + body = self.make(tmp_path) + assert "#link()" in body + assert "#link()" in body + + def test_every_entry_gets_an_anchor(self, tmp_path) -> None: + assert self.make(tmp_path).count("#metadata(none)") == 2 + + def test_external_links_are_untouched(self, tmp_path) -> None: + assert '#link("https://x.test")' in self.make(tmp_path) + + def test_links_outside_a_manifest_stay_plain(self, tmp_path) -> None: + from doc_engine.converter import convert_document + + (tmp_path / "b.md").write_text("# B\n") + body = convert_document("[B](b.md)", base_dir=tmp_path).body + assert '#link("b.md")' in body