feat(oki): fondations de la migration vers la stack souveraine OKI
Architecture décidée et documentée dans ADR.md : SvelteKit natif en fichiers plats plutôt que Strapi (55 outils, données statiques, souveraineté et budget performance prioritaires — doctrine §1.2, §4.4). - frontend/ : SvelteKit 2 + Svelte 5 (runes), adapter-static, 58 pages prérendues, CSS natif en @layer (reset→tokens→base→layouts→components), tokens oklch(), container queries, typo fluide clamp(), 2 polices WOFF2 auto-hébergées (Atkinson Hyperlegible, Fraunces), zéro CDN tiers - i18n FR/gcf typé (clé manquante = erreur de build), bascule Kréyòl persistée en localStorage, micro-textes créoles (« Ou pa manké ayen ») - Composants : ToolCard, DifficultyBadge (forme+texte+couleur, jamais la couleur seule), CategoryNav (≤5 entrées, divulgation progressive), SearchBar (raccourci /), LanguageSwitcher, EndMarker - content/ : 55 fiches migrées depuis src/ (script frontend/scripts/ migrate-tools.mjs, idempotent), licences SPDX reprises de directory.json - docs/ : DESIGN_SYSTEM.md et CONTENT_GUIDE.md Budgets vérifiés : JS 41 Ko gzip, CSS 2,9 Ko gzip, svelte-check 0 erreur. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# exitchatcontrol × OKI — frontend
|
||||
|
||||
Annuaire souverain d'outils libres. SvelteKit 2 + Svelte 5, site 100 %
|
||||
prérendu, zéro CDN tiers, zéro tracker. Voir [`ADR.md`](../ADR.md) pour la
|
||||
décision d'architecture et `../docs/` pour le design system et le guide
|
||||
de contenu.
|
||||
|
||||
## Prérequis
|
||||
|
||||
Node ≥ 22.12, pnpm.
|
||||
|
||||
## Commandes
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm migrate # (re)génère content/ depuis l'ancien site Astro (src/)
|
||||
pnpm dev # serveur de développement
|
||||
pnpm build # build statique dans build/ (prérend les 58 pages)
|
||||
pnpm preview # sert le build localement
|
||||
pnpm check # svelte-check (types + a11y)
|
||||
```
|
||||
|
||||
## Déploiement
|
||||
|
||||
`build/` est un site statique : n'importe quel serveur de fichiers convient
|
||||
(nginx du `docker/` existant, YunoHost `my_webapp`, etc.). Aucune dépendance
|
||||
cloud, aucun service à maintenir — un miroir = une copie du dossier.
|
||||
|
||||
## Arborescence
|
||||
|
||||
```
|
||||
src/styles/ tokens, reset, base, layouts (cascade @layer)
|
||||
src/lib/i18n/ fr.json + gcf.json typés, store de langue (runes)
|
||||
src/lib/components/ ToolCard, DifficultyBadge, CategoryNav, SearchBar,
|
||||
LanguageSwitcher, EndMarker
|
||||
src/lib/content/ chargeur build-time des fichiers ../content/
|
||||
src/routes/ / (landing), /outils (annuaire filtrable), /outils/[slug]
|
||||
static/fonts/ 2 WOFF2 auto-hébergés (Atkinson Hyperlegible, Fraunces)
|
||||
scripts/ migrate-tools.mjs
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "exitchatcontrol-oki",
|
||||
"private": true,
|
||||
"version": "4.0.0-alpha.1",
|
||||
"type": "module",
|
||||
"description": "Exit Chat Control × OKI — annuaire souverain d'outils libres. SvelteKit statique, zéro tracker, zéro CDN.",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"migrate": "node scripts/migrate-tools.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/atkinson-hyperlegible": "^5.2.8",
|
||||
"@fontsource/fraunces": "^5.2.9",
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"marked": "^16.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1076
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Migration des données du site Astro vers les fichiers plats OKI (ADR-001,
|
||||
* mission §7). Extrait les 55 ToolCard des MDX français + métadonnées de
|
||||
* src/data/{tools,directory}.json et produit :
|
||||
* content/outils/<slug>.md (frontmatter JSON-par-ligne + sections HTML)
|
||||
* content/categories.json (catégories dérivées des sections sources)
|
||||
*
|
||||
* Idempotent : relançable, écrase la sortie. Usage : pnpm migrate
|
||||
*/
|
||||
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
const OUT = path.join(ROOT, 'content')
|
||||
|
||||
const DIFFICULTY = { 1: 'beginner', 2: 'intermediate', 3: 'advanced' }
|
||||
|
||||
const tools = JSON.parse(await readFile(path.join(SRC, 'data', 'tools.json'), 'utf8'))
|
||||
const directory = JSON.parse(await readFile(path.join(SRC, 'data', 'directory.json'), 'utf8'))
|
||||
const toolById = new Map(tools.map((t) => [t.id, t]))
|
||||
const dirById = new Map(directory.map((d) => [d.id, d]))
|
||||
|
||||
function parseFrontmatter(raw) {
|
||||
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
|
||||
if (!m) return [{}, raw]
|
||||
const meta = {}
|
||||
for (const line of m[1].split('\n')) {
|
||||
const i = line.indexOf(':')
|
||||
if (i === -1) continue
|
||||
const key = line.slice(0, i).trim()
|
||||
let value = line.slice(i + 1).trim()
|
||||
if (/^'.*'$/.test(value)) value = value.slice(1, -1)
|
||||
meta[key] = /^\d+$/.test(value) ? Number(value) : value
|
||||
}
|
||||
return [meta, raw.slice(m[0].length)]
|
||||
}
|
||||
|
||||
/** Extrait le contenu d'un slot (Fragment/ol/ul) dans un bloc ToolCard. */
|
||||
function slot(block, name) {
|
||||
const re = new RegExp(
|
||||
`<(Fragment|ol|ul)([^>]*slot="${name}"[^>]*)>([\\s\\S]*?)</\\1>`
|
||||
)
|
||||
const m = block.match(re)
|
||||
if (!m) return null
|
||||
// Les listes gardent leur balise (le HTML passe tel quel dans le markdown) ;
|
||||
// les Fragments sont déballés.
|
||||
return m[1] === 'Fragment' ? m[3].trim() : `<${m[1]}>${m[3]}</${m[1]}>`.trim()
|
||||
}
|
||||
|
||||
/** Tags = textes des <span class="tool-tag"> hors difficulté (déjà structurée). */
|
||||
function extractTags(block) {
|
||||
const html = slot(block, 'tags') ?? ''
|
||||
const tags = [...html.matchAll(/<span class="tool-tag">([\s\S]*?)<\/span>/g)]
|
||||
.map((m) => m[1].replace(/\s+/g, ' ').trim())
|
||||
.filter((t) => !/^[🟢🟡🔴]/u.test(t))
|
||||
return tags
|
||||
}
|
||||
|
||||
/** Difficulté : `level` de tools.json, sinon l'emoji du tag MDX (🟢/🟡/🔴). */
|
||||
function difficulty(tool, block) {
|
||||
if (DIFFICULTY[tool.level]) return DIFFICULTY[tool.level]
|
||||
const html = slot(block, 'tags') ?? ''
|
||||
if (html.includes('🟢')) return 'beginner'
|
||||
if (html.includes('🟡')) return 'intermediate'
|
||||
if (html.includes('🔴')) return 'advanced'
|
||||
console.warn(`⚠ ${tool.id} : difficulté introuvable — « intermediate » par défaut`)
|
||||
return 'intermediate'
|
||||
}
|
||||
|
||||
const fm = (key, value) => (value == null ? null : `${key}: ${JSON.stringify(value)}`)
|
||||
|
||||
const sectionsDir = path.join(SRC, 'content', 'sections', 'fr')
|
||||
const files = (await readdir(sectionsDir)).filter((f) => f.endsWith('.mdx')).sort()
|
||||
|
||||
await mkdir(path.join(OUT, 'outils'), { recursive: true })
|
||||
|
||||
const categories = []
|
||||
const seen = new Set()
|
||||
let written = 0
|
||||
|
||||
for (const file of files) {
|
||||
const raw = await readFile(path.join(sectionsDir, file), 'utf8')
|
||||
const [meta, body] = parseFrontmatter(raw)
|
||||
const blocks = [...body.matchAll(/<ToolCard tool="([^"]+)"[^>]*>([\s\S]*?)<\/ToolCard>/g)]
|
||||
if (blocks.length === 0) continue
|
||||
|
||||
categories.push({
|
||||
slug: meta.id,
|
||||
title: meta.title,
|
||||
order: meta.order,
|
||||
part: meta.part
|
||||
})
|
||||
|
||||
for (const [, toolId, block] of blocks) {
|
||||
const tool = toolById.get(toolId)
|
||||
if (!tool) {
|
||||
console.warn(`⚠ ${file} : ToolCard "${toolId}" absent de tools.json — ignoré`)
|
||||
continue
|
||||
}
|
||||
const slug = toolId.replace(/^t-/, '')
|
||||
if (seen.has(slug)) {
|
||||
console.warn(`⚠ ${slug} : doublon (déjà émis) — ignoré dans ${file}`)
|
||||
continue
|
||||
}
|
||||
seen.add(slug)
|
||||
const dir = dirById.get(slug)
|
||||
const nameOverride = slot(block, 'name')
|
||||
|
||||
const front = [
|
||||
fm('name', nameOverride ?? tool.name),
|
||||
fm('slug', slug),
|
||||
fm('category', meta.id),
|
||||
fm('difficulty', difficulty(tool, block)),
|
||||
fm('license', dir?.license),
|
||||
fm('url', tool.url),
|
||||
fm('color', tool.color),
|
||||
fm('monogram', tool.monogram),
|
||||
fm('iconSlug', tool.iconSlug),
|
||||
fm('tags', extractTags(block)),
|
||||
fm('lang', 'fr')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const sections = [
|
||||
['what', slot(block, 'what')],
|
||||
['why', slot(block, 'why')],
|
||||
['who', slot(block, 'who')],
|
||||
['install', slot(block, 'install')],
|
||||
['extra', slot(block, 'extra')]
|
||||
]
|
||||
.filter(([, html]) => html)
|
||||
.map(([key, html]) => `## ${key}\n\n${html}`)
|
||||
.join('\n\n')
|
||||
|
||||
await writeFile(path.join(OUT, 'outils', `${slug}.md`), `---\n${front}\n---\n\n${sections}\n`)
|
||||
written++
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(path.join(OUT, 'categories.json'), JSON.stringify(categories, null, 2) + '\n')
|
||||
console.log(`✔ ${written} fiches écrites dans content/outils/, ${categories.length} catégories`)
|
||||
|
||||
const missing = tools.filter((t) => !seen.has(t.id.replace(/^t-/, '')))
|
||||
if (missing.length) {
|
||||
console.warn(`⚠ outils de tools.json sans ToolCard fr : ${missing.map((t) => t.id).join(', ')}`)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* app.css — ordre de cascade explicite et auditable (doctrine §5.2).
|
||||
* Chaque couche est déclarée AVANT tout import : la position dans la
|
||||
* cascade est ici, pas dans l'ordre des fichiers.
|
||||
*/
|
||||
@layer reset, tokens, base, layouts, components, utilities;
|
||||
|
||||
/* @font-face n'est pas concerné par les couches */
|
||||
@import './styles/fonts.css';
|
||||
|
||||
@import './styles/reset.css' layer(reset);
|
||||
@import './styles/tokens.css' layer(tokens);
|
||||
@import './styles/base.css' layer(base);
|
||||
@import './styles/layouts.css' layer(layouts);
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#17181f" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import { page } from '$app/state'
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
interface NavCategory {
|
||||
slug: string
|
||||
title: string
|
||||
}
|
||||
|
||||
let { categories }: { categories: NavCategory[] } = $props()
|
||||
|
||||
// Hick-Hyman (doctrine §1.3) : ≤ 5 entrées visibles, le reste en
|
||||
// divulgation progressive (<details>, fonctionne sans JS).
|
||||
const VISIBLE = 4
|
||||
let visible = $derived(categories.slice(0, VISIBLE))
|
||||
let rest = $derived(categories.slice(VISIBLE))
|
||||
// searchParams non lisibles au prerender : garde `browser`
|
||||
let current = $derived(browser ? page.url.searchParams.get('cat') : null)
|
||||
|
||||
function href(slug: string | null): string {
|
||||
return slug ? `/outils?cat=${slug}` : '/outils'
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav aria-label={t('directory.filterCategory')} class="catnav">
|
||||
<ul class="cluster">
|
||||
<li><a href={href(null)} aria-current={current === null ? 'page' : undefined}>{t('directory.allCategories')}</a></li>
|
||||
{#each visible as cat (cat.slug)}
|
||||
<li>
|
||||
<a href={href(cat.slug)} aria-current={current === cat.slug ? 'page' : undefined}>{cat.title}</a>
|
||||
</li>
|
||||
{/each}
|
||||
{#if rest.length > 0}
|
||||
<li>
|
||||
<details>
|
||||
<summary>+ {rest.length}</summary>
|
||||
<ul class="stack">
|
||||
{#each rest as cat (cat.slug)}
|
||||
<li>
|
||||
<a href={href(cat.slug)} aria-current={current === cat.slug ? 'page' : undefined}>{cat.title}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
text-decoration: none;
|
||||
color: var(--oki-ink);
|
||||
}
|
||||
|
||||
a[aria-current='page'] {
|
||||
background: var(--oki-jaune);
|
||||
border-color: var(--oki-jaune);
|
||||
color: var(--oki-noir);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
details {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px dashed var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details ul {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-inline-start: 0;
|
||||
margin-block-start: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--oki-surface);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
min-inline-size: 16rem;
|
||||
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.15);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
// Jamais la couleur seule : forme + texte + couleur (doctrine §1.1 —
|
||||
// ~8 % de déficience de vision des couleurs).
|
||||
const SHAPES = { beginner: '●', intermediate: '◆', advanced: '▲' } as const
|
||||
|
||||
let { difficulty }: { difficulty: keyof typeof SHAPES } = $props()
|
||||
</script>
|
||||
|
||||
<span class="badge badge-{difficulty}">
|
||||
<span aria-hidden="true">{SHAPES[difficulty]}</span>
|
||||
{t(`difficulty.${difficulty}`)}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-0);
|
||||
font-weight: 600;
|
||||
padding: 0.15em 0.6em;
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.badge-beginner {
|
||||
color: var(--diff-beginner-ink);
|
||||
}
|
||||
.badge-intermediate {
|
||||
color: var(--diff-intermediate-ink);
|
||||
}
|
||||
.badge-advanced {
|
||||
color: var(--diff-advanced-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
</script>
|
||||
|
||||
<!-- Marqueur de fin explicite : restitue le point d'arrêt naturel que le
|
||||
scroll infini supprime (doctrine §1.5 et §3.2). -->
|
||||
<p class="end" role="status">
|
||||
<strong>{t('directory.endOfList')}</strong>
|
||||
<span>{t('directory.endOfListSub')}</span>
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.end {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
text-align: center;
|
||||
padding-block: var(--space-5);
|
||||
border-top: 4px double var(--oki-line);
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--oki-ink-2);
|
||||
font-size: var(--text-0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { getLocale, setLocale, locales, localeNames, t } from '$lib/i18n/index.svelte'
|
||||
</script>
|
||||
|
||||
<!-- Bascule explicite FR / Kréyòl, persistée en localStorage (mission §4.3).
|
||||
Amélioration progressive : sans JS, le site reste lisible en français. -->
|
||||
<div class="switch" role="group" aria-label={t('lang.switchLabel')}>
|
||||
{#each locales as locale (locale)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={getLocale() === locale}
|
||||
onclick={() => setLocale(locale)}
|
||||
>
|
||||
{localeNames[locale]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-height: var(--tap-target);
|
||||
font-size: var(--text-0);
|
||||
}
|
||||
|
||||
button[aria-pressed='true'] {
|
||||
background: var(--oki-jaune);
|
||||
color: var(--oki-noir);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
let { value = $bindable('') }: { value?: string } = $props()
|
||||
let input: HTMLInputElement
|
||||
|
||||
// raccourci « / » desktop (doctrine §2.2 — le clavier est roi)
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === '/' && document.activeElement !== input) {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input, textarea, [contenteditable]')) return
|
||||
e.preventDefault()
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<p class="search">
|
||||
<label>
|
||||
<span class="visually-hidden">{t('search.label')}</span>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<style>
|
||||
input {
|
||||
inline-size: 100%;
|
||||
max-inline-size: 28rem;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--oki-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import DifficultyBadge from './DifficultyBadge.svelte'
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
interface CardTool {
|
||||
name: string
|
||||
slug: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
license?: string
|
||||
color?: string
|
||||
monogram?: string
|
||||
tags: string[]
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
let { tool }: { tool: CardTool } = $props()
|
||||
</script>
|
||||
|
||||
<article class="card">
|
||||
<header class="cluster">
|
||||
<span class="monogram" style:--tool-color={tool.color} aria-hidden="true">
|
||||
{tool.monogram ?? tool.name.slice(0, 2)}
|
||||
</span>
|
||||
<h3><a href="/outils/{tool.slug}">{tool.name}</a></h3>
|
||||
</header>
|
||||
|
||||
<p class="excerpt"><span class="visually-hidden">{t('tool.why')} : </span>{tool.excerpt}</p>
|
||||
|
||||
<footer class="cluster">
|
||||
<DifficultyBadge difficulty={tool.difficulty} />
|
||||
{#if tool.license}
|
||||
<span class="meta">{tool.license}</span>
|
||||
{/if}
|
||||
{#each tool.tags as tag (tag)}
|
||||
<span class="meta">{tag}</span>
|
||||
{/each}
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-left: 4px solid var(--oki-jaune);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--oki-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monogram {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: 2.5rem;
|
||||
block-size: 2.5rem;
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--tool-color, var(--oki-surface-2));
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-0);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
/* toute la carte est cliquable via le pseudo-élément du lien —
|
||||
un vrai <a>, pas de div cliquable (doctrine §4.3) */
|
||||
h3 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
h3 a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
h3 a:focus-visible::after {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: -3px;
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
color: var(--oki-ink-2);
|
||||
font-size: var(--text-0);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--text-0);
|
||||
color: var(--oki-ink-2);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 0.1em 0.6em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Chargeur de contenu — s'exécute uniquement au build (site prérendu,
|
||||
* ADR-001) : ni fs ni marked ne sont expédiés au client.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { marked } from 'marked'
|
||||
|
||||
export type Difficulty = 'beginner' | 'intermediate' | 'advanced'
|
||||
|
||||
export interface ToolSections {
|
||||
what?: string
|
||||
why?: string
|
||||
who?: string
|
||||
install?: string
|
||||
extra?: string
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
name: string
|
||||
slug: string
|
||||
category: string
|
||||
difficulty: Difficulty
|
||||
license?: string
|
||||
url: string
|
||||
color?: string
|
||||
monogram?: string
|
||||
iconSlug?: string
|
||||
tags: string[]
|
||||
/** extrait texte brut de « why », pour les cartes */
|
||||
excerpt: string
|
||||
sections: ToolSections
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
slug: string
|
||||
title: string
|
||||
order: number
|
||||
part: number
|
||||
}
|
||||
|
||||
const CONTENT = path.resolve(process.cwd(), '..', 'content')
|
||||
const SECTION_KEYS = ['what', 'why', 'who', 'install', 'extra'] as const
|
||||
|
||||
function parseFrontmatter(raw: string): [Record<string, unknown>, string] {
|
||||
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
|
||||
if (!m) throw new Error('frontmatter manquant')
|
||||
const meta: Record<string, unknown> = {}
|
||||
for (const line of m[1].split('\n')) {
|
||||
const i = line.indexOf(':')
|
||||
if (i === -1) continue
|
||||
// les valeurs sont encodées en JSON par le script de migration
|
||||
meta[line.slice(0, i).trim()] = JSON.parse(line.slice(i + 1).trim())
|
||||
}
|
||||
return [meta, raw.slice(m[0].length)]
|
||||
}
|
||||
|
||||
function parseSections(body: string): ToolSections {
|
||||
const sections: ToolSections = {}
|
||||
const parts = body.split(/^## (\w+)\s*$/m)
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const key = parts[i] as (typeof SECTION_KEYS)[number]
|
||||
if (SECTION_KEYS.includes(key)) {
|
||||
sections[key] = marked.parse(parts[i + 1].trim(), { async: false })
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' '
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (e) => ENTITIES[e])
|
||||
.replace(/[ \t\r\n]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function loadTool(file: string): Tool {
|
||||
const raw = readFileSync(path.join(CONTENT, 'outils', file), 'utf8')
|
||||
const [meta, body] = parseFrontmatter(raw)
|
||||
const sections = parseSections(body)
|
||||
const why = stripHtml(sections.why ?? sections.what ?? '')
|
||||
return {
|
||||
...(meta as unknown as Omit<Tool, 'sections' | 'excerpt'>),
|
||||
excerpt: why.length > 160 ? `${why.slice(0, 157).trimEnd()}…` : why,
|
||||
sections
|
||||
}
|
||||
}
|
||||
|
||||
let cache: { tools: Tool[]; categories: Category[] } | null = null
|
||||
|
||||
export function loadContent() {
|
||||
if (cache) return cache
|
||||
const categories = (
|
||||
JSON.parse(readFileSync(path.join(CONTENT, 'categories.json'), 'utf8')) as Category[]
|
||||
).sort((a, b) => a.order - b.order)
|
||||
const order = new Map(categories.map((c, i) => [c.slug, i]))
|
||||
const tools = readdirSync(path.join(CONTENT, 'outils'))
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map(loadTool)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(order.get(a.category) ?? 99) - (order.get(b.category) ?? 99) ||
|
||||
a.name.localeCompare(b.name, 'fr')
|
||||
)
|
||||
cache = { tools, categories }
|
||||
return cache
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"site": {
|
||||
"name": "Exit Chat Control",
|
||||
"tagline": "Reprends le contrôle de ton internet",
|
||||
"description": "Annuaire d'outils libres pour la souveraineté numérique — messagerie chiffrée, VPN, auto-hébergement, OS libres."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"directory": "Annuaire",
|
||||
"contribute": "Contribuer",
|
||||
"skipToContent": "Aller au contenu"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Chwazi zouti ou",
|
||||
"subtitle": "Reprends le contrôle de ton internet : des outils libres, expliqués simplement, du premier geste au serveur auto-hébergé.",
|
||||
"cta": "Voir l'annuaire"
|
||||
},
|
||||
"directory": {
|
||||
"title": "Annuaire des outils",
|
||||
"intro": "Chaque outil est libre, vérifié, et classé par difficulté. Commence par le vert.",
|
||||
"filterCategory": "Catégorie",
|
||||
"filterDifficulty": "Difficulté",
|
||||
"allCategories": "Toutes les catégories",
|
||||
"allDifficulties": "Toutes",
|
||||
"results": "outils",
|
||||
"resultOne": "outil",
|
||||
"empty": "Ayen pa maché èvè filtè-lasa — essaie d'élargir les filtres.",
|
||||
"endOfList": "Ou pa manké ayen",
|
||||
"endOfListSub": "Tu es au bout de la liste — rien ne se recharge dans ton dos.",
|
||||
"prevPage": "Page précédente",
|
||||
"nextPage": "Page suivante",
|
||||
"page": "Page"
|
||||
},
|
||||
"tool": {
|
||||
"what": "À quoi ça sert",
|
||||
"why": "Pourquoi c'est important",
|
||||
"who": "Pour qui, quand",
|
||||
"install": "Installer et utiliser",
|
||||
"officialSite": "Site officiel",
|
||||
"sourceCode": "Code source",
|
||||
"license": "Licence",
|
||||
"category": "Catégorie",
|
||||
"backToDirectory": "Retour à l'annuaire"
|
||||
},
|
||||
"difficulty": {
|
||||
"beginner": "Débutant",
|
||||
"intermediate": "Intermédiaire",
|
||||
"advanced": "Avancé"
|
||||
},
|
||||
"search": {
|
||||
"label": "Rechercher un outil",
|
||||
"placeholder": "Rechercher… (touche /)",
|
||||
"noResults": "Aucun outil trouvé"
|
||||
},
|
||||
"lang": {
|
||||
"switchLabel": "Langue",
|
||||
"switchTo": "Passer en"
|
||||
},
|
||||
"footer": {
|
||||
"license": "Contenu et code libres (MIT). Zéro tracker, zéro CDN, zéro cookie.",
|
||||
"contribute": "Proposer un outil ou une correction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"site": {
|
||||
"name": "Exit Chat Control",
|
||||
"tagline": "Pran kontwòl entènèt a-w",
|
||||
"description": "Lis a zouti lib pou libèté niméwik — mésajri chifré, VPN, oto-ébèjman, sistèm lib."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Lakou",
|
||||
"directory": "Lis a zouti",
|
||||
"contribute": "Pòté mannèv",
|
||||
"skipToContent": "Alé dirèk asi kontni-la"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Chwazi zouti ou",
|
||||
"subtitle": "Pran kontwòl entènèt a-w : zouti lib, èspliké senp, dépi prèmyé jès jis sèwvè a-w.",
|
||||
"cta": "Gadé lis-la"
|
||||
},
|
||||
"directory": {
|
||||
"title": "Lis a zouti",
|
||||
"intro": "Chak zouti lib, vérifyé, é rangé pa difikilté. Koumansé èvè vèw-la.",
|
||||
"filterCategory": "Katégori",
|
||||
"filterDifficulty": "Difikilté",
|
||||
"allCategories": "Tout katégori",
|
||||
"allDifficulties": "Tout",
|
||||
"results": "zouti",
|
||||
"resultOne": "zouti",
|
||||
"empty": "Ayen pa maché èvè filtè-lasa — èséyé louvri filtè a-w.",
|
||||
"endOfList": "Ou pa manké ayen",
|
||||
"endOfListSub": "Ou rivé an bout a lis-la — ayen pa ka richajé dèyè do a-w.",
|
||||
"prevPage": "Paj avan",
|
||||
"nextPage": "Paj apré",
|
||||
"page": "Paj"
|
||||
},
|
||||
"tool": {
|
||||
"what": "Pou ki sa i sèwvi",
|
||||
"why": "Poukisa sa enpòwtan",
|
||||
"who": "Pou kimoun, kitan",
|
||||
"install": "Enstalé é sèwvi",
|
||||
"officialSite": "Sit ofisyèl",
|
||||
"sourceCode": "Kòd sous",
|
||||
"license": "Lisans",
|
||||
"category": "Katégori",
|
||||
"backToDirectory": "Viré an lis-la"
|
||||
},
|
||||
"difficulty": {
|
||||
"beginner": "Débitan",
|
||||
"intermediate": "Entèmédia",
|
||||
"advanced": "Avansé"
|
||||
},
|
||||
"search": {
|
||||
"label": "Chèché on zouti",
|
||||
"placeholder": "Chèché… (touch /)",
|
||||
"noResults": "Pon zouti pa touvé"
|
||||
},
|
||||
"lang": {
|
||||
"switchLabel": "Lang",
|
||||
"switchTo": "Pasé an"
|
||||
},
|
||||
"footer": {
|
||||
"license": "Kontni é kòd lib (MIT). Pon trakè, pon CDN, pon cookie.",
|
||||
"contribute": "Pwopozé on zouti oben on koreksyon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import fr from './fr.json'
|
||||
import gcf from './gcf.json'
|
||||
import { browser } from '$app/environment'
|
||||
import { defaultLocale, type Locale } from './locales'
|
||||
|
||||
export { locales, localeNames, defaultLocale, type Locale } from './locales'
|
||||
|
||||
type Dict = typeof fr
|
||||
// gcf est vérifié par TypeScript contre la forme du français : une clé
|
||||
// manquante est une erreur de build, pas un fallback silencieux (ADR-001).
|
||||
const dicts: Record<Locale, Dict> = { fr, gcf }
|
||||
|
||||
const STORAGE_KEY = 'oki-lang'
|
||||
|
||||
function initialLocale(): Locale {
|
||||
if (!browser) return defaultLocale
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
return saved === 'gcf' || saved === 'fr' ? saved : defaultLocale
|
||||
}
|
||||
|
||||
// Rune d'état partagée (Svelte 5) : la langue de l'interface.
|
||||
// Le HTML prérendu est en fr ; le créole est une amélioration progressive
|
||||
// (persistance localStorage — mission §4.3, doctrine §4.2).
|
||||
const state = $state({ locale: initialLocale() })
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return state.locale
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale) {
|
||||
state.locale = locale
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, locale)
|
||||
document.documentElement.lang = locale
|
||||
}
|
||||
}
|
||||
|
||||
function lookup(dict: Dict, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((node, key) => {
|
||||
if (node && typeof node === 'object' && key in node) {
|
||||
return (node as Record<string, unknown>)[key]
|
||||
}
|
||||
return undefined
|
||||
}, dict)
|
||||
}
|
||||
|
||||
/** Chaîne d'interface pour `path` dans la langue courante (réactif). */
|
||||
export function t(path: string): string {
|
||||
const hit = lookup(dicts[state.locale], path) ?? lookup(dicts[defaultLocale], path)
|
||||
if (typeof hit !== 'string') {
|
||||
throw new Error(`Clé i18n manquante « ${path} » (locale : ${state.locale})`)
|
||||
}
|
||||
return hit
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// gcf = créole guadeloupéen (ISO 639-3, valide BCP 47).
|
||||
// La langue du cœur d'OKI : les micro-textes créoles activent les circuits
|
||||
// de confiance avant toute lecture consciente (doctrine §4.2).
|
||||
export const locales = ['fr', 'gcf'] as const
|
||||
export type Locale = (typeof locales)[number]
|
||||
export const defaultLocale: Locale = 'fr'
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
fr: 'Français',
|
||||
gcf: 'Kréyòl'
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import '../app.css'
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
let { children } = $props()
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main">{t('nav.skipToContent')}</a>
|
||||
|
||||
<header class="center">
|
||||
<div class="cluster bar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
{t('site.name')}
|
||||
</a>
|
||||
<nav aria-label={t('nav.home')}>
|
||||
<ul class="cluster">
|
||||
<li><a href="/outils">{t('nav.directory')}</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
|
||||
{t('nav.contribute')}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="center stack" style="--gap: var(--space-5)">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="center">
|
||||
<p>{t('footer.license')}</p>
|
||||
<p>
|
||||
<a href="https://github.com/exitchatcontrol/exitchatcontrol.org" rel="external">
|
||||
{t('footer.contribute')}
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
justify-content: space-between;
|
||||
padding-block: var(--space-3);
|
||||
border-bottom: 4px solid var(--oki-jaune);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-2);
|
||||
text-decoration: none;
|
||||
color: var(--oki-ink);
|
||||
min-height: var(--tap-target);
|
||||
}
|
||||
|
||||
/* marque OKI : les trois bandes panafricaines — identité du territoire
|
||||
dès le premier écran, test des 50 ms (doctrine §1.1 et §4.2) */
|
||||
.brand-mark {
|
||||
inline-size: 0.9rem;
|
||||
block-size: 1.6rem;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--oki-vert) 0 33%,
|
||||
var(--oki-jaune) 33% 66%,
|
||||
var(--oki-rouge) 66% 100%
|
||||
);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-2);
|
||||
text-decoration: none;
|
||||
color: var(--oki-ink);
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--oki-jaune);
|
||||
text-decoration-thickness: 3px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--oki-line);
|
||||
padding-block: var(--space-4);
|
||||
color: var(--oki-ink-2);
|
||||
font-size: var(--text-0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
// Tout le site est prérendu (ADR-001) : HTML statique servi par nginx,
|
||||
// un lien fonctionne sans JS (doctrine §4.4).
|
||||
export const prerender = true
|
||||
@@ -0,0 +1,11 @@
|
||||
import { loadContent } from '$lib/content/tools.server'
|
||||
|
||||
export function load() {
|
||||
const { tools, categories } = loadContent()
|
||||
return {
|
||||
categories,
|
||||
toolCount: tools.length,
|
||||
// les premiers gestes : outils débutant, un par catégorie
|
||||
starters: tools.filter((t) => t.difficulty === 'beginner').slice(0, 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import ToolCard from '$lib/components/ToolCard.svelte'
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
let { data } = $props()
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t('site.name')} — {t('site.tagline')}</title>
|
||||
<meta name="description" content={t('site.description')} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Valeur livrée en < 5 s (doctrine §2.1) : le héros dit quoi, pour qui,
|
||||
et donne UNE action primaire (Von Restorff, doctrine §1.2). -->
|
||||
<section class="hero stack">
|
||||
<h1>{t('hero.title')}</h1>
|
||||
<p class="sub">{t('hero.subtitle')}</p>
|
||||
<p><a class="cta" href="/outils">{t('hero.cta')} · {data.toolCount} {t('directory.results')}</a></p>
|
||||
</section>
|
||||
|
||||
<section class="stack">
|
||||
<h2>{t('directory.intro')}</h2>
|
||||
<div class="card-grid">
|
||||
<ul>
|
||||
{#each data.starters as tool (tool.slug)}
|
||||
<li><ToolCard {tool} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stack">
|
||||
<h2>{t('directory.filterCategory')}</h2>
|
||||
<ul class="cluster cats">
|
||||
{#each data.categories as cat (cat.slug)}
|
||||
<li><a href="/outils?cat={cat.slug}">{cat.title}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: var(--space-5);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: var(--text-2);
|
||||
color: var(--oki-ink-2);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
/* une seule action primaire par écran (Von Restorff, doctrine §1.2) */
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
background: var(--oki-jaune);
|
||||
color: var(--oki-noir);
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.cats {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cats a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
text-decoration: none;
|
||||
color: var(--oki-ink);
|
||||
}
|
||||
|
||||
.cats a:hover {
|
||||
border-color: var(--oki-jaune);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { loadContent } from '$lib/content/tools.server'
|
||||
|
||||
export function load() {
|
||||
const { tools, categories } = loadContent()
|
||||
return {
|
||||
categories,
|
||||
// seules les données de carte partent au client (budget JS, doctrine §4.4)
|
||||
tools: tools.map(({ sections: _sections, ...card }) => card)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import { page } from '$app/state'
|
||||
import CategoryNav from '$lib/components/CategoryNav.svelte'
|
||||
import SearchBar from '$lib/components/SearchBar.svelte'
|
||||
import ToolCard from '$lib/components/ToolCard.svelte'
|
||||
import EndMarker from '$lib/components/EndMarker.svelte'
|
||||
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
let { data } = $props()
|
||||
let query = $state('')
|
||||
|
||||
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'] as const
|
||||
|
||||
// Chaque filtre vit dans l'URL (query param) : partageable (mission §5.1).
|
||||
// Le HTML prérendu montre la liste complète ; le filtrage est une
|
||||
// amélioration progressive côté client (ADR-001). Les searchParams ne
|
||||
// sont pas lisibles au prerender, d'où la garde `browser`.
|
||||
let cat = $derived(browser ? page.url.searchParams.get('cat') : null)
|
||||
let diff = $derived(browser ? page.url.searchParams.get('diff') : null)
|
||||
|
||||
let filtered = $derived(
|
||||
data.tools.filter(
|
||||
(tool) =>
|
||||
(!cat || tool.category === cat) &&
|
||||
(!diff || tool.difficulty === diff) &&
|
||||
(!query ||
|
||||
`${tool.name} ${tool.tags.join(' ')} ${tool.excerpt}`
|
||||
.toLocaleLowerCase('fr')
|
||||
.includes(query.toLocaleLowerCase('fr')))
|
||||
)
|
||||
)
|
||||
|
||||
function diffHref(d: string | null): string {
|
||||
const params = new URLSearchParams(browser ? page.url.searchParams : undefined)
|
||||
if (cat) params.set('cat', cat)
|
||||
if (d) params.set('diff', d)
|
||||
else params.delete('diff')
|
||||
const qs = params.toString()
|
||||
return qs ? `/outils?${qs}` : '/outils'
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t('directory.title')} — {t('site.name')}</title>
|
||||
<meta name="description" content={t('directory.intro')} />
|
||||
</svelte:head>
|
||||
|
||||
<header class="stack">
|
||||
<h1>{t('directory.title')}</h1>
|
||||
<p>{t('directory.intro')}</p>
|
||||
</header>
|
||||
|
||||
<div class="stack filters">
|
||||
<SearchBar bind:value={query} />
|
||||
<CategoryNav categories={data.categories} />
|
||||
<nav aria-label={t('directory.filterDifficulty')}>
|
||||
<ul class="cluster">
|
||||
<li>
|
||||
<a href={diffHref(null)} aria-current={diff === null ? 'page' : undefined}>
|
||||
{t('directory.allDifficulties')}
|
||||
</a>
|
||||
</li>
|
||||
{#each DIFFICULTIES as d (d)}
|
||||
<li>
|
||||
<a href={diffHref(d)} aria-current={diff === d ? 'page' : undefined}>
|
||||
<DifficultyBadge difficulty={d} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section class="stack" aria-live="polite">
|
||||
<p class="count">
|
||||
{filtered.length}
|
||||
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
|
||||
</p>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="empty">{t('directory.empty')}</p>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
<ul>
|
||||
{#each filtered as tool (tool.slug)}
|
||||
<li><ToolCard {tool} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<EndMarker />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
--gap: var(--space-3);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
text-decoration: none;
|
||||
color: var(--oki-ink);
|
||||
}
|
||||
|
||||
nav a[aria-current='page'] {
|
||||
background: var(--oki-surface-2);
|
||||
border-color: var(--oki-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--oki-ink-2);
|
||||
font-size: var(--text-0);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--space-5);
|
||||
border: 1px dashed var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
text-align: center;
|
||||
color: var(--oki-ink-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import { loadContent } from '$lib/content/tools.server'
|
||||
|
||||
export function entries() {
|
||||
return loadContent().tools.map(({ slug }) => ({ slug }))
|
||||
}
|
||||
|
||||
export function load({ params }: { params: { slug: string } }) {
|
||||
const { tools, categories } = loadContent()
|
||||
const tool = tools.find((t) => t.slug === params.slug)
|
||||
if (!tool) error(404)
|
||||
const category = categories.find((c) => c.slug === tool.category)
|
||||
return { tool, category }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
|
||||
import { t } from '$lib/i18n/index.svelte'
|
||||
|
||||
let { data } = $props()
|
||||
let tool = $derived(data.tool)
|
||||
|
||||
const SECTION_ORDER = ['what', 'why', 'who', 'install', 'extra'] as const
|
||||
// « extra » n'a pas d'intitulé i18n dédié : rattaché à l'installation
|
||||
const HEADINGS: Record<string, string> = {
|
||||
what: 'tool.what',
|
||||
why: 'tool.why',
|
||||
who: 'tool.who',
|
||||
install: 'tool.install',
|
||||
extra: 'tool.install'
|
||||
}
|
||||
|
||||
let jsonLd = $derived(
|
||||
`<script type="application/ld+json">${JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: tool.name,
|
||||
url: tool.url,
|
||||
license: tool.license ? `https://spdx.org/licenses/${tool.license}` : undefined,
|
||||
applicationCategory: data.category?.title
|
||||
})}${'<'}/script>`
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{tool.name} — {t('site.name')}</title>
|
||||
<meta name="description" content={tool.excerpt} />
|
||||
<meta property="og:title" content={tool.name} />
|
||||
<meta property="og:description" content={tool.excerpt} />
|
||||
<meta property="og:type" content="article" />
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — JSON-LD généré au build depuis nos propres données -->
|
||||
{@html jsonLd}
|
||||
</svelte:head>
|
||||
|
||||
<nav aria-label={t('tool.backToDirectory')}>
|
||||
<a href="/outils">← {t('tool.backToDirectory')}</a>
|
||||
</nav>
|
||||
|
||||
<article class="stack" style="--gap: var(--space-4)">
|
||||
<header class="stack">
|
||||
<div class="cluster">
|
||||
<span class="monogram" style:--tool-color={tool.color} aria-hidden="true">
|
||||
{tool.monogram ?? tool.name.slice(0, 2)}
|
||||
</span>
|
||||
<h1>{tool.name}</h1>
|
||||
</div>
|
||||
<div class="cluster meta">
|
||||
<DifficultyBadge difficulty={tool.difficulty} />
|
||||
{#if data.category}
|
||||
<a href="/outils?cat={data.category.slug}">{data.category.title}</a>
|
||||
{/if}
|
||||
{#if tool.license}
|
||||
<span>{t('tool.license')} : {tool.license}</span>
|
||||
{/if}
|
||||
{#each tool.tags as tag (tag)}
|
||||
<span>{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<p>
|
||||
<a class="cta" href={tool.url} rel="external">{t('tool.officialSite')} ↗</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#each SECTION_ORDER as key (key)}
|
||||
{#if tool.sections[key]}
|
||||
<section class="prose stack">
|
||||
<h2>{t(HEADINGS[key])}</h2>
|
||||
<!-- HTML produit au build par marked depuis notre propre contenu versionné -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="stack">{@html tool.sections[key]}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.monogram {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: 3.5rem;
|
||||
block-size: 3.5rem;
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--tool-color, var(--oki-surface-2));
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--oki-ink-2);
|
||||
font-size: var(--text-0);
|
||||
}
|
||||
|
||||
.meta > * {
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 0.15em 0.6em;
|
||||
}
|
||||
|
||||
.meta a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
padding-inline: var(--space-4);
|
||||
background: var(--oki-jaune);
|
||||
color: var(--oki-noir);
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: var(--text-3);
|
||||
border-bottom: 3px solid var(--oki-jaune);
|
||||
display: inline-block;
|
||||
padding-block-end: var(--space-1);
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--tap-target);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* base.css — HTML sémantique nu (doctrine §4.3 : l'accessibilité est
|
||||
* d'abord un problème de HTML correct).
|
||||
*/
|
||||
body {
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-1);
|
||||
line-height: var(--leading);
|
||||
color: var(--oki-ink);
|
||||
background: var(--oki-surface);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
line-height: 1.15;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-ink);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* focus visible sur TOUT élément interactif (doctrine §4.3) */
|
||||
:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: var(--tap-target); /* Fitts, doctrine §1.3 */
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--oki-line);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--oki-surface-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
background: var(--oki-surface-2);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--oki-surface-2);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--oki-jaune);
|
||||
color: var(--oki-noir);
|
||||
}
|
||||
|
||||
/* lien d'évitement clavier */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--space-3);
|
||||
inset-block-start: var(--space-3);
|
||||
translate: 0 -300%;
|
||||
background: var(--oki-surface);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
translate: 0 0;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Polices auto-hébergées, servies depuis notre domaine — jamais de Google
|
||||
* Fonts (doctrine §5.3 : performance, RGPD, souveraineté).
|
||||
* Budget : 2 fichiers WOFF2 maximum. Le gras du corps est synthétisé.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Atkinson Hyperlegible';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/atkinson-hyperlegible-latin-400-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/fraunces-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* layouts.css — primitives de composition (approche « Every Layout » :
|
||||
* des compositions, pas des pages — doctrine §5.2).
|
||||
* Le responsive passe par container queries, jamais par media queries
|
||||
* de largeur d'écran (doctrine §2.2 point 6).
|
||||
*/
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap, var(--space-3));
|
||||
}
|
||||
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap, var(--space-2));
|
||||
align-items: var(--align, center);
|
||||
}
|
||||
|
||||
.center {
|
||||
box-sizing: content-box;
|
||||
max-inline-size: var(--max, 70rem);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-inline-size: var(--measure);
|
||||
}
|
||||
|
||||
/* grille de cartes : pilotée par le conteneur, pas par l'écran */
|
||||
.card-grid {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.card-grid > ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@container (min-width: 44rem) {
|
||||
.card-grid > ul {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 68rem) {
|
||||
.card-grid > ul {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Reset moderne minimal (doctrine §5.2 — ~30 lignes, rien de plus). */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* jamais de font-size en px sur html : respect de la taille utilisateur (doctrine §4.3) */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
svg,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* tokens.css — design tokens OKI (doctrine §5.2).
|
||||
* Toute valeur visuelle du site vit ici ; les composants Svelte consomment
|
||||
* les tokens, jamais de valeur brute (couleur, taille) dans un composant.
|
||||
* Couleurs en oklch() : luminance perceptuelle maîtrisée => contraste WCAG
|
||||
* calculable (méthode modus-vivendi, doctrine §4.3).
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* ── Identité panafricaine OKI ─────────────────────────────────────── */
|
||||
--oki-ink: light-dark(oklch(18% 0.02 260), oklch(94% 0.01 260));
|
||||
--oki-surface: light-dark(oklch(97% 0.005 90), oklch(14% 0.02 260));
|
||||
--oki-surface-2: light-dark(oklch(93% 0.008 90), oklch(20% 0.02 260));
|
||||
--oki-line: light-dark(oklch(85% 0.01 90), oklch(32% 0.02 260));
|
||||
--oki-ink-2: light-dark(oklch(38% 0.02 260), oklch(75% 0.01 260));
|
||||
|
||||
/* Couleurs identitaires (drapeau panafricain — jaune/vert/rouge/noir) */
|
||||
--oki-jaune: oklch(75% 0.18 85);
|
||||
--oki-vert: oklch(55% 0.18 145);
|
||||
--oki-rouge: oklch(55% 0.22 25);
|
||||
--oki-noir: oklch(15% 0.01 260);
|
||||
|
||||
/* Accent principal : le jaune OKI, décliné pour rester AA sur chaque fond */
|
||||
--accent: light-dark(oklch(52% 0.14 85), var(--oki-jaune));
|
||||
--accent-ink: light-dark(oklch(35% 0.1 85), oklch(85% 0.16 85));
|
||||
|
||||
/* ── Difficulté — jamais portée par la couleur seule (doctrine §1.1) ──
|
||||
Chaque niveau = couleur + texte + icône dans DifficultyBadge. */
|
||||
--diff-beginner: oklch(65% 0.15 145);
|
||||
--diff-intermediate: oklch(75% 0.16 85);
|
||||
--diff-advanced: oklch(55% 0.18 25);
|
||||
/* variantes texte, contrastées AA sur --oki-surface */
|
||||
--diff-beginner-ink: light-dark(oklch(42% 0.14 145), oklch(78% 0.15 145));
|
||||
--diff-intermediate-ink: light-dark(oklch(48% 0.13 85), oklch(80% 0.15 85));
|
||||
--diff-advanced-ink: light-dark(oklch(46% 0.18 25), oklch(72% 0.16 25));
|
||||
|
||||
/* ── Typographie fluide (clamp, zéro media query — doctrine §5.2) ────
|
||||
Échelle modulaire ratio 1.25, corps 16px mobile → 18px desktop. */
|
||||
--text-0: clamp(0.875rem, 0.85rem + 0.15vw, 0.9375rem);
|
||||
--text-1: clamp(1rem, 0.95rem + 0.4vw, 1.125rem);
|
||||
--text-2: clamp(1.25rem, 1.17rem + 0.5vw, 1.4rem);
|
||||
--text-3: clamp(1.56rem, 1.42rem + 0.8vw, 1.75rem);
|
||||
--text-4: clamp(1.95rem, 1.7rem + 1.4vw, 2.44rem);
|
||||
--text-5: clamp(2.44rem, 2rem + 2.4vw, 3.4rem);
|
||||
|
||||
--font-text: 'Atkinson Hyperlegible', system-ui, sans-serif;
|
||||
--font-display: 'Fraunces', var(--font-text);
|
||||
|
||||
--measure: 65ch; /* largeur de lecture 45-75ch (doctrine §2.2) */
|
||||
--leading: 1.6;
|
||||
|
||||
/* ── Espacement modulaire ────────────────────────────────────────── */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
|
||||
/* ── Interaction ─────────────────────────────────────────────────── */
|
||||
--tap-target: 48px; /* Fitts (doctrine §1.3) */
|
||||
--radius-1: 4px;
|
||||
--radius-2: 10px;
|
||||
--duration-1: 120ms;
|
||||
--duration-2: 240ms;
|
||||
--ease-out: cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
--focus-ring: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--oki-ink: light-dark(oklch(5% 0.01 260), oklch(99% 0 0));
|
||||
--oki-line: light-dark(oklch(40% 0.01 260), oklch(70% 0.01 260));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Exit Chat Control",
|
||||
"short_name": "ExitCC",
|
||||
"description": "Annuaire d'outils libres pour la souveraineté numérique",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#17181f",
|
||||
"theme_color": "#17181f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import adapter from '@sveltejs/adapter-static'
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
// Site 100 % prérendu : servi par nginx sur l'infra OKI, mirrorable
|
||||
// (ADR-001 ; doctrine §4.4 — HTML rendu serveur, un lien marche sans JS).
|
||||
adapter: adapter({
|
||||
fallback: '404.html'
|
||||
}),
|
||||
prerender: {
|
||||
handleHttpError: 'fail'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
fs: {
|
||||
// le contenu éditorial vit à la racine du repo, hors de frontend/
|
||||
allow: ['..']
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user