The build-time stripper matched only a literal passed directly to useShadowStyles. Literals appended to shared styles (useShadowStyles(X + `…`)) and named literals (const NAME_CSS = `…`, export const NAME_STYLES = `…`) shipped their comments verbatim; three plugins' comments reached public bundles that way (check:public-hygiene). All three shapes are stripped now.
160 lines
6.9 KiB
JavaScript
160 lines
6.9 KiB
JavaScript
// vite-plugin-strip-shadow-css-comments — production bundles carry only CSS rules.
|
|
//
|
|
// Every component injects its shadow-DOM styles from a template literal passed to
|
|
// useShadowStyles(`…`). Those literals ship verbatim inside the built IIFE, so any
|
|
// CSS block comment written in them would reach the public drop-in as dead weight.
|
|
// This build-only plugin rewrites the source of every .vue and .js file under
|
|
// library/ and plugin-designs/ before the Vue compiler sees it (enforce: 'pre'):
|
|
// inside each such literal it removes the block comments and collapses the blank
|
|
// lines they leave behind. Nothing outside the literal changes, and dev mode is
|
|
// untouched so the comments stay readable while working.
|
|
//
|
|
// A literal is left exactly as written (with a build warning) when it cannot be
|
|
// rewritten safely: it contains a `${…}` substitution, or a comment, string or
|
|
// url() inside it never closes. Comment markers that sit inside a CSS string
|
|
// (`content:"*/"`) or an unquoted url() are string content and are preserved.
|
|
import { resolve, dirname, sep } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const SOURCE_ROOTS = ['library', 'plugin-designs'].map(d => resolve(ROOT, d) + sep)
|
|
const TAG = '[strip-shadow-css-comments]'
|
|
|
|
// The literals that ship as CSS, in the three shapes this tree writes them:
|
|
// useShadowStyles(`…`) the literal passed directly;
|
|
// useShadowStyles(PANE_GRID_STYLES + `…`) a literal appended to shared styles;
|
|
// const PS_SHADOW_CSS = `…` / export const PANE_GRID_STYLES = `…` a named literal passed later.
|
|
// Only the first shape was matched until 2026-09-28, and regions found three plugins' comments shipping
|
|
// from the other two (check:public-hygiene) — the same mistake written independently, i.e. a trap.
|
|
const CALL = /\buseShadowStyles\(\s*(?:[A-Za-z_$][\w$]*\s*\+\s*)*`|\b(?:export\s+)?const\s+[A-Z][A-Z0-9_]*(?:_CSS|_STYLES)\s*=\s*`/g
|
|
|
|
/**
|
|
* Find the end of a template literal whose opening backtick is just before `from`.
|
|
* Returns { end } = index of the closing backtick, or { reason } when the literal
|
|
* contains a substitution or never closes.
|
|
*/
|
|
function findTemplateEnd(code, from) {
|
|
for (let i = from; i < code.length; i++) {
|
|
const c = code[i]
|
|
if (c === '\\') { i++; continue }
|
|
if (c === '`') return { end: i }
|
|
if (c === '$' && code[i + 1] === '{') return { reason: 'contains a ${…} substitution', at: i }
|
|
}
|
|
return { reason: 'template literal never closes', at: code.length }
|
|
}
|
|
|
|
/**
|
|
* Remove CSS block comments from one literal's text. Strings ('…' / "…") and
|
|
* unquoted url(…) are copied through untouched so a comment marker inside them
|
|
* is never mistaken for a comment. Returns { css, comments } or { reason }.
|
|
*/
|
|
export function stripCssComments(css) {
|
|
const n = css.length
|
|
let out = ''
|
|
let comments = 0
|
|
let i = 0
|
|
while (i < n) {
|
|
const c = css[i]
|
|
if (c === '"' || c === "'") {
|
|
let j = i + 1
|
|
while (j < n && css[j] !== c && css[j] !== '\n') { if (css[j] === '\\') j++; j++ }
|
|
if (j >= n) return { reason: 'a CSS string never closes' }
|
|
if (css[j] === '\n') { out += css.slice(i, j); i = j; continue } // stray quote: only its own line is treated as a string
|
|
out += css.slice(i, j + 1); i = j + 1; continue
|
|
}
|
|
if (c === '/' && css[i + 1] === '*') {
|
|
const j = css.indexOf('*/', i + 2)
|
|
if (j < 0) return { reason: 'a CSS comment never closes' }
|
|
comments++; i = j + 2; continue
|
|
}
|
|
if ((c === 'u' || c === 'U') && /^url\(/i.test(css.slice(i, i + 4)) && (i === 0 || !/[\w-]/.test(css[i - 1]))) {
|
|
let j = i + 4
|
|
while (j < n && (css[j] === ' ' || css[j] === '\t')) j++
|
|
if (css[j] === '"' || css[j] === "'") { out += css.slice(i, j); i = j; continue } // quoted url: the string branch owns it
|
|
const k = css.indexOf(')', j)
|
|
if (k < 0) return { reason: 'an unquoted url() never closes' }
|
|
out += css.slice(i, k + 1); i = k + 1; continue
|
|
}
|
|
out += c; i++
|
|
}
|
|
if (comments > 0) {
|
|
out = out.replace(/[ \t]+(?=\r?\n|$)/g, '') // whitespace left behind at the end of a line
|
|
out = out.replace(/\r?\n(?:[ \t]*\r?\n)+/g, '\n') // runs of blank lines
|
|
}
|
|
return { css: out, comments }
|
|
}
|
|
|
|
/**
|
|
* Rewrite every useShadowStyles(`…`) literal in a module's source.
|
|
* Returns { code, changed, literals, comments, bytes, skipped: [{ line, reason }] }.
|
|
*/
|
|
export function stripShadowStyleComments(code) {
|
|
const result = { code, changed: false, literals: 0, comments: 0, bytes: 0, skipped: [] }
|
|
let out = ''
|
|
let last = 0
|
|
CALL.lastIndex = 0
|
|
let m
|
|
while ((m = CALL.exec(code))) {
|
|
const open = m.index + m[0].length
|
|
const lit = findTemplateEnd(code, open)
|
|
if (lit.reason) {
|
|
result.skipped.push({ line: lineOf(code, m.index), reason: lit.reason })
|
|
CALL.lastIndex = lit.at
|
|
continue
|
|
}
|
|
result.literals++
|
|
const original = code.slice(open, lit.end)
|
|
const stripped = stripCssComments(original)
|
|
if (stripped.reason) {
|
|
result.skipped.push({ line: lineOf(code, m.index), reason: stripped.reason })
|
|
} else if (stripped.css !== original) {
|
|
out += code.slice(last, open) + stripped.css
|
|
last = lit.end
|
|
result.comments += stripped.comments
|
|
result.bytes += original.length - stripped.css.length
|
|
result.changed = true
|
|
}
|
|
CALL.lastIndex = lit.end + 1
|
|
}
|
|
if (result.changed) result.code = out + code.slice(last)
|
|
return result
|
|
}
|
|
|
|
function lineOf(code, index) {
|
|
let line = 1
|
|
for (let i = 0; i < index; i++) if (code[i] === '\n') line++
|
|
return line
|
|
}
|
|
|
|
function isSourceModule(id) {
|
|
const q = id.indexOf('?')
|
|
if (q >= 0) return false // Vue sub-requests (?vue&type=…) read the already-rewritten descriptor
|
|
if (!(id.endsWith('.vue') || id.endsWith('.js'))) return false
|
|
return SOURCE_ROOTS.some(root => id.startsWith(root))
|
|
}
|
|
|
|
export function stripShadowCssCommentsPlugin() {
|
|
let logger = console
|
|
const totals = { files: 0, literals: 0, comments: 0, bytes: 0, skipped: 0 }
|
|
return {
|
|
name: 'doxa:strip-shadow-css-comments',
|
|
apply: 'build',
|
|
enforce: 'pre',
|
|
configResolved(config) { logger = config.logger },
|
|
transform(code, id) {
|
|
if (!isSourceModule(id)) return null
|
|
const r = stripShadowStyleComments(code)
|
|
for (const s of r.skipped) {
|
|
totals.skipped++
|
|
logger.warn(`${TAG} left untouched ${id.slice(ROOT.length + 1)}:${s.line} — ${s.reason}`)
|
|
}
|
|
if (!r.changed) return null
|
|
totals.files++; totals.literals += r.literals; totals.comments += r.comments; totals.bytes += r.bytes
|
|
return { code: r.code, map: null }
|
|
},
|
|
buildEnd() {
|
|
logger.info(`${TAG} removed ${totals.comments} CSS comments (${totals.bytes} bytes) from ${totals.literals} shadow-style literals in ${totals.files} files` +
|
|
(totals.skipped ? `; ${totals.skipped} literal(s) left untouched` : ''))
|
|
},
|
|
}
|
|
}
|