Skip to content
Open
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
28 changes: 24 additions & 4 deletions src/atsphinx/typst/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from sphinx import addnodes
from sphinx._cli.util.colour import darkgreen
from sphinx.builders import Builder
from sphinx.errors import SphinxError
from sphinx.errors import NoUri, SphinxError
from sphinx.util.fileutil import copy_asset, copy_asset_file
from sphinx.util.nodes import inline_all_toctrees

Expand Down Expand Up @@ -40,6 +40,10 @@ def __init__(self, app: Sphinx, env: BuildEnvironment) -> None: # noqa: D107
self._static_dir = Path(self.outdir / "_static")
self._images_dir = Path(self.outdir / "_images")
self._build_date = date.today()
# Docnames merged into the document currently being written, like
# the LaTeX builder's ``self.docnames``. Populated by
# ``assemble_doctree()`` before it resolves references.
self.docnames: set[str] = set()

def init(self): # noqa: D102
super().init()
Expand Down Expand Up @@ -111,13 +115,29 @@ def assemble_doctree(
root_section += toctree
root = root.copy()
root += root_section
tree = inline_all_toctrees(self, {docname}, docname, root, darkgreen, [docname])
self.docnames = {docname}
tree = inline_all_toctrees(
self, self.docnames, docname, root, darkgreen, [docname]
)
tree["docname"] = docname
# Like the LaTeX builder, resolve references right after merging so
# that Sphinx's own domains (:doc:, :ref:, :confval:, intersphinx,
# ...) do the resolution; see get_target_uri()/get_relative_uri()
# for how that maps onto the single merged Typst document.
self.env.resolve_references(tree, docname, self)
return tree

def get_target_uri(self, docname, typ=None): # noqa: D102
# TODO: Implement it!
return ""
if docname not in self.docnames:
raise NoUri(docname, typ)
# All documents are merged into one, so the docname itself is
# enough; the writer turns it into the appropriate Typst label.
return docname

def get_relative_uri(self, from_, to, typ=None): # noqa: D102
# Ignore source path: there's only one output file, so there is no
# relative path to compute (cf. the LaTeX builder).
return self.get_target_uri(to, typ)

def copy_assets(self): # noqa: D102
# Copying all theme assets.
Expand Down
99 changes: 89 additions & 10 deletions src/atsphinx/typst/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ def _typst_local_package_fullname(name: str, version: str | None = None) -> str:
return f"@local/{name}:{version}"


def _doc_label(docname: str) -> str:
# All documents are merged into a single Typst file, so every label
# needs a per-document namespace to avoid collisions between e.g. two
# documents that each have a "Installation" section.
return docname.replace("/", ":")


class TypstTranslator(SphinxTranslator, BaseTypstTranslator):
"""Custom translator that has converter from dotctree to Typst syntax."""

Expand Down Expand Up @@ -65,15 +72,49 @@ def __init__(self, document: nodes.document, builder: Builder) -> None:
self.context = {
"has_index": False,
}
# Track current document for cross-references (like the LaTeX
# writer's ``curfilestack``). The builder merges everything into
# one doctree, so this is what lets us namespace labels per
# document and tell which document a relative reference is in.
self.curfilestack = [document["docname"]]

# ------
# visit/departuer methods
# ------
def visit_document(self, node: nodes.document):
super().visit_document(node)
self._write_anchor(_doc_label(self.curfilestack[-1]))

def visit_title(self, node: nodes.title):
if isinstance(node.parent, nodes.section) and self._section_level < 1:
raise nodes.SkipNode

super().visit_title(node)

def depart_title(self, node: nodes.title):
"""Add a label to section titles for cross-referencing."""
docname = _doc_label(self.curfilestack[-1])
if isinstance(node.parent, nodes.section):
ids = node.parent.get("ids", [])
else:
ids = []

if ids:
# Add the section label AFTER the title text but BEFORE
# newlines. Typst syntax: == Title <label>
self.body.append(f" <{docname}:{ids[0]}>")

super().depart_title(node)

# Sphinx may register more than one id for a section (e.g. an
# explicit ``.. _target:`` placed right above the heading, used by
# :ref:). Typst only allows a single label per element, so the
# first id is attached directly to the heading above and every
# other id gets its own invisible anchor here - the same way HTML
# emits an empty anchor element for each extra id on a section.
for node_id in ids[1:]:
self._write_anchor(f"{docname}:{node_id}")

# TODO: It should separate transform and translate.
def visit_container(self, node: nodes.container):
if node.get("literal_block"):
Expand All @@ -99,20 +140,40 @@ def visit_image(self, node: nodes.image):
node["uri"] = uri_map
super().visit_image(node)

def visit_reference(self, node):
def visit_reference(self, node: nodes.reference):
# NOTE: It may be should implement in rst2typst.
if not node.get("internal", False):
return super().visit_reference(node)
if "refuri" in node:
uri = node["refuri"][1:]
elif "refid" in node:
uri = node["refid"]
if "refid" in node:
# Reference resolved within the current document (see
# ``curfilestack``), e.g. a ``:ref:`` to a label in this file.
uri = f"{_doc_label(self.curfilestack[-1])}:{node['refid']}"
elif "refuri" in node:
# Reference resolved to another document by Sphinx's standard
# reference resolution (``Builder.get_relative_uri``), shaped
# like ``docname`` or ``docname#labelid``.
docname, _, labelid = node["refuri"].partition("#")
uri = _doc_label(docname)
if labelid:
uri += f":{labelid}"
else:
raise ExtensionError("<reference> requires 'refuri' or 'refid' attribute")
return self.body.append(f"#link(<{uri}>)[")

# Implements for Sphinx's nodes
# =============================
def visit_pending_xref(self, node: addnodes.pending_xref):
# NOTE: Implement this if rendering anything is needed.
# By the time the writer runs, ``resolve_references()`` has
# already replaced every resolvable ``pending_xref`` with a
# plain ``reference`` node (see ``assemble_doctree``); like in
# Sphinx's LaTeX writer, this is only reached for the rare case
# of an unresolved reference left in place.
pass

def depart_pending_xref(self, node: addnodes.pending_xref):
pass

def visit_desc(self, node: addnodes.desc):
self.packages.add(_typst_local_package_fullname("atsphinx-typst"), "desc")
self.body.append(f"{self._hi.prefix}#desc(\n")
Expand All @@ -127,10 +188,17 @@ def visit_desc_signature(self, node: addnodes.desc_signature):
self._hi.push(" ")

def depart_desc_signature(self, node: addnodes.desc_signature):
for id in node.get("ids", []):
self.body.append(f" <{id}>")
# Namespaced the same way as section headings (see depart_title())
# so that :py:class:, :confval:, etc. xrefs resolve consistently
# across the merged document.
docname = _doc_label(self.curfilestack[-1])
ids = node.get("ids", [])
if ids:
self.body.append(f" <{docname}:{ids[0]}>")
self._hi.pop()
self.body.append(f"{self._hi.prefix}],\n")
for node_id in ids[1:]:
self._write_anchor(f"{docname}:{node_id}")

def visit_desc_name(self, node: addnodes.desc_name):
self.body.append(f"{self._hi.indent}#strong(delta: 400)[")
Expand Down Expand Up @@ -171,8 +239,19 @@ def depart_index(self, node: addnodes.index):
pass

def visit_start_of_file(self, node: addnodes.start_of_file):
# NOTE: Implement this when rendering anything as the "start of file."
pass
# Track current document for cross-references (like LaTeX writer).
docname = node["docname"]
self.curfilestack.append(docname)
self._write_anchor(_doc_label(docname))

def depart_start_of_file(self, node: addnodes.start_of_file):
pass
self.curfilestack.pop()

def _write_anchor(self, label: str) -> None:
# An invisible, addressable label that isn't attached to any
# visible content. Used to mark the start of a document - so a
# whole-document ``:doc:`` reference (which carries no section
# anchor) has something to link to even if that document has no
# sections - and for ids on a section beyond the first, which
# can't get a second label of their own (see depart_title()).
self.body.append(f"#metadata(none) <{label}>\n")
128 changes: 128 additions & 0 deletions tests/test_e2e/test_it.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from typing import TYPE_CHECKING

import pytest
Expand Down Expand Up @@ -74,3 +75,130 @@ def test__copy_content_images(app: SphinxTestApp):
assert (app.outdir / "index.typ").exists()
assert (app.outdir / "_images/example.png").exists()
assert (app.outdir / "_images/example.png").is_file()


@pytest.mark.sphinx("typst", testroot="cross-references")
def test__cross_references(app: SphinxTestApp):
"""Test cross-referencing documents using :doc: role."""
app.build()
out = app.outdir / "index.typ"
assert out.exists()
content = out.read_text()
# Check that cross-reference content is present
assert "Cross-Reference Test Documentation" in content
# Verify that referenced pages are included in the main document
assert "Page 1" in content
assert "Page 2" in content
assert "Nested Page" in content
# Check that cross-references are converted to Typst links.
# A plain :doc: link carries no section anchor, so it points at the
# document-level label (see TypstTranslator._write_anchor()).
assert "#link(<page1>)" in content
assert "#link(<page2>)" in content
assert "#link(<subdir:nested>)" in content
assert "#link(<>)" not in content
# Verify that section labels are created for the first section of each document
assert "<page1:page-1>" in content
assert "<page2:page-2>" in content
assert "<subdir:nested:nested-page>" in content
# Verify content from referenced pages is included
assert "first page in our cross-reference test" in content
assert "second page in our cross-reference test" in content
assert "nested page in a subdirectory" in content
# Edge case: document without sections
assert "This document has no sections" in content
# Verify that a document-level label is generated for documents without sections
assert "#link(<no_sections>)" in content


@pytest.mark.sphinx("typst", testroot="cross-references")
def test__cross_references_ref_role_resolves_to_actual_target(app: SphinxTestApp):
""":ref: must resolve to the actual target, not be treated as a docname.

For ``:ref:`custom-target``` the target is the explicit
``.. _custom-target:`` label in page2.rst, which is a distinct id from
the section's own auto-generated ``target-section`` id - :ref: must
resolve to the former, and that label must actually exist in the
output.
"""
app.build()
content = (app.outdir / "index.typ").read_text()
assert "#link(<page2:custom-target>)" in content
assert content.count("<page2:custom-target>") >= 2


@pytest.mark.sphinx("typst", testroot="cross-references")
def test__cross_references_bare_relative_doc_path(app: SphinxTestApp):
"""A bare relative :doc: target resolves to the current doc's directory.

Per Sphinx docs: ":doc:`parrot` occurring in sketches/index links to
sketches/parrot" - i.e. no ``../``/``./`` prefix is needed.
``subdir/nested.rst`` references ``:doc:`sibling``` with no prefix, so
it must resolve to ``subdir/sibling``, not a top-level ``sibling``
document.
"""
app.build()
content = (app.outdir / "index.typ").read_text()
assert "#link(<subdir:sibling>)" in content
assert "Sibling Page" in content


@pytest.mark.sphinx("typst", testroot="cross-references")
def test__cross_references_no_sections_label_is_actually_defined(app: SphinxTestApp):
"""A no-sections document must not link to a label that's never emitted.

``no_sections.rst`` has no headings, so it never gets a section label.
A whole-document ``:doc:`` reference to it must still resolve to some
label that is actually defined in the output (the document-level
anchor), not a dangling reference.
"""
app.build()
content = (app.outdir / "index.typ").read_text()
assert "#link(<no_sections>)" in content
# One occurrence is the `#link(<no_sections>)` reference itself; a
# second, independent occurrence must exist as the actual label
# definition it points to.
assert content.count("<no_sections>") >= 2


@pytest.mark.sphinx("typst", testroot="cross-references")
def test__cross_references_implicit_title_uses_document_title(app: SphinxTestApp):
"""Implicit-title :doc: must use the target document's title as link text.

Per Sphinx docs: "If no explicit link text is given... the link caption
will be the title of the given document."
"""
app.build()
content = (app.outdir / "index.typ").read_text()
assert "#link(<page1>)[Page 1]" in content
assert "#link(<page2>)[Page 2]" in content


@pytest.mark.sphinx("typst", testroot="cross-references")
def test__cross_references_labels_use_valid_typst_syntax(app: SphinxTestApp):
"""Every generated label must only use characters Typst's <label> syntax allows.

Per Typst's docs, a label's name may only contain letters, numbers, ``_``,
``-``, ``:`` and ``.``. ``:confval:`my_option[]``` has a reftarget
containing ``[``/``]`` (mirroring this project's own real
``typst_documents[].entrypoint`` confval), which is copied verbatim into
the generated label and breaks the Typst parser ("unclosed label").
"""
app.build()
content = (app.outdir / "index.typ").read_text()
label_pattern = re.compile(r"<([^<>\s]+)>")
valid_label = re.compile(r"^[A-Za-z0-9_.:-]+$")
invalid = [m for m in label_pattern.findall(content) if not valid_label.match(m)]
assert invalid == []


@pytest.mark.sphinx("typstpdf", testroot="cross-references")
def test__cross_references_pdf_compiles(app: SphinxTestApp):
"""The merged document with cross-references must still compile to PDF.

Combines several of the issues above (dangling labels, label characters
Typst rejects) into one end-to-end check that the generated Typst source
is actually valid.
"""
app.build()
assert (app.outdir / "index.pdf").exists()
14 changes: 14 additions & 0 deletions tests/testdocs/test-cross-references/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# noqa: D100

extensions = [
"atsphinx.typst",
]

typst_documents = [
{
"entrypoint": "index",
"filename": "index",
"theme": "manual",
"title": "Cross-Reference Test Documentation",
}
]
Loading