From 4fdbb4d960d63ff424b5b279e41bfea314232808 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 25 Apr 2026 19:34:33 -0500 Subject: [PATCH] Render Wikidata URLs as their Wikipedia article on /i/ Adds special handling for Wikidata entity URLs (https://www.wikidata.org/entity/ID and https://www.wikidata.org/wiki/ID) on the /i/ external content page. When a Wikidata URL is used, the entity's enwiki sitelink is resolved via the Wikidata Action API and the page renders the same rich Wikipedia embed that would appear for the Wikipedia URL directly. Falls back to a generic link preview when the entity has no English Wikipedia article. --- src/components/ExternalContentHeader.tsx | 42 ++++++++++++++- src/hooks/useWikidataEntity.ts | 67 ++++++++++++++++++++++++ src/lib/linkEmbed.ts | 23 ++++++++ src/pages/ExternalContentPage.tsx | 10 +++- 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useWikidataEntity.ts diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 9ce381e4..099c6fd0 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -11,7 +11,7 @@ import { LinkEmbed } from '@/components/LinkEmbed'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; -import { extractYouTubeId, extractWikipediaTitle, extractBlueskyPost } from '@/lib/linkEmbed'; +import { extractYouTubeId, extractWikipediaTitle, extractWikidataId, extractBlueskyPost } from '@/lib/linkEmbed'; import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; import { shareOrCopy } from '@/lib/share'; import { useLinkPreview } from '@/hooks/useLinkPreview'; @@ -26,6 +26,7 @@ import { useShareOrigin } from '@/hooks/useShareOrigin'; import { genUserName } from '@/lib/genUserName'; import { getCountryInfo, getWikipediaTitle } from '@/lib/countries'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; +import { useWikidataEntity } from '@/hooks/useWikidataEntity'; import { EXTRA_KINDS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; import { cn } from '@/lib/utils'; @@ -36,12 +37,17 @@ import { cn } from '@/lib/utils'; export function UrlContentHeader({ url }: { url: string }) { const wikiTitle = useMemo(() => extractWikipediaTitle(url), [url]); + const wikidataId = useMemo(() => extractWikidataId(url), [url]); const blueskyPost = useMemo(() => extractBlueskyPost(url), [url]); if (wikiTitle) { return ; } + if (wikidataId) { + return ; + } + if (blueskyPost) { return ; } @@ -49,6 +55,40 @@ export function UrlContentHeader({ url }: { url: string }) { return ; } +// --------------------------------------------------------------------------- +// Wikidata entity header — resolves the entity to its Wikipedia article and +// delegates to WikipediaArticleHeader. Falls back to LinkEmbed when there is +// no English Wikipedia sitelink (or while resolving fails). +// --------------------------------------------------------------------------- + +function WikidataEntityHeader({ id, url }: { id: string; url: string }) { + const { data: entity, isLoading } = useWikidataEntity(id); + + if (isLoading) { + return ( +
+ +
+ + + +
+ + + +
+
+
+ ); + } + + if (entity?.wikipediaTitle && entity.wikipediaUrl) { + return ; + } + + return ; +} + // --------------------------------------------------------------------------- // Bluesky post header (full feed-style, like a thread top post) // --------------------------------------------------------------------------- diff --git a/src/hooks/useWikidataEntity.ts b/src/hooks/useWikidataEntity.ts new file mode 100644 index 00000000..31c31dd5 --- /dev/null +++ b/src/hooks/useWikidataEntity.ts @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query'; + +export interface WikidataEntity { + /** The Wikidata entity ID (e.g. "Q42"). */ + id: string; + /** + * English Wikipedia article title for this entity, if one exists. + * Derived from the `enwiki` sitelink. + */ + wikipediaTitle: string | null; + /** + * Full URL to the English Wikipedia article for this entity, if one exists. + * Derived from the `enwiki` sitelink URL. + */ + wikipediaUrl: string | null; +} + +async function fetchWikidataEntity( + id: string, + signal?: AbortSignal, +): Promise { + try { + // Use the Action API with CORS-friendly origin=* and minimal props. + // We only need the English Wikipedia sitelink to resolve to a Wikipedia article. + const url = new URL('https://www.wikidata.org/w/api.php'); + url.searchParams.set('action', 'wbgetentities'); + url.searchParams.set('ids', id); + url.searchParams.set('props', 'sitelinks/urls'); + url.searchParams.set('sitefilter', 'enwiki'); + url.searchParams.set('format', 'json'); + url.searchParams.set('origin', '*'); + + const response = await fetch(url.toString(), { + signal, + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) return null; + + const data = await response.json(); + const entity = data?.entities?.[id]; + if (!entity || entity.missing !== undefined) return null; + + const enwiki = entity.sitelinks?.enwiki; + const wikipediaTitle = typeof enwiki?.title === 'string' ? enwiki.title : null; + const wikipediaUrl = typeof enwiki?.url === 'string' ? enwiki.url : null; + + return { id, wikipediaTitle, wikipediaUrl }; + } catch { + return null; + } +} + +/** + * Resolve a Wikidata entity ID (e.g. "Q42") to its English Wikipedia article, if any. + * Uses the Wikidata Action API `wbgetentities` endpoint with `sitefilter=enwiki`. + */ +export function useWikidataEntity(id: string | null) { + return useQuery({ + queryKey: ['wikidata-entity', id], + queryFn: ({ signal }) => fetchWikidataEntity(id!, signal), + enabled: !!id, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days + retry: 1, + }); +} diff --git a/src/lib/linkEmbed.ts b/src/lib/linkEmbed.ts index e83313dc..d1d74842 100644 --- a/src/lib/linkEmbed.ts +++ b/src/lib/linkEmbed.ts @@ -125,6 +125,29 @@ export function extractArchiveOrgId(url: string): string | null { } } +/** + * Extract a Wikidata entity ID (Q…, P…, or L…) from a Wikidata URL, or null if not one. + * + * Supports the "globally unique" concept URI form described at + * https://www.wikidata.org/wiki/Wikidata:Identifiers: + * - https://www.wikidata.org/entity/Q42 + * - http://www.wikidata.org/entity/Q42 + * …as well as the browser-facing page URL: + * - https://www.wikidata.org/wiki/Q42 + */ +export function extractWikidataId(url: string): string | null { + try { + const u = new URL(url); + const host = u.hostname.replace(/^www\./, ''); + if (host !== 'wikidata.org') return null; + // Match /entity/{id} or /wiki/{id} where id is Q|P|L followed by digits. + const match = u.pathname.match(/^\/(?:entity|wiki)\/([QPL]\d+)$/); + return match ? match[1] : null; + } catch { + return null; + } +} + /** * Extract a Wikipedia article title from a Wikipedia URL, or null if not a Wikipedia article link. * Supports en.wikipedia.org/wiki/{title} and other language subdomains. diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index a6b5108e..b0426352 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -38,10 +38,11 @@ import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; +import { useWikidataEntity } from '@/hooks/useWikidataEntity'; import { useToast } from '@/hooks/useToast'; import { getDisplayName } from '@/lib/getDisplayName'; import { timeAgo } from '@/lib/timeAgo'; -import { extractWikipediaTitle } from '@/lib/linkEmbed'; +import { extractWikipediaTitle, extractWikidataId } from '@/lib/linkEmbed'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; import type { BookReview } from '@/lib/bookstr'; @@ -170,7 +171,12 @@ export function ExternalContentPage() { const { data: linkPreview } = useLinkPreview(linkPreviewUrl); // For Wikipedia URLs, use the Wikipedia API for accurate titles. - const wikiTitle = useMemo(() => linkPreviewUrl ? extractWikipediaTitle(linkPreviewUrl) : null, [linkPreviewUrl]); + // For Wikidata URLs, resolve the entity's English Wikipedia sitelink and fall through + // to the Wikipedia branch so the page title and back-link behave identically. + const directWikiTitle = useMemo(() => linkPreviewUrl ? extractWikipediaTitle(linkPreviewUrl) : null, [linkPreviewUrl]); + const wikidataId = useMemo(() => linkPreviewUrl ? extractWikidataId(linkPreviewUrl) : null, [linkPreviewUrl]); + const { data: wikidataEntity } = useWikidataEntity(directWikiTitle ? null : wikidataId); + const wikiTitle = directWikiTitle ?? wikidataEntity?.wikipediaTitle ?? null; const { data: wikiSummary } = useWikipediaSummary(wikiTitle); const resolvedTitle = wikiSummary?.title ?? linkPreview?.title;