You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

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 ![](/static/media/u1/a.webp)";
    expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(
      "cover ![](https://example.com/static/media/u1/a.webp)",
    );
  });

  test("keeps absolute http/https image links unchanged", () => {
    const input = "![](https://cdn.example.com/a.png)\n![](http://cdn.example.com/b.png)";
    expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(input);
  });

  test("keeps protocol-relative and data URI image links unchanged", () => {
    const input = "![](//cdn.example.com/a.png)\n![](data:image/png;base64,AAAA)";
    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 `![${alt}](${base}${url})`;
  });
}

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 = "![img](/static/media/x.png)";
    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:

  1. Visit /<public post url> while logged out; confirm 导出 .md is visible.
  2. Click export; confirm file downloads as <slug>.md or post-<id>.md.
  3. 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:

![local](/static/media/u1/a.webp)
![abs](https://cdn.example.com/a.png)
![proto](//cdn.example.com/a.png)
![data](data:image/png;base64,AAAA)
[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:

  1. While loading (pending), export button should be disabled.
  2. On not-found/error page (error && !data), export button should not render.
  3. If bodyMarkdown empty, 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, downloadMarkdownFile names are consistent across test and integration steps.