diff --git a/src/constants/index.ts b/src/constants/index.ts index 66a9a32..fc1f510 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,3 +2,9 @@ export const FORM_URL = "https://docs.google.com/forms/d/e/1FAIpQLSc1-E-aEEFeRlxggQLTea6SY96W-1fNVUN5MxVYKWfmId1yUg/viewform"; export const JOIN_URL = "https://docs.google.com/forms/d/e/1FAIpQLSdEsHCLaclQtPJf7it7b-N6OJIV9nXOwgEJA6Pl_hp6nYOeiA/viewform?usp=header"; + +// ISR(Incremental Static Regeneration)の再生成間隔(秒)。 +// ビルド時に全ページを生成するのではなく、リクエスト時に生成してこの間隔でキャッシュを更新することで、 +// ビルドごとのNotion APIの呼び出し回数を大幅に削減する。 +// 1時間 = Notionの画像URLの有効期限ともおおよそ揃えている。 +export const ISR_REVALIDATE_SECONDS = 3600; diff --git a/src/pages/achievements/index.tsx b/src/pages/achievements/index.tsx index d4c6ee4..4c231de 100644 --- a/src/pages/achievements/index.tsx +++ b/src/pages/achievements/index.tsx @@ -1,3 +1,4 @@ +import { ISR_REVALIDATE_SECONDS } from "@/constants"; import { AchievementsScreen } from "@/screens/Achievements"; import { Props as AchievementItemProps } from "@/ui/AchievementItem"; import cacheRemoteImage from "@/utils/cacheRemoteImage"; @@ -62,5 +63,6 @@ export const getStaticProps = async () => { props: { achievements, }, + revalidate: ISR_REVALIDATE_SECONDS, }; }; diff --git a/src/pages/blog/[id]/index.tsx b/src/pages/blog/[id]/index.tsx index 61b9382..169b2b4 100644 --- a/src/pages/blog/[id]/index.tsx +++ b/src/pages/blog/[id]/index.tsx @@ -1,3 +1,4 @@ +import { ISR_REVALIDATE_SECONDS } from "@/constants"; import { BlogArticleScreen } from "@/screens/BlogArticle"; import { Block, Column } from "@/types/block"; import { Props as ArticleItemProps } from "@/ui/ArticleItem"; @@ -5,7 +6,7 @@ import { Props as PageInfo } from "@/ui/ArticleTitle"; import cacheRemoteImage from "@/utils/cacheRemoteImage"; import createOGPImage from "@/utils/createOGPImage"; import { Meta } from "@/utils/meta"; -import { getBlocks, getDatabase, getPage } from "@/utils/notion"; +import { getBlocks, getPage } from "@/utils/notion"; import { getArticles } from "@/utils/useGetArticles"; export default function Article({ @@ -42,17 +43,24 @@ export default function Article({ } export const getStaticPaths = async () => { - const articles = await getArticles(); - - const paths = articles.map((article) => ({ - params: { id: article.id }, - })); - - return { paths, fallback: false }; + // ビルド時に全記事を事前生成すると記事数に比例してNotion APIを大量に呼び出してしまう。 + // パスは事前生成せず、リクエスト時にオンデマンド生成し、ISRでキャッシュする。 + return { paths: [], fallback: "blocking" }; }; export const getStaticProps = async ({ params }: { params: { id: string } }) => { const pageId = params.id as string; + + // 公開記事の一覧。記事の存在確認とおすすめ記事の両方に使い回し、Notionへの問い合わせを1回に抑える。 + const publicArticles = await getArticles(); + + // fallback: 'blocking' では任意のIDでアクセスされ得るため、 + // 公開記事に含まれないID(非公開・存在しない・公開日前)は404にする。 + const isPublicArticle = publicArticles.some((article) => article.id === pageId); + if (!isPublicArticle) { + return { notFound: true, revalidate: ISR_REVALIDATE_SECONDS }; + } + const blocks = (await getBlocks(pageId)) as Block[]; const page = (await getPage(pageId)) as any; const createdBy = page.properties.Created_By?.formula?.string; @@ -128,9 +136,7 @@ export const getStaticProps = async ({ params }: { params: { id: string } }) => .join(""); const description = fullText.length <= 100 ? fullText : fullText.slice(0, 100) + "..."; - const getSuggestArticles = async () => { - const publicArticles = await getArticles(); - + const getSuggestArticles = () => { const shuffleArray = (array: any[]) => { for (let i = array.length - 1; i >= 0; i--) { const tmp = Math.floor(Math.random() * (i + 1)); @@ -140,12 +146,14 @@ export const getStaticProps = async ({ params }: { params: { id: string } }) => }; const suggestLength = 3; - const results = shuffleArray(publicArticles).slice(0, suggestLength); + // 表示中の記事を除いた公開記事からランダムに選ぶ(publicArticlesを再利用) + const candidates = publicArticles.filter((article) => article.id !== pageId); + const results = shuffleArray(candidates).slice(0, suggestLength); return results as ArticleItemProps[]; }; - const suggestArticles = await getSuggestArticles(); + const suggestArticles = getSuggestArticles(); const title = page.properties.Name.title[0].plain_text; const writerName = customName || createdBy || null; @@ -160,5 +168,6 @@ export const getStaticProps = async ({ params }: { params: { id: string } }) => description: description, lastEditedTime: page.last_edited_time, }, + revalidate: ISR_REVALIDATE_SECONDS, }; }; diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 17a9dd2..3cea458 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,3 +1,4 @@ +import { ISR_REVALIDATE_SECONDS } from "@/constants"; import { BlogScreen } from "@/screens/Blog"; import { Props as ArticleItemProps } from "@/ui/ArticleItem"; import { Meta } from "@/utils/meta"; @@ -19,5 +20,6 @@ export async function getStaticProps() { props: { articles, }, + revalidate: ISR_REVALIDATE_SECONDS, }; } diff --git a/src/pages/blog/tag/[tag]/index.tsx b/src/pages/blog/tag/[tag]/index.tsx index 20e1716..26d8944 100644 --- a/src/pages/blog/tag/[tag]/index.tsx +++ b/src/pages/blog/tag/[tag]/index.tsx @@ -1,45 +1,39 @@ -import { BlogScreen } from "@/screens/Blog"; -import { Props as ArticleItemProps } from "@/ui/ArticleItem"; -import { Meta } from "@/utils/meta"; -import { getDatabase } from "@/utils/notion"; -import { getArticles } from "@/utils/useGetArticles"; - -export default function TagPage({ tag, articles }: { tag: string; articles: ArticleItemProps[] }) { - const headingText = `${tag}に関する記事`; - return ( - <> - - - - ); -} - -export async function getStaticPaths() { - const databaseId = process.env.NOTION_BLOG_DATABASE_ID; - const articleDb = await getDatabase(databaseId); - - const tags = new Set(); - articleDb.forEach((article: any) => { - article.properties.tag.multi_select.forEach((tag: any) => { - tags.add(tag.name); - }); - }); - - const paths = Array.from(tags).map((tag) => ({ - params: { tag: tag }, - })); - - return { paths, fallback: false }; -} - -export async function getStaticProps({ params }: { params: { tag: string } }) { - const tag = params.tag; - const articles = await getArticles(tag); - - return { - props: { - tag, - articles, - }, - }; -} +import { ISR_REVALIDATE_SECONDS } from "@/constants"; +import { BlogScreen } from "@/screens/Blog"; +import { Props as ArticleItemProps } from "@/ui/ArticleItem"; +import { Meta } from "@/utils/meta"; +import { getArticles } from "@/utils/useGetArticles"; + +export default function TagPage({ tag, articles }: { tag: string; articles: ArticleItemProps[] }) { + const headingText = `${tag}に関する記事`; + return ( + <> + + + + ); +} + +export async function getStaticPaths() { + // タグの一覧取得のためだけにビルド時へNotionへ問い合わせるのを避け、 + // リクエスト時にオンデマンド生成してISRでキャッシュする。 + return { paths: [], fallback: "blocking" }; +} + +export async function getStaticProps({ params }: { params: { tag: string } }) { + const tag = params.tag; + const articles = await getArticles(tag); + + // 公開記事が存在しないタグ(存在しないタグへの直接アクセスなど)は404にする。 + if (articles.length === 0) { + return { notFound: true, revalidate: ISR_REVALIDATE_SECONDS }; + } + + return { + props: { + tag, + articles, + }, + revalidate: ISR_REVALIDATE_SECONDS, + }; +} diff --git a/src/pages/blog/writer/[writer]/index.tsx b/src/pages/blog/writer/[writer]/index.tsx index 02cfd5d..a72164b 100644 --- a/src/pages/blog/writer/[writer]/index.tsx +++ b/src/pages/blog/writer/[writer]/index.tsx @@ -1,54 +1,46 @@ -import { BlogScreen } from "@/screens/Blog"; -import { Props as ArticleItemProps } from "@/ui/ArticleItem"; -import { Meta } from "@/utils/meta"; -import { getDatabase } from "@/utils/notion"; -import { getArticles } from "@/utils/useGetArticles"; - -export default function WriterPage({ - writer, - articles, -}: { - writer: string; - articles: ArticleItemProps[]; -}) { - const headingText = `${writer}による記事`; - - return ( - <> - - - - ); -} - -export async function getStaticPaths() { - const databaseId = process.env.NOTION_BLOG_DATABASE_ID; - const articleDb = await getDatabase(databaseId); - - const writers = new Set(); - articleDb.forEach((article: any) => { - const customName = article.properties.Custom_Name?.rich_text?.[0]?.plain_text; - const createdBy = article.properties.Created_By?.formula?.string; - const writerName = customName || createdBy; - if (writerName) { - writers.add(writerName); - } - }); - const paths = Array.from(writers).map((writer) => ({ - params: { writer: writer }, - })); - - return { paths, fallback: false }; -} - -export async function getStaticProps({ params }: { params: { writer: string } }) { - const writer = params.writer; - const articles = await getArticles(undefined, params.writer); - - return { - props: { - writer, - articles, - }, - }; -} +import { ISR_REVALIDATE_SECONDS } from "@/constants"; +import { BlogScreen } from "@/screens/Blog"; +import { Props as ArticleItemProps } from "@/ui/ArticleItem"; +import { Meta } from "@/utils/meta"; +import { getArticles } from "@/utils/useGetArticles"; + +export default function WriterPage({ + writer, + articles, +}: { + writer: string; + articles: ArticleItemProps[]; +}) { + const headingText = `${writer}による記事`; + + return ( + <> + + + + ); +} + +export async function getStaticPaths() { + // ライター一覧の取得のためだけにビルド時にNotionへ問い合わせるのを避け、 + // リクエスト時にオンデマンド生成してISRでキャッシュする。 + return { paths: [], fallback: "blocking" }; +} + +export async function getStaticProps({ params }: { params: { writer: string } }) { + const writer = params.writer; + const articles = await getArticles(undefined, params.writer); + + // 公開記事が存在しないライター(存在しないライターへの直接アクセスなど)は404にする。 + if (articles.length === 0) { + return { notFound: true, revalidate: ISR_REVALIDATE_SECONDS }; + } + + return { + props: { + writer, + articles, + }, + revalidate: ISR_REVALIDATE_SECONDS, + }; +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ed59830..3183f8c 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,3 +1,4 @@ +import { ISR_REVALIDATE_SECONDS } from "@/constants"; import { TopScreen } from "@/screens/Top"; import { Props as ArticleItemProps } from "@/ui/ArticleItem"; import { ProductionDetailProps as ProductionProps } from "@/ui/Production"; @@ -147,5 +148,6 @@ export const getStaticProps = async () => { product: filteredProduct, articles: filteredArticles, }, + revalidate: ISR_REVALIDATE_SECONDS, }; }; diff --git a/src/pages/products/index.tsx b/src/pages/products/index.tsx index 3e9a0b3..4e7fb78 100644 --- a/src/pages/products/index.tsx +++ b/src/pages/products/index.tsx @@ -1,3 +1,4 @@ +import { ISR_REVALIDATE_SECONDS } from "@/constants"; import { ProductsScreen } from "@/screens/Products"; import { ProductionDetailProps as ProductionProps } from "@/ui/Production"; import cacheRemoteImage from "@/utils/cacheRemoteImage"; @@ -73,5 +74,6 @@ export const getStaticProps = async () => { props: { products, }, + revalidate: ISR_REVALIDATE_SECONDS, }; }; diff --git a/src/utils/cacheRemoteImage.ts b/src/utils/cacheRemoteImage.ts index 42e8cde..b11876d 100644 --- a/src/utils/cacheRemoteImage.ts +++ b/src/utils/cacheRemoteImage.ts @@ -10,36 +10,52 @@ const cacheRemoteImage = async function (id: string, name: string, url: string) //拡張子を .webpに変更 const cover = `${path}/${name}.webp`; - // 既にファイルが存在すれば再取得しない - if (fs.existsSync(cover)) { - const metadata = await sharp(cover).rotate().metadata(); + try { + // 既にファイルが存在すれば再取得しない + if (fs.existsSync(cover)) { + const metadata = await sharp(cover).rotate().metadata(); + return { + url: `/${id}/${name}.webp`, + width: metadata.width, + height: metadata.height, + }; + } + + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }); + } + + const src = await fetch(url).then((r) => r.blob()); + const binary = await src.arrayBuffer(); + const buffer = Buffer.from(binary); + + //Sharpによる画像処理 + const output = await sharp(buffer) + .rotate() + .resize(1200, null, { withoutEnlargement: true, fit: "inside" }) + .webp({ quality: 80 }) + .toFile(cover); + return { url: `/${id}/${name}.webp`, - width: metadata.width, - height: metadata.height, + width: output.width, + height: output.height, + }; + } catch (error) { + // fallback: 'blocking' によりリクエスト時(サーバーレス環境)で実行された場合、 + // public/ は読み取り専用のため書き込みに失敗する。 + // その場合はキャッシュを諦め、Notionの元URLをそのまま返してページ生成を継続する。 + // (revalidate間隔をNotionのURL有効期限とおおよそ揃えているため、表示は維持される) + console.warn( + `cacheRemoteImage: 画像をキャッシュできなかったため元URLを使用します (${id}/${name})`, + error, + ); + return { + url, + width: undefined, + height: undefined, }; } - - if (!fs.existsSync(path)) { - fs.mkdirSync(path, { recursive: true }); - } - - const src = await fetch(url).then((r) => r.blob()); - const binary = await src.arrayBuffer(); - const buffer = Buffer.from(binary); - - //Sharpによる画像処理 - const output = await sharp(buffer) - .rotate() - .resize(1200, null, { withoutEnlargement: true, fit: "inside" }) - .webp({ quality: 80 }) - .toFile(cover); - - return { - url: `/${id}/${name}.webp`, - width: output.width, - height: output.height, - }; }; export default cacheRemoteImage; diff --git a/src/utils/createOGPImage.tsx b/src/utils/createOGPImage.tsx index 5a73a94..1e3230f 100644 --- a/src/utils/createOGPImage.tsx +++ b/src/utils/createOGPImage.tsx @@ -10,83 +10,91 @@ const createOGPImage = async function ( writerName: string, lastEditTime: string, ) { - const regularFont = fs.readFileSync("public/ZenKakuGothicNew-Regular.ttf"); - const boldFont = fs.readFileSync("public/ZenKakuGothicNew-Bold.ttf"); - const path = `public/${id}/`; const cover = `${path}ogp.png`; const result = `/${id}/ogp.png`; - if (!fs.existsSync(path)) { - fs.mkdirSync(path); - } + try { + const regularFont = fs.readFileSync("public/ZenKakuGothicNew-Regular.ttf"); + const boldFont = fs.readFileSync("public/ZenKakuGothicNew-Bold.ttf"); + + if (!fs.existsSync(path)) { + fs.mkdirSync(path); + } - if (fs.existsSync(cover)) { - const stats = fs.statSync(cover); - const fileUpdateTime = new Date(stats.mtime).getTime(); - const notionUpdateTime = new Date(lastEditTime).getTime(); + if (fs.existsSync(cover)) { + const stats = fs.statSync(cover); + const fileUpdateTime = new Date(stats.mtime).getTime(); + const notionUpdateTime = new Date(lastEditTime).getTime(); - // ファイルの更新時間よりも、Notionの更新時間の方が新しい場合のみ、以降の生成処理に進む - if (fileUpdateTime > notionUpdateTime) { - return result; + // ファイルの更新時間よりも、Notionの更新時間の方が新しい場合のみ、以降の生成処理に進む + if (fileUpdateTime > notionUpdateTime) { + return result; + } } - } - const svg = await satori( -
+ const svg = await satori(
-
{title}
-
@{writerName}
-
-
, - { - width: 1200, - height: 630, - fonts: [ - { - name: "Zen Kaku Gothic New", - data: regularFont, - weight: 400, - style: "normal", - }, - { - name: "Zen Kaku Gothic New", - data: boldFont, - weight: 700, - style: "normal", - }, - ], - }, - ); +
+
{title}
+
@{writerName}
+
+ , + { + width: 1200, + height: 630, + fonts: [ + { + name: "Zen Kaku Gothic New", + data: regularFont, + weight: 400, + style: "normal", + }, + { + name: "Zen Kaku Gothic New", + data: boldFont, + weight: 700, + style: "normal", + }, + ], + }, + ); - // ogp画像ではsvgが使えないため、pngに変換する。 - const pngData = await sharp(Buffer.from(svg)).png().toBuffer(); - fs.writeFileSync(cover, pngData); - return result; + // ogp画像ではsvgが使えないため、pngに変換する。 + const pngData = await sharp(Buffer.from(svg)).png().toBuffer(); + fs.writeFileSync(cover, pngData); + return result; + } catch (error) { + // fallback: 'blocking' によりリクエスト時(サーバーレス環境)で実行された場合、 + // public/ 配下のフォント読み込みや画像書き込みに失敗する。 + // その場合はビルド時に生成済みの画像パスをそのまま返す(CDNから配信される)。 + console.warn(`createOGPImage: OGP画像を生成できなかったため既存パスを返します (${id})`, error); + return result; + } }; export default createOGPImage;