Kode Konversi Ghost Saya ke Markdown
Karena sudah malas mengurus Ghost, saya berpindah kembali ke flatfile (BSSG), dan saya mencoba menyederhanakan teknik saya dalam membuat postingan. Skrip migrasi saya akan saya tulis di sini.
Skrip ini juga hasil belajar dengen menggunakan deno.
// Import required modules
import { parse, stringify } from "jsr:@std/yaml";
import * as Path from "jsr:@std/path";
import TurndownService from "npm:turndown";
import { gfm } from "npm:@guyplusplus/turndown-plugin-gfm";
// Configuration settings
const GHOST_API_URL = "https://web";
const API_KEY = "key";
const MAX_FILENAME_LENGTH = 240;
const OUTPUT_DIRECTORY = "content/blog";
// Ghost API communication
async function ghostFetch(endpoint: string) {
const url = `${GHOST_API_URL}/ghost/api/content/${endpoint}?key=${API_KEY}&include=authors,tags&limit=all`;
try {
const response = await fetch(url);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`API request failed: ${response.status} ${response.statusText}\n${errorBody}`,
;
)
}
const contentType = response.headers.get("content-type");
if (!contentType?.includes("application/json")) {
throw new Error(`Unexpected content type: ${contentType}`);
}
return await response.json();
catch (error) {
} console.error(`API Error in ${endpoint}:`, error);
throw error;
}
}
async function getPosts() {
return ghostFetch(`posts/`);
}
async function getSettings() {
return ghostFetch(`settings/`);
}
// Content processors
function processCaption(html: string): {
: string | null;
markdown: string;
plain
} {if (!html) return { markdown: null, plain: "" };
// Convert HTML to Markdown
const markdown = turndownService.turndown(html);
// Convert HTML to plain text
const plain = html
.replace(/<[^>]+>/g, "") // Remove HTML tags
.replace(/\s+/g, " ") // Replace multiple whitespace with one space
.trim();
return { markdown, plain };
}
// Single date handling function with format options
function formatDate(
: string,
dateStr: "iso" | "filename" = "iso",
formatType: string {
)if (!dateStr) {
const now = new Date();
return formatType === "filename"
? now.toISOString().split("T")[0]
: now.toISOString();
}
try {
const dt = new Date(dateStr);
return formatType === "filename"
? dt.toISOString().split("T")[0]
: dt.toISOString();
catch (e) {
} // Fallback: try to extract with split
try {
if (formatType === "filename") {
return dateStr.includes("T")
? dateStr.split("T")[0]
: dateStr.split(" ")[0];
}return dateStr;
catch {
} const now = new Date();
return formatType === "filename"
? now.toISOString().split("T")[0]
: now.toISOString();
}
}
}
// File utilities
function sanitizeFilename(slug: string, datePrefix: string): string {
// Remove characters that might cause issues in filenames
const safeSlug = slug.replace(/[^\w\-]/g, "");
// Calculate maximum slug length to keep total filename under limit
const maxSlugLength = MAX_FILENAME_LENGTH - 14;
const truncatedSlug =
.length > maxSlugLength
safeSlug? safeSlug.substring(0, maxSlugLength)
: safeSlug;
return `${datePrefix}-${truncatedSlug}.md`;
}
async function exists(path: string): Promise<boolean> {
try {
await Deno.stat(path);
return true;
catch {
} return false;
}
}
async function ensureOutputDirectory(): Promise<string> {
await Deno.mkdir(OUTPUT_DIRECTORY, { recursive: true });
return OUTPUT_DIRECTORY;
}
async function writePostFile(
: string,
filePath: string,
content: Promise<boolean> {
)try {
await Deno.writeTextFile(filePath, content);
return true;
catch (e) {
} console.error(`Error writing file ${filePath}: ${e}`);
return false;
}
}
// Setup Turndown for HTML to Markdown conversion
const turndownService = new TurndownService({
: "fenced",
codeBlockStyle: "atx",
headingStyle: "---",
hr;
})
.addRule("youtubeIFrame", {
turndownService: (node: HTMLElement) =>
filter.nodeName === "IFRAME" &&
node/youtube/i.test(node.getAttribute("src") ?? ""),
: (_content: string, node: HTMLElement) => {
replacementconst src = node.getAttribute("src") ?? "";
const videoId = src.split("/").pop()?.split("?")[0] ?? "";
return `\n\n{{< youtube ${videoId} >}}\n\n`;
,
};
})
.addRule("ghostCallout", {
turndownService: (node: HTMLElement) => {
filterreturn (
.nodeName === "DIV" &&
node.classList.contains("kg-card") &&
node.classList.contains("kg-callout-card")
node;
),
}: (_content: string, node: HTMLElement) => {
replacementconst emojiDiv = node.querySelector(".kg-callout-emoji");
const textDiv = node.querySelector(".kg-callout-text");
// Get emoji and clean text content
const emoji = emojiDiv?.textContent?.trim() || "";
const textContent = textDiv
? turndownService.turndown(textDiv.innerHTML)
: "";
const cleanedContent = textContent.replace(/<\/?[^>]+(>|$)/g, "").trim();
// Map Ghost color classes to Hugo types
const colorClass = Array.from(node.classList).find((c) =>
.startsWith("kg-callout-card-"),
c;
)const colorTypeMap: Record<string, string> = {
: "info",
blue: "warning",
yellow: "error",
red;
}
const type = colorClass
? colorTypeMap[colorClass.replace("kg-callout-card-", "")] ||
.replace("kg-callout-card-", "")
colorClass: null;
// Build type attribute if we have a valid mapping
const typeAttribute = type && colorTypeMap[type] ? ` type="${type}"` : "";
return `\n\n{{< callout emoji="${emoji}"${typeAttribute} >}}\n ${cleanedContent}\n{{< /callout >}}\n\n`;
,
};
})
.use(gfm);
turndownService
// Yaml frontmatter generator
function generateFrontmatter(
: any,
post: { markdown: string | null; plain: string },
featureImageCaption: string {
)// Get title and description
const title = post.meta_title || post.title || "Untitled Post";
const description = post.meta_description || post.excerpt || "";
// Format dates
const formattedDate = post.published_at
? formatDate(post.published_at, "iso")
: "";
const formattedLastmod = post.updated_at
? formatDate(post.updated_at, "iso")
: "";
// Format tags
const formattedTags =
.tags
post?.map((t: any) => t.name)
.filter(Boolean)
.join(", ") || "";
// Build frontmatter lines
const frontmatter = ["---"];
.push(`title: ${title}`);
frontmatter.push(`date: ${formattedDate}`);
frontmatter
if (formattedLastmod) {
.push(`lastmod: ${formattedLastmod}`);
frontmatter
}
if (formattedTags) {
.push(`tags: ${formattedTags}`);
frontmatter
}
.push(`slug: ${post.slug || ""}`);
frontmatter
if (post.feature_image) {
.push(`image: ${post.feature_image}`);
frontmatterif (featureImageCaption.plain) {
.push(`image_caption: ${featureImageCaption.plain}`);
frontmatter
}
}
if (description) {
.push(`description: ${description}`);
frontmatter
}
.push("---");
frontmatter
return frontmatter.join("\n");
}
// Main content generation function
async function generateContent(force = false): Promise<boolean> {
console.time("Posts conversion completed in");
try {
// Ensure output directory exists once at the start
const outputDir = await ensureOutputDirectory();
// Get posts from API
const { posts } = await getPosts();
console.log(`Found ${posts.length} posts to process`);
let processedCount = 0;
let skippedCount = 0;
for (const post of posts) {
const slug = post.slug || "";
// Get date for filename in YYYY-MM-DD format
const datePrefix = formatDate(post.published_at, "filename");
// Create safe filename with date prefix
const filename = sanitizeFilename(slug, datePrefix);
const filePath = Path.join(outputDir, filename);
// Check if file needs to be updated
const fileExists = await exists(filePath);
const shouldUpdate = force || !fileExists;
if (!shouldUpdate) {
console.log(`Skipping post: ${filename} (file already exists)`);
++;
skippedCountcontinue;
}
console.log(
`Processing: ${filename} (${fileExists ? "forced update" : "new post"})`,
;
)
// Process HTML to Markdown
const content = turndownService.turndown(post.html || "");
// Process feature image caption
const featureImageCaption = processCaption(
.feature_image_caption || "",
post;
)
// Generate frontmatter
const frontmatterText = generateFrontmatter(post, featureImageCaption);
// Combine frontmatter and content
const fileContent = `${frontmatterText}\n${content}\n`;
// Write file
await writePostFile(filePath, fileContent);
++;
processedCount
}
console.timeEnd("Posts conversion completed in");
console.log(
`Conversion complete: ${processedCount} posts processed, ${skippedCount} skipped`,
;
)
return true;
catch (error) {
} console.error(`Error: ${error}`);
return false;
}
}
// Site configuration function
async function siteConfig() {
console.log("Setting up site config...");
try {
const { settings } = await getSettings();
const config = parse(await Deno.readTextFile("config.yml")) as any;
.title = settings.title;
config.timeZone = settings.timezone;
config.params.title = settings.title;
config.params.description = settings.description;
config
await Deno.writeTextFile("config.yml", stringify(config));
return true;
catch (error) {
} console.error(`Error setting up site config: ${error}`);
return false;
}
}
// Simplified command line argument handling
async function main() {
const args = Deno.args;
const force = args.includes("--force");
// Generate content with force flag if provided
return await generateContent(force);
}
// Execute main function if this is the main module
if (import.meta.main) {
const success = await main();
.exit(success ? 0 : 1);
Deno }
Seperti biasa, jika ada yang salah, harap maklum.