Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/changelog/2026-10-18-purge-nome-pelo-conteudo.md
Original file line number Diff line number Diff line change
@@ -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).
105 changes: 81 additions & 24 deletions vite/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>.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'
Expand Down Expand Up @@ -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-<hash>.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')
Expand Down Expand Up @@ -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`)
}
},
},
}
}
Expand Down
Loading