diff --git a/docs/changelog/2026-10-18-purge-nome-pelo-conteudo.md b/docs/changelog/2026-10-18-purge-nome-pelo-conteudo.md new file mode 100644 index 000000000..dc75d4038 --- /dev/null +++ b/docs/changelog/2026-10-18-purge-nome-pelo-conteudo.md @@ -0,0 +1,51 @@ +# 2026-10-18 — Plugin de purge: nome do CSS pelo conteúdo purgado + +## Prompt original + +> O projeto ddsoft-online identificou num changelog recente uma alteração necessária no +> bootswatch-solid. […] adicione o bootswatch-solid e faça a alteração + +Changelog de origem: `ddsoft-online/docs/changelog/2026-10-17-css-tema-cache.md` — "A correção +certa é no plugin de purge do pacote: renomear o asset depois de purgar." + +## Causa + +`bootswatchPurge` (`vite/index.mjs`) enxuga cada CSS no `generateBundle`, quando o Vite já deu o +hash do nome pelo CSS **inteiro** do tema, que vem do pacote e não muda. Quando o app passa a usar +uma classe nova, o conteúdo purgado muda e o nome não: no ddsoft-online, `bootstrap-CqnRIIsZ.css` +saiu em 20/09 sem a regra de `textBgInfo` e em 17/10 com ela, e o navegador seguiu com a cópia +antiga em cache (selo GHS sem fundo, com o texto branco do badge). + +## O que mudou + +`vite/index.mjs`: + +- O `generateBundle` passa a `order: 'post'`, para rodar depois de o Vite escrever os nomes dos CSS + nos chunks (`__vite__mapDeps`) e no `index.html`. +- Cada CSS que o purge muda ganha no nome o hash do conteúdo final (SHA-256, base64url, 8 + caracteres — o tamanho do hash do Vite). O asset é emitido com `this.emitFile` (o Rolldown + recusa entrada nova atribuída ao `bundle`), com o `name`/`originalFileName` do original. +- O nome antigo é trocado no código dos chunks, no `viteMetadata.importedCss` (manifest) e nos + assets `.html`. +- CSS que o purge não muda, ou sem hash no nome, fica com o nome do Vite. + +Sem mudança de API. Um consumidor que tenha o contorno local (o `ddsoft:css-tema-pelo-conteudo` do +ddsoft-online) continua funcionando: o nome já bate com o conteúdo e ele não troca nada. + +## Como foi testado + +No `ddsoft-online/frontend` (Vite 8 / Rolldown), sem o plugin de contorno local: + +- com o `vite/index.js` publicado na 0.2.1: `npm run test:build` **falha** — + "bootstrap-CqnRIIsZ.css não tem o hash do conteúdo (7iFlItaN)"; +- com este `vite/index.mjs` no lugar: passa ("4 CSS de tema com nome pelo conteúdo"). Conferido + também que os 29 CSS de `dist/assets` têm o hash do próprio conteúdo, todos são referenciados por + um chunk JS ou pelo `index.html`, e nenhum nome antigo sobrou. + +O conteúdo purgado é o mesmo de antes, exceto pelas quebras de linha no fim do arquivo (por isso +o hash é `qdUYmFLA` e não o `7iFlItaN` do contorno). + +## Pendência + +Publicar numa versão (0.2.2) e, no ddsoft-online, subir a dependência e tirar o +`ddsoft:css-tema-pelo-conteudo` do `vite.config.ts` (a trava do `check-build.mjs` fica). diff --git a/vite/index.mjs b/vite/index.mjs index 866efdb4b..6a812bce1 100644 --- a/vite/index.mjs +++ b/vite/index.mjs @@ -14,7 +14,15 @@ // estão no conjunto usado. O scope do tema e as vars (`:root`/reboot sem hash de // contract) não exigem nada → sempre mantidos. Sem o plugin, o CSS completo é // servido (correto, só maior) — é otimização, não correção. +// +// O nome do arquivo acompanha o conteúdo PURGADO. O Vite dá o hash do nome pelo +// CSS inteiro do pacote, que não muda quando o app passa a usar uma classe nova: +// o mesmo `bootstrap-.css` saía com e sem a regra, e o navegador seguia +// com a cópia antiga em cache (ddsoft-online, 17/10/2026). Depois de purgar, o +// asset ganha o hash do conteúdo final e as referências nos chunks JS +// (`__vite__mapDeps`) e no HTML são trocadas. +import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -55,6 +63,19 @@ export function purgeThemeCss(css, used, allHashes) { return stringify(ast) } +/** 8 caracteres base64url do SHA-256 do conteúdo (o mesmo tamanho do hash do Vite). */ +const contentHash = (src) => createHash('sha256').update(src).digest('base64url').slice(0, 8) + +/** Troca o hash do nome (`nome-.css`) pelo do conteúdo; `null` se não há hash no nome. */ +function renameByContent(file, src) { + const m = /^(.*-)[\w-]{8}(\.css)$/.exec(file) + return m ? `${m[1]}${contentHash(src)}${m[2]}` : null +} + +/** Troca cada nome antigo pelo novo num texto (chunk JS ou HTML). */ +const replaceNames = (text, renames) => + renames.reduce((t, [from, to]) => t.split(from).join(to), text) + /** Todos os hashes de classe de contract do pacote (valores dos exports de família). */ function loadContractHashes(pkgDir) { const manifestPath = path.join(pkgDir, 'contract-manifest.json') @@ -83,30 +104,66 @@ export function bootswatchPurge(options = {}) { if (options.disabled) return allHashes = loadContractHashes(pkgDir) }, - generateBundle(_outputOptions, bundle) { - if (options.disabled || allHashes.size === 0) return - // 1) conjunto usado = hashes de contract presentes nos chunks JS. - const used = new Set() - for (const file of Object.keys(bundle)) { - const c = bundle[file] - if (c.type !== 'chunk') continue - for (const m of c.code.matchAll(STR_HASH)) if (allHashes.has(m[1])) used.add(m[1]) - } - // 2) purga cada asset CSS (os chunks de tema). - let before = 0 - let after = 0 - for (const file of Object.keys(bundle)) { - const a = bundle[file] - if (a.type !== 'asset' || !file.endsWith('.css')) continue - const src = String(a.source) - before += src.length - a.source = purgeThemeCss(src, used, allHashes) - after += a.source.length - } - if (before) { - const pct = (100 * (1 - after / before)).toFixed(0) - this.info?.(`bootswatch purge: CSS ${(before / 1024).toFixed(0)}KB → ${(after / 1024).toFixed(0)}KB (-${pct}%), ${used.size} classes usadas`) - } + // `post`: depois do Vite preencher os nomes dos CSS nos chunks + // (`__vite__mapDeps`) e no index.html, que aqui são trocados pelo novo. + generateBundle: { + order: 'post', + handler(_outputOptions, bundle) { + if (options.disabled || allHashes.size === 0) return + // 1) conjunto usado = hashes de contract presentes nos chunks JS. + const used = new Set() + for (const file of Object.keys(bundle)) { + const c = bundle[file] + if (c.type !== 'chunk') continue + for (const m of c.code.matchAll(STR_HASH)) if (allHashes.has(m[1])) used.add(m[1]) + } + // 2) purga cada asset CSS (os chunks de tema) e renomeia pelo conteúdo. + let before = 0 + let after = 0 + const renames = [] + for (const file of Object.keys(bundle)) { + const a = bundle[file] + if (a.type !== 'asset' || !file.endsWith('.css')) continue + const src = String(a.source) + const purged = purgeThemeCss(src, used, allHashes) + before += src.length + after += purged.length + if (purged === src) continue + const to = renameByContent(file, purged) + if (!to || to === file) { + a.source = purged + continue + } + // O Rolldown não aceita entrada nova atribuída ao `bundle`: emite. + delete bundle[file] + this.emitFile({ + type: 'asset', + fileName: to, + source: purged, + name: a.names?.[0] ?? a.name, + originalFileName: a.originalFileNames?.[0] ?? a.originalFileName, + }) + renames.push([file, to]) + } + // 3) referências ao nome antigo: código dos chunks, metadados do Vite + // (manifest) e assets de texto (index.html). + if (renames.length) { + const map = new Map(renames) + for (const o of Object.values(bundle)) { + if (o.type === 'chunk') { + o.code = replaceNames(o.code, renames) + const css = o.viteMetadata?.importedCss + if (css) for (const [from, to] of map) if (css.delete(from)) css.add(to) + } else if (o.fileName.endsWith('.html') && typeof o.source === 'string') { + o.source = replaceNames(o.source, renames) + } + } + } + if (before) { + const pct = (100 * (1 - after / before)).toFixed(0) + this.info?.(`bootswatch purge: CSS ${(before / 1024).toFixed(0)}KB → ${(after / 1024).toFixed(0)}KB (-${pct}%), ${used.size} classes usadas`) + } + }, }, } }