Skip to content

記事ページSEO対応#29

Draft
MatsunoHitoshi wants to merge 3 commits into
mainfrom
cursor/seo-bc90
Draft

記事ページSEO対応#29
MatsunoHitoshi wants to merge 3 commits into
mainfrom
cursor/seo-bc90

Conversation

@MatsunoHitoshi
Copy link
Copy Markdown
Owner

Implement comprehensive SEO for article pages to make them discoverable by search engines.

Previously, article pages were fully client-side rendered, making their content invisible to search engine crawlers. This PR converts the article pages to server components, adds dynamic metadata (title, description, OGP), structured data (JSON-LD), a robots.txt file, and a dynamic sitemap.xml to ensure content is indexed and displayed correctly in search results.


Open in Cursor Open in Web

- 記事ページをSSR化し、generateMetadataで動的メタ情報を生成
  - title, description, OGP (og:title, og:description, og:type等)
  - twitter:card メタタグ
  - canonical URL
- JSON-LD構造化データ(Articleスキーマ)を追加
- SSR側で記事コンテンツを非表示テキストとして埋め込み(クローラー対応)
- robots.txt を追加(公開ページのみ許可、管理画面はdisallow)
- sitemap.xml を動的生成(公開済み記事一覧を含む)
- TipTapコンテンツからプレーンテキストを抽出するユーティリティを追加

Co-authored-by: HitoshiMatsuno <MatsunoHitoshi@users.noreply.github.com>
@cursor
Copy link
Copy Markdown

cursor Bot commented Feb 12, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
graph-viz-with-llm Error Error Feb 12, 2026 3:54am

Request Review

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello @MatsunoHitoshi, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

このプルリクエストは、記事ページのSEO(検索エンジン最適化)を大幅に改善することを目的としています。記事コンテンツが検索エンジンに適切にインデックスされ、より多くのユーザーに発見されるように、サーバーサイドレンダリングへの移行、動的なメタデータと構造化データの追加、そしてクローラー制御のためのrobots.txtsitemap.xmlの導入を行いました。これにより、記事の可視性と検索パフォーマンスが向上します。

Highlights

  • 記事ページのサーバーコンポーネント化: 以前はクライアントサイドレンダリングされていた記事ページをサーバーコンポーネントに移行し、検索エンジンがコンテンツをクロールできるようにしました。
  • 動的メタデータ生成: 記事ごとに動的なタイトル、ディスクリプション、OGP(Open Graph Protocol)を生成する機能を追加し、検索結果やSNSシェア時の表示を最適化しました。
  • 構造化データ (JSON-LD) の追加: 記事コンテンツのJSON-LD構造化データを導入し、検索エンジンが記事の情報をより正確に理解し、リッチスニペットとして表示できるようにしました。
  • robots.txt の設定: 検索エンジンのクローラーがサイトを効率的に巡回できるよう、robots.txt ファイルを追加し、クロールを許可・不許可するパスを定義しました。
  • 動的サイトマップ (sitemap.xml) の生成: 公開されている記事を自動的に含む動的な sitemap.xml を生成する機能を追加し、検索エンジンがすべての記事ページを発見しやすくしました。
  • TipTap JSONからのテキスト抽出ユーティリティ: TipTapエディタのJSONコンテンツからプレーンテキストを抽出するユーティリティ関数を導入し、メタデータ生成に活用できるようにしました。

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/app/_utils/text/extract-text-from-tiptap.ts
    • TipTap JSONからプレーンテキストを抽出するユーティリティ関数 extractTextFromTiptap を追加しました。
  • src/app/articles/[workspaceId]/article-page-client.tsx
    • 既存のクライアントサイドロジックを分離し、記事表示のためのクライアントコンポーネントとして新規追加しました。
  • src/app/articles/[workspaceId]/page.tsx
    • ページをサーバーコンポーネントに変換し、generateMetadata 関数による動的メタデータ生成を実装しました。
    • 記事のJSON-LD構造化データを生成する ArticleJsonLd コンポーネントを追加しました。
    • 検索エンジンクローラー向けの非表示テキストコンテンツを追加しました。
    • クライアントサイドのインタラクティブな部分を ArticlePageClient コンポーネントに委譲しました。
  • src/app/robots.ts
    • 検索エンジンのクロールルールを定義する robots.txt ファイルを新規追加しました。
  • src/app/sitemap.ts
    • 公開されている記事を動的に含める sitemap.xml を生成するファイルを新規追加しました。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

このプルリクエストは、記事ページをサーバーコンポーネントに変換し、動的なメタデータ、JSON-LD、robots.txt、サイトマップを追加することで、SEOを包括的に改善するものです。コードは全体的にうまく構成されています。私のフィードバックは、データベースクエリのキャッシュによるパフォーマンス向上、コードの重複を減らして保守性を高めること、そしてサイトマップのSEO詳細を改善してクロールの効率を上げること、といった点に焦点を当てています。また、型定義に関する小さな改善提案も含まれています。

Comment on lines +5 to +6
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function extractTextFromTiptap(node: any, maxLength = 200): string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

引数の型を any ではなく unknown を使用することで、より型安全になります。unknown を使用すると、eslint-disable コメントも不要になります。関数内で型チェックや型アサーションを行うことで、安全に値を利用できます。

export function extractTextFromTiptap(node: unknown, maxLength = 200): string {

Comment on lines +9 to +32
async function getWorkspaceForMeta(workspaceId: string) {
const workspace = await db.workspace.findFirst({
where: {
id: workspaceId,
status: WorkspaceStatus.PUBLISHED,
isDeleted: false,
},
);
select: {
id: true,
name: true,
description: true,
content: true,
createdAt: true,
updatedAt: true,
user: {
select: PUBLIC_USER_SELECT,
},
tags: {
select: { name: true },
},
},
});
return workspace;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getWorkspaceForMeta 関数は generateMetadataArticlePage の両方から呼び出されています。reactcache 関数でこの関数をラップすることで、リクエストごとにデータベースへのクエリが一度だけ実行されるようになり、パフォーマンスが向上します。

また、ファイルの先頭に import { cache } from 'react'; を追加してください。

const getWorkspaceForMeta = cache(async (workspaceId: string) => {
  const workspace = await db.workspace.findFirst({
    where: {
      id: workspaceId,
      status: WorkspaceStatus.PUBLISHED,
      isDeleted: false,
    },
    select: {
      id: true,
      name: true,
      description: true,
      content: true,
      createdAt: true,
      updatedAt: true,
      user: {
        select: PUBLIC_USER_SELECT,
      },
      tags: {
        select: { name: true },
      },
    },
  });
  return workspace;
});

Comment on lines +49 to +52
const description =
workspace.description ??
(extractTextFromTiptap(workspace.content, 160) ||
`${workspace.name} – ArsTraverse で公開された記事`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

この description を生成するロジックは、90行目からの ArticleJsonLd コンポーネント内でも繰り返されています。コードの重複を避け、保守性を高めるために、このロジックをヘルパー関数に切り出すことをお勧めします。

例えば、以下のような関数を定義し、両方の場所から呼び出すことができます。

function getArticleDescription(workspace: NonNullable<Awaited<ReturnType<typeof getWorkspaceForMeta>>>): string {
  return workspace.description ??
    (extractTextFromTiptap(workspace.content, 160) ||
      `${workspace.name} – ArsTraverse で公開された記事`);
}
Suggested change
const description =
workspace.description ??
(extractTextFromTiptap(workspace.content, 160) ||
`${workspace.name} – ArsTraverse で公開された記事`);
const description = getArticleDescription(workspace);

],
},
],
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "https://arstraverse.com";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

baseUrl の定義が複数のファイルで重複しています。これを共通の定数として例えば src/lib/constants.ts のようなファイルに切り出すことで、保守性が向上します。

重複箇所:

  • src/app/articles/[workspaceId]/page.tsx (この行と128行目)
  • src/app/robots.ts (4行目)
  • src/app/sitemap.ts (6行目)

提案:
src/lib/constants.ts:

export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "https://arstraverse.com";

そして、各ファイルでは import { BASE_URL } from '@/lib/constants'; のようにインポートして const baseUrl = BASE_URL; として使用します。

Comment on lines +90 to +93
const description =
workspace.description ??
(extractTextFromTiptap(workspace.content, 160) ||
`${workspace.name} – ArsTraverse で公開された記事`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

先ほどの提案と同様に、こちらの description 生成ロジックも共通のヘルパー関数 getArticleDescription を使用するようにリファクタリングしてください。

Suggested change
const description =
workspace.description ??
(extractTextFromTiptap(workspace.content, 160) ||
`${workspace.name} – ArsTraverse で公開された記事`);
const description = getArticleDescription(workspace);

Comment thread src/app/sitemap.ts
const staticEntries: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

lastModified: new Date() を使用すると、ビルドのたびにサイトマップの更新日時が新しくなり、実際にはコンテンツが変更されていなくても検索エンジンに更新されたと伝えてしまいます。これはクロールの非効率につながる可能性があります。ホームページのような静的なページについては、最後に大きな変更があった日付を固定で指定するか、頻繁に更新されない場合は lastModified を省略することを検討してください。

Suggested change
lastModified: new Date(),
lastModified: new Date("2024-01-01"), // 例: 最後に意味のある変更があった日付

Comment thread src/app/sitemap.ts
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ホームページと同様に、この静的ページでも new Date() を使うのではなく、最後に意味のある変更があった日付を固定で指定する方がSEO的に望ましいです。

Suggested change
lastModified: new Date(),
lastModified: new Date("2024-01-01"), // 例: 最後に意味のある変更があった日付

- Workspaceモデルに isSearchable フィールドを追加(デフォルト: false)
- publish APIに isSearchable パラメータを追加
- updateSearchable APIを新設(公開済み記事の設定変更用)
- 公開モーダルにトグルUIを追加
  - 新規公開時: 検索エンジン公開を選択してから公開
  - 公開済み: トグル変更で即座にAPIで保存
- generateMetadata で isSearchable=false の場合は noindex/nofollow を設定
- sitemap.xml は isSearchable=true の記事のみ含める
- JSON-LD / SEOテキストも isSearchable=true の場合のみ出力

Co-authored-by: HitoshiMatsuno <MatsunoHitoshi@users.noreply.github.com>
Co-authored-by: HitoshiMatsuno <MatsunoHitoshi@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants