diff --git a/README.md b/README.md
index e3ff03f..06bec90 100644
--- a/README.md
+++ b/README.md
@@ -440,6 +440,21 @@ You can use `${variable}` syntax inside Brei tasks just as you would in a stand-
Entangled has been used successfully with the following document generators. Note that some of these examples were built using older versions of Entangled, but they should work just the same.
+### Weaving
+
+The `entangled weave` command transforms a Markdown document into Markdown that renders well in a documentation tool, by applying one or more filters to the code blocks:
+
+```shell
+entangled weave --list-filters # show available filters
+entangled weave docs/index.md # apply the `label` filter, print to stdout
+entangled weave -f title docs/index.md -o out/index.md # MkDocs/pymdownx style titles
+```
+
+- `label` wraps each named code block in a `
` with a label showing its name or target file. This renders in most Markdown previews (GitHub, Pandoc, Python-Markdown with `md_in_html`).
+- `title` adds a `title` attribute to the code block header, as understood by `pymdownx.superfences`.
+
+Filters can be combined by repeating `-f`; they are applied in the given order. The same transformation is available from Python through `entangled.weave.transform_markdown`, so that documentation tools only need a thin adapter.
+
### Pandoc
[Pandoc](https://pandoc.org/) is a very versatile tool for converting documents in any format. It specifically has very wide support for different forms of Markdown syntax out in the wild, including a filter system that lets you extend the workings of Pandoc. Those filters can be written in any language through an API, for instance Python filters can be written using `panflute`, but there is also native support for Lua.
diff --git a/docs/man-pages/english.md b/docs/man-pages/english.md
index d83d888..6c1a2b5 100644
--- a/docs/man-pages/english.md
+++ b/docs/man-pages/english.md
@@ -67,6 +67,10 @@ Each command has its own arguments and flags that can be inspected using `entang
: Keep a loop running, watching for changes in the file system.
+`weave [-f filter]... [-o output] [input]`
+
+: Transform Markdown for rendering by documentation tools. Use `--list-filters` to see available filters.
+
WRITING PROGRAMS
================
diff --git a/entangled/commands/__init__.py b/entangled/commands/__init__.py
index c230c6d..a4580a2 100644
--- a/entangled/commands/__init__.py
+++ b/entangled/commands/__init__.py
@@ -8,7 +8,8 @@
from .status import status
from .brei import brei
from .serve import serve
+from .weave import weave
__all__ = [
- "main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve"
+ "main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve", "weave"
]
diff --git a/entangled/commands/weave.py b/entangled/commands/weave.py
new file mode 100644
index 0000000..7d821af
--- /dev/null
+++ b/entangled/commands/weave.py
@@ -0,0 +1,50 @@
+from pathlib import Path, PurePath
+import sys
+
+import rich_click as click
+
+from .main import main
+
+from ..config import read_config
+from ..interface import Context
+from ..weave import filters, filter_description, transform_markdown
+
+
+def print_filters():
+ width = max(map(len, filters))
+ for name, f in filters.items():
+ print(f"{name:<{width}} {filter_description(f)}")
+
+
+@main.command()
+@click.argument("input", type=click.Path(dir_okay=False, allow_dash=True, path_type=Path),
+ default="-")
+@click.option("-o", "--output", type=click.Path(dir_okay=False, allow_dash=True, path_type=Path),
+ default="-", help="output file, defaults to standard output")
+@click.option("-f", "--filter", "filter_names", type=click.Choice(list(filters)), multiple=True,
+ help="filter to apply, can be repeated; defaults to `label`")
+@click.option("--list-filters", is_flag=True, help="list available filters and exit")
+def weave(*, input: Path, output: Path, filter_names: tuple[str, ...] = (),
+ list_filters: bool = False):
+ """Transform Markdown for rendering by documentation tools."""
+ if list_filters:
+ print_filters()
+ return
+
+ context = Context()
+ context |= read_config(context.fs)
+
+ if input == Path("-"):
+ text = sys.stdin.read()
+ filename = PurePath("-")
+ else:
+ text = input.read_text()
+ filename = input
+
+ selected = [filters[n] for n in (filter_names or ("label",))]
+ result = "".join(transform_markdown(context, text, *selected, filename=filename))
+
+ if output == Path("-"):
+ _ = sys.stdout.write(result)
+ else:
+ _ = output.write_text(result)
diff --git a/entangled/logging.py b/entangled/logging.py
index 9f43c5b..48ec806 100644
--- a/entangled/logging.py
+++ b/entangled/logging.py
@@ -20,7 +20,7 @@ def _render_buffer(self, *args, **kwargs):
return "\n".join(line.rstrip() for line in rendered.splitlines()) + newline_char
# Global rich console object
-console: Console = WhitespaceStrippingConsole()
+console: Console = WhitespaceStrippingConsole(stderr=True)
def logger():
return logging.getLogger("entangled")
diff --git a/entangled/weave/__init__.py b/entangled/weave/__init__.py
new file mode 100644
index 0000000..557bab6
--- /dev/null
+++ b/entangled/weave/__init__.py
@@ -0,0 +1,9 @@
+from .base import (
+ ContentFilter, CodeBlockFilter, codeblock_filter, compose_filters, iter_bind, transform_markdown
+)
+from .filters import filters, filter_description, block_title
+
+__all__ = [
+ "ContentFilter", "CodeBlockFilter", "codeblock_filter", "compose_filters", "iter_bind",
+ "transform_markdown", "filters", "filter_description", "block_title"
+]
diff --git a/entangled/weave/base.py b/entangled/weave/base.py
new file mode 100644
index 0000000..ee317c7
--- /dev/null
+++ b/entangled/weave/base.py
@@ -0,0 +1,66 @@
+"""
+Generic machinery for weaving: transforming the Markdown source of a page,
+code block by code block, into Markdown that renders well in some target.
+"""
+
+from collections.abc import Callable, Iterable, Iterator
+from functools import partial, reduce, wraps
+from itertools import chain
+from pathlib import PurePath
+
+from ..interface import Context, markdown
+from ..iterators import numbered_lines
+from ..model import Content, PlainText, ReferenceId, ReferenceMap, content_to_text
+
+
+type ContentFilter = Callable[[ReferenceMap, Content], Iterable[Content]]
+type CodeBlockFilter = Callable[[ReferenceMap, ReferenceId], Iterable[Content]]
+
+
+def codeblock_filter(f: CodeBlockFilter) -> ContentFilter:
+ """Lift a filter on code blocks to a filter on content, passing plain text
+ through unchanged."""
+ @wraps(f)
+ def _filter(rm: ReferenceMap, c: Content) -> Iterable[Content]:
+ match c:
+ case PlainText():
+ return [c]
+ case ReferenceId():
+ return f(rm, c)
+ return _filter
+
+
+def iter_bind[T, U](lst: Iterable[T], f: Callable[[T], Iterable[U]]) -> Iterator[U]:
+ return chain.from_iterable(map(f, lst))
+
+
+def compose_filter(a: ContentFilter, b: ContentFilter) -> ContentFilter:
+ def _joined(rm: ReferenceMap, c: Content) -> Iterable[Content]:
+ return iter_bind(a(rm, c), partial(b, rm))
+ return _joined
+
+
+def compose_filters(*args: ContentFilter) -> ContentFilter:
+ """Compose filters left to right: the first filter is applied first."""
+ identity: ContentFilter = lambda _, c: [c]
+ return reduce(compose_filter, args, identity)
+
+
+def transform_markdown(
+ context: Context,
+ text: str,
+ *filters: ContentFilter,
+ filename: PurePath = PurePath("-"),
+ reference_map: ReferenceMap | None = None,
+) -> Iterator[str]:
+ """
+ Read a Markdown document, pass its content through the given filters,
+ and yield the resulting Markdown text.
+
+ Filters may modify code blocks in the reference map in place. If no
+ `reference_map` is given, a fresh one is used for this document only.
+ """
+ refs = ReferenceMap() if reference_map is None else reference_map
+ content = markdown(context, refs, numbered_lines(filename, text))
+ for c in iter_bind(content, partial(compose_filters(*filters), refs)):
+ yield content_to_text(refs, c)[0]
diff --git a/entangled/weave/filters.py b/entangled/weave/filters.py
new file mode 100644
index 0000000..83842b0
--- /dev/null
+++ b/entangled/weave/filters.py
@@ -0,0 +1,89 @@
+"""
+Filters for `entangled weave`. Each filter is registered by name in `filters`;
+the first line of its docstring serves as description for `--list-filters`.
+"""
+
+from html import escape
+import re
+
+from ..model import CodeBlock, Content, PlainText, ReferenceId, ReferenceMap
+from ..model.properties import Attribute, Class, Id
+from .base import ContentFilter, codeblock_filter
+
+
+def block_title(code_block: CodeBlock) -> str | None:
+ """Compute a human readable title for a code block from its id and `file`
+ attribute. Returns `None` for anonymous code blocks."""
+ ids = [p.value for p in code_block.properties if isinstance(p, Id)]
+ filenames = [p.value for p in code_block.properties # pyright: ignore[reportAny]
+ if isinstance(p, Attribute) and p.key == "file"]
+
+ if len(ids) > 1 or len(filenames) > 1:
+ return "error: ambiguous code block title"
+ if ids and filenames:
+ return f"#{ids[0]} / file: {filenames[0]}"
+ if ids:
+ return f"#{ids[0]}"
+ if filenames:
+ return f"file: {filenames[0]}"
+ return None
+
+
+def first_line(text: str) -> tuple[str, str]:
+ head, nl, rest = text.partition("\n")
+ return head, nl + rest
+
+
+@codeblock_filter
+def title(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
+ """Add a `title` attribute to the code block header (MkDocs/pymdownx style)."""
+ code_block = reference_map[r]
+ text = block_title(code_block)
+ ids = [p.value for p in code_block.properties if isinstance(p, Id)]
+ classes = [p.value for p in code_block.properties if isinstance(p, Class)]
+
+ head, rest = first_line(code_block.open_line)
+ fence_match = re.match(r"\s*(`{3,}|~{3,})", head)
+ fence = fence_match.group(1) if fence_match else "```"
+
+ attrs: list[str] = []
+ if ids:
+ attrs.append(str(Id(ids[0])))
+ attrs.extend(str(Class(c)) for c in classes[1:])
+ if text:
+ attrs.append(str(Attribute("title", text)))
+
+ head = fence + (classes[0] if classes else "")
+ if attrs:
+ head += " {" + " ".join(attrs) + "}"
+
+ code_block.open_line = head + (rest or "\n")
+ return [r]
+
+
+@codeblock_filter
+def label(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
+ """Wrap code blocks in a `
` with a label; renders in most Markdown previews."""
+ code_block = reference_map[r]
+ text = block_title(code_block)
+ if text is None:
+ return [r]
+
+ ind = code_block.indent
+ header = (f"{ind}
\n"
+ f"{ind}
{escape(text)}
\n\n")
+ # The closing fence may lack a newline at the end of a file. The blank
+ # lines around `
` end the HTML block in CommonMark.
+ eol = "" if code_block.close_line.endswith("\n") else "\n"
+ footer = f"{eol}\n{ind}
\n\n"
+ return [PlainText(header), r, PlainText(footer)]
+
+
+filters: dict[str, ContentFilter] = {
+ "label": label,
+ "title": title,
+}
+
+
+def filter_description(f: ContentFilter) -> str:
+ return (f.__doc__ or "").strip().partition("\n")[0]
diff --git a/man/entangled.1 b/man/entangled.1
index f830bf4..9b72c05 100644
--- a/man/entangled.1
+++ b/man/entangled.1
@@ -51,6 +51,10 @@ Tangle codes from the documentation.
.TP
\f[CR]watch\f[R]
Keep a loop running, watching for changes in the file system.
+.TP
+\f[CR]weave [\-f filter]... [\-o output] [input]\f[R]
+Transform Markdown for rendering by documentation tools.
+Use \f[CR]\-\-list\-filters\f[R] to see available filters.
.SH WRITING PROGRAMS
Entangled enables a \f[B]documentation first\f[R] approach to
programming.
diff --git a/test/weave/test_weave.py b/test/weave/test_weave.py
new file mode 100644
index 0000000..8fb9cb9
--- /dev/null
+++ b/test/weave/test_weave.py
@@ -0,0 +1,75 @@
+from pathlib import Path
+from contextlib import chdir
+
+from entangled.commands import main
+from entangled.interface import Context
+from entangled.weave import filters, transform_markdown
+
+import pytest
+
+
+md_in = """# Example
+
+``` {.python file=hello.py}
+<
>
+```
+
+``` {.python #main .extra}
+print("hello")
+```
+
+``` {.python}
+anonymous = True
+```
+"""
+
+
+def weave(text: str, *names: str) -> str:
+ return "".join(transform_markdown(Context(), text, *(filters[n] for n in names)))
+
+
+def test_identity():
+ assert weave(md_in) == md_in
+
+
+def test_title():
+ out = weave(md_in, "title")
+ assert '```python {title="file: hello.py"}\n' in out
+ assert '```python {#main .extra title="#main"}\n' in out
+ assert "```python\nanonymous = True" in out
+ assert "<>" in out
+
+
+def test_label():
+ out = weave(md_in, "label")
+ assert out.count('') == 2
+ assert 'file: hello.py' in out
+ assert '#main' in out
+ assert "\n\n``` {.python file=hello.py}\n<>\n```\n\n
\n\n" in out
+ assert "\n\n``` {.python}\nanonymous = True\n```\n" in out
+
+
+def test_label_indented_without_trailing_newline():
+ text = "- item\n\n ``` {.python #a}\n x = 1\n ```"
+ out = weave(text, "label")
+ assert out.endswith(" ```\n\n \n\n")
+ assert " \n" in out
+
+
+def test_label_and_title_compose():
+ out = weave(md_in, "label", "title")
+ assert '#main
\n\n```python {#main .extra title="#main"}\n' in out
+
+
+def test_weave_command(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
+ with chdir(tmp_path):
+ Path("input.md").write_text(md_in)
+ main(args=["weave", "-f", "title", "-o", "output.md", "input.md"], standalone_mode=False)
+ assert '```python {title="file: hello.py"}' in Path("output.md").read_text()
+
+ main(args=["weave", "input.md"], standalone_mode=False)
+ assert '