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:
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user