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
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ doc-%:

docs: doc-markdown

doc-singlemarkdown:
@$(SPHINX_BUILD) -M singlemarkdown "$(SOURCE_DIR)" "$(BUILD_DIR)" $(SPHINX_OPTS) $(O) -a -t Partners

docs-single: doc-singlemarkdown


test-diff:
@echo "Building markdown..."
@$(SPHINX_BUILD) -M markdown "$(SOURCE_DIR)" "$(BUILD_DIR)" $(SPHINX_OPTS) $(O) -a -t Partners

@echo "Building singlemarkdown..."
@$(SPHINX_BUILD) -M singlemarkdown "$(SOURCE_DIR)" "$(BUILD_DIR)" $(SPHINX_OPTS) $(O) -a -t Partners

@echo "Building markdown with configuration overrides..."
@$(SPHINX_BUILD) -M markdown "$(SOURCE_DIR)" "$(BUILD_DIR)/overrides" $(SPHINX_OPTS) $(O) -a \
-D markdown_http_base="https://localhost" -D markdown_uri_doc_suffix=".html" \
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ extensions = [
]
```

Build markdown files with `sphinx-build` command
Build separate markdown files with `sphinx-build` command:
```sh
sphinx-build -M markdown ./docs ./build
```

Build a single consolidated markdown file with:
```sh
sphinx-build -M singlemarkdown ./docs ./build
```
This will generate a single markdown file containing all your documentation in one place.

## Configurations

You can add the following configurations to your `conf.py` file:
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ classifiers = [
]
keywords = ["sphinx", "sphinx-extension", "markdown", "docs", "documentation", "builder"]
dependencies = ["sphinx>=5.1.0", "tabulate", "docutils"]
requires-python = ">=3.7"
requires-python = ">=3.9"

[tool.poetry.plugins] # Optional super table

[tool.poetry.plugins."sphinx.builders"]
"markdown" = "sphinx_markdown_builder"
"singlemarkdown" = "sphinx_markdown_builder.singlemarkdown"

[project.entry-points."sphinx.builders"]
"markdown" = "sphinx_markdown_builder"
"singlemarkdown" = "sphinx_markdown_builder.singlemarkdown"

[project.optional-dependencies]
dev = [
Expand Down
23 changes: 22 additions & 1 deletion sphinx_markdown_builder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,38 @@
A Sphinx extension to add markdown generation support.
"""

from typing import Any, Dict

from sphinx.application import Sphinx

from sphinx_markdown_builder.builder import MarkdownBuilder
from sphinx_markdown_builder.singlemarkdown import SingleFileMarkdownBuilder

__version__ = "0.6.8"
__docformat__ = "reStructuredText"


def setup(app):
def setup(app: Sphinx) -> Dict[str, Any]:
"""Setup the Sphinx extension.

This is the main entry point for the extension.
"""
# Register the regular markdown builder
app.add_builder(MarkdownBuilder)

# Register the single file markdown builder
app.add_builder(SingleFileMarkdownBuilder)

# Add configuration values
app.add_config_value("markdown_http_base", "", "html", str)
app.add_config_value("markdown_uri_doc_suffix", ".md", "html", str)
app.add_config_value("markdown_anchor_sections", False, "html", bool)
app.add_config_value("markdown_anchor_signatures", False, "html", bool)
app.add_config_value("markdown_docinfo", False, "html", bool)
app.add_config_value("markdown_bullet", "*", "html", str)

return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}
2 changes: 1 addition & 1 deletion sphinx_markdown_builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class MarkdownBuilder(Builder):

def __init__(self, app: Sphinx, env: BuildEnvironment = None):
super().__init__(app, env)
self.writer = None
self.writer: MarkdownWriter | None = None
self.sec_numbers = None
self.current_doc_name = None

Expand Down
4 changes: 3 additions & 1 deletion sphinx_markdown_builder/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ def make(self):
_ContextT = TypeVar("_ContextT", bound=SubContext)

Translator = Callable[[Any, Any], Dict[str, Any]]
DEFAULT_TRANSLATOR: Translator = lambda _node, _elem: {}
DEFAULT_TRANSLATOR: Translator = lambda _node, _elem: {} # pylint: disable=invalid-name


class PushContext(Generic[_ContextT]): # pylint: disable=too-few-public-methods
Expand All @@ -405,10 +405,12 @@ def create(self, node, element_key) -> _ContextT:
return self.ctx(*self.args, **kwargs)


# pylint: disable=invalid-name
ItalicContext = PushContext(WrappedContext, "*") # _ is more restrictive
StrongContext = PushContext(WrappedContext, "**") # _ is more restrictive
SubscriptContext = PushContext(WrappedContext, "<sub>", "</sub>")
DocInfoContext = PushContext(
MetaContext,
translator=lambda _node, elem: {"name": f"{elem}: "},
)
# pylint: enable=invalid-name
229 changes: 229 additions & 0 deletions sphinx_markdown_builder/singlemarkdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Single Markdown builder."""

# pyright: reportIncompatibleMethodOverride=false, reportImplicitOverride=false

from __future__ import annotations

import os
from typing import TYPE_CHECKING, cast

from docutils import nodes
from docutils.io import StringOutput
from sphinx._cli.util.colour import darkgreen
from sphinx.environment.adapters.toctree import global_toctree_for_doc
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util.docutils import SphinxTranslator, new_document
from sphinx.util.nodes import inline_all_toctrees
from sphinx.util.osutil import ensuredir, os_path

from sphinx_markdown_builder.builder import MarkdownBuilder
from sphinx_markdown_builder.singletranslator import SingleMarkdownTranslator
from sphinx_markdown_builder.writer import MarkdownWriter

if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.util.typing import ExtensionMetadata

logger = logging.getLogger(__name__)


class SingleFileMarkdownBuilder(MarkdownBuilder):
"""Builds the whole document tree as a single Markdown page."""

name: str = "singlemarkdown"
epilog: str = __("The Markdown page is in %(outdir)s.")

# These are copied from SingleFileHTMLBuilder
copysource: bool = False

# Use the custom translator for single file output
default_translator_class: type[SphinxTranslator] = SingleMarkdownTranslator

def get_outdated_docs(self) -> str | list[str]:
return "all documents"

def get_target_uri(self, docname: str, typ: str | None = None) -> str:
if docname in self.env.all_docs:
# All references are on the same page, use anchors
# Add anchor for document (like singlehtml uses #document-)
return f"#document-{docname}"
# External files like images or other resources
if docname:
return docname + self.out_suffix
return ""

def get_relative_uri(self, from_: str, to: str, typ: str | None = None) -> str:
# Ignore source - all links are in the same document
return self.get_target_uri(to, typ)

def render_partial(self, node: nodes.Node | None) -> dict[str, str | bytes]:
"""Utility: Render a lone doctree node."""
if node is None:
return {"fragment": ""}

# Create a new writer for this partial rendering
writer = MarkdownWriter(self)

# Create a mini doctree containing only the node if it's not already a document
if not isinstance(node, nodes.document):
# Create a proper document with settings
doctree = new_document("", self.env.settings)
doctree.append(node)
else:
doctree = node

# Render to string
destination = StringOutput(encoding="utf-8")
_ = writer.write(doctree, destination)

# Convert all return values to strings to match expected type
fragment = writer.output if writer.output is not None else ""

# Return required fragments with string values
return {
"fragment": fragment,
"title": "",
"css": "",
"js": "",
"script": "",
}

def _get_local_toctree(
self,
docname: str,
collapse: bool = True,
**kwargs: bool | int | str,
) -> str:
if isinstance(includehidden := kwargs.get("includehidden"), str):
if includehidden.lower() == "false":
kwargs["includehidden"] = False
elif includehidden.lower() == "true":
kwargs["includehidden"] = True
if kwargs.get("maxdepth") == "":
_ = kwargs.pop("maxdepth")
toctree = global_toctree_for_doc(
self.env,
docname,
self,
collapse=collapse,
**kwargs, # pyright: ignore[reportArgumentType]
)
fragment = self.render_partial(toctree)["fragment"]
return str(fragment)

def assemble_doctree(self) -> nodes.document:
master = cast(str, self.config.root_doc)
tree = self.env.get_doctree(master)
tree = inline_all_toctrees(self, set(), master, tree, darkgreen, [master])
tree["docname"] = master
self.env.resolve_references(tree, master, self)
return tree

def assemble_toc_secnumbers(self) -> dict[str, dict[str, tuple[int, ...]]]:
new_secnumbers: dict[str, tuple[int, ...]] = {}
for docname, secnums in self.env.toc_secnumbers.items():
for id_, secnum in secnums.items():
alias = f"{docname}/{id_}"
new_secnumbers[alias] = secnum

root_doc = cast(str, self.config.root_doc)
return {root_doc: new_secnumbers}

def assemble_toc_fignumbers(
self,
) -> dict[str, dict[str, dict[str, tuple[int, ...]]]]:
new_fignumbers: dict[str, dict[str, tuple[int, ...]]] = {}
for docname, fignumlist in self.env.toc_fignumbers.items():
for figtype, fignums in fignumlist.items():
alias = f"{docname}/{figtype}"
_ = new_fignumbers.setdefault(alias, {})
for id_, fignum in fignums.items():
new_fignumbers[alias][id_] = fignum

root_doc = cast(str, self.config.root_doc)
return {root_doc: new_fignumbers}

def get_doc_context(
self,
docname: str, # pylint: disable=unused-argument # pyright: ignore[reportUnusedParameter]
body: str,
metatags: str,
) -> dict[str, str | bytes | bool | list[dict[str, str]] | None]:
# no relation links...
root_doc = cast(str, self.config.root_doc)
toctree = global_toctree_for_doc(self.env, root_doc, self, collapse=False)
# if there is no toctree, toc is None
if toctree:
toc = self.render_partial(toctree)["fragment"]
display_toc = True
else:
toc = ""
display_toc = False
return {
"parents": [],
"prev": None,
"next": None,
"docstitle": None,
"title": cast(str, self.config.html_title),
"meta": None,
"body": body,
"metatags": metatags,
"rellinks": [],
"sourcename": "",
"toc": toc,
"display_toc": display_toc,
}

def write_documents(self, _docnames: set[str]) -> None:
# Prepare for writing all documents
self.prepare_writing(self.env.all_docs.keys())

# Assemble single doctree from all documents (like singlehtml)
logger.info("assembling single document")
doctree = self.assemble_doctree()
self.env.toc_secnumbers = self.assemble_toc_secnumbers()
self.env.toc_fignumbers = self.assemble_toc_fignumbers()

# Write the assembled document
logger.info("writing")
root_doc = cast(str, self.config.root_doc)

# Set current_doc_name for the translator (needed for URL adjustments)
self.current_doc_name = root_doc
self.sec_numbers = self.env.toc_secnumbers.get(root_doc, {})

# Prepare writer for output
self.writer: MarkdownWriter | None = MarkdownWriter(self)

destination = StringOutput(encoding="utf-8")
_ = self.writer.write(doctree, destination)

# Write to output file
outfilename = os.path.join(self.outdir, os_path(root_doc) + self.out_suffix)

# Ensure output directory exists
ensuredir(os.path.dirname(outfilename))

try:
with open(outfilename, "w", encoding="utf-8") as f:
_ = f.write(self.writer.output if self.writer.output is not None else "")
except OSError as err:
logger.warning(__("error writing file %s: %s"), outfilename, err)


def setup(app: Sphinx) -> ExtensionMetadata:
"""Setup the singlemarkdown builder extension.

This follows the pattern from Sphinx's own singlehtml.py.
"""
# Setup the main extension first
app.setup_extension("sphinx_markdown_builder")

# No need to register the builder here as it's already registered in __init__.py

return {
"version": "builtin",
"parallel_read_safe": True,
"parallel_write_safe": True,
}
35 changes: 35 additions & 0 deletions sphinx_markdown_builder/singletranslator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Custom translator for single markdown file output."""

# pyright: reportImplicitOverride=false

from typing import TYPE_CHECKING

from docutils import nodes

from sphinx_markdown_builder.translator import MarkdownTranslator

if TYPE_CHECKING: # pragma: no cover
from sphinx_markdown_builder.singlemarkdown import SingleFileMarkdownBuilder


class SingleMarkdownTranslator(MarkdownTranslator):
"""Translator that ensures proper content inclusion for a single markdown file."""

def __init__(self, document: nodes.document, builder: "SingleFileMarkdownBuilder"):
super().__init__(document, builder)
# Track docnames as we traverse (like HTML translator)
self.docnames: list[str] = []

def visit_start_of_file(self, node: nodes.Element) -> None:
"""Handle start_of_file nodes created by inline_all_toctrees.

This is similar to how the HTML5 translator handles it - just add an anchor.
"""
docname = node["docname"]
self.docnames.append(docname)
# Add anchor for document linking (like singlehtml does)
self.add(f'<a id="document-{docname}"></a>', prefix_eol=2)

def depart_start_of_file(self, _node: nodes.Element) -> None:
"""Clean up after start_of_file node."""
self.docnames.pop()
Loading