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