Skip to content

Docs build aborts for any package with a module named html: llms_txt_suffix_mode "auto" collides the Markdown twins #36

Description

@thorwhalen

A project with a module whose last name component is html cannot build its docs: sphinx-build -b html aborts in the sphinx_llm.txt extension.

sphinx.errors.ExtensionError: Documents '_autosummary/pkg.renderers.html' (replace)
and '_autosummary/pkg.renderers' (append) resolve to the same published
Markdown path '_autosummary/pkg.renderers.html.md'

Seen on a thorwhalen/* package (epythet 0.2.12, sphinx-llm 1.1.0, Sphinx 9.1, Python 3.12) as soon as a renderers/html.py submodule got an API page. Reproduced locally with epythet quickstart ..

Cause

epythet.agent_outputs.sphinx_settings turns the agent outputs on but leaves llms_txt_suffix_mode at sphinx-llm's default, "auto". In "auto" mode sphinx_llm.txt.resolve_markdown_targets publishes two spellings of every page's Markdown twin:

  • MarkdownLayout.APPEND — <html_target>.md, i.e. <page>.html.md
  • MarkdownLayout.REPLACE — html_target.with_suffix(".md"), i.e. <page>.md

For a document whose docname already ends in .html the two spellings collide with those of its parent:

document append twin replace twin
_autosummary/pkg.renderers pkg.renderers.html.md pkg.renderers.md
_autosummary/pkg.renderers.html pkg.renderers.html.html.md pkg.renderers.html.md

copy_markdown_files detects the clash and raises rather than silently overwriting one twin with the other, which is the right call on its side.

Note that llms_txt_exclude is not a workaround: the collision check runs in the first pass over plans, before _is_excluded is consulted, so an excluded document still claims its target paths.

Suggested fix

Set "llms_txt_suffix_mode": "append" in epythet.agent_outputs.sphinx_settings. epythet already assumes the append spelling everywhere it references a twin:

  • agent_outputs.link_relation_tags emits href="{basename}.html.md"
  • agent_outputs.inject_link_relations_into_site looks for page.with_name(page.name + ".md")

So the REPLACE copies are unreferenced by epythet's own discovery contract, and dropping them costs nothing while removing a whole class of build failure. (If they are wanted for URL-suffix-style serving, the alternative is to keep "auto" and make the mode configurable from [tool.epythet].)

Second, smaller finding: a committed conf.py override is silently clobbered

epythet.templates.conf_py_shim's own docstring says:

Put project-specific overrides below the import; anything defined here wins over the generated value.

That is not what happens. epythet.scaffold.write_generated_file overwrites the file whenever the marker is present and the content differs from the template:

if not any(marker in existing for marker in markers):
    say(f"Keeping hand-written {path.name} (no epythet marker found)")
    return
if existing == content:
    return
path.write_text(content, encoding="utf-8")

Since the marker is the from epythet.sphinx_conf import * line, a shim with an override appended matches the marker, differs from the template, and gets regenerated — the override is lost on the next build, with Wrote .../conf.py as the only trace.

A project that needs one raw Sphinx setting therefore has to drop the marker and import the generated settings by hand, which also opts it out of future shim updates. Either the docstring should be corrected, or write_generated_file should treat "template + extra lines below it" as user content (e.g. overwrite only when the existing text is exactly a known generated version, the way refresh_docsrc_gitignore already handles appended lines).

A [tool.epythet] passthrough for raw Sphinx settings — the same shape as theme_options — would remove the need for a committed conf.py at all, and would have made the first issue above a one-line project-side fix.

Workaround in use

The affected repo commits a hand-written docsrc/conf.py with no marker line:

from epythet import sphinx_conf as _epythet_generated

globals().update(
    {_k: _v for _k, _v in vars(_epythet_generated).items() if not _k.startswith("_")}
)

llms_txt_suffix_mode = "append"

With that, the build succeeds and both module pages plus both twins are published at distinct paths.


Filed from an unattended session; not starting the epythet-side change.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions