neputa note

Astroのrssフィードに記事の本文を追加する

初稿:

- 7 min read -

img of Astroのrssフィードに記事の本文を追加する

記事概要

  • 先日のBloggerからAstroへ移行した記事の別途詳細

※参考 - Blog移行記事

BlogをBloggerからAstroへ移行した

10年以上の期間お世話になったGoogle Bloggerに別れを告げ、この度AstroでBlogサイトを構築し移行した。Astroは静的サイトを手軽に開発できる軽量フレームワーク。無料のテンプレートをベースにカスタマイズを行った。それなりの作業ボリュームとなったので、詳細は別記事に分け、今回は移行作業全体をまとめる。

目的

  • 「@astrojs/rss」で出力するrss.xmlに、Blog記事の本文を追加する
  • Blog記事の本文は、全文を出力する
  • Blog記事はMDXで記述している
  • RSSリーダーで読みやすい形式で出力するため以下を考慮する
    • markdwon記法をパーサーでHTMLに変換する
    • XSSなどセキュリティリスクを回避するためサニタイズ処理をする
  • 一連の実装についてまとめる

用語説明

Astro とは?

Astroは、ブログやマーケティング、eコマースなど、コンテンツ駆動のウェブサイトを作成するためのウェブフレームワークです。Astroは、新しいフロントエンドアーキテクチャを開拓し、他のフレームワークと比較してJavaScriptのオーバーヘッドと複雑さを低減することで知られています。高速でSEOに優れたウェブサイトが必要なら、Astroが最適です。 Astro公式Docs より引用

MDXとは?

MDXでは、マークダウン・コンテンツでJSXを使用することができます。インタラクティブなチャートやアラートなどのコンポーネントをインポートして、コンテンツに埋め込むことができます。これにより、コンポーネントを使った長文のコンテンツ作成が簡単になります。 What is MDX? | MDXより引用

markdown-itとは?

sanitize-htmlとは?

  • XSS等の攻撃対象となりうるhtmlタグや属性をエスケープや削除する
  • sanitize-html - npm

作業環境

  • OS - Ubuntu-22.04LTS on WSL2
  • Node.js - v20.14.0
  • pnpm - v9.4.0
  • Astro - v4.11.3

作業概要

  1. 公式Docsを参考にrss.xml.tsを作成する
  2. markdown-itとsanitize-htmlをインストールする
  3. MDX→HTML変換モジュールを作成する
  4. 上記モジュールをrss.xml.tsに組み込む

作業詳細

標準的なrss.xml.tsを作成する

  • 公式Docのサンプルに沿って、rss.xml.tsを作成する
  • 公式サンプルと異なるのは以下。
    • サイト情報を自前のconfigより取得
    • link生成関数
    • header(Content-Type)を追加
    • 加工無しの本文 post.body
rss.xml.ts
import { getRssString } from '@astrojs/rss'
import { siteConfig } from '@/site-config'
import { getPermalink, getPosts } from '@/utils'

export const GET = async () => {
  const posts = await getPosts()

  const rss = await getRssString({
    title: siteConfig.title,
    description: siteConfig.description,
    site: import.meta.env.SITE,

    items: posts.map((post, index) => ({
      link: getPermalink(post.slug, 'post'),
      title: post.data.title,
      description: `${post.data.description}...`,
      pubDate: post.data.pubDate,
      content: post.body
    }))
  })

  return new Response(rss, {
    headers: {
      'Content-Type': 'application/xml'
    }
  })
}

markdown-itとsanitize-htmlをインストールする

  1. markdown-itとsanitize-htmlパッケージをプロジェクトにインストール
install
$ npm install -D sanitize-html markdown-it
  1. TypeScriptの人は@typesも
code
npm install -D @types/markdown-it、@types/sanitize-html

MDX→HTML変換モジュールを作成する

要件

  • import文を削除する(frontmatter直後のみ)
  • componentなどMDXに埋め込んだHTMLタグはsanitizeした上でそのまま出力する

使用モジュール

  • sanitize-html
  • markdown-it

処理ステップ

  1. sanitizeOptions定義作成
  1. frontmatter直下のimport文のみ削除
  • コンテンツとして書かれたimport文を削除しないため
  1. 最初のサニタイズ処理を実行
  • 本文はまだMDX形式
  • ターゲットはcode blockやcomponent
  1. HTML特殊文字にパースされた山カッコを戻す
  • ターゲットは最初のサニタイズで削除されずにHTMLパースされた箇所
  • これを再度HTML化する
  1. textlintのignoreコメントを削除
  • MDX上でtextlintの処理を避けたい箇所に使用しているもの
  1. markdown-itでHTMLに変換
  2. 最終的なサニタイズ処理を実行

サニタイズを2度行う理由

  • パース→サニタイズを通常どおり行うと、MDXに埋め込まれているHTMLタグが文字列として出力される
  • code blockに書かれたコードはサニタイズが必要
  • 以上を考慮し、実装する方法として以下の処理ステップとした
    1. MDXのままサニタイズ
    2. HTMLのまま出力したい箇所をリバース
    3. markdownパース
    4. 最終サニタイズ

完成したモジュール

mdxUtils.ts
import sanitizeHtml from 'sanitize-html'
import MarkdownIt from 'markdown-it'
const parser = new MarkdownIt({ html: true })

/* Sanitize Options
 * imgタグとalt属性、aタグのhrf属性を有効
 */
const sanitizeOptions = {
  allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
  allowedAttributes: {
    ...sanitizeHtml.defaults.allowedAttributes,
    img: ['alt'],
    a: ['href']
  }
}

/**
 * Description:
 * MDX本文テキストをHTMLに変換する
 * markdownパース、htmlのsanitizeを行う
 * @param {string} mdxContent
 * @returns {string}
 */
export function mdxToHtml(mdxContent: string): string {
  // import文を削除
  const importRemoved = removeInitialImports(mdxContent)

  // 1. componentをターゲットにsanitizeHtml
  // 2. 残ったHTMLパース済みのタグをreplaceで戻す
  // 3. textlintのignore文をreplaceで削除
  const initialSanitizedHtml = sanitizeHtml(importRemoved.toString(), sanitizeOptions)
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replaceAll('{/* textlint-disable */}', '')

  // HTMLへ変換
  const htmlContent = parser.render(initialSanitizedHtml)

  // 最終的なsanitize処理
  return sanitizeHtml(htmlContent, sanitizeOptions)
}

/**
 * Description MDX本文テキストの最初(frontmatter直後)のimport文を削除する
 * @param {string} content - MDX本文テキスト
 * @returns {string}
 */
export function removeInitialImports(content: string): string {
  const lines = content.split('\n')

  // 2行目がimport文でない場合、処理を行わない
  if (!lines[1].trim().startsWith('import')) {
    console.log('No imports to remove.')
    return content
  }

  let i = 1
  while (i < lines.length) {
    const line = lines[i].trim()

    // import文ではない行が現れたらbreak
    if (line.startsWith('import')) {
      lines.splice(i, 1)
    } else {
      break
    }
  }

  // 改行文字で再結合
  const modifiedContent = lines.join('\n')

  return modifiedContent
}

上記モジュールをrss.xml.tsに組み込む

  • mdxUtils.tsのモジュールをimportする
    • tsconfigでpathsを定義しているので適宜修正を
  • contentにmdxToHtml関数で処理した値を設定
rss.xml.ts
import { getRssString } from '@astrojs/rss'
import { siteConfig } from '@/site-config'
import { getPermalink, getPosts } from '@/utils'
import { getPermalink, getPosts, mdxToHtml } from '@/utils'

export const GET = async () => {
  const posts = await getPosts()

  const rss = await getRssString({
    title: siteConfig.title,
    description: siteConfig.description,
    site: import.meta.env.SITE,

    items: posts.map((post, index) => ({
      link: getPermalink(post.slug, 'post'),
      title: post.data.title,
      description: `${post.data.description}...`,
      pubDate: post.data.pubDate,
      content: post.body
      content: mdxToHtml(post.body)
    }))
  })

  return new Response(rss, {
    headers: {
      'Content-Type': 'application/xml'
    }
  })
}

以上

目次