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.ts → blog.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): ParseImportResult — apps/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
titleis 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# H1and 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): TiptapNode — apps/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 |
 |
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 (includingjavascript:anddata:). - Image src allowed protocols:
http:,https:only. Inlinedata: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.jsimages.remotePatternshas 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
- Body images first (each in sequence — easy to throttle).
- Cover image last.
- Each successful mirror replaces
srcin 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: publishedfrontmatter was downgraded to draft - See if the cover URL was rejected
8. Testing
No unit tests yet — apps/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):
- Drop a
.mdfile with frontmatter, GFM tables, fenced code, admonitions, an image, and a YouTube URL into the import dialog. - Confirm preview shows warnings for: missing-image-mirror failures, category creation, status downgrade.
- Open the editor for the new post and verify each construct rendered as the right TipTap node.
- Publish and confirm
tiptap-renderer.tsemits 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. |