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.
This commit is contained in:
Alex Gleason
2026-04-25 19:34:33 -05:00
parent f4f8e49627
commit 4fdbb4d960
4 changed files with 139 additions and 3 deletions
+41 -1
View File
@@ -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 <WikipediaArticleHeader title={wikiTitle} url={url} />;
}
if (wikidataId) {
return <WikidataEntityHeader id={wikidataId} url={url} />;
}
if (blueskyPost) {
return <BlueskyPostHeader author={blueskyPost.author} rkey={blueskyPost.rkey} url={url} />;
}
@@ -49,6 +55,40 @@ export function UrlContentHeader({ url }: { url: string }) {
return <LinkEmbed url={url} showActions={false} />;
}
// ---------------------------------------------------------------------------
// 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 (
<div className="rounded-2xl border border-border overflow-hidden">
<Skeleton className="w-full aspect-[16/9]" />
<div className="p-5 space-y-3">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="space-y-2 pt-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
if (entity?.wikipediaTitle && entity.wikipediaUrl) {
return <WikipediaArticleHeader title={entity.wikipediaTitle} url={entity.wikipediaUrl} />;
}
return <LinkEmbed url={url} showActions={false} />;
}
// ---------------------------------------------------------------------------
// Bluesky post header (full feed-style, like a thread top post)
// ---------------------------------------------------------------------------
+67
View File
@@ -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<WikidataEntity | null> {
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,
});
}
+23
View File
@@ -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.
+8 -2
View File
@@ -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;