Browse Source
Add a task-by-task implementation plan for public post markdown export, including TDD checkpoints, integration steps, and manual acceptance verification. Made-with: Cursormain
1 changed files with 275 additions and 0 deletions
@ -0,0 +1,275 @@ |
|||||
|
# 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** |
||||
|
|
||||
|
```ts |
||||
|
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](/public/assets/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** |
||||
|
|
||||
|
```ts |
||||
|
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** |
||||
|
|
||||
|
```bash |
||||
|
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)** |
||||
|
|
||||
|
```ts |
||||
|
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/public/assets/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)** |
||||
|
|
||||
|
```ts |
||||
|
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" }); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```vue |
||||
|
<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** |
||||
|
|
||||
|
```bash |
||||
|
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: |
||||
|
|
||||
|
```md |
||||
|
 |
||||
|
 |
||||
|
 |
||||
|
 |
||||
|
[plain](/public/assets/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** |
||||
|
|
||||
|
```bash |
||||
|
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. |
||||
|
|
||||
Loading…
Reference in new issue