A personal journal.

Kode Konversi Ghost Saya ke Markdown

Published on: 25/04/2025 • Updated on: 14/05/2025 • 5 min read

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): {
  markdown: string | null;
  plain: string;
} {
  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(
  dateStr: string,
  formatType: "iso" | "filename" = "iso",
): 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 =
    safeSlug.length > maxSlugLength
      ? 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(
  filePath: string,
  content: string,
): 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({
  codeBlockStyle: "fenced",
  headingStyle: "atx",
  hr: "---",
});

turndownService.addRule("youtubeIFrame", {
  filter: (node: HTMLElement) =>
    node.nodeName === "IFRAME" &&
    /youtube/i.test(node.getAttribute("src") ?? ""),
  replacement: (_content: string, node: HTMLElement) => {
    const src = node.getAttribute("src") ?? "";
    const videoId = src.split("/").pop()?.split("?")[0] ?? "";
    return `\n\n{{< youtube ${videoId} >}}\n\n`;
  },
});

turndownService.addRule("ghostCallout", {
  filter: (node: HTMLElement) => {
    return (
      node.nodeName === "DIV" &&
      node.classList.contains("kg-card") &&
      node.classList.contains("kg-callout-card")
    );
  },
  replacement: (_content: string, node: HTMLElement) => {
    const 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) =>
      c.startsWith("kg-callout-card-"),
    );
    const colorTypeMap: Record<string, string> = {
      blue: "info",
      yellow: "warning",
      red: "error",
    };

    const type = colorClass
      ? colorTypeMap[colorClass.replace("kg-callout-card-", "")] ||
        colorClass.replace("kg-callout-card-", "")
      : 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`;
  },
});

turndownService.use(gfm);

// Yaml frontmatter generator
function generateFrontmatter(
  post: any,
  featureImageCaption: { markdown: string | null; plain: string },
): 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 =
    post.tags
      ?.map((t: any) => t.name)
      .filter(Boolean)
      .join(", ") || "";

  // Build frontmatter lines
  const frontmatter = ["---"];
  frontmatter.push(`title: ${title}`);
  frontmatter.push(`date: ${formattedDate}`);

  if (formattedLastmod) {
    frontmatter.push(`lastmod: ${formattedLastmod}`);
  }

  if (formattedTags) {
    frontmatter.push(`tags: ${formattedTags}`);
  }

  frontmatter.push(`slug: ${post.slug || ""}`);

  if (post.feature_image) {
    frontmatter.push(`image: ${post.feature_image}`);
    if (featureImageCaption.plain) {
      frontmatter.push(`image_caption: ${featureImageCaption.plain}`);
    }
  }

  if (description) {
    frontmatter.push(`description: ${description}`);
  }

  frontmatter.push("---");

  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)`);
        skippedCount++;
        continue;
      }

      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(
        post.feature_image_caption || "",
      );

      // 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;

    config.title = settings.title;
    config.timeZone = settings.timezone;
    config.params.title = settings.title;
    config.params.description = settings.description;

    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();
  Deno.exit(success ? 0 : 1);
}

Seperti biasa, jika ada yang salah, harap maklum.