From 146a257a1477e39dfa9331449003cc5c840c7d18 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 17 Jul 2026 01:29:37 +0200 Subject: [PATCH 1/2] Post.html_metadata_json help text --- posts/migrations/0019_post_html_metadata_json.py | 3 +-- posts/models.py | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/posts/migrations/0019_post_html_metadata_json.py b/posts/migrations/0019_post_html_metadata_json.py index 1fd44ec6fb..aef8627ffa 100644 --- a/posts/migrations/0019_post_html_metadata_json.py +++ b/posts/migrations/0019_post_html_metadata_json.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("posts", "0018_notebook_markdown_zh_tw_post_short_title_zh_tw_and_more"), ] @@ -16,7 +15,7 @@ class Migration(migrations.Migration): field=models.JSONField( blank=True, default=None, - help_text="Custom JSON for HTML meta tags. Supported fields are: title, description, image_url", + help_text="Custom JSON for HTML meta tags. Supported fields are: title, description, image_url, canonical_url.
canonical_url: absolute URL to index instead of this post. Also disables the automatic noindex on bots-only posts.", null=True, ), ), diff --git a/posts/models.py b/posts/models.py index ba6655d9b9..92a681a023 100644 --- a/posts/models.py +++ b/posts/models.py @@ -657,7 +657,12 @@ class PostStatusChange(models.TextChoices): # Whether we should display Post/Notebook on the homepage show_on_homepage = models.BooleanField(default=False, db_index=True) html_metadata_json = models.JSONField( - help_text="Custom JSON for HTML meta tags. Supported fields are: title, description, image_url", + help_text=( + "Custom JSON for HTML meta tags. Supported fields are: title, description, " + "image_url, canonical_url.
" + "canonical_url: absolute URL to index instead of this post. " + "Also disables the automatic noindex on bots-only posts." + ), null=True, blank=True, default=None, From 8b86b741bc0ae7ac6f0501d4c60dda83c1397ba7 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 17 Jul 2026 01:30:26 +0200 Subject: [PATCH 2/2] Frontend: canonical_url and noindex metadata --- .../notebooks/[id]/[[...slug]]/page.tsx | 2 + .../questions/[id]/[[...slug]]/page.tsx | 2 + front_end/src/types/post.ts | 1 + front_end/src/utils/questions/metadata.ts | 53 +++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 front_end/src/utils/questions/metadata.ts diff --git a/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page.tsx b/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page.tsx index e216ab5915..cc43ca1a18 100644 --- a/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page.tsx +++ b/front_end/src/app/(main)/notebooks/[id]/[[...slug]]/page.tsx @@ -7,6 +7,7 @@ import { defaultDescription } from "@/constants/metadata"; import ServerPostsApi from "@/services/api/posts/posts.server"; import { getValidString } from "@/utils/formatters/string"; import { getPostLink } from "@/utils/navigation"; +import { getPostSeoMetadata } from "@/utils/questions/metadata"; import IndividualNotebookPage from "./page_compotent"; @@ -26,6 +27,7 @@ export async function generateMetadata(props: Props): Promise { const parsedDescription = String(file).split("\n")[0]; return { + ...getPostSeoMetadata(postData), title: getValidString(postData.html_metadata_json?.title) ?? getValidString(postData.short_title) ?? diff --git a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx index c95e3517e9..dcf5fc6267 100644 --- a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx +++ b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx @@ -4,6 +4,7 @@ import { defaultDescription } from "@/constants/metadata"; import { SearchParams } from "@/types/navigation"; import { getValidString } from "@/utils/formatters/string"; import { getPostTitle } from "@/utils/questions/helpers"; +import { getPostSeoMetadata } from "@/utils/questions/metadata"; import IndividualQuestionPage from "./page_component"; import { cachedGetPostForMetadata } from "./utils/get_post"; @@ -22,6 +23,7 @@ export async function generateMetadata(props: Props): Promise { } const questionTitle = getPostTitle(postData); return { + ...getPostSeoMetadata(postData), title: getValidString(postData.html_metadata_json?.title) ?? getValidString(postData.short_title) ?? diff --git a/front_end/src/types/post.ts b/front_end/src/types/post.ts index 8438e9bc09..ca8c2f8d3a 100644 --- a/front_end/src/types/post.ts +++ b/front_end/src/types/post.ts @@ -166,6 +166,7 @@ type BasePost = { title: string; description: string; image_url: string; + canonical_url: string; }; }; diff --git a/front_end/src/utils/questions/metadata.ts b/front_end/src/utils/questions/metadata.ts new file mode 100644 index 0000000000..67ecc407bf --- /dev/null +++ b/front_end/src/utils/questions/metadata.ts @@ -0,0 +1,53 @@ +import "server-only"; +import { Metadata } from "next"; + +import { Post } from "@/types/post"; +import { BotLeaderboardStatus } from "@/types/projects"; +import { getValidString } from "@/utils/formatters/string"; +import { getPostLink } from "@/utils/navigation"; +import { getPublicSettings } from "@/utils/public_settings.server"; + +type SeoPost = Parameters[0] & + Pick; + +/** + * Bot-only posts duplicate their human counterparts, which lets Google pick the + * bot version as canonical. Keyed off default_project to match how bot-only + * posts are identified elsewhere (see comments/services/feed.py). + */ +function isBotsOnlyPost(post: SeoPost) { + return ( + post.projects?.default_project?.bot_leaderboard_status === + BotLeaderboardStatus.BotsOnly + ); +} + +/** + * A canonical pointing at another URL combined with noindex is contradictory: + * Google may apply the noindex to the canonical target, deindexing the very + * page we're pointing at. So an explicit override always suppresses the + * bot-only noindex — never emit both. + * + * Without an override the canonical is self-referencing: any slug resolves + * under the /questions/[id]/[[...slug]] catch-all, so each variant would + * otherwise be a separate indexable URL for the same post. + */ +export function getPostSeoMetadata(post: SeoPost): Metadata { + const canonicalOverride = getValidString( + post.html_metadata_json?.canonical_url + ); + if (canonicalOverride) { + return { alternates: { canonical: canonicalOverride } }; + } + + const { PUBLIC_APP_URL } = getPublicSettings(); + const metadata: Metadata = { + alternates: { canonical: `${PUBLIC_APP_URL}${getPostLink(post)}` }, + }; + + if (isBotsOnlyPost(post)) { + metadata.robots = { index: false, follow: true }; + } + + return metadata; +}