feat(wikipedia): Wikipedia reader with search and article view

This commit is contained in:
2026-08-06 21:11:30 +02:00
parent 31fbf571ee
commit 47e3cbafb7
17 changed files with 1402 additions and 26 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
"typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json",
"prepare": "husky install",
"commit": "git-cz",
+5
View File
@@ -36,6 +36,8 @@ import {
_BOTSU_DOCUMENTS_PATH,
_BOTSU_SERVICES_PATH,
_BOTSU_TCG_PATH,
_BOTSU_WIKIPEDIA_PATH,
_BOTSU_WIKIPEDIA_ARTICLE_PATH,
} from './paths';
import {
getAppPathFromHref,
@@ -82,6 +84,7 @@ import { BotsuDocumentEditor, BotsuDocuments } from '../../botsu/documents';
import { DocumentSyncProvider } from '../../botsu/documents/DocumentSyncContext';
import { BotsuCommunityCanvasHome, BotsuStartPage } from '../../botsu/start';
import { BotsuTcgPage } from '../../botsu/tcg/BotsuTcgPage';
import { BotsuWikipediaHome, BotsuWikipediaArticle } from '../../botsu/wikipedia';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
@@ -187,6 +190,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_BOTSU_DOCUMENTS_PATH} element={<BotsuDocuments />} />
<Route path={_BOTSU_DOCUMENT_PATH} element={<BotsuDocumentEditor />} />
<Route path={_BOTSU_TCG_PATH} element={<BotsuTcgPage />} />
<Route path={_BOTSU_WIKIPEDIA_PATH} element={<BotsuWikipediaHome />} />
<Route path={_BOTSU_WIKIPEDIA_ARTICLE_PATH} element={<BotsuWikipediaArticle />} />
<Route path={_BOTSU_SERVICES_PATH} element={<BotsuServices />} />
<Route path={_BOTSU_EMBED_PATH} element={<BotsuEmbed />} />
</Route>
+2
View File
@@ -82,6 +82,8 @@ export const _BOTSU_EMBED_PATH = 'embed/:appId/';
export const _BOTSU_DOCUMENTS_PATH = 'documents/';
export const _BOTSU_DOCUMENT_PATH = 'documents/:documentId/';
export const _BOTSU_TCG_PATH = 'tcg/';
export const _BOTSU_WIKIPEDIA_PATH = 'wikipedia/';
export const _BOTSU_WIKIPEDIA_ARTICLE_PATH = 'wikipedia/:slug/';
export const BOTSU_PATH = '/botsu/';
export const BOTSU_APPS_PATH = `/botsu/${_BOTSU_APPS_PATH}`;
export const BOTSU_SERVICES_PATH = `/botsu/${_BOTSU_SERVICES_PATH}`;
+12 -1
View File
@@ -8,7 +8,7 @@ test('member catalogue exposes the collaborative suite without administration',
assert.deepEqual(
apps.map((app) => app.id),
['discussions', 'documents', 'tables', 'files', 'transfers', 'services', 'generations-audiovisuel', 'generations-vision', 'generations-textuel', 'emoji-tcg']
['discussions', 'documents', 'tables', 'files', 'transfers', 'services', 'generations-audiovisuel', 'generations-vision', 'generations-textuel', 'emoji-tcg', 'radio', 'shop', 'voxel', 'dessin', 'cinema', 'casino', 'bourse', 'peertube', 'wikipedia', 'pets', 'openstreetmap']
);
assert.equal(
apps.some((app) => app.id === 'administration'),
@@ -40,5 +40,16 @@ test('catalogue records live and planned applications explicitly', () => {
assert.equal(getApp('documents')?.availability, 'planned');
assert.equal(getApp('tables')?.launch.mode, 'iframe');
assert.equal(getApp('transfers')?.launch.mode, 'external');
assert.equal(getApp('radio')?.availability, 'planned');
assert.equal(getApp('shop')?.availability, 'planned');
assert.equal(getApp('voxel')?.availability, 'planned');
assert.equal(getApp('dessin')?.availability, 'planned');
assert.equal(getApp('cinema')?.availability, 'planned');
assert.equal(getApp('casino')?.availability, 'planned');
assert.equal(getApp('bourse')?.availability, 'planned');
assert.equal(getApp('peertube')?.availability, 'planned');
assert.equal(getApp('wikipedia')?.availability, 'available');
assert.equal(getApp('pets')?.availability, 'planned');
assert.equal(getApp('openstreetmap')?.availability, 'planned');
assert.equal(getApp('missing'), undefined);
});
+100 -1
View File
@@ -10,7 +10,18 @@ export type BotsuAppId =
| 'generations-audiovisuel'
| 'generations-vision'
| 'generations-textuel'
| 'emoji-tcg';
| 'emoji-tcg'
| 'radio'
| 'shop'
| 'voxel'
| 'dessin'
| 'cinema'
| 'casino'
| 'bourse'
| 'peertube'
| 'wikipedia'
| 'pets'
| 'openstreetmap';
export type NativeLaunch = { mode: 'native'; path: string };
export type IframeLaunch = { mode: 'iframe'; url: string };
@@ -115,6 +126,94 @@ export const botsuApps: readonly BotsuApp[] = [
availability: 'available',
launch: { mode: 'native', path: '/botsu/tcg/' },
},
{
id: 'radio',
label: 'Radio',
description: 'Écoute de la radio en direct.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/radio/' },
},
{
id: 'shop',
label: 'Shop',
description: 'Boutique de la suite BOTSU.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/shop/' },
},
{
id: 'voxel',
label: 'Voxel',
description: 'Édition et création voxel.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/voxel/' },
},
{
id: 'dessin',
label: 'Dessin',
description: 'Espace de dessin collaboratif.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/dessin/' },
},
{
id: 'cinema',
label: 'Cinéma',
description: 'Films et séries.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/cinema/' },
},
{
id: 'casino',
label: 'Casino',
description: 'Jeux de hasard et divertissement.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/casino/' },
},
{
id: 'bourse',
label: 'Bourse',
description: 'Suivi des marchés financiers.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/bourse/' },
},
{
id: 'peertube',
label: 'PeerTube',
description: 'Vidéos hébergées sur le réseau PeerTube.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/peertube/' },
},
{
id: 'wikipedia',
label: 'Wikipédia',
description: 'Encyclopédie libre et collaborative.',
roles: ['member', 'admin'],
availability: 'available',
launch: { mode: 'native', path: '/botsu/wikipedia/' },
},
{
id: 'pets',
label: 'Pets',
description: 'Adopte et élève des animaux virtuels.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/pets/' },
},
{
id: 'openstreetmap',
label: 'OpenStreetMap',
description: 'Cartographie libre et collaborative.',
roles: ['member', 'admin'],
availability: 'planned',
launch: { mode: 'native', path: '/botsu/openstreetmap/' },
},
];
export const getVisibleApps = (
+29 -1
View File
@@ -53,6 +53,34 @@ async function saveLinks(
await mx.setAccountData(BOTSU_LINKS_EVENT, { links } satisfies BotsuLinksContent);
}
function getFaviconUrl(url: string): string {
try {
const parsed = new URL(url);
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=32`;
} catch {
return '';
}
}
function LinkFavicon({ url, label }: { url: string; label: string }) {
const [failed, setFailed] = useState(false);
const faviconUrl = getFaviconUrl(url);
if (!faviconUrl || failed) {
return <Icon size="100" src={Icons.External} aria-hidden="true" />;
}
return (
<img
className="botsu-link-favicon"
src={faviconUrl}
alt=""
aria-hidden="true"
onError={() => setFailed(true)}
/>
);
}
function AddLinkDialog({
onSave,
onCancel,
@@ -227,7 +255,7 @@ export function BotsuLinks() {
>
<NavItemContent>
<Box as="span" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
<Icon size="100" src={Icons.External} aria-hidden="true" />
<LinkFavicon url={link.url} label={link.label} />
<Text as="span" size="T300" truncate>
{link.label.toLocaleLowerCase('fr-FR')}
</Text>
+67 -19
View File
@@ -17,7 +17,8 @@ import { botsuServices } from './BotsuServices';
import { BotsuLinks } from './BotsuLinks';
import { Line } from 'folds';
const APP_NAV_IDS = new Set(['discussions', 'documents', 'tables', 'files', 'emoji-tcg']);
const APP_NAV_IDS = new Set(['emoji-tcg', 'radio', 'shop', 'voxel', 'dessin', 'cinema', 'casino', 'bourse', 'pets']);
const LIVE_NAV_IDS = new Set(['peertube', 'wikipedia', 'openstreetmap']);
const GENERATIONS_NAV_IDS = new Set(['generations-audiovisuel', 'generations-vision', 'generations-textuel']);
const HIDDEN_SERVICE_PANEL_LABELS = new Set(['accueil', 'recherche']);
@@ -41,6 +42,17 @@ const getAppNavIcon = (appId: BotsuAppId | 'test') => {
if (appId === 'generations-vision') return Icons.Eye;
if (appId === 'generations-textuel') return Icons.Message;
if (appId === 'emoji-tcg') return Icons.Smile;
if (appId === 'radio') return Icons.Headphone;
if (appId === 'shop') return Icons.Cup;
if (appId === 'voxel') return Icons.Category;
if (appId === 'dessin') return Icons.Pencil;
if (appId === 'cinema') return Icons.VideoCamera;
if (appId === 'casino') return Icons.Cup;
if (appId === 'bourse') return Icons.Star;
if (appId === 'peertube') return Icons.Play;
if (appId === 'wikipedia') return Icons.Bookmark;
if (appId === 'pets') return Icons.Leaf;
if (appId === 'openstreetmap') return Icons.Globe;
return Icons.Space;
};
@@ -108,10 +120,14 @@ export function BotsuNav() {
const [applicationsClosed, setApplicationsClosed] = useState(false);
const [servicesClosed, setServicesClosed] = useState(false);
const [generationsClosed, setGenerationsClosed] = useState(false);
const [liveClosed, setLiveClosed] = useState(false);
const [linksClosed, setLinksClosed] = useState(false);
const apps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps).filter((app) =>
APP_NAV_IDS.has(app.id)
);
const liveApps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps).filter((app) =>
LIVE_NAV_IDS.has(app.id)
);
const generationsApps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps).filter((app) =>
GENERATIONS_NAV_IDS.has(app.id)
);
@@ -132,6 +148,56 @@ export function BotsuNav() {
</PageNavHeader>
<PageNavContent>
<Box direction="Column" gap="500">
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={linksClosed}
onClick={() => setLinksClosed((closed) => !closed)}
>
liens
</RoomNavCategoryButton>
</NavCategoryHeader>
{!linksClosed && (
<Box direction="Column" gap="100">
<BotsuLinks />
</Box>
)}
</NavCategory>
<Line className="botsu-links-separator" />
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={liveClosed}
onClick={() => setLiveClosed((closed) => !closed)}
>
live
</RoomNavCategoryButton>
</NavCategoryHeader>
{!liveClosed && (
<Box direction="Column" gap="100">
{liveApps.map((app) => {
const target = getAppNavTarget(app);
const to = target && 'to' in target ? target.to : undefined;
const href = target && 'href' in target ? target.href : undefined;
return (
<BotsuNavLink
active={to ? location.pathname.startsWith(to) : false}
href={href}
key={app.id}
to={to}
>
<BotsuNavLabel icon={getAppNavIcon(app.id)}>
{app.label.toLocaleLowerCase('fr-FR')}
</BotsuNavLabel>
</BotsuNavLink>
);
})}
</Box>
)}
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
@@ -217,24 +283,6 @@ export function BotsuNav() {
</Box>
)}
</NavCategory>
<Line className="botsu-links-separator" />
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={linksClosed}
onClick={() => setLinksClosed((closed) => !closed)}
>
liens
</RoomNavCategoryButton>
</NavCategoryHeader>
{!linksClosed && (
<Box direction="Column" gap="100">
<BotsuLinks />
</Box>
)}
</NavCategory>
</Box>
</PageNavContent>
</PageNav>
+17 -3
View File
@@ -261,7 +261,8 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /<BotsuNavLabel icon=\{getAppNavIcon\(app\.id\)\}>/);
assert.match(nav, /<BotsuNavLabel icon=\{getServiceNavIcon\(service\.label\)\}>/);
assert.match(nav, /appId === 'discussions'\) return Icons\.Pencil/);
assert.match(nav, /APP_NAV_IDS = new Set\(\['discussions', 'documents', 'tables', 'files', 'emoji-tcg'\]\)/);
assert.match(nav, /APP_NAV_IDS = new Set\(\['emoji-tcg', 'radio', 'shop', 'voxel', 'dessin', 'cinema', 'casino', 'bourse', 'pets'\]\)/);
assert.match(nav, /LIVE_NAV_IDS = new Set\(\['peertube', 'wikipedia', 'openstreetmap'\]\)/);
assert.match(nav, /GENERATIONS_NAV_IDS = new Set\(\['generations-audiovisuel', 'generations-vision', 'generations-textuel'\]\)/);
assert.match(nav, /appId === 'documents'\) return Icons\.File/);
assert.match(nav, /appId === 'tables'\) return Icons\.Category/);
@@ -269,6 +270,17 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /appId === 'generations-audiovisuel'\) return Icons\.VideoCamera/);
assert.match(nav, /appId === 'generations-vision'\) return Icons\.Eye/);
assert.match(nav, /appId === 'generations-textuel'\) return Icons\.Message/);
assert.match(nav, /appId === 'radio'\) return Icons\.Headphone/);
assert.match(nav, /appId === 'shop'\) return Icons\.Cup/);
assert.match(nav, /appId === 'voxel'\) return Icons\.Category/);
assert.match(nav, /appId === 'dessin'\) return Icons\.Pencil/);
assert.match(nav, /appId === 'cinema'\) return Icons\.VideoCamera/);
assert.match(nav, /appId === 'casino'\) return Icons\.Cup/);
assert.match(nav, /appId === 'bourse'\) return Icons\.Star/);
assert.match(nav, /appId === 'peertube'\) return Icons\.Play/);
assert.match(nav, /appId === 'wikipedia'\) return Icons\.Bookmark/);
assert.match(nav, /appId === 'pets'\) return Icons\.Leaf/);
assert.match(nav, /appId === 'openstreetmap'\) return Icons\.Globe/);
assert.match(nav, /serviceLabel === 'paste'\) return Icons\.Pencil/);
assert.match(nav, /serviceLabel === 'traduction'\) return Icons\.Globe/);
assert.doesNotMatch(nav, /BotsuLogo|BOTSU_LOGO_PATH|BOTSU_LOGO_INNER_PATH|botsu-panel-logo/);
@@ -282,13 +294,15 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /linksClosed/);
assert.match(nav, />\s*applications\s*<\/RoomNavCategoryButton>/);
assert.match(nav, />\s*générations\s*<\/RoomNavCategoryButton>/);
assert.match(nav, />\s*live\s*<\/RoomNavCategoryButton>/);
assert.match(nav, />\s*services\s*<\/RoomNavCategoryButton>/);
assert.match(nav, />\s*liens\s*<\/RoomNavCategoryButton>/);
assert.match(nav, /botsu-links-separator/);
assert.ok(nav.indexOf('>\n liens') < nav.indexOf('botsu-links-separator'));
assert.ok(nav.indexOf('botsu-links-separator') < nav.indexOf('>\n live'));
assert.ok(nav.indexOf('>\n live') < nav.indexOf('>\n applications'));
assert.ok(nav.indexOf('>\n applications') < nav.indexOf('>\n générations'));
assert.ok(nav.indexOf('>\n générations') < nav.indexOf('>\n services'));
assert.ok(nav.indexOf('>\n services') < nav.indexOf('botsu-links-separator'));
assert.ok(nav.indexOf('botsu-links-separator') < nav.indexOf('>\n liens'));
assert.doesNotMatch(nav, /to="\/botsu\/services\/"/);
assert.match(services, /label: 'Accueil'/);
assert.match(services, /label: 'Recherche'/);
+7
View File
@@ -700,3 +700,10 @@
border: 0;
border-top: 1px solid color-mix(in srgb, var(--tc-surface-on, #f5f5f5) 12%, transparent);
}
.botsu-link-favicon {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2px;
}
@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { HouseIcon, ArrowLeftIcon } from '@phosphor-icons/react';
import { fetchWikipediaArticle, type WikipediaArticleContent } from './wikipedia-scraper';
import { getWikipediaHomePath } from './model';
export function BotsuWikipediaArticle() {
const { slug } = useParams<{ slug: string }>();
const location = useLocation();
const articleTitleFromNav = (location.state as { articleTitle?: string } | null)?.articleTitle;
const [article, setArticle] = useState<WikipediaArticleContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const displayTitle = articleTitleFromNav ?? slug?.replace(/_/g, ' ') ?? 'Article';
useEffect(() => {
if (!slug) {
setLoading(false);
setError('Article introuvable.');
return;
}
let cancelled = false;
const title = articleTitleFromNav ?? slug.replace(/_/g, ' ');
setLoading(true);
setError('');
fetchWikipediaArticle(title, 'fr')
.then((result) => {
if (!cancelled) {
setArticle(result);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(`Impossible de charger l'article : ${err instanceof Error ? err.message : 'erreur inconnue'}`);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [slug, articleTitleFromNav]);
if (!slug) {
return (
<section className="botsu-wikipedia-article" aria-label="Article">
<div className="botsu-wikipedia-article-empty">
<p>Article introuvable.</p>
<Link to={getWikipediaHomePath()}>Retour à l'accueil</Link>
</div>
</section>
);
}
return (
<section className="botsu-wikipedia-article" aria-label={`Article: ${displayTitle}`}>
<header className="botsu-wikipedia-article-header">
<Link
aria-label="Retour à l'accueil Wikipédia"
className="botsu-wikipedia-home-link"
to={getWikipediaHomePath()}
>
<ArrowLeftIcon size={20} weight="bold" />
</Link>
<h1 className="botsu-wikipedia-article-header-title">{displayTitle}</h1>
{article && (
<a
aria-label="Voir sur Wikipédia"
className="botsu-wikipedia-external-link"
href={article.url}
rel="noopener noreferrer"
target="_blank"
title="Voir sur Wikipédia"
>
<HouseIcon size={18} weight="bold" />
</a>
)}
</header>
<section className="botsu-wikipedia-content" aria-label="Contenu de l'article">
{loading && (
<div className="botsu-wikipedia-loading">
<p>Chargement de l'article</p>
</div>
)}
{error && (
<div className="botsu-wikipedia-article-empty">
<p>{error}</p>
<Link to={getWikipediaHomePath()}>Retour à l'accueil</Link>
</div>
)}
{article && !loading && (
<div
className="botsu-wikipedia-article-body mw-parser-output"
// Wikipedia HTML is sanitized by their API — we render it as-is
dangerouslySetInnerHTML={{ __html: article.html }}
/>
)}
</section>
</section>
);
}
@@ -0,0 +1,133 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
import { Link, useNavigate } from 'react-router-dom';
import {
searchWikipediaArticles,
type WikipediaSearchResult,
} from './wikipedia-scraper';
import { getWikipediaArticlePath } from './model';
export function BotsuWikipediaHome() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [results, setResults] = useState<WikipediaSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [searched, setSearched] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const doSearch = useCallback(async (q: string) => {
const trimmed = q.trim();
if (!trimmed) {
setResults([]);
setSearched(false);
return;
}
setSearching(true);
setError('');
try {
const found = await searchWikipediaArticles(trimmed, 'fr');
setResults(found);
setSearched(true);
} catch (err) {
setError('Recherche impossible. Vérifie ta connexion.');
setResults([]);
} finally {
setSearching(false);
}
}, []);
const handleQueryChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 400);
};
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && results.length > 0) {
const first = results[0];
const slug = first.title.replace(/ /g, '_');
navigate(getWikipediaArticlePath(slug), { state: { articleTitle: first.title } });
}
};
return (
<section className="botsu-wikipedia" aria-label="Wikipédia">
<div className="botsu-wikipedia-header">
<h1 className="botsu-wikipedia-title">Wikipédia</h1>
<p className="botsu-wikipedia-subtitle">Encyclopédie libre recherche et lecture</p>
</div>
<div className="botsu-wikipedia-search-bar">
<MagnifyingGlassIcon
className="botsu-wikipedia-search-icon"
size={20}
weight="bold"
/>
<input
aria-label="Rechercher un article Wikipédia"
className="botsu-wikipedia-search-input"
maxLength={200}
placeholder="Rechercher un article…"
type="search"
value={query}
onChange={(event) => handleQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
/>
{searching && (
<span className="botsu-wikipedia-searching" aria-live="polite">
Recherche
</span>
)}
</div>
{error && (
<p className="botsu-message botsu-wikipedia-error" role="alert">
{error}
</p>
)}
{searched && results.length === 0 && !searching && (
<div className="botsu-wikipedia-empty">
<p>Aucun article trouvé pour « {query} ».</p>
</div>
)}
{results.length > 0 && (
<ul className="botsu-wikipedia-results" aria-label="Résultats de recherche">
{results.map((result) => {
const slug = result.title.replace(/ /g, '_');
return (
<li className="botsu-wikipedia-result-item" key={result.url}>
<Link
aria-label={`Lire ${result.title}`}
className="botsu-wikipedia-result-link"
to={getWikipediaArticlePath(slug)}
state={{ articleTitle: result.title }}
>
<strong className="botsu-wikipedia-result-title">{result.title}</strong>
{result.description && (
<span className="botsu-wikipedia-result-desc">{result.description}</span>
)}
<span className="botsu-wikipedia-result-url">{result.url}</span>
</Link>
</li>
);
})}
</ul>
)}
{!searched && !searching && (
<div className="botsu-wikipedia-empty">
<p>Tape un titre pour rechercher un article Wikipédia.</p>
</div>
)}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
import './wikipedia.css';
export * from './BotsuWikipediaHome';
export * from './BotsuWikipediaArticle';
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createWikipediaArticle,
deleteWikipediaArticle,
listWikipediaArticles,
loadWikipediaArticle,
parseWikipediaArticle,
saveWikipediaArticle,
searchWikipediaArticles,
type WikipediaArticle,
type WikipediaStorage,
} from './model.ts';
const makeStorage = (): WikipediaStorage => {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return [...store.keys()][index] ?? null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
};
test('saves and loads a Wikipedia article', () => {
const storage = makeStorage();
const article: WikipediaArticle = {
version: 1,
slug: 'Albert_Einstein',
objectId: 'bdoc_123e4567-e89b-42d3-a456-426614174000',
title: 'Albert Einstein',
updatedAt: 1700000000000,
};
const saved = saveWikipediaArticle(storage, article);
assert.equal(saved.slug, 'Albert_Einstein');
assert.equal(saved.objectId, 'bdoc_123e4567-e89b-42d3-a456-426614174000');
const loaded = loadWikipediaArticle(storage, 'Albert_Einstein');
assert.ok(loaded);
assert.equal(loaded?.title, 'Albert Einstein');
assert.equal(loaded?.objectId, 'bdoc_123e4567-e89b-42d3-a456-426614174000');
});
test('loadWikipediaArticle returns undefined for unknown slug', () => {
const storage = makeStorage();
assert.equal(loadWikipediaArticle(storage, 'Inconnu'), undefined);
});
test('lists Wikipedia articles sorted by updatedAt desc', () => {
const storage = makeStorage();
saveWikipediaArticle(storage, {
version: 1,
slug: 'B',
objectId: 'bdoc_00000000-0000-4000-a000-000000000002',
title: 'Article B',
updatedAt: 1000,
});
saveWikipediaArticle(storage, {
version: 1,
slug: 'A',
objectId: 'bdoc_00000000-0000-4000-a000-000000000001',
title: 'Article A',
updatedAt: 2000,
});
const articles = listWikipediaArticles(storage);
assert.equal(articles.length, 2);
assert.equal(articles[0].slug, 'A'); // plus récent d'abord
assert.equal(articles[1].slug, 'B');
});
test('deletes a Wikipedia article', () => {
const storage = makeStorage();
saveWikipediaArticle(storage, {
version: 1,
slug: 'Test',
objectId: 'bdoc_00000000-0000-4000-a000-000000000003',
title: 'Test',
updatedAt: 1000,
});
assert.ok(loadWikipediaArticle(storage, 'Test'));
const deleted = deleteWikipediaArticle(storage, 'Test');
assert.equal(deleted, true);
assert.equal(loadWikipediaArticle(storage, 'Test'), undefined);
});
test('deleteWikipediaArticle returns false for unknown slug', () => {
const storage = makeStorage();
assert.equal(deleteWikipediaArticle(storage, 'Inconnu'), false);
});
test('searches Wikipedia articles by title and slug', () => {
const articles: WikipediaArticle[] = [
{ version: 1, slug: 'Albert_Einstein', objectId: 'bdoc_a', title: 'Albert Einstein', updatedAt: 1 },
{ version: 1, slug: 'Marie_Curie', objectId: 'bdoc_b', title: 'Marie Curie', updatedAt: 2 },
{ version: 1, slug: 'Relativité', objectId: 'bdoc_c', title: 'Théorie de la relativité', updatedAt: 3 },
];
const results = searchWikipediaArticles(articles, 'einstein');
assert.equal(results.length, 1);
assert.equal(results[0].slug, 'Albert_Einstein');
const results2 = searchWikipediaArticles(articles, 'relativité');
assert.equal(results2.length, 1);
assert.equal(results2[0].slug, 'Relativité');
// Empty query returns all
const all = searchWikipediaArticles(articles, '');
assert.equal(all.length, 3);
// Too long query returns empty
const none = searchWikipediaArticles(articles, 'x'.repeat(81));
assert.equal(none.length, 0);
});
test('parseWikipediaArticle validates article records', () => {
const valid = {
version: 1,
slug: 'Test',
objectId: 'bdoc_123e4567-e89b-42d3-a456-426614174000',
title: 'Test Article',
updatedAt: 1700000000000,
};
const parsed = parseWikipediaArticle(valid);
assert.equal(parsed.slug, 'Test');
assert.equal(parsed.title, 'Test Article');
// Rejects invalid
assert.throws(() => parseWikipediaArticle({ ...valid, version: 2 }), /version/);
assert.throws(() => parseWikipediaArticle({ ...valid, slug: '../hack' }), /slug/);
assert.throws(() => parseWikipediaArticle({ ...valid, objectId: 'bad' }), /object/);
assert.throws(() => parseWikipediaArticle({ ...valid, title: '' }), /title/);
assert.throws(() => parseWikipediaArticle({ ...valid, updatedAt: -1 }), /updatedAt/);
});
test('createWikipediaArticle builds a valid article from slug and objectId', () => {
const now = () => 1700000000000;
const article = createWikipediaArticle('Albert_Einstein', 'bdoc_123e4567-e89b-42d3-a456-426614174000', 'Albert Einstein', now);
assert.equal(article.slug, 'Albert_Einstein');
assert.equal(article.objectId, 'bdoc_123e4567-e89b-42d3-a456-426614174000');
assert.equal(article.title, 'Albert Einstein');
assert.equal(article.updatedAt, 1700000000000);
assert.equal(article.version, 1);
});
+172
View File
@@ -0,0 +1,172 @@
import { parseBotsuDocumentObjectId } from '@botsu/protocol';
const WIKIPEDIA_SLUG_MAX_LENGTH = 200;
const WIKIPEDIA_ARTICLE_STORAGE_PREFIX = 'botsu.wikipedia.article.v1.';
const MAXIMUM_WIKIPEDIA_SEARCH_LENGTH = 80;
export const WIKIPEDIA_SLUG_PATTERN = /^[^\x00-\x1f\x7f\/\\?%#&]{1,200}$/;
export const parseWikipediaSlug = (value: unknown): string => {
if (typeof value !== 'string' || !WIKIPEDIA_SLUG_PATTERN.test(value)) {
throw new TypeError('Invalid Wikipedia slug');
}
return value;
};
export const getWikipediaHomePath = (): string => '/botsu/wikipedia/';
export const getWikipediaArticlePath = (slug: string): string =>
`/botsu/wikipedia/${encodeURIComponent(parseWikipediaSlug(slug))}/`;
export const parseWikipediaArticlePath = (pathname: string): string | undefined => {
const match = /^\/botsu\/wikipedia\/([^/]+)\/$/.exec(pathname);
if (!match) return undefined;
try {
return parseWikipediaSlug(decodeURIComponent(match[1]));
} catch {
return undefined;
}
};
// --- Article storage (local-first, maps slug → shared document objectId) ---
export type WikipediaArticle = {
version: 1;
slug: string;
objectId: string;
title: string;
updatedAt: number;
};
export type WikipediaStorage = {
readonly length: number;
getItem: (key: string) => string | null;
key: (index: number) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
const ARTICLE_KEYS = ['version', 'slug', 'objectId', 'title', 'updatedAt'] as const;
const articleStorageKey = (slug: string): string =>
`${WIKIPEDIA_ARTICLE_STORAGE_PREFIX}${parseWikipediaSlug(slug)}`;
export const parseWikipediaArticle = (value: unknown): WikipediaArticle => {
if (typeof value !== 'object' || value === null) throw new TypeError('Invalid article');
const descriptors = Object.getOwnPropertyDescriptors(value);
const keys = Reflect.ownKeys(descriptors);
if (
keys.length !== ARTICLE_KEYS.length ||
keys.some((key) => typeof key !== 'string' || !ARTICLE_KEYS.includes(key as never))
) {
throw new TypeError('Invalid article keys');
}
const record = Object.fromEntries(
ARTICLE_KEYS.map((key) => {
const descriptor = descriptors[key];
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
throw new TypeError(`Invalid article ${key}`);
}
return [key, descriptor.value];
}),
);
if (record.version !== 1) throw new TypeError('Invalid article version');
const slug = parseWikipediaSlug(record.slug);
const objectId = parseBotsuDocumentObjectId(record.objectId);
if (typeof record.title !== 'string' || record.title.trim().length < 1 || record.title.length > 200) {
throw new TypeError('Invalid article title');
}
if (!Number.isSafeInteger(record.updatedAt) || (record.updatedAt as number) < 0) {
throw new TypeError('Invalid article updatedAt');
}
return {
version: 1,
slug,
objectId,
title: record.title.trim(),
updatedAt: record.updatedAt as number,
};
};
export const saveWikipediaArticle = (
storage: WikipediaStorage,
article: WikipediaArticle,
): WikipediaArticle => {
const parsed = parseWikipediaArticle(article);
storage.setItem(articleStorageKey(parsed.slug), JSON.stringify(parsed));
return parsed;
};
export const loadWikipediaArticle = (
storage: WikipediaStorage,
slug: string,
): WikipediaArticle | undefined => {
const normalizedSlug = parseWikipediaSlug(slug);
const stored = storage.getItem(articleStorageKey(normalizedSlug));
if (stored === null) return undefined;
try {
const article = parseWikipediaArticle(JSON.parse(stored));
return article.slug === normalizedSlug ? article : undefined;
} catch {
return undefined;
}
};
export const deleteWikipediaArticle = (storage: WikipediaStorage, slug: string): boolean => {
const normalizedSlug = parseWikipediaSlug(slug);
const key = articleStorageKey(normalizedSlug);
const existed = storage.getItem(key) !== null;
if (existed) storage.removeItem(key);
return existed;
};
export const listWikipediaArticles = (storage: WikipediaStorage): WikipediaArticle[] => {
const articles = new Map<string, WikipediaArticle>();
const keyCount = Math.min(Math.max(0, storage.length), 1_000);
for (let index = 0; index < keyCount; index += 1) {
const key = storage.key(index);
if (key?.startsWith(WIKIPEDIA_ARTICLE_STORAGE_PREFIX)) {
try {
const slug = parseWikipediaSlug(key.slice(WIKIPEDIA_ARTICLE_STORAGE_PREFIX.length));
const article = loadWikipediaArticle(storage, slug);
if (article) articles.set(slug, article);
} catch {
// Ignore unrelated or malformed storage entries.
}
}
}
return [...articles.values()].sort((left, right) => right.updatedAt - left.updatedAt);
};
const normalizeWikipediaSearchText = (value: string): string =>
value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase()
.trim();
export const searchWikipediaArticles = (
articles: readonly WikipediaArticle[],
query: string,
): WikipediaArticle[] => {
if (typeof query !== 'string' || query.length > MAXIMUM_WIKIPEDIA_SEARCH_LENGTH) return [];
const needle = normalizeWikipediaSearchText(query);
if (!needle) return [...articles];
return articles.filter((article) =>
normalizeWikipediaSearchText(`${article.title} ${article.slug}`).includes(needle),
);
};
export const createWikipediaArticle = (
slug: string,
objectId: string,
title: string,
now: () => number,
): WikipediaArticle =>
parseWikipediaArticle({
version: 1,
slug: parseWikipediaSlug(slug),
objectId: parseBotsuDocumentObjectId(objectId),
title,
updatedAt: now(),
});
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
fetchWikipediaArticle,
parseWikipediaApiResponse,
searchWikipediaArticles,
parseWikipediaSearchResults,
} from './wikipedia-scraper.ts';
test('parses a Wikipedia API parse response into clean HTML', () => {
const apiResponse = {
parse: {
title: 'Albert Einstein',
pageid: 736,
text: {
'*': '<div class="mw-parser-output"><p>Albert Einstein est un <b>physicien</b>.</p><p>Il a développé la théorie de la <a href="/wiki/Relativit%C3%A9" title="Relativité">relativité</a>.</p></div>',
},
},
};
const result = parseWikipediaApiResponse(apiResponse, 'fr');
assert.equal(result.title, 'Albert Einstein');
assert.ok(result.html.includes('<p>Albert Einstein est un <b>physicien</b>.</p>'));
assert.ok(result.html.includes('relativité'));
assert.equal(result.url, 'https://fr.wikipedia.org/wiki/Albert_Einstein');
assert.equal(result.pageId, 736);
});
test('parseWikipediaApiResponse strips mw-parser-output wrapper', () => {
const apiResponse = {
parse: {
title: 'Test',
pageid: 1,
text: {
'*': '<div class="mw-parser-output"><p>Contenu</p></div>',
},
},
};
const result = parseWikipediaApiResponse(apiResponse, 'fr');
assert.ok(result.html.includes('<p>Contenu</p>'));
assert.ok(!result.html.includes('mw-parser-output'));
});
test('parseWikipediaApiResponse rejects malformed responses', () => {
assert.throws(() => parseWikipediaApiResponse(null, 'fr'), /response/);
assert.throws(() => parseWikipediaApiResponse({}, 'fr'), /response/);
assert.throws(() => parseWikipediaApiResponse({ parse: {} }, 'fr'), /response/);
assert.throws(() => parseWikipediaApiResponse({ parse: { title: 123 } }, 'fr'), /response/);
});
test('parseWikipediaSearchResults parses opensearch results', () => {
const response: [string, string[], string[], string[]] = [
'einstein',
['Albert Einstein', 'Einstein (unité)', 'Einsteinium'],
['Physicien allemand', 'Unité photochimique', 'Élément chimique'],
[
'https://fr.wikipedia.org/wiki/Albert_Einstein',
'https://fr.wikipedia.org/wiki/Einstein_(unit%C3%A9)',
'https://fr.wikipedia.org/wiki/Einsteinium',
],
];
const results = parseWikipediaSearchResults(response);
assert.equal(results.length, 3);
assert.equal(results[0].title, 'Albert Einstein');
assert.equal(results[0].description, 'Physicien allemand');
assert.equal(results[0].url, 'https://fr.wikipedia.org/wiki/Albert_Einstein');
assert.equal(results[1].title, 'Einstein (unité)');
});
test('parseWikipediaSearchResults rejects malformed responses', () => {
assert.throws(() => parseWikipediaSearchResults(null), /response/);
assert.throws(() => parseWikipediaSearchResults([]), /response/);
assert.throws(() => parseWikipediaSearchResults(['q', [], []]), /response/);
});
test('searchWikipediaArticles searches real Wikipedia', async () => {
const results = await searchWikipediaArticles('Einstein', 'fr');
assert.ok(results.length > 0, 'should return at least one result');
assert.equal(results[0].title, 'Albert Einstein');
assert.ok(results[0].url.includes('wikipedia.org'));
// opensearch descriptions are often empty — that's fine
});
test('searchWikipediaArticles returns empty for nonsense query', async () => {
const results = await searchWikipediaArticles('xyzzynonexistent987654321', 'fr');
assert.equal(results.length, 0);
});
test('fetchWikipediaArticle fetches and parses a real Wikipedia article', async () => {
const result = await fetchWikipediaArticle('France', 'fr');
assert.equal(result.title, 'France');
assert.ok(result.html.length > 100, 'HTML content should be substantial');
assert.ok(result.html.includes('<p>'), 'should contain paragraph tags');
assert.equal(result.url, 'https://fr.wikipedia.org/wiki/France');
assert.ok(result.pageId > 0);
});
test('fetchWikipediaArticle handles non-existent pages', async () => {
await assert.rejects(
fetchWikipediaArticle('XyzzyNonexistentPage12345', 'fr'),
/not found|missing|introuvable/i,
);
});
@@ -0,0 +1,139 @@
export type WikipediaArticleContent = {
title: string;
html: string;
url: string;
pageId: number;
};
export type WikipediaSearchResult = {
title: string;
description: string;
url: string;
};
const WIKIPEDIA_API_BASE = 'https://fr.wikipedia.org/w/api.php';
const buildApiUrl = (params: Record<string, string>): string => {
const searchParams = new URLSearchParams({
format: 'json',
origin: '*',
...params,
});
return `${WIKIPEDIA_API_BASE}?${searchParams.toString()}`;
};
export const parseWikipediaApiResponse = (
value: unknown,
lang: string,
): WikipediaArticleContent => {
if (typeof value !== 'object' || value === null) {
throw new TypeError('Invalid Wikipedia API response');
}
const response = value as Record<string, unknown>;
const parse = response.parse;
if (typeof parse !== 'object' || parse === null) {
throw new TypeError('Invalid Wikipedia API response: missing parse');
}
const p = parse as Record<string, unknown>;
if (typeof p.title !== 'string' || p.title.length < 1) {
throw new TypeError('Invalid Wikipedia API response: missing title');
}
if (typeof p.pageid !== 'number' || !Number.isSafeInteger(p.pageid) || p.pageid < 1) {
throw new TypeError('Invalid Wikipedia API response: missing pageid');
}
const text = p.text;
if (typeof text !== 'object' || text === null) {
throw new TypeError('Invalid Wikipedia API response: missing text');
}
const rawHtml = (text as Record<string, unknown>)['*'];
if (typeof rawHtml !== 'string' || rawHtml.length < 1) {
throw new TypeError('Invalid Wikipedia API response: missing text content');
}
// Strip the mw-parser-output wrapper div
const innerMatch = /<div class="mw-parser-output">([\s\S]*)<\/div>\s*$/i.exec(rawHtml);
const html = innerMatch ? innerMatch[1].trim() : rawHtml;
const encodedTitle = encodeURIComponent(p.title.replace(/ /g, '_'));
const url = `https://${lang}.wikipedia.org/wiki/${encodedTitle}`;
return {
title: p.title,
html,
url,
pageId: p.pageid,
};
};
export const parseWikipediaSearchResults = (value: unknown): WikipediaSearchResult[] => {
if (!Array.isArray(value) || value.length < 4) {
throw new TypeError('Invalid Wikipedia search response');
}
const [_query, titles, descriptions, urls] = value as [string, string[], string[], string[]];
if (!Array.isArray(titles) || !Array.isArray(descriptions) || !Array.isArray(urls)) {
throw new TypeError('Invalid Wikipedia search response');
}
const results: WikipediaSearchResult[] = [];
const count = Math.min(titles.length, descriptions.length, urls.length);
for (let i = 0; i < count; i++) {
if (typeof titles[i] !== 'string' || typeof urls[i] !== 'string') continue;
results.push({
title: titles[i],
description: typeof descriptions[i] === 'string' ? descriptions[i] : '',
url: urls[i],
});
}
return results;
};
export const searchWikipediaArticles = async (
query: string,
lang: string = 'fr',
fetchImpl: typeof fetch = fetch,
): Promise<WikipediaSearchResult[]> => {
const url = buildApiUrl({
action: 'opensearch',
search: query,
limit: '10',
namespace: '0',
language: lang,
});
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`Wikipedia search failed: ${response.status}`);
}
const data: unknown = await response.json();
return parseWikipediaSearchResults(data);
};
export const fetchWikipediaArticle = async (
title: string,
lang: string = 'fr',
fetchImpl: typeof fetch = fetch,
): Promise<WikipediaArticleContent> => {
const url = buildApiUrl({
action: 'parse',
page: title,
prop: 'text',
language: lang,
});
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`Wikipedia fetch failed: ${response.status}`);
}
const data: unknown = await response.json();
const result = parseWikipediaApiResponse(data, lang);
if (!result.html || result.html.length < 10) {
throw new Error('Article introuvable ou vide');
}
return result;
};
@@ -0,0 +1,337 @@
/* Wikipedia home — search-first */
.botsu-wikipedia {
padding: 24px 32px;
max-width: 900px;
margin: 0 auto;
}
.botsu-wikipedia-header {
margin-bottom: 20px;
}
.botsu-wikipedia-title {
font-size: 2rem;
font-weight: 700;
color: var(--botsu-color-text);
margin: 0 0 4px;
}
.botsu-wikipedia-subtitle {
font-size: 0.9rem;
color: var(--botsu-color-text-muted);
margin: 0;
}
/* Search bar */
.botsu-wikipedia-search-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
background: var(--botsu-color-surface);
margin-bottom: 20px;
}
.botsu-wikipedia-search-bar:focus-within {
border-color: var(--botsu-color-accent);
box-shadow: 0 0 0 2px var(--botsu-color-focus);
}
.botsu-wikipedia-search-icon {
color: var(--botsu-color-text-muted);
flex-shrink: 0;
}
.botsu-wikipedia-search-input {
flex: 1;
border: none;
background: transparent;
color: var(--botsu-color-text);
font-family: var(--botsu-font-family);
font-size: 1rem;
outline: none;
}
.botsu-wikipedia-search-input::placeholder {
color: var(--botsu-color-text-muted);
}
.botsu-wikipedia-searching {
font-size: 0.8rem;
color: var(--botsu-color-text-muted);
flex-shrink: 0;
}
/* Search results */
.botsu-wikipedia-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.botsu-wikipedia-result-item {
margin: 0;
}
.botsu-wikipedia-result-link {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 16px;
border-radius: var(--botsu-radius);
text-decoration: none;
color: inherit;
transition: background 0.1s;
}
.botsu-wikipedia-result-link:hover {
background: var(--botsu-color-surface-raised);
}
.botsu-wikipedia-result-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--botsu-color-accent);
}
.botsu-wikipedia-result-desc {
font-size: 0.85rem;
color: var(--botsu-color-text);
line-height: 1.4;
}
.botsu-wikipedia-result-url {
font-size: 0.75rem;
color: var(--botsu-color-text-muted);
}
/* Empty / error states */
.botsu-wikipedia-empty {
text-align: center;
padding: 48px 16px;
color: var(--botsu-color-text-muted);
}
.botsu-message {
padding: 12px 16px;
border-radius: var(--botsu-radius);
background: var(--botsu-color-surface);
color: var(--botsu-color-text-muted);
font-size: 0.9rem;
}
.botsu-wikipedia-error {
background: #fef2f2;
color: #dc2626;
margin-bottom: 16px;
}
/* Article view — read-only */
.botsu-wikipedia-article {
display: flex;
flex-direction: column;
height: 100%;
}
.botsu-wikipedia-article-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 16px;
color: var(--botsu-color-text-muted);
}
.botsu-wikipedia-article-empty a {
color: var(--botsu-color-accent);
text-decoration: none;
}
.botsu-wikipedia-article-empty a:hover {
text-decoration: underline;
}
.botsu-wikipedia-article-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
border-bottom: var(--botsu-border-width) solid var(--botsu-color-border);
background: var(--botsu-color-surface);
flex-shrink: 0;
}
.botsu-wikipedia-home-link {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: var(--botsu-radius);
color: var(--botsu-color-text-muted);
text-decoration: none;
flex-shrink: 0;
}
.botsu-wikipedia-home-link:hover {
background: var(--botsu-color-surface-raised);
color: var(--botsu-color-accent);
}
.botsu-wikipedia-external-link {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: var(--botsu-radius);
color: var(--botsu-color-text-muted);
text-decoration: none;
flex-shrink: 0;
margin-left: auto;
}
.botsu-wikipedia-external-link:hover {
background: var(--botsu-color-surface-raised);
color: var(--botsu-color-accent);
}
.botsu-wikipedia-article-header-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--botsu-color-text);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
}
.botsu-wikipedia-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.botsu-wikipedia-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 64px 16px;
color: var(--botsu-color-text-muted);
}
/* Wikipedia article body — rendered HTML from API */
.botsu-wikipedia-article-body {
padding: 24px 32px;
max-width: 800px;
margin: 0 auto;
font-family: var(--botsu-font-family);
font-size: 0.95rem;
line-height: 1.7;
color: var(--botsu-color-text);
}
/* Wikipedia content styling — keep it readable in BOTSU theme */
.botsu-wikipedia-article-body h2 {
font-size: 1.4rem;
font-weight: 600;
margin: 28px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--botsu-color-border);
color: var(--botsu-color-text);
}
.botsu-wikipedia-article-body h3 {
font-size: 1.15rem;
font-weight: 600;
margin: 20px 0 8px;
color: var(--botsu-color-text);
}
.botsu-wikipedia-article-body h4 {
font-size: 1rem;
font-weight: 600;
margin: 16px 0 6px;
color: var(--botsu-color-text);
}
.botsu-wikipedia-article-body p {
margin: 0 0 12px;
}
.botsu-wikipedia-article-body a {
color: var(--botsu-color-accent);
text-decoration: none;
}
.botsu-wikipedia-article-body a:hover {
text-decoration: underline;
}
.botsu-wikipedia-article-body ul,
.botsu-wikipedia-article-body ol {
margin: 0 0 12px;
padding-left: 24px;
}
.botsu-wikipedia-article-body li {
margin-bottom: 4px;
}
.botsu-wikipedia-article-body img {
max-width: 100%;
height: auto;
border-radius: var(--botsu-radius);
}
.botsu-wikipedia-article-body table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 0.9rem;
}
.botsu-wikipedia-article-body th,
.botsu-wikipedia-article-body td {
border: 1px solid var(--botsu-color-border);
padding: 6px 10px;
text-align: left;
}
.botsu-wikipedia-article-body th {
background: var(--botsu-color-surface);
font-weight: 600;
}
.botsu-wikipedia-article-body blockquote {
margin: 12px 0;
padding: 8px 16px;
border-left: 3px solid var(--botsu-color-accent);
background: var(--botsu-color-surface);
border-radius: 0 var(--botsu-radius) var(--botsu-radius) 0;
font-style: italic;
}
.botsu-wikipedia-article-body .infobox {
float: right;
margin: 0 0 16px 16px;
max-width: 280px;
font-size: 0.85rem;
}
.botsu-wikipedia-article-body .mw-editsection {
display: none;
}
.botsu-wikipedia-article-body sup.reference {
font-size: 0.75rem;
}
.botsu-wikipedia-article-body .references {
font-size: 0.85rem;
}