+
+ ブログ
+
{t('privacy' as any, lang)}
diff --git a/src/components/__tests__/BlogListPage.test.tsx b/src/components/__tests__/BlogListPage.test.tsx
new file mode 100644
index 0000000..8bf65dd
--- /dev/null
+++ b/src/components/__tests__/BlogListPage.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import BlogListPage from '../BlogListPage';
+import type { BlogPostMeta } from '../../lib/blog';
+
+const mockPosts: BlogPostMeta[] = [
+ { slug: 'post-1', title: 'ガイド記事', description: '説明1', date: '2026-03-27', category: 'guide' },
+ { slug: 'post-2', title: 'Tips記事', description: '説明2', date: '2026-03-26', category: 'tips' },
+ { slug: 'post-3', title: 'もう1つのガイド', description: '説明3', date: '2026-03-25', category: 'guide' },
+];
+
+describe('BlogListPage', () => {
+ it('全記事を表示する', () => {
+ render(
);
+ expect(screen.getByText('ガイド記事')).toBeDefined();
+ expect(screen.getByText('Tips記事')).toBeDefined();
+ expect(screen.getByText('もう1つのガイド')).toBeDefined();
+ });
+
+ it('使い方ガイドタブでフィルタリングされる', async () => {
+ render(
);
+ await userEvent.click(screen.getByRole('tab', { name: '使い方ガイド' }));
+ expect(screen.getByText('ガイド記事')).toBeDefined();
+ expect(screen.getByText('もう1つのガイド')).toBeDefined();
+ expect(screen.queryByText('Tips記事')).toBeNull();
+ });
+
+ it('Tipsタブでフィルタリングされる', async () => {
+ render(
);
+ await userEvent.click(screen.getByRole('tab', { name: 'Tips' }));
+ expect(screen.getByText('Tips記事')).toBeDefined();
+ expect(screen.queryByText('ガイド記事')).toBeNull();
+ });
+
+ it('すべてタブで全記事に戻る', async () => {
+ render(
);
+ await userEvent.click(screen.getByRole('tab', { name: 'Tips' }));
+ await userEvent.click(screen.getByRole('tab', { name: 'すべて' }));
+ expect(screen.getByText('ガイド記事')).toBeDefined();
+ expect(screen.getByText('Tips記事')).toBeDefined();
+ });
+});
diff --git a/src/i18n/en.json b/src/i18n/en.json
index eba5f65..bed34f5 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -117,7 +117,7 @@
"legal.privacy.s5_title": "5. Cookies",
"legal.privacy.s5_body": "We use cookies for Firebase Authentication session management. We do not use cookies for advertising.",
"legal.privacy.s6_title": "6. Contact",
- "legal.privacy.s6_body": "For privacy inquiries, contact the operator (Osaka29 Tech Bureau, osaka29.jp) or contract@osaka29.jp.",
+ "legal.privacy.s6_body": "For privacy inquiries, contact the operator (Osaka29 Tech Bureau, osaka29.jp) or support@obatlog.com.",
"lp.brandLine": "ObatLog — medication diary",
"lp.footer.operator": "Operator:",
"lp.footer.contact": "Contact:",
diff --git a/src/i18n/id.json b/src/i18n/id.json
index 053b000..f331e8e 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -117,7 +117,7 @@
"legal.privacy.s5_title": "5. Cookie",
"legal.privacy.s5_body": "Kami memakai cookie untuk sesi Firebase Authentication. Tidak memakai cookie untuk iklan.",
"legal.privacy.s6_title": "6. Kontak",
- "legal.privacy.s6_body": "Untuk pertanyaan privasi, hubungi pengelola (Osaka29 Tech Bureau, osaka29.jp) atau contract@osaka29.jp.",
+ "legal.privacy.s6_body": "Untuk pertanyaan privasi, hubungi pengelola (Osaka29 Tech Bureau, osaka29.jp) atau support@obatlog.com.",
"lp.brandLine": "ObatLog — catatan obat",
"lp.footer.operator": "Pengelola:",
"lp.footer.contact": "Kontak:",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index d58aeee..9d80cac 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -117,7 +117,7 @@
"legal.privacy.s5_title": "5. Cookie について",
"legal.privacy.s5_body": "本サービスでは、Firebase Authentication によるセッション管理のために Cookie を使用しています。広告目的の Cookie は使用していません。",
"legal.privacy.s6_title": "6. お問い合わせ",
- "legal.privacy.s6_body": "プライバシーに関するお問い合わせは、運営 第29大阪技術局(osaka29.jp)または contract@osaka29.jp までご連絡ください。",
+ "legal.privacy.s6_body": "プライバシーに関するお問い合わせは、運営 第29大阪技術局(osaka29.jp)または support@obatlog.com までご連絡ください。",
"lp.brandLine": "ObatLog — おくすり記録",
"lp.footer.operator": "運営:",
"lp.footer.contact": "お問い合わせ:",
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
new file mode 100644
index 0000000..f5ec1db
--- /dev/null
+++ b/src/lib/blog.ts
@@ -0,0 +1,86 @@
+// ブログ記事の読み取り・HTML変換ユーティリティ
+// ビルド時にサーバーコンポーネントから呼ばれる
+import fs from 'fs';
+import path from 'path';
+import matter from 'gray-matter';
+import { remark } from 'remark';
+import html from 'remark-html';
+
+const BLOG_DIR = path.join(process.cwd(), 'content/blog');
+
+export type BlogCategory = 'tips' | 'guide';
+
+export interface BlogPostMeta {
+ slug: string;
+ title: string;
+ description: string;
+ date: string;
+ category: BlogCategory;
+}
+
+export interface BlogPost extends BlogPostMeta {
+ contentHtml: string;
+}
+
+const REQUIRED_FIELDS = ['title', 'description', 'date', 'category'] as const;
+const VALID_CATEGORIES: BlogCategory[] = ['tips', 'guide'];
+
+// frontmatterバリデーション(不備時はビルドエラー)
+function validateFrontmatter(data: Record
, filePath: string): void {
+ for (const field of REQUIRED_FIELDS) {
+ if (!data[field]) {
+ throw new Error(`frontmatter に "${field}" がありません: ${filePath}`);
+ }
+ }
+ if (!VALID_CATEGORIES.includes(data.category as BlogCategory)) {
+ throw new Error(`category は "tips" または "guide" のみ: ${filePath}`);
+ }
+ if (isNaN(Date.parse(data.date as string))) {
+ throw new Error(`date の形式が不正です(YYYY-MM-DD): ${filePath}`);
+ }
+}
+
+// 全記事のメタデータを日付降順で取得
+export function getAllPosts(): BlogPostMeta[] {
+ const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.md'));
+ const posts = files.map(file => {
+ const filePath = path.join(BLOG_DIR, file);
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const { data } = matter(raw);
+ validateFrontmatter(data, filePath);
+ return {
+ slug: file.replace(/\.md$/, ''),
+ title: data.title as string,
+ description: data.description as string,
+ date: data.date as string,
+ category: data.category as BlogCategory,
+ };
+ });
+ return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+}
+
+// 全slugの配列(generateStaticParams 用)
+export function getAllSlugs(): string[] {
+ return fs.readdirSync(BLOG_DIR)
+ .filter(f => f.endsWith('.md'))
+ .map(f => f.replace(/\.md$/, ''));
+}
+
+// 個別記事のメタデータ + HTML本文を取得
+export async function getPostBySlug(slug: string): Promise {
+ const filePath = path.join(BLOG_DIR, `${slug}.md`);
+ const raw = fs.readFileSync(filePath, 'utf-8');
+ const { data, content } = matter(raw);
+ validateFrontmatter(data, filePath);
+
+ const result = await remark().use(html).process(content);
+
+ return {
+ slug,
+ title: data.title as string,
+ description: data.description as string,
+ date: data.date as string,
+ category: data.category as BlogCategory,
+ contentHtml: result.toString(),
+ };
+}
diff --git a/src/lib/site.ts b/src/lib/site.ts
index 50e912f..95ac90e 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -1,5 +1,5 @@
/** 正規URL(SEO・sitemap)。末尾スラッシュなし */
export function getSiteUrl(): string {
- const raw = process.env.NEXT_PUBLIC_SITE_URL || 'https://obatlog.osaka29.jp';
+ const raw = process.env.NEXT_PUBLIC_SITE_URL || 'https://obatlog.com';
return raw.replace(/\/$/, '');
}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index d007b24..cb33d4e 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,4 +1,5 @@
import type { Config } from 'tailwindcss';
+import typography from '@tailwindcss/typography';
const config: Config = {
content: [
@@ -6,7 +7,7 @@ const config: Config = {
'./src/components/**/*.{ts,tsx}',
],
theme: { extend: {} },
- plugins: [],
+ plugins: [typography],
};
export default config;