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 && ( +