Compare commits

4 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 f6e10a293a ci: keep npm and Next compile caches across image builds
CI / Lint e auditorias (push) Successful in 45s
CI / Trivy (dependências e segredos) (push) Successful in 8s
CI / Imagem Docker (linux/arm64) (push) Successful in 4m11s
Release / Plano (reaproveitar a imagem?) (push) Successful in 5s
Release / ci (push) Skipped
Release / Publica a imagem (linux/arm64) (push) Successful in 2m40s
Release / Cria a release no Gitea (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:09:41 -03:00
Lucas RayanandClaude Sonnet 5.5 4ad130a334 ci: build, test and publish the image once, then let the release reuse it
CI / Lint e auditorias (push) Canceled after 46s
CI / Trivy (dependências e segredos) (push) Canceled after 0s
CI / Imagem Docker (linux/arm64) (push) Canceled after 0s
Drop the pull_request trigger (the branch push already validates the commit),
drop the second npm run build from the lint job (the image build compiles it),
and build the image on arm64 after the cheap checks pass. The CI publishes it
as inputs-<hash>, so the merge to main only re-tags it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:08:52 -03:00
Lucas RayanandClaude Sonnet 5.5 7d0edf13c4 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>
2026-11-01 08:02:57 -03:00
Lucas RayanandClaude Sonnet 5.5 d8323c653b chore: point the repository links to Gitea and swap the GitHub icon
CI / Lint, design e build (push) Canceled after 12s
CI / Trivy (dependências e segredos) (push) Canceled after 0s
CI / Imagem Docker (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 07:53:39 -03:00
15 changed files with 1214 additions and 69 deletions

No files matched your search

+43 -20
View File
@@ -3,9 +3,13 @@ name: CI
# Documentação e configuração de ferramenta não mudam nada que a suíte teste. # 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 # 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. # 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: on:
pull_request:
paths-ignore: ['**.md', 'LICENSE', '.claude/**', '.superset/**', '.wolf/**', 'scripts/release-notes.sh']
push: push:
branches-ignore: [main] branches-ignore: [main]
paths-ignore: ['**.md', 'LICENSE', '.claude/**', '.superset/**', '.wolf/**', 'scripts/release-notes.sh'] paths-ignore: ['**.md', 'LICENSE', '.claude/**', '.superset/**', '.wolf/**', 'scripts/release-notes.sh']
@@ -20,7 +24,7 @@ concurrency:
jobs: jobs:
verificar: verificar:
name: Lint, design e build name: Lint e auditorias
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
env: env:
@@ -35,24 +39,11 @@ jobs:
node-version: 22 node-version: 22
cache: npm 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 - run: npm ci
# eslint + auditoria do design system (tokens, contraste AAA) + auditoria de páginas # eslint + auditoria do design system (tokens, contraste AAA) + auditoria de páginas
- run: npm run check - run: npm run check
- run: npm run build
# Trivy roda pela imagem oficial: o trivy-action clona o repositório do Trivy # 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. # 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 # 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 docker rm "$cid" >/dev/null
exit "$status" 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: imagem:
name: Imagem Docker name: Imagem Docker (linux/arm64)
runs-on: ubuntu-latest needs: [verificar, seguranca]
runs-on: ubuntu-24.04-arm
env:
REGISTRY: git.lucasrguerra.dev.br
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Build
if: ${{ steps.plano.outputs.reuse != 'true' }}
run: docker build -t espdocs:ci . run: docker build -t espdocs:ci .
- name: Trivy na imagem - name: Trivy na imagem
if: ${{ steps.plano.outputs.reuse != 'true' }}
run: | 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 \ 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 image --severity HIGH,CRITICAL --exit-code 1 espdocs:ci
- name: A imagem sobe e responde - name: A imagem sobe e responde
if: ${{ steps.plano.outputs.reuse != 'true' }}
run: | run: |
docker run -d --name espdocs-ci --network host -e PORT=3999 espdocs:ci docker run -d --name espdocs-ci --network host -e PORT=3999 espdocs:ci
for i in $(seq 1 30); do for i in $(seq 1 30); do
@@ -95,3 +110,11 @@ jobs:
docker logs espdocs-ci | tail -5 docker logs espdocs-ci | tail -5
docker rm -f espdocs-ci >/dev/null docker rm -f espdocs-ci >/dev/null
test "${ok:-0}" = 1 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"
+12 -23
View File
@@ -18,10 +18,10 @@ env:
jobs: jobs:
# A imagem só depende dos arquivos que entram no build, não da documentação nem # 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 # da configuração de ferramentas. O push da branch já a construiu, testou e
# o mesmo conteúdo (um merge só de docs, uma mudança de workflow, uma tag num # publicou como inputs-<hash>; quando essa tag existe (o caso normal de um
# commit que acabou de ser publicado), não há o que construir: as tags novas # merge, uma tag num commit validado, um merge só de docs), não há o que
# apontam para o manifesto existente. # construir: as tags novas apontam para o manifesto existente.
plano: plano:
name: Plano (reaproveitar a imagem?) name: Plano (reaproveitar a imagem?)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -32,23 +32,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- id: plano - id: plano
env: run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT"
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"
# Nada é publicado sem a suíte completa passar antes. Pulada quando a imagem é # Nada é publicado sem a suíte completa passar antes. Pulada quando a imagem é
# reaproveitada: o mesmo conteúdo já passou por ela. # reaproveitada: o mesmo conteúdo já passou por ela.
@@ -68,6 +52,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: docker/setup-buildx-action@v4
- name: Autentica no registro - name: Autentica no registro
@@ -89,7 +78,7 @@ jobs:
type=sha,format=long type=sha,format=long
- name: Build e push - name: Build e push
if: ${{ needs.plano.outputs.reuse != 'true' }} if: ${{ steps.replano.outputs.reuse != 'true' }}
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -104,7 +93,7 @@ jobs:
# Mesmo manifesto, nomes novos: nenhuma camada é lida nem escrita. # Mesmo manifesto, nomes novos: nenhuma camada é lida nem escrita.
- name: Aplica as tags à imagem existente - name: Aplica as tags à imagem existente
if: ${{ needs.plano.outputs.reuse == 'true' }} if: ${{ steps.replano.outputs.reuse == 'true' }}
run: | run: |
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') 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 }}" docker buildx imagetools create $tags "${{ needs.plano.outputs.image }}:inputs-${{ needs.plano.outputs.inputs }}"
+4 -2
View File
@@ -6,14 +6,16 @@ WORKDIR /app
# O Puppeteer só é usado em scripts locais (slides, capturas): não baixa o Chrome. # O Puppeteer só é usado em scripts locais (slides, capturas): não baixa o Chrome.
ENV PUPPETEER_SKIP_DOWNLOAD=true ENV PUPPETEER_SKIP_DOWNLOAD=true
COPY package.json package-lock.json ./ 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 FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . 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 FROM node:22-alpine
WORKDIR /app WORKDIR /app
+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",
},
});
}
+7 -14
View File
@@ -1,19 +1,12 @@
import { paginaMeta, jsonLd } from "@/lib/seo"; import { paginaMeta, jsonLd, SITE } from "@/lib/seo";
import Header from "@/components/Header"; import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import Image from "next/image"; import Image from "next/image";
import GiteaMark from "@/components/GiteaMark";
import { ArrowUpRight, Code, Globe, GitCompare, Info, Languages } from "lucide-react"; import { ArrowUpRight, Code, Globe, GitCompare, Info, Languages } from "lucide-react";
const GITHUB = "https://github.com/lucasrguerra/ESPDocs"; const REPOSITORIO = SITE.repositorio;
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>
);
}
export const metadata = paginaMeta({ 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: 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: 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: 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 ( return (
@@ -60,8 +53,8 @@ export default function Sobre() {
title="Sobre o ESPDocs" 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." 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"> <a href={REPOSITORIO} target="_blank" rel="noopener noreferrer" className="btn btn-primary">
<GithubMark className="h-4 w-4" /> <GiteaMark className="h-4 w-4" />
Ver o repositório Ver o repositório
<ArrowUpRight className="h-4 w-4" aria-hidden="true" /> <ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</a> </a>
@@ -108,7 +101,7 @@ export default function Sobre() {
</p> </p>
<ol className="mt-6 space-y-3"> <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.", "Implemente as melhorias e confira as referências técnicas oficiais.",
"Abra um Pull Request descrevendo os ajustes feitos.", "Abra um Pull Request descrevendo os ajustes feitos.",
].map((passo, i) => ( ].map((passo, i) => (
+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>
+3 -4
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import GiteaMark from "@/components/GiteaMark";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import { SITE } from "@/lib/seo"; import { SITE } from "@/lib/seo";
@@ -123,12 +124,10 @@ export default function Footer() {
href={SITE.repositorio} href={SITE.repositorio}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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"> <GiteaMark className="h-4 w-4" />
<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>
</a> </a>
<a <a
href={SITE.autorLinkedin} href={SITE.autorLinkedin}
+8
View File
@@ -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>
);
}
+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;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const SITE = {
autorUrl: "https://lucasrguerra.dev.br", autorUrl: "https://lucasrguerra.dev.br",
autorLinkedin: "https://linkedin.com/in/lucasrguerra", autorLinkedin: "https://linkedin.com/in/lucasrguerra",
autorEmail: "contato@lucasrguerra.dev.br", autorEmail: "contato@lucasrguerra.dev.br",
repositorio: "https://github.com/lucasrguerra/ESPDocs", repositorio: "https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs",
blog: "https://cienciaembarcada.com.br", blog: "https://cienciaembarcada.com.br",
livroTitulo: "Microcontroladores ESP32", livroTitulo: "Microcontroladores ESP32",
livroSubtitulo: "Conectividade, Eficiência e Desempenho", livroSubtitulo: "Conectividade, Eficiência e Desempenho",
+21
View File
@@ -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"