feat(i18n): Simplified Chinese (zh-CN), search answers per locale, fr/de/es data fixes
CI / Lint e auditorias (push) Successful in 1m10s
CI / Trivy (dependências e segredos) (push) Successful in 12s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m39s
Release / Plano (reaproveitar a imagem?) (push) Successful in 6s
Release / ci (push) Skipped
Release / Publica a imagem (linux/arm64) (push) Successful in 18s
Release / Cria a release no Gitea (push) Successful in 5s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-11-01 23:28:39 -03:00
1 parent ae61416468
commit 5a9fe676fc
61 files changed
+2517 -920

No files matched your search

+79 -24
View File
@@ -1,4 +1,5 @@
import { TOPICOS, normalizar } from "./searchTopics.js";
import { BUSCA_I18N } from "./searchI18n.js";
/**
* Motor da busca inteligente (puro: sem React nem DOM).
@@ -21,6 +22,26 @@ const INTENCAO_DADOS = new Set([
"quanto", "quantos", "quantas", "capacidade", "limite", "limites", "tem", "possui", "suporta",
]);
const CJK = /[\u3400-\u9fff]/;
const INTENCAO_PINOS_ZH = ["引脚", "管脚", "针脚", "脚位"];
const INTENCAO_DADOS_ZH = ["规格", "参数", "容量", "多少", "支持吗", "是否支持", "有吗"];
const STOP_ZH = new Set(["的", "吗", "呢", "啊", "吧", "了", "是", "什么", "哪个", "哪些", "怎么", "如何", "有", "和", "与", "在", "要", "想", "我", "请", "能", "可以"]);
/** Palavras de um trecho em chinês (Intl.Segmenter; sem ele, bigramas de caracteres). */
function segmentar(texto) {
let palavras = [];
try {
const seg = new Intl.Segmenter("zh", { granularity: "word" });
palavras = [...seg.segment(texto)].filter((s) => s.isWordLike).map((s) => s.segment);
} catch {
const cs = [...texto];
palavras = cs.length <= 2 ? [texto] : cs.slice(0, -1).map((c, i) => c + cs[i + 1]);
}
return palavras.filter((w) => !STOP_ZH.has(w));
}
const fmt = (modelo, v) => modelo.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ""));
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. */
@@ -45,7 +66,7 @@ const palavrasDe = (texto) => texto.split(/[^a-z0-9]+/).filter((w) => w.length >
/** 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: [] };
if (!dados) return { itens: [], series: {}, porFuncao: new Map(), aliases: new Map(), chaves: [], locale: "pt", topicosLoc: null };
const itensRaw = Array.isArray(dados) ? dados : (dados.itens ?? []);
const seriesRaw = Array.isArray(dados) ? {} : (dados.series ?? {});
@@ -90,11 +111,15 @@ export function preparar(dados) {
if (k.startsWith("esp32-")) aliases.set(k.slice(6), chave);
}
return { itens, series, porFuncao, aliases, chaves: Object.keys(series) };
return { itens, series, porFuncao, aliases, chaves: Object.keys(series), locale: Array.isArray(dados) ? "pt" : (dados.locale ?? "pt"), topicosLoc: Array.isArray(dados) ? null : (dados.topicos ?? null) };
}
/** Tenta casar um termo do dicionário com os tokens livres. Devolve { idx[], qualidade } ou null. */
function casarTermo(termo, livres) {
if (CJK.test(termo)) {
for (const { t, i } of livres) if (CJK.test(t) && t.includes(termo)) return { idx: [i], qualidade: 3 + termo.length / 10, cjk: true };
return null;
}
const partes = termo.split(" ");
if (partes.length > 1) {
for (let i = 0; i + partes.length <= livres.length; i++) {
@@ -115,7 +140,9 @@ function casarTermo(termo, livres) {
export function interpretar(indice, consulta) {
const bruto = normalizar(consulta)
.replace(/gpio\s*-?\s*(\d+)/g, "gpio$1")
.replace(/[^a-z0-9.+\-_ ]/g, " ");
.replace(/([\u3400-\u9fff])([a-z0-9])/g, "$1 $2")
.replace(/([a-z0-9])([\u3400-\u9fff])/g, "$1 $2")
.replace(/[^a-z0-9.+\-_ \u3400-\u9fff]/g, " ");
const tokens = bruto.split(/\s+/).filter(Boolean);
const usados = new Set();
@@ -138,6 +165,17 @@ export function interpretar(indice, consulta) {
const gpios = [];
let querPinos = false;
let querDados = false;
// Chinês não separa palavras por espaço: tira do trecho as palavras de intenção.
tokens.forEach((t, i) => {
if (!CJK.test(t)) return;
let resto = t;
for (const w of INTENCAO_PINOS_ZH) if (resto.includes(w)) { querPinos = true; resto = resto.split(w).join(""); }
for (const w of INTENCAO_DADOS_ZH) if (resto.includes(w)) { querDados = true; resto = resto.split(w).join(""); }
if (resto !== t) {
tokens[i] = resto;
if (!resto) usados.add(i);
}
});
tokens.forEach((t, i) => {
if (/^gpio\d+$/.test(t)) {
gpios.push(t);
@@ -158,7 +196,8 @@ export function interpretar(indice, consulta) {
const candidatos = [];
for (const topico of TOPICOS) {
let melhor = null;
for (const termo of topico.termos) {
const extras = indice.topicosLoc?.[topico.id]?.termos;
for (const termo of extras ? [...topico.termos, ...extras] : topico.termos) {
const m = casarTermo(normalizar(termo), livres);
if (m && (!melhor || m.qualidade > melhor.qualidade)) melhor = m;
}
@@ -166,8 +205,15 @@ export function interpretar(indice, consulta) {
}
candidatos.sort((a, b) => b.qualidade - a.qualidade);
const topicos = [];
const cjkUsados = [];
for (const c of candidatos) {
if (topicos.length >= 3) break;
if (c.cjk) {
// Um mesmo trecho em chinês pode citar vários assuntos.
topicos.push(c.topico);
cjkUsados.push(...c.idx);
continue;
}
if (c.idx.some((i) => usados.has(i))) continue;
c.idx.forEach((i) => usados.add(i));
topicos.push(c.topico);
@@ -183,7 +229,8 @@ export function interpretar(indice, consulta) {
}
});
const restantes = tokens.filter((_, i) => !usados.has(i));
cjkUsados.forEach((i) => usados.add(i));
const restantes = tokens.filter((_, i) => !usados.has(i)).flatMap((t) => (CJK.test(t) ? segmentar(t) : [t]));
return { tokens, chips, topicos, gpios, funcoes, restantes, querPinos, querDados };
}
@@ -206,11 +253,17 @@ function pinosDoTopico(serie, topico) {
return { forte, fraco };
}
const hrefPinos = (chave, pinos, motivo) =>
`/series/${chave}?pinos=${pinos.map((p) => p.n).join(",")}&motivo=${encodeURIComponent(motivo)}#connections`;
const hrefPinos = (I, chave, pinos, motivo) =>
`${I.rotas.series}/${chave}?pinos=${pinos.map((p) => p.n).join(",")}&motivo=${encodeURIComponent(motivo)}#connections`;
function respostas(indice, it) {
const saida = [];
const I = BUSCA_I18N[indice.locale] ?? BUSCA_I18N.pt;
const T = I.t;
const loc = (topico) => ({
nome: indice.topicosLoc?.[topico.id]?.nome ?? topico.nome,
rotulo: indice.topicosLoc?.[topico.id]?.pinoRotulo ?? topico.pinoRotulo,
});
const alvos = it.chips.slice(0, 2);
for (const chave of alvos) {
@@ -218,14 +271,15 @@ function respostas(indice, it) {
if (!serie) continue;
for (const topico of it.topicos.slice(0, 2)) {
const tn = loc(topico);
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",
titulo: fmt(T.specTitulo, { topico: tn.nome, serie: serie.nome }),
descricao: T.specDesc,
linhas: specs.slice(0, 5).map((s) => ({ k: s.label, v: s.valor })),
href: `/series/${chave}#${specs[0].secao}`,
href: `${I.rotas.series}/${chave}#${specs[0].secao}`,
}
: null;
@@ -234,19 +288,19 @@ function respostas(indice, it) {
const cartaoPinos = principais.length
? {
tipo: "pino",
titulo: `Pinos do ${serie.nome} ${topico.pinoRotulo}`,
titulo: fmt(T.pinosTitulo, { serie: serie.nome, rotulo: tn.rotulo }),
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`
? fmt(T.pinosMistos, { forte: forte.length, fraco: fraco.length })
: 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`,
? fmt(forte.length === 1 ? T.pinoUm : T.pinosVarios, { n: forte.length })
: fmt(T.pinosFraco, { n: fraco.length }),
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}`),
.concat(fraco.length && forte.length ? [fmt(T.condicionais, { n: fraco.length })] : forte.length ? [] : fraco.slice(0, 10).map(rotuloPino)),
href: hrefPinos(I, chave, [...forte, ...fraco], fmt(T.motivoTopico, { topico: tn.nome, rotulo: tn.rotulo })),
}
: null;
@@ -257,13 +311,13 @@ function respostas(indice, it) {
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`,
titulo: fmt(T.diagTitulo, { serie: serie.nome }),
descricao: fmt(T.diagDesc, { n: serie.pinos.length }),
href: `${I.rotas.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}` });
saida.push({ tipo: "resposta", titulo: fmt(T.especTitulo, { serie: serie.nome }), descricao: T.especDesc, href: `${I.rotas.series}/${chave}` });
}
}
}
@@ -271,6 +325,7 @@ function respostas(indice, it) {
// Assunto sem chip: panorama por série ("qual série tem PSRAM?")
if (!alvos.length && it.topicos.length) {
const topico = it.topicos[0];
const tn = loc(topico);
const linhas = [];
for (const chave of indice.chaves) {
const serie = indice.series[chave];
@@ -278,7 +333,7 @@ function respostas(indice, it) {
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" });
saida.push({ tipo: "resposta", titulo: fmt(T.emCada, { topico: tn.nome }), descricao: T.compDesc, linhas, href: I.rotas.comparacao });
}
}
@@ -291,9 +346,9 @@ function respostas(indice, it) {
const serie = indice.series[chave];
saida.push({
tipo: "pino",
titulo: `${rotuloPino(pino)} no ${serie.nome}`,
titulo: fmt(T.pinoNo, { pino: rotuloPino(pino), serie: serie.nome }),
descricao: [pino.f.join(" · "), pino.a[0]].filter(Boolean).join(" — "),
href: hrefPinos(chave, [pino], `Pino ${rotuloPino(pino)}`),
href: hrefPinos(I, chave, [pino], fmt(T.motivoPino, { pino: rotuloPino(pino) })),
});
}
}
@@ -331,7 +386,7 @@ 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 })),
topicos: it.topicos.map((t) => ({ id: t.id, nome: indice.topicosLoc?.[t.id]?.nome ?? t.nome })),
};
const smart = respostas(indice, it).map((r, i) => ({ id: `r${i}`, keywords: "", ...r }));