feat(wikipedia): Wikipedia reader with search and article view
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'/);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user