diff --git a/drizzle/0007_site-url.sql b/drizzle/0007_site-url.sql new file mode 100644 index 0000000..85c620b --- /dev/null +++ b/drizzle/0007_site-url.sql @@ -0,0 +1 @@ +ALTER TABLE "settings" ADD COLUMN "site_url" text DEFAULT '' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..9251ae6 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1049 @@ +{ + "id": "5aefdde1-d3b1-4781-972c-9af831af8163", + "prevId": "cdae9fb6-cf03-47a9-a922-b6bd45ae9017", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "comments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_public": { + "name": "email_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "comment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_post_id_status_idx": { + "name": "comments_post_id_status_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_id_idx": { + "name": "comments_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_status_idx": { + "name": "comments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_post_id_posts_id_fk": { + "name": "comments_post_id_posts_id_fk", + "tableFrom": "comments", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nav_items": { + "name": "nav_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "nav_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "nav_items_page_id_pages_id_fk": { + "name": "nav_items_page_id_pages_id_fk", + "tableFrom": "nav_items", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "nav_items_target_check": { + "name": "nav_items_target_check", + "value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "pages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "content_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tags": { + "name": "post_tags", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "post_tags_tag_id_idx": { + "name": "post_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_tags_post_id_posts_id_fk": { + "name": "post_tags_post_id_posts_id_fk", + "tableFrom": "post_tags", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tags_tag_id_tags_id_fk": { + "name": "post_tags_tag_id_tags_id_fk", + "tableFrom": "post_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tags_post_id_tag_id_pk": { + "name": "post_tags_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "posts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "featured_image_url": { + "name": "featured_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_image_alt": { + "name": "featured_image_alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "content_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_status_published_at_idx": { + "name": "posts_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_author_id_users_id_fk": { + "name": "posts_author_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_slug_unique": { + "name": "posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'My Blog'" + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "footer_text": { + "name": "footer_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "posts_per_page": { + "name": "posts_per_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "excerpt_words": { + "name": "excerpt_words", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 40 + }, + "home_mode": { + "name": "home_mode", + "type": "home_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'posts'" + }, + "home_tag_id": { + "name": "home_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "home_page_id": { + "name": "home_page_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'solarized-dark'" + }, + "font": { + "name": "font", + "type": "font", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'geist'" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_home_tag_id_tags_id_fk": { + "name": "settings_home_tag_id_tags_id_fk", + "tableFrom": "settings", + "tableTo": "tags", + "columnsFrom": [ + "home_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "settings_home_page_id_pages_id_fk": { + "name": "settings_home_page_id_pages_id_fk", + "tableFrom": "settings", + "tableTo": "pages", + "columnsFrom": [ + "home_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_single_row_check": { + "name": "settings_single_row_check", + "value": "\"settings\".\"id\" = 1" + }, + "settings_posts_per_page_check": { + "name": "settings_posts_per_page_check", + "value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50" + }, + "settings_excerpt_words_check": { + "name": "settings_excerpt_words_check", + "value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200" + } + }, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tags_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tags": { + "name": "user_tags", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_tags_tag_id_idx": { + "name": "user_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tags_user_id_users_id_fk": { + "name": "user_tags_user_id_users_id_fk", + "tableFrom": "user_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_tags_tag_id_tags_id_fk": { + "name": "user_tags_tag_id_tags_id_fk", + "tableFrom": "user_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_tags_user_id_tag_id_pk": { + "name": "user_tags_user_id_tag_id_pk", + "columns": [ + "user_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'author'" + }, + "can_create_tags": { + "name": "can_create_tags", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_publish_posts": { + "name": "can_publish_posts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_unpublish_posts": { + "name": "can_unpublish_posts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_delete_posts": { + "name": "can_delete_posts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_approve_comments": { + "name": "can_approve_comments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.comment_status": { + "name": "comment_status", + "schema": "public", + "values": [ + "pending", + "approved" + ] + }, + "public.content_status": { + "name": "content_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.font": { + "name": "font", + "schema": "public", + "values": [ + "geist", + "inter", + "lora", + "merriweather", + "jetbrains-mono", + "source-serif", + "eb-garamond", + "playfair-display", + "open-sans", + "work-sans", + "atkinson-hyperlegible", + "space-grotesk" + ] + }, + "public.home_mode": { + "name": "home_mode", + "schema": "public", + "values": [ + "posts", + "tag", + "page" + ] + }, + "public.theme": { + "name": "theme", + "schema": "public", + "values": [ + "solarized-dark", + "solarized-light", + "dracula", + "nord", + "gruvbox-dark", + "mono", + "mono-dark", + "catppuccin-mocha", + "catppuccin-latte", + "tokyo-night", + "one-dark", + "rose-pine", + "everforest-dark", + "monokai", + "github-light" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "author" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f968929..5d99a73 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1783266633118, "tag": "0006_author-permissions", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1783271305140, + "tag": "0007_site-url", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actions/settings.ts b/src/actions/settings.ts index e8db3eb..67ff07e 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -19,6 +19,7 @@ export async function updateSettingsAction( const parsed = settingsFormSchema.safeParse({ siteTitle: formData.get("siteTitle"), + siteUrl: formData.get("siteUrl") ?? "", headerText: formData.get("headerText"), footerText: formData.get("footerText"), postsPerPage: formData.get("postsPerPage"), @@ -83,6 +84,7 @@ export async function updateSettingsAction( await saveSettings( { siteTitle: data.siteTitle, + siteUrl: data.siteUrl, headerText: data.headerText, footerText: data.footerText, postsPerPage: data.postsPerPage, diff --git a/src/app/(public)/(home)/page.tsx b/src/app/(public)/(home)/page.tsx index 3d9a3c7..58ac965 100644 --- a/src/app/(public)/(home)/page.tsx +++ b/src/app/(public)/(home)/page.tsx @@ -1,9 +1,13 @@ +import type { Metadata } from "next"; import { PageArticle } from "@/components/public/PageArticle"; import { PostListSection } from "@/components/public/PostListSection"; import { parsePage } from "@/lib/pagination"; +import { pageAlternates } from "@/lib/seo"; import { resolveHomeContent } from "@/lib/services/home"; import { getSettings } from "@/lib/services/settings"; +export const metadata: Metadata = { alternates: pageAlternates("/") }; + /** * Configured home page: the full post list, a tag's post list, or a * static page — decided by site settings, with a safe fallback to the diff --git a/src/app/(public)/pages/[slug]/page.tsx b/src/app/(public)/pages/[slug]/page.tsx index c347082..cea1235 100644 --- a/src/app/(public)/pages/[slug]/page.tsx +++ b/src/app/(public)/pages/[slug]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { PageArticle } from "@/components/public/PageArticle"; +import { pageAlternates } from "@/lib/seo"; import { getPublishedPageBySlug } from "@/lib/services/pages"; type Props = { params: Promise<{ slug: string }> }; @@ -9,7 +10,11 @@ export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; const page = await getPublishedPageBySlug(slug); if (!page) return {}; - return { title: page.title }; + return { + title: page.title, + alternates: pageAlternates(`/pages/${page.slug}`), + openGraph: { title: page.title, url: `/pages/${page.slug}` }, + }; } export default async function StaticPage({ params }: Props) { diff --git a/src/app/(public)/posts/(list)/page.tsx b/src/app/(public)/posts/(list)/page.tsx index 6bcd8a4..afb214a 100644 --- a/src/app/(public)/posts/(list)/page.tsx +++ b/src/app/(public)/posts/(list)/page.tsx @@ -1,9 +1,13 @@ import type { Metadata } from "next"; import { PostListSection } from "@/components/public/PostListSection"; import { parsePage } from "@/lib/pagination"; +import { pageAlternates } from "@/lib/seo"; import { getSettings } from "@/lib/services/settings"; -export const metadata: Metadata = { title: "All posts" }; +export const metadata: Metadata = { + title: "All posts", + alternates: pageAlternates("/posts"), +}; export default async function PostsPage({ searchParams, diff --git a/src/app/(public)/posts/[slug]/page.tsx b/src/app/(public)/posts/[slug]/page.tsx index ea02bb4..d643f0b 100644 --- a/src/app/(public)/posts/[slug]/page.tsx +++ b/src/app/(public)/posts/[slug]/page.tsx @@ -4,8 +4,10 @@ import { submitCommentAction } from "@/actions/comments"; import { CommentsSection } from "@/components/public/CommentsSection"; import { PostArticle } from "@/components/public/PostArticle"; import { generateExcerpt } from "@/lib/excerpt"; +import { absoluteUrl, pageAlternates, resolveSiteUrl } from "@/lib/seo"; import { listApprovedComments } from "@/lib/services/comments"; import { getPublishedPostBySlug } from "@/lib/services/posts"; +import { getSettings } from "@/lib/services/settings"; type Props = { params: Promise<{ slug: string }> }; @@ -13,7 +15,30 @@ export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; const post = await getPublishedPostBySlug(slug); if (!post) return {}; - return { title: post.title, description: generateExcerpt(post.body, 30) || undefined }; + const description = generateExcerpt(post.body, 30) || undefined; + return { + title: post.title, + description, + alternates: pageAlternates(`/posts/${post.slug}`), + openGraph: { + type: "article", + title: post.title, + description, + url: `/posts/${post.slug}`, + publishedTime: post.publishedAt?.toISOString(), + modifiedTime: post.updatedAt.toISOString(), + authors: [post.authorName], + tags: post.tags.map((t) => t.name), + images: post.featuredImageUrl + ? [{ url: post.featuredImageUrl, alt: post.featuredImageAlt ?? undefined }] + : undefined, + }, + twitter: { + card: post.featuredImageUrl ? "summary_large_image" : "summary", + title: post.title, + description, + }, + }; } export default async function PostPage({ params }: Props) { @@ -21,9 +46,41 @@ export default async function PostPage({ params }: Props) { // Draft posts are filtered inside the query — they 404 like unknown slugs. const post = await getPublishedPostBySlug(slug); if (!post) notFound(); - const comments = await listApprovedComments(post.id); + const [comments, settings] = await Promise.all([ + listApprovedComments(post.id), + getSettings(), + ]); + + const siteUrl = resolveSiteUrl(settings); + const jsonLd = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + url: absoluteUrl(siteUrl, `/posts/${post.slug}`), + datePublished: post.publishedAt?.toISOString(), + dateModified: post.updatedAt.toISOString(), + author: { "@type": "Person", name: post.authorName }, + keywords: post.tags.map((t) => t.name).join(", ") || undefined, + description: generateExcerpt(post.body, 30) || undefined, + ...(post.featuredImageUrl + ? { + image: post.featuredImageUrl.startsWith("/") + ? absoluteUrl(siteUrl, post.featuredImageUrl) + : post.featuredImageUrl, + } + : {}), + }; + return ( <> +