Browse Source

docs: add article markdown export implementation plan

Add a task-by-task implementation plan for public post markdown export, including TDD checkpoints, integration steps, and manual acceptance verification.

Made-with: Cursor
main
npmrun 2 weeks ago
parent
commit
2d5e027f82
  1. 275
      docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md

275
docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md

@ -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 ![](/public/assets/u1/a.webp)";
expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(
"cover ![](https://example.com/public/assets/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](/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 `![${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**
```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 = "![img](/public/assets/x.png)";
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
![local](/public/assets/u1/a.webp)
![abs](https://cdn.example.com/a.png)
![proto](//cdn.example.com/a.png)
![data](data:image/png;base64,AAAA)
[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…
Cancel
Save