Skip to content

Markdown I/O

gen_markdown_io generates the typed boundary between your schema entities and their on-disk YAML-frontmatter form: one {Entity}Frontmatter module per entity, built on the markdown-store runtime crate. It also returns the MarkdownIoOutput metadata that gen_store consumes when the store backend is Backend::Markdown — see the markdown backend guide for the full store story; this page covers the persistence layer itself.

let md = ontogen::gen_markdown_io(&schema.entities, &ontogen::MarkdownIoConfig {
output_dir: "src/persistence/markdown/generated".into(),
vault_root: "data/vault".into(), // where the .md files live
layout: ontogen::MarkdownLayout::PerEntityDir, // data/vault/<entity>/<id>.md
id_strategy: ontogen::IdStrategy::SlugFromField("title".into()),
list_cap: 10_000, // loud error past this many records
})?;

The vault fields describe the store’s runtime shape and flow into the returned MarkdownIoOutput; the generated files land in output_dir.

For each entity, a module like:

/// Frontmatter view of a Task: every persisted field except the id (the
/// filename stem), the markdown body, and derived `has_many` views.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskFrontmatter {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epic_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
pub const TASK_FM_FIELDS: &[&str] = &["title", "epic_id", "tags"];
impl TaskFrontmatter {
pub fn from_task(value: &Task) -> Self { /* wikilink-encodes relations */ }
pub fn into_task(self, id: String, body: String) -> Task { /* strips them */ }
}

Three rules decide what’s in the struct:

  • the id is never frontmatter — it’s the filename stem;
  • the body is never frontmatter — it’s the markdown after the fence (#[ontology(body)] threads it through into_*’s signature);
  • has_many fields are never frontmatter — they’re derived views, reconstructed by walking the child folder (storing them would create dual-write drift against the child foreign keys). belongs_to and many_to_many fields ARE stored, as wikilinks.

{ENTITY}_FM_FIELDS is the owned-key set passed to the runtime’s Document::merge_serialize: cleared options remove their keys, and any keys a human added by hand survive every generated rewrite.

from_* / into_* are the only place wikilink syntax exists:

on disk in your code
epic_id: '[[E0042]]' ⇄ task.epic_id == Some("E0042")
tags: ⇄ task.tags == vec!["codegen"]
- '[[codegen]]'

The same files render as a navigable graph in Obsidian; your API emits clean ids. Round-trips are lossless in both directions, and untouched files re-render byte-for-byte.

You don’t need the store layer to use this boundary — parse and write records directly against markdown-store:

let doc = vault.read_record("tasks", "ship-the-emitter")?;
let fm: TaskFrontmatter = doc.deserialize()?;
let task = fm.into_task("ship-the-emitter".into(), doc.body().to_string());

(The four vault config fields are still required in MarkdownIoConfig even for leaf-only use — the deliberate cost of the backend being a first-class citizen rather than a side door.)