This node covers two related content pipelines in youteacher_content: an agent-facing markdown importer that turns markdown plus media into a draft post identical to one an editor would make, and a zip-based bulk export/import of posts and categories. Both revolve around the same fact: posts are stored as BlockNote JSON, and media lives in object storage, so every path has to convert to that shape and keep media references consistent.
The agent markdown import
There is one endpoint, POST /import-markdown, registered for administrators only. It accepts multipart/form-data: text fields (markdown, optional title, optional categorySlug) alongside repeated file parts — one optional featuredImage and any number of media files. On success it returns 201 with a post DTO that is indistinguishable from a post created in the editor — same BlockNote content, same draft status. Unknown file fields are logged and ignored.
Size is bounded at two layers. The markdown body is capped at 1 MB. Each media file is re-validated against a 10 MB application cap; when a part trips the underlying multipart limit, the error is remapped to a single canonical 400 / file_too_large so a client sees the same code no matter how it hit the ceiling.
The use case behind the endpoint runs as an ordered, validate-first sequence:
- Text checks. Markdown must be non-empty. The title is taken from the explicit field, or, failing that, from the first H1 in the markdown; with neither, the import is rejected.
- Category. If a
categorySlugis given, it must resolve to an existing category. - Media validation, before any upload. Every file must have an
image/MIME type and stay under the size cap. - Reference validation. Markdown may reference media with a
media:filenametoken; each such reference must match an uploaded file, or the import fails withunknown_media_ref. - Upload as a saga. Only now are files uploaded to object storage, with each stored key tracked. Files are de-duplicated by filename so a repeated reference uploads once.
- Rewrite and convert. The
media:filenametokens are rewritten to the stored URLs, then the markdown is converted to BlockNote JSON. If no featured image was supplied, the first inline image in the content becomes the featured image. - Persist as a draft. A post is created with
contentFormat: blocknote; on a slug collision the title gets a short id suffix, mirroring the ordinary create path.
The saga is the safety property: if any step after upload throws, every object already uploaded is deleted before the error surfaces, so a failed import leaves no orphaned media.
Markdown → BlockNote conversion
Conversion uses the marked lexer (GitHub-flavored) and maps its token tree onto BlockNote's canonical block types: headings (level capped at 3), paragraphs, bullet and numbered list items, code blocks, and images. A paragraph is split at each image token — text runs become paragraph blocks and images become standalone image blocks, matching how a BlockNote editor lays out a line like "here's a photo" followed by the image. Inline styling (bold, italic, inline code, links) is preserved; an inline image inside a text paragraph degrades to its alt text since BlockNote has no inline-image type. Anything unrecognized falls through as a paragraph, and empty input yields a single empty paragraph.
On top of standard markdown the converter understands a generic slash-command directive syntax for the editor's custom blocks. A leaf directive is ::name{key="value"} on its own line; a container directive opens with :::name{...}, spans following lines, and closes with :::. The directive name becomes the BlockNote block type as-is, and the container body is carried verbatim in props.content. Because the name maps straight through, any custom block the editor registers is importable without new conversion code.
Zip-based export and import
Bulk transfer works through a self-contained zip. Export selects posts by explicit ids, by category, or all (capped at 500), then assembles a bundle: a manifest.json (version, timestamp, counts), categories/categories.json (including every ancestor category, ordered by depth), and one posts/<slug>.json per post. Media is the interesting part — the exporter gathers every internal media URL (featured image, inline images found by walking the BlockNote content, and attachments), downloads each into the zip under a media/-prefixed path, and rewrites the URLs inside each post's content and featured-image field to those relative paths. Only URLs under the instance's object-storage prefix are treated as internal; external URLs are left alone. Media that fails to download is skipped with a warning rather than aborting the export.
Import reverses this and is deliberately forgiving. It validates the manifest version, imports categories parents-first (reusing an existing category with the same slug, otherwise creating it), uploads every media file back to storage while building a path→URL map, then imports each post. Content is un-rewritten by replacing the relative media paths with the new storage URLs. Every post is imported as a draft; slug collisions get an id suffix, a unique-constraint violation triggers the same suffix fallback, and an alias is restored only if it is not already in use. Errors on individual posts, categories, or media are collected into a result summary instead of failing the whole import, so a partial bundle still lands what it can.
The BlockNote media walker is shared by both directions: it parses the content JSON, recurses through block content and children, and reads image props.url, either collecting internal URLs (export) or substituting them from a map on a deep-cloned tree (import). The zip itself is built with archiver at compression level 6, buffering chunks in memory and finalizing once.
Why it is shaped this way
The recurring theme is a single content representation with consistent media references. Markdown import exists so an agent can produce editor-grade posts without knowing BlockNote's JSON; the directive syntax keeps that door open for custom blocks without per-block code. The saga and the validate-before-upload ordering exist so failures never leave half-uploaded media. Export and import move that same representation between instances, rewriting media references on the way out and back so a bundle is portable and re-hostable rather than tied to one storage origin.