Literate Programming
Write prose and code intermixed. This works great for scientific software, tutorials, or any other software.
Bootstrap
Generate pretty Bootstrap websites. Your Markdown is translated to a snappy responsive website.
Jupyter
Use Jupyter to evaluate code snippets on the fly. Great for writing documentation.
This is a set of Pandoc filters for literate programming written in Python. These filters are part of Entangled. They constitute a complete workflow for literate programming on them selves, but it is advised to use them in conjunction with Entangled.
In particular, while you can tangle source files with the
entangled.tangle
Python module, it is much more convenient
to use the entangled
executable, which automatically
tangles files upon saving the Markdown files, and more importantly, also
does the reverse, keeping your files continuously in sync.
This module also acts as a test-bed and environment for rapid prototyping of features that may end up in the main (Haskell based part of) Entangled distribution. Currently we have:
tangle
, extract source files from embedded code blocks in Markdown.annotate
, add name tags to code blocks.doctest
, run documentation tests through Jupyter.bootstrap
, expand some elements into Bootstrap widgets.
Version
«pandoc_entangled/__init__.py»=
from importlib import metadata
= metadata.version("pandoc-entangled") __version__
Demo
This page uses the very same pandoc filters it implements. On the top you see a rendering of a Bootstrap card-deck. This deck is generated from a code block containing the Dhall description of the content.
Sometimes, when you write literate code it cannot be avoided that you have to implement some boring stuff that doesn’t warrant a detailed explanation. In this case you can collapse a code block behind a button.
import numpy as np
def compute_pi(sample_size):
= np.random.uniform(0, 1, size=[sample_size, 2])
points = np.count_nonzero((points**2).sum(axis=1) <= 1.0)
within_circle return 4 * within_circle / sample_size
- If you’re documenting a library you may want to have code blocks evaluated inline.
«compute-pi»=
print("π ≈ {}".format(compute_pi(1000000)))
Readme – Entangled, Pandoc filters
This contains several Pandoc filters and scripts for literate programming in Markdown. These filters are enough to get you going with literate programming using Pandoc.
Filter | Function |
---|---|
pandoc-annotate-codeblocks |
Adds annotation to code blocks in the woven output. |
pandoc-doctest |
Runs doc-tests by passing the content of code blocks through Jupyter. |
pandoc-tangle |
Generate source files from the content of code blocks. |
pandoc-bootstrap |
Expand some elements specifically targeting a Bootstrap page. |
Install
Entangled filters has the following prerequisites:
- Python >=3.7: All of these filters are written in Python. This is mainly to encourage as many users (I mean YOU) to start developing Pandoc filters.
- Dhall: the
pandoc-bootstrap
filter requiresdhall-to-json
to be installed: see Dhall language. TLDR: downloaddhall-json-*-[windows|macos|linux].[zip|tar.bz2]
from the Dhall release page, and extract it to a location in your$PATH
. Dhall is awesome, it will make your life better.
Installation is easiest using pip
,
pip install entangled-filters
For development
To run tests, after doing a normal install (to get the executables installed), run
pip install --upgrade -e .[test]
pytest
The executables are auto-generated by the setup.py
script and call some python -m
command.
Supported syntax
See the project homepage for more info.
Named code blocks
``` {.python #hello}
print("Hello, World!")
```
Reference code blocks
``` {.python #main}
def main():
<<hello>>
```
Define files
``` {.python file=hello.py}
<<main>>
if __name__ == "__main__":
main()
```
Documentation tests
``` {.python .doctest #the-question}
6*7
---
42
```
pandoc-tangle
Extracts code blocks and writes them to files.
pandoc -t plain --filter pandoc-tangle hello.md
pandoc-annotate-codeblocks
Annotates code blocks in generated HTML or PDF output with name tags.
pandoc -t html5 -s --filter pandoc-anotate-codeblocks hello.md
pandoc-doctest
Runs doctests, and include results into output. Also annotates the
code blocks (so no need to run
pandoc-annotate-codeblocks
).
pandoc -t html5 -s --filter pandoc-doctest hello.md
pandoc-bootstrap
Also annotates code blocks, and has two features:
Expand a Dhall specification into a card deck for Bootstrap, that is a flex-box with a single row and several columns of cards. This is nice to have at the top of a page to draw attention to some key points.
Collapsible/foldable code blocks. Add a
.bootstrap-fold
class to a code block to have the code block hidden behind a button. This is nice for some larger uninteresting code.
This filter should be used together with a Bootstrap template for Pandoc. An example of its use can be seen here: Chaotic Pendulum, with the source code at gh:jhidding/chaotic-pendulum.
Docker
The Entangled pandoc filters is available as a Docker image.
Run
In your current working directory with a README.md file run
docker run --rm -ti --user $UID -v $PWD:/data nlesc/pandoc-tangle README.md
This will extracts code blocks and writes them to files.
Build
docker build -t nlesc/pandoc-tangle .
Config
Configuration of the entangled
module is done through a
file that should sit in the current directory.
As a configuration format we use Dhall, with a
fall-back to JSON. The file should be named either
entangled.dhall
or entangled.json
. For the
Dhall based configuration to work, you need to have the
dhall-to-json
executable installed.
«pandoc_entangled/config.py»=
from .typing import (JSONType)
import subprocess
import json
import sys
def read_config() -> JSONType:
"""Reads config from `entangled.dhall` with fall-back to `entangled.json`."""
try:
= subprocess.run(
result "dhall-to-json", "--file", "entangled.dhall"],
[=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', check=True)
stdoutreturn json.loads(result.stdout)
except subprocess.CalledProcessError as e:
print("Error reading `entangled.dhall`:\n" + e.stderr, file=sys.stderr)
except FileNotFoundError:
print("Warning: could not find `dhall-to-json`, trying to read JSON instead.",
file=sys.stderr)
return json.load(open("entangled.json", "r"))
def get_language_info(config: JSONType, identifier: str) -> JSONType:
= { k["language"]: k["kernel"] for k in config["jupyter"] }
kernels
try:
= next(lang for lang in config["entangled"]["languages"]
language if identifier in lang["identifiers"])
except StopIteration:
raise ValueError(f"Language with identifier `{identifier}` not found in config.")
return {"jupyter": kernels.get(language["name"]), **language}
Panflute
Panflute reads JSON from standard input, lets you apply filters to an
intermediate object based representation and writes back to JSON. The
actual filtering is done by a Panflute Action
. The action
takes an Element
and a Document
as argument
and can return one of three things:
None
, leaves the element unchangedElement
, replaces the elementList[Element]
, splices the list into list that contained the element
These are some types that we can use for type annotations.
«pandoc_entangled/typing.py»=
from typing import (Union, List, Dict, Callable, Any)
from panflute import (Element, Doc, CodeBlock)
= Union[Element, List[Element], None]
ActionReturn = Callable[[Element, Doc], ActionReturn]
Action = Dict[str, List[CodeBlock]]
CodeMap = Any JSONType
Tangle
The global structure of a filter in panflute
runs
run_filter
from a main
function. We’ll keep a
global registry of all code-blocks entered. In panflute
a
global variable is passed on top of the doc
parameter that
is passed to all involved functions.
«pandoc_entangled/tangle.py»=
from panflute import (run_filter, Doc, Element, CodeBlock)
from typing import (Optional, Dict, Callable)
from .typing import (CodeMap)
import sys
<<get-code-block>>
<<tangle-prepare>>
<<tangle-action>>
<<tangle-finalize>>
def main(doc: Optional[Doc] = None) -> None:
run_filter(=prepare, finalize=finalize, doc=doc) action, prepare
We prepare a global variable doc.codes
with a
defaultdict
for empty lists.
«tangle-prepare»=
from collections import defaultdict
def prepare(doc: Doc) -> None:
= defaultdict(list) doc.code_map
In the action, we store whatever code block we can find a name for.
«tangle-action»=
<<get-name>>
def action(elem: Element, doc: Doc) -> None:
if isinstance(elem, CodeBlock):
= get_name(elem)
name if name:
doc.code_map[name].append(elem)
In the finalisation we need to expand code blocks, and run those
blocks that are marked as .doctest
.
Getting a name
If the code block contains an identifier, that is used as the name.
Alternatively, if a the code-block has an attribute
file=...
, the given filename is used as a name.
«get-name»=
def get_name(elem: Element) -> Optional[str]:
if elem.identifier:
return elem.identifier
if "file" in elem.attributes:
return elem.attributes["file"]
return None
Expand code references
To expand code references we match a regular expression with a reference line. Every reference is then replaced with the expanded code block matching it. This is implemented as a doubly recursive function.
«get-code-block»=
import re
<<replace-expr>>
def get_code(code_map: CodeMap, name: str) -> str:
<<expand>>
<<look-up>>
return look_up(name=name, prefix="")
def expand_code_block(code_map: CodeMap, code_block: CodeBlock) -> str:
<<expand>>
<<look-up>>
return expand(code_block)
The replace_expr
function does one of two things. If the
input is not a match, the input is returned unchanged. If it is a match,
all named sub-matches are taken out and given as keyword argument to the
given replace
function, the result of which is
returned.
«replace-expr»=
def replace_expr(expr: str, replace: Callable[..., str], text: str) -> str:
"""Matches (fullmatch) `text` using the expression `expr`. If the expression
matches, then returns the result of passing named sub-matches as
keyword arguments to `replace`. Returns `text` otherwise."""
= re.fullmatch(expr, text)
match if match:
return replace(**match.groupdict())
else:
return text
In our case the replace
function is called
look_up
; it looks up the given name and indents the result
with a given prefix.
«look-up»=
from textwrap import indent
def look_up(*, name: str, prefix: str) -> str:
= code_map[name]
blocks if not blocks:
raise ValueError(f"No code with name `{name}` found.")
= "\n".join(expand(code) for code in blocks)
result return indent(result, prefix)
The function expand
takes a CodeBlock
object and expands all references using the given regex. Decomposing
this particular regex:
(?P<prefix>[ \t]*)
matches the indentation, either tabs or spaces.<<(?P<name>[^ >]*)>>
matches the named reference, surrounded by<<...>>
.\Z
matches the end of input.
«expand»=
def expand(code: CodeBlock) -> str:
= "(?P<prefix>[ \t]*)<<(?P<name>[^ >]*)>>\\Z"
pattern return "\n".join(
replace_expr(pattern, look_up, line)for line in code.text.splitlines())
Together, expand
and look_up
form a doubly
recursive pair of functions, evaluating the contents of any code block
found in the input.
Finalize
To finalize, we write out all files that we can find.
«tangle-finalize»=
def get_file_map(code_map: CodeMap) -> Dict[str, str]:
"""Extracts all file references from `code_map`."""
return { code[0].attributes["file"]: codename
for codename, code in code_map.items()
if "file" in code[0].attributes }
Only files that are different from those on disk should be overwritten.
«tangle-finalize»+
def write_file(filename: str, text: str) -> None:
"""Writes `text` to file `filename`, only if `text` is different
from contents of `filename`."""
try:
= open(filename).read()
content if content == text:
return
except FileNotFoundError:
pass
print(f"Writing `{filename}`.", file=sys.stderr)
open(filename, 'w').write(text)
def finalize(doc: Doc) -> None:
"""Writes all file references found in `doc.code_map` to disk.
This only overwrites a file if the content is different."""
= get_file_map(doc.code_map)
file_map for filename, codename in file_map.items():
write_file(filename, get_code(doc.code_map, codename))= [] doc.content
Code block annotation
This adds the name of a code block to the output.
«pandoc_entangled/annotate.py»=
from collections import defaultdict
from .tangle import get_name
from panflute import (Span, Str, Para, CodeBlock, Div, Emph, Doc, run_filter)
from typing import (Optional)
def prepare(doc):
= defaultdict(lambda: 0)
doc.code_count
def action(elem, doc):
if isinstance(elem, CodeBlock):
= get_name(elem)
name if name is None:
return
if doc.code_count[name] == 0:
= Span(Emph(Str(f"«{name}»=")))
label += 1
doc.code_count[name] else:
= Span(Emph(Str(f"«{name}»+")))
label return Div(Para(label), elem, classes=["annotated-code"])
def main(doc: Optional[Doc] = None) -> None:
return run_filter(action, prepare=prepare)
Doctesting
This Pandoc filter runs doc-tests from Python. If a cell is marked
with a .doctest
class, the output is checked against the
given output. We use Jupyter kernels to evaluate the input.
«pandoc_entangled/doctest.py»=
from panflute import (Doc, Element, CodeBlock)
from ansi2html import Ansi2HTMLConverter
from .typing import (ActionReturn, JSONType, CodeMap)
from .tangle import (get_name, expand_code_block)
from .config import get_language_info
from collections import defaultdict
import sys
<<doctest-suite>>
<<get-doc-tests>>
<<doctest-report>>
<<doctest-run-suite>>
def prepare(doc: Doc) -> None:
assert hasattr(doc, "config"), "Need to read config first."
assert hasattr(doc, "code_map"), "Need to tangle first."
= get_doc_tests(doc.code_map)
doc.suites for name, suite in doc.suites.items():
run_suite(doc.config, suite)= defaultdict(lambda: 0)
doc.code_counter
def action(elem: Element, doc: Doc) -> ActionReturn:
if isinstance(elem, CodeBlock):
= get_name(elem)
name if "doctest" in elem.classes or "eval" in elem.classes:
= doc.suites[name].code_blocks[doc.code_counter[name]]
test += 1
doc.code_counter[name] return generate_report(elem, test)
+= 1
doc.code_counter[name] return None
Get doc tests from code map
«get-doc-tests»=
def get_language(c: CodeBlock) -> str:
if not c.classes:
raise ValueError(f"Code block `{c.name}` has no language specified.")
return c.classes[0]
def get_doc_tests(code_map: CodeMap) -> Dict[str, Suite]:
def convert_code_block(c: CodeBlock) -> Test:
= get_name(c)
name = expand_code_block(code_map, c)
code if "doctest" in c.classes:
= code.split("\n---\n")
s if len(s) != 2:
raise ValueError(f"Doc test `{name}` should have single `---` line.")
return Test(s[0], s[1])
else:
return Test(code, None)
= {}
result for k, v in code_map.items():
if any("doctest" in c.classes or "eval" in c.classes for c in v):
= Suite(
result[k] =[convert_code_block(c) for c in v],
code_blocks=get_language(v[0]))
language
return result
Test Suite
Every code block that is part of a .doctest
suite will
be stored in a Test
object, even if it is not a doc-test
block itself. The default status
of a test is
PENDING
.
A test may succeed, fail, throw an error or return an unknown result
(other than text/plain
).
«doctest-suite»=
from dataclasses import dataclass
from typing import (Optional, List, Dict)
from enum import Enum
class TestStatus(Enum):
= 0
PENDING = 1
SUCCESS = 2
FAIL = 3
ERROR = 4 UNKNOWN
«doctest-suite»+
@dataclass
class Test:
= False # not a pytest class
__test__ str
code: str]
expect: Optional[str] = None
result: Optional[str] = None
error: Optional[= TestStatus.PENDING status: TestStatus
A suite is just a list of Test
s with some meta-data
attached.
«doctest-suite»+
@dataclass
class Suite:
code_blocks: List[Test]str language:
Evaluation
We use jupyter_client
to communicate with the REPL in
question.
«doctest-run-suite»=
import jupyter_client
import queue
def run_suite(config: JSONType, s: Suite) -> None:
<<jupyter-get-kernel-name>>
with jupyter_client.run_kernel(kernel_name=kernel_name) as kc:
print(f"Kernel `{kernel_name}` running ...", file=sys.stderr)
<<jupyter-eval-test>>
for test in s.code_blocks:
jupyter_eval(test)if test.status is TestStatus.ERROR:
break
Jupyter
The configuration should have a Jupyter kernel name stored for the language.
«jupyter-get-kernel-name»=
= get_language_info(config, s.language)
info = info["jupyter"] if "jupyter" in info else None
kernel_name if not kernel_name:
raise RuntimeError(f"No Jupyter kernel known for the {s.language} language.")
= jupyter_client.kernelspec.find_kernel_specs()
specs if kernel_name not in specs:
raise RuntimeError(f"Jupyter kernel `{kernel_name}` not installed.")
After sending the test to the Jupyter kernel, we need to retrieve the
result. To match the JSON records, we use pampy
, making the
following code a lot cleaner.
«jupyter-eval-test»=
def jupyter_eval(test: Test):
= kc.execute(test.code)
msg_id while True:
try:
= kc.get_iopub_msg(timeout=1000)
msg if handle(test, msg_id, msg):
return
except queue.Empty:
= "Operation timed out."
test.error = TestStatus.ERROR
test.status return
The handle
function is a pattern matcher. Each pattern
looks like a dictionary, but may contain one or more _
symbols. The contents of the matching dictionary at the _
symbols are passed to the function following the pattern.
«jupyter-eval-test»+
def handle(test, msg_id, msg):
from pampy import match, _
def print_unknown_msg(data):
import sys
print(data, file=sys.stderr)
return False
<<jupyter-handlers>>
return match(msg
<<jupyter-match>>
)
execute_result
A result is tested for equality with the expected result.
«jupyter-match»=
"msg_type": "execute_result"
, { "parent_header": { "msg_id" : msg_id }
, "content": { "data" : { "text/plain": _ } } }
, , execute_result_text
«jupyter-handlers»=
def execute_result_text(data):
= test.result or ""
test.result if data is not None:
+= str(data)
test.result if (test.expect is None) or test.result.strip() == test.expect.strip():
= TestStatus.SUCCESS
test.status else:
= TestStatus.FAIL
test.status return False
output to stdout or stderr
«jupyter-match»+
"msg_type": "stream"
, { "parent_header": { "msg_id" : msg_id }
, "content": { "text": _ } }
, , stream_text
«jupyter-handlers»+
def stream_text(data):
= test.result or ""
test.result += data
test.result return False
display data
«jupyter-match»+
"msg_type": "display_data"
, { "parent_header": { "msg_id" : msg_id }
, "content": { "data": { "text/plain": _ } } }
, , stream_text
status
If status idle
is given, the computation is done, and we
don’t need to wait for further messages.
«jupyter-match»+
"msg_type": "status"
, { "parent_header": { "msg_id" : msg_id }
, "content": { "execution_state": "idle" } }
, , status_idle
«jupyter-handlers»+
def status_idle(_):
if test.expect is None:
= TestStatus.SUCCESS
test.status elif test.status == TestStatus.PENDING:
= TestStatus.FAIL
test.status return True
error
If an error is given, we set the appropriate flags in
test
and stop further testing in this session.
«jupyter-match»+
"msg_type": "error"
, { "parent_header": { "msg_id" : msg_id }
, "content": { "traceback": _ } }
, , error_traceback
«jupyter-handlers»+
def error_traceback(tb):
= "\n".join(msg["content"]["traceback"])
test.error = TestStatus.ERROR
test.status return True
otherwise
Any other message we ignore and wait for further messages.
«jupyter-match»+
, _lambda x: False ,
Generate report
The generic output of a documentation test, in HTML, should look something like:
<div class="doctest" data-status="STATUS">
<div class="doctestInput"><...></div>
<div class="doctestResult"><...></div>
</div>
To create the outer div
we have a helper function.
«doctest-content-div»=
def content_div(*output):
= {"status": t.status.name}
status_attr = elem.text.split("\n---\n")
code = Div(CodeBlock(
input_code 0], identifier=elem.identifier,
code[=elem.classes), classes=["doctestInput"])
classesreturn Div(input_code, *output, classes=["doctest"], attributes=status_attr)
Then the generate_report
function transforms a
CodeBlock
as follows.
«doctest-report»=
from panflute import Div, RawBlock
def generate_report(elem: CodeBlock, t: Test) -> ActionReturn:
= Ansi2HTMLConverter(inline=True)
conv def to_raw(txt):
return Div(
RawBlock('<pre class="ansi2html-content">'
+ conv.convert(txt, full=False)
+ '</pre>', format="html"),
=["programOutput"])
classes<<doctest-content-div>>
if t.status is TestStatus.ERROR:
return content_div( Div( to_raw(t.error)
=["doctestError"] ) )
, classesif t.status is TestStatus.FAIL:
return content_div( Div( to_raw(t.result)
=["doctestResult"] )
, classes
, Div( to_raw(t.expect)=["doctestExpect"] ) )
, classesif t.status is TestStatus.SUCCESS:
return content_div( Div( to_raw(t.result)
=["doctestResult"] ) )
, classesif t.status is TestStatus.PENDING:
return content_div()
if t.status is TestStatus.UNKNOWN:
return content_div( Div( to_raw(t.result)
=["doctestUnknown"] ) )
, classesreturn None
Main
This module reuses most of the tangle module.
«pandoc_entangled/doctest_main.py»=
import panflute
from . import tangle
from . import annotate
from . import doctest
from .config import read_config
def main() -> None:
<<load-document>>
= read_config()
doc.config
tangle.prepare(doc)= doc.walk(tangle.action)
doc
annotate.prepare(doc)# doc = doc.walk(annotate.action)
doctest.prepare(doc)= doc.walk(doctest.action)
doc
panflute.dump(doc)
Bug in panflute
or jupyter_client
There is a bug in jupyter_client
that prevents it from
working when either stdin
or stdout
is closed.
This means that we have to read the input seperately.
«load-document»=
import io
import sys
= sys.stdin.read()
json_input = io.StringIO(json_input)
json_stream = panflute.load(json_stream) doc
Bootstrap
The pandoc-bootstrap
filter enables content generation
for Bootstrap 4. This has the following features:
- Generate a card deck from a Yaml description in a code block.
- Create collapsible code cells to hide boilerplate or othewise boring code.
Card deck
«pandoc_entangled/schema/Card.dhall»=
let Link =
{ href : Text
, content : Text
}
let Location = < Top | Right | Bottom | Left >
in let Card =
{ Type =
{ image : Optional Text
, title : Text
, text : Text
, link : Optional Link
, imageLocation : Location }
, default =
{ image = None Text
, link = None Link
, imageLocation = Location.Top }
}
in Card
«test/card-deck-example.dhall»=
let Card = ./schema/Card.dhall
in [ Card :: { title = "Literate Programming"
, text =
''
Write prose and code intermixed. Not just some choice snippets: **all code is included!**
This document is a rendering of a completely **self-contained Markdown** file.
''
, link = Some { href = "http://entangled.github.io/"
, content = "About Entangled" } } ]
Should generate something like:
<div class="container-fluid"><div class="row">
<div class="col"><div class="card h-100">
<div class="card-body">
<h3 class="card-title">Literate Programming</h3>
<p class="card-text">Write prose and code intermixed. Not just some choice snippets: **all code is included!**
</p>
This document is a rendering of a completely **self-contained Markdown** file.</div>
<a href="https://entangled.github.io/" class="btn btn-primary mt-auto mx-4">About Entangled</a>
</div></div>
...</div></div>
«pandoc_entangled/bootstrap.py»=
from panflute import (Element, Doc, Plain, CodeBlock, Div, Str, Image, Header,
Link, convert_text, run_filters, RawBlock, Space, LineBreak, MetaInlines)from typing import (Optional)
from pathlib import (Path)
import subprocess
import pkg_resources
import json
from .typing import (JSONType)
from .tangle import get_name
from . import annotate
= Path(pkg_resources.resource_filename(__name__, "."))
data_path
def parse_dhall(content: str, cwd: Optional[Path] = None) -> JSONType:
"""Takes Dhall content and parses it to JSON compatible data."""
= cwd or Path(".")
cwd = subprocess.run(
result "dhall-to-json"], cwd=cwd,
[input=content, stdout=subprocess.PIPE,
=subprocess.PIPE, encoding="utf-8", check=True)
stderrreturn json.loads(result.stdout)
<<bootstrap-card-deck>>
<<bootstrap-fold-code-block>>
def prepare(doc: Doc) -> Doc:
from datetime import date
from itertools import islice, chain, repeat
def intersperse(delimiter, seq):
return islice(chain.from_iterable(zip(repeat(delimiter), seq)), 1, None)
annotate.prepare(doc)
if "footer" in doc.metadata:
= [[Str(str(date.today()))]]
content try:
= list(doc.metadata["footer"].content)
old_footer except AttributeError:
= [Str("")]
old_footer
try:
= doc.metadata["version"].content[0]
version "version:"), Space, version])
content.append([Str(except (AttributeError, KeyError):
pass
try:
= doc.metadata["license"].content[0]
license "license:"), Space, license])
content.append([Str(except (AttributeError, KeyError):
pass
= sum(intersperse([Space, Str("—"), Space], content), [])
content "footer"] = MetaInlines(
doc.metadata[*old_footer, LineBreak, *content)
def main(doc: Optional[Doc] = None) -> None:
=prepare, doc=doc) run_filters([bootstrap_card_deck, bootstrap_fold_code], prepare
«bootstrap-card-deck»=
def bootstrap_card_deck(elem: Element, doc: Doc) -> Optional[Element]:
def outer_container(*elements: Element):
return Div(Div(*elements, classes=["card-deck"]), classes=["container-fluid", "my-4"])
def horizontal_card(card_data: JSONType) -> Element:
assert "title" in card_data and "text" in card_data
= card_data["title"]
title = convert_text(card_data["text"])
text
= []
content = [
body =3, classes=["card-title"]),
Header(Str(title), level*text, classes=["card-text"])
Div(
]
if "link" in card_data:
"link"]["content"]),
body.append(Plain(Link(Str(card_data[=card_data["link"]["href"],
url=["btn", "btn-secondary", "mt-auto", "mx-4"])))
classes
= Div(Plain(Image(url=card_data["image"], title=title, attributes={"width": "100%"})), classes=["col-4"])
card_img = Div(Div(*body, classes=["card-body"]), classes=["col-8"])
card_body if card_data["imageLocation"] == "Left":
= [ card_img, card_body ]
content else:
= [ card_body, card_img ]
content = Div(Div(*content, classes=["row", "no-gutters"]), classes=["card", "rounded-lg"])
content return content
def vertical_card(card_data: JSONType) -> Element:
assert "title" in card_data and "text" in card_data
= card_data["title"]
title = convert_text(card_data["text"])
text
= []
content if "image" in card_data:
=card_data["image"], title=title, classes=["card-img-top"])))
content.append(Plain(Image(url
= [
body =3, classes=["card-title"]),
Header(Str(title), level*text, classes=["card-text"])
Div(
]
if "link" in card_data:
"link"]["content"]),
body.append(Plain(Link(Str(card_data[=card_data["link"]["href"],
url=["btn", "btn-secondary", "mt-auto", "mx-4"])))
classes
*body, classes=["card-body", "d-flex", "flex-column"]))
content.append(Div(
= Div(Div(*content, classes=["card", "h-100", "rounded-lg"]), classes=["col"])
content return content
def card(card_data):
if card_data["imageLocation"] in ["Top", "Bottom"]:
return vertical_card(card_data)
else:
return horizontal_card(card_data)
if isinstance(elem, CodeBlock) and "bootstrap-card-deck" in elem.classes:
= parse_dhall(elem.text, cwd=data_path)
deck_data = map(card, deck_data)
content return outer_container(*content)
return None
Foldable code blocks
«bootstrap-fold-code-block»=
def fix_name(name: str) -> str:
return name.replace(".", "-dot-").replace("/", "-slash-")
def bootstrap_fold_code(elem: Element, doc: Doc) -> Optional[Element]:
if isinstance(elem, CodeBlock):
= get_name(elem)
name if "bootstrap-fold" in elem.classes and name is not None:
= fix_name(name)
fixed_name = {
button_attrs "class": "btn btn-outline-primary btn-sm fold-toggle",
"type": "button",
"data-toggle": "collapse",
"data-target": "#" + fixed_name + "-container",
"aria-controls": fixed_name + "-container"
}= " ".join(f"{k}=\"{v}\"" for k, v in button_attrs.items())
attr_str = RawBlock(f"<button {attr_str}><<{name}>>=</button>")
button "overflow-auto")
elem.classes.append("style"] = "max-height: 50vh"
elem.attributes[return Div(button, Div(elem, classes=["collapse"], identifier=fixed_name + "-container"),
=["fold-block"])
classes
else:
if "annotated" in elem.attributes:
return None
return annotate.action(elem, doc)
return None
MkDocs
Python-markdown powers MkDocs
. To pass Entangled style
code blocks we need to help Python-Markdown along a bit. These routines
are designed to be called by pymdownx.superfences
. To use
them, add the following block to mkdocs.yml
and enable
highlight.js
.
markdown_extensions:
- pymdownx.superfences:
custom_fences:
- name: "*"
class: "codehilite"
format: !!python/name:entangled.pymd.format
validator: !!python/name:entangled.pymd.validator
TODO:
- implement validator
- find a way of testing this code
«pymd»=
= ["format", "validator"] __all__
Filter
«pymd»+
def format(source, language, css_class, options, md, classes=None, id_value='', **kwargs):
= source \
patched_source "<", "<") \
.replace(">", ">")
.replace(= "<pre><code class={}>{}</code></pre>".format(language, patched_source)
code_block = "<div class=\"lp-fragment\"><div class=\"lp-ref\">{}</div>{}</div>"
ann if "file" in options:
= "«file://{}»".format(options["file"])
name return ann.format(name, code_block)
elif "id" in options:
= "«{}»".format(options["id"])
name return ann.format(name, code_block)
return code_block
Validate
«pymd»+
def validator(language, options):
return True
Code injection
This filter lets you inject code directly into the HTML, mainly meant for visualisation.
«pandoc_entangled/inject.py»=
from typing import (Optional)
from panflute import (Doc, CodeBlock, Div, Link, Str, Plain, RawBlock, Emph)
from . import tangle
def action(elem, doc):
if isinstance(elem, CodeBlock) and "inject" in elem.attributes:
= elem.identifier
name = Emph(Str(f"«{name}»="))
label = Link(Str(f"{name} output"), url=f"#{name}", classes=["nav-item", "nav-link", "active"],
itemNav ="nav-source-tab", attributes={
identifier"data-toggle": "tab", "aria-controls": f"{name}", "aria-selected": "true"})
= Link(label, url="#nav-source", classes=["nav-item", "nav-link"],
sourceNav ="nav-source-tab", attributes={
identifier"data-toggle": "tab", "aria-controls": "nav-source", "aria-selected": "false"})
= Div(Plain(itemNav, sourceNav), classes=["nav", "nav-tabs"], identifier=f"{name}-nav")
nav
= f"{name}-source"
elem.identifier "annotated"] = "true"
elem.attributes[= Div(classes=["tab-pane", "fade", "show", "active"], identifier=name)
targetPane = Div(elem, classes=["tab-pane", "fade"], identifier="nav-source")
sourcePane = Div(targetPane, sourcePane, classes=["tab-content"], identifier=f"{name}-content")
content = tangle.get_code(doc.code_map, name)
expanded_source = RawBlock(f"<script>\n{expanded_source}\n</script>")
script return Div(nav, content, script, classes=["entangled-inject"])
def main(doc: Optional[Doc] = None) -> None:
import sys
import io
import panflute
= sys.stdin.read()
json_input = io.StringIO(json_input)
json_stream = panflute.load(json_stream)
doc
tangle.prepare(doc)= doc.walk(tangle.action)
doc = doc.walk(action)
doc panflute.dump(doc)