From 679034568ac361fb7c5b46ba87295ff3bfa6dafc Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 2 Aug 2026 20:19:36 +0700 Subject: [PATCH] =?UTF-8?q?feat(dev):=20Ghost=20Blog=20Backup=20=E2=80=94?= =?UTF-8?q?=20export=20to=20Markdown/HTML=20(client-side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Dev tool: drop a Ghost content export (Settings → Migration → Export JSON) and get a ZIP of one file per post — Markdown (YAML frontmatter + turndown-converted body, GFM tables) and/or standalone HTML pages ready to host statically (e.g. R2). Posts/pages/drafts sorted into folders; tags joined from posts_tags; generic frontmatter (Astro/Hugo/Jekyll-friendly). All in-browser, nothing uploaded. - Pure ghost-export.lib.ts (parse/join/frontmatter/templates), 12 tests; turndown + fflate dynamic-imported in the island. Bilingual (EN + ID). status: beta. - Notes in UI that Ghost exports omit image files (URLs only). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- package-lock.json | 27 ++++ package.json | 2 + src/islands/dev/GhostBackup.tsx | 171 +++++++++++++++++++++++ src/registry/tool-seo.ts | 34 +++++ src/registry/tools.ts | 13 +- src/tools/dev/ghost-export.lib.test.ts | 106 ++++++++++++++ src/tools/dev/ghost-export.lib.ts | 185 +++++++++++++++++++++++++ 7 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/islands/dev/GhostBackup.tsx create mode 100644 src/tools/dev/ghost-export.lib.test.ts create mode 100644 src/tools/dev/ghost-export.lib.ts diff --git a/package-lock.json b/package-lock.json index 5e46ea6..797208c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@ffmpeg/util": "^0.12.2", "@huggingface/transformers": "^4.2.0", "@imgly/background-removal": "^1.4.5", + "@joplin/turndown-plugin-gfm": "^1.0.67", "@mediapipe/tasks-vision": "^0.10.35", "@nanostores/react": "^0.7.3", "@sqlite.org/sqlite-wasm": "^3.50.1-build1", @@ -63,6 +64,7 @@ "signature_pad": "^5.1.3", "smol-toml": "^1.7.0", "tailwindcss": "^3.4.19", + "turndown": "^7.2.4", "upscaler": "^1.0.0", "yaml": "^2.9.0" }, @@ -4296,6 +4298,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@joplin/turndown-plugin-gfm": { + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.67.tgz", + "integrity": "sha512-FZfW5EZfidhzd1IaY1uxHnIZPTVOxAdleMZ4/1U6Nt5b7+Qj5JThDnaIomuJtetnUBzuRNbe9FWMuqD4B3dlWA==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -4453,6 +4461,12 @@ "langium": "3.3.1" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@nanostores/react": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-0.7.3.tgz", @@ -19464,6 +19478,19 @@ "zustand": "^4.3.2" } }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 136766b..1200c58 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@ffmpeg/util": "^0.12.2", "@huggingface/transformers": "^4.2.0", "@imgly/background-removal": "^1.4.5", + "@joplin/turndown-plugin-gfm": "^1.0.67", "@mediapipe/tasks-vision": "^0.10.35", "@nanostores/react": "^0.7.3", "@sqlite.org/sqlite-wasm": "^3.50.1-build1", @@ -100,6 +101,7 @@ "signature_pad": "^5.1.3", "smol-toml": "^1.7.0", "tailwindcss": "^3.4.19", + "turndown": "^7.2.4", "upscaler": "^1.0.0", "yaml": "^2.9.0" }, diff --git a/src/islands/dev/GhostBackup.tsx b/src/islands/dev/GhostBackup.tsx new file mode 100644 index 0000000..ab79374 --- /dev/null +++ b/src/islands/dev/GhostBackup.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { Ghost, Download, FileText, FileCode } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { downloadService } from '@/services/download'; +import type { Lang } from '@/i18n/config'; +import { + parseGhostExport, selectPosts, toMarkdown, toHtmlPage, + type NormalizedPost, +} from '@/tools/dev/ghost-export.lib'; + +type Format = 'md' | 'html' | 'both'; + +const input = 'w-full border-2 border-border bg-muted px-3 py-2 text-sm outline-none focus:shadow-brutal-sm'; + +const TR: Record string; + optionsH: string; drafts: string; pages: string; formatH: string; + md: string; mdNote: string; html: string; htmlNote: string; both: string; + imageNote: string; generate: string; working: string; another: string; + errRead: string; errGen: string; +}> = { + en: { + intro: 'Turn a Ghost export into a folder of Markdown and/or standalone HTML files — one per post, with YAML frontmatter and your tags. Everything runs in your browser; nothing is uploaded.', + how: 'In Ghost admin: Settings → Migration → Export, then drop the downloaded JSON here.', + drop: 'Drop your Ghost export (.json)', dropSub: 'Processed on your device — no upload.', + loaded: (n, d, p) => `${n} posts found — ${d} draft${d === 1 ? '' : 's'}, ${p} page${p === 1 ? '' : 's'}.`, + optionsH: 'What to include', drafts: 'Include drafts (→ drafts/ folder)', pages: 'Include pages (→ pages/ folder)', + formatH: 'Output format', + md: 'Markdown', mdNote: '.md + YAML frontmatter (for Astro/Hugo/Jekyll…)', + html: 'HTML', htmlNote: 'standalone .html pages (ready to host on R2/static)', + both: 'Both', + imageNote: 'Note: Ghost exports don’t include image files, only their URLs — so image links stay pointing at your Ghost/CDN. Save the images separately if you’re shutting the site down.', + generate: 'Convert & download ZIP', working: 'Converting…', another: 'Choose another file', + errRead: 'Could not read this file.', errGen: 'Could not convert the export.', + }, + id: { + intro: 'Ubah ekspor Ghost menjadi folder berisi berkas Markdown dan/atau HTML mandiri — satu per pos, dengan frontmatter YAML dan tag Anda. Semuanya berjalan di browser Anda; tidak ada yang diunggah.', + how: 'Di admin Ghost: Settings → Migration → Export, lalu letakkan berkas JSON yang terunduh di sini.', + drop: 'Letakkan ekspor Ghost Anda (.json)', dropSub: 'Diproses di perangkat Anda — tanpa unggahan.', + loaded: (n, d, p) => `${n} pos ditemukan — ${d} draf, ${p} halaman.`, + optionsH: 'Yang disertakan', drafts: 'Sertakan draf (→ folder drafts/)', pages: 'Sertakan halaman (→ folder pages/)', + formatH: 'Format keluaran', + md: 'Markdown', mdNote: '.md + frontmatter YAML (untuk Astro/Hugo/Jekyll…)', + html: 'HTML', htmlNote: 'halaman .html mandiri (siap dihosting di R2/statis)', + both: 'Keduanya', + imageNote: 'Catatan: ekspor Ghost tidak menyertakan berkas gambar, hanya URL-nya — jadi tautan gambar tetap mengarah ke Ghost/CDN Anda. Simpan gambar secara terpisah jika Anda menutup situs.', + generate: 'Konversi & unduh ZIP', working: 'Mengonversi…', another: 'Pilih berkas lain', + errRead: 'Tidak dapat membaca berkas ini.', errGen: 'Tidak dapat mengonversi ekspor.', + }, +}; + +export default function GhostBackup({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [posts, setPosts] = useState(null); + const [includeDrafts, setIncludeDrafts] = useState(true); + const [includePages, setIncludePages] = useState(true); + const [format, setFormat] = useState('md'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const onDrop = async (files: File[]) => { + setError(''); + const f = files[0]; + if (!f) return; + try { + setPosts(parseGhostExport(await f.text())); + } catch (e) { + setPosts(null); + setError(e instanceof Error ? e.message : t.errRead); + } + }; + + const generate = async () => { + if (!posts) return; + setError(''); + setBusy(true); + try { + const selected = selectPosts(posts, { includeDrafts, includePages }); + const files: Record = {}; + const enc = new TextEncoder(); + + if (format === 'md' || format === 'both') { + const [{ default: Turndown }, gfm] = await Promise.all([ + import('turndown'), + import('@joplin/turndown-plugin-gfm'), + ]); + const td = new Turndown({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-', emDelimiter: '*' }); + td.use((gfm as { gfm: (s: unknown) => void }).gfm); + const htmlToMd = (html: string) => td.turndown(html); + for (const p of selected) { + const { path, content } = toMarkdown(p, htmlToMd); + files[path] = enc.encode(content); + } + } + if (format === 'html' || format === 'both') { + for (const p of selected) { + const { path, content } = toHtmlPage(p); + files[path] = enc.encode(content); + } + } + + const { zipSync } = await import('fflate'); + const zipped = zipSync(files, { level: 6 }); + downloadService.download(new Blob([zipped], { type: 'application/zip' }), 'ghost-backup.zip'); + } catch (e) { + setError(e instanceof Error ? e.message : t.errGen); + } finally { + setBusy(false); + } + }; + + const counts = posts + ? { total: posts.length, drafts: posts.filter(p => p.status !== 'published').length, pages: posts.filter(p => p.type === 'page').length } + : null; + + return ( +
+

{t.intro}

+ + {!posts && ( + <> + +
+

{t.drop}

+

{t.dropSub}

+
+
+

{t.how}

+ + )} + + {posts && counts && ( +
+ {t.loaded(counts.total, counts.drafts, counts.pages)} + +
+

{t.optionsH}

+ + +
+ +
+

{t.formatH}

+
+ {([['md', FileText, t.md, t.mdNote], ['html', FileCode, t.html, t.htmlNote], ['both', Download, t.both, '']] as const).map(([val, Icon, label, note]) => ( + + ))} +
+
+ +

{t.imageNote}

+ + {error && {error}} +
+ + +
+
+ )} + + {!posts && error && {error}} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 3cf3fa6..5c54b39 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config'; * a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data. */ const en: Record = { + 'ghost-backup': { + title: 'Free Ghost Blog Backup Tool — Export to Markdown', + description: 'A free tool to convert a Ghost blog export into Markdown or standalone HTML files — one per post, with frontmatter and tags. Runs in your browser; nothing is uploaded.', + intro: 'This free Ghost backup tool turns the JSON you export from Ghost admin into a ZIP of clean Markdown (or ready-to-host HTML) files — one per post, with YAML frontmatter and your tags. Perfect for backing up, migrating to a static site, or moving off Ghost. Everything is processed on your device.', + howTo: [ + 'In Ghost admin, go to Settings → Migration → Export and download your content JSON.', + 'Drop that JSON file here — it is parsed entirely in your browser.', + 'Choose what to include (drafts, pages) and the output format: Markdown, HTML, or both.', + 'Download a ZIP with one file per post, organized into posts/, pages/ and drafts/ folders.', + ], + faqs: [ + { q: 'Is my blog content uploaded anywhere?', a: 'No. Your Ghost export is read and converted entirely in your browser with JavaScript. The file never leaves your device.' }, + { q: 'What is the Ghost export format?', a: 'It is a single JSON file you download from Ghost admin (Settings → Migration → Export). It contains all your posts, pages, tags and metadata.' }, + { q: 'Does it include my images?', a: 'Ghost exports do not include image files — only their URLs. So image links in the output still point at your Ghost site or CDN; save the images separately if you are shutting the site down.' }, + { q: 'Can I use the output with Astro, Hugo or Jekyll?', a: 'Yes. Each post becomes a Markdown file with generic YAML frontmatter (title, slug, date, tags, draft…), which works with Astro, Hugo, Jekyll, 11ty and Obsidian with minimal tweaks. You can also export standalone HTML pages to host directly.' }, + ], + }, 'legacy-letter': { title: 'Free Digital Legacy Letter Tool — Encrypted', description: 'A free tool to write an encrypted letter of passwords and final words for your family — opened by password or family shares, only when the time comes. 100% in your browser.', @@ -1291,6 +1308,23 @@ const en: Record = { }; const id: Record = { + 'ghost-backup': { + title: 'Tool Cadangan Blog Ghost Gratis — Ekspor ke Markdown', + description: 'Tool gratis untuk mengubah ekspor blog Ghost menjadi berkas Markdown atau HTML mandiri — satu per pos, dengan frontmatter dan tag. Berjalan di browser; tidak ada yang diunggah.', + intro: 'Tool cadangan Ghost gratis ini mengubah JSON yang Anda ekspor dari admin Ghost menjadi ZIP berisi berkas Markdown yang rapi (atau HTML siap-hosting) — satu per pos, dengan frontmatter YAML dan tag Anda. Cocok untuk mencadangkan, migrasi ke situs statis, atau pindah dari Ghost. Semuanya diproses di perangkat Anda.', + howTo: [ + 'Di admin Ghost, buka Settings → Migration → Export dan unduh JSON konten Anda.', + 'Letakkan berkas JSON itu di sini — diurai sepenuhnya di browser Anda.', + 'Pilih yang disertakan (draf, halaman) dan format keluaran: Markdown, HTML, atau keduanya.', + 'Unduh ZIP berisi satu berkas per pos, tertata dalam folder posts/, pages/, dan drafts/.', + ], + faqs: [ + { q: 'Apakah konten blog saya diunggah ke suatu tempat?', a: 'Tidak. Ekspor Ghost Anda dibaca dan dikonversi sepenuhnya di browser Anda dengan JavaScript. Berkas tidak pernah meninggalkan perangkat.' }, + { q: 'Apa format ekspor Ghost?', a: 'Berupa satu berkas JSON yang Anda unduh dari admin Ghost (Settings → Migration → Export). Berisi semua pos, halaman, tag, dan metadata Anda.' }, + { q: 'Apakah menyertakan gambar saya?', a: 'Ekspor Ghost tidak menyertakan berkas gambar — hanya URL-nya. Jadi tautan gambar pada keluaran tetap mengarah ke situs Ghost atau CDN Anda; simpan gambar secara terpisah jika Anda menutup situs.' }, + { q: 'Bisakah keluarannya dipakai dengan Astro, Hugo, atau Jekyll?', a: 'Ya. Setiap pos menjadi berkas Markdown dengan frontmatter YAML generik (title, slug, date, tags, draft…), yang bekerja dengan Astro, Hugo, Jekyll, 11ty, dan Obsidian dengan sedikit penyesuaian. Anda juga dapat mengekspor halaman HTML mandiri untuk dihosting langsung.' }, + ], + }, 'legacy-letter': { title: 'Tool Surat Wasiat Digital Gratis — Terenkripsi', description: 'Tool gratis untuk menulis surat terenkripsi berisi kata sandi dan pesan terakhir untuk keluarga — dibuka dengan kata sandi atau bagian keluarga, hanya saat waktunya tiba. 100% di browser Anda.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index e9ff9d9..7500248 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -146,6 +146,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/dev/JsonToml'), status: 'stable' }, + { + id: 'ghost-backup', + name: 'Ghost Blog Backup', + category: 'Dev', + route: '/tools/ghost-backup', + keywords: ['ghost', 'blog', 'backup', 'export', 'markdown', 'html', 'migrate', 'static site', 'cms', 'convert', 'json'], + icon: Ghost, + summary: 'Convert a Ghost export to Markdown or HTML files (ZIP)', + load: () => import('@/islands/dev/GhostBackup'), + status: 'beta' + }, { id: 'markdown', name: 'Markdown Preview', diff --git a/src/tools/dev/ghost-export.lib.test.ts b/src/tools/dev/ghost-export.lib.test.ts new file mode 100644 index 0000000..ebd5b6d --- /dev/null +++ b/src/tools/dev/ghost-export.lib.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { parseGhostExport, selectPosts, postPath, frontmatter, toMarkdown, toHtmlPage } from './ghost-export.lib'; + +const EXPORT = JSON.stringify({ + db: [{ + data: { + posts: [ + { id: '1', title: 'Hello World', slug: 'hello-world', html: '

Hi there

', status: 'published', visibility: 'public', type: 'post', published_at: '2024-01-15T10:00:00.000Z', updated_at: '2024-01-16T10:00:00.000Z', custom_excerpt: 'A greeting', feature_image: 'https://cdn/x.jpg' }, + { id: '2', title: 'Draft Note', slug: 'draft-note', html: '

WIP

', status: 'draft', visibility: 'public', type: 'post' }, + { id: '3', title: 'About', slug: 'about', html: '

About us

', status: 'published', visibility: 'public', type: 'page' }, + { id: '4', title: 'Members Only', slug: 'members', html: '

secret

', status: 'published', visibility: 'members', type: 'post' }, + ], + tags: [{ id: '10', name: 'News' }, { id: '11', name: 'Updates' }], + posts_tags: [ + { post_id: '1', tag_id: '11', sort_order: 1 }, + { post_id: '1', tag_id: '10', sort_order: 0 }, + ], + }, + }], +}); + +describe('parseGhostExport', () => { + it('reads posts and joins tags in sort order', () => { + const posts = parseGhostExport(EXPORT); + expect(posts).toHaveLength(4); + const hello = posts.find(p => p.id === '1')!; + expect(hello.title).toBe('Hello World'); + expect(hello.tags).toEqual(['News', 'Updates']); // sort_order 0 then 1 + expect(hello.excerpt).toBe('A greeting'); + }); + + it('tolerates a top-level data object (no db array)', () => { + const posts = parseGhostExport(JSON.stringify({ data: { posts: [{ id: 'a', title: 'T', slug: 't', html: '

x

', status: 'published', type: 'post' }] } })); + expect(posts[0].title).toBe('T'); + }); + + it('throws on invalid JSON and on non-Ghost files', () => { + expect(() => parseGhostExport('not json')).toThrow(/not valid JSON/); + expect(() => parseGhostExport('{"db":[{"data":{"posts":[]}}]}')).toThrow(/No posts/); + }); +}); + +describe('selectPosts', () => { + const posts = parseGhostExport(EXPORT); + it('everything: all 4', () => { + expect(selectPosts(posts, { includeDrafts: true, includePages: true })).toHaveLength(4); + }); + it('published posts only: excludes draft + page', () => { + const sel = selectPosts(posts, { includeDrafts: false, includePages: false }); + expect(sel.map(p => p.id).sort()).toEqual(['1', '4']); + }); + it('drafts included but no pages', () => { + const sel = selectPosts(posts, { includeDrafts: true, includePages: false }); + expect(sel.map(p => p.id).sort()).toEqual(['1', '2', '4']); + }); +}); + +describe('postPath', () => { + const posts = parseGhostExport(EXPORT); + const byId = (id: string) => posts.find(p => p.id === id)!; + it('routes by status/type into folders', () => { + expect(postPath(byId('1'), 'md')).toBe('posts/hello-world.md'); + expect(postPath(byId('2'), 'md')).toBe('drafts/draft-note.md'); // draft + expect(postPath(byId('3'), 'html')).toBe('pages/about.html'); // page + }); +}); + +describe('frontmatter', () => { + const hello = parseGhostExport(EXPORT).find(p => p.id === '1')!; + it('emits generic YAML with the key fields', () => { + const fm = frontmatter(hello); + expect(fm.startsWith('---\n')).toBe(true); + expect(fm.trimEnd().endsWith('---')).toBe(true); + expect(fm).toContain('title: Hello World'); + expect(fm).toContain('slug: hello-world'); + expect(fm).toContain('draft: false'); + expect(fm).toMatch(/tags:\n\s+- News\n\s+- Updates/); + expect(fm).toContain('feature_image: https://cdn/x.jpg'); + }); + it('marks drafts and members visibility', () => { + const posts = parseGhostExport(EXPORT); + expect(frontmatter(posts.find(p => p.id === '2')!)).toContain('draft: true'); + expect(frontmatter(posts.find(p => p.id === '4')!)).toContain('visibility: members'); + }); +}); + +describe('toMarkdown / toHtmlPage', () => { + const hello = parseGhostExport(EXPORT).find(p => p.id === '1')!; + it('combines frontmatter + converted body', () => { + const { path, content } = toMarkdown(hello, html => html.replace(/<[^>]+>/g, '').trim()); + expect(path).toBe('posts/hello-world.md'); + expect(content).toContain('title: Hello World'); + expect(content).toContain('Hi there'); // stub strips tags + }); + it('wraps a standalone HTML page with title + content', () => { + const { path, content } = toHtmlPage(hello); + expect(path).toBe('posts/hello-world.html'); + expect(content).toContain('Hello World'); + expect(content).toContain(''); + expect(content).toContain('

Hi there

'); + }); + it('escapes HTML-unsafe titles', () => { + const evil = { ...hello, title: 'A & "x"' }; + expect(toHtmlPage(evil).content).toContain('A <b>& "x"'); + }); +}); diff --git a/src/tools/dev/ghost-export.lib.ts b/src/tools/dev/ghost-export.lib.ts new file mode 100644 index 0000000..9a1ece5 --- /dev/null +++ b/src/tools/dev/ghost-export.lib.ts @@ -0,0 +1,185 @@ +/** + * Parse a Ghost content export (the JSON you download from Ghost admin → + * Settings → Migration → Export) and turn each post into a Markdown or standalone + * HTML file. Pure + framework-free: HTML→Markdown conversion is injected (turndown + * lives in the island so this lib stays testable and dependency-light). + */ +import { stringify as yamlStringify } from 'yaml'; + +export interface NormalizedPost { + id: string; + title: string; + slug: string; + html: string; + status: string; // 'published' | 'draft' | 'scheduled' … + visibility: string; // 'public' | 'members' | 'paid' … + type: string; // 'post' | 'page' + createdAt?: string; + publishedAt?: string; + updatedAt?: string; + excerpt?: string; + featureImage?: string; + tags: string[]; +} + +export interface ExportOptions { + includeDrafts: boolean; + includePages: boolean; +} + +interface RawPost { + id?: string | number; + title?: string; + slug?: string; + html?: string; + status?: string; + visibility?: string; + type?: string; + page?: boolean; // older exports mark pages with `page: true` + created_at?: string; + published_at?: string; + updated_at?: string; + custom_excerpt?: string; + excerpt?: string; + feature_image?: string; + tags?: { name?: string }[]; +} + +/** Read the `data` object out of a Ghost export, tolerating a few shapes. */ +function exportData(obj: unknown): Record { + const o = obj as Record; + const db = o?.db as Array<{ data?: Record }> | undefined; + return (db?.[0]?.data ?? (o?.data as Record) ?? o ?? {}) as Record; +} + +/** Parse a Ghost export JSON string into normalized posts (tags joined). */ +export function parseGhostExport(jsonText: string): NormalizedPost[] { + let obj: unknown; + try { + obj = JSON.parse(jsonText); + } catch { + throw new Error('This file is not valid JSON — export your content from Ghost admin → Settings → Migration.'); + } + const data = exportData(obj); + const posts = (data.posts as RawPost[]) ?? []; + if (!Array.isArray(posts) || posts.length === 0) { + throw new Error('No posts found — is this a Ghost content export (a “db”/“data.posts” JSON)?'); + } + + // Tags come as a separate array + a posts_tags join table. + const tags = (data.tags as { id?: string | number; name?: string }[]) ?? []; + const tagById = new Map(tags.map(t => [String(t.id), t.name ?? ''])); + const joins = (data.posts_tags as { post_id?: string | number; tag_id?: string | number; sort_order?: number }[]) ?? []; + const tagsForPost = new Map(); + for (const j of joins.slice().sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))) { + const name = tagById.get(String(j.tag_id)); + if (!name) continue; + const key = String(j.post_id); + (tagsForPost.get(key) ?? tagsForPost.set(key, []).get(key)!).push(name); + } + + return posts.map((p): NormalizedPost => { + const id = String(p.id ?? ''); + const embedded = Array.isArray(p.tags) ? p.tags.map(t => t?.name ?? '').filter(Boolean) : []; + return { + id, + title: p.title ?? 'Untitled', + slug: p.slug ?? '', + html: p.html ?? '', + status: p.status ?? 'draft', + visibility: p.visibility ?? 'public', + type: p.type ?? (p.page ? 'page' : 'post'), + createdAt: p.created_at, + publishedAt: p.published_at ?? undefined, + updatedAt: p.updated_at, + excerpt: p.custom_excerpt ?? p.excerpt ?? undefined, + featureImage: p.feature_image ?? undefined, + tags: embedded.length ? embedded : (tagsForPost.get(id) ?? []), + }; + }); +} + +/** Apply the include-drafts / include-pages options. */ +export function selectPosts(posts: NormalizedPost[], opts: ExportOptions): NormalizedPost[] { + return posts.filter(p => { + if (p.type === 'page' && !opts.includePages) return false; + if (p.status !== 'published' && !opts.includeDrafts) return false; + return true; + }); +} + +/** A filesystem-safe slug (Ghost slugs already are, but guard empties/odd chars). */ +function safeSlug(post: NormalizedPost): string { + const s = (post.slug || post.title || post.id || 'untitled') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return s || 'untitled'; +} + +/** Output folder: drafts/ for anything unpublished, pages/ for pages, else posts/. */ +export function postPath(post: NormalizedPost, ext: 'md' | 'html'): string { + const folder = post.status !== 'published' ? 'drafts' : post.type === 'page' ? 'pages' : 'posts'; + return `${folder}/${safeSlug(post)}.${ext}`; +} + +/** Generic YAML frontmatter block (portable across Astro/Hugo/Jekyll/11ty). */ +export function frontmatter(post: NormalizedPost): string { + const fm: Record = { + title: post.title, + slug: post.slug || safeSlug(post), + date: post.publishedAt ?? post.createdAt, + updated: post.updatedAt, + draft: post.status !== 'published', + }; + if (post.visibility && post.visibility !== 'public') fm.visibility = post.visibility; + if (post.type === 'page') fm.type = 'page'; + if (post.excerpt) fm.excerpt = post.excerpt; + if (post.featureImage) fm.feature_image = post.featureImage; + if (post.tags.length) fm.tags = post.tags; + // Drop undefined keys so the YAML stays clean. + for (const k of Object.keys(fm)) if (fm[k] === undefined) delete fm[k]; + return `---\n${yamlStringify(fm).trimEnd()}\n---\n`; +} + +/** Build the Markdown file for a post. `htmlToMd` converts the post's HTML body. */ +export function toMarkdown(post: NormalizedPost, htmlToMd: (html: string) => string): { path: string; content: string } { + const body = post.html ? htmlToMd(post.html).trim() : ''; + return { path: postPath(post, 'md'), content: `${frontmatter(post)}\n${body}\n` }; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** Build a minimal, self-contained HTML page for a post — ready to host statically. */ +export function toHtmlPage(post: NormalizedPost): { path: string; content: string } { + const desc = post.excerpt ? `\n ` : ''; + const hero = post.featureImage ? `\n ` : ''; + const tags = post.tags.length ? `\n

${post.tags.map(escapeHtml).join(' · ')}

` : ''; + const date = post.publishedAt ?? post.createdAt ?? ''; + const content = ` + + + + + ${escapeHtml(post.title)}${desc} + + + +
+

${escapeHtml(post.title)}

+ ${date ? `` : ''}${hero} + ${post.html}${tags} +
+ + +`; + return { path: postPath(post, 'html'), content }; +}