From 6327783554018e69a6d8abd36f6ea538df6ee9f3 Mon Sep 17 00:00:00 2001
From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>
Date: Sun, 21 Jun 2026 19:35:30 +0900
Subject: 一般質問ページに FAQPage 構造化データを追加
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
QuestionSummary コンポーネントを導入し、63ページの一般質問に
Schema.org FAQPage JSON-LD を追加。表と構造化データを単一の
データ源から生成するため、表の更新と JSON-LD の同期が自動化される。
- src/components/QuestionSummary.astro: 新規。表と JSON-LD を生成
- src/components/StructuredQA.astro: 削除(QuestionSummary に置換)
- scripts/migrate-to-question-summary.mjs: 旧 Markdown 表の変換用
- src/content/docs/ippan-situmon/**/*.mdx: 63ファイル移行済み
新規ページ作成時は QuestionSummary コンポーネントを使用する。
Markdown 表を直接書くことは禁止(コンポーネントにルールを明記)。
---
scripts/migrate-to-question-summary.mjs | 187 +++++++++++++++++++++
src/components/QuestionSummary.astro | 88 ++++++++++
.../r1d/12gatu/2-gomi-sisetu-jouhou.mdx | 28 +--
.../ippan-situmon/r1d/3gatu/1-dyslexia-kankyo.mdx | 52 +++---
.../r1d/3gatu/2-jinkou-suikei-kagaku.mdx | 18 +-
.../r1d/6gatu/1-touhyouritu-koujou.mdx | 34 ++--
.../r1d/6gatu/2-homepage-siminsanka.mdx | 30 ++--
.../r1d/9gatu/1-tochi-jourei-keisi.mdx | 21 ++-
.../r2d/3gatu/1-carbon-neutral-giman.mdx | 45 ++---
.../r2d/3gatu/2-senkyo-yokusuru-again.mdx | 18 +-
.../r3d/12gatu/1-tokyo-vaction-kenpou-ihan.mdx | 16 +-
.../r3d/12gatu/2-manabu-kikai-sonsitu.mdx | 30 ++--
.../r3d/12gatu/3-kokyo-toire-kyouryokuten.mdx | 18 +-
.../r3d/3gatu/1-mizukara-rissuru-sikumi.mdx | 18 +-
.../r3d/3gatu/2-ijime-taiou-minaosi.mdx | 22 ++-
.../3gatu/3-hoiku-youchien-mask-kyosei-sinai.mdx | 16 +-
.../r3d/9gatu/1-cashless-point-gamble.mdx | 28 +--
.../r3d/9gatu/3-vaccine-sabetu-jinkensingai.mdx | 16 +-
.../r4d/12gatu/1-simin-machizukuri-jourei.mdx | 56 +++---
.../r4d/12gatu/2-stop-cashless-jirihin.mdx | 14 +-
.../r4d/3gatu/1-ijime-judai-daisansya.mdx | 34 ++--
.../r4d/3gatu/2-ijime-judai-chousa.mdx | 32 ++--
.../r4d/3gatu/3-kyouin-ijime-taibatu.mdx | 16 +-
.../r4d/3gatu/4-jouhou-koukai-fufuku-sinsa.mdx | 30 ++--
.../r4d/6gatu/1-judai-jitai-kodomo-chusin.mdx | 72 ++++----
.../r4d/6gatu/2-hontouno-kyouikuwo.mdx | 16 +-
.../r4d/9gatu/1-judai-jitai-kyogi-toben.mdx | 33 ++--
.../r4d/9gatu/2-tokyo-saresio-kaihatu.mdx | 36 ++--
.../r4d/9gatu/4-daisy-ikkatu-fukudokuhon.mdx | 14 +-
.../9gatu/5-guideline-syusei-mokusyoku-owari.mdx | 20 ++-
.../2-ijime-judai-jitai-chousa-sosiki-kousei.mdx | 28 +--
.../12gatu/3-ijime-siryou-tukuranai-arienai.mdx | 26 +--
.../r5d/12gatu/4-taibatu-kyouin-syougen-yusen.mdx | 21 ++-
.../r5d/12gatu/5-gyakutai-keisi-sityou.mdx | 24 +--
.../3gatu/1-gyakutai-tuuhou-amakumiru-kodaira.mdx | 58 ++++---
.../2-kodaira-dake-ijou-ijime-judai-jitai.mdx | 32 ++--
...3-ijime-judai-jitai-tyousa-houkokusyo-keisi.mdx | 20 ++-
.../r5d/3gatu/4-simin-uttae-koukateki-kaiketu.mdx | 18 +-
.../r5d/6gatu/1-ijime-judai-tenken-hyouka.mdx | 48 +++---
.../r5d/6gatu/2-kodaira-kyusekki-kyoten.mdx | 24 +--
.../r5d/6gatu/3-dokusyo-public-comment-more.mdx | 20 ++-
.../9gatu/1-ijime-judai-bunso-gennan-daisansya.mdx | 45 ++---
.../2-kyouikuiinkai-tenken-hyouka-nannotame.mdx | 34 ++--
.../r5d/9gatu/3-jouhou-koukai-samatageruna.mdx | 24 +--
.../4-gyousei-fufuku-sinsakai-rieki-souhan.mdx | 20 ++-
...jime-yosan-kyohi-sityou-kyouikuchou-sekinin.mdx | 28 +--
.../2-kyouiku-iinkai-husei-taiou-zijyou-kinou.mdx | 22 ++-
.../r6d/3gatu/1-tokiwakai-syougaisya-gyakutai.mdx | 18 +-
.../3gatu/2-ijime-taisaku-iinkai-kinou-huzen.mdx | 26 +--
.../r6d/3gatu/3-kaitei-ijime-bousi-kihonhousin.mdx | 36 ++--
.../4-kyouikutyou-kyogi-touben-harasumento.mdx | 14 +-
.../r6d/9gatu/1-gyakutaigosoku-hogosya-bunri.mdx | 34 ++--
.../r6d/9gatu/2-ijime-yosan-yobo-sityou-syonin.mdx | 18 +-
.../r6d/9gatu/3-gyousei-huhuku-sinsakai.mdx | 24 +--
.../ippan-situmon/r7d/12gatu/1-koubunsyo-kanri.mdx | 35 ++--
.../r7d/12gatu/2-ijime-judai-yosan.mdx | 16 +-
.../r7d/12gatu/3-nyusatsu-hutyou-zuii-keiyaku.mdx | 18 +-
.../r7d/3gatu/1-tokiwakai-gyakutaigosoku-wakai.mdx | 16 +-
.../r7d/3gatu/2-koubunsyo-kanri-syomondai.mdx | 36 ++--
.../r7d/3gatu/3-syokusaikanri-jittai.mdx | 22 ++-
...okiwakai-syougaisya-gyakutai-taiou-futatabi.mdx | 32 ++--
.../6gatu/2-takanodai-ekimae-koubunsyo-husei.mdx | 30 ++--
.../r7d/6gatu/3-ijime-taisaku-yobihi-ihou.mdx | 14 +-
.../6gatu/4-kyouikuchou-kyogi-touben-chousa.mdx | 20 ++-
.../r7d/9gatu/1-koubunsyo-husei-futatabi.mdx | 38 +++--
.../r7d/9gatu/2-ijime-taisaku-yobihi-futatabi.mdx | 18 +-
66 files changed, 1276 insertions(+), 739 deletions(-)
create mode 100644 scripts/migrate-to-question-summary.mjs
create mode 100644 src/components/QuestionSummary.astro
diff --git a/scripts/migrate-to-question-summary.mjs b/scripts/migrate-to-question-summary.mjs
new file mode 100644
index 0000000..75cabd2
--- /dev/null
+++ b/scripts/migrate-to-question-summary.mjs
@@ -0,0 +1,187 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { readdir } from "node:fs/promises";
+import { join, extname, relative } from "node:path";
+
+const DOCS_DIR = join(import.meta.dirname, "..", "src", "content", "docs");
+
+/**
+ * Recursively collect all .mdx files, excluding files starting with "_" or "index".
+ */
+async function collectMdxFiles(dir) {
+ const entries = await readdir(dir, { withFileTypes: true });
+ const files = [];
+
+ for (const entry of entries) {
+ if (entry.name.startsWith("_")) continue;
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ const subFiles = await collectMdxFiles(fullPath);
+ files.push(...subFiles);
+ } else if (extname(entry.name) === ".mdx" && entry.name !== "index.mdx") {
+ files.push(fullPath);
+ }
+ }
+
+ return files;
+}
+
+/**
+ * Extract Q&A rows from the summary table in the MDX content.
+ * Table format:
+ * | 質問 | 答弁概要(クリックで詳細) |
+ * |---|---|
+ * | ① question text | [answer text](#anchor) |
+ */
+function extractQATable(content) {
+ // Find the summary table: starts with "| 質問 | 答弁概要"
+ const tableStartRegex =
+ /\| 質問 \| 答弁概要(クリックで詳細) \|\r?\n\|[-| ]+\|\r?\n/;
+ const tableStartMatch = content.match(tableStartRegex);
+ if (!tableStartMatch) return null;
+
+ const tableStart = tableStartMatch.index;
+ const afterHeader = content.slice(tableStart + tableStartMatch[0].length);
+
+ // Collect table rows
+ const rows = [];
+ const lines = afterHeader.split(/\r?\n/);
+ for (const line of lines) {
+ const rowMatch = line.match(/^\| (.+?) \| \[(.+?)\]\((#.+?)\)\s*\|$/);
+ if (!rowMatch) break;
+ rows.push({
+ question: rowMatch[1].trim(),
+ answer: rowMatch[2].trim(),
+ anchor: rowMatch[3].replace(/^#/, "").trim(),
+ });
+ }
+
+ if (rows.length === 0) return null;
+
+ // Calculate the end of the table (header + separator + all rows)
+ const tableText = content.slice(tableStart);
+ const tableEndMatch = tableText.match(
+ new RegExp(
+ `(?:^\\| .+? \\| \\[.+?\\]\\(#.+?\\)\\s*\\|$(?:\\r?\\n)?){${rows.length}}`,
+ "m",
+ ),
+ );
+ if (!tableEndMatch) return null;
+
+ const tableEnd =
+ tableStart + tableStartMatch[0].length + tableEndMatch[0].length;
+
+ return {
+ rows,
+ tableStart,
+ tableEnd,
+ };
+}
+
+/**
+ * Extract frontmatter fields from MDX content.
+ */
+function extractFrontmatter(content) {
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ if (!match) return {};
+ const fm = match[1];
+
+ const titleMatch = fm.match(/^title:\s*["']?(.+?)["']?\s*$/m);
+ const dateMatch = fm.match(
+ /^(?:first|date):\s*["']?(\d{4}-\d{2}-\d{2})["']?\s*$/m,
+ );
+
+ return {
+ title: titleMatch ? titleMatch[1].trim() : null,
+ date: dateMatch ? dateMatch[1].trim() : null,
+ };
+}
+
+/**
+ * Ensure the import line for QuestionSummary exists in the import block.
+ */
+function addImport(content, importPath) {
+ const importLine = `import QuestionSummary from '${importPath}';`;
+
+ if (content.includes("QuestionSummary")) {
+ return content; // Already imported
+ }
+
+ // Find the last import statement and insert after it
+ const importRegex = /^(import .+)$/gm;
+ const imports = [...content.matchAll(importRegex)];
+ if (imports.length > 0) {
+ const lastImport = imports[imports.length - 1];
+ const insertPos = lastImport.index + lastImport[0].length;
+ return (
+ content.slice(0, insertPos) + "\n" + importLine + content.slice(insertPos)
+ );
+ }
+
+ return content; // No existing imports
+}
+
+/**
+ * Generate the QuestionSummary component JSX string.
+ */
+function generateQuestionSummary(qaRows, headline, datePublished) {
+ const qaEntries = qaRows
+ .map((row) => {
+ return ` { question: ${JSON.stringify(row.question)}, answer: ${JSON.stringify(row.answer)}, anchor: ${JSON.stringify(row.anchor)} },`;
+ })
+ .join("\n");
+
+ const props = [];
+ if (headline) props.push(` headline=${JSON.stringify(headline)}`);
+ if (datePublished)
+ props.push(` datePublished=${JSON.stringify(datePublished)}`);
+
+ return ``;
+}
+
+async function main() {
+ const files = await collectMdxFiles(DOCS_DIR);
+ console.log(`Found ${files.length} MDX files to check.`);
+
+ let migrated = 0;
+ let skipped = 0;
+
+ for (const file of files) {
+ const content = readFileSync(file, "utf-8");
+ const tableData = extractQATable(content);
+
+ if (!tableData) {
+ skipped++;
+ continue;
+ }
+
+ const fm = extractFrontmatter(content);
+
+ // Generate the replacement component
+ const component = generateQuestionSummary(
+ tableData.rows,
+ fm.title,
+ fm.date,
+ );
+
+ // Replace the table with the component
+ let newContent =
+ content.slice(0, tableData.tableStart) +
+ component +
+ content.slice(tableData.tableEnd);
+
+ // Ensure the import exists
+ newContent = addImport(newContent, "@/components/QuestionSummary.astro");
+
+ writeFileSync(file, newContent, "utf-8");
+ const relPath = relative(DOCS_DIR, file);
+ console.log(` Migrated: ${relPath} (${tableData.rows.length} QA pairs)`);
+ migrated++;
+ }
+
+ console.log(`\nDone: ${migrated} migrated, ${skipped} skipped.`);
+}
+
+main().catch((err) => {
+ console.error("Error:", err);
+ process.exit(1);
+});
diff --git a/src/components/QuestionSummary.astro b/src/components/QuestionSummary.astro
new file mode 100644
index 0000000..96ec6f8
--- /dev/null
+++ b/src/components/QuestionSummary.astro
@@ -0,0 +1,88 @@
+---
+/**
+ * QuestionSummary — 一般質問の「まとめ」表と FAQPage JSON-LD を単一のデータ源から生成。
+ *
+ * ## 使い方(新規一般質問ページを作成する際のルール)
+ *
+ * 1. Markdown の表(| 質問 | 答弁概要 |)は **書かない**。
+ * 2. 代わりにこのコンポーネントを import して使う:
+ *
+ * ```mdx
+ * import QuestionSummary from '@/components/QuestionSummary.astro';
+ *
+ *
+ * ```
+ *
+ * 3. 表の更新は qa 配列を編集するだけ。表表示と JSON-LD が自動で同期される。
+ *
+ * - question: 表の「質問」列の内容(番号付きで)
+ * - answer: 表の「答弁概要」列のテキスト(リンク構文なしのプレーンテキスト)
+ * - anchor: 詳細セクションへのアンカー(見出しから自動生成されるID)
+ * - headline: ページのタイトル(JSON-LD の headline に使われる)
+ * - datePublished: 質問日(ISO 8601: YYYY-MM-DD)
+ */
+
+export interface QA {
+ /** 質問文(例: "通報後に作成する文書と保存期間は。") */
+ question: string;
+ /** 答弁概要のテキスト(リンク構文を含まないプレーンテキスト) */
+ answer: string;
+ /** 詳細セクションへのアンカー(例: "-通報後に作成する文書と保存期間は") */
+ anchor: string;
+}
+
+export interface Props {
+ /** Q&A の配列 */
+ qa: QA[];
+ /** ページの表題 */
+ headline?: string;
+ /** 公開日(ISO 8601形式: YYYY-MM-DD) */
+ datePublished?: string;
+}
+
+const { qa, headline, datePublished } = Astro.props;
+---
+
+{
+ qa.length > 0 && (
+