feat: site search with a dialog in the header and a /api/busca endpoint
CI / Lint, design e build (push) Successful in 1m52s
CI / Trivy (dependências e segredos) (push) Successful in 12s
CI / Imagem Docker (push) Successful in 2m26s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-11-01 08:02:57 -03:00
1 parent d8323c653b
commit 7d0edf13c4
7 files changed
+1115 -5

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import { construirIndiceBusca } from "@/lib/searchIndex";
export const dynamic = "force-static";
/** Índice de busca carregado sob demanda pelo diálogo de pesquisa (Ctrl/⌘+K). */
export function GET() {
return Response.json(construirIndiceBusca(), {
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
},
});
}
+39 -5
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef, Suspense } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { AlertTriangle, CheckCircle, List, Grid, Search, Sliders, X, Zap } from "lucide-react"; import { AlertTriangle, CheckCircle, List, Grid, Search, Sliders, X, Zap } from "lucide-react";
/* /*
@@ -41,7 +42,25 @@ const type_colors = {
nc: "#94a3b8", // muted gray nc: "#94a3b8", // muted gray
}; };
export default function ConnectionsDiagram({ connections, serie }) { /**
* Aceita ?pinos=28,30,31&motivo=... (gerado pela busca do site): mostra só
* esses pinos, pelo número físico, com um aviso para voltar à lista completa.
* O Suspense é exigido por useSearchParams em páginas estáticas.
*/
export default function ConnectionsDiagram(props) {
return (
<Suspense fallback={null}>
<ConnectionsDiagramInner {...props} />
</Suspense>
);
}
function ConnectionsDiagramInner({ connections, serie }) {
const params = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const pinosFiltro = useMemo(() => new Set((params.get("pinos") ?? "").split(",").filter(Boolean)), [params]);
const motivoFiltro = params.get("motivo") ?? "";
const [activeFilter, setActiveFilter] = useState("All"); const [activeFilter, setActiveFilter] = useState("All");
const [selectedConnection, setSelectedConnection] = useState(null); const [selectedConnection, setSelectedConnection] = useState(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -60,7 +79,8 @@ export default function ConnectionsDiagram({ connections, serie }) {
searchQuery === "" || searchQuery === "" ||
connection.nome.toLowerCase().includes(searchQuery.toLowerCase()) || connection.nome.toLowerCase().includes(searchQuery.toLowerCase()) ||
connection.numero.toString().toLowerCase().includes(searchQuery.toLowerCase()); connection.numero.toString().toLowerCase().includes(searchQuery.toLowerCase());
return matchesFilter && matchesSearch; const matchesPinos = pinosFiltro.size === 0 || pinosFiltro.has(String(connection.numero));
return matchesFilter && matchesSearch && matchesPinos;
}; };
const getCategoryColor = (category) => { const getCategoryColor = (category) => {
@@ -70,7 +90,9 @@ export default function ConnectionsDiagram({ connections, serie }) {
const nomeCategoria = (cat) => (cat === "Strapping" ? "Inicialização" : cat); const nomeCategoria = (cat) => (cat === "Strapping" ? "Inicialização" : cat);
const visibleConnections = useMemo(() => connections.filter(isConnectionVisible), [connections, activeFilter, searchQuery]); const visibleConnections = useMemo(() => connections.filter(isConnectionVisible), [connections, activeFilter, searchQuery, pinosFiltro]);
const mostrarTodos = () => router.replace(`${pathname}#connections`, { scroll: false });
const abrir = (connection, evento) => { const abrir = (connection, evento) => {
gatilhoRef.current = evento.currentTarget; gatilhoRef.current = evento.currentTarget;
@@ -137,6 +159,18 @@ export default function ConnectionsDiagram({ connections, serie }) {
</p> </p>
</div> </div>
{pinosFiltro.size > 0 && (
<div role="status" className="callout flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink">
<strong className="font-semibold">Filtrado pela busca:</strong> {motivoFiltro || "pinos selecionados"} ({pinosFiltro.size}{" "}
{pinosFiltro.size === 1 ? "pino" : "pinos"})
</p>
<button type="button" onClick={mostrarTodos} className="btn btn-ghost btn-sm">
Mostrar todos os pinos
</button>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1"> <div className="relative flex-1">
<label htmlFor="busca-pino" className="sr-only">Buscar conexão por nome ou número</label> <label htmlFor="busca-pino" className="sr-only">Buscar conexão por nome ou número</label>
@@ -191,7 +225,7 @@ export default function ConnectionsDiagram({ connections, serie }) {
<Search className="mx-auto h-8 w-8 text-muted" aria-hidden="true" /> <Search className="mx-auto h-8 w-8 text-muted" aria-hidden="true" />
<p className="mt-3 font-semibold text-ink">Nenhuma conexão encontrada</p> <p className="mt-3 font-semibold text-ink">Nenhuma conexão encontrada</p>
<p className="mt-1 text-sm text-muted">Ajuste a busca ou volte para todas as funções.</p> <p className="mt-1 text-sm text-muted">Ajuste a busca ou volte para todas as funções.</p>
<button type="button" onClick={() => { setActiveFilter("All"); setSearchQuery(""); }} className="btn btn-primary btn-sm mt-5"> <button type="button" onClick={() => { setActiveFilter("All"); setSearchQuery(""); if (pinosFiltro.size) mostrarTodos(); }} className="btn btn-primary btn-sm mt-5">
Limpar busca e filtros Limpar busca e filtros
</button> </button>
</div> </div>
+2
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Link from "next/link"; import Link from "next/link";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import SearchDialog from "@/components/SearchDialog";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { import {
@@ -185,6 +186,7 @@ export default function Header() {
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" /> <SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
Selecionar chip Selecionar chip
</Link> </Link>
<SearchDialog />
<ThemeToggle /> <ThemeToggle />
<button <button
ref={menuButtonRef} ref={menuButtonRef}
+383
View File
@@ -0,0 +1,383 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Search,
X,
CornerDownLeft,
ArrowUp,
ArrowDown,
FileText,
Cpu,
Terminal,
BookOpen,
AlertTriangle,
Box,
Sparkles,
Plug,
} from "lucide-react";
import { preparar, buscar, EXEMPLOS_DE_BUSCA } from "@/lib/searchEngine";
import { normalizar } from "@/lib/searchTopics";
const TIPOS = {
resposta: { label: "Resposta", Icon: Sparkles, cor: "bg-accent text-accent-contrast" },
pino: { label: "Pinos", Icon: Plug, cor: "bg-accent text-accent-contrast" },
pagina: { label: "Página", 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: "Glossário", Icon: BookOpen, cor: "bg-copper-soft text-copper" },
erro: { label: "Erro", Icon: AlertTriangle, cor: "bg-gold-soft text-gold" },
componente: { label: "Componente", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
};
const INTELIGENTES = new Set(["resposta", "pino"]);
function Destaque({ texto, termos }) {
if (!termos.length) return texto;
// Posições do texto normalizado só valem para o original se o comprimento não mudou.
const norm = normalizar(texto);
if (norm.length !== texto.length) return texto;
const marcas = new Array(texto.length).fill(false);
for (const t of termos) {
let i = norm.indexOf(t);
while (i !== -1) {
for (let k = i; k < i + t.length; k++) marcas[k] = true;
i = norm.indexOf(t, i + t.length);
}
}
const partes = [];
let atual = "";
let marcado = false;
for (let i = 0; i < texto.length; i++) {
if (marcas[i] !== marcado && atual) {
partes.push({ t: atual, m: marcado });
atual = "";
}
marcado = marcas[i];
atual += texto[i];
}
if (atual) partes.push({ t: atual, m: marcado });
return partes.map((p, i) =>
p.m ? (
<mark key={i} className="rounded-sm bg-accent-soft px-0.5 text-accent">
{p.t}
</mark>
) : (
<span key={i}>{p.t}</span>
)
);
}
function Pilula({ children, onClick, forte = false }) {
const base = "rounded-full px-2.5 py-1 text-xs font-semibold";
if (!onClick) return <span className={`${base} ${forte ? "bg-accent text-accent-contrast" : "bg-accent-soft text-accent"}`}>{children}</span>;
return (
<button
type="button"
onClick={onClick}
className={`${base} cursor-pointer border border-line bg-surface text-ink-soft transition-colors hover:border-accent hover:text-accent`}
>
{children}
</button>
);
}
export default function SearchDialog() {
const [aberto, setAberto] = useState(false);
const [montado, setMontado] = useState(false);
const [consulta, setConsulta] = useState("");
const [indice, setIndice] = useState(null);
const [erro, setErro] = useState(false);
const [ativo, setAtivo] = useState(0);
const inputRef = useRef(null);
const listaRef = useRef(null);
const router = useRouter();
useEffect(() => setMontado(true), []);
const fechar = useCallback(() => {
setAberto(false);
setConsulta("");
setAtivo(0);
}, []);
// Atalhos globais: Ctrl/⌘+K abre; "/" abre fora de campos de texto.
useEffect(() => {
function onKey(e) {
const emCampo = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName) || e.target?.isContentEditable;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setAberto((v) => !v);
} else if (e.key === "/" && !emCampo && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setAberto(true);
} else if (e.key === "Escape") {
fechar();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [fechar]);
const carregarIndice = useCallback(() => {
setErro(false);
let cancelado = false;
fetch("/api/busca?v=2", { cache: "no-cache" })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((dados) => {
if (!cancelado) {
setIndice(preparar(dados));
}
})
.catch((err) => {
console.error("Erro ao carregar índice de busca:", err);
if (!cancelado) setErro(true);
});
return () => {
cancelado = true;
};
}, []);
// Carrega o índice (textos + especificações + pinos) na primeira abertura.
useEffect(() => {
if (!aberto || indice) return;
return carregarIndice();
}, [aberto, indice, carregarIndice]);
useEffect(() => {
if (aberto) {
document.body.style.overflow = "hidden";
inputRef.current?.focus();
}
return () => {
document.body.style.overflow = "";
};
}, [aberto]);
const termos = useMemo(() => normalizar(consulta).split(/\s+/).filter((t) => t.length > 1), [consulta]);
const busca = useMemo(() => {
if (!indice) return { resultados: [], interpretacao: { chips: [], topicos: [] }, sugestoes: [], totalRespostas: 0 };
if (!consulta.trim()) {
return { resultados: indice.itens.filter((i) => i.tipo === "pagina"), interpretacao: { chips: [], topicos: [] }, sugestoes: [], totalRespostas: 0 };
}
return buscar(indice, consulta);
}, [indice, consulta]);
const { resultados, interpretacao, sugestoes } = busca;
const vazia = !consulta.trim();
useEffect(() => setAtivo(0), [consulta]);
useEffect(() => {
listaRef.current?.querySelector(`[data-idx="${ativo}"]`)?.scrollIntoView({ block: "nearest" });
}, [ativo]);
function ir(item) {
if (!item) return;
fechar();
router.push(item.href);
// Se já estamos na página de destino, só a âncora muda: avisa quem escuta (abas da série).
if (item.href.includes("#")) setTimeout(() => window.dispatchEvent(new HashChangeEvent("hashchange")), 80);
}
function anexar(termo) {
setConsulta((c) => `${c.trim()} ${termo}`.trim());
inputRef.current?.focus();
}
function onInputKey(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
setAtivo((i) => Math.min(i + 1, resultados.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setAtivo((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
ir(resultados[ativo]);
}
}
const entendeu = interpretacao.chips.length > 0 || interpretacao.topicos.length > 0;
return (
<>
<button
type="button"
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="Buscar no ESPDocs (Ctrl+K)"
>
<Search className="h-4 w-4" aria-hidden="true" />
<span className="hidden text-sm font-medium xl:inline">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>
</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="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">
<Search className="h-5 w-5 shrink-0 text-accent" aria-hidden="true" />
<input
ref={inputRef}
id="campo-busca"
type="text"
value={consulta}
onChange={(e) => setConsulta(e.target.value)}
onKeyDown={onInputKey}
placeholder="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="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="Fechar busca"
>
<X className="h-4 w-4" />
</button>
</div>
{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">Entendi:</span>
{interpretacao.chips.map((c) => (
<Pilula key={c.chave} forte>{c.nome}</Pilula>
))}
{interpretacao.topicos.map((t) => (
<Pilula key={t.id}>{t.nome}</Pilula>
))}
</div>
)}
{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 ? "Perguntar sobre:" : "Para qual chip?"}</span>
{sugestoes.map((s) => (
<Pilula key={s.rotulo} onClick={() => anexar(s.anexar)}>
{s.rotulo}
</Pilula>
))}
</div>
)}
<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">Não foi possível carregar o índice de busca.</p>
<button
type="button"
onClick={carregarIndice}
className="btn btn-primary btn-sm mt-3"
>
Tentar novamente
</button>
</div>
)}
{!erro && !indice && <p className="p-6 text-center text-sm text-muted">Carregando…</p>}
{indice && vazia && (
<div className="px-3 pb-3 pt-1">
<p className="text-xs font-semibold text-muted">Experimente perguntar</p>
<div className="mt-2 flex flex-wrap gap-2">
{EXEMPLOS_DE_BUSCA.map((ex) => (
<Pilula key={ex} onClick={() => setConsulta(ex)}>
{ex}
</Pilula>
))}
</div>
<p className="mt-4 text-xs font-semibold text-muted">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">Nenhum resultado para “{consulta}”</p>
<p className="mt-1 text-sm text-muted">Tente “brownout”, “flash s3”, “pinos usb c6” ou “LVGL”.</p>
</div>
)}
{resultados.map((item, idx) => {
const { Icon, label, cor } = TIPOS[item.tipo] ?? TIPOS.pagina;
const sel = idx === ativo;
const inteligente = INTELIGENTES.has(item.tipo);
return (
<button
key={`${item.id}-${idx}`}
type="button"
role="option"
aria-selected={sel}
data-idx={idx}
onMouseMove={() => setAtivo(idx)}
onClick={() => ir(item)}
className={`flex w-full cursor-pointer items-start gap-3 rounded-2xl p-3 text-left transition-colors ${
sel ? "bg-surface-2" : ""
} ${inteligente ? "my-1 border border-line" : ""}`}
>
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-xl ${cor}`}>
<Icon className="h-4 w-4" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">
{inteligente ? item.titulo : <Destaque texto={item.titulo} termos={termos} />}
</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold ${inteligente ? "bg-accent-soft text-accent" : cor}`}>{label}</span>
</div>
<p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-muted">
{inteligente ? item.descricao : <Destaque texto={item.descricao} termos={termos} />}
</p>
{item.linhas && (
<dl className="mt-2 grid gap-x-4 gap-y-1 text-xs sm:grid-cols-[auto_1fr]">
{item.linhas.map((l) => (
<div key={l.k} className="contents">
<dt className="font-semibold text-ink-soft">{l.k}</dt>
<dd className="text-ink">{l.v}</dd>
</div>
))}
</dl>
)}
{item.chips && (
<div className="mt-2 flex flex-wrap gap-1.5">
{item.chips.map((c) => (
<span key={c} className="rounded-md border border-line bg-surface px-1.5 py-0.5 font-mono text-xs text-ink-soft">
{c}
</span>
))}
</div>
)}
</div>
{sel && <CornerDownLeft className="mt-2 h-4 w-4 shrink-0 text-accent" aria-hidden="true" />}
</button>
);
})}
</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" /> navegar</span>
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" /> abrir</span>
<span><kbd className="font-mono font-semibold">Esc</kbd> fechar</span>
<span className="ml-auto">{vazia ? "Dica: pressione / em qualquer página" : `${resultados.length} resultado(s)`}</span>
</div>
</div>
</div>,
document.body
)}
</>
);
}
+391
View File
@@ -0,0 +1,391 @@
import { TOPICOS, normalizar } from "./searchTopics.js";
/**
* Motor da busca inteligente (puro: sem React nem DOM).
*
* Em vez de só casar texto, ele INTERPRETA a consulta:
* "flash s3" → chip = ESP32-S3, assunto = Flash
* e então monta respostas diretas: o que a série diz sobre aquele assunto,
* quais pinos do diagrama se relacionam a ele (com link já filtrado) e, por
* fim, os resultados de texto de sempre (glossário, erros, componentes...).
*/
const STOP = new Set([
"de", "do", "da", "dos", "das", "o", "a", "os", "as", "um", "uma", "no", "na", "nos", "nas", "em",
"para", "pra", "pro", "com", "sem", "que", "qual", "quais", "como", "e", "ou", "ver", "sobre", "eh",
"preciso", "quero", "onde", "fica", "ficam", "esta", "estao", "meu", "minha", "usar", "uso",
]);
const INTENCAO_PINOS = new Set(["pino", "pinos", "pinagem", "pinout", "pin", "pins", "gpios"]);
const INTENCAO_DADOS = new Set([
"dados", "especificacao", "especificacoes", "spec", "specs", "informacao", "informacoes", "info",
"quanto", "quantos", "quantas", "capacidade", "limite", "limites", "tem", "possui", "suporta",
]);
const EXEMPLOS_TOPICOS = ["flash", "psram", "usb", "adc", "i2c", "deep sleep", "wifi", "strapping"];
/** Distância de edição com corte: devolve > max assim que passa do limite. */
function distancia(a, b, max) {
if (Math.abs(a.length - b.length) > max) return max + 1;
let anterior = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const atual = [i];
let minimo = i;
for (let j = 1; j <= b.length; j++) {
const v = Math.min(anterior[j] + 1, atual[j - 1] + 1, anterior[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
atual[j] = v;
if (v < minimo) minimo = v;
}
if (minimo > max) return max + 1;
anterior = atual;
}
return anterior[b.length];
}
const palavrasDe = (texto) => texto.split(/[^a-z0-9]+/).filter((w) => w.length > 1);
/** Normaliza o payload do servidor uma única vez, na abertura da busca. */
export function preparar(dados) {
if (!dados) return { itens: [], series: {}, porFuncao: new Map(), aliases: new Map(), chaves: [] };
const itensRaw = Array.isArray(dados) ? dados : (dados.itens ?? []);
const seriesRaw = Array.isArray(dados) ? {} : (dados.series ?? {});
const itens = itensRaw.map((d) => {
const _t = normalizar(d.titulo ?? "");
const _d = normalizar(d.descricao ?? "");
const _k = normalizar(d.keywords ?? "");
return { ...d, _t, _d, _k, _all: `${_t} ${_k} ${_d}`, _w: new Set(palavrasDe(`${_t} ${_k}`)) };
});
const series = {};
const porFuncao = new Map(); // "gpio26" | "mtck" -> [{ chave, pino }]
const aliases = new Map();
for (const [chave, s] of Object.entries(seriesRaw)) {
const pinos = (s.pinos ?? []).map((p) => {
const fs = (p.f ?? []).map(normalizar);
const avs = (p.a ?? []).map(normalizar);
return {
...p,
_fs: fs,
_fn: fs,
_avs: avs,
_nome: normalizar(p.nome ?? ""),
_blob: normalizar([p.nome ?? "", ...(p.f ?? []), ...(p.c ?? []), ...(p.a ?? [])].join(" ")),
};
});
series[chave] = { ...s, pinos };
for (const p of pinos) {
for (const f of new Set([p._nome, ...p._fs])) {
if (!/^[a-z0-9_]{3,}$/.test(f)) continue;
if (!porFuncao.has(f)) porFuncao.set(f, []);
porFuncao.get(f).push({ chave, pino: p });
}
}
const k = normalizar(chave);
aliases.set(k, chave);
aliases.set(k.replace("-", ""), chave);
if (k.startsWith("esp32-")) aliases.set(k.slice(6), chave);
}
return { itens, series, porFuncao, aliases, chaves: Object.keys(series) };
}
/** Tenta casar um termo do dicionário com os tokens livres. Devolve { idx[], qualidade } ou null. */
function casarTermo(termo, livres) {
const partes = termo.split(" ");
if (partes.length > 1) {
for (let i = 0; i + partes.length <= livres.length; i++) {
if (partes.every((p, j) => livres[i + j].t === p)) {
return { idx: partes.map((_, j) => livres[i + j].i), qualidade: 3 + partes.length };
}
}
return null;
}
for (const { t, i } of livres) {
if (t === termo) return { idx: [i], qualidade: 3 };
if (t === `${termo}s` || `${t}s` === termo) return { idx: [i], qualidade: 2.5 };
if (termo.length >= 5 && t.length >= 5 && distancia(t, termo, 1) <= 1) return { idx: [i], qualidade: 1 };
}
return null;
}
export function interpretar(indice, consulta) {
const bruto = normalizar(consulta)
.replace(/gpio\s*-?\s*(\d+)/g, "gpio$1")
.replace(/[^a-z0-9.+\-_ ]/g, " ");
const tokens = bruto.split(/\s+/).filter(Boolean);
const usados = new Set();
// 1) Chips
const chips = [];
tokens.forEach((t, i) => {
const chave = indice.aliases.get(t);
if (chave && t !== "esp32") {
if (!chips.includes(chave)) chips.push(chave);
usados.add(i);
}
});
tokens.forEach((t, i) => {
if (t !== "esp32") return;
usados.add(i);
if (!chips.length && indice.series.ESP32) chips.push("ESP32");
});
// 2) Pinos citados (GPIO26) e palavras de intenção / ligação
const gpios = [];
let querPinos = false;
let querDados = false;
tokens.forEach((t, i) => {
if (/^gpio\d+$/.test(t)) {
gpios.push(t);
usados.add(i);
} else if (INTENCAO_PINOS.has(t)) {
querPinos = true;
usados.add(i);
} else if (INTENCAO_DADOS.has(t)) {
querDados = true;
usados.add(i);
} else if (STOP.has(t)) {
usados.add(i);
}
});
// 3) Assuntos: os melhores casamentos, sem reaproveitar token
const livres = tokens.map((t, i) => ({ t, i })).filter(({ i }) => !usados.has(i));
const candidatos = [];
for (const topico of TOPICOS) {
let melhor = null;
for (const termo of topico.termos) {
const m = casarTermo(normalizar(termo), livres);
if (m && (!melhor || m.qualidade > melhor.qualidade)) melhor = m;
}
if (melhor) candidatos.push({ topico, ...melhor });
}
candidatos.sort((a, b) => b.qualidade - a.qualidade);
const topicos = [];
for (const c of candidatos) {
if (topicos.length >= 3) break;
if (c.idx.some((i) => usados.has(i))) continue;
c.idx.forEach((i) => usados.add(i));
topicos.push(c.topico);
}
// 4) Nome/função de pino exato (ex.: "mtck", "xtal_p")
const funcoes = [];
tokens.forEach((t, i) => {
if (usados.has(i) || t.length < 3) return;
if (indice.porFuncao.has(t)) {
funcoes.push(t);
usados.add(i);
}
});
const restantes = tokens.filter((_, i) => !usados.has(i));
return { tokens, chips, topicos, gpios, funcoes, restantes, querPinos, querDados };
}
const rotuloPino = (p) => {
const gpio = p.f.find((f) => /^GPIO\d+$/i.test(f));
return gpio && gpio.toLowerCase() !== p.nome.toLowerCase() ? `${gpio}/${p.nome}` : p.nome;
};
/** Pinos do diagrama ligados ao assunto, já separados por nível de certeza. */
function pinosDoTopico(serie, topico) {
const forte = [];
const fraco = [];
for (const p of serie.pinos) {
let nivel = null;
if (topico.pinoNivel) nivel = topico.pinoNivel(p);
else if ((topico.pinoCats && p.c.some((c) => topico.pinoCats.includes(c))) || (topico.pinoRe && topico.pinoRe.test(p._blob))) nivel = "forte";
if (nivel === "forte") forte.push(p);
else if (nivel === "fraco") fraco.push(p);
}
return { forte, fraco };
}
const hrefPinos = (chave, pinos, motivo) =>
`/series/${chave}?pinos=${pinos.map((p) => p.n).join(",")}&motivo=${encodeURIComponent(motivo)}#connections`;
function respostas(indice, it) {
const saida = [];
const alvos = it.chips.slice(0, 2);
for (const chave of alvos) {
const serie = indice.series[chave];
if (!serie) continue;
for (const topico of it.topicos.slice(0, 2)) {
const specs = serie.specs.filter((s) => s.topicos.includes(topico.id));
const cartaoSpec = specs.length
? {
tipo: "resposta",
titulo: `${topico.nome} no ${serie.nome}`,
descricao: "Dados da série",
linhas: specs.slice(0, 5).map((s) => ({ k: s.label, v: s.valor })),
href: `/series/${chave}#${specs[0].secao}`,
}
: null;
const { forte, fraco } = pinosDoTopico(serie, topico);
const principais = forte.length ? forte : fraco;
const cartaoPinos = principais.length
? {
tipo: "pino",
titulo: `Pinos do ${serie.nome} ${topico.pinoRotulo}`,
descricao:
forte.length && fraco.length
? `${forte.length} ocupados + ${fraco.length} que dependem do modo de uso (ex.: SPI de 8 linhas) · abre o diagrama filtrado`
: forte.length
? `${forte.length} ${forte.length === 1 ? "pino" : "pinos"} · abre o diagrama já filtrado`
: `${fraco.length} pinos que só são afetados em certos modos de uso · abre o diagrama filtrado`,
chips: forte
.slice(0, 12)
.map(rotuloPino)
.concat(forte.length > 12 ? [`+${forte.length - 12}`] : [])
.concat(fraco.length && forte.length ? [`+${fraco.length} condicionais`] : forte.length ? [] : fraco.slice(0, 10).map(rotuloPino)),
href: hrefPinos(chave, [...forte, ...fraco], `${topico.nome}: pinos ${topico.pinoRotulo}`),
}
: null;
(it.querPinos ? [cartaoPinos, cartaoSpec] : [cartaoSpec, cartaoPinos]).filter(Boolean).forEach((c) => saida.push(c));
}
if (!it.topicos.length && !it.gpios.length && !it.funcoes.length) {
if (it.querPinos && serie.pinos.length) {
saida.push({
tipo: "pino",
titulo: `Diagrama de pinos do ${serie.nome}`,
descricao: `${serie.pinos.length} pinos com funções, restrições e avisos`,
href: `/series/${chave}#connections`,
});
}
if (it.querDados) {
saida.push({ tipo: "resposta", titulo: `Especificações do ${serie.nome}`, descricao: "Processador, memória, conectividade, periféricos e mais", href: `/series/${chave}` });
}
}
}
// Assunto sem chip: panorama por série ("qual série tem PSRAM?")
if (!alvos.length && it.topicos.length) {
const topico = it.topicos[0];
const linhas = [];
for (const chave of indice.chaves) {
const serie = indice.series[chave];
const spec = serie.specs.find((s) => s.topicos.includes(topico.id));
if (spec) linhas.push({ k: serie.nome, v: spec.valor.length > 46 ? `${spec.valor.slice(0, 45)}…` : spec.valor });
}
if (linhas.length > 1) {
saida.push({ tipo: "resposta", titulo: `${topico.nome} em cada série`, descricao: "Comparativo rápido · adicione o chip na busca para detalhar", linhas, href: "/comparacao" });
}
}
// GPIO26 e nomes de função: "gpio26 s3", "mtck"
const buscas = [...it.gpios, ...it.funcoes];
for (const termo of buscas) {
const achados = indice.porFuncao.get(termo) ?? [];
const filtrados = it.chips.length ? achados.filter((a) => it.chips.includes(a.chave)) : achados;
for (const { chave, pino } of filtrados.slice(0, it.chips.length ? 3 : 5)) {
const serie = indice.series[chave];
saida.push({
tipo: "pino",
titulo: `${rotuloPino(pino)} no ${serie.nome}`,
descricao: [pino.f.join(" · "), pino.a[0]].filter(Boolean).join(" — "),
href: hrefPinos(chave, [pino], `Pino ${rotuloPino(pino)}`),
});
}
}
return saida;
}
function pontuarTermo(item, t) {
if (item._t === t) return 100;
if (item._t.startsWith(t)) return 60;
if (item._t.includes(t)) return 40;
if (item._k.includes(t)) return 15;
if (item._d.includes(t)) return 10;
if (t.length >= 4) {
const max = t.length >= 8 ? 2 : 1;
for (const w of item._w) if (distancia(w, t, max) <= max) return 6;
}
return 0;
}
function casaTopico(item, topico) {
let melhor = 0;
for (const termo of topico.termos) {
const n = normalizar(termo);
if (n.includes(" ") ? item._all.includes(n) : item._w.has(n)) {
melhor = Math.max(melhor, item._t.includes(n) ? 2 : 1);
}
}
return melhor;
}
const topicoCasou = (item, topicos) => topicos.some((t) => casaTopico(item, t));
export function buscar(indice, consulta) {
const it = interpretar(indice, consulta);
const interpretacao = {
chips: it.chips.map((c) => ({ chave: c, nome: indice.series[c]?.nome ?? c })),
topicos: it.topicos.map((t) => ({ id: t.id, nome: t.nome })),
};
const smart = respostas(indice, it).map((r, i) => ({ id: `r${i}`, keywords: "", ...r }));
const nomesChips = it.chips.map((c) => new RegExp(`(?<![a-z0-9])${normalizar(c).replace(/[-]/g, "\\-")}(?![a-z0-9]|-[a-z0-9])`));
const pontuados = [];
for (const item of indice.itens) {
let s = 0;
let ok = true;
for (const t of it.restantes) {
const p = pontuarTermo(item, t);
if (!p) {
ok = false;
break;
}
s += p;
}
if (!ok) continue;
let acertos = 0;
for (const topico of it.topicos) {
const m = casaTopico(item, topico);
if (m) {
s += m === 2 ? 50 : 20;
acertos++;
}
}
nomesChips.forEach((nome, i) => {
if (item.tipo === "serie" && item.titulo === it.chips[i]) {
s += 300;
acertos++;
} else if (nome.test(item._all)) {
s += 25;
acertos++;
}
});
const ehSerie = item.tipo === "serie" && it.chips.includes(item.titulo);
if (!it.restantes.length && !(it.topicos.length ? acertos > (ehSerie ? 0 : 0) && topicoCasou(item, it.topicos) : ehSerie)) continue;
if (s > 0) pontuados.push({ item, s });
}
pontuados.sort((a, b) => b.s - a.s);
// Quando já respondemos com cartões, a série que repete a resposta fica no fim.
const texto = pontuados.slice(0, 24).map((p) => p.item);
const sugestoes = [];
if (it.topicos.length && !it.chips.length) {
for (const c of indice.chaves.slice(0, 12)) sugestoes.push({ rotulo: indice.series[c].nome, anexar: c });
} else if (it.chips.length && !it.topicos.length && !it.gpios.length) {
for (const nome of EXEMPLOS_TOPICOS) sugestoes.push({ rotulo: nome, anexar: nome });
}
return { resultados: [...smart, ...texto], interpretacao, sugestoes, totalRespostas: smart.length };
}
export const EXEMPLOS_DE_BUSCA = ["flash s3", "pinos usb c6", "deep sleep p4", "gpio0 esp32", "secure boot c3", "psram"];
+104
View File
@@ -0,0 +1,104 @@
import seriesData from "@/public/series.json";
import frameworksData from "@/public/frameworks.json";
import { categoriasGlossario } from "@/lib/glossarioData";
import { errosCatalogo } from "@/lib/diagnosticoData";
import { COMPONENTES_REGISTRY } from "@/lib/componentRegistryData";
import { montarConhecimento } from "@/lib/searchTopics";
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
/**
* Índice de busca do site, montado no servidor a partir dos mesmos dados que
* alimentam as páginas. Uma série, termo ou erro novo entra sozinho na busca.
*
* Cada entrada: { id, tipo, titulo, descricao, href, keywords }
* `keywords` guarda texto extra pesquisável (não exibido).
*/
const PAGINAS = [
{ titulo: "Início", descricao: "Página inicial do ESPDocs", href: "/", keywords: "home inicio" },
{ titulo: "Todas as Séries", descricao: "Pinouts, especificações e restrições de cada série ESP32", href: "/series", keywords: "chips pinout especificações" },
{ titulo: "Comparador de Séries", descricao: "Compare até 4 modelos lado a lado", href: "/comparacao", keywords: "comparar comparação versus" },
{ titulo: "Catálogo de Placas", descricao: "Placas de desenvolvimento, conectores e compras", href: "/catalogo", keywords: "placas dev boards comprar devkit" },
{ titulo: "Seletor Inteligente", descricao: "Descubra o chip ideal para o seu projeto", href: "/seletor", keywords: "quiz escolher recomendar" },
{ titulo: "Diagnóstico de Erros", descricao: "Solucione brownout, bootloop, WDT e falhas", href: "/diagnostico", keywords: "erro problema bug falha" },
{ titulo: "Frameworks & SDKs", descricao: "ESP-IDF, Arduino, Rust, RainMaker e mais", href: "/frameworks", keywords: "sdk" },
{ titulo: "Component Registry", descricao: "Catálogo de drivers e gerador de idf_component.yml", href: "/componentes", keywords: "bibliotecas drivers yml" },
{ titulo: "Glossário Técnico", descricao: "Conceitos essenciais de hardware e software", href: "/glossario", keywords: "termos dicionário significado" },
{ titulo: "O Livro", descricao: "Microcontroladores ESP32: conectividade, eficiência e desempenho", href: "/livro", keywords: "livro book capa comprar" },
{ titulo: "Sobre o Projeto", descricao: "História, autor e Ciência Embarcada", href: "/sobre", keywords: "autor contato" },
];
const cortar = (texto = "", max = 140) => (texto.length > max ? `${texto.slice(0, max - 1).trimEnd()}…` : texto);
export function construirIndiceBusca() {
const itens = [];
for (const p of PAGINAS) {
itens.push({ tipo: "pagina", ...p, keywords: p.keywords });
}
for (const [chave, s] of Object.entries(seriesData)) {
itens.push({
tipo: "serie",
titulo: chave,
descricao: cortar(`${s.nome_completo ?? ""} — ${s.descricao ?? ""}`),
href: `/series/${chave}`,
keywords: [s.arquitetura, s.wifi, s.bluetooth, s.frequencia, s.nucleos].filter(Boolean).join(" "),
});
}
for (const [chave, f] of Object.entries(frameworksData)) {
itens.push({
tipo: "framework",
titulo: f.nome ?? chave,
descricao: cortar(`${f.nome_completo ?? ""} — ${f.descricao ?? ""}`),
href: `/frameworks/${encodeURIComponent(chave)}`,
keywords: [f.linguagem, f.tipo, f.funcao_principal].filter(Boolean).join(" "),
});
}
for (const cat of categoriasGlossario) {
for (const t of cat.termos) {
const slug = t.termo.toLowerCase().replace(/[\s()/²]+/g, "-");
itens.push({
tipo: "glossario",
titulo: t.termo,
descricao: cortar(t.definicao),
href: `/glossario#${slug}`,
keywords: [cat.nome, t.exemplo].filter(Boolean).join(" "),
});
}
}
for (const e of errosCatalogo) {
itens.push({
tipo: "erro",
titulo: e.titulo,
descricao: cortar(e.resumo),
href: `/diagnostico#${e.id}`,
keywords: [e.logSerial, ...(e.sintomas ?? [])].filter(Boolean).join(" "),
});
}
for (const c of COMPONENTES_REGISTRY) {
itens.push({
tipo: "componente",
titulo: c.titulo,
descricao: cortar(c.descricao),
href: "/componentes",
keywords: [c.name, c.namespace, ...(c.tags ?? [])].filter(Boolean).join(" "),
});
}
const conexoes = {};
for (const chave of Object.keys(seriesData)) {
const arquivo = path.join(process.cwd(), "public", "conexoes", `${chave}.json`);
if (existsSync(arquivo)) conexoes[chave] = JSON.parse(readFileSync(arquivo, "utf8"));
}
return {
itens: itens.map((item, i) => ({ id: i, ...item })),
series: montarConhecimento(seriesData, conexoes),
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Conhecimento de domínio da busca inteligente.
*
* Sem dependências nem alias de import: roda no servidor (montando o índice),
* no navegador (interpretando a consulta) e em scripts de teste no Node.
*
* Todos os textos de `termos` já estão normalizados (minúsculos, sem acento).
*/
export const normalizar = (s = "") =>
String(s)
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
/**
* Reconhece pinos ocupados por flash/PSRAM com base no que o diagrama diz.
* "forte": o pino é consumido pela memória (ou é a alimentação dela).
* "fraco": só é afetado em certos modos (ex.: SPI de 8 linhas/octal).
* Avisos genéricos de tensão mínima ("para chips com flash integrada...") não contam.
*/
const nivelMemoria = (palavra) => (p) => {
const citam = p._avs.filter((a) => a.includes(palavra) && !/^para chips (com|sem)/.test(a));
if (citam.some((a) => /(utilizad|dedicad|conectad)/.test(a) && !/a menos que/.test(a))) return "forte";
if (p._fn.some((f) => /alimentacao/.test(f) && f.includes(palavra))) return "forte";
if (citam.length) return "fraco";
return null;
};
/**
* Assuntos que o usuário pode citar. Cada um sabe:
* - termos: como as pessoas escrevem (sinônimos, siglas, plurais comuns);
* - pinoCats / pinoRe: como reconhecer os pinos relacionados no diagrama;
* - pinoRotulo: frase usada no título do resultado de pinos.
* Os dados de especificação ligam-se aos assuntos por SPEC_CAMPOS.
*/
export const TOPICOS = [
{ id: "flash", nome: "Flash", termos: ["flash", "memoria flash", "spi flash", "armazenamento", "firmware", "rom externa"], pinoNivel: nivelMemoria("flash"), pinoRotulo: "ligados à memória flash" },
{ id: "psram", nome: "PSRAM", termos: ["psram", "ram externa", "spiram"], pinoNivel: nivelMemoria("psram"), pinoRotulo: "ligados à PSRAM" },
{ id: "memoria", nome: "Memória", termos: ["memoria", "sram", "heap", "rtc ram", "kb de ram", "memoria interna", "ram"], pinoRotulo: "" },
{ id: "wifi", nome: "Wi-Fi", termos: ["wifi", "wi-fi", "wireless", "sem fio", "802.11", "wifi6", "wifi 6", "wi-fi 6"], pinoCats: ["Antena"], pinoRotulo: "de antena e rádio" },
{ id: "bluetooth", nome: "Bluetooth", termos: ["bluetooth", "ble", "bt", "bluetooth le", "mesh"], pinoCats: ["Antena"], pinoRotulo: "de antena e rádio" },
{ id: "zigbee", nome: "Zigbee/Thread", termos: ["zigbee", "thread", "802.15.4", "ieee 802.15.4"], pinoRotulo: "" },
{ id: "matter", nome: "Matter", termos: ["matter", "casa inteligente", "smart home"], pinoRotulo: "" },
{ id: "ethernet", nome: "Ethernet", termos: ["ethernet", "rmii", "cabo de rede", "lan"], pinoCats: ["Ethernet"], pinoRotulo: "de Ethernet" },
{ id: "can", nome: "CAN / TWAI", termos: ["can", "twai", "can bus", "canbus", "can fd"], pinoRe: /twai|\bcan\b/, pinoRotulo: "de CAN/TWAI" },
{ id: "usb", nome: "USB", termos: ["usb", "otg", "usb serial", "usb otg", "hid", "cdc"], pinoCats: ["USB"], pinoRotulo: "de USB" },
{ id: "jtag", nome: "JTAG / Depuração", termos: ["jtag", "debug", "depuracao", "depurar", "openocd", "gdb"], pinoCats: ["JTAG"], pinoRotulo: "de JTAG" },
{ id: "adc", nome: "ADC", termos: ["adc", "analogico", "entrada analogica", "conversor analogico", "analogread", "saradc"], pinoCats: ["ADC"], pinoRotulo: "com ADC" },
{ id: "dac", nome: "DAC", termos: ["dac", "saida analogica", "conversor digital"], pinoCats: ["DAC"], pinoRotulo: "com DAC" },
{ id: "touch", nome: "Touch capacitivo", termos: ["touch", "capacitivo", "toque", "touchpad", "sensor de toque"], pinoCats: ["Touch"], pinoRotulo: "de touch capacitivo" },
{ id: "uart", nome: "UART", termos: ["uart", "serial", "tx", "rx", "txd", "rxd", "porta serial"], pinoCats: ["UART"], pinoRotulo: "de UART" },
{ id: "spi", nome: "SPI", termos: ["spi", "mosi", "miso", "sclk", "barramento spi"], pinoCats: ["SPI"], pinoRotulo: "de SPI" },
{ id: "i2c", nome: "I2C", termos: ["i2c", "sda", "scl", "twi", "iic"], pinoCats: ["I2C"], pinoRotulo: "de I2C" },
{ id: "i2s", nome: "I2S", termos: ["i2s", "audio", "som", "microfone", "pdm", "codec"], pinoRe: /i2s|\bpdm\b/, pinoRotulo: "de I2S/áudio" },
{ id: "pwm", nome: "PWM", termos: ["pwm", "ledc", "mcpwm", "servo", "dimmer"], pinoRe: /ledc|pwm/, pinoRotulo: "de PWM" },
{ id: "sd", nome: "Cartão SD / SDIO", termos: ["sd", "sdio", "sd card", "sdcard", "cartao sd", "microsd", "sdmmc", "mmc"], pinoCats: ["SD"], pinoRe: /sdio|sdmmc|\bsd\b/, pinoRotulo: "de cartão SD/SDIO" },
{ id: "camera", nome: "Câmera", termos: ["camera", "cam", "dvp", "csi", "mipi csi", "ov2640", "video"], pinoCats: ["CSI"], pinoRe: /\bcam\b|dvp|cam_/, pinoRotulo: "de câmera" },
{ id: "lcd", nome: "Display / LCD", termos: ["lcd", "display", "tela", "dsi", "mipi dsi", "oled", "tft", "lvgl", "rgb"], pinoCats: ["DSI"], pinoRe: /lcd/, pinoRotulo: "de display/LCD" },
{ id: "strapping", nome: "Boot / Strapping", termos: ["strapping", "boot", "bootstrap", "modo de boot", "download mode", "modo de gravacao", "inicializacao"], pinoCats: ["Strapping"], pinoRotulo: "de strapping (modo de boot)" },
{ id: "energia_pinos", nome: "Alimentação", termos: ["alimentacao", "vdd", "3v3", "3.3v", "tensao", "regulador", "ldo", "fonte"], pinoCats: ["Power"], pinoRotulo: "de alimentação" },
{ id: "clock", nome: "Clock / Cristal", termos: ["clock", "xtal", "cristal", "oscilador", "32khz", "40mhz"], pinoCats: ["Clock"], pinoRotulo: "de clock/cristal" },
{ id: "gpio", nome: "GPIO", termos: ["gpio", "entradas e saidas"], pinoCats: ["GPIO"], pinoRotulo: "de GPIO" },
{ id: "baixo_consumo", nome: "Baixo consumo / ULP", termos: ["ulp", "lp core", "lp cpu", "coprocessador", "lp gpio", "low power", "rtc gpio"], pinoCats: ["Low Power"], pinoRotulo: "de baixo consumo (LP/RTC)" },
{ id: "antena", nome: "Antena / RF", termos: ["antena", "rf", "radiofrequencia", "ipex"], pinoCats: ["Antena"], pinoRotulo: "de antena/RF" },
{ id: "seguranca", nome: "Segurança", termos: ["seguranca", "secure boot", "criptografia", "encriptacao", "aes", "sha", "rsa", "hmac", "ecdsa", "efuse", "tee", "trng", "rng", "hsm", "protecao"], pinoRotulo: "" },
{ id: "consumo", nome: "Consumo de energia", termos: ["consumo", "bateria", "deep sleep", "light sleep", "sleep", "modem sleep", "corrente", "miliamperes", "autonomia", "economia de energia", "low power mode"], pinoRotulo: "" },
{ id: "temperatura", nome: "Temperatura", termos: ["temperatura", "calor", "graus", "faixa de temperatura", "industrial"], pinoRotulo: "" },
{ id: "ia", nome: "IA / Aceleração", termos: ["ia", "ai", "machine learning", "ml", "tinyml", "vetorial", "inteligencia artificial", "rede neural", "esp-nn", "esp-dl"], pinoRotulo: "" },
{ id: "processador", nome: "Processador", termos: ["processador", "cpu", "nucleo", "nucleos", "core", "dual core", "single core", "arquitetura", "xtensa", "risc-v", "riscv"], pinoRotulo: "" },
{ id: "frequencia", nome: "Frequência", termos: ["frequencia", "mhz", "velocidade", "desempenho", "performance"], pinoRotulo: "" },
{ id: "ferramentas", nome: "Ferramentas / SDK", termos: ["esp-idf", "idf", "arduino", "sdk", "toolchain", "versao do idf"], pinoRotulo: "" },
{ id: "encapsulamento", nome: "Módulos e encapsulamento", termos: ["modulo", "modulos", "encapsulamento", "package", "qfn", "wroom", "wrover", "mini-1"], pinoRotulo: "" },
];
/**
* Campos de public/series.json que respondem a cada assunto.
* `campo` aceita caminho com ponto (seguranca.aes). `secao` é o id da âncora
* na página /series/[key].
*/
export const SPEC_CAMPOS = [
{ campo: "arquitetura", label: "Arquitetura", secao: "processador", topicos: ["processador"] },
{ campo: "nucleos", label: "Núcleos", secao: "processador", topicos: ["processador"] },
{ campo: "frequencia", label: "Frequência", secao: "processador", topicos: ["frequencia", "processador"] },
{ campo: "coprocessador_ulp", label: "Coprocessador ULP", secao: "processador", topicos: ["baixo_consumo"] },
{ campo: "aceleradores_ia", label: "Aceleradores de IA", secao: "processador", topicos: ["ia"] },
{ campo: "wifi", label: "Wi-Fi", secao: "conectividade", topicos: ["wifi"] },
{ campo: "bluetooth", label: "Bluetooth", secao: "conectividade", topicos: ["bluetooth"] },
{ campo: "zigbee_thread", label: "Zigbee/Thread", secao: "conectividade", topicos: ["zigbee"] },
{ campo: "matter", label: "Matter", secao: "conectividade", topicos: ["matter"] },
{ campo: "ethernet", label: "Ethernet", secao: "conectividade", topicos: ["ethernet"] },
{ campo: "ethernet_mac", label: "Ethernet MAC", secao: "conectividade", topicos: ["ethernet"] },
{ campo: "can", label: "CAN", secao: "conectividade", topicos: ["can"] },
{ campo: "memoria_sram", label: "SRAM", secao: "memoria", topicos: ["memoria"] },
{ campo: "memoria_sram_rtc", label: "SRAM RTC", secao: "memoria", topicos: ["memoria", "baixo_consumo"] },
{ campo: "memoria_rom", label: "ROM", secao: "memoria", topicos: ["memoria"] },
{ campo: "flash_externa", label: "Flash externa", secao: "memoria", topicos: ["flash", "memoria"] },
{ campo: "psram_externa", label: "PSRAM externa", secao: "memoria", topicos: ["psram", "memoria"] },
{ campo: "gpio", label: "GPIO", secao: "perifericos", topicos: ["gpio"] },
{ campo: "adc", label: "ADC", secao: "perifericos", topicos: ["adc"] },
{ campo: "dac", label: "DAC", secao: "perifericos", topicos: ["dac"] },
{ campo: "touch", label: "Touch", secao: "perifericos", topicos: ["touch"] },
{ campo: "uart", label: "UART", secao: "perifericos", topicos: ["uart"] },
{ campo: "spi", label: "SPI", secao: "perifericos", topicos: ["spi", "flash"] },
{ campo: "i2c", label: "I2C", secao: "perifericos", topicos: ["i2c"] },
{ campo: "i2s", label: "I2S", secao: "perifericos", topicos: ["i2s"] },
{ campo: "pwm", label: "PWM", secao: "perifericos", topicos: ["pwm"] },
{ campo: "usb", label: "USB", secao: "perifericos", topicos: ["usb"] },
{ campo: "lcd", label: "LCD", secao: "interfaces", topicos: ["lcd"] },
{ campo: "camera", label: "Câmera", secao: "interfaces", topicos: ["camera"] },
{ campo: "mipi_csi", label: "MIPI CSI", secao: "interfaces", topicos: ["camera"] },
{ campo: "mipi_dsi", label: "MIPI DSI", secao: "interfaces", topicos: ["lcd"] },
{ campo: "sdio", label: "SDIO", secao: "interfaces", topicos: ["sd"] },
{ campo: "sensor_temperatura", label: "Sensor de temperatura", secao: "interfaces", topicos: ["temperatura"] },
{ campo: "seguranca.aes", label: "AES", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.sha", label: "SHA", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.rsa", label: "RSA", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.ecc", label: "ECC", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.ecdsa", label: "ECDSA", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.hmac", label: "HMAC", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.assinatura_digital", label: "Assinatura digital", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.rng", label: "Gerador aleatório", secao: "seguranca", topicos: ["seguranca"] },
{ campo: "seguranca.criptografia_flash", label: "Criptografia de flash", secao: "seguranca", topicos: ["seguranca", "flash"] },
{ campo: "seguranca.secure_boot", label: "Secure Boot", secao: "seguranca", topicos: ["seguranca", "strapping"] },
{ campo: "temperatura_operacao", label: "Temperatura de operação", secao: "temperatura", topicos: ["temperatura"] },
{ campo: "temperatura_armazenamento", label: "Temperatura de armazenamento", secao: "temperatura", topicos: ["temperatura"] },
{ campo: "esp_idf_minimo", label: "ESP-IDF mínimo", secao: "ferramentas", topicos: ["ferramentas"] },
{ campo: "arduino_core", label: "Core Arduino", secao: "ferramentas", topicos: ["ferramentas"] },
{ campo: "depuracao", label: "Depuração", secao: "ferramentas", topicos: ["jtag", "ferramentas"] },
{ campo: "encapsulamento", label: "Encapsulamento", secao: "ferramentas", topicos: ["encapsulamento"] },
{ campo: "modulos", label: "Módulos", secao: "ferramentas", topicos: ["encapsulamento"] },
];
const ROTULOS_ENERGIA = {
active: "Ativo",
modem_sleep: "Modem Sleep",
light_sleep: "Light Sleep",
deep_sleep: "Deep Sleep",
hibernation: "Hibernação",
};
const pegar = (obj, caminho) => caminho.split(".").reduce((o, k) => (o == null ? o : o[k]), obj);
/**
* Monta, para cada série, a lista plana de especificações (com os assuntos a
* que respondem) e os pinos do diagrama. É o que o navegador baixa.
*
* @param {Record<string, object>} series conteúdo de public/series.json
* @param {Record<string, object[]>} conexoes pinos por série (public/conexoes)
*/
export function montarConhecimento(series, conexoes) {
const saida = {};
for (const [chave, s] of Object.entries(series)) {
const specs = [];
for (const c of SPEC_CAMPOS) {
const valor = pegar(s, c.campo);
if (valor == null || valor === "") continue;
specs.push({ label: c.label, valor: String(valor), secao: c.secao, topicos: c.topicos });
}
for (const [modo, valor] of Object.entries(s.consumo_energia ?? {})) {
const base = modo.replace(/_cpu.*|_wifi.*/, "");
const label = ROTULOS_ENERGIA[modo] ?? ROTULOS_ENERGIA[base] ?? modo.replace(/_/g, " ");
specs.push({ label: `Consumo: ${label}`, valor: String(valor), secao: "energia", topicos: ["consumo"] });
}
saida[chave] = {
nome: s.nome_completo ?? chave,
specs,
pinos: (conexoes[chave] ?? []).map((p) => ({
n: String(p.numero),
nome: p.nome,
f: p.funcoes ?? [],
c: p.categorias ?? [],
a: p.avisos ?? [],
})),
};
}
return saida;
}