feat(i18n): French and German localization (phase 5)
CI / Lint e auditorias (push) Successful in 49s
CI / Trivy (dependências e segredos) (push) Successful in 11s
CI / Imagem Docker (linux/arm64) (push) Successful in 3m14s

- Add full message catalogs and content for French (fr) and German (de)
- Implement ASCII canonical slugs for French and German routes
- Add language-specific search endpoints (/api/busca/fr, /api/busca/de)
- Configure strict 404 responses for /fr/livro and /de/livro
- Localize SEO, OpenGraph (fr_FR, de_DE), Twitter and JSON-LD schemas
- Add localized code example comments across 14 examples for fr and de
- Enable 'fr' and 'de' in ENABLED_LOCALES with root negotiation and redirect
- Extend test suite in check-i18n, smoke-imagem and verifica-vazamentos

Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
Lucas RayanandAntigravity committed 2026-11-01 22:06:36 -03:00
1 parent 7b673aeb4b
commit 7fce46b9e1
104 files changed
+33369 -686

No files matched your search

+141 -25
View File
@@ -54,10 +54,34 @@ const TIPOS_ES = {
componente: { label: "Componente", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
};
const TIPOS_FR = {
resposta: { label: "Réponse", Icon: Sparkles, cor: "bg-accent text-accent-contrast" },
pino: { label: "Broches", Icon: Plug, cor: "bg-accent text-accent-contrast" },
pagina: { label: "Page", Icon: FileText, cor: "bg-surface-2 text-ink-soft" },
serie: { label: "Série", Icon: Cpu, cor: "bg-accent-soft text-accent" },
framework: { label: "Framework", Icon: Terminal, cor: "bg-teal-soft text-teal" },
glossario: { label: "Glossaire", Icon: BookOpen, cor: "bg-copper-soft text-copper" },
erro: { label: "Erreur", Icon: AlertTriangle, cor: "bg-gold-soft text-gold" },
componente: { label: "Composant", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
};
const TIPOS_DE = {
resposta: { label: "Antwort", Icon: Sparkles, cor: "bg-accent text-accent-contrast" },
pino: { label: "Pins", Icon: Plug, cor: "bg-accent text-accent-contrast" },
pagina: { label: "Seite", Icon: FileText, cor: "bg-surface-2 text-ink-soft" },
serie: { label: "Serie", Icon: Cpu, cor: "bg-accent-soft text-accent" },
framework: { label: "Framework", Icon: Terminal, cor: "bg-teal-soft text-teal" },
glossario: { label: "Glossar", Icon: BookOpen, cor: "bg-copper-soft text-copper" },
erro: { label: "Fehler", Icon: AlertTriangle, cor: "bg-gold-soft text-gold" },
componente: { label: "Komponente", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
};
const INTELIGENTES = new Set(["resposta", "pino"]);
const EXEMPLOS_EN = ["flash s3", "usb pins c6", "gpio0 esp32", "brownout", "LVGL", "FreeRTOS"];
const EXEMPLOS_ES = ["flash s3", "pines usb c6", "gpio0 esp32", "brownout", "LVGL", "FreeRTOS"];
const EXEMPLOS_FR = ["flash s3", "broches usb c6", "gpio0 esp32", "brownout", "LVGL", "FreeRTOS"];
const EXEMPLOS_DE = ["flash s3", "usb pins c6", "gpio0 esp32", "brownout", "LVGL", "FreeRTOS"];
function Destaque({ texto, termos }) {
if (!termos.length) return texto;
@@ -110,12 +134,22 @@ function Pilula({ children, onClick, forte = false }) {
export default function SearchDialog() {
const pathname = usePathname() || "";
const locale = pathname === "/en" || pathname.startsWith("/en/") ? "en" : (pathname === "/es" || pathname.startsWith("/es/") ? "es" : "pt");
const locale = pathname === "/en" || pathname.startsWith("/en/")
? "en"
: pathname === "/es" || pathname.startsWith("/es/")
? "es"
: pathname === "/fr" || pathname.startsWith("/fr/")
? "fr"
: pathname === "/de" || pathname.startsWith("/de/")
? "de"
: "pt";
const isEn = locale === "en";
const isEs = locale === "es";
const isFr = locale === "fr";
const isDe = locale === "de";
const TIPOS = isEn ? TIPOS_EN : isEs ? TIPOS_ES : TIPOS_PT;
const EXEMPLOS = isEn ? EXEMPLOS_EN : isEs ? EXEMPLOS_ES : EXEMPLOS_DE_BUSCA;
const TIPOS = isEn ? TIPOS_EN : isEs ? TIPOS_ES : isFr ? TIPOS_FR : isDe ? TIPOS_DE : TIPOS_PT;
const EXEMPLOS = isEn ? EXEMPLOS_EN : isEs ? EXEMPLOS_ES : isFr ? EXEMPLOS_FR : isDe ? EXEMPLOS_DE : EXEMPLOS_DE_BUSCA;
const [aberto, setAberto] = useState(false);
const [montado, setMontado] = useState(false);
@@ -156,7 +190,15 @@ export default function SearchDialog() {
const carregarIndice = useCallback(() => {
setErro(false);
let cancelado = false;
const url = isEn ? "/api/busca/en?v=2" : isEs ? "/api/busca/es?v=2" : "/api/busca?v=2";
const url = isEn
? "/api/busca/en?v=2"
: isEs
? "/api/busca/es?v=2"
: isFr
? "/api/busca/fr?v=2"
: isDe
? "/api/busca/de?v=2"
: "/api/busca?v=2";
fetch(url, { cache: "no-cache" })
.then((r) => {
if (!r.ok) {
@@ -180,7 +222,7 @@ export default function SearchDialog() {
return () => {
cancelado = true;
};
}, [isEn, isEs]);
}, [isEn, isEs, isFr, isDe]);
// Carrega o índice (textos + especificações + pinos) na primeira abertura.
useEffect(() => {
@@ -251,17 +293,17 @@ export default function SearchDialog() {
id="botao-busca"
onClick={() => setAberto(true)}
className="flex h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full border border-line px-3 text-ink transition-colors hover:bg-surface-2 xl:px-4"
aria-label={isEn ? "Search ESPDocs (Ctrl+K)" : isEs ? "Buscar en ESPDocs (Ctrl+K)" : "Buscar no ESPDocs (Ctrl+K)"}
aria-label={isEn ? "Search ESPDocs (Ctrl+K)" : isEs ? "Buscar en ESPDocs (Ctrl+K)" : isFr ? "Rechercher sur ESPDocs (Ctrl+K)" : isDe ? "ESPDocs durchsuchen (Strg+K)" : "Buscar no ESPDocs (Ctrl+K)"}
>
<Search className="h-4 w-4" aria-hidden="true" />
<span className="hidden text-sm font-medium xl:inline">{isEn ? "Search" : "Buscar"}</span>
<kbd className="hidden rounded-md border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-xs font-semibold text-muted xl:inline">Ctrl K</kbd>
<span className="hidden text-sm font-medium xl:inline">{isEn ? "Search" : isEs ? "Buscar" : isFr ? "Rechercher" : isDe ? "Suchen" : "Buscar"}</span>
<kbd className="hidden rounded-md border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-xs font-semibold text-muted xl:inline">{isDe ? "Strg K" : "Ctrl K"}</kbd>
</button>
{montado &&
aberto &&
createPortal(
<div className="fixed inset-0 z-[120] flex items-start justify-center px-4 pt-[8vh]" role="dialog" aria-modal="true" aria-label={isEn ? "Search ESPDocs" : isEs ? "Buscar en ESPDocs" : "Buscar no ESPDocs"}>
<div className="fixed inset-0 z-[120] flex items-start justify-center px-4 pt-[8vh]" role="dialog" aria-modal="true" aria-label={isEn ? "Search ESPDocs" : isEs ? "Buscar en ESPDocs" : isFr ? "Rechercher sur ESPDocs" : isDe ? "ESPDocs durchsuchen" : "Buscar no ESPDocs"}>
<div className="dialog-scrim absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={fechar} />
<div className="dialog-panel relative flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-line bg-canvas shadow-2xl">
<div className="flex items-center gap-3 border-b border-line px-5">
@@ -273,17 +315,27 @@ export default function SearchDialog() {
value={consulta}
onChange={(e) => setConsulta(e.target.value)}
onKeyDown={onInputKey}
placeholder={isEn ? "Ask ESPDocs: “flash s3”, “usb pins c6”, “gpio0 esp32”…" : isEs ? "Pregunte a ESPDocs: “flash s3”, “pines usb c6”, “gpio0 esp32”…" : "Pergunte ao ESPDocs: “flash s3”, “pinos usb c6”, “gpio0 esp32”…"}
placeholder={
isEn
? "Ask ESPDocs: “flash s3”, “usb pins c6”, “gpio0 esp32”…"
: isEs
? "Pregunte a ESPDocs: “flash s3”, “pines usb c6”, “gpio0 esp32”…"
: isFr
? "Demandez à ESPDocs : « flash s3 », « broches usb c6 », « gpio0 esp32 »…"
: isDe
? "ESPDocs fragen: „flash s3“, „usb pins c6“, „gpio0 esp32“… "
: "Pergunte ao ESPDocs: “flash s3”, “pinos usb c6”, “gpio0 esp32”…"
}
className="flex-1 bg-transparent py-5 text-base text-ink outline-none placeholder:text-muted"
autoComplete="off"
spellCheck={false}
aria-label={isEn ? "Search query" : isEs ? "Término de búsqueda" : "Termo de busca"}
aria-label={isEn ? "Search query" : isEs ? "Término de búsqueda" : isFr ? "Requête de recherche" : isDe ? "Suchbegriff" : "Termo de busca"}
/>
<button
type="button"
onClick={fechar}
className="grid h-9 w-9 cursor-pointer place-items-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-ink"
aria-label={isEn ? "Close search" : isEs ? "Cerrar búsqueda" : "Fechar busca"}
aria-label={isEn ? "Close search" : isEs ? "Cerrar búsqueda" : isFr ? "Fermer la recherche" : isDe ? "Suche schließen" : "Fechar busca"}
>
<X className="h-4 w-4" />
</button>
@@ -291,7 +343,7 @@ export default function SearchDialog() {
{entendeu && (
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-surface px-5 py-2.5">
<span className="text-xs font-semibold text-muted">{isEn ? "Understood:" : isEs ? "Entendido:" : "Entendi:"}</span>
<span className="text-xs font-semibold text-muted">{isEn ? "Understood:" : isEs ? "Entendido:" : isFr ? "Compris :" : isDe ? "Erkannt:" : "Entendi:"}</span>
{interpretacao.chips.map((c) => (
<Pilula key={c.chave} forte>{c.nome}</Pilula>
))}
@@ -303,7 +355,11 @@ export default function SearchDialog() {
{sugestoes.length > 0 && (
<div className="flex flex-wrap items-center gap-2 border-b border-line px-5 py-2.5">
<span className="text-xs font-semibold text-muted">{interpretacao.chips.length ? (isEn ? "Ask about:" : isEs ? "Preguntar sobre:" : "Perguntar sobre:") : (isEn ? "Which chip?" : isEs ? "¿Para qué chip?" : "Para qual chip?")}</span>
<span className="text-xs font-semibold text-muted">
{interpretacao.chips.length
? (isEn ? "Ask about:" : isEs ? "Preguntar sobre:" : isFr ? "Poser une question sur :" : isDe ? "Frage zu:" : "Perguntar sobre:")
: (isEn ? "Which chip?" : isEs ? "¿Para qué chip?" : isFr ? "Pour quelle puce ?" : isDe ? "Für welchen Chip?" : "Para qual chip?")}
</span>
{sugestoes.map((s) => (
<Pilula key={s.rotulo} onClick={() => anexar(s.anexar)}>
{s.rotulo}
@@ -315,21 +371,37 @@ export default function SearchDialog() {
<div ref={listaRef} className="flex-1 overflow-y-auto p-2" role="listbox">
{erro && (
<div className="p-6 text-center">
<p className="text-sm font-semibold text-danger">{isEn ? "Could not load search index." : isEs ? "No se pudo cargar el índice de búsqueda." : "Não foi possível carregar o índice de busca."}</p>
<p className="text-sm font-semibold text-danger">
{isEn
? "Could not load search index."
: isEs
? "No se pudo cargar el índice de búsqueda."
: isFr
? "Impossible de charger l'index de recherche."
: isDe
? "Suchindex konnte nicht geladen werden."
: "Não foi possível carregar o índice de busca."}
</p>
<button
type="button"
onClick={carregarIndice}
className="btn btn-primary btn-sm mt-3"
>
{isEn ? "Try again" : isEs ? "Reintentar" : "Tentar novamente"}
{isEn ? "Try again" : isEs ? "Reintentar" : isFr ? "Réessayer" : isDe ? "Erneut versuchen" : "Tentar novamente"}
</button>
</div>
)}
{!erro && !indice && <p className="p-6 text-center text-sm text-muted">{isEn ? "Loading…" : isEs ? "Cargando…" : "Carregando…"}</p>}
{!erro && !indice && (
<p className="p-6 text-center text-sm text-muted">
{isEn ? "Loading…" : isEs ? "Cargando…" : isFr ? "Chargement…" : isDe ? "Wird geladen…" : "Carregando…"}
</p>
)}
{indice && vazia && (
<div className="px-3 pb-3 pt-1">
<p className="text-xs font-semibold text-muted">{isEn ? "Try asking" : isEs ? "Pruebe a preguntar" : "Experimente perguntar"}</p>
<p className="text-xs font-semibold text-muted">
{isEn ? "Try asking" : isEs ? "Pruebe a preguntar" : isFr ? "Essayez de demander" : isDe ? "Versuchen Sie" : "Experimente perguntar"}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{EXEMPLOS.map((ex) => (
<Pilula key={ex} onClick={() => setConsulta(ex)}>
@@ -337,14 +409,36 @@ export default function SearchDialog() {
</Pilula>
))}
</div>
<p className="mt-4 text-xs font-semibold text-muted">{isEn ? "Go to" : isEs ? "Ir a" : "Ir para"}</p>
<p className="mt-4 text-xs font-semibold text-muted">
{isEn ? "Go to" : isEs ? "Ir a" : isFr ? "Aller à" : isDe ? "Gehe zu" : "Ir para"}
</p>
</div>
)}
{indice && !vazia && resultados.length === 0 && (
<div className="p-8 text-center">
<p className="font-display text-lg font-semibold text-ink">{isEn ? `No results for “${consulta}”` : isEs ? `Sin resultados para “${consulta}”` : `Nenhum resultado para “${consulta}”`}</p>
<p className="mt-1 text-sm text-muted">{isEn ? "Try “brownout”, “flash s3”, “usb pins c6” or “LVGL”." : isEs ? "Pruebe “brownout”, “flash s3”, “pines usb c6” o “LVGL”." : "Tente “brownout”, “flash s3”, “pinos usb c6” ou “LVGL”."}</p>
<p className="font-display text-lg font-semibold text-ink">
{isEn
? `No results for “${consulta}”`
: isEs
? `Sin resultados para “${consulta}”`
: isFr
? `Aucun résultat pour « ${consulta} »`
: isDe
? `Keine Ergebnisse für „${consulta}“`
: `Nenhum resultado para “${consulta}”`}
</p>
<p className="mt-1 text-sm text-muted">
{isEn
? "Try “brownout”, “flash s3”, “usb pins c6” or “LVGL”."
: isEs
? "Pruebe “brownout”, “flash s3”, “pines usb c6” o “LVGL”."
: isFr
? "Essayez « brownout », « flash s3 », « broches usb c6 » ou « LVGL »."
: isDe
? "Versuchen Sie „brownout“, „flash s3“, „usb pins c6“ oder „LVGL“."
: "Tente “brownout”, “flash s3”, “pinos usb c6” ou “LVGL”."}
</p>
</div>
)}
@@ -407,10 +501,32 @@ export default function SearchDialog() {
</div>
<div className="hidden items-center gap-4 border-t border-line bg-surface px-4 py-2.5 text-xs text-muted md:flex">
<span className="flex items-center gap-1"><ArrowUp className="h-3 w-3" /><ArrowDown className="h-3 w-3" /> {isEn ? "navigate" : "navegar"}</span>
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" /> {isEn ? "open" : "abrir"}</span>
<span><kbd className="font-mono font-semibold">Esc</kbd> {isEn ? "close" : isEs ? "cerrar" : "fechar"}</span>
<span className="ml-auto">{vazia ? (isEn ? "Tip: press / on any page" : isEs ? "Consejo: presione / en cualquier página" : "Dica: pressione / em qualquer página") : `${resultados.length} ${isEn ? "result(s)" : "resultado(s)"}`}</span>
<span className="flex items-center gap-1"><ArrowUp className="h-3 w-3" /><ArrowDown className="h-3 w-3" /> {isEn ? "navigate" : isEs ? "navegar" : isFr ? "naviguer" : isDe ? "navigieren" : "navegar"}</span>
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" /> {isEn ? "open" : isEs ? "abrir" : isFr ? "ouvrir" : isDe ? "öffnen" : "abrir"}</span>
<span><kbd className="font-mono font-semibold">Esc</kbd> {isEn ? "close" : isEs ? "cerrar" : isFr ? "fermer" : isDe ? "schließen" : "fechar"}</span>
<span className="ml-auto">
{vazia
? (isEn
? "Tip: press / on any page"
: isEs
? "Consejo: presione / en cualquier página"
: isFr
? "Astuce : appuyez sur / sur n'importe quelle page"
: isDe
? "Tipp: Drücken Sie / auf einer beliebigen Seite"
: "Dica: pressione / em qualquer página")
: `${resultados.length} ${
isEn
? "result(s)"
: isEs
? "resultado(s)"
: isFr
? "résultat(s)"
: isDe
? "Ergebnis(se)"
: "resultado(s)"
}`}
</span>
</div>
</div>
</div>,