feat(formacoes): módulo 2 (DHT22 + LCD 16x2 no Wokwi), PDFs e página de entrada
Release / Plano (reaproveitar a imagem?) (push) Successful in 7s
CI / Lint e auditorias (push) Successful in 1m48s
CI / Trivy (dependências e segredos) (push) Successful in 12s
CI / Imagem Docker (linux/arm64) (push) Failing after 2m27s
Release / ci (push) Failing after 4m31s
Release / Publica a imagem (linux/arm64) (push) Skipped
Release / Cria a release no Gitea (push) Skipped

- Formação 2 da LASER: slides visuais, apostila, projeto do Wokwi e scanner
  I2C, validados no simulador (leitura normal, temperatura negativa e sensor
  desconectado). Circuito com pull-ups de 10 kΩ e 4,7 kΩ para 3V3 e aviso
  sobre os pull-ups de 5 V da plaquinha PCF8574.
- PDFs prontos dos slides e das apostilas dos módulos 1 e 2;
  slides-pdf agora também exporta a apostila.
- Página de entrada em /formacoes e /formacoes/N, gerada por
  scripts/formacoes-viewer.mjs a partir do formacao.json de cada pasta.
- A apostila passou de index.html para apostila.html.
- Corrige o link do Fritzing da apostila 1 (/apostilas/1 não existe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5.5 committed 2026-11-02 13:11:28 -03:00
1 parent 71c5708726
commit 1860e34cbb
32 files changed
+6469 -1943

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
/**
* Gera a página de entrada de cada formação e a lista geral de formações.
*
* npm run formacoes-viewer
*
* Cada pasta public/formacoes/N/ descreve o próprio material num
* formacao.json. Daqui saem:
* public/formacoes/N/index.html a página servida em /formacoes/N
* public/formacoes/index.html a lista servida em /formacoes
*
* O que dá para medir não fica no JSON: tamanho de cada arquivo, número de
* páginas dos PDFs e o roteiro da aula, lido das aberturas de seção do
* slide.html. Assim a página não desatualiza quando o material muda.
*
* Os links são absolutos (/formacoes/N/...) porque o middleware serve a
* página em /formacoes/N, sem barra final, e um link relativo apontaria para
* a pasta de cima.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const raiz = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pastaFormacoes = path.join(raiz, "public", "formacoes");
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
function tamanho(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} MB`;
}
/* Conta os objetos /Type /Page de um PDF. Basta para os PDFs do Chrome, que
não comprimem a árvore de páginas. */
function paginasPdf(arquivo) {
const bruto = fs.readFileSync(arquivo, "latin1");
return (bruto.match(/\/Type\s*\/Page(?![s\w])/g) || []).length;
}
/* As aberturas de seção do slide.html viram o roteiro da aula, cada uma com
link para o slide em que começa. */
function roteiro(pasta) {
const html = fs.readFileSync(path.join(pasta, "slide.html"), "utf8");
const slides = [...html.matchAll(/<section class="slide([^"]*)"[^>]*data-titulo="([^"]*)"/g)];
const partes = [];
slides.forEach((m, i) => {
if (!/\bsecao\b/.test(m[1])) return;
partes.push({ indice: i + 1, titulo: m[2].replace(/^§\d+\s*·\s*/, "") });
});
partes.forEach((p, i) => {
const fim = i + 1 < partes.length ? partes[i + 1].indice - 1 : slides.length;
p.quantos = fim - p.indice + 1;
});
return { partes, total: slides.length };
}
const ICONES = {
slides: '<path d="M3 5h18v12H3z"/><path d="M8 21h8M12 17v4"/>',
apostila: '<path d="M4 4.5A1.5 1.5 0 0 1 5.5 3H20v16H5.5A1.5 1.5 0 0 0 4 20.5z"/><path d="M4 20.5A1.5 1.5 0 0 0 5.5 22H20"/>',
pdf: '<path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4"/><path d="M9 13h6M9 17h4"/>',
codigo: '<path d="m8 8-4 4 4 4M16 8l4 4-4 4M13.5 5l-3 14"/>',
zip: '<path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4"/><path d="M11 6h1M11 9h1M11 12h1M10.5 15h2v3h-2z"/>',
imagem: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 17-5-5-9 8"/>',
fritzing: '<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h3v3H8zM13 13h3v3h-3zM11 9.5h3.5V13"/>',
seta: '<path d="M5 12h14M13 6l6 6-6 6"/>',
baixar: '<path d="M12 4v12M6 10l6 6 6-6M5 20h14"/>',
tocar: '<path d="M7 5v14l11-7z"/>',
livro: '<path d="M12 6c-2-1.5-5-2-8-1.5v14c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-14c-3-.5-6 0-8 1.5zM12 6v14"/>',
};
const icone = (nome) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONES[nome]}</svg>`;
const CLASSE_ICONE = { pdf: "pdf", codigo: "codigo", zip: "codigo", imagem: "imagem" };
const cabecalho = (titulo, descricao) => `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(titulo)}</title>
<meta name="description" content="${esc(descricao)}">
<meta name="author" content="Lucas Rayan Guerra">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/marca/espdocs-marca.svg">
<link rel="stylesheet" href="/formacoes/assets/viewer.css">
</head>
<body>
<!-- Gerado por scripts/formacoes-viewer.mjs a partir do formacao.json. Não edite à mão. -->
`;
const topo = (nav) => `
<header class="topo">
<div class="envoltorio">
<a href="/formacoes"><img src="/formacoes/assets/laser.svg" alt="LASER"></a>
<span class="topo-liga">Liga Acadêmica de Sistemas Embarcados<br>Centro de Informática · UFPE</span>
<nav>${nav}</nav>
</div>
</header>`;
const rodape = `
<footer class="rodape">
<div class="envoltorio">
<span><strong>LASER</strong> · Liga Acadêmica de Sistemas Embarcados · CIn · UFPE</span>
<span>Material de <strong>Lucas Rayan Guerra</strong> · uso livre para fins educacionais · <a href="mailto:contato@lucasrguerra.dev.br">contato@lucasrguerra.dev.br</a></span>
</div>
</footer>
</body>
</html>
`;
function paginaFormacao(pasta, dados) {
const n = dados.modulo;
const base = `/formacoes/${n}`;
const url = (arq) => (/^https?:/.test(arq) ? arq : `${base}/${arq}`);
const info = (arq) => {
const caminho = path.join(pasta, arq);
if (!fs.existsSync(caminho)) throw new Error(`formacao ${n}: não achei ${arq}`);
const bytes = fs.statSync(caminho).size;
const paginas = arq.endsWith(".pdf") ? paginasPdf(caminho) : 0;
return { bytes, paginas };
};
const { partes, total } = roteiro(pasta);
const pdfSlides = `LASER-ESP32-Modulo-${n}-Slides.pdf`;
const pdfApostila = `LASER-ESP32-Modulo-${n}-Apostila.pdf`;
const iSlides = info(pdfSlides);
const iApostila = info(pdfApostila);
const fichas = dados.fichas.map(([k, v]) => `<li>${esc(k)}: <b>${esc(v)}</b></li>`).join("");
const acao = ([rotulo, arq, baixar], primario) =>
`<a class="botao pequeno${primario ? " primario" : ""}" href="${url(arq)}"${baixar ? " download" : ""}${/^https?:/.test(arq) ? ' rel="noopener"' : ""}>${icone(baixar ? "baixar" : "seta")}${esc(rotulo)}</a>`;
const grupos = dados.grupos.map((g) => {
const itens = g.arquivos.map((a) => {
const { bytes, paginas } = info(a.arquivo);
const nome = path.basename(a.arquivo);
const meta = paginas ? `${paginas} págs · ${tamanho(bytes)}` : tamanho(bytes);
const botoes = [];
if (a.tipo === "codigo") {
botoes.push(`<a href="${url(a.arquivo)}" download>Baixar</a>`);
} else {
if (a.abrir) botoes.push(`<a href="${url(a.arquivo)}"${a.tipo === "imagem" || a.tipo === "pdf" ? ' target="_blank" rel="noopener"' : ""}>Abrir</a>`);
if (a.baixar) botoes.push(`<a href="${url(a.arquivo)}" download>Baixar</a>`);
}
let codigo = "";
if (a.tipo === "codigo") {
const fonte = fs.readFileSync(path.join(pasta, a.arquivo), "utf8");
codigo = `<details class="codigo-fonte"><summary>Ver código</summary><pre><code>${esc(fonte.trimEnd())}</code></pre></details>`;
}
return `
<li class="arquivo">
<div class="icone ${CLASSE_ICONE[a.tipo] || ""}">${icone(a.tipo)}</div>
<div>
<div class="arquivo-nome">${esc(nome)}</div>
<div class="arquivo-desc">${a.descricao}</div>
${codigo}
</div>
<div class="arquivo-tam">${meta}</div>
<div class="arquivo-acoes">${botoes.join("")}</div>
</li>`;
}).join("");
return `
<div class="grupo-arquivos">
<h3>${esc(g.titulo)}</h3>
<ul class="arquivos">${itens}
</ul>
</div>`;
}).join("");
const guias = [
`<div class="guia">
<h3>Apresentando os slides</h3>
<p>Abra <a href="${base}/slide.html">slide.html</a> no navegador. O palco é 16:9 e se ajusta a qualquer tela ou projetor.</p>
<dl class="atalhos">
<dt><kbd>→</kbd><kbd>←</kbd></dt><dd>avançar e voltar (ou clique nas metades da tela)</dd>
<dt><kbd>F</kbd></dt><dd>tela cheia</dd>
<dt><kbd>O</kbd></dt><dd>panorama com todos os slides</dd>
<dt><kbd>B</kbd></dt><dd>tela preta, para pausar a projeção</dd>
</dl>
</div>`,
`<div class="guia">
<h3>Lendo e imprimindo a apostila</h3>
<p>A <a href="${base}/apostila.html">versão online</a> se pagina em A4 sozinha ao abrir, o que leva alguns segundos.</p>
<p>Para papel, prefira o <a href="${url(pdfApostila)}" download>PDF pronto</a>: ele já sai com margens, cores e quebras de página certas, sem configurar a impressora.</p>
</div>`,
...dados.guias.map((g) => `<div class="guia">
<h3>${esc(g.titulo)}</h3>
${g.itens ? `<ol>${g.itens.map((i) => `<li>${i}</li>`).join("")}</ol>` : ""}
${g.texto ? `<p>${g.texto}</p>` : ""}
</div>`),
].join("\n\t\t");
const listaRoteiro = partes.map((p, i) => `
<li><a href="${base}/slide.html#${p.indice}"><b>${i + 1}</b><span>${esc(p.titulo)}<small>slide ${p.indice} · ${p.quantos} slide${p.quantos > 1 ? "s" : ""}</small></span></a></li>`).join("");
const titulo = `LASER · Formação em ESP32 · Módulo ${n}: ${dados.titulo.join(" ")}`;
return cabecalho(titulo, dados.descricao) + topo(`<a href="#arquivos">Arquivos</a><a href="#como-usar">Como usar</a><a href="/formacoes">Formações</a>`) + `
<main>
<section class="abertura">
<div class="envoltorio">
<div>
<div class="selo">Formação em ESP32 · Módulo ${n}</div>
<h1>${esc(dados.titulo[0])}<strong>${esc(dados.titulo[1])}</strong></h1>
<p class="abertura-sub">${esc(dados.subtitulo)}</p>
<ul class="fichas">${fichas}</ul>
<div class="acoes">
<a class="botao primario" href="${base}/slide.html">${icone("tocar")}Apresentar os slides</a>
<a class="botao" href="${base}/apostila.html">${icone("livro")}Ler a apostila</a>
<a class="botao" href="#baixar">${icone("baixar")}Baixar em PDF</a>
</div>
</div>
<div class="previas">
<a class="p-apostila" href="${base}/apostila.html" aria-label="Abrir a apostila"><img src="${base}/previa-apostila.jpg" alt="Capa da apostila do módulo ${n}" width="640" height="905"></a>
<a class="p-slides" href="${base}/slide.html" aria-label="Abrir os slides"><img src="${base}/previa-slides.jpg" alt="Capa dos slides do módulo ${n}" width="960" height="540"></a>
</div>
</div>
</section>
<hr class="feixe">
<section class="bloco" id="baixar">
<div class="envoltorio">
<p class="rotulo">Comece por aqui</p>
<h2>Três formas de usar este material</h2>
<p class="bloco-sub">Para apresentar, para estudar e para pôr a mão no circuito. Os PDFs são a versão final, para quem só quer o material pronto.</p>
<div class="portas">
<article class="porta">
<a class="porta-imagem" href="${base}/slide.html"><img src="${base}/previa-slides.jpg" alt="" loading="lazy"></a>
<div class="porta-corpo">
<h3>Slides</h3>
<p>${total} slides para projetar direto do navegador, com navegação por teclado e panorama.</p>
<div class="porta-meta">PDF · ${iSlides.paginas} págs · ${tamanho(iSlides.bytes)}</div>
<div class="acoes">
<a class="botao pequeno primario" href="${base}/slide.html">${icone("tocar")}Apresentar</a>
<a class="botao pequeno" href="${url(pdfSlides)}" download>${icone("baixar")}PDF</a>
</div>
</div>
</article>
<article class="porta">
<a class="porta-imagem" href="${base}/apostila.html"><img src="${base}/previa-apostila.jpg" alt="" loading="lazy"></a>
<div class="porta-corpo">
<h3>Apostila</h3>
<p>O conteúdo completo, com explicações, tabelas e o código comentado, paginado em A4.</p>
<div class="porta-meta">PDF · ${iApostila.paginas} págs · ${tamanho(iApostila.bytes)}</div>
<div class="acoes">
<a class="botao pequeno primario" href="${base}/apostila.html">${icone("livro")}Ler</a>
<a class="botao pequeno" href="${url(pdfApostila)}" download>${icone("baixar")}PDF</a>
</div>
</div>
</article>
<article class="porta">
<a class="porta-imagem clara" href="${url(dados.pratica.imagem)}" target="_blank" rel="noopener"><img src="${url(dados.pratica.imagem)}" alt="" loading="lazy"></a>
<div class="porta-corpo">
<h3>${esc(dados.pratica.titulo)}</h3>
<p>${esc(dados.pratica.texto)}</p>
<div class="porta-meta">${esc(dados.pratica.meta)}</div>
<div class="acoes">${dados.pratica.acoes.map((a, i) => acao(a, i === 0)).join("")}</div>
</div>
</article>
</div>
</div>
</section>
<section class="bloco" id="roteiro">
<div class="envoltorio">
<p class="rotulo">Roteiro da aula</p>
<h2>${partes.length} partes, ${total} slides</h2>
<p class="bloco-sub">Cada parte abre os slides direto no ponto em que ela começa.</p>
<ol class="roteiro">${listaRoteiro}
</ol>
</div>
</section>
<section class="bloco" id="arquivos">
<div class="envoltorio">
<p class="rotulo">Todos os arquivos</p>
<h2>O que tem nesta formação</h2>
<p class="bloco-sub">Tudo o que foi usado na aula, com o que é cada arquivo e para que serve.</p>
${grupos}
</div>
</section>
<section class="bloco" id="como-usar">
<div class="envoltorio">
<p class="rotulo">Como usar</p>
<h2>Passo a passo</h2>
<div class="guias">
${guias}
</div>
</div>
</section>
</main>
` + rodape;
}
function paginaLista(formacoes) {
const cartoes = formacoes.map(({ dados }) => {
const base = `/formacoes/${dados.modulo}`;
const duracao = (dados.fichas.find(([k]) => k === "Duração") || [])[1];
return `
<article class="porta">
<a class="porta-imagem" href="${base}"><img src="${base}/previa-slides.jpg" alt="" loading="lazy"></a>
<div class="porta-corpo">
<div class="porta-meta">MÓDULO ${dados.modulo}${duracao ? ` · ${esc(duracao.toUpperCase())}` : ""}</div>
<h3>${esc(dados.titulo.join(" "))}</h3>
<p>${esc(dados.subtitulo)}</p>
<div class="acoes"><a class="botao pequeno primario" href="${base}">${icone("seta")}Ver o material</a></div>
</div>
</article>`;
}).join("");
return cabecalho("LASER · Formação em ESP32", "Slides, apostilas e arquivos de prática da formação em ESP32 da LASER, Liga Acadêmica de Sistemas Embarcados da UFPE.") + topo(`<a href="#formacoes">Módulos</a>`) + `
<main>
<section class="abertura">
<div class="envoltorio" style="grid-template-columns:1fr">
<div>
<div class="selo">Formação em ESP32</div>
<h1>Material das<strong>formações da LASER</strong></h1>
<p class="abertura-sub">Slides, apostilas e arquivos de prática de cada módulo, para acompanhar a aula, revisar depois ou imprimir.</p>
</div>
</div>
</section>
<hr class="feixe">
<section class="bloco" id="formacoes">
<div class="envoltorio">
<p class="rotulo">Módulos</p>
<h2>${formacoes.length} formações disponíveis</h2>
<div class="formacoes">${cartoes}
</div>
</div>
</section>
</main>
` + rodape;
}
const formacoes = fs.readdirSync(pastaFormacoes)
.filter((d) => /^\d+$/.test(d) && fs.existsSync(path.join(pastaFormacoes, d, "formacao.json")))
.sort((a, b) => a - b)
.map((d) => {
const pasta = path.join(pastaFormacoes, d);
return { pasta, dados: JSON.parse(fs.readFileSync(path.join(pasta, "formacao.json"), "utf8")) };
});
for (const { pasta, dados } of formacoes) {
fs.writeFileSync(path.join(pasta, "index.html"), paginaFormacao(pasta, dados));
console.log(`public/formacoes/${dados.modulo}/index.html`);
}
fs.writeFileSync(path.join(pastaFormacoes, "index.html"), paginaLista(formacoes));
console.log("public/formacoes/index.html");