Astro6.4

Sätteri ってなに?

Astro 6.4 | Astro
Astro 6.4 introduces a new pluggable Markdown processor API, a Rust-based Markdown processor for faster builds, and helpers to wire up experimental advanced routing with Cloudflare.
Astro 6.4 | Astro faviconhttps://astro.build/blog/astro-640/
Astro 6.4 | Astro

Astroのバージョンが6.4に更新された。

Markdown処理の記述方法が変わるとともに将来的にはSätteriというRust製markdownパーサーに移行するのだという。

Sätteri
Sätteri puts flexible JavaScript plugins on top of a fast Rust Markdown / MDX engine. Best of both worlds.
Sätteri faviconhttps://satteri.bruits.org/

Sätteriでは既存のremark/rehypeプラグインは動作しないので、今までのプラグインをそのまま使う場合はunifiedを使う。

import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import remarkToc from 'remark-toc';

export default defineConfig({
  markdown: {
    processor: unified({
      remarkPlugins: [remarkToc],
    }),
  },
});

Sätteri に移行する

将来的に廃止ならば書き換えようということで BridgyFedのために作った u-photoクラス付与と 外部から追加していた remark-link-cardを Sätteri用に作成した。

u-photo付与

import { defineHastPlugin } from "satteri";

const addUPhoto = defineHastPlugin({
  name: "add-u-photo",
  element: {
    filter: ["img"],
    visit(node, ctx) {
      const existing = node.properties?.class ?? "";
      const classList = (Array.isArray(existing) ? existing : String(existing).split(" "))
        .map(c => c.trim())
        .filter(Boolean);

      if (!classList.includes("u-photo")) {
        classList.push("u-photo");
      }

      ctx.setProperty(node, "class", classList.join(" "));
    },
  },
});

export default addUPhoto;
import { defineMdastPlugin, defineHastPlugin } from "satteri";
import ogs from "open-graph-scraper";

const ogCache = new Map();

async function isImageValid(url) {
  if (!url) return false;
  try {
    const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3000) });
    return res.ok;
  } catch {
    return false;
  }
}

async function fetchOgData(url) {
  try {
    const { result } = await ogs({ url, timeout: 8000 });
    const domain = new URL(url).hostname;
    const imageUrl = result.ogImage?.[0]?.url ?? "";
    const image = await isImageValid(imageUrl) ? imageUrl : "";
    return {
      title: result.ogTitle ?? result.dcTitle ?? domain,
      description: result.ogDescription ?? "",
      image,
      favicon: "https://www.google.com/s2/favicons?domain=" + domain,
    };
  } catch (e) {
    console.error("[link-card] OGS error:", e?.result?.error ?? String(e), "url:", url);
    const domain = new URL(url).hostname;
    return {
      title: domain,
      description: "",
      image: "",
      favicon: "https://www.google.com/s2/favicons?domain=" + domain,
    };
  }
}

function makeText(value) {
  return { type: "text", value };
}
function makeEl(tagName, props, children) {
  return { type: "element", tagName, properties: props ?? {}, children: children ?? [] };
}

function buildCardNode(url, og) {
  const hasImage = og.image != null && og.image !== "";

  return makeEl("a", { class: "rlc-container", href: url }, [
    makeEl("div", { class: "rlc-info" }, [
      makeEl("div", { class: "rlc-title" }, [makeText(og.title)]),
      ...(og.description ? [makeEl("div", { class: "rlc-description" }, [makeText(og.description)])] : []),
      makeEl("div", { class: "rlc-url-container" }, [
        makeEl("img", { class: "rlc-favicon", src: og.favicon, alt: og.title + " favicon", width: "16", height: "16" }),
        makeEl("span", { class: "rlc-url" }, [makeText(url)]),
      ]),
    ]),
    ...(hasImage ? [makeEl("div", { class: "rlc-image-container" }, [
      makeEl("img", { class: "rlc-image", src: og.image, alt: og.title }),
    ])] : []),
  ]);
}

export const linkCardMdast = defineMdastPlugin({
  name: "link-card-collect",
  async paragraph(node) {
    if (node.children.length !== 1) return;
    const child = node.children[0];
    if (child.type !== "link") return;
    if (child.children[0]?.value !== child.url) return;
    console.log("[link-card] fetching OGP for:", child.url);
    const og = await fetchOgData(child.url);
    ogCache.set(child.url, og);
  },
});

export const linkCardHast = defineHastPlugin({
  name: "link-card-replace",
  element: {
    filter: ["p"],
    visit(node, ctx) {
      if (node.children.length !== 1) return;
      const a = node.children[0];
      if (a.type !== "element" || a.tagName !== "a") return;
      const href = a.properties?.href;
      if (!ogCache.has(href)) return;
      ctx.replaceNode(node, buildCardNode(href, ogCache.get(href)));
    },
  },
});

これらを astro.config.mjs で読み込む。

// 省略
  markdown: {
    processor: satteri({
      mdastPlugins: [linkCardMdast],
      hastPlugins: [addUPhoto, linkCardHast],
      gfm: true,
    }),
    shikiConfig: {
      theme: "catppuccin-frappe",
      langs: [],
      wrap: false,
    },
  },
// mdxプラグインの方でも同様に記述する