アーキテクチャと URL 解決の流れ

プラグインの内部動作を理解するための開発者向け解説です。リクエストが md ホストに到達してから Markdown が応答されるまでの流れを、ファイル単位の責務とともに追いかけます。 設定画面の各タブで何が起きているかを「コードレベルで」把握したい方向けの章です。

全体アーキテクチャ

本プラグインは「メインサイト (www) の HTML 出力に一切手を加えない」という大原則のもと、 md サブドメイン (例: md.example.com) のリクエストだけを template_redirect priority -1 で早期に捕捉 し、Markdown を返却して exit する設計です。 メインサイトのコードパスはまったく変わらず、md ホストでは「テーマ HTML 生成より前」に処理が分岐します。

全体アーキテクチャ — md ホストのリクエストだけを template_redirect priority -1 で捕捉し、resolver / renderer / cache を経て Markdown を返却
図: プラグイン全体のアーキテクチャ。HTTP リクエストから Markdown 応答までの主要モジュールと責務分担。

主要モジュールの責務

プラグインは includes/ 配下の複数の単機能モジュールに分割されています。 各モジュールはお互いを直接呼ばず、core-functions.phpksmd_handle_subdomain_request() から段階的に呼び出される薄い構成です。

ファイル 主たる責務 主要関数
includes/md-resolver.php URI を $wp_query adapter として route 種別に解決する ksmd_resolve_request() / ksmd_build_sitemap_index() / ksmd_build_robots_txt()
includes/md-renderer.php post_content (HTML) を Markdown へ変換 (DOMDocument + 7 ルール) ksmd_render_post_body_markdown() / ksmd_html_to_markdown() / ksmd_rewrite_url_to_md()
includes/md-route-renderers.php archive / term / author / date / home / front_page / search / feed / 404 各 route の Markdown 構築 ksmd_render_route() ほか route 別ヘルパ群
includes/md-schema-mapper.php singular post の Markdown インライン Schema.org 記法ヘッダ・フッタ生成 ksmd_schema_header_block() / ksmd_schema_footer_block() / ksmd_schema_escape()
includes/cache.php 3 backend (transient / object_cache / custom_table) の抽象化 ksmd_cache_get() / ksmd_cache_set() / ksmd_cache_flush_all()
includes/security.php 公開可視性ゲート + アクセスログ + Test renderer 用 HMAC token ksmd_is_post_publicly_serviceable() / ksmd_access_log() / ksmd_anonymize_ip()
includes/md-bootstrap-installer.php md ホスト用 index.php の安全な自動生成・削除・検証 ksmd_bootstrap_validate_path() / ksmd_bootstrap_install() / ksmd_bootstrap_uninstall()
includes/md-alternate-link.php メイン側 HTML 配信時に md 並行版への参照を HTTP Link: ヘッダ + HTML <link> タグで告知 ksmd_alt_link_should_output() / ksmd_alt_link_route_is_enabled() / ksmd_alt_link_emit_http_header() / ksmd_alt_link_emit_html_tag()
includes/md-favicon-proxy.php md ホストの favicon リクエスト (5 URI) をメイン Site Icon に 302 redirect / 動的 webmanifest 生成 ksmd_is_favicon_request() / ksmd_redirect_favicon() / ksmd_output_webmanifest()
includes/core-functions.php すべてを束ねるエントリポイント (template_redirect hook) ksmd_handle_subdomain_request() / ksmd_send_response()

2 つのコードパス: メイン vs md

プラグインを有効化しても、メイン (www) ホストのリクエストには影響がありません。 ksmd_handle_subdomain_request() の冒頭で HTTP_HOSTmd_host 設定値と照合し、 一致しない場合は即 return します。これにより:

💡 Tip

priority -1 を使う理由は、Yoast / RankMath / Cocoon 等が template_redirect priority 1〜10 で canonical やリダイレクトを発行する前に出力を完結させ、それらの干渉を完全に避けるためです。

load 順序とブートストラップ

kashiwazaki-llmo-md-subdomain.php の冒頭でプラグイン定数 (KSMD_OPTION_KEY = 'ksmd_settings', KSMD_PLUGIN_PATH, KSMD_VERSION = '1.0.1' 等) を定義し、 plugins_loaded priority 0 で ksmd_bootstrap_plugin() を実行します。 この関数で:

1

load_plugin_textdomain()kashiwazaki-llmo-md-subdomain textdomain を読み込み (i18n 初期化)

2

ksmd_migrate_legacy_option() で旧 option key (kashiwazaki_llmo_md_settings) → 新 key (ksmd_settings) を 1 回限り移行

3

includes/core-functions.phprequire_once。これにより resolver / renderer / route_renderers / schema_mapper / security / cache が芋づる式に読み込まれる

4

is_admin() 時のみ admin/admin-loader.phpincludes/md-bootstrap-installer.php を追加 require (フロントの opcode footprint を増やさない)

リクエストフロー

md ホストへの HTTP リクエストが届いてから Markdown が返るまでの流れを、 ksmd_handle_subdomain_request() の実装に沿って追いかけます。

URL 解決フロー — HTTP リクエストから host 判定、ルート解決、cache 参照、レンダラ起動、レスポンス送信までの 11 ステップ
図: md ホストでのリクエスト処理フロー。host 判定 → 防御ガード → URI 抽出 → 特殊パス → resolver → cache → renderer → 送信。

template_redirect priority -1 の役割

core-functions.php の冒頭で次のように hook を登録しています。

add_action( 'template_redirect', 'ksmd_handle_subdomain_request', -1 );

template_redirect は WordPress が「テーマテンプレートを読み込む直前」に発火するアクションです。 この時点では:

つまり「WP のクエリ解決はそのまま使い、テーマレンダリングだけバイパスする」という理想的なタイミングです。 自前で url_to_postid()get_page_by_path() を呼ぶ必要がなく、core が解決済みの結果を読むだけの薄い adapter で済みます。

11 ステップのリクエスト処理

以下が ksmd_handle_subdomain_request() の処理順序です。

# 処理 失敗時の挙動
1計測開始 (microtime(true)$GLOBALS['ksmd_request_start_ts'] に保存)
2HTTP_HOSTmd_host 設定と比較不一致 → return (メインに副作用ゼロ)
3DOING_AJAX / REST_REQUEST / DOING_CRON ガード該当 → return
4kill_switch 設定確認有効 → 503 Service Unavailable
5enabled (master switch) 確認無効 → 503 + 案内メッセージ
6REQUEST_URI から path のみ抽出
7特殊パス: /robots.txt / /sitemap*.xml / /favicon*専用ハンドラへ → exit
7.5サブディレモード root short-circuit (v1.0.3): URI=='/' かつ前提条件 (home_path 非空 + md_host ≠ home_host) かつ subdir_mode_enabled のとき ksmd_subdir_mode_handle()前提条件不成立 → 通常 resolver 経路へ抜ける (副作用ゼロ)
8ksmd_resolve_request() で route 解決404 → ksmd_send_404()
9cache key 算出 + cache 参照HIT → 公開可視性再確認後 ksmd_send_response()
10route 別 renderer で Markdown 生成
11cache に保存 → レスポンス送信 → exit

特殊パスの早期分岐

URI が /robots.txt または /sitemap.xml / /sitemap-{type}-{N}.xml にマッチする場合は、resolver を呼ばずに ksmd_output_robots() / ksmd_output_sitemap() へ直接分岐します。 これらのレスポンスは Cache-Control: public, max-age=300 で edge cache 可能です (Markdown 本体は no-store と対照的)。

レスポンス送信時のヘッダ

ksmd_send_response() が出力するヘッダ群は以下の通りです。 これらは「md は seo の補助、canonical は seo 側」という設計方針の HTTP レイヤでの宣言です。

header( 'Content-Type: text/markdown; charset=utf-8' );
header( 'X-Content-Type-Options: nosniff' );
header( 'X-Robots-Tag: noindex, follow' );
header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
header( 'Pragma: no-cache' );
header( 'Link: <' . esc_url_raw( $canonical ) . '>; rel="canonical"' );  // $canonical=false なら省略 (subdir mode custom 等、v1.0.3)
header( 'X-Ksmd-Cache: ' . $cache_status );  // HIT / MISS / NO-CACHE

📝 v1.0.3 — canonical 3 状態化

ksmd_send_response() の第 4 引数 $canonical_url は v1.0.3 で 3 状態に拡張されました:

既存 callers (search route 等) の null / 文字列引数の挙動は維持。

📝 Note

X-Robots-Tag: noindex, follow は md ホストを Google 検索結果に出さない宣言です。 md は AI クローラ専用で、人間向け検索は seo (www) で完結させるための主従宣言です。

md ホスト判定

判定ロジック

md ホスト判定は ksmd_handle_subdomain_request() の最初の関門です。

$host = isset( $_SERVER['HTTP_HOST'] )
    ? strtolower( wp_unslash( $_SERVER['HTTP_HOST'] ) )
    : '';

$opts = get_option( KSMD_OPTION_KEY, array() );
$default_md_host = ksmd_default_md_host();
$md_host = isset( $opts['md_host'] ) && $opts['md_host'] !== ''
    ? $opts['md_host']
    : $default_md_host;
$md_host = apply_filters( 'ksmd_host', $md_host );

if ( $host !== strtolower( (string) $md_host ) ) {
    return; // メイン側は完全に no-op
}

主な特徴:

md_host のデフォルト値算出

md_host 設定が空の場合は ksmd_default_md_host() でデフォルトを算出します。

function ksmd_default_md_host() {
    $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
    if ( ! $home_host ) {
        return 'md.example.com';
    }
    // www. を除去してから md. を付与
    $host = preg_replace( '/^www\./', '', $home_host );
    return 'md.' . $host;
}

home_url() の host から www. プレフィックスを除去し、md. を付与します。 これにより https://www.example.com/ から md.example.com が、 https://example.com/ からも同じく md.example.com が得られます。

AJAX / REST / cron の防御ガード

md ホスト経由で AJAX や REST API が叩かれるケース (例: ブラウザで md ホスト画面を開いた状態で fetch する) に備え、以下のガードで処理を抜けます:

if (
    ( defined( 'DOING_AJAX' ) && DOING_AJAX )
    || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
    || ( defined( 'DOING_CRON' ) && DOING_CRON )
) {
    return;
}

これらの定数は WordPress core が判定したリクエスト種別フラグです。 該当する場合、md plugin は介入せずに WP の通常処理に委ねます。

WP_Query adapter (md-resolver.php)

設計方針: core 解決結果を読むだけ

md-resolver.php は「URI を route 種別に解決する」モジュールですが、 自前で url_to_postid()get_page_by_path() を呼びませんtemplate_redirect 時点で $wp_query はすでに WP の rewrite 解決を完了しているため、 $wp_query->is_singular() 等の判定メソッドを順番に評価するだけの薄い adapter として実装されています。

これにより以下のバグクラスが構造的に消滅します:

判定順序と route 種別

ksmd_resolve_request() は以下の順序で判定します。 front_page を singular より先に評価している点に注意してください (静的フロントページ show_on_front=page では is_singular()true になるため)。

順序 判定メソッド route type context に追加される情報
0is_front_page()front_pagefront_post (page_on_front の WP_Post)
1is_singular()singularobject = WP_Post
2is_post_type_archive()archivepost_type
3is_category()categoryterm = WP_Term
4is_tag()tagterm
5is_tax()termterm
6is_author()authorauthor = WP_User
7is_date()dateyear / month / day
8is_search()searchs (検索クエリ)
9is_feed()feed
10is_home()home
マッチなし404

route と post_type の二重ゲート

各判定では「enabled_routes に含まれているか」と「enabled_post_types に含まれているか」の 二重ゲートを通過する必要があります。たとえば singular route の場合:

if ( $wp_query->is_singular() ) {
    $post = $wp_query->get_queried_object();
    if ( $post instanceof WP_Post
         && in_array( $post->post_type, $allowed_types, true ) ) {
        return array(
            'type'    => 'singular',
            'object'  => $post,
            'context' => $context,
        );
    }
    return ksmd_route_404( $context );
}

archive route も「archive route が有効」かつ「対象 post_type が enabled_post_types に含まれる」の両方を要求します。 これにより、対象範囲タブで CPT を OFF にすれば、その CPT の archive も自動的に表示されません。

context にページネーション情報を含める

$wp_query->get('paged') が 2 以上の場合、$context['paged'] に値を入れて renderer に渡します。これにより /blog/page/2/ 等のページング URL が正しく動きます。

$context = array( 'uri' => $uri );
$paged   = isset( $wp_query ) ? (int) $wp_query->get( 'paged' ) : 0;
if ( $paged > 1 ) {
    $context['paged'] = $paged;
}

Route renderer (md-route-renderers.php)

ksmd_render_route()route_type に応じたヘルパ関数にディスパッチします。 すべての route renderer は次の共通フォーマットを返します:

archive (post_type archive)

ksmd_render_post_type_archive()get_post_type_object() から labels->name を取得して H1 にし、get_post_type_archive_link() を canonical URL として用います。 一覧は WP_Queryposts_per_page=50 を取得し、ksmd_render_post_listing() で Markdown 化します。

Schema.org type のデフォルトは CollectionPage、filter ksmd_schema_type で上書き可能です。

term (category / tag / custom taxonomy)

ksmd_render_term_archive() の特徴は post_type の動的決定にあります。 素朴に WP_Query をデフォルトで呼ぶと post のみが対象になり、 CPT 中心サイトで「2021 年の post 1 件しか出ない」症状が起きます。 そこで:

$term_post_types = ksmd_compute_term_archive_post_types( $term, $opts );
$term_post_types = apply_filters(
    'ksmd_term_archive_post_types',
    $term_post_types,
    $term,
    $context
);

ksmd_compute_term_archive_post_types() は taxonomy の object_typeenabled_post_types の積集合を取り、attachment のみを除外して返します。 これにより、admin が設定画面で当該 taxonomy の登録 post_type を全部無効化したら一覧は空になり、 「記事がありません」が表示されます。

author

ksmd_render_author_archive()$author->display_name を H1、 $author->user_description を bio として出力します。 Schema.org type のデフォルトは ProfilePage です。 post_type は ksmd_compute_archive_default_post_types($opts, 'author', $context) で算出され、 page と attachment が除外されます (WP デフォルト動作と整合)。

date

年月日のいずれかに応じて H1 (2026 / 2026-04 / 2026-04-15) を組み立て、 get_year_link() / get_month_link() / get_day_link() で URL を取得します。 WP_Query には date_query として year/month/day を渡します。

search

ksmd_render_search_route()$context['s'] を H1 に組み込みます。 注目すべきは cache key に検索クエリの md5 を含める点です:

$cache_uri = $uri;
if ( $resolved['type'] === 'search' && isset( $resolved['context']['s'] ) ) {
    $cache_uri = $uri . '?s=' . md5( (string) $resolved['context']['s'] );
}
$cache_key = 'ksmd_md_' . md5( $cache_uri );

これがないと ?s=foo?s=bar が同じ cache を共有してしまいます。 また、検索無効時に ?s=foo でアクセスされた場合は resolver で 404 を返し、 その判定を cache 参照より先に行うことで「home cache を search 経由で汚染しない」短絡も実装されています。

feed

md 化された feed は RSS XML ではなく、最新 50 件を post listing 形式で Markdown として返します。 AI クローラ向けなので RSS XML より Markdown のほうが効率的という設計判断です。

home / front_page

ksmd_render_home_route() はもっとも複雑な renderer です。3 つの特別な振る舞いがあります:

  1. home_intro_markdown: admin で設定された Markdown を H1/Canonical の直後に挿入。 空ならば blogdescription にフォールバック (filter ksmd_home_intro_markdown で上書き可)。
  2. サイトのセクション: enabled_post_typeshas_archive=true の主要アーカイブを 自動列挙し、リンクは md_host 化 (filter ksmd_home_archive_links で拡張可)。
  3. BreadcrumbList → ItemList 置換: home 限定で「Home → Home」の冗長 2 階層 BreadcrumbList を抑制し、 代わりに Schema.org/ItemList として主要アーカイブを schema header に差し込み。 add_filter/remove_filterksmd_filter_home_schema_header_lines を一時登録する方式。

404

ksmd_render_404_route() はシンプルに H1 # 404 Not Found + URI 表示 + Schema.org Thing を返します。 404 は cache 対象外です (ksmd_send_404() 内で set_transient しない)。 これは「無効 URL の連打で wp_options が肥大する cache flood」への対策です。

Singular renderer (md-renderer.php)

HTML → Markdown 変換 7 ルール

md-renderer.php は post の本文を Markdown に変換します。Composer は使わず、自前の DOMDocument パーサで以下のホワイトリストルールを適用します:

HTML タグMarkdown 出力
<h1><h6>#######
<p>段落 (空行で区切る)
<ul><li>- item
<ol><li>1. item
<a href="x">y</a>[y](<x>) (CommonMark angle-bracket)
<strong> / <b>**y**
<em> / <i>*y*
<blockquote>> y
<pre><code class="language-php">```php ... ```
<img alt="a" src="s">![a](<s>)
<br>強制改行 (行末スペース 2 個 + LF)
<hr>---

完全除外タグ

以下のタグは中身ごと完全に削除されます:

$excluded = array(
    'nav', 'aside', 'footer', 'script', 'style',
    'form', 'iframe', 'noscript', 'svg', 'canvas',
);

これにより、サイドバー・フッタ・JavaScript・SVG 等のノイズが Markdown 出力に混入することを防ぎます。 Gutenberg のラッパ divsection 等は「中身を再帰的に処理」するため、構造ごと透過されて本文だけが取り出されます。

PHP 8.2+ 対応

旧来の mb_convert_encoding($html, 'HTML-ENTITIES') は PHP 8.2 で deprecated になりました。 本プラグインでは XML 宣言プリペンドで UTF-8 を明示する方式を採用しています:

$prepended = '<' . '?xml encoding="UTF-8"?' . '>' . $html;
libxml_use_internal_errors( true );
$dom = new DOMDocument( '1.0', 'UTF-8' );
$dom->loadHTML(
    $prepended,
    LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET
);
libxml_clear_errors();
libxml_use_internal_errors( false );

LIBXML_HTML_NOIMPLIED<html><body> ラッパを抑制、 LIBXML_HTML_NODEFDTD で DOCTYPE を抑制、LIBXML_NONET でネットワークアクセスを禁止します。

the_content フィルタの安全な適用

post_content は WordPress の the_content フィルタを通過したものを使います。 ただし以下の副作用は md 経路では除外します:

$had_autoembed = false;
if ( isset( $wp_embed ) && is_object( $wp_embed ) ) {
    $had_autoembed = remove_filter(
        'the_content',
        array( $wp_embed, 'autoembed' ),
        8
    );
}
$had_filter_content_tags = remove_filter(
    'the_content',
    'wp_filter_content_tags'
);

$raw_html = apply_filters( 'the_content', $post->post_content );

if ( $had_autoembed ) {
    add_filter( 'the_content', array( $wp_embed, 'autoembed' ), 8 );
}
if ( $had_filter_content_tags ) {
    add_filter( 'the_content', 'wp_filter_content_tags' );
}

URL の md_host 化

「md 内は md→md で完結」という設計方針に従い、本文中の内部リンクは md_host へ書き換えます。 ただし以下のパスは除外され、元 URL のままになります:

外部 URL (mailto:, tel:, javascript:, 別ドメイン等) も対象外です。 判定は ksmd_is_internal_url() + ksmd_is_md_serviceable_path() の組合せで行います。

scheme の動的決定

md_host の scheme は home_url() の scheme を継承します。 本番 https サイトは https のまま、ローカル http (localhost / WSL2) は http のまま動きます。

$home_scheme = wp_parse_url( $home_url, PHP_URL_SCHEME );
$scheme      = ( $home_scheme === 'http' ) ? 'http' : 'https';
$scheme      = (string) apply_filters(
    'ksmd_md_scheme', $scheme, $url, $md_host
);
if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
    $scheme = 'https';  // 防御
}

Canonical blockquote

H1 タイトル直後に正規ページ (seo URL) を blockquote で明示します:

> **Canonical:** https://example.com/article-slug/
> This Markdown is the AI-optimized parallel version of the canonical HTML page above. Authority, freshness, and canonicalness belong to the canonical page.

これは HTTP Link ヘッダ・Schema.org/Article.url と並んで主従宣言の 3 層目です。 人間が md ホストの URL を直接見たときにも canonical を意識できるよう、可視部分にも書きます。

Schema.org マッピング (md-schema-mapper.php)

type の決定ロジック

ksmd_schema_header_block($post) は post の Schema.org type を以下の優先順で決定します:

  1. schema_type_map[$post->post_type] (admin 設定値)
  2. デフォルト: page なら WebPage、それ以外は Article
  3. filter ksmd_schema_type($type, $post->post_type, ['post' => $post]) で最終上書き
$default = ( $post->post_type === 'page' ) ? 'WebPage' : 'Article';
$type    = isset( $schema_type_map[ $post->post_type ] )
    ? $schema_type_map[ $post->post_type ]
    : $default;
$type    = apply_filters(
    'ksmd_schema_type',
    $type,
    $post->post_type,
    array( 'post' => $post )
);

route 別の Schema.org type

route renderer 側でも同じ filter を使い、第 2 引数で route_type を渡します:

routeデフォルト type
archiveCollectionPage
term / category / tagCollectionPage
authorProfilePage
dateCollectionPage
home / front_pageWebSite
searchSearchResultsPage
404Thing
feedCollectionPage

BreadcrumbList の URL 戦略

ヘッダブロックには Schema.org/{Type}Schema.org/BreadcrumbList が並びます。 URL の使い分けは:

これは「Schema entity の url は canonical を、BreadcrumbList は閲覧導線を」という意味の使い分けです。

Markdown インライン Schema.org 記法

出力例 (post 単一):

> **Schema.org/Article**
> - headline: 記事タイトル
> - author: 著者名
> - datePublished: 2026-04-30T10:00:00+00:00
> - dateModified: 2026-04-30T12:00:00+00:00
> - inLanguage: ja-JP
> - url: https://example.com/articles/sample/
>
> **Schema.org/BreadcrumbList**
> - 1: サイト名 (https://md.example.com/)
> - 2: ブログ (https://md.example.com/blog/)
> - 3: 記事タイトル (https://md.example.com/articles/sample/)

なぜ JSON-LD ではなく Markdown インラインなのか

柏崎剛が提唱する LLM ネイティブサブドメイン の考え方に基づき、AI クローラ向けに Markdown 内へ Schema.org を直接インライン記述する設計です。 理由は次の 3 点です:

  1. LLM のアテンションは先頭に強く偏る (lost-in-the-middle): JSON-LD を <head> や末尾 script タグに置くと、本文中盤の context window から外れたときに参照されにくい。 blockquote として本文先頭に書けば、必ずアテンション圏内に入る。
  2. JSON のシンタックスノイズ: {"@context":"https://schema.org","@type":"Article",...} よりも > - headline: ... のほうが token あたりの情報密度が高い。
  3. 人間にも読める: Markdown のまま GitHub で diff レビューできる。

キャッシュ層 (cache.php)

backend 抽象化

cache.php は 3 つの backend (transient / object_cache / custom_table) を統一 API で扱えるようにする抽象化レイヤです。 外部からは ksmd_cache_get() / ksmd_cache_set() / ksmd_cache_delete() / ksmd_cache_flush_all() の 4 関数のみを使います。

backend 保存先 ヒット時の処理 適合サイト
transient (デフォルト) wp_options テーブル get_transient() 小〜中規模、1 サーバ構成
object_cache Redis / Memcached 等 wp_cache_get($key, 'ksmd', false, $found) persistent object cache 導入済の中〜大規模
custom_table {$wpdb->prefix}ksmd_cache SELECT cache_value FROM ... WHERE cache_key = %s 大規模、wp_options 肥大を避けたい場合

backend 検出と fallback

設定値が object_cache でも、実際に persistent object cache ドロップインが入っていない場合は 自動的に transient にフォールバックします。

function ksmd_cache_backend() {
    $opts    = get_option( KSMD_OPTION_KEY, array() );
    $backend = isset( $opts['cache_backend'] )
        ? (string) $opts['cache_backend']
        : 'transient';
    if ( $backend === 'object_cache' && ! wp_using_ext_object_cache() ) {
        $backend = 'transient';
    }
    return $backend;
}

cache key 生成

cache key は URI (search の場合は ?s= を含む) を md5 して prefix を付けたものです:

$cache_uri = $uri;
if ( $resolved['type'] === 'search' && isset( $resolved['context']['s'] ) ) {
    $cache_uri = $uri . '?s=' . md5( (string) $resolved['context']['s'] );
}
$cache_key = 'ksmd_md_' . md5( $cache_uri );

md5 を使う理由は WordPress の transient key 長制限 (172 文字) と、cache key の URL safety です。 非 ASCII slug や長い URL でも一定長で正規化されます。

TTL とデフォルト値

デフォルト TTL は HOUR_IN_SECONDS (3600 秒) です。 cache_duration 設定で変更可能 (キャッシュタブで秒単位指定)。 0 を指定すると set_transient 呼び出しを skip し、毎回 MISS として再生成します (デバッグ用)。

flush 戦略

ksmd_cache_flush_all() はもっとも巧妙な部分です。backend 別の挙動:

custom_table のスキーマ

CREATE TABLE {$wpdb->prefix}ksmd_cache (
    cache_key VARCHAR(64) NOT NULL,
    cache_value LONGTEXT NOT NULL,
    expires_at INT UNSIGNED NOT NULL,
    PRIMARY KEY  (cache_key),
    KEY expires (expires_at)
) {charset_collate};

テーブルは初回 ksmd_cache_set_custom_table() 呼び出し時に dbDelta() で作成し、 ksmd_cache_table_ready option flag で再作成を抑止します (ホットパスで dbDelta が走らないように)。

セキュリティ層 (security.php)

役割の最小化

セキュリティモジュールの責務は意図的に小さく保たれています:

UA allowlist や rate limit は意図的に実装していません。LLMO/GEO の目的は 「AI クローラに来てほしい」であり、これらの遮断レイヤとは矛盾するためです。 悪質トラフィックの遮断は Cloudflare / WAF / DDoS L7 等の上位レイヤに委譲します。

公開可視性ゲート

function ksmd_is_post_publicly_serviceable( $post ) {
    if ( ! $post instanceof WP_Post ) return false;
    if ( $post->post_status !== 'publish' ) return false;
    if ( $post->post_password !== '' ) return false;
    if ( post_password_required( $post ) ) return false;
    if ( function_exists( 'is_post_publicly_viewable' )
         && ! is_post_publicly_viewable( $post ) ) {
        return false;
    }
    return true;
}

この関数は cache HIT / MISS の両経路で呼ばれます。HIT 経路で再確認するのは、 publishdraft 等の状態変更後、cache 削除前に到達したリクエストで HIT 経由の本文公開を防ぐためです (cache invalidation の保険)。

アクセスログテーブル

CREATE TABLE {$wpdb->prefix}ksmd_access_logs (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    logged_at DATETIME NOT NULL,
    ip VARCHAR(64) NOT NULL DEFAULT '',
    user_agent VARCHAR(255) NOT NULL DEFAULT '',
    uri VARCHAR(500) NOT NULL DEFAULT '',
    status SMALLINT UNSIGNED NOT NULL DEFAULT 0,
    duration_ms INT UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY  (id),
    KEY logged_at (logged_at)
);

旧実装は update_option('ksmd_access_logs', ...) でした。 この方式では wp_options 行ロックで AI クローラの高頻度アクセス時に競合・損失が発生したため、 専用テーブル + INSERT only に切り替えています。retention は読み込み側で適用 (古い行は logged_at >= cutoff で除外)。

IPv6 anonymize (/64 マスク)

旧実装は explode(':', $ip)2001:db8::1 のような短縮表記を 正しく扱えませんでした。新実装は inet_pton / inet_ntop で正規化します:

function ksmd_anonymize_ip( $ip ) {
    if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
        return preg_replace( '/\.\d+$/', '.0', $ip );
    }
    if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
        $bin = inet_pton( $ip );
        if ( $bin === false || strlen( $bin ) !== 16 ) {
            return $ip;
        }
        // 上位 8 バイト (network prefix /64) のみ保持、下位 8 バイトを 0 マスク
        $masked = substr( $bin, 0, 8 ) . str_repeat( "\0", 8 );
        $out = inet_ntop( $masked );
        return $out === false ? $ip : $out;
    }
    return $ip;
}

これは RFC 7239 / GDPR 推奨の「IPv6 anonymization to /64」に準拠しています。

mb_substr fallback

mbstring 拡張が無効な環境を想定し、UA / URI の長さ制限には function_exists('mb_substr') ガード付きで substr にフォールバックします:

$substr = function_exists( 'mb_substr' )
    ? function ( $s, $start, $len ) { return mb_substr( $s, $start, $len ); }
    : function ( $s, $start, $len ) { return substr( $s, $start, $len ); };

Test renderer 用 HMAC token

設定画面の Test renderer は admin (example.com) から md ホスト (md.example.com) へ HTTP リクエストを発行しますが、 cookie はクロスドメインで送信されないため wp_create_nonce 系では検証できません。 代わりに wp_salt('nonce') を共有秘密とした HMAC-SHA256 トークンを使い、両側で同じ salt を参照します:

function ksmd_generate_test_token( $uri ) {
    $expires = time() + 300; // 5 分有効
    $payload = $uri . '|' . $expires;
    $sig     = hash_hmac( 'sha256', $payload, wp_salt( 'nonce' ) );
    return base64_encode( $expires . '.' . $sig );
}

Bootstrap installer (md-bootstrap-installer.php)

目的

md ホスト用の index.php (ABSPATH の wp-blog-header.phprequire する 3 行 PHP) を 管理画面から自動生成・削除する機能です。 DNS / バーチャルホスト設定で md.example.com を WP インストールの兄弟ディレクトリに向けたあと、 そのディレクトリ内に index.php を置く必要があるのですが、SSH を開かずに済ませるための機能です。

生成される index.php のテンプレート

<?php
/**
 * KSMD-BOOTSTRAP-MARKER v1
 * Generated by wp-plugin-kashiwazaki-llmo-md-subdomain on 2026-04-30T...
 * Do not edit manually. Use plugin admin UI to regenerate or remove.
 */
define( "WP_USE_THEMES", true );
require __DIR__ . "/../{abspath_basename}/wp-blog-header.php";

{abspath_basename} は WP インストールディレクトリの最下位名 (例: html)。 兄弟構成と ABSPATH 直下サブディレクトリ構成 (XServer 標準) の 2 種に対応します。 Bedrock / カスタム配置は対応外で、手動 SSH を促すエラーメッセージが出ます。

8 段階の安全ゲート

ksmd_bootstrap_validate_path() は以下の 8 段階の検証をすべて通過した場合のみ ok=true を返します:

#ゲート失敗条件
1絶対パス + realpath/ で始まらない / realpath 失敗
2ディレクトリ存在is_dir() 失敗
3書き込み可能 (静的)is_writable() 失敗
4-aABSPATH 自身ではないmd path == ABSPATH
4-bWP コア dir でないbasename ∈ {wp-admin, wp-includes, wp-content}
5兄弟 or 直下サブディレクトリ孫以下 / 非関連パス
6wp-blog-header.php 到達ABSPATH 内に該当ファイルなし
7既存 index.php 安全性marker / sha256 / version の 3 重一致なし
8probe ファイル書き込みテストopen_basedir / SELinux 等

3 重識別チェック (marker / sha256 / version)

既存 index.php の上書きは「本プラグインが直前に生成したファイル」のみ許可します。 判定は 3 つの条件すべてを満たす必要があります:

  1. KSMD-BOOTSTRAP-MARKER 文字列がファイル内に存在
  2. hash_file('sha256', $path)ksmd_bootstrap_meta option の installed_sha256 と一致
  3. KSMD-BOOTSTRAP-MARKER {version} がファイル内に存在 (現バージョン: v1)

これにより、ユーザが手動配置した別の index.php を意図せず壊すことを防ぎます。

atomic 書き込み (TOCTOU 対策)

ファイル書き込みは tempnam → file_put_contents → rename の atomic 手順で行います:

  1. tempnam($verified_dir, '.ksmd-bootstrap-') で同一ディレクトリ内に一時ファイル作成
  2. tempnam が指定ディレクトリ外に作成された場合は失敗扱い (strpos === 0 チェック)
  3. file_put_contents でテンプレート書き込み
  4. rename 直前に realpath 再検証 (TOCTOU 二重ガード)
  5. rename($tmp, $target) で atomic 置換
  6. rename 後 is_link($target) 確認 (race 検出時は unlink)
  7. chmod($target, 0644)

権限と nonce

すべてのアクション (path 保存・install・uninstall・restore・check) は admin POST 経由で:

これにより CSRF と権限昇格の両方を防いでいます。

24 時間バックアップ

uninstall (削除) 時には削除する index.php の内容を ksmd_bootstrap_last_removed_backup option に 24 時間保存します。 操作ミスからの復旧用に restore アクションも用意されています。

メインサイト (www) との分離

副作用ゼロの 3 層保証

メインサイトの HTML 出力に副作用を与えないことは設計の最上位原則です。 これを 3 層で保証しています:

  1. HTTP_HOST 判定: ksmd_handle_subdomain_request() 冒頭で $host !== $md_host なら即 return。 以後のコードはまったく実行されない。
  2. template_redirect priority -1: 他プラグインの priority 0 以降 hook 群が動く前に、md ホストでは exit 済。 www 側ではこの hook は return するので、後続 hook はそのまま動く。
  3. option 名前空間の分離: すべての option は ksmd_* プレフィックス。他プラグイン (ksus_* 等) には書き込まない。

save_post hook の慎重な設計

save_post hook は www 側でも発火します。ここでは cache 削除のみを行い、 メイン側の動作 (Yoast の sitemap 再生成、RankMath の SEO score 計算等) には干渉しません。

add_action( 'save_post', 'ksmd_invalidate_post_cache', 10, 3 );

function ksmd_invalidate_post_cache( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id )
         || wp_is_post_autosave( $post_id ) ) {
        return;
    }
    ksmd_invalidate_after_post_change( $post );
}

revision / autosave は cache 影響しないため早期 return します。

option 命名規則

本プラグインが書き込む option / table 一覧:

名前種別用途
ksmd_settingsoptionメイン設定 7 タブ全項目
ksmd_cache_table_readyoptioncache テーブル作成済 flag
ksmd_access_log_table_readyoptionaccess_log テーブル作成済 flag
ksmd_bootstrap_metaoptionbootstrap install meta (sha256/version/path)
ksmd_bootstrap_last_removed_backupoptionuninstall 時の 24h バックアップ
ksmd_access_logsoption (legacy)旧アクセスログ option (read-only fallback)
{prefix}ksmd_cachetablecache backend = custom_table 用
{prefix}ksmd_access_logstableアクセスログ専用テーブル
_transient_ksmd_*optioncache backend = transient 用

キャッシュ無効化フロー

3 つのトリガ

cache invalidation は以下の 3 つの hook で発火します:

hook発火条件flush 範囲
save_post post の保存 (publish / draft / private 全状態) 個別 post cache + 一覧系 全 flush
transition_post_status post 状態遷移 (publish→draft 等) 個別 post cache + 一覧系 全 flush
update_option_ksmd_settings 設定保存 (kill_switch 切替・post_type/route 変更等) 条件付き 全 flush

ksmd_invalidate_after_post_change ヘルパ

save_posttransition_post_status は共通のヘルパを呼びます:

function ksmd_invalidate_after_post_change( $post ) {
    if ( ! ( $post instanceof WP_Post ) ) return;

    // auto-draft は cache 対象外なので skip
    if ( $post->post_status === 'auto-draft' ) return;

    // (1) 個別 post の cache を削除 (backend 抽象化経由)
    if ( function_exists( 'ksmd_cache_delete' ) ) {
        $url = get_permalink( $post );
        if ( is_string( $url ) && $url !== '' ) {
            $uri = ksmd_url_to_uri_path( $url );
            if ( $uri !== '' ) {
                ksmd_cache_delete( 'ksmd_md_' . md5( $uri ) );
            }
        }
    }

    // (2) 一覧系 (archive/term/author/date/home/front_page/search/feed)
    //     と sitemap の cache を一括 flush
    if ( function_exists( 'ksmd_cache_flush_all' ) ) {
        ksmd_cache_flush_all();
    }
}

実装方針: 個別 post 数だけ列挙すると O(post 数) の cache key を消す必要があり高コストになります。 一覧系は post 1 件の更新で全件影響しうる (新着記事一覧の変動) ため、ksmd_cache_flush_all() で全 ksmd cache を一括 flush するのが最も簡潔です。読み込み側で次回 cache MISS → 再生成されます。

設定変更時の条件付き flush

update_option_ksmd_settings hook では、変更内容を見て flush 対象を絞ります:

auto-draft の skip

投稿エディタを開いただけで auto-draft の post が大量に生成されます。 これらに対して flush_all を呼ぶと、エディタを開くたびに全 cache が消える deadly sin になります。 そのため auto-draft は早期 return します。

post 公開・更新時の挙動

状態遷移マトリクス

遷移個別 cache一覧 cachewww への影響
auto-draft → 何か
draft → publish削除全 flushなし
publish → publish (更新)削除全 flushなし
publish → draft削除全 flushなし
publish → trash削除全 flushなし
publish → private削除全 flushなし
publish → password 設定削除全 flushなし

HIT 経路での公開可視性再確認

cache が削除される前にリクエストが届いたケースに備え、cache HIT 時にも公開可視性を再確認します:

if ( $cached !== false && ! $force_miss ) {
    if ( $resolved['type'] === 'singular' && isset( $resolved['object'] ) ) {
        $post = $resolved['object'];
        if ( $post instanceof WP_Post
             && ! ksmd_is_post_publicly_serviceable( $post ) ) {
            // 公開可視性失効 → cache を削除しつつ 404
            ksmd_cache_delete( $cache_key );
            ksmd_send_404( $uri );
        }
    }
    ksmd_send_response( $cached, 'HIT', $uri, $canonical_for_hit );
}

これにより、publishdraft 変更後・cache 削除前の race window でも cache HIT 経由で本文が公開されることはありません。

permalink 検証

get_permalink($post) は post status (publish/draft/private/trash) に関係なく URL 文字列を計算しますが、 trash 等で空文字や false を返すケースもあります。そのため戻り値検証を入れています:

$url = get_permalink( $post );
if ( is_string( $url ) && $url !== '' ) {
    $uri = ksmd_url_to_uri_path( $url );
    if ( $uri !== '' ) {
        ksmd_cache_delete( 'ksmd_md_' . md5( $uri ) );
    }
}

ファイル一覧と責務

プラグインを構成するすべてのファイルとその責務を表にまとめます。

ファイル 行数目安 責務
kashiwazaki-llmo-md-subdomain.php 262 プラグインヘッダ、定数定義、plugins_loaded hook、option migration、デフォルト設定値、activation hook
uninstall.php アンインストール時の option / table 全削除
includes/core-functions.php 355 template_redirect hook 本体、host 判定、route ディスパッチ、レスポンス送信、robots.txt / sitemap 出力
includes/md-resolver.php 376 WP_Query adapter、URI → route 解決、sitemap index/個別 XML 生成、robots.txt 生成
includes/md-renderer.php 550 singular post HTML → Markdown 変換、URL md_host 化、Canonical blockquote 生成
includes/md-route-renderers.php 952 archive / term / author / date / home / front_page / search / feed / 404 各 route の Markdown 構築
includes/md-schema-mapper.php 205 singular の Markdown インライン Schema.org 記法ヘッダ・フッタ生成、type マッピング、escape
includes/cache.php 204 3 backend (transient/object_cache/custom_table) 抽象化、custom_table CRUD、flush 戦略
includes/security.php 301 公開可視性ゲート、アクセスログ INSERT、IPv6 anonymize (/64)、HMAC token
includes/md-bootstrap-installer.php 788 md ホスト用 index.php 自動生成・削除、8 段階安全ゲート、3 重識別チェック、24h バックアップ
admin/admin-loader.php 187 admin menu 登録、admin_post hook 群、save_post / transition_post_status / update_option hook、cache invalidation
admin/settings-page-html.php 設定画面 HTML レンダラ (タブナビ + 各タブのフォーム HTML)
admin/settings-page-logic.php admin POST handler 群 (save / clear_cache / kill_switch / export / import / test_render / clear_logs / bootstrap)
admin/sections/general.php 一般タブ HTML (master switch / kill switch / md_host)
admin/sections/content_targets.php 対象範囲タブ HTML (post_type / route 選択)
admin/sections/output.php 出力タブ HTML (Schema header/footer / home_intro)
admin/sections/schema.php Schema.org タブ HTML (post_type/route ごとの type マッピング)
admin/sections/cache.php キャッシュタブ HTML (3 backend 選択 / TTL)
admin/sections/i18n.php 言語タブ HTML (default_in_language / locale 連携)
admin/sections/diagnostics.php 診断タブ HTML (Test renderer / アクセスログ / Export Import / 互換チェック / Bootstrap)
languages/*.po / *.mo i18n 翻訳ファイル (textdomain: kashiwazaki-llmo-md-subdomain)

💡 Tip

各モジュールは互いを直接呼ばず、function_exists() ガードで疎結合に連携しています。 例えば md-route-renderers.phpksmd_is_internal_url()ksmd_rewrite_url_to_md()function_exists でガードしてから呼ぶため、renderer 単体で他モジュールが load されない状況でも fatal にならないようになっています。

まとめ

本章では本プラグインのアーキテクチャを 11 のセクションで解説しました。要点:

次章「拡張ポイント」では、このアーキテクチャに対して外部 (テーマ・他プラグイン) からカスタマイズを差し込むための 11+ の filter リファレンスを詳解します。