9.2 KiB
Article Markdown Export Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a visitor-available .md export button on public post detail pages that downloads bodyMarkdown and normalizes site-relative image URLs to absolute URLs.
Architecture: Keep export fully client-side in the existing public post detail page to avoid adding new server endpoints. Add a focused utility module to normalize markdown image URLs and trigger downloads, with unit tests for URL handling rules. Integrate button states and toast feedback in app/pages/@[publicSlug]/posts/[postSlug].vue without changing existing post loading/edit/comment behaviors.
Tech Stack: Nuxt 4, Vue 3 <script setup>, Bun test (bun:test), existing Nuxt UI UButton and useToast.
Spec: docs/superpowers/specs/2026-04-23-article-markdown-export-design.md
File Structure Map
- Create
app/utils/markdown-export.ts- Single responsibility: normalize markdown image URLs + download markdown file in browser.
- Create
app/utils/markdown-export.test.ts- Unit tests for normalization and filename helpers.
- Modify
app/pages/@[publicSlug]/posts/[postSlug].vue- Add export action wiring, button state, and success/failure toast UX in existing action row.
Task 1: Add Markdown Export Utility with TDD
Files:
-
Create:
app/utils/markdown-export.ts -
Test:
app/utils/markdown-export.test.ts -
Step 1: Write failing tests for URL normalization and filename fallback
import { describe, expect, test } from "bun:test";
import { buildMarkdownExportFileName, normalizeMarkdownImageUrls } from "./markdown-export";
describe("normalizeMarkdownImageUrls", () => {
test("converts site-relative image links to absolute", () => {
const input = "cover ";
expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(
"cover ",
);
});
test("keeps absolute http/https image links unchanged", () => {
const input = "\n";
expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(input);
});
test("keeps protocol-relative and data URI image links unchanged", () => {
const input = "\n";
expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(input);
});
test("does not rewrite normal markdown links", () => {
const input = "[asset](/static/media/u1/a.webp)";
expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(input);
});
});
describe("buildMarkdownExportFileName", () => {
test("uses slug when present", () => {
expect(buildMarkdownExportFileName({ slug: "hello-world", id: 12 })).toBe("hello-world.md");
});
test("falls back to post-id when slug is blank", () => {
expect(buildMarkdownExportFileName({ slug: " ", id: 12 })).toBe("post-12.md");
});
});
- Step 2: Run test to verify failure
Run: bun test app/utils/markdown-export.test.ts
Expected: FAIL with module/function not found.
- Step 3: Implement minimal utility module
const IMAGE_LINK_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
function normalizeOrigin(origin: string): string {
return origin.replace(/\/+$/, "");
}
function isAbsoluteLike(url: string): boolean {
return /^(https?:)?\/\//i.test(url) || /^data:/i.test(url);
}
export function normalizeMarkdownImageUrls(markdown: string, origin: string): string {
const base = normalizeOrigin(origin);
return markdown.replace(IMAGE_LINK_RE, (full, alt, url) => {
if (!url.startsWith("/") || isAbsoluteLike(url)) {
return full;
}
return ``;
});
}
export function buildMarkdownExportFileName(input: { slug: string; id: number }): string {
const slug = input.slug.trim();
return slug.length > 0 ? `${slug}.md` : `post-${input.id}.md`;
}
export function downloadMarkdownFile(filename: string, content: string): void {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
- Step 4: Run tests to verify pass
Run: bun test app/utils/markdown-export.test.ts
Expected: PASS.
- Step 5: Commit
git add app/utils/markdown-export.ts app/utils/markdown-export.test.ts
git commit -m "feat(export): add markdown export utilities with url normalization tests"
Task 2: Integrate Export Button into Public Post Detail Page
Files:
-
Modify:
app/pages/@[publicSlug]/posts/[postSlug].vue -
Step 1: Add focused failing assertion test for helper contract (guard against regressions)
import { describe, expect, test } from "bun:test";
import { normalizeMarkdownImageUrls } from "../../../utils/markdown-export";
describe("public post export contract", () => {
test("normalizes site image link for page-export flow", () => {
const body = "";
expect(normalizeMarkdownImageUrls(body, "https://site.local")).toContain(
"https://site.local/static/media/x.png",
);
});
});
Run location suggestion: append to app/utils/markdown-export.test.ts rather than introducing a Vue SFC test harness.
- Step 2: Run test to verify it fails first (if newly added case)
Run: bun test app/utils/markdown-export.test.ts
Expected: FAIL if new case uncovers a parsing gap; otherwise treat as green safety net and proceed.
- Step 3: Implement page integration (imports, handler, toast, button)
import {
buildMarkdownExportFileName,
downloadMarkdownFile,
normalizeMarkdownImageUrls,
} from "../../../utils/markdown-export";
const toast = useToast();
function exportMarkdown() {
if (!data.value) {
return;
}
try {
const origin = window.location.origin;
const content = normalizeMarkdownImageUrls(data.value.bodyMarkdown || "", origin);
const filename = buildMarkdownExportFileName({ slug: data.value.slug, id: data.value.id });
downloadMarkdownFile(filename, content);
toast.add({ title: "已导出 Markdown", color: "success" });
} catch {
toast.add({ title: "导出失败,请稍后重试", color: "error" });
}
}
<UButton
v-if="data"
variant="soft"
color="neutral"
size="sm"
:disabled="pending"
@click="exportMarkdown"
>
导出 .md
</UButton>
- Step 4: Run test + build-level sanity check
Run: bun test app/utils/markdown-export.test.ts && bun run build
Expected: tests PASS and Nuxt build succeeds.
- Step 5: Commit
git add app/pages/@[publicSlug]/posts/[postSlug].vue app/utils/markdown-export.test.ts
git commit -m "feat(posts): add public post markdown export action"
Task 3: Manual Acceptance Verification Against Spec
Files:
-
Verify:
app/pages/@[publicSlug]/posts/[postSlug].vue -
Verify:
docs/superpowers/specs/2026-04-23-article-markdown-export-design.md -
Step 1: Start app for manual checks
Run: bun run dev
Expected: app starts and public post detail page is reachable.
- Step 2: Verify core export behaviors
Manual checklist:
- Visit
/<public post url>while logged out; confirm导出 .mdis visible. - Click export; confirm file downloads as
<slug>.mdorpost-<id>.md. - Open exported file and confirm正文内容等于
bodyMarkdown(仅图片链接归一化例外)。
- Step 3: Verify image URL normalization and no-overwrite rules
Use a post containing all sample links and validate output:




[plain](/static/media/u1/a.webp)
Expected in exported file:
-
first link rewritten to absolute URL with current origin
-
other four lines unchanged except markdown-preserving formatting
-
Step 4: Verify error and state behavior
Manual checklist:
- While loading (
pending), export button should be disabled. - On not-found/error page (
error && !data), export button should not render. - If
bodyMarkdownempty, exported file exists and is empty content.
- Step 5: Commit any small UX copy tweaks from manual verification
git add app/pages/@[publicSlug]/posts/[postSlug].vue
git commit -m "chore(export): polish markdown export ux states after manual verification"
Self-Review
- Spec coverage:
- Public detail page export entry: Task 2
- Visitor access: Task 2 + Task 3 validation
- Export content is body only: Task 2 handler
- Relative image URL to absolute conversion: Task 1 + Task 3
- UX states/toasts/error handling: Task 2 + Task 3
- Placeholder scan: No TODO/TBD placeholders; each code-changing step has concrete snippet and command.
- Type consistency:
buildMarkdownExportFileName,normalizeMarkdownImageUrls,downloadMarkdownFilenames are consistent across test and integration steps.