import pluginWebmentions from "@chrisburnell/eleventy-cache-webmentions";
import pluginRss from "@11ty/eleventy-plugin-rss";
import embedEverything from "eleventy-plugin-embed-everything";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import sitemap from "@quasibit/eleventy-plugin-sitemap";
import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import { minify } from "html-minifier-terser";
import registerUnfurlShortcode, { getCachedCard, prefetchUrl } from "./lib/unfurl-shortcode.js";
import matter from "gray-matter";
import { createHash, createHmac } from "crypto";
import { createRequire } from "module";
import { execFileSync } from "child_process";
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";

const esmRequire = createRequire(import.meta.url);
const postGraph = esmRequire("@rknightuk/eleventy-plugin-post-graph");

const __dirname = dirname(fileURLToPath(import.meta.url));
const siteUrl = process.env.SITE_URL || "https://example.com";

export default function (eleventyConfig) {
  // Don't use .gitignore for determining what to process
  // (content/ is in .gitignore because it's a symlink, but we need to process it)
  eleventyConfig.setUseGitIgnore(false);

  // Ignore output directory (prevents re-processing generated files via symlink)
  eleventyConfig.ignores.add("_site");
  eleventyConfig.ignores.add("_site/**");
  eleventyConfig.ignores.add("/app/data/site");
  eleventyConfig.ignores.add("/app/data/site/**");
  eleventyConfig.ignores.add("node_modules");
  eleventyConfig.ignores.add("node_modules/**");
  eleventyConfig.ignores.add("CLAUDE.md");
  eleventyConfig.ignores.add("README.md");

  // Ignore Pagefind output directory
  eleventyConfig.ignores.add("pagefind");
  eleventyConfig.ignores.add("pagefind/**");
  // Ignore interactive assets (served via passthrough copy, not processed as templates)
  eleventyConfig.ignores.add("interactive");
  eleventyConfig.ignores.add("interactive/**");

  // Configure watch targets to exclude output directory
  eleventyConfig.watchIgnores.add("_site");
  eleventyConfig.watchIgnores.add("_site/**");
  eleventyConfig.watchIgnores.add("/app/data/site");
  eleventyConfig.watchIgnores.add("/app/data/site/**");
  eleventyConfig.watchIgnores.add("pagefind");
  eleventyConfig.watchIgnores.add("pagefind/**");
  eleventyConfig.watchIgnores.add(".cache/og");
  eleventyConfig.watchIgnores.add(".cache/og/**");
  eleventyConfig.watchIgnores.add(".cache/unfurl");
  eleventyConfig.watchIgnores.add(".cache/unfurl/**");

  // Watcher tuning: handle rapid successive file changes
  // When a post is created via Micropub, the file is written twice in quick
  // succession: first the initial content, then ~2s later a Micropub update
  // adds syndication URLs. awaitWriteFinish delays the watcher event until
  // the file is stable (no writes for 2s), so both changes are captured in
  // one build. The throttle adds a 3s build-level debounce on top.
  eleventyConfig.setChokidarConfig({
    awaitWriteFinish: {
      stabilityThreshold: 2000,
      pollInterval: 100,
    },
  });
  eleventyConfig.setWatchThrottleWaitTime(3000);

  // Configure markdown-it with linkify enabled (auto-convert URLs to links)
  const md = markdownIt({
    html: true,
    linkify: true,  // Auto-convert URLs to clickable links
    typographer: true,
  });
  md.use(markdownItAnchor, {
    permalink: markdownItAnchor.permalink.headerLink(),
    slugify: (s) => s.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""),
    level: [2, 3, 4],
  });

  // Hashtag plugin: converts #tag to category links on-site
  // Syndication targets (Bluesky, Mastodon) handle raw #tag natively via facet detection
  md.inline.ruler.push("hashtag", (state, silent) => {
    const pos = state.pos;
    if (state.src.charCodeAt(pos) !== 0x23 /* # */) return false;

    // Must be at start of string or preceded by whitespace/punctuation (not part of a URL fragment or hex color)
    if (pos > 0) {
      const prevChar = state.src.charAt(pos - 1);
      if (!/[\s()\[\]{},;:!?"'«»""'']/.test(prevChar)) return false;
    }

    // Match hashtag: # followed by letter/underscore, then word chars (letters, digits, underscores)
    const tail = state.src.slice(pos + 1);
    const match = tail.match(/^([a-zA-Z_]\w*)/);
    if (!match) return false;

    const tag = match[1];

    // Skip pure hex color codes (3, 4, 6, or 8 hex digits with nothing else)
    if (/^[0-9a-fA-F]{3,8}$/.test(tag)) return false;

    if (!silent) {
      const slug = tag.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
      const tokenOpen = state.push("link_open", "a", 1);
      tokenOpen.attrSet("href", `/categories/${slug}/`);
      tokenOpen.attrSet("class", "p-category hashtag");

      const tokenText = state.push("text", "", 0);
      tokenText.content = `#${tag}`;

      state.push("link_close", "a", -1);
    }

    state.pos = pos + 1 + tag.length;
    return true;
  });

  eleventyConfig.setLibrary("md", md);

  // Syntax highlighting for fenced code blocks (```lang)
  eleventyConfig.addPlugin(syntaxHighlight);

  // RSS plugin for feed filters (dateToRfc822, absoluteUrl, etc.)
  // Custom feed templates in feed.njk and feed-json.njk use these filters
  eleventyConfig.addPlugin(pluginRss);

  // Post graph — GitHub-style contribution grid for posting frequency
  eleventyConfig.addPlugin(postGraph, {
    sort: "desc",
    limit: 2,
    dayBoxTitle: true,
    selectorLight: ":root",
    selectorDark: ".dark",
    boxColorLight: "#e7e5e4",      // surface-200 (warm stone)
    highlightColorLight: "#d97706", // amber-600 (accent)
    textColorLight: "#1c1917",      // surface-900
    boxColorDark: "#292524",        // surface-800
    highlightColorDark: "#fbbf24",  // amber-400
    textColorDark: "#fafaf9",       // surface-50
  });

  // JSON encode filter for JSON feed
  eleventyConfig.addFilter("jsonEncode", (value) => {
    return JSON.stringify(value);
  });

  // Guess MIME type from URL extension
  function guessMimeType(url, category) {
    const lower = (typeof url === "string" ? url : "").toLowerCase();
    if (category === "photo") {
      if (lower.includes(".png")) return "image/png";
      if (lower.includes(".gif")) return "image/gif";
      if (lower.includes(".webp")) return "image/webp";
      if (lower.includes(".svg")) return "image/svg+xml";
      return "image/jpeg";
    }
    if (category === "audio") {
      if (lower.includes(".ogg") || lower.includes(".opus")) return "audio/ogg";
      if (lower.includes(".flac")) return "audio/flac";
      if (lower.includes(".wav")) return "audio/wav";
      return "audio/mpeg";
    }
    if (category === "video") {
      if (lower.includes(".webm")) return "video/webm";
      if (lower.includes(".mov")) return "video/quicktime";
      return "video/mp4";
    }
    return "application/octet-stream";
  }

  // Extract URL string from value that may be a string or {url, alt} object
  function resolveMediaUrl(value) {
    if (typeof value === "string") return value;
    if (value && typeof value === "object" && value.url) return value.url;
    return null;
  }

  // Feed attachments filter — builds JSON Feed attachments array from post data
  eleventyConfig.addFilter("feedAttachments", (postData) => {
    const attachments = [];
    const processMedia = (items, category) => {
      const list = Array.isArray(items) ? items : [items];
      for (const item of list) {
        const rawUrl = resolveMediaUrl(item);
        if (!rawUrl) continue;
        const url = rawUrl.startsWith("http") ? rawUrl : `${siteUrl}${rawUrl}`;
        attachments.push({ url, mime_type: guessMimeType(rawUrl, category) });
      }
    };
    if (postData.photo) processMedia(postData.photo, "photo");
    if (postData.audio) processMedia(postData.audio, "audio");
    if (postData.video) processMedia(postData.video, "video");
    return attachments;
  });

  // Textcasting support filter — builds clean support object excluding null values
  eleventyConfig.addFilter("textcastingSupport", (support) => {
    if (!support) return {};
    const obj = {};
    if (support.url) obj.url = support.url;
    if (support.stripe) obj.stripe = support.stripe;
    if (support.lightning) obj.lightning = support.lightning;
    if (support.paymentPointer) obj.payment_pointer = support.paymentPointer;
    return obj;
  });

  // Protocol type filter — classifies a URL by its origin protocol/network
  eleventyConfig.addFilter("protocolType", (url) => {
    if (!url || typeof url !== "string") return "web";
    const lower = url.toLowerCase();
    if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
    // Match Fediverse instances by known domain patterns (avoid overly broad "social")
    if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
        lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.") ||
        lower.includes("fediverse")) return "fediverse";
    return "web";
  });

  // Email obfuscation filter - converts email to HTML entities
  // Blocks ~95% of spam harvesters while remaining valid for microformat parsers
  // Usage: {{ email | obfuscateEmail }} or {{ email | obfuscateEmail("href") }}
  eleventyConfig.addFilter("obfuscateEmail", (email, mode = "display") => {
    if (!email) return "";
    // Convert each character to HTML decimal entity
    const encoded = [...email].map(char => `&#${char.charCodeAt(0)};`).join("");
    if (mode === "href") {
      // For mailto: links, also encode the "mailto:" prefix
      const mailto = [...("mailto:")].map(char => `&#${char.charCodeAt(0)};`).join("");
      return mailto + encoded;
    }
    return encoded;
  });

  // Alias dateToRfc822 (plugin provides dateToRfc2822)
  eleventyConfig.addFilter("dateToRfc822", (date) => {
    return pluginRss.dateToRfc2822(date);
  });

  // Embed Everything - auto-embed YouTube, Vimeo, Bluesky, Mastodon, etc.
  eleventyConfig.addPlugin(embedEverything, {
    use: ["youtube", "vimeo", "twitter", "mastodon", "bluesky", "spotify", "soundcloud"],
    youtube: {
      options: {
        lite: false,
        recommendSelfOnly: true,
      },
    },
    mastodon: {
      options: {
        server: "indieweb.social",
      },
    },
  });

  // Unfurl shortcode — renders any URL as a rich card (OpenGraph/Twitter Card metadata)
  // Usage in templates: {% unfurl "https://example.com/article" %}
  registerUnfurlShortcode(eleventyConfig);

  // Synchronous unfurl filter — reads from pre-populated disk cache.
  // Safe for deeply nested includes where async shortcodes fail silently.
  // Usage: {{ url | unfurlCard | safe }}
  eleventyConfig.addFilter("unfurlCard", getCachedCard);

  // Custom transform to convert YouTube links to embeds
  eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
    if (!outputPath || !outputPath.endsWith(".html")) {
      return content;
    }
    // Match <a> tags where href contains youtube.com/watch or youtu.be
    // Link text can be: URL, www.youtube..., youtube..., or youtube-related text
    const youtubePattern = /<a[^>]+href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)[^"]*"[^>]*>(?:https?:\/\/)?(?:www\.)?[^<]*(?:youtube|youtu\.be)[^<]*<\/a>/gi;

    content = content.replace(youtubePattern, (match, videoId) => {
      // Use standard YouTube iframe with exact oEmbed parameters
      return `</p><div class="video-embed"><iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen title="YouTube video"></iframe></div><p>`;
    });

    // Clean up empty <p></p> tags created by the replacement
    content = content.replace(/<p>\s*<\/p>/g, '');

    return content;
  });

  // Image optimization - transforms <img> tags automatically
  // PROCESS_REMOTE_IMAGES: set to "true" to let Sharp download and re-encode remote images.
  // Default "false" — skips remote URLs (adds eleventy:ignore) to avoid OOM from Sharp's
  // native memory usage when processing hundreds of external images (bookmarks, webmentions).
  const processRemoteImages = process.env.PROCESS_REMOTE_IMAGES === "true";
  if (!processRemoteImages) {
    eleventyConfig.htmlTransformer.addPosthtmlPlugin("html", () => {
      return (tree) => {
        tree.match({ tag: "img" }, (node) => {
          if (node.attrs?.src && /^https?:\/\//.test(node.attrs.src)) {
            node.attrs["eleventy:ignore"] = "";
          }
          return node;
        });
        return tree;
      };
    }, { priority: 1 }); // priority > 0 runs before image plugin (priority -1)
  }

  eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
    extensions: "html",
    formats: ["webp", "jpeg"],
    widths: ["auto"],
    failOnError: false,
    cacheOptions: {
      duration: process.env.ELEVENTY_RUN_MODE === "build" ? "1d" : "30d",
    },
    concurrency: 4,
    defaultAttributes: {
      loading: "lazy",
      decoding: "async",
      sizes: "auto",
      alt: "",
    },
  });

  // Sitemap generation
  eleventyConfig.addPlugin(sitemap, {
    sitemap: {
      hostname: siteUrl,
    },
  });

  // Wrap <table> elements in <table-saw> for responsive tables
  eleventyConfig.addTransform("table-saw-wrap", function (content, outputPath) {
    if (outputPath && outputPath.endsWith(".html")) {
      return content.replace(/<table(\s|>)/g, "<table-saw><table$1").replace(/<\/table>/g, "</table></table-saw>");
    }
    return content;
  });

  // Fix OG image meta tags post-rendering — bypasses Eleventy 3.x race condition (#3183).
  // page.url is unreliable during parallel rendering, but outputPath IS correct
  // since files are written to the correct location. Derives the OG slug from
  // outputPath and replaces placeholders emitted by base.njk.
  eleventyConfig.addTransform("og-fix", function (content, outputPath) {
    if (!outputPath || !outputPath.endsWith(".html")) return content;

    // Derive correct page URL and OG slug from outputPath (immune to race condition)
    // Content pages match: .../type/yyyy/MM/dd/slug/index.html
    const dateMatch = outputPath.match(
      /\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/index\.html$/
    );

    if (dateMatch) {
      const [, type, year, month, day, slug] = dateMatch;
      const pageUrlPath = `/${type}/${year}/${month}/${day}/${slug}/`;
      const correctFullUrl = `${siteUrl}${pageUrlPath}`;
      const ogSlug = `${year}-${month}-${day}-${slug}`;
      const hasOg = existsSync(resolve(__dirname, ".cache", "og", `${ogSlug}.png`));
      const ogImageUrl = hasOg
        ? `${siteUrl}/og/${ogSlug}.png`
        : `${siteUrl}/images/og-default.png`;
      const twitterCard = hasOg ? "summary_large_image" : "summary";

      // Fix og:url and canonical (also affected by race condition)
      content = content.replace(
        /(<meta property="og:url" content=")[^"]*(")/,
        `$1${correctFullUrl}$2`
      );
      content = content.replace(
        /(<link rel="canonical" href=")[^"]*(")/,
        `$1${correctFullUrl}$2`
      );

      // Replace OG image and twitter card placeholders
      content = content.replace(/__OG_IMAGE_PLACEHOLDER__/g, ogImageUrl);
      content = content.replace(/__TWITTER_CARD_PLACEHOLDER__/g, twitterCard);
    } else {
      // Non-date pages (homepage, about, etc.): use defaults
      content = content.replace(
        /__OG_IMAGE_PLACEHOLDER__/g,
        `${siteUrl}/images/og-default.png`
      );
      content = content.replace(/__TWITTER_CARD_PLACEHOLDER__/g, "summary");
    }

    return content;
  });

  // HTML minification — only during initial build, skip during watch rebuilds
  eleventyConfig.addTransform("htmlmin", async function (content, outputPath) {
    if (outputPath && outputPath.endsWith(".html") && process.env.ELEVENTY_RUN_MODE === "build") {
      try {
        return await minify(content, {
          collapseWhitespace: true,
          removeComments: true,
          html5: true,
          decodeEntities: true,
          minifyCSS: false,
          minifyJS: false,
        });
      } catch {
        console.warn(`[htmlmin] Parse error in ${outputPath}, skipping minification`);
        return content;
      }
    }
    return content;
  });

  // Copy static assets to output
  eleventyConfig.addPassthroughCopy("css");
  eleventyConfig.addPassthroughCopy("images");
  eleventyConfig.addPassthroughCopy("js");
  eleventyConfig.addPassthroughCopy("favicon.ico");
  eleventyConfig.addPassthroughCopy("interactive");
  eleventyConfig.addPassthroughCopy({ ".cache/og": "og" });

  // Copy vendor web components from node_modules
  eleventyConfig.addPassthroughCopy({
    "node_modules/@zachleat/table-saw/table-saw.js": "js/table-saw.js",
    "node_modules/@11ty/is-land/is-land.js": "js/is-land.js",
    "node_modules/@zachleat/filter-container/filter-container.js": "js/filter-container.js",
  });

  // Copy Inter font files (latin + latin-ext subsets, woff2 only for modern browsers)
  eleventyConfig.addPassthroughCopy({
    "node_modules/@fontsource/inter/files/inter-latin-*-normal.woff2": "fonts",
    "node_modules/@fontsource/inter/files/inter-latin-ext-*-normal.woff2": "fonts",
  });

  // Watch for content changes
  eleventyConfig.addWatchTarget("./content/");
  eleventyConfig.addWatchTarget("./css/");

  // Webmentions plugin configuration
  const wmDomain = siteUrl.replace("https://", "").replace("http://", "");
  eleventyConfig.addPlugin(pluginWebmentions, {
    domain: siteUrl,
    feed: `http://127.0.0.1:8080/webmentions/api/mentions?per-page=10000`,
    key: "children",
  });

  // Date formatting filter
  eleventyConfig.addFilter("dateDisplay", (dateObj) => {
    if (!dateObj) return "";
    const date = new Date(dateObj);
    return date.toLocaleDateString("en-GB", {
      year: "numeric",
      month: "long",
      day: "numeric",
    });
  });

  // ISO date filter
  eleventyConfig.addFilter("isoDate", (dateObj) => {
    if (!dateObj) return "";
    return new Date(dateObj).toISOString();
  });

  // Digest-to-HTML filter for RSS feed descriptions
  eleventyConfig.addFilter("digestToHtml", (digest, siteUrl) => {
    const typeLabels = {
      articles: "Articles",
      notes: "Notes",
      photos: "Photos",
      bookmarks: "Bookmarks",
      likes: "Likes",
      reposts: "Reposts",
    };
    const typeOrder = ["articles", "notes", "photos", "bookmarks", "likes", "reposts"];
    let html = "";

    for (const type of typeOrder) {
      const posts = digest.byType[type];
      if (!posts || !posts.length) continue;

      html += `<h3>${typeLabels[type]}</h3><ul>`;
      for (const post of posts) {
        const postUrl = siteUrl + post.url;
        let label;
        if (type === "likes") {
          const target = post.data.likeOf || post.data.like_of;
          label = `Liked: ${target}`;
        } else if (type === "bookmarks") {
          const target = post.data.bookmarkOf || post.data.bookmark_of;
          label = post.data.title || `Bookmarked: ${target}`;
        } else if (type === "reposts") {
          const target = post.data.repostOf || post.data.repost_of;
          label = `Reposted: ${target}`;
        } else if (post.data.title) {
          label = post.data.title;
        } else {
          const content = post.templateContent || "";
          label = content.replace(/<[^>]*>/g, "").slice(0, 120).trim() || "Untitled";
        }
        html += `<li><a href="${postUrl}">${label}</a></li>`;
      }
      html += `</ul>`;
    }

    return html;
  });

  // Truncate filter
  eleventyConfig.addFilter("truncate", (str, len = 200) => {
    if (!str) return "";
    if (str.length <= len) return str;
    return str.slice(0, len).trim() + "...";
  });

  // Clean excerpt for OpenGraph - strips HTML, decodes entities, removes extra whitespace
  eleventyConfig.addFilter("ogDescription", (content, len = 200) => {
    if (!content) return "";
    // Strip HTML tags
    let text = content.replace(/<[^>]+>/g, ' ');
    // Decode common HTML entities
    text = text.replace(/&amp;/g, '&')
               .replace(/&lt;/g, '<')
               .replace(/&gt;/g, '>')
               .replace(/&quot;/g, '"')
               .replace(/&#39;/g, "'")
               .replace(/&nbsp;/g, ' ');
    // Remove extra whitespace
    text = text.replace(/\s+/g, ' ').trim();
    // Truncate
    if (text.length > len) {
      text = text.slice(0, len).trim() + "...";
    }
    return text;
  });

  // Extract first image from content for OpenGraph fallback
  eleventyConfig.addFilter("extractFirstImage", (content) => {
    if (!content) return null;
    // Match all <img> tags, skip hidden ones and data URIs
    const imgRegex = /<img[^>]*?\ssrc=["']([^"']+)["'][^>]*>/gi;
    let match;
    while ((match = imgRegex.exec(content)) !== null) {
      const fullTag = match[0];
      const src = match[1];
      if (src.startsWith("data:")) continue;
      if (/\bhidden\b/.test(fullTag)) continue;
      return src;
    }
    return null;
  });

  // Head filter for arrays
  eleventyConfig.addFilter("head", (array, n) => {
    if (!Array.isArray(array) || n < 1) return array;
    return array.slice(0, n);
  });

  // Slugify filter
  eleventyConfig.addFilter("slugify", (str) => {
    if (!str) return "";
    return str
      .toLowerCase()
      .replace(/[^\w\s-]/g, "")
      .replace(/[\s_-]+/g, "-")
      .replace(/^-+|-+$/g, "");
  });

  eleventyConfig.addFilter("stripTrailingSlash", (url) => {
    if (!url || typeof url !== "string") return url || "";
    return url.endsWith("/") ? url.slice(0, -1) : url;
  });

  // Hash filter for cache busting - generates MD5 hash of file content
  eleventyConfig.addFilter("hash", (filePath) => {
    try {
      const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
      const content = readFileSync(fullPath);
      return createHash("md5").update(content).digest("hex").slice(0, 8);
    } catch {
      // Return timestamp as fallback if file not found
      return Date.now().toString(36);
    }
  });

  // Derive OG slug from page.url (reliable) instead of page.fileSlug
  // (which suffers from Nunjucks race conditions in Eleventy 3.x parallel rendering).
  // OG images are named with the full date prefix to match URL segments exactly.
  eleventyConfig.addFilter("ogSlug", (url) => {
    if (!url) return "";
    const segments = url.split("/").filter(Boolean);
    // Date-based URL: /type/yyyy/MM/dd/slug/ → 5 segments → "yyyy-MM-dd-slug"
    if (segments.length === 5) {
      const [, year, month, day, slug] = segments;
      return `${year}-${month}-${day}-${slug}`;
    }
    // Fallback: last segment (for pages, legacy URLs)
    return segments[segments.length - 1] || "";
  });

  // Check if a generated OG image exists for this slug
  eleventyConfig.addFilter("hasOgImage", (slug) => {
    if (!slug) return false;
    const ogPath = resolve(__dirname, ".cache", "og", `${slug}.png`);
    return existsSync(ogPath);
  });

  // Inline file contents (for critical CSS inlining)
  eleventyConfig.addFilter("inlineFile", (filePath) => {
    try {
      const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
      return readFileSync(fullPath, "utf-8");
    } catch {
      return "";
    }
  });

  // Extract raw Markdown body from a source file (strips front matter)
  eleventyConfig.addFilter("rawMarkdownBody", (inputPath) => {
    try {
      const src = readFileSync(inputPath, "utf-8");
      const { content } = matter(src);
      return content.trim();
    } catch {
      return "";
    }
  });

  // Current timestamp filter (for client-side JS buildtime)
  eleventyConfig.addFilter("timestamp", () => Date.now());

  // Date filter (for sidebar dates)
  eleventyConfig.addFilter("date", (dateObj, format) => {
    if (!dateObj) return "";
    const date = new Date(dateObj);
    const options = {};

    if (format.includes("MMM")) options.month = "short";
    if (format.includes("d")) options.day = "numeric";
    if (format.includes("yyyy")) options.year = "numeric";

    return date.toLocaleDateString("en-US", options);
  });

  // Webmention filters - with legacy URL support
  // This filter checks both current URL and any legacy URLs from redirects
  // Merges webmentions + conversations with deduplication (conversations first)
  eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url, urlAliases, conversationMentions = []) {
    if (!url) return [];

    // Merge conversations + webmentions with deduplication
    const seen = new Set();
    const merged = [];

    // Add conversations first (richer metadata)
    for (const item of conversationMentions) {
      const key = item['wm-id'] || item.url;
      if (key && !seen.has(key)) {
        seen.add(key);
        merged.push(item);
      }
    }

    // Add webmentions (skip duplicates)
    if (webmentions) {
      for (const item of webmentions) {
        const key = item['wm-id'];
        if (!key || seen.has(key)) continue;
        if (item.url && seen.has(item.url)) continue;
        seen.add(key);
        merged.push(item);
      }
    }

    // Build list of all URLs to check (current + legacy)
    const urlsToCheck = new Set();

    // Add current URL variations
    const absoluteUrl = url.startsWith("http") ? url : `${siteUrl}${url}`;
    urlsToCheck.add(absoluteUrl);
    urlsToCheck.add(absoluteUrl.replace(/\/$/, ""));
    urlsToCheck.add(absoluteUrl.endsWith("/") ? absoluteUrl : `${absoluteUrl}/`);

    // Add legacy URLs from aliases (if provided)
    if (urlAliases?.aliases) {
      const normalizedUrl = url.replace(/\/$/, "");
      const oldUrls = urlAliases.aliases[normalizedUrl] || [];
      for (const oldUrl of oldUrls) {
        urlsToCheck.add(`${siteUrl}${oldUrl}`);
        urlsToCheck.add(`${siteUrl}${oldUrl}/`);
        urlsToCheck.add(`${siteUrl}${oldUrl}`.replace(/\/$/, ""));
      }
    }

    // Compute legacy /content/ URL from current URL for old webmention.io targets
    // Pattern: /type/yyyy/MM/dd/slug/ → /content/type/yyyy-MM-dd-slug/
    const pathSegments = url.replace(/\/$/, "").split("/").filter(Boolean);
    if (pathSegments.length === 5) {
      const [type, year, month, day, slug] = pathSegments;
      const contentUrl = `/content/${type}/${year}-${month}-${day}-${slug}/`;
      urlsToCheck.add(`${siteUrl}${contentUrl}`);
      urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, ""));
    }

    // Filter merged data matching any of our URLs
    const matched = merged.filter((wm) => urlsToCheck.has(wm["wm-target"]));

    // Deduplicate cross-source: same author + same interaction type = same mention
    // (webmention.io and conversations API may both report the same like/reply)
    const deduped = [];
    const authorActions = new Set();
    for (const wm of matched) {
      const authorUrl = wm.author?.url || wm.url || "";
      const action = wm["wm-property"] || "mention";
      const key = `${authorUrl}::${action}`;
      if (authorActions.has(key)) continue;
      authorActions.add(key);
      deduped.push(wm);
    }
    return deduped;
  });

  eleventyConfig.addFilter("webmentionsByType", function (mentions, type) {
    if (!mentions) return [];
    const typeMap = {
      likes: "like-of",
      reposts: "repost-of",
      bookmarks: "bookmark-of",
      replies: "in-reply-to",
      mentions: "mention-of",
    };
    const wmProperty = typeMap[type] || type;
    return mentions.filter((m) => m["wm-property"] === wmProperty);
  });

  // Post navigation — find previous/next post in a collection
  // (Nunjucks {% set %} inside {% for %} doesn't propagate, so we need filters)
  eleventyConfig.addFilter("previousInCollection", function (collection, page) {
    if (!collection || !page) return null;
    const index = collection.findIndex((p) => p.url === page.url);
    return index > 0 ? collection[index - 1] : null;
  });

  eleventyConfig.addFilter("nextInCollection", function (collection, page) {
    if (!collection || !page) return null;
    const index = collection.findIndex((p) => p.url === page.url);
    return index >= 0 && index < collection.length - 1
      ? collection[index + 1]
      : null;
  });

  // Posting frequency — compute posts-per-month for last 12 months (for sparkline).
  // Returns an inline SVG that uses currentColor for stroke and a semi-transparent
  // gradient fill. Wrap in a colored span to set the domain color via Tailwind.
  eleventyConfig.addFilter("postingFrequency", (posts) => {
    if (!Array.isArray(posts) || posts.length === 0) return "";
    const now = new Date();
    const counts = new Array(12).fill(0);
    for (const post of posts) {
      const postDate = new Date(post.date || post.data?.date);
      if (isNaN(postDate.getTime())) continue;
      const monthsAgo = (now.getFullYear() - postDate.getFullYear()) * 12 + (now.getMonth() - postDate.getMonth());
      if (monthsAgo >= 0 && monthsAgo < 12) {
        counts[11 - monthsAgo]++;
      }
    }

    // Extrapolate the current (partial) month to avoid false downward trend.
    // e.g. 51 posts in 5 days of a 31-day month projects to ~316.
    const dayOfMonth = now.getDate();
    const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
    if (dayOfMonth < daysInMonth && counts[11] > 0) {
      counts[11] = Math.round(counts[11] / dayOfMonth * daysInMonth);
    }

    const max = Math.max(...counts, 1);
    const w = 200;
    const h = 32;
    const pad = 2;
    const step = w / (counts.length - 1);
    const points = counts.map((v, i) => {
      const x = i * step;
      const y = h - pad - ((v / max) * (h - pad * 2));
      return `${x.toFixed(1)},${y.toFixed(1)}`;
    }).join(" ");
    // Closed polygon for gradient fill (line path + bottom corners)
    const fillPoints = `${points} ${w},${h} 0,${h}`;
    return [
      `<svg viewBox="0 0 ${w} ${h}" class="sparkline" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Posting frequency over the last 12 months">`,
      `<defs><linearGradient id="spk-fill" x1="0" y1="0" x2="0" y2="1">`,
      `<stop offset="0%" stop-color="currentColor" stop-opacity="0.25"/>`,
      `<stop offset="100%" stop-color="currentColor" stop-opacity="0.02"/>`,
      `</linearGradient></defs>`,
      `<polygon fill="url(#spk-fill)" points="${fillPoints}"/>`,
      `<polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="${points}"/>`,
      `</svg>`,
    ].join("");
  });

  // Filter AI-involved posts (ai-text-level > "0" or aiTextLevel > "0")
  eleventyConfig.addFilter("aiPosts", (posts) => {
    if (!Array.isArray(posts)) return [];
    return posts.filter((post) => {
      const level = post.data?.aiTextLevel || post.data?.["ai-text-level"] || "0";
      return level !== "0" && level !== 0;
    });
  });

  // AI stats — returns { total, aiCount, percentage, byLevel }
  eleventyConfig.addFilter("aiStats", (posts) => {
    if (!Array.isArray(posts)) return { total: 0, aiCount: 0, percentage: 0, byLevel: {} };
    const total = posts.length;
    const byLevel = { 0: 0, 1: 0, 2: 0, 3: 0 };
    for (const post of posts) {
      const level = parseInt(post.data?.aiTextLevel || post.data?.["ai-text-level"] || "0", 10);
      byLevel[level] = (byLevel[level] || 0) + 1;
    }
    const aiCount = total - byLevel[0];
    return {
      total,
      aiCount,
      percentage: total > 0 ? ((aiCount / total) * 100).toFixed(1) : "0",
      byLevel,
    };
  });

  // Helper: exclude drafts from collections
  const isPublished = (item) => !item.data.draft;

  // Collections for different post types
  // Note: content path is content/ due to symlink structure
  // "posts" shows ALL content types combined
  eleventyConfig.addCollection("posts", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date);
  });

  eleventyConfig.addCollection("notes", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/notes/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date);
  });

  eleventyConfig.addCollection("articles", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/articles/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date);
  });

  eleventyConfig.addCollection("bookmarks", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/bookmarks/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date);
  });

  eleventyConfig.addCollection("photos", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/photos/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date);
  });

  eleventyConfig.addCollection("likes", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/likes/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date);
  });

  // Replies collection - posts with inReplyTo/in_reply_to property
  // Supports both camelCase (Indiekit Eleventy preset) and underscore (legacy) names
  eleventyConfig.addCollection("replies", function (collectionApi) {
    return collectionApi
      .getAll()
      .filter((item) => isPublished(item) && (item.data.inReplyTo || item.data.in_reply_to))
      .sort((a, b) => b.date - a.date);
  });

  // Reposts collection - posts with repostOf/repost_of property
  // Supports both camelCase (Indiekit Eleventy preset) and underscore (legacy) names
  eleventyConfig.addCollection("reposts", function (collectionApi) {
    return collectionApi
      .getAll()
      .filter((item) => isPublished(item) && (item.data.repostOf || item.data.repost_of))
      .sort((a, b) => b.date - a.date);
  });

  // Pages collection - root-level slash pages (about, now, uses, etc.)
  // Includes both content/*.md (legacy) and content/pages/*.md (new post-type-page)
  // Created via Indiekit's page post type
  eleventyConfig.addCollection("pages", function (collectionApi) {
    const rootPages = collectionApi.getFilteredByGlob("content/*.md");
    const pagesDir = collectionApi.getFilteredByGlob("content/pages/*.md");
    return [...rootPages, ...pagesDir]
      .filter(page => isPublished(page) && !page.inputPath.includes('content.json') && !page.inputPath.includes('pages.json'))
      .sort((a, b) => (a.data.title || a.data.name || "").localeCompare(b.data.title || b.data.name || ""));
  });

  // All content combined for homepage feed
  eleventyConfig.addCollection("feed", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date)
      .slice(0, 20);
  });

  // Categories collection - deduplicated by slug to avoid duplicate permalinks
  eleventyConfig.addCollection("categories", function (collectionApi) {
    const categoryMap = new Map(); // slug -> original name (first seen)
    const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");

    collectionApi.getAll().filter(isPublished).forEach((item) => {
      if (item.data.category) {
        const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
        cats.forEach((cat) => {
          if (cat && typeof cat === 'string' && cat.trim()) {
            const slug = slugify(cat.trim());
            if (slug && !categoryMap.has(slug)) {
              categoryMap.set(slug, cat.trim());
            }
          }
        });
      }
    });
    return [...categoryMap.values()].sort();
  });

  // Category feeds — pre-grouped posts for per-category RSS/JSON feeds
  eleventyConfig.addCollection("categoryFeeds", function (collectionApi) {
    const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
    const grouped = new Map(); // slug -> { name, slug, posts[] }

    collectionApi
      .getFilteredByGlob("content/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date)
      .forEach((item) => {
        if (!item.data.category) return;
        const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
        for (const cat of cats) {
          if (!cat || typeof cat !== "string" || !cat.trim()) continue;
          const slug = slugify(cat.trim());
          if (!slug) continue;
          if (!grouped.has(slug)) {
            grouped.set(slug, { name: cat.trim(), slug, posts: [] });
          }
          const entry = grouped.get(slug);
          if (entry.posts.length < 50) {
            entry.posts.push(item);
          }
        }
      });

    return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name));
  });

  // Recent posts for sidebar
  eleventyConfig.addCollection("recentPosts", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/**/*.md")
      .filter(isPublished)
      .sort((a, b) => b.date - a.date)
      .slice(0, 5);
  });

  // Featured posts — curated selection via `pinned: true` frontmatter
  // Property named "pinned" to avoid conflict with "featured" (hero image) in MF2/Micropub
  eleventyConfig.addCollection("featuredPosts", function (collectionApi) {
    return collectionApi
      .getFilteredByGlob("content/**/*.md")
      .filter(isPublished)
      .filter((item) => item.data.pinned === true || item.data.pinned === "true")
      .sort((a, b) => b.date - a.date);
  });

  // Weekly digests — posts grouped by ISO week for digest pages and RSS feed
  eleventyConfig.addCollection("weeklyDigests", function (collectionApi) {
    const allPosts = collectionApi
      .getFilteredByGlob("content/**/*.md")
      .filter(isPublished)
      .filter((item) => {
        // Exclude replies
        return !(item.data.inReplyTo || item.data.in_reply_to);
      })
      .sort((a, b) => b.date - a.date);

    // ISO week helpers
    const getISOWeek = (date) => {
      const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
      d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
      const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
    };
    const getISOYear = (date) => {
      const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
      d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
      return d.getUTCFullYear();
    };

    // Group by ISO week
    const weekMap = new Map();

    for (const post of allPosts) {
      const d = new Date(post.date);
      const week = getISOWeek(d);
      const year = getISOYear(d);
      const key = `${year}-W${String(week).padStart(2, "0")}`;

      if (!weekMap.has(key)) {
        // Calculate Monday (start) and Sunday (end) of ISO week
        const jan4 = new Date(Date.UTC(year, 0, 4));
        const dayOfWeek = jan4.getUTCDay() || 7;
        const monday = new Date(jan4);
        monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1 + (week - 1) * 7);
        const sunday = new Date(monday);
        sunday.setUTCDate(monday.getUTCDate() + 6);

        weekMap.set(key, {
          year,
          week,
          slug: `${year}/W${String(week).padStart(2, "0")}`,
          label: `Week ${week}, ${year}`,
          startDate: monday.toISOString().slice(0, 10),
          endDate: sunday.toISOString().slice(0, 10),
          posts: [],
        });
      }

      weekMap.get(key).posts.push(post);
    }

    // Post type detection (matches blog.njk logic)
    const typeDetect = (post) => {
      if (post.data.likeOf || post.data.like_of) return "likes";
      if (post.data.bookmarkOf || post.data.bookmark_of) return "bookmarks";
      if (post.data.repostOf || post.data.repost_of) return "reposts";
      if (post.data.photo && post.data.photo.length) return "photos";
      if (post.data.title) return "articles";
      return "notes";
    };

    // Build byType for each week and convert to array
    const digests = [...weekMap.values()].map((entry) => {
      const byType = {};
      for (const post of entry.posts) {
        const type = typeDetect(post);
        if (!byType[type]) byType[type] = [];
        byType[type].push(post);
      }
      return { ...entry, byType };
    });

    // Sort newest-week-first
    digests.sort((a, b) => {
      if (a.year !== b.year) return b.year - a.year;
      return b.week - a.week;
    });

    return digests;
  });

  // Generate OpenGraph images for posts without photos
  // Runs on every build (including watcher rebuilds) — manifest caching makes it fast
  // for incremental: only new posts without an OG image get generated (~200ms each)
  eleventyConfig.on("eleventy.before", () => {
    const contentDir = resolve(__dirname, "content");
    const cacheDir = resolve(__dirname, ".cache");
    const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
    try {
      execFileSync(process.execPath, [
        "--max-old-space-size=768",
        resolve(__dirname, "lib", "og-cli.js"),
        contentDir,
        cacheDir,
        siteName,
      ], {
        stdio: "inherit",
        env: { ...process.env, NODE_OPTIONS: "" },
      });

      // Sync new OG images to output directory.
      // During incremental builds, .cache/og is in watchIgnores so Eleventy's
      // passthrough copy won't pick up newly generated images. Copy them manually.
      const ogCacheDir = resolve(cacheDir, "og");
      const ogOutputDir = resolve(__dirname, "_site", "og");
      if (existsSync(ogCacheDir) && existsSync(resolve(__dirname, "_site"))) {
        mkdirSync(ogOutputDir, { recursive: true });
        let synced = 0;
        for (const file of readdirSync(ogCacheDir)) {
          if (file.endsWith(".png") && !existsSync(resolve(ogOutputDir, file))) {
            copyFileSync(resolve(ogCacheDir, file), resolve(ogOutputDir, file));
            synced++;
          }
        }
        if (synced > 0) {
          console.log(`[og] Synced ${synced} new image(s) to output`);
        }
      }
    } catch (err) {
      console.error("[og] Image generation failed:", err.message);
    }
  });

  // Pre-fetch unfurl metadata for all interaction URLs in content files.
  // Populates the disk cache BEFORE templates render, so the synchronous
  // unfurlCard filter (used in nested includes like recent-posts) has data.
  eleventyConfig.on("eleventy.before", async () => {
    const contentDir = resolve(__dirname, "content");
    if (!existsSync(contentDir)) return;

    const urls = new Set();
    const interactionProps = [
      "likeOf", "like_of", "bookmarkOf", "bookmark_of",
      "repostOf", "repost_of", "inReplyTo", "in_reply_to",
    ];

    const walk = (dir) => {
      for (const entry of readdirSync(dir, { withFileTypes: true })) {
        const full = resolve(dir, entry.name);
        if (entry.isDirectory()) { walk(full); continue; }
        if (!entry.name.endsWith(".md")) continue;
        try {
          const { data } = matter(readFileSync(full, "utf-8"));
          for (const prop of interactionProps) {
            if (data[prop]) urls.add(data[prop]);
          }
        } catch { /* skip unparseable files */ }
      }
    };
    walk(contentDir);

    if (urls.size === 0) return;
    console.log(`[unfurl] Pre-fetching ${urls.size} interaction URLs...`);
    await Promise.all([...urls].map((url) => prefetchUrl(url)));
    console.log(`[unfurl] Pre-fetch complete.`);
  });

  // Post-build hook: pagefind indexing + WebSub notification
  // Pagefind runs once on the first build (initial or watcher's first full build), then never again.
  // WebSub runs on every non-incremental build.
  // Note: --incremental CLI flag sets incremental=true even for the watcher's first full build,
  // so we cannot use the incremental flag to guard pagefind. Use a one-shot flag instead.
  let pagefindDone = false;
  eleventyConfig.on("eleventy.after", async ({ dir, directories, runMode, incremental }) => {
    // Markdown for Agents — generate index.md alongside index.html for articles
    const mdEnabled = (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true";
    if (mdEnabled && !incremental) {
      const outputDir = directories?.output || dir.output;
      const contentDir = resolve(__dirname, "content/articles");
      const aiTrain = process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes";
      const search = process.env.MARKDOWN_AGENTS_SEARCH || "yes";
      const aiInput = process.env.MARKDOWN_AGENTS_AI_INPUT || "yes";
      const authorName = process.env.AUTHOR_NAME || "Blog Author";
      let mdCount = 0;
      try {
        const files = readdirSync(contentDir).filter(f => f.endsWith(".md"));
        for (const file of files) {
          const src = readFileSync(resolve(contentDir, file), "utf-8");
          const { data: fm, content: body } = matter(src);
          if (!fm || fm.draft) continue;
          // Derive the output path from the article's permalink or url
          const articleUrl = fm.permalink || fm.url;
          if (!articleUrl || !articleUrl.startsWith("/articles/")) continue;
          const mdDir = resolve(outputDir, articleUrl.replace(/^\//, "").replace(/\/$/, ""));
          const mdPath = resolve(mdDir, "index.md");
          const trimmedBody = body.trim();
          const tokens = Math.ceil(trimmedBody.length / 4);
          const title = (fm.title || "").replace(/"/g, '\\"');
          const date = fm.date ? new Date(fm.date).toISOString() : fm.published || "";
          let frontLines = [
            "---",
            `title: "${title}"`,
            `date: ${date}`,
            `author: ${authorName}`,
            `url: ${siteUrl}${articleUrl}`,
          ];
          if (fm.category && Array.isArray(fm.category) && fm.category.length > 0) {
            frontLines.push("categories:");
            for (const cat of fm.category) {
              frontLines.push(`  - ${cat}`);
            }
          }
          if (fm.description) {
            frontLines.push(`description: "${fm.description.replace(/"/g, '\\"')}"`);
          }
          frontLines.push(`tokens: ${tokens}`);
          frontLines.push(`content_signal: ai-train=${aiTrain}, search=${search}, ai-input=${aiInput}`);
          frontLines.push("---");
          mkdirSync(mdDir, { recursive: true });
          writeFileSync(mdPath, frontLines.join("\n") + "\n\n# " + (fm.title || "") + "\n\n" + trimmedBody + "\n");
          mdCount++;
        }
        console.log(`[markdown-agents] Generated ${mdCount} article .md files`);
      } catch (err) {
        console.error("[markdown-agents] Error generating .md files:", err.message);
      }
    }

    // Pagefind indexing — run exactly once per process lifetime
    if (!pagefindDone) {
      pagefindDone = true;
      const outputDir = directories?.output || dir.output;
      try {
        console.log(`[pagefind] Indexing ${outputDir} (${runMode})...`);
        execFileSync("npx", ["pagefind", "--site", outputDir, "--output-subdir", "pagefind", "--glob", "**/*.html"], {
          stdio: "inherit",
          timeout: 120000,
        });
        console.log("[pagefind] Indexing complete");
      } catch (err) {
        console.error("[pagefind] Indexing failed:", err.message);
      }

    }

    // Syndication webhook — trigger after incremental rebuilds (new posts are now live)
    // Cuts syndication latency from ~2 min (poller) to ~5 sec (immediate trigger)
    if (incremental) {
      const syndicateUrl = process.env.SYNDICATE_WEBHOOK_URL;
      if (syndicateUrl) {
        try {
          const secretFile = process.env.SYNDICATE_SECRET_FILE || "/app/data/config/.secret";
          const secret = readFileSync(secretFile, "utf-8").trim();

          // Build a minimal HS256 JWT using built-in crypto (no jsonwebtoken dependency)
          const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
          const now = Math.floor(Date.now() / 1000);
          const payload = Buffer.from(JSON.stringify({
            me: siteUrl,
            scope: "update",
            iat: now,
            exp: now + 300, // 5 minutes
          })).toString("base64url");
          const signature = createHmac("sha256", secret)
            .update(`${header}.${payload}`)
            .digest("base64url");
          const token = `${header}.${payload}.${signature}`;

          const res = await fetch(`${syndicateUrl}?token=${token}`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            signal: AbortSignal.timeout(30000),
          });
          console.log(`[syndicate-hook] Triggered syndication: ${res.status}`);
        } catch (err) {
          console.error(`[syndicate-hook] Failed:`, err.message);
        }
      }
    }

    // WebSub hub notification — skip on incremental rebuilds
    if (incremental) return;
    const hubUrl = "https://websubhub.com/hub";
    const feedUrls = [
      `${siteUrl}/`,
      `${siteUrl}/feed.xml`,
      `${siteUrl}/feed.json`,
    ];

    // Discover category feed URLs from build output
    const outputDir = directories?.output || dir.output;
    const categoriesDir = resolve(outputDir, "categories");
    try {
      for (const entry of readdirSync(categoriesDir, { withFileTypes: true })) {
        if (entry.isDirectory() && existsSync(resolve(categoriesDir, entry.name, "feed.xml"))) {
          feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.xml`);
          feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.json`);
        }
      }
    } catch {
      // categoriesDir may not exist on first build — ignore
    }

    console.log(`[websub] Notifying hub for ${feedUrls.length} URLs...`);
    for (const feedUrl of feedUrls) {
      try {
        const res = await fetch(hubUrl, {
          method: "POST",
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`,
        });
        console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`);
      } catch (err) {
        console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message);
      }
    }
  });

  return {
    dir: {
      input: ".",
      output: "_site",
      includes: "_includes",
      data: "_data",
    },
    markdownTemplateEngine: false,  // Disable to avoid Nunjucks interpreting {{ in content
    htmlTemplateEngine: "njk",
  };
}
