Files
rspade_system/node_modules/svgo/plugins/inlineStyles.js
root d523f0f600 Fix code quality violations and exclude Manifest from checks
Document application modes (development/debug/production)
Add global file drop handler, order column normalization, SPA hash fix
Serve CDN assets via /_vendor/ URLs instead of merging into bundles
Add production minification with license preservation
Improve JSON formatting for debugging and production optimization
Add CDN asset caching with CSS URL inlining for production builds
Add three-mode system (development, debug, production)
Update Manifest CLAUDE.md to reflect helper class architecture
Refactor Manifest.php into helper classes for better organization
Pre-manifest-refactor checkpoint: Add app_mode documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 10:38:22 +00:00

394 lines
13 KiB
JavaScript
Executable File

import * as csstree from 'css-tree';
import { syntax } from 'csso';
import { attrsGroups, pseudoClasses } from './_collections.js';
import { detachNodeFromParent, querySelectorAll } from '../lib/xast.js';
import { visitSkip } from '../lib/util/visit.js';
import { compareSpecificity, includesAttrSelector } from '../lib/style.js';
/**
* @typedef InlineStylesParams
* @property {boolean=} onlyMatchedOnce Inlines selectors that match once only.
* @property {boolean=} removeMatchedSelectors
* Clean up matched selectors. Unused selects are left as-is.
* @property {string[]=} useMqs
* Media queries to use. An empty string indicates all selectors outside of
* media queries.
* @property {string[]=} usePseudos
* Pseudo-classes and elements to use. An empty string indicates all
* non-pseudo-classes and elements.
*/
export const name = 'inlineStyles';
export const description = 'inline styles (additional options)';
/**
* Some pseudo-classes can only be calculated by clients, like :visited,
* :future, or :hover, but there are other pseudo-classes that we can evaluate
* during optimization.
*
* Pseudo-classes that we can evaluate during optimization, and shouldn't be
* toggled conditionally through the `usePseudos` parameter.
*
* @see https://developer.mozilla.org/docs/Web/CSS/Pseudo-classes
*/
const preservedPseudos = [
...pseudoClasses.functional,
...pseudoClasses.treeStructural,
];
/**
* Merges styles from style nodes into inline styles.
*
* @type {import('../lib/types.js').Plugin<InlineStylesParams>}
* @author strarsis <strarsis@gmail.com>
*/
export const fn = (root, params) => {
const {
onlyMatchedOnce = true,
removeMatchedSelectors = true,
useMqs = ['', 'screen'],
usePseudos = [''],
} = params;
/**
* @type {{
* node: import('../lib/types.js').XastElement,
* parentNode: import('../lib/types.js').XastParent,
* cssAst: csstree.StyleSheet
* }[]}
*/
const styles = [];
/**
* @type {{
* node: csstree.Selector,
* item: csstree.ListItem<csstree.CssNode>,
* rule: csstree.Rule,
* matchedElements?: import('../lib/types.js').XastElement[]
* }[]}
*/
const selectors = [];
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'foreignObject') {
return visitSkip;
}
if (node.name !== 'style' || node.children.length === 0) {
return;
}
if (
node.attributes.type != null &&
node.attributes.type !== '' &&
node.attributes.type !== 'text/css'
) {
return;
}
const cssText = node.children
.filter((child) => child.type === 'text' || child.type === 'cdata')
.map((child) => child.value)
.join('');
/** @type {?csstree.CssNode} */
let cssAst = null;
try {
cssAst = csstree.parse(cssText, {
parseValue: false,
parseCustomProperty: false,
});
} catch {
return;
}
if (cssAst.type === 'StyleSheet') {
styles.push({ node, parentNode, cssAst });
}
// collect selectors
csstree.walk(cssAst, {
visit: 'Rule',
enter(node) {
const atrule = this.atrule;
// skip media queries not included into useMqs param
let mediaQuery = '';
if (atrule != null) {
mediaQuery = atrule.name;
if (atrule.prelude != null) {
mediaQuery += ` ${csstree.generate(atrule.prelude)}`;
}
}
if (!useMqs.includes(mediaQuery)) {
return;
}
if (node.prelude.type === 'SelectorList') {
node.prelude.children.forEach((childNode, item) => {
if (childNode.type === 'Selector') {
/**
* @type {{
* item: csstree.ListItem<csstree.CssNode>,
* list: csstree.List<csstree.CssNode>
* }[]}
*/
const pseudos = [];
childNode.children.forEach(
(grandchildNode, grandchildItem, grandchildList) => {
const isPseudo =
grandchildNode.type === 'PseudoClassSelector' ||
grandchildNode.type === 'PseudoElementSelector';
if (
isPseudo &&
!preservedPseudos.includes(grandchildNode.name)
) {
pseudos.push({
item: grandchildItem,
list: grandchildList,
});
}
},
);
const pseudoSelectors = csstree.generate({
type: 'Selector',
children: new csstree.List().fromArray(
pseudos.map((pseudo) => pseudo.item.data),
),
});
if (usePseudos.includes(pseudoSelectors)) {
for (const pseudo of pseudos) {
pseudo.list.remove(pseudo.item);
}
}
selectors.push({ node: childNode, rule: node, item: item });
}
});
}
},
});
},
},
root: {
exit: () => {
if (styles.length === 0) {
return;
}
const sortedSelectors = selectors
.slice()
.sort((a, b) => {
const aSpecificity = syntax.specificity(a.item.data);
const bSpecificity = syntax.specificity(b.item.data);
return compareSpecificity(aSpecificity, bSpecificity);
})
.reverse();
for (const selector of sortedSelectors) {
// match selectors
const selectorText = csstree.generate(selector.item.data);
/** @type {import('../lib/types.js').XastElement[]} */
const matchedElements = [];
try {
for (const node of querySelectorAll(root, selectorText)) {
if (node.type === 'element') {
matchedElements.push(node);
}
}
} catch {
continue;
}
// nothing selected
if (matchedElements.length === 0) {
continue;
}
// apply styles to matched elements
// skip selectors that match more than once if option onlyMatchedOnce is enabled
if (onlyMatchedOnce && matchedElements.length > 1) {
continue;
}
// apply <style/> to matched elements
for (const selectedEl of matchedElements) {
const styleDeclarationList = csstree.parse(
selectedEl.attributes.style ?? '',
{
context: 'declarationList',
parseValue: false,
},
);
if (styleDeclarationList.type !== 'DeclarationList') {
continue;
}
const styleDeclarationItems = new Map();
/** @type {csstree.ListItem<csstree.CssNode>} */
let firstListItem;
csstree.walk(styleDeclarationList, {
visit: 'Declaration',
enter(node, item) {
if (firstListItem == null) {
firstListItem = item;
}
styleDeclarationItems.set(node.property.toLowerCase(), item);
},
});
// merge declarations
csstree.walk(selector.rule, {
visit: 'Declaration',
enter(ruleDeclaration) {
// existing inline styles have higher priority
// no inline styles, external styles, external styles used
// inline styles, external styles same priority as inline styles, inline styles used
// inline styles, external styles higher priority than inline styles, external styles used
const property = ruleDeclaration.property;
if (
attrsGroups.presentation.has(property) &&
!selectors.some((selector) =>
includesAttrSelector(selector.item, property),
)
) {
delete selectedEl.attributes[property];
}
const matchedItem = styleDeclarationItems.get(property);
const ruleDeclarationItem =
styleDeclarationList.children.createItem(ruleDeclaration);
if (matchedItem == null) {
styleDeclarationList.children.insert(
ruleDeclarationItem,
firstListItem,
);
} else if (
matchedItem.data.important !== true &&
ruleDeclaration.important === true
) {
styleDeclarationList.children.replace(
matchedItem,
ruleDeclarationItem,
);
styleDeclarationItems.set(property, ruleDeclarationItem);
}
},
});
const newStyles = csstree.generate(styleDeclarationList);
if (newStyles.length !== 0) {
selectedEl.attributes.style = newStyles;
}
}
if (
removeMatchedSelectors &&
matchedElements.length !== 0 &&
selector.rule.prelude.type === 'SelectorList'
) {
// clean up matching simple selectors if option removeMatchedSelectors is enabled
selector.rule.prelude.children.remove(selector.item);
}
selector.matchedElements = matchedElements;
}
// no further processing required
if (!removeMatchedSelectors) {
return;
}
// clean up matched class + ID attribute values
for (const selector of sortedSelectors) {
if (selector.matchedElements == null) {
continue;
}
if (onlyMatchedOnce && selector.matchedElements.length > 1) {
// skip selectors that match more than once if option onlyMatchedOnce is enabled
continue;
}
for (const selectedEl of selector.matchedElements) {
// class
const classList = new Set(
selectedEl.attributes.class == null
? null
: selectedEl.attributes.class.split(' '),
);
for (const child of selector.node.children) {
if (
child.type === 'ClassSelector' &&
!selectors.some((selector) =>
includesAttrSelector(
selector.item,
'class',
child.name,
true,
),
)
) {
classList.delete(child.name);
}
}
if (classList.size === 0) {
delete selectedEl.attributes.class;
} else {
selectedEl.attributes.class = Array.from(classList).join(' ');
}
// ID
const firstSubSelector = selector.node.children.first;
if (
firstSubSelector?.type === 'IdSelector' &&
selectedEl.attributes.id === firstSubSelector.name &&
!selectors.some((selector) =>
includesAttrSelector(
selector.item,
'id',
firstSubSelector.name,
true,
),
)
) {
delete selectedEl.attributes.id;
}
}
}
for (const style of styles) {
csstree.walk(style.cssAst, {
visit: 'Rule',
enter: function (node, item, list) {
// clean up <style/> rulesets without any css selectors left
if (
node.type === 'Rule' &&
node.prelude.type === 'SelectorList' &&
node.prelude.children.isEmpty
) {
list.remove(item);
}
},
});
if (style.cssAst.children.isEmpty) {
// remove empty style element
detachNodeFromParent(style.node, style.parentNode);
} else {
// update style element if any styles left
const firstChild = style.node.children[0];
if (firstChild.type === 'text' || firstChild.type === 'cdata') {
firstChild.value = csstree.generate(style.cssAst);
}
}
}
},
},
};
};