On this page
View as Markdown

core

Convert notebook content and Markdown to HTML and GitHub-flavored Markdown

Use octavo.core to turn a document’s Markdown into article HTML or GitHub-flavored Markdown. For notebook content, first convert the cells and their saved outputs to Markdown. This conversion does not run the notebook.

Parse the Markdown once, then render either format:

doc = md2doc("# Installation\n\nRun `pip install -e .`.")
html = doc.to_html()
markdown = doc.to_gfm()

html.html contains the article HTML. html.toc contains a separate table of contents that a page layout can place beside the article. markdown contains the GitHub-flavored Markdown text.

YAML frontmatter supplies page metadata such as title and description. HTML rendering adds these above the article. Markdown export retains the frontmatter.

This module converts document content. Reading source directories, building navigation, and writing the website belong to octavo.pages and octavo.site.

Notebook to md


msg2md

def msg2md(
    m, # The message to render
    lang:str='python', # Language for the code fence
):

Render a message as document Markdown, applying publishing directives

msg2md applies code-cell publishing directives without executing code. include: false omits the cell. echo: false hides its source, and output: false hides its saved outputs. Notes, raw messages, and prompt/reply pairs retain aidialog's Markdown rendering.

Code-cell publishing supports these directives:

DirectiveEffect
include: falseOmit the entire cell.
echo: falseOmit the source.
output: falseOmit saved outputs.
output: asisInsert saved output as text without output fences.
code-*Add data-code-* attributes to the source fence.

Directives can appear in #| lines or in nbdev cell metadata. msg2md removes directive lines from the published source. It never executes code. eval: false therefore does not hide an existing saved output.

By default, the Markdown contains both the source and its saved output. These examples print the generated Markdown rather than render it.

shown = msg2md(Message('8*8', output:=code_output('64')))
print(shown)
```python
8*8
```

::: output
```output
64
```
:::

Use echo: false to publish the saved output without its source.

hidden_code = msg2md(Message('#| echo: false\n8*8', output))
print(hidden_code)
::: output
```output
64
```
:::

Use output: false to publish the source without its saved output. This example sets the directive through cell metadata.

source_only = msg2md(Message('8*8', output, meta={'nbdev': {'output': 'false'}}))
print(source_only)
```python
8*8
```

Use include: false to omit both source and output. The converter returns None for an omitted cell.

omitted = msg2md(Message('#| include: false\n8*8', output))
test_eq(omitted, None)

Use output: asis when saved text contains Markdown to include in the document. Combine it with echo: false to show only that Markdown.

asis = msg2md(Message('#| echo: false\n#| output: asis\nprint("**Result:** 64")', code_output('**Result:** 64')))
print(asis)
**Result:** 64

Use code-fold to put source code in a collapsible panel and code-summary to name the panel. msg2md carries these settings as fence attributes for the document renderer. It removes directive comments from the displayed code and escapes attribute values.

code_md = msg2md(Message('#| code-fold: show\n#| code-summary: Setup\nx=1'))
print(code_md)
```python {data-code-fold="show" data-code-summary="Setup"}
x=1
```

dlg2md

def dlg2md(
    d
):

Render a dialog as document Markdown, fencing code in the notebook's language

dlg2md joins the rendered messages with blank lines and ends the document with a newline. It includes every message unless a code cell's publishing directives exclude it.

Code fences use the notebook's kernel language, from kernelspec.language or else language_info.name, and fall back to python. Quarto does the same, so a notebook running a non-Python kernel keeps its language class in HTML, such as language-bpl, and its fence label in Markdown. Here the dialog's notebook metadata names the BPL kernel:

bpl_md = dlg2md(Dialog([Message('1 2 3+4', output=code_output('5 6 7'))], meta={'kernelspec': {'name': 'bpl', 'language': 'bpl'}}))
assert bpl_md.startswith('```bpl\n1 2 3+4\n```')
print(bpl_md)
```bpl
1 2 3+4
```

::: output
```output
5 6 7
```
:::

Md to Doc

md2doc separates YAML frontmatter from the Markdown body. A Doc stores the parsed settings in meta and the original frontmatter text in fm. Keeping the original text preserves its formatting when exporting Markdown.

body retains the source Markdown. frag contains the parsed MDHTML used for HTML export. Code folds are prepared separately for each output without modifying body.


Doc

def Doc(
    body:str, # The original Markdown body, without frontmatter
    frag:str, # The document as mdhtml
    meta:dict, # Parsed YAML frontmatter
    fm:str, # The frontmatter block as the author wrote it; empty where there was none
    warnings:list, # Whatever mdhtml reported while parsing
):

A document’s source Markdown, parsed mdhtml, metadata, and warnings

Settings are written with hyphens, as in Quarto: toc-depth, open-graph, image-alt. Python names can't contain a hyphen, so snake_keys replaces each - in a key with _, at every level, before Octavo reads the settings. Code then reads meta['toc_depth'], or o.toc_depth, and takes toc_depth=2 as a keyword argument. Values don't change.


snake_keys

def snake_keys(
    o
):

o with each - in the keys of its dicts replaced by _, at every level

settings = snake_keys({'toc-depth': 2, 'open-graph': {'image-alt': 'A chart'}, 'nav-links': [{'text': 'Docs', 'href': 'docs.html'}]})
test_eq(settings, {'toc_depth': 2, 'open_graph': {'image_alt': 'A chart'}, 'nav_links': [{'text': 'Docs', 'href': 'docs.html'}]})
settings
{'toc_depth': 2,
 'open_graph': {'image_alt': 'A chart'},
 'nav_links': [{'text': 'Docs', 'href': 'docs.html'}]}

A key spelt both ways results in the last one winning:

test_eq(snake_keys({'toc-depth': 2, 'toc_depth': 3}),{'toc_depth':3})

md2doc

def md2doc(
    md:str, # Markdown, with optional YAML frontmatter
    math:str='brackets', # Inline maths syntax: `brackets` for `\(…\)`, or `dollars` to also read `$…$`
):

Parse Markdown md to a Doc, retaining its source and preparing code folds for HTML

Put a page’s title and description in YAML frontmatter when they need to appear in navigation or listings. Octavo renders them above the article. Start the body with prose and use headings for its sections.

For nbdev notebooks, nbdev converts the opening # title and > description into frontmatter before Octavo reads the document.

sample = """---
title: config
description: Configuring nbdev and bootstrapping notebook export
order: 3
---

Configuration lives in `pyproject.toml`, and [export](04_export.md) reads it.

## Reading config

`get_config` walks up from the current directory until it finds one.

### Search order

Each directory from the current one to the root, taking the first hit.

## Writing config

`nbdev-create-config` writes a starting one.
"""
res = md2doc(sample)
res
Doc(config: 405 chars, 0 warnings)

Parsed YAML preserves value types. In this example, order is the integer 3.

test_eq(res.meta['title'], 'config')
test_eq(res.meta['order'], 3)
res.meta
{'title': 'config',
 'description': 'Configuring nbdev and bootstrapping notebook export',
 'order': 3}

Inline maths uses \(…\) by default, and display maths $$…$$. Pass math='dollars' to also read $…$ as inline maths, as Pandoc and Quarto do. The default leaves a single $ in prose, such as a price, as text.

With dollars, a price and maths in the same paragraph can merge into one maths span, because the price's $ opens it. Write \$ for a literal dollar sign there.

area = 'The area is $r^2$.'
assert 'class="math inline"' not in md2doc(area).frag
test_eq(md2doc(area, math='dollars').frag.count('class="math inline"'), 1)
test_eq(md2doc(r'It costs \$5, and the area is $r^2$.', math='dollars').frag.count('class="math inline"'), 1)

fm retains the original frontmatter block, including its line breaks and spelling.

assert sample.startswith(res.fm)
print(res.fm)
---
title: config
description: Configuring nbdev and bootstrapping notebook export
order: 3
---

Page title

Doc.header renders a frontmatter title as an <h1> with an optional description. Without a frontmatter title, it returns an empty string. This lets a Markdown file supply its own heading instead.


Doc.header

def header():

Return an HTML title block, or an empty string without a title

res.header
'<header class="title-block"><h1>config</h1><p class="description">Configuring nbdev and bootstrapping notebook export</p></header>'

This document supplies # Hand written in its body. Octavo adds no separate title block.

plain = md2doc("# Hand written\n\nNo frontmatter here.\n")
test_eq(plain.header, '')
test_eq(plain.fm, '')
plain.frag
'<h1>Hand written</h1>\n<p>No frontmatter here.</p>\n'

Source links need to point to published files. For example, a link to guide.ipynb should point to guide.html in the website.

relink applies a callable to each link and image address. Return a replacement string to change an address, or None to keep it. octavo.site supplies a callable that looks up page destinations.


def relink(
    frag:str, # A document as MDHTML
    link, # Address -> replacement, or None to keep it
):

Rewrite every link and image address in frag through link

A dictionary’s get method works as the callable. Listed addresses are replaced; unlisted ones, such as the external URL below, remain unchanged.

where = {'a.md':'a.html', 'i.png':'assets/i.png'}
relink('<p><a href="a.md">a</a> <a href="https://x.com">x</a> <img src="i.png"></p>', where.get)
'<p><a href="a.html">a</a> <a href="https://x.com">x</a> <img src="assets/i.png"></p>'

Rendering HTML

Doc.to_html renders the article and its table of contents. It returns a Rendered object with separate html, toc, and warnings attributes. Separating the article from the table of contents lets the page layout place them in different columns.

Use str(result) or an f-string to insert the article HTML into a page. Use the named attributes to access the table of contents and warnings.


Rendered

def Rendered(
    html:str, # The document as HTML, title block included, contents removed
    toc:str, # Table of contents as a `nav` element; empty where the document has no headings
    warnings:list, # Whatever mdhtml reported while parsing and lowering
):

Rendered article HTML, table of contents, and warnings


Doc.to_html

def to_html(
    link:NoneType=None, header:bool=True, **kwargs
):

Render HTML with an optional link-rewriting callable; pass kwargs to mdhtml2html

HTML includes the frontmatter title block by default. Pass header=False when the caller supplies its own title or layout.

rend = res.to_html()
assert rend.html.startswith('<header class="title-block">')
assert 'title: config' not in rend.html
assert not rend.html.startswith('<nav')
rend
bare = res.to_html(header=False)
assert not bare.html.startswith('<header class="title-block">')
bare
Rendered(463 chars, 0 warnings)

Rendered supports string conversion, but not string slicing. Access .html to work with the article as a string, or .toc for its table of contents.

test_eq(f'<main>{rend}</main>', f'<main>{rend.html}</main>')
with expect_fail(TypeError): rend[:10]
rend.toc
'<nav class="toc"><ol><li><a href="#reading-config">Reading config</a><ol><li><a href="#search-order">Search order</a></li></ol></li><li><a href="#writing-config">Writing config</a></li></ol></nav>'

Link replacements apply to one export. A subsequent export without the callable still uses the original address.

linked = res.to_html(link={'04_export.md':'export.html'}.get)
assert 'href="export.html"' in linked.html
assert 'href="04_export.md"' in res.to_html().html
linked
Rendered(592 chars, 0 warnings)

Trimming the table of contents

Limit the table of contents when a page has more subsection detail than readers need in the sidebar. trim_toc keeps the requested number of nested list levels. A depth of 1 shows top-level entries; None leaves the table unchanged.


trim_toc

def trim_toc(
    toc:str, depth:int=None
):

Limit a table of contents to depth nested levels, or retain all when None

Depth 1 retains “Reading config” but removes its “Search order” subsection. A depth larger than the existing nesting leaves all entries visible.

flat = trim_toc(rend.toc, 1)
assert 'Reading config' in flat and 'Search order' not in flat
test_eq(trim_toc(rend.toc, 9), rend.toc)
test_eq(trim_toc(rend.toc), rend.toc)
test_eq(trim_toc('', 1), '')
flat
'<nav class="toc"><ol><li><a href="#reading-config">Reading config</a></li><li><a href="#writing-config">Writing config</a></li></ol></nav>'

Exporting GFM

Doc.to_gfm converts Markdown extensions into GitHub-flavored Markdown and restores the original frontmatter. It prepares code folds from body on each export.

Ordinary Markdown retains its source spelling. Constructs requiring conversion are rewritten by md2gfm. An optional link callable replaces inline link and image destinations without rewriting the surrounding text.


Doc.to_gfm

def to_gfm(
    link:NoneType=None
):

Export GFM with original frontmatter and optional link rewriting

Here, the notebook link changes to guide.md#part. The frontmatter, code fence, math delimiters, and other text remain unchanged.

source_md = r'''---
title: Source spelling
---

A_name links to [the guide](guide.ipynb#part) and [the web](https://example.com).

```python
items = ["a_b"]
```

\[x_1 + y_2\]
'''
link = {'guide.ipynb#part':'guide.md#part'}.get
expected_gfm = source_md.replace('guide.ipynb#part', 'guide.md#part')
actual_gfm = md2doc(source_md).to_gfm(link=link)
test_eq(actual_gfm, expected_gfm)
print(actual_gfm)
---
title: Source spelling
---

A_name links to [the guide](guide.md#part) and [the web](https://example.com).

```python
items = ["a_b"]
```

\[x_1 + y_2\]

Code folding

Quarto has a code-fold directive which octavo supports. code-fold puts the source in a disclosure panel, while saved output stays outside it. code-summary supplies the panel label, defaulting to “Code”.

Use code-fold: show to start expanded. Here is a “Setup” panel with its output below:

source = Message('#| code-fold: show\n#| code-summary: Setup\n1 + 1', output=code_output('2'))
folded_doc = md2doc(dlg2md(Dialog(messages=[source])))
display(HTML(folded_doc.to_html().html))
Setup
1 + 1
2
print(folded_doc.to_gfm())
<details open>
<summary>Setup</summary>


```python
1 + 1
```

</details>

```output
2
```

Here's another example where the input is folded and the panel title falls back to the default:

source = Message('#| code-fold\nx = "```"', output=code_output('visible result'))
folded_doc = md2doc(dlg2md(Dialog(messages=[source])))
display(HTML(folded_doc.to_html().html))
Code
x = "```"
visible result
print(folded_doc.to_gfm())
<details>
<summary>Code</summary>


````python
x = "```"
````

</details>

```output
visible result
```