アーキテクチャと URL 解決の流れ
プラグインの内部動作を理解するための開発者向け解説です。リクエストが md ホストに到達してから Markdown が応答されるまでの流れを、ファイル単位の責務とともに追いかけます。 設定画面の各タブで何が起きているかを「コードレベルで」把握したい方向けの章です。
全体アーキテクチャ
本プラグインは「メインサイト (www) の HTML 出力に一切手を加えない」という大原則のもと、
md サブドメイン (例: md.example.com) のリクエストだけを template_redirect priority -1 で早期に捕捉
し、Markdown を返却して exit する設計です。
メインサイトのコードパスはまったく変わらず、md ホストでは「テーマ HTML 生成より前」に処理が分岐します。
主要モジュールの責務
プラグインは includes/ 配下の複数の単機能モジュールに分割されています。
各モジュールはお互いを直接呼ばず、core-functions.php の ksmd_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_HOST を md_host 設定値と照合し、
一致しない場合は即 return します。これにより:
- www 側: 既存のテーマ・SEO プラグイン・
<head>出力すべてがそのまま動作 - md 側:
template_redirectpriority-1で他プラグインの priority 0 以降より先に捕捉して Markdown を返却 →exit
💡 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() を実行します。
この関数で:
load_plugin_textdomain() で kashiwazaki-llmo-md-subdomain textdomain を読み込み (i18n 初期化)
ksmd_migrate_legacy_option() で旧 option key (kashiwazaki_llmo_md_settings) → 新 key (ksmd_settings) を 1 回限り移行
includes/core-functions.php を require_once。これにより resolver / renderer / route_renderers / schema_mapper / security / cache が芋づる式に読み込まれる
is_admin() 時のみ admin/admin-loader.php と includes/md-bootstrap-installer.php を追加 require (フロントの opcode footprint を増やさない)
リクエストフロー
md ホストへの HTTP リクエストが届いてから Markdown が返るまでの流れを、
ksmd_handle_subdomain_request() の実装に沿って追いかけます。
template_redirect priority -1 の役割
core-functions.php の冒頭で次のように hook を登録しています。
add_action( 'template_redirect', 'ksmd_handle_subdomain_request', -1 );
template_redirect は WordPress が「テーマテンプレートを読み込む直前」に発火するアクションです。
この時点では:
$wp_queryはすでにparse_request→query→wphook を経て populate 済み- テーマの
header.php/index.phpはまだ読み込まれていない - SEO プラグインの canonical 出力はまだ動いていない (priority 0 以降)
つまり「WP のクエリ解決はそのまま使い、テーマレンダリングだけバイパスする」という理想的なタイミングです。
自前で url_to_postid() や get_page_by_path() を呼ぶ必要がなく、core が解決済みの結果を読むだけの薄い adapter で済みます。
11 ステップのリクエスト処理
以下が ksmd_handle_subdomain_request() の処理順序です。
| # | 処理 | 失敗時の挙動 |
|---|---|---|
| 1 | 計測開始 (microtime(true) を $GLOBALS['ksmd_request_start_ts'] に保存) | — |
| 2 | HTTP_HOST を md_host 設定と比較 | 不一致 → return (メインに副作用ゼロ) |
| 3 | DOING_AJAX / REST_REQUEST / DOING_CRON ガード | 該当 → return |
| 4 | kill_switch 設定確認 | 有効 → 503 Service Unavailable |
| 5 | enabled (master switch) 確認 | 無効 → 503 + 案内メッセージ |
| 6 | REQUEST_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 経路へ抜ける (副作用ゼロ) |
| 8 | ksmd_resolve_request() で route 解決 | 404 → ksmd_send_404() |
| 9 | cache key 算出 + cache 参照 | HIT → 公開可視性再確認後 ksmd_send_response() |
| 10 | route 別 renderer で Markdown 生成 | — |
| 11 | cache に保存 → レスポンス送信 → 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 状態に拡張されました:
- 文字列 (非空): その URL を canonical として明示出力 (例: search route の
?s=込み URL) null(デフォルト、従来):home_url( $uri )にフォールバックfalse(v1.0.3 追加): canonical ヘッダを出力しない (サブディレモードの custom モード等、対応ページがメインサイトに存在しない可能性が高いケース用の sentinel)
既存 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
}
主な特徴:
- case insensitive 比較:
strtolowerを両側に適用 (MD.example.comでも一致) - filter 上書き可能:
ksmd_hostfilter で multisite 等の動的化に対応 - port は無視しない:
HTTP_HOSTは port を含む値が入りうるが、md_host設定も同形式で揃える前提
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 として実装されています。
これにより以下のバグクラスが構造的に消滅します:
url_to_postid()+ 手書き fallback の根本設計ミスget_page_by_path()の post_status / attachment / 階層 page slug 衝突誤検知- term archive 全 taxonomy × 全 term の線形スキャン
- pagination URL (
/post/page/2/) 未対応 - クエリストリング singular (
/?p=123) 未対応 - 非 ASCII slug の URL encoding 正規化不足
index.php/ trailing slash / canonical の揺らぎ
判定順序と route 種別
ksmd_resolve_request() は以下の順序で判定します。
front_page を singular より先に評価している点に注意してください
(静的フロントページ show_on_front=page では is_singular() も true になるため)。
| 順序 | 判定メソッド | route type | context に追加される情報 |
|---|---|---|---|
| 0 | is_front_page() | front_page | front_post (page_on_front の WP_Post) |
| 1 | is_singular() | singular | object = WP_Post |
| 2 | is_post_type_archive() | archive | post_type |
| 3 | is_category() | category | term = WP_Term |
| 4 | is_tag() | tag | term |
| 5 | is_tax() | term | term |
| 6 | is_author() | author | author = WP_User |
| 7 | is_date() | date | year / month / day |
| 8 | is_search() | search | s (検索クエリ) |
| 9 | is_feed() | feed | — |
| 10 | is_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 は次の共通フォーマットを返します:
- H1 (
# {タイトル}) - Canonical blockquote (seo URL を blockquote で明示)
- route 別の description / bio / intro Markdown (任意)
- Schema.org ヘッダブロック (route 別 type)
- 本文 (post listing もしくは固定フロントページの本文)
- Schema.org フッタブロック (Organization)
archive (post_type archive)
ksmd_render_post_type_archive() は get_post_type_object() から
labels->name を取得して H1 にし、get_post_type_archive_link() を canonical URL として用います。
一覧は WP_Query で posts_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_type と
enabled_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 つの特別な振る舞いがあります:
-
home_intro_markdown: admin で設定された Markdown を H1/Canonical の直後に挿入。
空ならば
blogdescriptionにフォールバック (filterksmd_home_intro_markdownで上書き可)。 -
サイトのセクション:
enabled_post_types∩has_archive=trueの主要アーカイブを 自動列挙し、リンクは md_host 化 (filterksmd_home_archive_linksで拡張可)。 -
BreadcrumbList → ItemList 置換: home 限定で「Home → Home」の冗長 2 階層 BreadcrumbList を抑制し、
代わりに
Schema.org/ItemListとして主要アーカイブを schema header に差し込み。add_filter/remove_filterでksmd_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"> |  |
<br> | 強制改行 (行末スペース 2 個 + LF) |
<hr> | --- |
完全除外タグ
以下のタグは中身ごと完全に削除されます:
$excluded = array(
'nav', 'aside', 'footer', 'script', 'style',
'form', 'iframe', 'noscript', 'svg', 'canvas',
);
これにより、サイドバー・フッタ・JavaScript・SVG 等のノイズが Markdown 出力に混入することを防ぎます。
Gutenberg のラッパ div や section 等は「中身を再帰的に処理」するため、構造ごと透過されて本文だけが取り出されます。
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 経路では除外します:
$wp_embed->autoembed: 外部 oEmbed でブロッキング HTTP リクエストが発生wp_filter_content_tags: lazy-loading / responsive image 等の HTML 属性追加
$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 のままになります:
/wp-admin///wp-login.php/wp-content/(uploads / themes / plugins assets)/wp-includes//wp-json/(REST API)/xmlrpc.php/feed/- date archive (
/2026/,/2026/01/,/2026/01/15/) - 検索 (
?s=...)
外部 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 を以下の優先順で決定します:
schema_type_map[$post->post_type](admin 設定値)- デフォルト:
pageならWebPage、それ以外はArticle - 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 |
|---|---|
archive | CollectionPage |
term / category / tag | CollectionPage |
author | ProfilePage |
date | CollectionPage |
home / front_page | WebSite |
search | SearchResultsPage |
404 | Thing |
feed | CollectionPage |
BreadcrumbList の URL 戦略
ヘッダブロックには Schema.org/{Type} と Schema.org/BreadcrumbList が並びます。
URL の使い分けは:
- entity の
urlフィールド (Article/WebPage 等): seo の正規 URL (構造化データレベルの主従宣言) - BreadcrumbList の各項: md_host の URL (ナビゲーション目的、md 内巡回完結)
これは「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 点です:
-
LLM のアテンションは先頭に強く偏る (lost-in-the-middle):
JSON-LD を
<head>や末尾 script タグに置くと、本文中盤の context window から外れたときに参照されにくい。 blockquote として本文先頭に書けば、必ずアテンション圏内に入る。 -
JSON のシンタックスノイズ:
{"@context":"https://schema.org","@type":"Article",...}よりも> - headline: ...のほうが token あたりの情報密度が高い。 - 人間にも読める: 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 別の挙動:
-
transient (常に削除):
wp_optionsから_transient_ksmd_%プレフィックスの行をDELETESQL で一括削除。 -
object_cache:
WP 6.1+ で
wp_cache_supports('flush_group')がtrueならwp_cache_flush_group('ksmd')でグループ単位 flush。 非対応かつ backend が object_cache ならwp_cache_flush()で全 object cache を flush (本プラグインの cache key を tracking していないため止むを得ず)。 backend が transient の場合はwp_cache_flush()を呼ばないのが重要 — 他プラグインの cache まで道連れにしないため。 -
custom_table:
TRUNCATE TABLEで全行削除。
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)
役割の最小化
セキュリティモジュールの責務は意図的に小さく保たれています:
is_post_publicly_viewable()ゲート (draft / private / password 保護を強制 404)- アクセスログ (来訪 AI クローラの観測、弾かない)
- Test renderer 用 HMAC token (cookieless 認証)
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 経路で再確認するのは、
publish → draft 等の状態変更後、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.php を require する 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-a | ABSPATH 自身ではない | md path == ABSPATH |
| 4-b | WP コア dir でない | basename ∈ {wp-admin, wp-includes, wp-content} |
| 5 | 兄弟 or 直下サブディレクトリ | 孫以下 / 非関連パス |
| 6 | wp-blog-header.php 到達 | ABSPATH 内に該当ファイルなし |
| 7 | 既存 index.php 安全性 | marker / sha256 / version の 3 重一致なし |
| 8 | probe ファイル書き込みテスト | open_basedir / SELinux 等 |
3 重識別チェック (marker / sha256 / version)
既存 index.php の上書きは「本プラグインが直前に生成したファイル」のみ許可します。
判定は 3 つの条件すべてを満たす必要があります:
KSMD-BOOTSTRAP-MARKER文字列がファイル内に存在hash_file('sha256', $path)がksmd_bootstrap_metaoption のinstalled_sha256と一致KSMD-BOOTSTRAP-MARKER {version}がファイル内に存在 (現バージョン:v1)
これにより、ユーザが手動配置した別の index.php を意図せず壊すことを防ぎます。
atomic 書き込み (TOCTOU 対策)
ファイル書き込みは tempnam → file_put_contents → rename の atomic 手順で行います:
tempnam($verified_dir, '.ksmd-bootstrap-')で同一ディレクトリ内に一時ファイル作成- tempnam が指定ディレクトリ外に作成された場合は失敗扱い (
strpos === 0チェック) file_put_contentsでテンプレート書き込み- rename 直前に
realpath再検証 (TOCTOU 二重ガード) rename($tmp, $target)で atomic 置換- rename 後
is_link($target)確認 (race 検出時は unlink) chmod($target, 0644)
権限と nonce
すべてのアクション (path 保存・install・uninstall・restore・check) は admin POST 経由で:
current_user_can('manage_options')確認- nonce action 名 = admin POST action 名 (1:1 対応)
- 送信フォームに
wp_nonce_fieldを埋め込み
これにより CSRF と権限昇格の両方を防いでいます。
24 時間バックアップ
uninstall (削除) 時には削除する index.php の内容を
ksmd_bootstrap_last_removed_backup option に 24 時間保存します。
操作ミスからの復旧用に restore アクションも用意されています。
メインサイト (www) との分離
副作用ゼロの 3 層保証
メインサイトの HTML 出力に副作用を与えないことは設計の最上位原則です。 これを 3 層で保証しています:
-
HTTP_HOST 判定:
ksmd_handle_subdomain_request()冒頭で$host !== $md_hostなら即return。 以後のコードはまったく実行されない。 -
template_redirect priority -1:
他プラグインの priority 0 以降 hook 群が動く前に、md ホストでは
exit済。 www 側ではこの hook はreturnするので、後続 hook はそのまま動く。 -
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_settings | option | メイン設定 7 タブ全項目 |
ksmd_cache_table_ready | option | cache テーブル作成済 flag |
ksmd_access_log_table_ready | option | access_log テーブル作成済 flag |
ksmd_bootstrap_meta | option | bootstrap install meta (sha256/version/path) |
ksmd_bootstrap_last_removed_backup | option | uninstall 時の 24h バックアップ |
ksmd_access_logs | option (legacy) | 旧アクセスログ option (read-only fallback) |
{prefix}ksmd_cache | table | cache backend = custom_table 用 |
{prefix}ksmd_access_logs | table | アクセスログ専用テーブル |
_transient_ksmd_* | option | cache 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_post と transition_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 対象を絞ります:
- 常に flush: kill_switch 切替時
- センシティブキーが変わったら flush:
enabled_post_types/enabled_routes/cache_backend/enable_schema_header/enable_schema_footer/schema_type_map/default_in_language/home_intro_markdown/md_host - flush しない: 上記以外 (cache_duration の変更等は新規取得時に自然反映)
auto-draft の skip
投稿エディタを開いただけで auto-draft の post が大量に生成されます。
これらに対して flush_all を呼ぶと、エディタを開くたびに全 cache が消える deadly sin になります。
そのため auto-draft は早期 return します。
post 公開・更新時の挙動
状態遷移マトリクス
| 遷移 | 個別 cache | 一覧 cache | www への影響 |
|---|---|---|---|
| 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 );
}
これにより、publish → draft 変更後・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.php は ksmd_is_internal_url() や ksmd_rewrite_url_to_md()
を function_exists でガードしてから呼ぶため、renderer 単体で他モジュールが load されない状況でも fatal にならないようになっています。
まとめ
本章では本プラグインのアーキテクチャを 11 のセクションで解説しました。要点:
- HTTP_HOST 判定 + template_redirect priority -1 で md ホストだけを早期捕捉
- WP_Query adapter として動作し、自前の URL 解決ロジックは持たない
- 9 つの route (singular / archive / term / author / date / home / front_page / search / feed) に対応
- HTML → Markdown は DOMDocument + 7 ホワイトリストルール
- Markdown インライン Schema.org 記法 (JSON-LD 不使用)
- 3 backend 抽象化 (transient / object_cache / custom_table)
- save_post + transition_post_status + update_option hook で cache invalidation
- メインサイトには副作用ゼロ を 3 層で保証
次章「拡張ポイント」では、このアーキテクチャに対して外部 (テーマ・他プラグイン) からカスタマイズを差し込むための 11+ の filter リファレンスを詳解します。