On this page
View as Markdown

migrate

Convert Quarto sources and settings for Octavo

migrate_quarto converts Quarto sources in place. Commit or back up the source tree before calling it. It renames QMD documents, updates links and metadata, and reports unsupported settings. It preserves an existing octavo.yml and leaves the original Quarto configuration for review.

The conversion does not port themes, CSS, layouts, shortcodes, or execution settings. Review the reported differences and compare the rendered site with a saved Quarto build before publishing.

The caller supplies a configuration callback and can pass extra accepted page keys. Octavo does not read nbdev project configuration.

Source conversion

Renaming a document also requires updating references to it. A page beside guide.qmd refers to it differently from a page in a subdirectory: guide.qmd versus ../guide.qmd. Migration calculates replacement paths from the documentation root and from each source file’s directory.

The current implementation performs text replacement rather than parsing links. Matching text outside link destinations can also change.

The Markdown copy of each page also moves. Quarto published page.html.md beside page.html; Octavo publishes page.md. Hand-written indexes such as llms.txt often link these copies, so migration rewrites .html.md targets to .md in Markdown sources and .txt files.


def repl_html_md_links(
    txt
):

Rewrite Quarto .html.md Markdown-copy targets to Octavo's .md


def repl_qmd_links(
    txt, moves, src, root
):

Rewrite relative and root-relative .qmd link targets to .md

Quarto lets authors mark a block of content for a particular output format—for example, HTML for the website or Markdown for a README. Migration replaces Quarto’s block markers with mdhtml's raw-format fences.

For example, this Quarto block:

::: {.content-visible when-format="html"}
<p>Only include this in HTML output.</p>
:::

becomes:

```{=html}
<p>Only include this in HTML output.</p>
```

The function changes the surrounding markers, not the content inside them. Quarto’s markdown, commonmark, and md format names all become md. If an HTML block has a style attribute, the function preserves it by wrapping the content in a <div> with that style.

This conversion changes block markers, not the language of the body. Markdown inside an HTML-targeted block is not converted to HTML. The current helper also does not preserve unless-format conditions. Review these cases before relying on the converted output.


repl_content_visible

def repl_content_visible(
    s
):

Convert Quarto ::: {.content-visible when-format=...} to {=md} / {=html}

Octavo reads Quarto's frontmatter keys as they are, such as toc-depth, page-layout and categories, so migration leaves them alone. It changes one thing: Octavo shows a page's description under its title, where Quarto shows subtitle, so a subtitle, or a legacy summary, becomes the description. A page that already has a description keeps it, and its subtitle is reported as unsupported.


repl_frontmatter_keys

def repl_frontmatter_keys(
    s
):

Move Quarto's subtitle, or a legacy summary, to the description Octavo shows, unless the page has one

report_page_keys reports frontmatter settings outside Octavo's PAGE_KEYS. Integrations pass extra_keys for settings they consume themselves. source_frontmatter reads the metadata from a document or notebook without executing its code.

Callers with another source format can pass parsed metadata to report_page_keys; Octavo then uses the path only to label the diagnostic.


report_page_keys

def report_page_keys(
    p, # Source path used in diagnostics
    extra_keys:tuple=(), # Metadata keys consumed by the calling integration
    metadata:NoneType=None, # Parsed metadata; otherwise read from the document
):

Print frontmatter keys outside Octavo's settings and extra_keys


source_frontmatter

def source_frontmatter(
    p
):

Frontmatter of a document or notebook

Documentation conversion must not rewrite executable notebook code or discard saved results. migrate_nb_cells applies the text conversions only to Markdown cells. Code cells and their saved outputs are retained.

migrate_nb_cells and migrate_md_file write a file only when a conversion changes its content.


migrate_md_file

def migrate_md_file(
    path, procs
):

Apply procs to a Markdown, QMD or text file in-place


migrate_nb_cells

def migrate_nb_cells(
    path, procs
):

Apply procs to all markdown cells in a notebook in-place

Dropdown entries keep their labels and point to the rendered HTML pages.

test_eq(_nav_item(dict(text='Help', menu=[dict(text='Guide', href='guide.qmd')])),
    dict(text='Help', children=[dict(text='Guide', href='guide.html')]))

Icon names belong to the renderer. Unsupported icons are reported rather than emitted as broken tool links.

Quarto and Octavo use different settings for navigation bars. Migration converts text links and dropdowns into Octavo navigation links, and icon links into tools. Links to documentation sources are changed to HTML destinations so visitors reach rendered pages. Explicit accessibility labels are preserved. Icons not bundled with Octavo are reported and omitted for manual replacement.

quarto_sidebar converts parsed Quarto sidebar settings into sidebar-links. Pages become links and sections become groups. Entries without text take the page's title at build time. The caller chooses which configuration file supplies the sidebar and passes its path for diagnostic messages. Quarto's auto entries and glob patterns are reported and omitted.


quarto_sidebar

def quarto_sidebar(
    sb, path
):

Convert Quarto sidebar settings; use path to identify reported omissions

read_quarto_config reads a YAML mapping and rejects scalars and lists. Missing or empty files return empty settings.

quarto_config copies supported website and HTML settings into Octavo's flat configuration and converts the navbar. It accepts an optional settings overlay, such as nbdev's generated configuration. The caller handles output paths and adds any result from quarto_sidebar.

This is not a complete translation of Quarto configuration. Unsupported settings are reported for manual review. In particular, the conversion leaves themes and site CSS to the caller.


quarto_config

def quarto_config(
    qy, root, overrides:NoneType=None
):

Convert parsed Quarto settings and optional overlay, reporting omissions under root


read_quarto_config

def read_quarto_config(
    path
):

Read a Quarto YAML mapping; missing or empty files give empty settings

Applying the migration

Switching builders requires source changes as well as new configuration: renamed documents need updated links, and Quarto-specific markup needs conversion. Migration checks rename conflicts and configuration before editing, preserves an existing octavo.yml, and retains _quarto.yml for comparison. Changes are made in place without automatic rollback, so commit or back up the project first.


migrate_quarto

def migrate_quarto(
    path, # Source directory
    config, # Callable taking the source directory and returning Octavo settings
    extra_keys:tuple=(), # Page metadata keys consumed by the calling integration
    extra_moves:NoneType=None, # Additional source-to-destination Path pairs; supplied relative paths include `path`
):

Convert sources in place, create missing configuration, and return the rename mapping

The caller supplies a configuration function. It runs after rename-conflict checks and before source changes, and only when octavo.yml is absent. This example uses a source-root Quarto project. nbdev supplies its own function to combine nbdev.yml, a custom sidebar, and project-relative output paths.

extra_moves maps source Paths to destination Paths, using filesystem paths just like the returned rename map. These pairs join document renames for conflict checks, link updates, and renaming. Extra files outside the document formats are not read or converted.

def example_config(root):
    qy = read_quarto_config(root/'_quarto.yml')
    cfg = quarto_config(qy, root)
    if out := nested_idx(qy, 'project', 'output-dir'): cfg['output'] = out
    if sb := nested_idx(qy, 'website', 'sidebar'): cfg |= quarto_sidebar(sb, root/'_quarto.yml')
    return cfg

The notebook below contains a saved result and code that must not execute during migration.

migration_tmp = tempfile.TemporaryDirectory()
root = Path(migration_tmp.name)
(root/'_quarto.yml').write_text('website:\n  title: Demo\n')
(root/'guide.qmd').write_text('---\nsubtitle: A guide\n---\n\n[Notebook](index.ipynb)')
code = mk_cell('raise RuntimeError("do not run")')
code.outputs = [dict(output_type='stream', name='stdout', text='Saved result\n')]
book = new_nb([mk_cell('[Guide](guide.qmd)', 'markdown'), code])
write_nb(book, root/'index.ipynb')

Migration renames the guide and moves its subtitle into the description.

moves = migrate_quarto(root, example_config)
test_eq(moves, {root/'guide.qmd': root/'guide.md'})
test_eq(source_frontmatter(root/'guide.md')['description'], 'A guide')

Notebook Markdown links change, while executable code and saved outputs remain intact.

migrated = read_nb(root/'index.ipynb')
test_eq(migrated.cells[0].source, '[Guide](guide.md)')
test_eq(migrated.cells[1].source, code.source)
test_eq(migrated.cells[1].outputs, code.outputs)

A second migration has no files to rename and preserves the existing Octavo configuration.

before = (root/'octavo.yml').read_text()
test_eq(migrate_quarto(root, example_config), {})
test_eq((root/'octavo.yml').read_text(), before)
test_eq(yaml.safe_load(before)['title'], 'Demo')
migration_tmp.cleanup()

Callers can supply additional renames. Their contents are not converted, but document links follow the new filenames. Existing destinations are rejected before source changes.

with tempfile.TemporaryDirectory() as d:
    root = Path(d)
    (root/'octavo.yml').write_text('{}')
    old,new = root/'old.bin',root/'new.bin'
    old.write_bytes(b'opaque payload')
    new.touch()
    (root/'guide.qmd').write_text('[Asset](old.bin)')
    with expect_fail(FileExistsError): migrate_quarto(root, example_config, extra_moves={old:new})
    test_eq((root/'guide.qmd').read_text(), '[Asset](old.bin)')
    new.unlink()
    moves = migrate_quarto(root, example_config, extra_moves={old:new})
    test_eq(moves[old], new)
    test_eq(new.read_bytes(), b'opaque payload')
    test_eq((root/'guide.md').read_text(), '[Asset](new.bin)')

A destination collision must stop migration before an existing file can be overwritten.

with tempfile.TemporaryDirectory() as d:
    p = Path(d)
    (p/'page.qmd').touch()
    (p/'page.md').touch()
    with expect_fail(FileExistsError, contains='page.md'): migrate_quarto(p, example_config)

Malformed YAML must fail before any source rename. This differs from a valid YAML value of the wrong type.

with tempfile.TemporaryDirectory() as d:
    p = Path(d)
    (p/'guide.qmd').write_text('# Guide')
    (p/'_quarto.yml').write_text('website: [invalid')
    with expect_fail(yaml.YAMLError): migrate_quarto(p, example_config)
    assert (p/'guide.qmd').exists() and not (p/'guide.md').exists()

Valid YAML is not always valid configuration. Lists, booleans, and numbers must fail before source changes, whether they came from Quarto or an existing Octavo configuration.

with tempfile.TemporaryDirectory() as d:
    nbs = Path(d)
    (nbs/'guide.qmd').write_text('# Guide')
    (nbs/'_quarto.yml').write_text('website:\n  title: Demo\n')
    for invalid in ('[]', 'false', '0'):
        (nbs/'octavo.yml').write_text(invalid)
        with expect_fail(ValueError, contains='mapping'): migrate_quarto(nbs, example_config)
        assert (nbs/'guide.qmd').exists() and (nbs/'_quarto.yml').exists()
    (nbs/'octavo.yml').unlink()
    for invalid in ('[]', 'false', '0'):
        (nbs/'_quarto.yml').write_text(invalid)
        with expect_fail(ValueError, contains='mapping'): migrate_quarto(nbs, example_config)
        assert (nbs/'guide.qmd').exists() and not (nbs/'guide.md').exists()