logicspike/docs

Blog Engine

Blog System — Markdown Import Pipeline

Last Updated: 2026-05-06 Status: Active

How the dashboard's "Import Markdown" feature turns a .md file (or pasted text) into a draft post. Lives entirely in apps/seller-dashboard/src/modules/blog/import/.


1. Pipeline at a Glance

Five stages, sharp boundaries:

Stage File Sync? Network?
Parse frontmatter + body parse-import.ts sync no
Resolve / create category run-import.ts async yes — GET /admin/categories, POST /admin/categories
Convert markdown → TipTap JSON markdown-to-tiptap.ts sync no
Mirror images to R2 mirror-images.ts async yes — POST /api/media/ingest-url per image
Create draft post run-import.tsblog.admin.api.ts async yes — POST /admin/posts

The split keeps parse-import.ts and markdown-to-tiptap.ts pure and unit-testable; the orchestration with side effects sits in run-import.ts.


2. Stage 1 — Parse Frontmatter

parseImport(rawMarkdown: string): ParseImportResultapps/seller-dashboard/src/modules/blog/import/parse-import.ts.

Hard limits

  • Max size: 5 MB. Larger files are rejected up front.
  • Empty input: rejected.
  • Bad YAML frontmatter: returns { ok: false, error } with the YAML parser message.

Frontmatter fields read

---
title: My Post Title          # optional — falls back to first H1
slug: custom-slug              # optional — server regenerates if conflict
excerpt: Short summary         # optional
seoTitle: Custom <title> tag   # also accepts seo_title
seoDescription: Meta desc      # also accepts seo_description
cover: https://example.com/img.jpg   # also: featuredImage, featured_image
category: Engineering          # single string
tags: [react, performance]     # array OR comma-separated string
status: published              # IGNORED — always imported as draft
---

Title resolution

  • If title is in frontmatter → use it. If the body opens with the same H1 (case-insensitive), strip the duplicate.
  • If no frontmatter title → derive from the first # H1 and strip it from the body.
  • If neither → return error Cannot determine post title.

Cover image rules

  • Only absolute http(s) URLs are accepted at parse time. Local file paths (./cover.png) are stripped with a warning — markdown imports don't have access to your local filesystem.
  • The cover URL is mirrored later in stage 4.

Status field is ignored

If frontmatter says status: published or status: scheduled, the importer logs a warning and imports as draft anyway. Rationale: imports should never auto-publish — the user clicks Publish when ready.

Output shape

interface ImportedPost {
    title: string
    slug?: string
    excerpt?: string
    seoTitle?: string
    seoDescription?: string
    cover?: string                // absolute http(s) URL only
    category?: string             // name, NOT id
    tags: string[]                // names, NOT ids
    body: string                  // markdown, frontmatter stripped
    warnings: string[]            // soft issues for the preview pane
}

3. Stage 2 — Category Resolution

resolveCategory(input) in run-import.ts. The blog-service createPost API takes categoryId, not a name — so the importer has to map the frontmatter string to an existing category id (or create one).

NOTE

Tags don't go through this flow — the dashboard sends tag names in the tags[] body and blog-service's upsertTags handles dedup + creation in one shot. Categories need explicit resolution because blog-service expects a category id.


4. Stage 3 — Markdown → TipTap JSON

markdownToTiptap(markdown: string): TiptapNodeapps/seller-dashboard/src/modules/blog/import/markdown-to-tiptap.ts.

Built on unified + remark-parse + remark-gfm (GitHub-Flavored Markdown). One-way only — round-trip is not supported because the editor has custom nodes (toggle, table-with-controls, block-actions) with no markdown equivalent.

Node mapping

Markdown TipTap node
#, ##, ###, ####, #####, ###### heading (level 1–6)
Paragraph paragraph
> text blockquote
Fenced ``` block codeBlock (with language attr)
- / * / + list bulletList + listItem
1. list orderedList + listItem
- [ ] / - [x] taskList + taskItem (with checked attr)
` header
![alt](src) image
--- / *** horizontalRule
Hard break (line ending in two spaces) hardBreak

Mark mapping

Markdown TipTap mark
**bold** / __bold__ bold
*italic* / _italic_ italic
~~strike~~ strike
`code` code
[link](href) link

Special transforms

GitHub-style admonitions (> [!NOTE], etc.) become editor callout nodes:

const ADMONITION_MAP = {
    NOTE: "info",
    INFO: "info",
    TIP: "tip",
    IMPORTANT: "info",
    WARNING: "warning",
    CAUTION: "danger",
    DANGER: "danger",
}

Bare YouTube URLs on their own line are converted to youtube nodes. The regex matches all common forms (youtube.com/watch?v=, youtu.be/, /embed/, /shorts/) and normalizes to a videoId. The renderer always emits the nocookie host downstream.

URL safety

  • Link href allowed protocols: http:, https:, mailto:, tel:. Everything else is dropped (including javascript: and data:).
  • Image src allowed protocols: http:, https: only. Inline data: images are stripped.

This is the first sanitization pass — the renderer adds another, and the SDK adds a third for defense-in-depth.


5. Stage 4 — Image Mirroring

mirrorImagesInDoc(content) and mirrorSingleUrl(url)apps/seller-dashboard/src/modules/blog/import/mirror-images.ts.

Walks the converted TipTap document, finds every image node with an external src, and asks media-service to fetch + rehost in R2 via POST /api/media/ingest-url. The post then references our own URLs.

Why mirror at all

  • Source CDNs die, rotate keys, or go behind paywalls.
  • External hosts can change content under the same URL — bad for archival integrity.
  • next.config.js images.remotePatterns has to allow every external host; mirroring keeps the allowlist short.

Failure mode is non-fatal

If /api/media/ingest-url fails for one image, the original URL stays in place and the user gets a warning in the preview pane. A flaky third-party host shouldn't block the entire import.

Order of operations

  1. Body images first (each in sequence — easy to throttle).
  2. Cover image last.
  3. Each successful mirror replaces src in the TipTap tree in place.

6. Stage 5 — Create Post

createPost(payload) in apps/seller-dashboard/src/modules/blog/api/blog.admin.api.ts — calls POST /admin/posts via the seller-dashboard proxy.

The post is always created as draft. scheduledFor and publish flags are not set by the importer.

{
    title: post.title,
    content: <converted TipTap JSON>,
    excerpt: post.excerpt,
    seoTitle: post.seoTitle,
    seoDescription: post.seoDescription,
    featuredImageUrl: <mirrored cover URL or original>,
    categoryId: <resolved id from stage 2>,
    tags: post.tags,                // names — backend upserts
}

The dashboard navigates to the editor for the new post id so the user can review before publishing.


7. UI Surface

The dialog component lives in apps/seller-dashboard/src/modules/blog/components/ (search for "Import Markdown"). It uses runImport() with an onProgress callback to show stage-by-stage progress:

type ImportStage =
    | { kind: "resolving-category" }
    | { kind: "mirroring-images"; total: number }
    | { kind: "creating-post" }

Warnings from every stage are surfaced in the preview pane before commit, so the user can:

  • See if any images failed to mirror
  • See if a new category was auto-created
  • See if their status: published frontmatter was downgraded to draft
  • See if the cover URL was rejected

8. Testing

No unit tests yetapps/seller-dashboard doesn't have Vitest configured. Tracked as item B16 in sdk-backlog.md.

The pure functions (parseImport, markdownToTiptap) are good candidates for the first test pass once the runner is in place: input markdown fixture → expected output JSON, check shape + sanitization rules.

Manual test path (until automation exists):

  1. Drop a .md file with frontmatter, GFM tables, fenced code, admonitions, an image, and a YouTube URL into the import dialog.
  2. Confirm preview shows warnings for: missing-image-mirror failures, category creation, status downgrade.
  3. Open the editor for the new post and verify each construct rendered as the right TipTap node.
  4. Publish and confirm tiptap-renderer.ts emits the right HTML on the public side. See editor.md §8 for renderer parity rules.

9. Known Gaps

Gap Impact
No tests Regressions in markdownToTiptap go undetected until manual QA. Seller-dashboard has no test runner — see B16 in sdk-backlog.md.
Local file paths in cover are silently dropped A user with cover: ./hero.png in frontmatter sees only a warning — never a way to upload that image as part of the import.
No way to import multiple posts at once One file per import dialog session. Bulk import isn't on the roadmap yet.
Tag dedup is name-exact, not slug-based If frontmatter says React and an existing tag is named react, you get two tags. Server-side upsertTags is case-sensitive on name.
Image mirroring is sequential A 30-image post mirrors slowly. Could parallelize but throughput is bounded by media-service's R2 ingest.
Blog Engine