mirror of
https://github.com/unraid/api.git
synced 2026-01-01 22:20:05 -06:00
- Introduced a new PostCSS plugin, `scopeTailwindToUnapi`, to scope Tailwind CSS classes to specific elements. - Updated Vite configuration to include the new PostCSS plugin for CSS processing. - Enhanced theme management in the theme store to apply scoped classes and dynamic CSS variables to multiple targets, including the document root and elements with the `.unapi` class. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Scoped styling for embedded (.unapi) contexts and a PostCSS plugin to automate it. * Theme refresh after mount to propagate CSS variables to embedded roots. * Exposed idempotent restart action for the Unraid API when offline. * **Bug Fixes** * Consistent dark mode and theme variable application across main and embedded views. * Interactive element and SSO styles now apply in embedded contexts. * Simplified changelog iframe with a reliable fallback renderer; improved logs styling scope. * **Tests** * New unit tests for the scoping plugin, changelog iframe, and related components. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
166 lines
4.3 KiB
TypeScript
166 lines
4.3 KiB
TypeScript
interface Container {
|
||
type: string;
|
||
parent?: Container;
|
||
}
|
||
|
||
interface Rule extends Container {
|
||
selector?: string;
|
||
selectors?: string[];
|
||
}
|
||
|
||
interface AtRule extends Container {
|
||
name: string;
|
||
params: string;
|
||
}
|
||
|
||
type PostcssPlugin = {
|
||
postcssPlugin: string;
|
||
Rule?(rule: Rule): void;
|
||
};
|
||
|
||
type PluginCreator<T> = {
|
||
(opts?: T): PostcssPlugin;
|
||
postcss?: boolean;
|
||
};
|
||
|
||
export interface ScopeOptions {
|
||
scope?: string;
|
||
layers?: string[];
|
||
includeRoot?: boolean;
|
||
}
|
||
|
||
const DEFAULT_SCOPE = '.unapi';
|
||
const DEFAULT_LAYERS = ['*'];
|
||
const DEFAULT_INCLUDE_ROOT = true;
|
||
|
||
const KEYFRAME_AT_RULES = new Set(['keyframes']);
|
||
const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']);
|
||
const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.theme-/, /^\.has-custom-/, /^\.dark\b/];
|
||
|
||
function shouldScopeRule(rule: Rule, targetLayers: Set<string>, includeRootRules: boolean): boolean {
|
||
const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0;
|
||
const hasSelectorArray = Array.isArray(rule.selectors) && rule.selectors.length > 0;
|
||
|
||
// Skip rules without selectors (e.g. @font-face) or nested keyframe steps
|
||
if (!hasSelectorString && !hasSelectorArray) {
|
||
return false;
|
||
}
|
||
|
||
const directParent = rule.parent;
|
||
if (directParent?.type === 'atrule') {
|
||
const parentAtRule = directParent as AtRule;
|
||
const parentAtRuleName = parentAtRule.name.toLowerCase();
|
||
if (KEYFRAME_AT_RULES.has(parentAtRuleName) || parentAtRuleName.endsWith('keyframes')) {
|
||
return false;
|
||
}
|
||
if (NON_SCOPED_AT_RULES.has(parentAtRuleName)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const includeAllLayers = targetLayers.has('*');
|
||
|
||
// Traverse ancestors to find the enclosing @layer declaration
|
||
let current: Container | undefined = rule.parent ?? undefined;
|
||
|
||
while (current) {
|
||
if (current.type === 'atrule') {
|
||
const currentAtRule = current as AtRule;
|
||
if (currentAtRule.name === 'layer') {
|
||
const layerNames = currentAtRule.params
|
||
.split(',')
|
||
.map((name: string) => name.trim())
|
||
.filter(Boolean);
|
||
if (includeAllLayers) {
|
||
return true;
|
||
}
|
||
return layerNames.some((name) => targetLayers.has(name));
|
||
}
|
||
}
|
||
current = current.parent ?? undefined;
|
||
}
|
||
|
||
// If the rule is not inside any @layer, treat it as root-level CSS
|
||
return includeRootRules;
|
||
}
|
||
|
||
function hasScope(selector: string, scope: string): boolean {
|
||
return selector.includes(scope);
|
||
}
|
||
|
||
function prefixSelector(selector: string, scope: string): string {
|
||
const trimmed = selector.trim();
|
||
|
||
if (!trimmed) {
|
||
return selector;
|
||
}
|
||
|
||
if (hasScope(trimmed, scope)) {
|
||
return trimmed;
|
||
}
|
||
|
||
// Do not prefix :host selectors – they are only valid at the top level
|
||
if (trimmed.startsWith(':host')) {
|
||
return trimmed;
|
||
}
|
||
|
||
if (trimmed === ':root') {
|
||
return scope;
|
||
}
|
||
|
||
if (trimmed.startsWith(':root')) {
|
||
return `${scope}${trimmed.slice(':root'.length)}`;
|
||
}
|
||
|
||
const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? '';
|
||
const shouldMergeWithScope =
|
||
!firstToken.includes('\\:') && MERGE_WITH_SCOPE_PATTERNS.some((pattern) => pattern.test(firstToken));
|
||
|
||
if (shouldMergeWithScope) {
|
||
return `${scope}${trimmed}`;
|
||
}
|
||
|
||
return `${scope} ${trimmed}`;
|
||
}
|
||
|
||
export const scopeTailwindToUnapi: PluginCreator<ScopeOptions> = (options: ScopeOptions = {}) => {
|
||
const scope = options.scope ?? DEFAULT_SCOPE;
|
||
const layers = options.layers ?? DEFAULT_LAYERS;
|
||
const includeRootRules = options.includeRoot ?? DEFAULT_INCLUDE_ROOT;
|
||
const targetLayers = new Set<string>(layers);
|
||
|
||
return {
|
||
postcssPlugin: 'scope-tailwind-to-unapi',
|
||
Rule(rule: Rule) {
|
||
if (!shouldScopeRule(rule, targetLayers, includeRootRules)) {
|
||
return;
|
||
}
|
||
|
||
const hasSelectorArray = Array.isArray(rule.selectors);
|
||
let selectors: string[] = [];
|
||
|
||
if (hasSelectorArray && rule.selectors) {
|
||
selectors = rule.selectors;
|
||
} else if (rule.selector) {
|
||
selectors = [rule.selector];
|
||
}
|
||
|
||
if (!selectors.length) {
|
||
return;
|
||
}
|
||
|
||
const scopedSelectors = selectors.map((selector: string) => prefixSelector(selector, scope));
|
||
|
||
if (hasSelectorArray) {
|
||
rule.selectors = scopedSelectors;
|
||
} else {
|
||
rule.selector = scopedSelectors.join(', ');
|
||
}
|
||
},
|
||
};
|
||
};
|
||
|
||
scopeTailwindToUnapi.postcss = true;
|
||
|
||
export default scopeTailwindToUnapi;
|