diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9bbd2cdf1..bde41a8e0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,10 @@ jobs: run: | docker run --rm \ -v "$PWD/apps/web:/web" \ + -v "$PWD/wiki:/wiki:ro" \ -w /web \ -e PUBLIC_SERVER_URL=/api/v1 \ + -e DEVFORGE_WIKI_DIR=/wiki \ node:22-bookworm \ bash -lc 'npm install --include=optional && npm run build && npm test' diff --git a/Cargo.lock b/Cargo.lock index 747a4d43b5..19bd1a956e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,6 +933,7 @@ dependencies = [ "subtle", "tokio", "tokio-util", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/Dockerfile b/Dockerfile index 3cdb9286e8..99966b5fc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,10 @@ WORKDIR /web COPY apps/web/package.json apps/web/package-lock.json ./ RUN npm install --include=optional COPY apps/web/ ./ -ENV PUBLIC_SERVER_URL=/api/v1 +# Wiki public (/wiki) : les pages wiki/*.md sont figées dans le build du front. +COPY wiki/ /wiki/ +ENV PUBLIC_SERVER_URL=/api/v1 \ + DEVFORGE_WIKI_DIR=/wiki RUN npm run build FROM rust:bookworm AS builder diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 31e1dcdffa..2379628b81 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -62,6 +62,7 @@ subtle = "2.6" png = "0.18" [dev-dependencies] +tower = { version = "0.5", features = ["util"] } wiremock = "0.6" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index d4ad25541a..e4e00e95c3 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -75,6 +75,7 @@ mod runner_routes; mod runner_store; mod secret_box; mod security; +mod static_web; mod spec_routes; mod sso; mod sso_routes; @@ -92,7 +93,6 @@ use serde_json::{json, Value}; use state::AppState; use std::net::SocketAddr; use std::sync::Arc; -use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -475,17 +475,9 @@ async fn main() -> Result<(), Box> { .with_state(state); // Front Astro : `web/` à côté du programme, share/devforge (Flatpak), ou DEVFORGE_STATIC_DIR. - let mut serving_web = false; if let Some(root) = paths::web_dir() { - let index = root.join("index.html"); tracing::info!(path = %root.display(), "serving static web assets"); - serving_web = true; - if index.is_file() { - app = - app.fallback_service(ServeDir::new(&root).not_found_service(ServeFile::new(index))); - } else { - app = app.fallback_service(ServeDir::new(&root)); - } + app = static_web::with_static_dir(app, &root); } serve_http(app, "server").await?; Ok(()) diff --git a/apps/server/src/static_web.rs b/apps/server/src/static_web.rs new file mode 100644 index 0000000000..930c7c05b1 --- /dev/null +++ b/apps/server/src/static_web.rs @@ -0,0 +1,172 @@ +//! Front Astro statique (`web/`) : fichiers, repli SPA et wiki public. +//! +//! Le wiki est figé au build du front (`web/wiki//index.html`, lu depuis +//! `wiki/*.md`) : servi comme n'importe quel fichier statique, sans session ni +//! base. `ServeDir` refuse `..` et les chemins hors racine. Une page de wiki +//! inconnue renvoie l'accueil du wiki (qui l'annonce) plutôt que la vitrine. + +use std::path::{Path, PathBuf}; + +use axum::{ + body::Body, + http::{header, HeaderValue, Request, StatusCode}, + response::{IntoResponse, Response}, + routing::any, + Router, +}; +use tower_http::services::ServeDir; + +/// Chemin sous `/wiki` ? (`/wiki`, `/wiki/`, `/wiki/x/…`) +pub fn is_wiki_path(path: &str) -> bool { + path == "/wiki" || path.starts_with("/wiki/") +} + +/// Fichier HTML servi quand aucun fichier ne correspond à `path`. +pub fn not_found_page(root: &Path, path: &str) -> Option { + if is_wiki_path(path) { + let wiki = root.join("wiki").join("index.html"); + if wiki.is_file() { + return Some(wiki); + } + } + let index = root.join("index.html"); + index.is_file().then_some(index) +} + +async fn serve_not_found(root: PathBuf, path: String) -> Response { + let Some(file) = not_found_page(&root, &path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + match tokio::fs::read(&file).await { + Ok(bytes) => { + let mut res = (StatusCode::NOT_FOUND, Body::from(bytes)).into_response(); + res.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + res + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +/// Ajoute le service statique en repli du routeur (API d'abord, puis fichiers). +pub fn with_static_dir(app: Router, root: &Path) -> Router +where + S: Clone + Send + Sync + 'static, +{ + let base = root.to_path_buf(); + let fallback = any(move |req: Request| { + let base = base.clone(); + let path = req.uri().path().to_string(); + async move { serve_not_found(base, path).await } + }); + app.fallback_service(ServeDir::new(root).fallback(fallback)) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::routing::get; + use tower::ServiceExt; + + fn site() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "df-static-web-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(dir.join("wiki/Kubernetes")).unwrap(); + std::fs::write(dir.join("index.html"), "VITRINE").unwrap(); + std::fs::write(dir.join("wiki/index.html"), "WIKI-HOME").unwrap(); + std::fs::write(dir.join("wiki/Kubernetes/index.html"), "WIKI-K8S").unwrap(); + std::fs::write(dir.join("wiki/search-index.json"), "[]").unwrap(); + // Hors racine : ne doit jamais être servi. + std::fs::write(dir.with_extension("secret"), "SECRET").unwrap(); + dir + } + + async fn get_body(app: Router, uri: &str) -> (StatusCode, String) { + let res = app + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = res.status(); + let bytes = axum::body::to_bytes(res.into_body(), 1 << 20) + .await + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } + + fn app(root: &Path) -> Router { + with_static_dir( + Router::new().route("/api/v1/health", get(|| async { "ok" })), + root, + ) + } + + #[tokio::test] + async fn wiki_pages_are_public_static_files() { + let root = site(); + let (s, b) = get_body(app(&root), "/wiki/Kubernetes/").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b, "WIKI-K8S"); + let (s, b) = get_body(app(&root), "/wiki/").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b, "WIKI-HOME"); + let (s, _) = get_body(app(&root), "/wiki/search-index.json").await; + assert_eq!(s, StatusCode::OK); + let (_, b) = get_body(app(&root), "/api/v1/health").await; + assert_eq!(b, "ok"); + } + + #[tokio::test] + async fn unknown_wiki_page_falls_back_to_wiki_home() { + let root = site(); + let (s, b) = get_body(app(&root), "/wiki/Nope/").await; + assert_eq!(s, StatusCode::NOT_FOUND); + assert_eq!(b, "WIKI-HOME"); + let (_, b) = get_body(app(&root), "/app/inconnu").await; + assert_eq!(b, "VITRINE"); + } + + #[tokio::test] + async fn traversal_never_leaves_web_root() { + let root = site(); + let name = root.with_extension("secret"); + let name = name.file_name().unwrap().to_string_lossy(); + for uri in [ + format!("/wiki/../../{name}"), + format!("/wiki/%2e%2e/%2e%2e/{name}"), + format!("/wiki/..%2f..%2f{name}"), + format!("/..%5c{name}"), + ] { + let (_, b) = get_body(app(&root), &uri).await; + assert!( + !b.contains("SECRET"), + "{uri} a servi un fichier hors racine" + ); + } + } + + #[test] + fn wiki_path_detection() { + assert!(is_wiki_path("/wiki")); + assert!(is_wiki_path("/wiki/Kubernetes/")); + assert!(!is_wiki_path("/wikipedia")); + assert!(!is_wiki_path("/app/wiki")); + } + + #[test] + fn missing_wiki_build_falls_back_to_site_index() { + let root = site(); + std::fs::remove_file(root.join("wiki/index.html")).unwrap(); + assert_eq!( + not_found_page(&root, "/wiki/x"), + Some(root.join("index.html")) + ); + } +} diff --git a/apps/server/src/worker.rs b/apps/server/src/worker.rs index 41b587c807..d969ed7237 100644 --- a/apps/server/src/worker.rs +++ b/apps/server/src/worker.rs @@ -15,7 +15,6 @@ use devforge_cluster::{ use devforge_deploy::{LocalShellExecutor, RemoteExecutor}; use serde::Deserialize; use serde_json::{json, Value}; -use tower_http::services::{ServeDir, ServeFile}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; @@ -454,17 +453,11 @@ async fn worker_update_start( }))) } -pub fn with_static_fallback(mut app: Router) -> Router { - if let Some(root) = crate::paths::web_dir() { - let index = root.join("index.html"); - if index.is_file() { - app = - app.fallback_service(ServeDir::new(&root).not_found_service(ServeFile::new(index))); - } else { - app = app.fallback_service(ServeDir::new(&root)); - } +pub fn with_static_fallback(app: Router) -> Router { + match crate::paths::web_dir() { + Some(root) => crate::static_web::with_static_dir(app, &root), + None => app, } - app } pub fn is_worker_role(local: &LocalClusterState) -> bool { diff --git a/apps/web/src/components/marketing/SiteFooter.tsx b/apps/web/src/components/marketing/SiteFooter.tsx index cd3a90d608..3ff46fa4e0 100644 --- a/apps/web/src/components/marketing/SiteFooter.tsx +++ b/apps/web/src/components/marketing/SiteFooter.tsx @@ -9,6 +9,9 @@ export function SiteFooter() { App + + Wiki + + + + + + ); +} + +/** Liste de navigation (barre latérale bureau et tiroir mobile). */ +function NavList({ nav, current, onNavigate }: { nav: WikiNavSection[]; current: string; onNavigate?: () => void }) { + return ( + + ); +} + +/** Tuiles d'accueil : une carte par section, une tuile par page. */ +function HomeTiles({ nav }: { nav: WikiNavSection[] }) { + return ( +
+ {nav.map((s) => { + const items = s.items.filter((i) => i.slug !== WIKI_HOME); + if (!items.length) return null; + return ( +
+

+ {s.title} +

+
+ {items.map((it) => ( + + + + + + {it.label} + + + + ))} +
+
+ ); + })} +
+ ); +} + +/** Recherche : index statique chargé à la première frappe (/wiki/search-index.json). */ +function useWikiSearch(query: string) { + const [index, setIndex] = useState(null); + const [failed, setFailed] = useState(false); + const loading = useRef(false); + useEffect(() => { + if (!query.trim() || index || loading.current) return; + loading.current = true; + fetch('/wiki/search-index.json', { credentials: 'omit' }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then((d: WikiSearchEntry[]) => setIndex(Array.isArray(d) ? d : [])) + .catch(() => setFailed(true)) + .finally(() => { + loading.current = false; + }); + }, [query, index]); + const hits: WikiSearchHit[] = useMemo(() => (index ? searchWiki(index, query) : []), [index, query]); + return { hits, ready: !!index, failed }; +} + +function SearchResults({ query, onPick }: { query: string; onPick: () => void }) { + const { hits, ready, failed } = useWikiSearch(query); + if (query.trim().length < 2) return null; + return ( +
+ {failed ? ( +

Recherche indisponible.

+ ) : !ready ? ( +

Chargement…

+ ) : hits.length === 0 ? ( +

Aucun résultat pour « {query.trim()} ».

+ ) : ( + + )} +
+ ); +} + +function SearchBox({ onPick }: { onPick?: () => void }) { + const [q, setQ] = useState(''); + return ( +
+ + { + setQ(''); + onPick?.(); + }} + /> +
+ ); +} + +export function WikiApp({ slug, title, markdown, nav, known, footer, embedded }: WikiAppProps) { + const knownSet = useMemo(() => new Set(known), [known]); + const [drawer, setDrawer] = useState(false); + const [signedIn, setSignedIn] = useState(false); + // Page inconnue : le serveur renvoie l'accueil du wiki ; on l'annonce au lieu d'une erreur. + const [missing, setMissing] = useState(null); + + useEffect(() => { + setSignedIn(!!getToken()); + if (slug !== WIKI_HOME) return; + const path = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); + const m = path.match(/^\/wiki\/([^/]+)$/); + if (m && m[1] !== WIKI_HOME && m[1] !== 'index.html') setMissing(m[1].slice(0, 80)); + }, [slug]); + + useEffect(() => { + if (!drawer) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setDrawer(false); + document.addEventListener('keydown', onKey); + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prev; + }; + }, [drawer]); + + const isHome = slug === WIKI_HOME; + const crumbs = !isHome ? nav.find((s) => s.items.some((i) => i.slug === slug))?.title : undefined; + + return ( +
+
+ +
+ + {drawer && ( + +
+ setDrawer(false)} /> +
+
+ setDrawer(false)} /> +
+
+ + )} + +
+ + +
+
+ +
+ {!embedded && ( +

+ Contenu minimal : la documentation n’a pas été embarquée dans cette version. +

+ )} + {missing && ( +
+

La page « {missing} » n’existe pas (encore).

+

+ Voici l’accueil du wiki : choisis une page ci-dessous ou utilise la recherche. +

+
+ )} +
+ {crumbs && ( +

+ + Wiki + {' '} + · {crumbs} +

+ )} + {!/^\s*#\s/.test(markdown) && ( +

{title}

+ )} + + {isHome && } +
+
+ {renderWikiInline(footer.replace(/\s+/g, ' ').trim(), { known: knownSet }, 'ft')} + Lecture seule · accessible sans connexion +
+
+
+ + ); +} diff --git a/apps/web/src/components/wiki/WikiMarkdown.test.tsx b/apps/web/src/components/wiki/WikiMarkdown.test.tsx new file mode 100644 index 0000000000..f4ab093ac7 --- /dev/null +++ b/apps/web/src/components/wiki/WikiMarkdown.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/preact'; +import { splitTableRow, WikiMarkdown } from './WikiMarkdown'; + +const known = new Set(['Home', 'Kubernetes', 'Premier-demarrage']); + +describe('WikiMarkdown (wiki public)', () => { + it('HTML brut affiché en texte, jamais interprété', () => { + (window as unknown as { __pwned?: number }).__pwned = undefined; + const { container } = render( + window.__pwned=1\n\n\n\n**gras**'} + />, + ); + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('b')).toBeNull(); + expect(container.textContent).toContain(''); + expect((window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + }); + + it('liens dangereux rendus en texte, liens wiki et externes sûrs', () => { + const { container } = render( + , + ); + const hrefs = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href')); + expect(hrefs).toEqual(['/wiki/Kubernetes/', 'https://github.com/bobdivx/devforge']); + expect(container.innerHTML).not.toContain('javascript:'); + expect(container.innerHTML).not.toContain('data:text'); + const ext = container.querySelector('a[href^="https://"]')!; + expect(ext.getAttribute('rel')).toContain('noopener'); + expect(container.textContent).toContain('Absente'); + }); + + it('titres avec ancres, listes imbriquées, code dans une liste, tableaux avec [[a|b]]', () => { + const md = [ + '# Titre', + '## Étape 1', + '1. Créer le secret :', + '', + ' ```bash', + ' kubectl get ns', + ' ```', + '', + '2. Déclarer :', + ' - sous-point', + '', + '| Tu veux | Page |', + '|---|---|', + '| Démarrer | [[Premier démarrage|Premier-demarrage]] |', + ].join('\n'); + const { container } = render(); + expect(container.querySelector('h1')!.textContent).toBe('Titre'); + expect(container.querySelector('h2')!.id).toBe('etape-1'); + const ol = container.querySelector('ol')!; + expect(ol.querySelectorAll(':scope > li')).toHaveLength(2); + expect(ol.querySelector('li ul li')!.textContent).toContain('sous-point'); + expect(ol.textContent).toContain('kubectl get ns'); + const cells = container.querySelectorAll('tbody td'); + expect(cells).toHaveLength(2); + expect(cells[1].querySelector('a')!.getAttribute('href')).toBe('/wiki/Premier-demarrage/'); + }); + + it('découpe de cellule : barre verticale protégée dans [[…]] et `code`', () => { + expect(splitTableRow('| a | [[L|P]] | `x|y` |')).toEqual(['a', '[[L|P]]', '`x|y`']); + }); +}); diff --git a/apps/web/src/components/wiki/WikiMarkdown.tsx b/apps/web/src/components/wiki/WikiMarkdown.tsx new file mode 100644 index 0000000000..0bade8a62c --- /dev/null +++ b/apps/web/src/components/wiki/WikiMarkdown.tsx @@ -0,0 +1,356 @@ +import type { ComponentChildren, VNode } from 'preact'; +import { CodeBlock } from '../chat/CodeBlock'; +import { headingId, parseWikiLink, resolveHref, wikiHref } from '../../lib/wiki'; + +/* + * Rendu Markdown du wiki public. Comme `lib/markdown.tsx` : aucun innerHTML, tout + * devient des nœuds Preact, donc le HTML brut des fichiers reste du texte. + * Liens : pages connues du wiki, ancres, http(s)/mailto ; le reste est affiché en texte. + * En plus du rendu chat : vrais titres h1–h4 avec ancres, listes imbriquées, + * blocs de code dans les listes, `[[Page]]` / `[[Libellé|Page]]` du GitHub Wiki. + */ + +export interface WikiRenderContext { + known: ReadonlySet; +} + +const LINK = 'text-[var(--color-accent)] underline decoration-[var(--color-accent)]/40 underline-offset-2 hover:decoration-[var(--color-accent)] [overflow-wrap:anywhere]'; +const INLINE_CODE = + 'rounded-md bg-white/[0.07] px-1.5 py-px font-mono text-[0.86em] text-[var(--color-ink)] [overflow-wrap:anywhere]'; + +function link(key: string, href: string, external: boolean, children: ComponentChildren): VNode { + return external ? ( + + {children} + + ) : ( + + {children} + + ); +} + +export function renderWikiInline(text: string, ctx: WikiRenderContext, keyBase = 'i'): ComponentChildren[] { + const out: ComponentChildren[] = []; + const re = + /(`+)([^`]+?)\1|\[\[([^\]\n]+)\]\]|!?\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|\*\*([^*\n]+?)\*\*|__([^_\n]+?)__|(^|[^\w*])\*(?=\S)([^*\n]+?)\*(?!\w)|(^|[^\w_])_(?=\S)([^_\n]+?)_(?!\w)|(https?:\/\/[^\s<>()`]+[^\s<>().,;:!?'"»`])/g; + let last = 0; + let m: RegExpExecArray | null; + let n = 0; + while ((m = re.exec(text))) { + if (m.index > last) out.push(text.slice(last, m.index)); + const k = `${keyBase}-${n++}`; + if (m[2] !== undefined) { + out.push( + + {m[2]} + , + ); + } else if (m[3] !== undefined) { + const wl = parseWikiLink(m[3]); + out.push( + ctx.known.has(wl.slug) + ? link(k, wikiHref(wl.slug, wl.anchor), false, wl.label) + : ( + + {wl.label} + + ), + ); + } else if (m[4] !== undefined) { + const r = resolveHref(m[5], ctx.known); + const label = renderWikiInline(m[4], ctx, k); + out.push(r.kind === 'none' ? {label} : link(k, r.href, r.kind === 'external', label)); + } else if (m[6] !== undefined || m[7] !== undefined) { + out.push( + + {renderWikiInline(m[6] ?? m[7], ctx, k)} + , + ); + } else if (m[9] !== undefined || m[11] !== undefined) { + const prefix = (m[9] !== undefined ? m[8] : m[10]) ?? ''; + if (prefix) out.push(prefix); + out.push({renderWikiInline((m[9] ?? m[11]) as string, ctx, k)}); + } else if (m[12] !== undefined) { + out.push(link(k, m[12], true, m[12])); + } + last = m.index + m[0].length; + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + +const FENCE = /^(\s*)(```+|~~~+)\s*([\w+.-]*)\s*$/; +const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/; +const HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; +const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; +const QUOTE = /^\s{0,3}>/; + +const indentOf = (l: string) => l.match(/^\s*/)![0].replace(/\t/g, ' ').length; + +const isTableSep = (l: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l); + +/** Découpe une ligne de tableau sans couper `[[Libellé|Page]]` ni le `code`. */ +export function splitTableRow(line: string): string[] { + const s = line.trim().replace(/^\|/, '').replace(/\|$/, ''); + const cells: string[] = []; + let cur = ''; + let depth = 0; + let inCode = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '`') inCode = !inCode; + else if (!inCode && c === '[' && s[i + 1] === '[') { + depth++; + cur += '[['; + i++; + continue; + } else if (!inCode && c === ']' && s[i + 1] === ']' && depth > 0) { + depth--; + cur += ']]'; + i++; + continue; + } else if (c === '\\' && s[i + 1] === '|') { + cur += '|'; + i++; + continue; + } else if (c === '|' && !inCode && depth === 0) { + cells.push(cur.trim()); + cur = ''; + continue; + } + cur += c; + } + cells.push(cur.trim()); + return cells; +} + +const HEADING_CLASS: Record = { + 1: 'mt-2 text-2xl font-semibold tracking-tight text-[var(--color-ink)] sm:text-3xl', + 2: 'mt-8 border-b border-[var(--color-line)] pb-2 text-xl font-semibold tracking-tight text-[var(--color-ink)]', + 3: 'mt-6 text-lg font-semibold text-[var(--color-ink)]', + 4: 'mt-5 text-base font-semibold text-[var(--color-ink)]', +}; + +function isBlockStart(line: string, next: string | undefined): boolean { + return ( + FENCE.test(line) || + HEADING.test(line) || + HR.test(line) || + QUOTE.test(line) || + LIST_ITEM.test(line) || + (line.includes('|') && next !== undefined && isTableSep(next)) + ); +} + +function renderBlocks( + lines: string[], + ctx: WikiRenderContext, + keyBase: string, + ids: Map, + tight = false, +): VNode[] { + const blocks: VNode[] = []; + let i = 0; + let b = 0; + while (i < lines.length) { + const line = lines[i]; + const key = `${keyBase}b${b++}`; + const fence = line.match(FENCE); + if (fence) { + const indent = fence[1].length; + const marker = fence[2]; + const body: string[] = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith(marker)) { + const l = lines[i++]; + const cut = Math.min(indent, indentOf(l)); + body.push(l.slice(cut)); + } + i++; + blocks.push( +
+ +
, + ); + continue; + } + if (!line.trim()) { + i++; + continue; + } + const h = line.match(HEADING); + if (h) { + const level = Math.min(h[1].length, 4); + const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4'; + let id = headingId(h[2]) || 'section'; + const seen = ids.get(id) ?? 0; + ids.set(id, seen + 1); + if (seen) id = `${id}-${seen}`; + blocks.push( + + {renderWikiInline(h[2], ctx, key)} + , + ); + i++; + continue; + } + if (HR.test(line)) { + blocks.push(
); + i++; + continue; + } + if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) { + const head = splitTableRow(line); + i += 2; + const rows: string[][] = []; + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(splitTableRow(lines[i++])); + blocks.push( +
+ + + + {head.map((c, j) => ( + + ))} + + + + {rows.map((r, ri) => ( + + {r.map((c, j) => ( + + ))} + + ))} + +
+ {renderWikiInline(c, ctx, `${key}-h${j}`)} +
+ {renderWikiInline(c, ctx, `${key}-${ri}-${j}`)} +
+
, + ); + continue; + } + if (QUOTE.test(line)) { + const q: string[] = []; + while (i < lines.length && QUOTE.test(lines[i])) q.push(lines[i++].replace(/^\s{0,3}>\s?/, '')); + blocks.push( +
+ {renderBlocks(q, ctx, key, ids, true)} +
, + ); + continue; + } + const li = line.match(LIST_ITEM); + if (li) { + const base = indentOf(line); + const ordered = /\d/.test(li[2]); + const start = ordered ? parseInt(li[2], 10) : 1; + const items: string[][] = []; + let looseGap = false; + while (i < lines.length) { + const l = lines[i]; + const mm = l.match(LIST_ITEM); + if (mm && indentOf(l) === base && /\d/.test(mm[2]) === ordered) { + const contentIndent = base + mm[2].length + 1; + const item = [mm[3]]; + i++; + while (i < lines.length) { + const c = lines[i]; + if (!c.trim()) { + // Ligne vide : l'item continue seulement si la suite est indentée. + let j = i; + while (j < lines.length && !lines[j].trim()) j++; + if (j < lines.length && indentOf(lines[j]) > base) { + looseGap = true; + for (; i < j; i++) item.push(''); + continue; + } + break; + } + if (indentOf(c) > base) { + item.push(c.slice(Math.min(contentIndent, indentOf(c)))); + i++; + continue; + } + // Continuation paresseuse d'un paragraphe. + if (!isBlockStart(c, lines[i + 1]) && item[item.length - 1]?.trim()) { + item.push(c.trim()); + i++; + continue; + } + break; + } + items.push(item); + continue; + } + if (!l.trim()) { + let j = i; + while (j < lines.length && !lines[j].trim()) j++; + const nx = j < lines.length ? lines[j].match(LIST_ITEM) : null; + if (nx && indentOf(lines[j]) === base && /\d/.test(nx[2]) === ordered) { + looseGap = true; + i = j; + continue; + } + } + break; + } + const cls = ordered ? 'my-3 list-decimal space-y-1.5 pl-6' : 'my-3 list-disc space-y-1.5 pl-6'; + const children = items.map((it, j) => ( +
  • + {renderBlocks(it, ctx, `${key}-${j}`, ids, !looseGap)} +
  • + )); + blocks.push( + ordered ? ( +
      + {children} +
    + ) : ( +
      + {children} +
    + ), + ); + continue; + } + const para: string[] = []; + while (i < lines.length && lines[i].trim() && (para.length === 0 || !isBlockStart(lines[i], lines[i + 1]))) { + para.push(lines[i++].trim()); + } + const inline: ComponentChildren[] = []; + para.forEach((l, j) => { + // Deux espaces finaux ou `\` : retour à la ligne forcé (Markdown). + const hard = /( {2,}|\\)$/.test(lines[i - para.length + j] ?? ''); + inline.push(...renderWikiInline(l.replace(/\\$/, ''), ctx, `${key}-${j}`)); + if (j < para.length - 1) inline.push(hard ?
    : ' '); + }); + blocks.push( + tight ? ( +
    + {inline} +
    + ) : ( +

    + {inline} +

    + ), + ); + } + return blocks; +} + +export function WikiMarkdown({ text, known }: { text: string; known: ReadonlySet }) { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + return ( +
    + {renderBlocks(lines, { known }, 'w', new Map())} +
    + ); +} diff --git a/apps/web/src/lib/wiki-source.ts b/apps/web/src/lib/wiki-source.ts new file mode 100644 index 0000000000..3fc45a5d41 --- /dev/null +++ b/apps/web/src/lib/wiki-source.ts @@ -0,0 +1,88 @@ +/* + * Lecture du dossier `wiki/` du dépôt — **au build uniquement** (frontmatter Astro, + * getStaticPaths). Rien de ce module n'est livré au navigateur. + * + * Emplacements essayés : DEVFORGE_WIKI_DIR, puis `../../wiki` depuis apps/web + * (dépôt) ou depuis /web (image Docker : `COPY wiki/ /wiki/`). Dossier absent → + * accueil minimal (`ensureHome`), jamais d'échec de build. + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + completeNav, + ensureHome, + isValidSlug, + pageTitle, + parseSidebar, + WIKI_REPO_URL, + type WikiData, + type WikiPage, +} from './wiki'; + +const MAX_BYTES = 512 * 1024; + +function wikiDir(): string | null { + const candidates = [ + process.env.DEVFORGE_WIKI_DIR, + resolve(process.cwd(), '../../wiki'), + resolve(process.cwd(), '../wiki'), + ].filter((p): p is string => !!p); + for (const dir of candidates) { + try { + if (existsSync(join(dir, 'Home.md')) || (existsSync(dir) && statSync(dir).isDirectory() && hasMd(dir))) { + return dir; + } + } catch { + /* suivant */ + } + } + return null; +} + +function hasMd(dir: string): boolean { + return readdirSync(dir).some((f: string) => f.endsWith('.md')); +} + +function readSmall(path: string): string | null { + try { + const st = statSync(path); + if (!st.isFile() || st.size > MAX_BYTES) return null; + return readFileSync(path, 'utf8'); + } catch { + return null; + } +} + +let cache: WikiData | null = null; + +export function loadWiki(): WikiData { + if (cache) return cache; + const dir = wikiDir(); + const pages: WikiPage[] = []; + let sidebar = ''; + let footer = ''; + if (dir) { + for (const file of readdirSync(dir).sort()) { + if (!file.endsWith('.md')) continue; + const slug = file.slice(0, -3); + if (slug === '_Sidebar') sidebar = readSmall(join(dir, file)) ?? ''; + else if (slug === '_Footer') footer = readSmall(join(dir, file)) ?? ''; + // README : note interne au dossier (synchro GitHub Wiki), pas une page. + if (slug === 'README' || !isValidSlug(slug)) continue; + const markdown = readSmall(join(dir, file)); + if (markdown === null) continue; + pages.push({ slug, title: pageTitle(markdown, slug), markdown }); + } + } + const embedded = pages.length > 0; + const all = ensureHome(pages); + const known = new Set(all.map((p) => p.slug)); + const nav = completeNav(parseSidebar(sidebar, known), all); + cache = { + pages: all, + nav, + footer: footer.trim() || `[DevForge](${WIKI_REPO_URL})`, + embedded, + }; + return cache; +} diff --git a/apps/web/src/lib/wiki.test.ts b/apps/web/src/lib/wiki.test.ts new file mode 100644 index 0000000000..77e51c8583 --- /dev/null +++ b/apps/web/src/lib/wiki.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSearchIndex, + completeNav, + ensureHome, + headingId, + isValidSlug, + parseSidebar, + parseWikiLink, + resolveHref, + searchWiki, + WIKI_HOME, + type WikiPage, +} from './wiki'; +import { loadWiki } from './wiki-source'; + +const known = new Set(['Home', 'Kubernetes', 'Premier-demarrage', 'Cluster']); + +describe('wiki : slugs et liens', () => { + it('liste blanche de slugs : pas de chemin, de point ni de fichier spécial', () => { + expect(isValidSlug('Kubernetes')).toBe(true); + expect(isValidSlug('SSO-et-OIDC')).toBe(true); + for (const bad of ['', '..', '../etc', 'a/b', 'a\\b', '_Sidebar', 'Home.md', '%2e%2e', 'a b', '-x']) { + expect(isValidSlug(bad)).toBe(false); + } + }); + + it('[[Libellé|Page]] et [[Page#ancre]]', () => { + expect(parseWikiLink('Premier démarrage|Premier-demarrage')).toEqual({ + label: 'Premier démarrage', + slug: 'Premier-demarrage', + anchor: undefined, + }); + expect(parseWikiLink('Kubernetes#Choix par défaut')).toEqual({ + label: 'Kubernetes', + slug: 'Kubernetes', + anchor: 'choix-par-defaut', + }); + }); + + it('liens : pages connues, ancres, http(s) ; schémas dangereux refusés', () => { + expect(resolveHref('Kubernetes.md', known)).toEqual({ kind: 'internal', href: '/wiki/Kubernetes/' }); + expect(resolveHref('./Cluster#Bascule', known)).toEqual({ kind: 'internal', href: '/wiki/Cluster/#bascule' }); + expect(resolveHref('#Étape-1', known)).toEqual({ kind: 'internal', href: '#etape-1' }); + expect(resolveHref('https://github.com/bobdivx/devforge', known).kind).toBe('external'); + expect(resolveHref('../deploy/usb/README.md', known)).toEqual({ + kind: 'external', + href: 'https://github.com/bobdivx/devforge/blob/main/deploy/usb/README.md', + }); + for (const bad of [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:text/html,