Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
171 changes: 171 additions & 0 deletions src/islands/dev/GhostBackup.tsx
Original file line number Diff line number Diff line change
@@ -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';

Check warning on line 15 in src/islands/dev/GhostBackup.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

'input' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 15 in src/islands/dev/GhostBackup.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

'input' is assigned a value but never used. Allowed unused vars must match /^_/u

const TR: Record<Lang, {
intro: string; how: string; drop: string; dropSub: string;
loaded: (n: number, d: number, p: number) => 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<NormalizedPost[] | null>(null);
const [includeDrafts, setIncludeDrafts] = useState(true);
const [includePages, setIncludePages] = useState(true);
const [format, setFormat] = useState<Format>('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<string, Uint8Array> = {};
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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

{!posts && (
<>
<Dropzone onDrop={onDrop} accept=".json,application/json" multiple={false}>
<div className="space-y-1">
<p className="flex items-center justify-center gap-2 text-lg font-bold"><Ghost className="h-5 w-5" /> {t.drop}</p>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>
<p className="text-xs text-muted-foreground">{t.how}</p>
</>
)}

{posts && counts && (
<div className="space-y-4">
<Alert variant="success">{t.loaded(counts.total, counts.drafts, counts.pages)}</Alert>

<div className="space-y-2 border-2 border-border p-3">
<p className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.optionsH}</p>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={includeDrafts} onChange={e => setIncludeDrafts(e.target.checked)} /> {t.drafts}</label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={includePages} onChange={e => setIncludePages(e.target.checked)} /> {t.pages}</label>
</div>

<div className="space-y-2 border-2 border-border p-3">
<p className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.formatH}</p>
<div className="grid gap-2 sm:grid-cols-3">
{([['md', FileText, t.md, t.mdNote], ['html', FileCode, t.html, t.htmlNote], ['both', Download, t.both, '']] as const).map(([val, Icon, label, note]) => (
<button key={val} type="button" onClick={() => setFormat(val)} aria-pressed={format === val}
className={`flex flex-col gap-1 border-2 border-border p-2 text-left text-sm press-brutal ${format === val ? 'bg-accent text-accent-foreground' : 'bg-muted'}`}>
<span className="flex items-center gap-1.5 font-bold"><Icon className="h-4 w-4" /> {label}</span>
{note && <span className={format === val ? 'text-accent-foreground/80' : 'text-muted-foreground'}>{note}</span>}
</button>
))}
</div>
</div>

<p className="border-2 border-border bg-muted px-3 py-2 text-xs text-muted-foreground">{t.imageNote}</p>

{error && <Alert variant="error">{error}</Alert>}
<div className="flex flex-wrap gap-2">
<Button onClick={generate} disabled={busy}><Download className="h-4 w-4" /> {busy ? t.working : t.generate}</Button>
<Button variant="secondary" onClick={() => { setPosts(null); setError(''); }}>{t.another}</Button>
</div>
</div>
)}

{!posts && error && <Alert variant="error">{error}</Alert>}
</div>
);
}
34 changes: 34 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolSeoContent> = {
'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.',
Expand Down Expand Up @@ -1291,6 +1308,23 @@ const en: Record<string, ToolSeoContent> = {
};

const id: Record<string, ToolSeoContent> = {
'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.',
Expand Down
13 changes: 12 additions & 1 deletion src/registry/tools.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading