diff --git a/package-lock.json b/package-lock.json index c15b0c0..462637e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@wooorm/starry-night": "^3.2.0", "cheerio": "^1.0.0-rc.12", "clsx": "^2.1.0", + "date-fns": "^3.3.1", "hasha": "^6.0.0", "mdast-util-to-string": "^4.0.0", "negotiator": "^0.6.3", @@ -9685,6 +9686,15 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-fns": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", + "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -18790,6 +18800,15 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-dts": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.2.tgz", + "integrity": "sha512-MpNwH0dZJHinVxk9bT8XUdjKTxMYrA5bLtrrGmFA6PTLwlOKnhi67XoRd6/ty+Djt6ZC0slR57qFhZDNMI6DhQ==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.1.0" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", diff --git a/package.json b/package.json index a754848..6a9ea7b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@wooorm/starry-night": "^3.2.0", "cheerio": "^1.0.0-rc.12", "clsx": "^2.1.0", + "date-fns": "^3.3.1", "hasha": "^6.0.0", "mdast-util-to-string": "^4.0.0", "negotiator": "^0.6.3", diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..6cd63b0 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,16 @@ +import { type MetadataRoute } from "next"; +import { getConfig } from "~/helpers/config"; + +function robots(): MetadataRoute.Robots { + const config = getConfig(); + + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${config.urlOrigin}/sitemap.xml`, + }; +} + +export default robots; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..4fd239e --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,30 @@ +import { type MetadataRoute } from "next"; +import { getConfig } from "~/helpers/config"; +import { queryAllPostsRegardlessLocale } from "~/queries/query-all-posts-regardless-locale"; + +async function sitemap(): Promise { + const config = getConfig(); + const posts = await queryAllPostsRegardlessLocale(); + + const entries: MetadataRoute.Sitemap = [ + { + url: `${config.urlOrigin}/`, + lastModified: new Date(), + changeFrequency: "yearly", + priority: 1, + }, + ]; + + for (const post of posts) { + entries.push({ + url: `${config.urlOrigin}/posts/${post.slug}`, + lastModified: post.lastEditedAt, + changeFrequency: "weekly", + priority: 0.9, + }); + } + + return entries; +} + +export default sitemap; diff --git a/src/helpers/config.ts b/src/helpers/config.ts index ae9cf61..2a3bbaa 100644 --- a/src/helpers/config.ts +++ b/src/helpers/config.ts @@ -1,4 +1,5 @@ interface Config { + urlOrigin: string; notion: { integrationSecret: string; bioDatabaseId: string; @@ -24,17 +25,24 @@ function resolveEnvironmentVariable(key: string): string { } export function getConfig(): Config { + let urlOrigin = `http://localhost:${process.env.PORT ?? "3000"}`; + + if (process.env.VERCEL_URL !== undefined) { + urlOrigin = `https://${process.env.VERCEL_URL}`; + } + return { + urlOrigin, notion: { integrationSecret: resolveEnvironmentVariable( - "NOTION_INTEGRATION_SECRET", + "NOTION_INTEGRATION_SECRET" ), bioDatabaseId: resolveEnvironmentVariable("NOTION_BIO_DATABASE_ID"), postDatabaseId: resolveEnvironmentVariable("NOTION_POST_DATABASE_ID"), }, googleAnalytics: { measurementId: resolveEnvironmentVariable( - "GOOGLE_ANALYTICS_MEASUREMENT_ID", + "GOOGLE_ANALYTICS_MEASUREMENT_ID" ), }, image: { diff --git a/src/helpers/notion.ts b/src/helpers/notion.ts index 9f08365..13c9acb 100644 --- a/src/helpers/notion.ts +++ b/src/helpers/notion.ts @@ -153,6 +153,9 @@ const zNotionCreatedByProperty = z.object({ id: z.string().min(1), name: z.string(), avatar_url: z.string().url(), + person: z.object({ + email: z.string().email(), + }), }), }); @@ -160,6 +163,7 @@ const zNotionCreatedByPropertyDeserializable = zNotionCreatedByProperty.transform((value) => { return { name: value.created_by.name, + email: value.created_by.person.email, avatarImageUrl: new URL(value.created_by.avatar_url), }; }); @@ -216,10 +220,11 @@ const zPostNotionPage = zNotionPage.omit({ properties: true }).extend({ cover: zNotionFile, properties: z.object({ Title: zNotionTitleProperty, + Summary: zNotionRichTextProperty, Slug: zNotionRichTextProperty, Status: zNotionStatusProperty, Locale: zNotionSelectProperty, - Tags: zNotionMultiSelectPropertyDeserialized, + Keywords: zNotionMultiSelectPropertyDeserialized, "Created at": zNotionCreatedTimeProperty, "Created by": zNotionCreatedByProperty, "Last edited at": zNotionLastEditedTimeProperty, @@ -232,10 +237,11 @@ const zPostNotionPageDeserialized = zPostNotionPage cover: zNotionFileDeserialized, properties: z.object({ Title: zNotionTitlePropertyDeserialized, + Summary: zNotionRichTextPropertyDeserialized, Slug: zNotionRichTextPropertyDeserialized, Status: zNotionStatusPropertyDeserialized, Locale: zNotionSelectPropertyDeserialized, - Tags: zNotionMultiSelectPropertyDeserialized, + Keywords: zNotionMultiSelectPropertyDeserialized, "Created at": zNotionCreatedTimePropertyDeserialized, "Created by": zNotionCreatedByPropertyDeserializable, "Last edited at": zNotionLastEditedTimePropertyDeserialized, @@ -248,7 +254,8 @@ const zPostNotionPageDeserialized = zPostNotionPage slug: value.properties.Slug, locale: value.properties.Locale as Locale, title: value.properties.Title, - tags: value.properties.Tags, + summary: value.properties.Summary, + keywords: value.properties.Keywords, coverImageUrl: new URL(value.cover), createdAt: value.properties["Created at"], createdBy: value.properties["Created by"], diff --git a/src/models/post.ts b/src/models/post.ts index 134ae72..cc72013 100644 --- a/src/models/post.ts +++ b/src/models/post.ts @@ -2,6 +2,7 @@ import { type Locale } from "~/models/locale"; interface Author { name: string; + email: string; avatarImageUrl: URL; } @@ -10,7 +11,8 @@ interface Post { slug: string; locale: Locale; title: string; - tags: string[]; + summary: string; + keywords: string[]; coverImageUrl: URL; createdAt: Date; createdBy: Author; diff --git a/src/queries/query-all-posts-regardless-locale.ts b/src/queries/query-all-posts-regardless-locale.ts new file mode 100644 index 0000000..eb0b31f --- /dev/null +++ b/src/queries/query-all-posts-regardless-locale.ts @@ -0,0 +1,39 @@ +import "server-only"; + +import { isAfter } from "date-fns"; +import { availableLocales } from "~/helpers/locale"; +import { type Locale } from "~/models/locale"; +import { type Post } from "~/models/post"; +import { findPosts } from "~/repositories/find-posts"; + +async function queryAllPostsRegardlessLocale(): Promise { + const postsByLocale: Partial> = {}; + + await Promise.all( + availableLocales.map(async (locale) => { + const posts = await findPosts({ locale, includeDrafts: false }); + + postsByLocale[locale] = posts; + }), + ); + + const postsBySlug: Record = {}; + + for (const posts of Object.values(postsByLocale)) { + for (const post of posts) { + postsBySlug[post.slug] ??= post; + + if (isAfter(post.createdAt, postsBySlug[post.slug].createdAt)) { + postsBySlug[post.slug].createdAt = post.createdAt; + } + + if (isAfter(post.lastEditedAt, postsBySlug[post.slug].lastEditedAt)) { + postsBySlug[post.slug].lastEditedAt = post.lastEditedAt; + } + } + } + + return Object.values(postsBySlug); +} + +export { queryAllPostsRegardlessLocale };