Compare commits
4
Commits
b1a4f4263f
...
f6e10a293a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e10a293a | ||
|
|
4ad130a334 | ||
|
|
7d0edf13c4 | ||
|
|
d8323c653b |
No files matched your search
+43
-20
@@ -3,9 +3,13 @@ name: CI
|
||||
# Documentação e configuração de ferramenta não mudam nada que a suíte teste.
|
||||
# Na main não roda por push: o release.yml é quem a chama, e só quando a imagem
|
||||
# precisa ser reconstruída; rodar nos dois repetiria a mesma suíte no mesmo commit.
|
||||
# Sem gatilho de pull_request: o push da branch já valida o mesmo commit, e os
|
||||
# dois juntos rodariam tudo duas vezes (o repositório não recebe PR de fork).
|
||||
#
|
||||
# A imagem é construída aqui uma única vez, em arm64 (a arquitetura do servidor),
|
||||
# testada e publicada como inputs-<hash>. Ao fazer o merge, o release encontra
|
||||
# essa tag e só aplica os nomes novos, sem rodar nem construir nada de novo.
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore: ['**.md', 'LICENSE', '.claude/**', '.superset/**', '.wolf/**', 'scripts/release-notes.sh']
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
paths-ignore: ['**.md', 'LICENSE', '.claude/**', '.superset/**', '.wolf/**', 'scripts/release-notes.sh']
|
||||
@@ -20,7 +24,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
verificar:
|
||||
name: Lint, design e build
|
||||
name: Lint e auditorias
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
@@ -35,24 +39,11 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# Next.js guarda aqui o resultado da compilação anterior; sem isso, cada
|
||||
# execução recompila todas as páginas do zero.
|
||||
- name: Cache de build do Next
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .next/cache
|
||||
key: next-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('app/**', 'components/**', 'lib/**') }}
|
||||
restore-keys: |
|
||||
next-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}-
|
||||
next-${{ runner.arch }}-
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# eslint + auditoria do design system (tokens, contraste AAA) + auditoria de páginas
|
||||
- run: npm run check
|
||||
|
||||
- run: npm run build
|
||||
|
||||
# Trivy roda pela imagem oficial: o trivy-action clona o repositório do Trivy
|
||||
# do mesmo servidor do workflow, que aqui é o Gitea, e não o GitHub.
|
||||
# O workspace do job é um volume que o daemon não enxerga, então os arquivos
|
||||
@@ -72,20 +63,44 @@ jobs:
|
||||
docker rm "$cid" >/dev/null
|
||||
exit "$status"
|
||||
|
||||
# Só começa depois das verificações baratas: um lint quebrado não gasta um
|
||||
# build de imagem. Roda no runner arm64, o mesmo tipo do servidor, então o
|
||||
# que é escaneado e testado é a imagem que vai ao ar.
|
||||
imagem:
|
||||
name: Imagem Docker
|
||||
runs-on: ubuntu-latest
|
||||
name: Imagem Docker (linux/arm64)
|
||||
needs: [verificar, seguranca]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
env:
|
||||
REGISTRY: git.lucasrguerra.dev.br
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# `docker build` simples: o cache de camadas fica no volume persistente do
|
||||
# daemon do runner, e o buildx criaria um BuildKit novo a cada job.
|
||||
|
||||
# Conteúdo já publicado (re-push, tag num commit já validado): nada a fazer.
|
||||
- id: plano
|
||||
run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- if: ${{ steps.plano.outputs.reuse != 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
# `docker build` simples, no daemon persistente do runner: as camadas e os
|
||||
# cache mounts do Dockerfile (npm e compilação do Next) sobrevivem entre
|
||||
# execuções. Um buildx criaria um BuildKit novo, e vazio, a cada job.
|
||||
- name: Build
|
||||
if: ${{ steps.plano.outputs.reuse != 'true' }}
|
||||
run: docker build -t espdocs:ci .
|
||||
|
||||
- name: Trivy na imagem
|
||||
if: ${{ steps.plano.outputs.reuse != 'true' }}
|
||||
run: |
|
||||
docker run --rm --network host -v trivy-cache:/root/.cache/ -e DOCKER_HOST=tcp://127.0.0.1:2375 aquasec/trivy:0.70.0 \
|
||||
image --severity HIGH,CRITICAL --exit-code 1 espdocs:ci
|
||||
|
||||
- name: A imagem sobe e responde
|
||||
if: ${{ steps.plano.outputs.reuse != 'true' }}
|
||||
run: |
|
||||
docker run -d --name espdocs-ci --network host -e PORT=3999 espdocs:ci
|
||||
for i in $(seq 1 30); do
|
||||
@@ -95,3 +110,11 @@ jobs:
|
||||
docker logs espdocs-ci | tail -5
|
||||
docker rm -f espdocs-ci >/dev/null
|
||||
test "${ok:-0}" = 1
|
||||
|
||||
# Passou em tudo: publica a mesma imagem que foi testada, sem reconstruir.
|
||||
- name: Publica inputs-<hash>
|
||||
if: ${{ steps.plano.outputs.reuse != 'true' }}
|
||||
run: |
|
||||
tag="${{ steps.plano.outputs.image }}:inputs-${{ steps.plano.outputs.inputs }}"
|
||||
docker tag espdocs:ci "$tag"
|
||||
docker push "$tag"
|
||||
@@ -18,10 +18,10 @@ env:
|
||||
|
||||
jobs:
|
||||
# A imagem só depende dos arquivos que entram no build, não da documentação nem
|
||||
# da configuração de ferramentas. Quando já existe no registro uma imagem para
|
||||
# o mesmo conteúdo (um merge só de docs, uma mudança de workflow, uma tag num
|
||||
# commit que acabou de ser publicado), não há o que construir: as tags novas
|
||||
# apontam para o manifesto existente.
|
||||
# da configuração de ferramentas. O push da branch já a construiu, testou e
|
||||
# publicou como inputs-<hash>; quando essa tag existe (o caso normal de um
|
||||
# merge, uma tag num commit validado, um merge só de docs), não há o que
|
||||
# construir: as tags novas apontam para o manifesto existente.
|
||||
plano:
|
||||
name: Plano (reaproveitar a imagem?)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,23 +32,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: plano
|
||||
env:
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
inputs=$(git ls-tree -r HEAD \
|
||||
| awk -F'\t' '$2 !~ /^(\.gitea|\.github|\.claude|\.superset|docs)\// && $2 !~ /^(README.*|CLAUDE\.md|DESIGN\.md|LICENSE|\.gitignore|CHANGELOG\.md|scripts\/release-notes\.sh)$/' \
|
||||
| sha256sum | cut -c1-16)
|
||||
ns="${REPO,,}"
|
||||
token=$(curl -fsS "https://$REGISTRY/v2/token?service=container_registry&scope=repository:$ns:pull" | jq -r .token)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -I \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json' \
|
||||
"https://$REGISTRY/v2/$ns/manifests/inputs-$inputs")
|
||||
echo "inputs-$inputs -> HTTP $code"
|
||||
reuse=false; [ "$code" = 200 ] && reuse=true
|
||||
echo "inputs=$inputs" >> "$GITHUB_OUTPUT"
|
||||
echo "reuse=$reuse" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$REGISTRY/$ns" >> "$GITHUB_OUTPUT"
|
||||
run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Nada é publicado sem a suíte completa passar antes. Pulada quando a imagem é
|
||||
# reaproveitada: o mesmo conteúdo já passou por ela.
|
||||
@@ -68,6 +52,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Se o ci chamado acima acabou de publicar a imagem (push direto na main),
|
||||
# o plano lá de cima está desatualizado: confere de novo antes de construir.
|
||||
- id: replano
|
||||
run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Autentica no registro
|
||||
@@ -89,7 +78,7 @@ jobs:
|
||||
type=sha,format=long
|
||||
|
||||
- name: Build e push
|
||||
if: ${{ needs.plano.outputs.reuse != 'true' }}
|
||||
if: ${{ steps.replano.outputs.reuse != 'true' }}
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -104,7 +93,7 @@ jobs:
|
||||
|
||||
# Mesmo manifesto, nomes novos: nenhuma camada é lida nem escrita.
|
||||
- name: Aplica as tags à imagem existente
|
||||
if: ${{ needs.plano.outputs.reuse == 'true' }}
|
||||
if: ${{ steps.replano.outputs.reuse == 'true' }}
|
||||
run: |
|
||||
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}')
|
||||
docker buildx imagetools create $tags "${{ needs.plano.outputs.image }}:inputs-${{ needs.plano.outputs.inputs }}"
|
||||
|
||||
+4
-2
@@ -6,14 +6,16 @@ WORKDIR /app
|
||||
# O Puppeteer só é usado em scripts locais (slides, capturas): não baixa o Chrome.
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
# Cache do npm no daemon: dependências já baixadas não vão à rede de novo.
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Cache de compilação do Next: só as páginas que mudaram são recompiladas.
|
||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
-14
@@ -1,19 +1,12 @@
|
||||
import { paginaMeta, jsonLd } from "@/lib/seo";
|
||||
import { paginaMeta, jsonLd, SITE } from "@/lib/seo";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import Image from "next/image";
|
||||
import GiteaMark from "@/components/GiteaMark";
|
||||
import { ArrowUpRight, Code, Globe, GitCompare, Info, Languages } from "lucide-react";
|
||||
|
||||
const GITHUB = "https://github.com/lucasrguerra/ESPDocs";
|
||||
|
||||
function GithubMark({ className }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 .5a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.6-4-1.6-.5-1.2-1.2-1.5-1.2-1.5-1-.7.1-.7.1-.7 1.1.1 1.7 1.1 1.7 1.1 1 .1 1.6-.7 1.9-1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.8 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.4 11.4 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.7.2 3 .1 3.3.8.8 1.2 1.9 1.2 3.1 0 4.5-2.7 5.5-5.3 5.8.5.4.9 1.1.9 2.2v3.2c0 .3.2.7.8.6A12 12 0 0012 .5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const REPOSITORIO = SITE.repositorio;
|
||||
|
||||
|
||||
export const metadata = paginaMeta({
|
||||
@@ -28,7 +21,7 @@ export default function Sobre() {
|
||||
{ icon: Languages, title: "Em português", desc: "Dados técnicos organizados e apresentados em português do Brasil." },
|
||||
{ icon: GitCompare, title: "Comparações visuais", desc: "Tabelas e diagramas para escolher a série certa sem abrir dez datasheets." },
|
||||
{ icon: Globe, title: "Acesso centralizado", desc: "Links diretos para a documentação oficial, repositórios e exemplos." },
|
||||
{ icon: Code, title: "Código aberto", desc: "Contribuições são bem-vindas no GitHub." },
|
||||
{ icon: Code, title: "Código aberto", desc: "Contribuições são bem-vindas no Gitea." },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -60,8 +53,8 @@ export default function Sobre() {
|
||||
title="Sobre o ESPDocs"
|
||||
lede="Uma plataforma não oficial, em português, que organiza as informações técnicas do ESP32 e leva você à documentação oficial da Espressif. Feita para a comunidade de hardware livre."
|
||||
>
|
||||
<a href={GITHUB} target="_blank" rel="noopener noreferrer" className="btn btn-primary">
|
||||
<GithubMark className="h-4 w-4" />
|
||||
<a href={REPOSITORIO} target="_blank" rel="noopener noreferrer" className="btn btn-primary">
|
||||
<GiteaMark className="h-4 w-4" />
|
||||
Ver o repositório
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
@@ -108,7 +101,7 @@ export default function Sobre() {
|
||||
</p>
|
||||
<ol className="mt-6 space-y-3">
|
||||
{[
|
||||
"Faça o fork do repositório no GitHub.",
|
||||
"Faça o fork do repositório no Gitea.",
|
||||
"Implemente as melhorias e confira as referências técnicas oficiais.",
|
||||
"Abra um Pull Request descrevendo os ajustes feitos.",
|
||||
].map((passo, i) => (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, Suspense } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { AlertTriangle, CheckCircle, List, Grid, Search, Sliders, X, Zap } from "lucide-react";
|
||||
|
||||
/*
|
||||
@@ -41,7 +42,25 @@ const type_colors = {
|
||||
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 [selectedConnection, setSelectedConnection] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -60,7 +79,8 @@ export default function ConnectionsDiagram({ connections, serie }) {
|
||||
searchQuery === "" ||
|
||||
connection.nome.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) => {
|
||||
@@ -70,7 +90,9 @@ export default function ConnectionsDiagram({ connections, serie }) {
|
||||
|
||||
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) => {
|
||||
gatilhoRef.current = evento.currentTarget;
|
||||
@@ -137,6 +159,18 @@ export default function ConnectionsDiagram({ connections, serie }) {
|
||||
</p>
|
||||
</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="relative flex-1">
|
||||
<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" />
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import Logo from "@/components/Logo";
|
||||
import GiteaMark from "@/components/GiteaMark";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { SITE } from "@/lib/seo";
|
||||
|
||||
@@ -123,12 +124,10 @@ export default function Footer() {
|
||||
href={SITE.repositorio}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub do projeto (abre em nova aba)"
|
||||
aria-label="Gitea do projeto (abre em nova aba)"
|
||||
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:border-line-strong hover:bg-surface"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 .5a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.6-4-1.6-.5-1.2-1.2-1.5-1.2-1.5-1-.7.1-.7.1-.7 1.1.1 1.7 1.1 1.7 1.1 1 .1 1.6-.7 1.9-1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.8 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.4 11.4 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.7.2 3 .1 3.3.8.8 1.2 1.9 1.2 3.1 0 4.5-2.7 5.5-5.3 5.8.5.4.9 1.1.9 2.2v3.2c0 .3.2.7.8.6A12 12 0 0 0 12 .5z" />
|
||||
</svg>
|
||||
<GiteaMark className="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href={SITE.autorLinkedin}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Marca do Gitea (Simple Icons, CC0). Troca o octocat nos links do repositório.
|
||||
export default function GiteaMark({ className }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import Logo from "@/components/Logo";
|
||||
import SearchDialog from "@/components/SearchDialog";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
@@ -185,6 +186,7 @@ export default function Header() {
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
Selecionar chip
|
||||
</Link>
|
||||
<SearchDialog />
|
||||
<ThemeToggle />
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ export const SITE = {
|
||||
autorUrl: "https://lucasrguerra.dev.br",
|
||||
autorLinkedin: "https://linkedin.com/in/lucasrguerra",
|
||||
autorEmail: "contato@lucasrguerra.dev.br",
|
||||
repositorio: "https://github.com/lucasrguerra/ESPDocs",
|
||||
repositorio: "https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs",
|
||||
blog: "https://cienciaembarcada.com.br",
|
||||
livroTitulo: "Microcontroladores ESP32",
|
||||
livroSubtitulo: "Conectividade, Eficiência e Desempenho",
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Uso: scripts/plano-imagem.sh <registro> <repositório>
|
||||
# Imprime inputs=, image= e reuse= (para $GITHUB_OUTPUT). O hash cobre só os
|
||||
# arquivos que entram no build da imagem, não a documentação nem a configuração
|
||||
# de ferramentas. reuse=true quando o registro já tem inputs-<hash>.
|
||||
set -euo pipefail
|
||||
registry=$1; ns=${2,,}
|
||||
|
||||
inputs=$(git ls-tree -r HEAD \
|
||||
| awk -F'\t' '$2 !~ /^(\.gitea|\.github|\.claude|\.superset|docs)\// && $2 !~ /^(README.*|CLAUDE\.md|DESIGN\.md|LICENSE|\.gitignore|CHANGELOG\.md|scripts\/release-notes\.sh)$/' \
|
||||
| sha256sum | cut -c1-16)
|
||||
token=$(curl -fsS "https://$registry/v2/token?service=container_registry&scope=repository:$ns:pull" | jq -r .token)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -I \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json' \
|
||||
"https://$registry/v2/$ns/manifests/inputs-$inputs")
|
||||
echo "inputs-$inputs -> HTTP $code" >&2
|
||||
reuse=false; [ "$code" = 200 ] && reuse=true
|
||||
echo "inputs=$inputs"
|
||||
echo "image=$registry/$ns"
|
||||
echo "reuse=$reuse"
|
||||
Reference in new issue
Block a user