Skip to content

Commit

Permalink
feat: Add idprefix option allowing to change/remove HTML id/href pr…
Browse files Browse the repository at this point in the history
…efixes
  • Loading branch information
pawamoy committed Apr 18, 2023
1 parent 26617cb commit 4d91463
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 18 deletions.
46 changes: 45 additions & 1 deletion docs/usage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ linking to their related documentation:

- [`exec`](#usage): The mother of all other options, enabling code execution.
- [`html`](#html-vs-markdown): Whether the output is alredady HTML, or needs to be converted from Markdown to HTML.
- [`id`](#handling-errors): Give an identifier to your code blocks to help debugging errors.
- [`id`](#handling-errors): Give an identifier to your code blocks to help
[debugging errors](#handling-errors), or to [prefix HTML ids](#html-ids).
- [`idprefix`](#html-ids): Change or remove the prefix in front of HTML ids/hrefs.
- [`result`](#wrap-result-in-a-code-block): Choose the syntax highlight of your code block output.
- [`returncode`](./shell/#expecting-a-non-zero-exit-code): Tell what return code is expected (shell code).
- [`session`](#sessions): Execute code blocks within a named session, reusing previously defined variables, etc..
Expand Down Expand Up @@ -72,6 +74,48 @@ print("#### S heading\n")
```
````

## HTML ids

When your executed code blocks output Markdown,
this Markdown is rendered to HTML, and every HTML id
is automatically prefixed with `exec-N--`, where N
is an integer incremented with each code block.
To avoid breaking links, every `href` attribute
is also updated when relevant.

You can change this prefix, or completely remove it
with the `idprefix` option.

The following ids are not prefixed:

````md exec="1" source="material-block"
```python exec="1" idprefix="" updatetoc="no"
print("#### Commands")
print("\n[link to commands](#commands)")
```
````

The following ids are prefixed with `cli-`:

````md exec="1" source="material-block"
```python exec="1" idprefix="cli-" updatetoc="no"
print("#### Commands")
print("\n[link to commands](#commands)")
```
````

If `idprefix` is not specified, and `id` is specified,
then the id is used as prefix:

The following ids are prefixed with `super-cli-`:

````md exec="1" source="material-block"
```python exec="1" id="super-cli" updatetoc="no"
print("#### Commands")
print("\n[link to commands](#commands)")
```
````

## Render the source code as well

It's possible to render both the result of the executed code block
Expand Down
2 changes: 2 additions & 0 deletions src/markdown_exec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def validator(
if language != "tree" and not exec_value:
return False
id_value = inputs.pop("id", "")
id_prefix_value = inputs.pop("idprefix", None)
html_value = _to_bool(inputs.pop("html", "no"))
source_value = inputs.pop("source", "")
result_value = inputs.pop("result", "")
Expand All @@ -74,6 +75,7 @@ def validator(
tabs_value = inputs.pop("tabs", "|".join(default_tabs))
tabs = tuple(_tabs_re.split(tabs_value, maxsplit=1))
options["id"] = id_value
options["id_prefix"] = id_prefix_value
options["html"] = html_value
options["source"] = source_value
options["result"] = result_value
Expand Down
5 changes: 4 additions & 1 deletion src/markdown_exec/formatters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def base_format(
result: str = "",
tabs: tuple[str, str] = default_tabs,
id: str = "", # noqa: A002
id_prefix: str | None = None,
returncode: int = 0,
transform_source: Callable[[str], tuple[str, str]] | None = None,
session: str | None = None,
Expand All @@ -68,6 +69,7 @@ def base_format(
result: If provided, use as language to format result in a code block.
tabs: Titles of tabs (if used).
id: An optional ID for the code block (useful when warning about errors).
id_prefix: A string used to prefix HTML ids in the generated HTML.
returncode: The expected exit code.
transform_source: An optional callable that returns transformed versions of the source.
The input source is the one that is ran, the output source is the one that is
Expand Down Expand Up @@ -130,4 +132,5 @@ def base_format(
result=result,
**extra,
)
return markdown.convert(wrapped_output)
prefix = id_prefix if id_prefix is not None else (f"{id}-" if id else None)
return markdown.convert(wrapped_output, id_prefix=prefix)
37 changes: 21 additions & 16 deletions src/markdown_exec/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from __future__ import annotations

from contextlib import contextmanager
from functools import lru_cache
from textwrap import indent
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Iterator

from markdown import Markdown
from markupsafe import Markup
Expand Down Expand Up @@ -205,6 +206,17 @@ def _mimic(md: Markdown, headings: list[Element], *, update_toc: bool = True) ->
return new_md


@contextmanager
def _id_prefix(md: Markdown, prefix: str | None) -> Iterator[None]:
MarkdownConverter.counter += 1
id_prepending_processor = md.treeprocessors[IdPrependingTreeprocessor.name]
id_prepending_processor.id_prefix = prefix if prefix is not None else f"exec-{MarkdownConverter.counter}--"
try:
yield
finally:
id_prepending_processor.id_prefix = ""


class MarkdownConverter:
"""Helper class to avoid breaking the original Markdown instance state."""

Expand All @@ -219,7 +231,11 @@ def __init__(self, md: Markdown, *, update_toc: bool = True) -> None: # noqa: D
def _original_md(self) -> Markdown:
return getattr(self._md_ref, "_original_md", self._md_ref)

def convert(self, text: str, stash: dict[str, str] | None = None) -> Markup:
def _report_headings(self, markup: Markup) -> None:
self._original_md.treeprocessors[InsertHeadings.name].headings[markup] = self._headings
self._headings = []

def convert(self, text: str, stash: dict[str, str] | None = None, id_prefix: str | None = None) -> Markup:
"""Convert Markdown text to safe HTML.
Parameters:
Expand All @@ -231,14 +247,9 @@ def convert(self, text: str, stash: dict[str, str] | None = None) -> Markup:
"""
md = _mimic(self._original_md, self._headings, update_toc=self._update_toc)

# prepare for conversion
md.treeprocessors[IdPrependingTreeprocessor.name].id_prefix = f"exec-{MarkdownConverter.counter}--"
MarkdownConverter.counter += 1

try:
# convert markdown to html
with _id_prefix(md, id_prefix):
converted = md.convert(text)
finally:
md.treeprocessors[IdPrependingTreeprocessor.name].id_prefix = ""

# restore html from stash
for placeholder, stashed in (stash or {}).items():
Expand All @@ -248,12 +259,6 @@ def convert(self, text: str, stash: dict[str, str] | None = None) -> Markup:

# pass headings to upstream conversion layer
if self._update_toc:
self._original_md.treeprocessors[InsertHeadings.name].headings[markup] = self.headings
self._report_headings(markup)

return markup

@property
def headings(self) -> list[Element]: # noqa: D102
headings = self._headings
self._headings = []
return headings
38 changes: 38 additions & 0 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

from __future__ import annotations

import re
from textwrap import dedent
from typing import TYPE_CHECKING

import pytest
from markdown.extensions.toc import TocExtension

from markdown_exec.rendering import MarkdownConfig, markdown_config

if TYPE_CHECKING:
Expand Down Expand Up @@ -36,3 +40,37 @@ def test_instantiating_config_singleton() -> None:
assert MarkdownConfig() is markdown_config
markdown_config.save([], {})
markdown_config.reset()


@pytest.mark.parametrize(
("id", "id_prefix", "expected"),
[
("", None, 'id="exec-\\d+--heading"'),
("", "", 'id="heading"'),
("", "some-prefix-", 'id="some-prefix-heading"'),
("some-id", None, 'id="some-id-heading"'),
("some-id", "", 'id="heading"'),
("some-id", "some-prefix-", 'id="some-prefix-heading"'),
],
)
def test_prefixing_headings(md: Markdown, id: str, id_prefix: str | None, expected: str) -> None: # noqa: A002
"""Assert that we prefix headings as specified.
Parameters:
md: A Markdown instance (fixture).
id: The code block id.
id_prefix: The code block id prefix.
expected: The id we expect to find in the HTML.
"""
TocExtension().extendMarkdown(md)
prefix = f'idprefix="{id_prefix}"' if id_prefix is not None else ""
html = md.convert(
dedent(
f"""
```python exec="1" id="{id}" {prefix}
print("# HEADING")
```
""",
),
)
assert re.search(expected, html)

0 comments on commit 4d91463

Please sign in to comment.