Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ NEW: Add toclist extension #485

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conf.py
Expand Up @@ -87,6 +87,7 @@
"linkify",
"substitution",
"tasklist",
"toclist",
]
myst_number_code_blocks = ["typescript"]
myst_heading_anchors = 2
Expand Down
1 change: 1 addition & 0 deletions docs/sphinx/reference.md
Expand Up @@ -69,6 +69,7 @@ List of extensions:
- "smartquotes": automatically convert standard quotations to their opening/closing variants
- "substitution": substitute keys, see the [substitutions syntax](syntax/substitutions) for details
- "tasklist": add check-boxes to the start of list items, see the [tasklist syntax](syntax/tasklists) for details
- "toclist": specify sphinx `toctree` in a Markdown native manner, see the [toclist syntax](syntax/toclists) for details

Math specific, when `"dollarmath"` activated, see the [Math syntax](syntax/math) for more details:

Expand Down
37 changes: 37 additions & 0 deletions docs/syntax/optional.md
Expand Up @@ -38,6 +38,7 @@ myst_enable_extensions = [
"smartquotes",
"substitution",
"tasklist",
"toclist",
]
```

Expand Down Expand Up @@ -682,6 +683,42 @@ Send a message to a recipient
Currently `sphinx.ext.autodoc` does not support MyST, see [](howto/autodoc).
:::

(syntax/toclists)=
## Table of Contents Lists

By adding `"toclist"` to `myst_enable_extensions` (in the sphinx `conf.py` [configuration file](https://www.sphinx-doc.org/en/master/usage/configuration.html)),
you will be able to specify [sphinx `toctree`](sphinx:toctree-directive) in a Markdown native manner.

`toclist` are identified by bullet lists that use the `+` character as the marker,
and each item should be a link to another source file or an external hyperlink:

```markdown
+ [Link text](subsection.md)
+ [Link text](https://example.com "Example external link")
```

is equivalent to:

````markdown
```{toctree}

subsection.md
Example external link <https://example.com>
```
````

+ [Link text](subsection.md)
+ [Link text](https://example.com "Example external link")

Note that the link text is omitted from the output `toctree`, and the title of the link is taken from either the link title, if present, or the title of the source file.

You can also specify the `maxdepth` and `numbered` options for all toclist in your `conf.py`:

```python
myst_toclist_maxdepth = 2
myst_toclist_numbered = True
```

(syntax/images)=

## Images
Expand Down
3 changes: 3 additions & 0 deletions docs/syntax/subsection.md
@@ -0,0 +1,3 @@
# Example subsection

A subsection referenced by the `toclist` example.
2 changes: 2 additions & 0 deletions myst_parser/docutils_.py
Expand Up @@ -65,6 +65,8 @@ def __repr__(self):
"ref_domains",
"update_mathjax",
"mathjax_classes",
"toclist_maxdepth",
"toclist_numbered",
)
"""Names of settings that cannot be set in docutils.conf."""

Expand Down
17 changes: 16 additions & 1 deletion myst_parser/main.py
Expand Up @@ -89,9 +89,9 @@ def check_extensions(self, attribute, value):
raise TypeError(f"myst_enable_extensions not iterable: {value}")
diff = set(value).difference(
[
"dollarmath",
"amsmath",
"deflist",
"dollarmath",
"fieldlist",
"html_admonition",
"html_image",
Expand All @@ -101,6 +101,7 @@ def check_extensions(self, attribute, value):
"linkify",
"substitution",
"tasklist",
"toclist",
]
)
if diff:
Expand Down Expand Up @@ -184,6 +185,18 @@ def check_extensions(self, attribute, value):
default=("{", "}"), metadata={"help": "Substitution delimiters"}
)

toclist_maxdepth: Optional[int] = attr.ib(
default=None,
validator=optional(instance_of(int)),
metadata={"help": "Max depth of toctree created from the toclist extension"},
)

toclist_numbered: bool = attr.ib(
default=False,
validator=instance_of(bool),
metadata={"help": "Number toctree entries created from the toclist extension"},
)

words_per_minute: int = attr.ib(
default=200,
validator=instance_of(int),
Expand Down Expand Up @@ -307,6 +320,8 @@ def create_md_parser(
"myst_footnote_transition": config.footnote_transition,
"myst_number_code_blocks": config.number_code_blocks,
"myst_highlight_code_blocks": config.highlight_code_blocks,
"myst_toclist_maxdepth": config.toclist_maxdepth,
"myst_toclist_numbered": config.toclist_numbered,
}
)

Expand Down
84 changes: 83 additions & 1 deletion myst_parser/sphinx_renderer.py
Expand Up @@ -10,6 +10,7 @@

from docutils import nodes
from docutils.parsers.rst import directives, roles
from markdown_it.token import Token
from markdown_it.tree import SyntaxTreeNode
from sphinx import addnodes
from sphinx.application import Sphinx, builtin_extensions
Expand All @@ -25,7 +26,7 @@
from sphinx.util.nodes import clean_astext
from sphinx.util.tags import Tags

from myst_parser.docutils_renderer import DocutilsRenderer
from myst_parser.docutils_renderer import DocutilsRenderer, token_line

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -222,6 +223,87 @@ def add_math_target(self, node: nodes.math_block) -> nodes.target:
self.document.note_explicit_target(target)
return target

def render_bullet_list(self, token: SyntaxTreeNode) -> None:
# override for toclist extension
if (
"toclist" not in self.config.get("myst_extensions", [])
or token.markup != "+"
):
return super().render_bullet_list(token)
return self.render_toclist(token)

def render_toclist(self, token: SyntaxTreeNode) -> None:
"""Render a toclist as a sphinx ``toctree`` item."""
# here we expect a list of links to documents/hyperlinks,
# with an optional title, e.g.
# `+ [discarded](doc.md "title")`
# print(token.pretty())
# <bullet_list>
# <list_item>
# <paragraph>
# <inline>
# <link href='doc.md' title='title'>
# <text>
items = []
for child in token.children:

# get the link
malformed_msg = "malformed toclist item, expected single link"
line = token_line(child, default=0) or None
if child.type != "list_item":
self.create_warning(malformed_msg, subtype="toclist", line=line)
continue
if len(child.children) != 1 or child.children[0].type != "paragraph":
self.create_warning(malformed_msg, subtype="toclist", line=line)
continue
if (
len(child.children[0].children) != 1
or child.children[0].children[0].type != "inline"
):
self.create_warning(malformed_msg, subtype="toclist", line=line)
continue
if (
len(child.children[0].children[0].children) != 1
or child.children[0].children[0].children[0].type != "link"
):
self.create_warning(malformed_msg, subtype="toclist", line=line)
continue
link = child.children[0].children[0].children[0]

# add the toc item
href = cast(str, link.attrGet("href") or "")
if not href:
continue
title = link.attrGet("title")
# Note: we discard the link children since the toctree cannot use them
items.append({"href": href, "title": title})

if not items:
return
# we simply create the directive token and let sphinx handle generating the AST
content = "\n".join(
str(i["href"]) if not i["title"] else f"{i['title']} <{i['href']}>"
for i in items
)
if self.config.get("myst_toclist_maxdepth", None) is not None:
content = f":maxdepth: {self.config['myst_toclist_maxdepth']}\n" + content
if self.config.get("myst_toclist_numbered", False):
content = ":numbered:\n" + content
toctree_token = SyntaxTreeNode(
tokens=[
Token(
"fence",
"",
0,
info="{toctree}",
map=cast(list, token.map),
content=content,
)
],
create_root=False,
)
return self.render_directive(toctree_token)


def minimal_sphinx_app(
configuration=None, sourcedir=None, with_builder=False, raise_on_warning=False
Expand Down
5 changes: 5 additions & 0 deletions tests/test_sphinx/sourcedirs/toclist/conf.py
@@ -0,0 +1,5 @@
extensions = ["myst_parser"]
exclude_patterns = ["_build"]
myst_enable_extensions = ["toclist"]
myst_toclist_maxdepth = 2
myst_toclist_numbered = True
7 changes: 7 additions & 0 deletions tests/test_sphinx/sourcedirs/toclist/index.md
@@ -0,0 +1,7 @@
# Title

+ [Some discarded text](page1.md)
+ [](https://example.com)
+ [](https://example.com "title")

- A normal list
1 change: 1 addition & 0 deletions tests/test_sphinx/sourcedirs/toclist/page1.md
@@ -0,0 +1 @@
# Page 1 Title
25 changes: 25 additions & 0 deletions tests/test_sphinx/test_sphinx_builds.py
Expand Up @@ -532,3 +532,28 @@ def test_fieldlist_extension(
regress_html=True,
regress_ext=f".sphinx{sphinx.version_info[0]}.html",
)


@pytest.mark.sphinx(
buildername="html",
srcdir=os.path.join(SOURCE_DIR, "toclist"),
freshenv=True,
)
def test_toclist_extension(
app,
status,
warning,
get_sphinx_app_doctree,
):
"""test enabling the toclist extension."""
app.build()
assert "build succeeded" in status.getvalue() # Build succeeded
warnings = warning.getvalue().strip()
assert warnings == ""

get_sphinx_app_doctree(
app,
docname="index",
regress=True,
regress_ext=".xml",
)
10 changes: 10 additions & 0 deletions tests/test_sphinx/test_sphinx_builds/test_toclist_extension.xml
@@ -0,0 +1,10 @@
<document source="index.md">
<section classes="tex2jax_ignore mathjax_ignore" ids="title" names="title">
<title>
Title
<compound classes="toctree-wrapper">
<toctree caption="True" entries="(None,\ 'page1') (None,\ 'https://example.com') ('title',\ 'https://example.com')" glob="False" hidden="False" includefiles="page1" includehidden="False" maxdepth="2" numbered="999" parent="index" rawentries="title" titlesonly="False">
<bullet_list bullet="-">
<list_item>
<paragraph>
A normal list