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>
353 lines
15 KiB
JavaScript
353 lines
15 KiB
JavaScript
/**
|
|
* 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
|
|
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");
|