Files
api/web/postcss/scopeTailwindToUnapi.ts
Pujit Mehrotra 277ac42046 feat: replace docker overview table with web component (7.3+) (#1764)
## Summary

Introduces a new Vue-based Docker container management interface
replacing the legacy webgui table.

### Container Management
- Start, stop, pause, resume, and remove containers via GraphQL
mutations
- Bulk actions for managing multiple containers at once
- Container update detection with one-click updates
- Real-time container statistics (CPU, memory, I/O)

### Organization & Navigation
- Folder-based container organization with drag-and-drop support
- Accessible reordering via keyboard controls
- Customizable column visibility with persistent preferences
- Column resizing and reordering
- Filtering and search across container properties

### Auto-start Configuration
- Dedicated autostart view with delay configuration
- Drag-and-drop reordering of start/stop sequences

### Logs & Console
- Integrated log viewer with filtering and download
- Persistent console sessions with shell selection
- Slideover panel for quick access

### Networking
- Port conflict detection and alerts
- Tailscale integration for container networking status
- LAN IP and port information display

### Additional Features
- Orphaned container detection and cleanup
- Template mapping management
- Critical notifications system
- WebUI visit links with Tailscale support

<sub>PR Summary by Claude Opus 4.5</sub>
2025-12-18 11:11:05 -05:00

230 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
interface Container {
type: string;
parent?: Container;
}
interface Rule extends Container {
selector?: string;
selectors?: string[];
}
interface AtRule extends Container {
name: string;
params: string;
}
type WalkAtRulesRoot = {
walkAtRules: (name: string, callback: (atRule: AtRule) => void) => void;
};
type ParentContainer = Container & {
insertBefore?: (oldNode: Container, newNode: Container) => void;
removeChild?: (node: Container) => void;
};
type RemovableAtRule = AtRule & {
nodes?: Container[];
remove?: () => void;
};
type PostcssPlugin = {
postcssPlugin: string;
Rule?(rule: Rule): void;
OnceExit?(root: WalkAtRulesRoot): 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[] = [/^\.has-custom-/, /^\.dark\b/];
const UNSCOPED_PATTERNS: RegExp[] = [/^\.Theme--/];
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;
}
// Do not scope Theme-- classes - they should remain global
const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? '';
if (!firstToken.includes('\\:') && UNSCOPED_PATTERNS.some((pattern) => pattern.test(firstToken))) {
return trimmed;
}
if (trimmed === ':root') {
return scope;
}
if (trimmed.startsWith(':root')) {
return `${scope}${trimmed.slice(':root'.length)}`;
}
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(', ');
}
},
OnceExit(root) {
// Remove @layer at-rules after all rules have been scoped.
// Tailwind CSS v4 uses @layer directives (e.g., @layer utilities, @layer components)
// to organize CSS. After the Rule hook scopes all selectors within these layers,
// the @layer wrappers are no longer needed in the final output.
//
// This cleanup step:
// 1. Extracts all scoped rules from inside @layer blocks
// 2. Moves them to the parent container (outside the @layer)
// 3. Removes the now-empty @layer wrapper
//
// This produces cleaner CSS output, avoids potential browser compatibility issues
// with CSS layers, and ensures the final CSS only contains the scoped rules without
// the organizational layer structure.
root.walkAtRules('layer', (atRule: AtRule) => {
const removableAtRule = atRule as RemovableAtRule;
const parent = atRule.parent as ParentContainer | undefined;
if (!parent) {
return;
}
// Extract all nodes from the @layer and move them to the parent
if (
Array.isArray(removableAtRule.nodes) &&
removableAtRule.nodes.length > 0 &&
typeof (parent as ParentContainer).insertBefore === 'function'
) {
const parentContainer = parent as ParentContainer;
while (removableAtRule.nodes.length) {
const node = removableAtRule.nodes[0]!;
parentContainer.insertBefore?.(atRule as unknown as Container, node);
}
}
// Remove the empty @layer wrapper
if (typeof removableAtRule.remove === 'function') {
removableAtRule.remove();
return;
}
(parent as ParentContainer).removeChild?.(atRule as unknown as Container);
});
},
};
};
scopeTailwindToUnapi.postcss = true;
export default scopeTailwindToUnapi;