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.
repl_html_md_links
def repl_html_md_links(
txt
):
Rewrite Quarto .html.md Markdown-copy targets to Octavo's .md
repl_qmd_links
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
Navigation and configuration
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()