UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

737 lines • 33 kB
/** * The app's stylesheet as the model learns it — the isomorphic half of `genuiSandboxKit({ styles })`: * * - Tailwind: the theme tokens, utilities and variants the app's Tailwind directives declare * (`@theme`, `@utility`, `@custom-variant`), and a Tailwind v3 config's theme as a v4 `@theme` * (the frame runs the v4 browser runtime); * - any other CSS (plain, Sass, Less, Bootstrap…): its vocabulary — the class names it defines, * grouped and deduplicated, the most used first, and the custom properties on its root; * - `url()`s turned into `data:` URIs (the frame has no network), within a size budget. * * Everything here is pure: the Node side (`genui/kit/styles.ts`) reads and compiles the files. */ import { themeVarsFromCss } from './sandbox-theme.js'; // --------------------------------------------------------------------------------------------- // A small CSS walker: the style rules' selectors, and the at-rules they sit in. // --------------------------------------------------------------------------------------------- /** At-rules whose block holds more rules (their selectors count); the others' blocks are skipped. */ const GROUPING_AT_RULES = /^@(media|supports|layer|container|scope|document|starting-style)\b/i; function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ''); } /** The index of the `}` closing the block opened at `open` (strings skipped). */ function blockEnd(css, open) { let depth = 0; for (let index = open; index < css.length; index++) { const c = css[index]; if (c === '"' || c === "'") { const close = css.indexOf(c, index + 1); index = close < 0 ? css.length : close; } else if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) return index; } } return css.length; } /** Every style rule's selector text, at any depth of grouping at-rules. */ function forEachSelector(css, visit) { const walk = (text) => { let start = 0; for (let index = 0; index < text.length; index++) { const c = text[index]; if (c === '"' || c === "'") { const close = text.indexOf(c, index + 1); index = close < 0 ? text.length : close; } else if (c === ';' || c === '}') { start = index + 1; } else if (c === '{') { const prelude = text.slice(start, index).trim(); const end = blockEnd(text, index); const body = text.slice(index + 1, end); if (prelude.startsWith('@')) { if (GROUPING_AT_RULES.test(prelude)) walk(body); } else if (prelude !== '') visit(prelude, body); index = end; start = end + 1; } } }; walk(stripComments(css)); } /** A generated (CSS-modules) name: `_button_x1y2z_3`, `Button_root__a1B2c`. */ const HASHED_CLASS = /(^_[\w-]+_[a-zA-Z0-9]{5,}(_\d+)?$)|(__[a-zA-Z0-9_-]{5}$)/; /** Whether a class name is a generated (CSS-modules) one — nobody can write it by hand. */ export function isGeneratedClassName(name) { return HASHED_CLASS.test(name); } /** The class names a selector names (escaped ones — `md\:flex` — and those in attribute values left out). */ function selectorClasses(selector) { const clean = selector.replace(/\[[^\]]*\]/g, '[]').replace(/"[^"]*"|'[^']*'/g, '""'); const out = []; for (const match of clean.matchAll(/\.((?:-?[_a-zA-Z]|--)[_a-zA-Z0-9-]*)(\\?)/g)) { if (match[2] === '\\') continue; out.push(match[1]); } return out; } /** * What a stylesheet gives the model to write with: its class names, grouped into families * (`btn`, `card`, `form`…) and compressed (`btn-{primary,secondary,danger}`), the most used first — * a class counts once per selector it appears in, and twice more where it stands alone (`.btn {`, * a component's root) — plus the custom properties on its root. Generated CSS-modules names are * left out (nobody can write them). */ export function extractCssVocabulary(css, options = {}) { const maxClasses = options.maxClasses ?? Number.POSITIVE_INFINITY; const maxPerFamily = options.maxPerFamily ?? Number.POSITIVE_INFINITY; const maxChars = options.maxChars ?? 12000; const maxVars = options.maxVars ?? 80; const score = new Map(); forEachSelector(css, (selectorList) => { for (const selector of selectorList.split(',')) { const trimmed = selector.trim(); const classes = selectorClasses(trimmed); const alone = classes.length === 1 && /^\.[_a-zA-Z0-9-]+$/.test(trimmed); for (const name of new Set(classes)) { if (HASHED_CLASS.test(name)) continue; score.set(name, (score.get(name) ?? 0) + 1 + (alone ? 2 : 0)); } } }); const family = (name) => name.replace(/^[-_]+/, '').split(/-|__/)[0] || name; const families = new Map(); const ranked = [...score.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); for (const [name, value] of ranked) { const key = family(name); const entry = families.get(key) ?? { score: 0, names: [] }; // A family ranks by its most used member (a component's root, `.btn`), not by its size — a // utility family with a class per breakpoint and step is large, not important. entry.score = Math.max(entry.score, value); entry.names.push(name); families.set(key, entry); } const orderedFamilies = [...families.entries()].sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0])); // Each family as patterns; families of the same shape (`m-{0..5,auto}`, `mt-{0..5,auto}`…) as one. const shaped = []; const byShape = new Map(); let counted = 0; for (const [name, entry] of orderedFamilies) { if (counted >= maxClasses) break; const names = entry.names.slice(0, Math.min(maxPerFamily, maxClasses - counted)); counted += names.length; const classes = compressClassNames(names); // A family of one bare class (`active`, `show`) keeps its own entry. const own = classes.some((each) => each !== name) && classes.every((each) => each === name || each.startsWith(`${name}-`)); const shape = own ? classes.map((each) => `\u0000${each.slice(name.length)}`).join(' ') : ''; const same = shape === '' ? undefined : byShape.get(shape); if (same !== undefined) { same.members.push(name); same.count += names.length; continue; } const group = { name, members: [name], classes, count: names.length }; shaped.push(group); if (shape !== '') byShape.set(shape, group); } const groups = []; let listed = 0; let chars = 0; for (const group of shaped) { let { name, classes } = group; if (group.members.length > 1) { name = renderAlternatives(group.members); classes = classes.map((each) => `${name}${each.slice(group.members[0].length)}`); } const size = name.length + classes.reduce((sum, each) => sum + each.length + 1, 4); if (chars + size > maxChars) continue; chars += size; listed += group.count; groups.push({ name, classes }); } const vars = Object.keys(themeVarsFromCss(css, { rootOnly: true })).slice(0, maxVars); return { groups, total: score.size, listed, vars }; } /** One alternative set rendered: `{0..5}`, `{0..5,auto}`, `{m,p}{,t,b}`, `{primary,secondary}`. */ function renderAlternatives(alternatives) { if (alternatives.length === 1) return alternatives[0]; const numbers = alternatives .filter((each) => /^(0|[1-9]\d{0,3})$/.test(each)) .map(Number) .sort((a, b) => a - b); const others = alternatives.filter((each) => !/^(0|[1-9]\d{0,3})$/.test(each)); const runs = []; for (let index = 0; index < numbers.length;) { let end = index; while (end + 1 < numbers.length && numbers[end + 1] === numbers[end] + 1) end++; if (end - index >= 2) runs.push(`${numbers[index]}..${numbers[end]}`); else for (let each = index; each <= end; each++) runs.push(String(numbers[each])); index = end + 1; } // Short words sharing their first letter (`m mt mb`: `m{,t,b}`), or their tails too // (`m mt p pt`: `{m,p}{,t}`). if (numbers.length === 0 && others.length >= 3 && others.every((each) => /^[a-z]{1,4}$/.test(each))) { const tails = new Map(); for (const each of others) { const head = each.slice(0, 1); tails.set(head, [...(tails.get(head) ?? []), each.slice(1)]); } const lists = [...tails.values()].map((list) => list.join(',')); if (tails.size === 1) return `${[...tails.keys()][0]}{${lists[0]}}`; if (lists.every((list) => list === lists[0])) return `{${[...tails.keys()].join(',')}}{${lists[0]}}`; } return `{${[...runs, ...others].join(',')}}`; } /** * Class names written as patterns, every name covered exactly once and nothing else: names with the * same number of `-segments` that differ in one segment merge into one pattern (`btn-primary`, * `btn-secondary` → `btn-{primary,secondary}`), again and again, segment by segment from the last — * so a utility family becomes one line (`{m,p}{,t,b,s,e,x,y}-{0..5,auto}`, * `{m,p}{,t,b,s,e,x,y}-{sm,md,lg,xl,xxl}-{0..5,auto}`). A run of three or more integers is a range * (`{0..5}`); short words sharing their tails share them (`{m,p}{,t,b}`). Order kept (the first * member's place). */ export function compressClassNames(names) { const byLength = new Map(); names.forEach((name, order) => { const segments = name.split('-').map((segment) => [segment]); const list = byLength.get(segments.length) ?? []; list.push({ order, segments }); byLength.set(segments.length, list); }); const patterns = []; for (const [length, start] of byLength) { let list = start; // Bare words (`alert`, `fade`) are names of their own, never alternatives of each other. for (let changed = length > 1; changed;) { changed = false; for (let position = length - 1; position >= 0; position--) { const merged = new Map(); for (const pattern of list) { const key = pattern.segments .map((alternatives, index) => (index === position ? '*' : alternatives.join(','))) .join('\u0000'); const into = merged.get(key); if (into === undefined) { merged.set(key, { order: pattern.order, segments: pattern.segments.map((a) => [...a]), }); continue; } changed = true; into.order = Math.min(into.order, pattern.order); const target = into.segments[position]; for (const alternative of pattern.segments[position]) if (!target.includes(alternative)) target.push(alternative); } list = [...merged.values()]; } } patterns.push(...list); } return patterns .sort((a, b) => a.order - b.order) .map((pattern) => pattern.segments.map(renderAlternatives).join('-')); } /** The vocabulary as the model reads it (inside the sandbox description). */ export function vocabularyToModelText(vocabulary, options = {}) { const attribute = options.jsx === true ? 'className (in JSX) or class (in html)' : 'class="…"'; const lines = [ `The app's stylesheet is loaded in the frame. Style the view with ITS classes (${attribute}) instead of writing your own CSS — write css only for layout it does not cover. The classes it defines, by family, most used first (a{b,c} means ab and ac; {0..3} means 0, 1, 2 and 3):`, ]; // Families of one bare class (`active`, `show`, `lead`) share one line, where the first one ranks. const bare = vocabulary.groups.filter((group) => group.classes.length === 1 && group.classes[0] === group.name); let bareLine = false; for (const group of vocabulary.groups) { if (bare.includes(group)) { if (!bareLine) lines.push(`- ${bare.map((each) => each.name).join(' ')}`); bareLine = true; continue; } // Families merged into one pattern are named by it: their classes say it already. lines.push(group.name.includes('{') ? `- ${group.classes.join(' ')}` : `- ${group.name}: ${group.classes.join(' ')}`); } if (vocabulary.total > vocabulary.listed) lines.push(`(${vocabulary.total - vocabulary.listed} rarer classes not listed.)`); if (vocabulary.vars.length > 0) lines.push(`Its custom properties, for your own css: ${vocabulary.vars.map((name) => `var(${name})`).join(', ')}.`); return lines.join('\n'); } // --------------------------------------------------------------------------------------------- // Tailwind // --------------------------------------------------------------------------------------------- /** Whether a stylesheet's source uses Tailwind, and which major version it reads as. */ export function tailwindVersionOf(source) { const text = stripComments(source); if (/@import\s+(url\()?\s*["']tailwindcss(\/[^"']*)?["']/.test(text)) return 4; if (/@(theme|custom-variant|utility|plugin|source)\b/.test(text)) return 4; if (/\/\*!\s*tailwindcss v4/.test(source)) return 4; if (/@tailwind\s+(base|components|utilities|variants|screens)\b/.test(text)) return 3; if (/\/\*!\s*tailwindcss v3/.test(source)) return 3; return null; } /** * The Tailwind directives of an app stylesheet the frame's Tailwind runtime gets too: its * `@custom-variant`s, `@utility`s, `@variant`s and `@theme` blocks (`@keyframes` inside them * included) — not its `@import`s, `@source`s, `@plugin`s or base styles (those reach the frame * compiled). */ export function tailwindDirectivesFromCss(css) { const text = stripComments(css); const out = []; const directive = /@(custom-variant|utility|theme)\b/g; let depth = 0; for (let index = 0; index < text.length; index++) { const c = text[index]; if (c === '{') depth++; else if (c === '}') depth = Math.max(0, depth - 1); else if (c === '@' && depth === 0) { directive.lastIndex = index; const match = directive.exec(text); if (match === null || match.index !== index) continue; // One statement (`@custom-variant dark (&:where(.dark *));`) or one balanced block. let end = index; let inner = 0; for (; end < text.length; end++) { const ch = text[end]; if (ch === ';' && inner === 0) break; if (ch === '{') inner++; else if (ch === '}') { inner--; if (inner === 0) break; } } out.push(text.slice(index, end + 1).trim()); index = end; } } return out.join('\n'); } /** The tokens a Tailwind stylesheet's directives declare (see {@link tailwindDirectivesFromCss}). */ export function tailwindTokensFromCss(css) { const tokens = { colors: [], radius: [], fonts: [], shadows: [], text: [], animations: [], utilities: [], variants: [], }; const text = stripComments(css); const add = (list, name) => { if (name !== '' && name !== '*' && !list.includes(name)) list.push(name); }; for (const block of text.matchAll(/@theme\b[^{]*\{/g)) { const open = (block.index ?? 0) + block[0].length - 1; const body = text.slice(open + 1, blockEnd(text, open)); for (const match of body.matchAll(/(?:^|[;{\s])--([A-Za-z0-9_-]+)\s*:\s*([^;]*)/g)) { const name = match[1]; if ((match[2] ?? '').trim() === 'initial') continue; const pick = (prefix, list) => { if (!name.startsWith(prefix)) return false; const rest = name.slice(prefix.length); if (!rest.includes('--')) add(list, rest); return true; }; if (pick('color-', tokens.colors)) continue; if (pick('radius-', tokens.radius)) continue; if (pick('font-', tokens.fonts)) continue; if (pick('shadow-', tokens.shadows)) continue; if (pick('text-', tokens.text)) continue; pick('animate-', tokens.animations); } } for (const match of text.matchAll(/@utility\s+([A-Za-z0-9_*-]+)/g)) add(tokens.utilities, match[1]); for (const match of text.matchAll(/@custom-variant\s+([A-Za-z0-9_-]+)/g)) add(tokens.variants, match[1]); return tokens; } /** What the model is told about Tailwind in the frame. `colors` may add the host theme's names. */ export function tailwindToModelText(tokens, options = {}) { const colors = [...new Set([...tokens.colors, ...(options.colors ?? [])])]; const list = (names, max) => names.slice(0, max).join(', ') + (names.length > max ? ', …' : ''); const parts = [ 'Tailwind CSS utility classes work in the frame, offline — the app\'s own Tailwind theme, compiled live for whatever classes you write (className="flex gap-2 p-4 rounded-lg bg-card text-card-foreground"). Prefer them to custom CSS; w-full for the full width of the frame.', ]; if (options.version === 3) parts.push('The app is on Tailwind v3, the frame runs v4 with its theme mapped: write v4 names (shadow-xs, rounded-xs, outline-hidden where v3 had shadow-sm, rounded-sm, outline-none).'); const theme = []; if (colors.length > 0) theme.push(`colors ${list(colors, 40)} (bg-*, text-*, border-*, ring-*)`); if (tokens.radius.length > 0) theme.push(`radius rounded-{${list(tokens.radius, 12)}}`); if (tokens.fonts.length > 0) theme.push(`fonts font-{${list(tokens.fonts, 8)}}`); if (tokens.shadows.length > 0) theme.push(`shadows shadow-{${list(tokens.shadows, 10)}}`); if (tokens.text.length > 0) theme.push(`text sizes text-{${list(tokens.text, 14)}}`); if (tokens.animations.length > 0) theme.push(`animations animate-{${list(tokens.animations, 10)}}`); if (theme.length > 0) parts.push(`The app's theme: ${theme.join('; ')}.`); if (tokens.utilities.length > 0) parts.push(`Its own utilities: ${list(tokens.utilities, 30)}.`); if (tokens.variants.length > 0) parts.push(`Its variants: ${tokens.variants .slice(0, 20) .map((name) => `${name}:`) .join(', ')}.`); return parts.join(' '); } /** `{ primary: { DEFAULT: x, foreground: y } }` → `[['primary', x], ['primary-foreground', y]]`. */ function flattenThemeObject(value, prefix = '') { if (typeof value === 'string' || typeof value === 'number') return prefix === '' ? [] : [[prefix, String(value)]]; if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; const out = []; for (const [key, inner] of Object.entries(value)) { const name = key === 'DEFAULT' ? prefix : prefix === '' ? key : `${prefix}-${key}`; if (name === '') continue; out.push(...flattenThemeObject(inner, name)); } return out; } function cssName(key) { return key.replace(/\./g, '_').replace(/[^A-Za-z0-9_-]/g, '-'); } function fontStack(value) { const list = Array.isArray(value) ? (Array.isArray(value[0]) ? value[0] : value) : [value]; const names = list.filter((each) => typeof each === 'string'); if (names.length === 0) return undefined; return names .map((name) => /\s/.test(name) && !/^["']/.test(name) && !name.includes('(') ? `"${name}"` : name) .join(', '); } function keyframesCss(name, frames) { if (typeof frames !== 'object' || frames === null) return undefined; const steps = Object.entries(frames).map(([step, decls]) => { const body = Object.entries((decls ?? {})) .filter(([, value]) => typeof value === 'string' || typeof value === 'number') .map(([prop, value]) => `${prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}: ${String(value)}`) .join('; '); return `${step} { ${body} }`; }); return `@keyframes ${cssName(name)} { ${steps.join(' ')} }`; } /** * A Tailwind v3 config's theme as the v4 directives the frame's runtime reads: colors → * `--color-*` (nested shades flattened, `DEFAULT` dropped, `<alpha-value>` → 1), `borderRadius` → * `--radius-*`, `fontFamily` → `--font-*`, `fontSize` → `--text-*`, `boxShadow` → `--shadow-*`, * `screens` → `--breakpoint-*`, `keyframes`/`animation` → `@keyframes` + `--animate-*` — the * `theme` and its `extend` together, function values skipped. `darkMode: 'class'` / `'selector'` / * `['class', '<selector>']` becomes a `dark` variant. */ export function tailwindV3ConfigToCss(config) { const cfg = (config ?? {}); const theme = {}; for (const [key, value] of Object.entries(cfg.theme ?? {})) if (key !== 'extend') theme[key] = value; for (const [key, value] of Object.entries(cfg.theme?.extend ?? {})) { const base = theme[key]; theme[key] = typeof base === 'object' && base !== null && typeof value === 'object' && value !== null ? { ...base, ...value } : value; } const lines = []; const extra = []; const scale = (key, prefix) => { for (const [name, value] of flattenThemeObject(theme[key])) lines.push(`--${prefix}-${cssName(name)}: ${value.replace(/<alpha-value>/g, '1')}`); }; scale('colors', 'color'); scale('borderRadius', 'radius'); scale('boxShadow', 'shadow'); scale('screens', 'breakpoint'); scale('spacing', 'spacing'); for (const [name, value] of Object.entries((theme.fontFamily ?? {}))) { const stack = fontStack(value); if (stack !== undefined) lines.push(`--font-${cssName(name)}: ${stack}`); } for (const [name, value] of Object.entries((theme.fontSize ?? {}))) { const [size, rest] = Array.isArray(value) ? value : [value]; if (typeof size !== 'string') continue; lines.push(`--text-${cssName(name)}: ${size}`); const lineHeight = typeof rest === 'string' ? rest : rest?.lineHeight; if (typeof lineHeight === 'string') lines.push(`--text-${cssName(name)}--line-height: ${lineHeight}`); } for (const [name, value] of Object.entries((theme.animation ?? {}))) if (typeof value === 'string') lines.push(`--animate-${cssName(name)}: ${value}`); for (const [name, frames] of Object.entries((theme.keyframes ?? {}))) { const css = keyframesCss(name, frames); if (css !== undefined) extra.push(css); } const dark = cfg.darkMode; let variant; if (dark === 'class' || dark === 'selector') variant = '.dark'; else if (Array.isArray(dark) && (dark[0] === 'class' || dark[0] === 'selector') && typeof dark[1] === 'string') variant = dark[1]; const out = []; if (variant !== undefined) out.push(`@custom-variant dark (&:where(${variant}, ${variant} *));`); if (lines.length > 0 || extra.length > 0) out.push(`@theme {\n${lines.map((line) => ` ${line};`).join('\n')}${extra.length > 0 ? `\n ${extra.join('\n ')}` : ''}\n}`); return out.join('\n'); } /** Split a selector list on its top-level commas (not those inside `()` / `[]` / strings). */ function splitSelectorList(prelude) { const out = []; let depth = 0; let start = 0; for (let index = 0; index < prelude.length; index++) { const c = prelude[index]; if (c === '\\') index++; else if (c === '"' || c === "'") { const close = prelude.indexOf(c, index + 1); index = close < 0 ? prelude.length : close; } else if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth = Math.max(0, depth - 1); else if (c === ',' && depth === 0) { out.push(prelude.slice(start, index)); start = index + 1; } } out.push(prelude.slice(start)); return out; } /** CSS identifier escapes undone: `hover\:bg-x` → `hover:bg-x`, `\31 0` → `10`. */ function unescapeCssIdent(ident) { return ident.replace(/\\([0-9a-fA-F]{1,6}\s?|[\s\S])/g, (_all, escaped) => /^[0-9a-fA-F]{1,6}\s?$/.test(escaped) ? String.fromCodePoint(Number.parseInt(escaped.trim(), 16)) : escaped); } /** * The classes a complex selector REQUIRES — those outside functional pseudo-classes (`:not(.x)`, * `:is(.a, .b)`, `:where(…)`, `:has(…)`), whose classes do not all have to be on the page — unescaped. */ function requiredClasses(selector) { let outer = ''; let depth = 0; for (let index = 0; index < selector.length; index++) { const c = selector[index]; if (c === '\\') { if (depth === 0) outer += selector.slice(index, index + 2); index++; } else if (c === '"' || c === "'") { const close = selector.indexOf(c, index + 1); index = close < 0 ? selector.length : close; if (depth === 0) outer += '""'; } else if (c === '[') { const close = selector.indexOf(']', index + 1); index = close < 0 ? selector.length : close; if (depth === 0) outer += '[]'; } else if (c === '(') depth++; else if (c === ')') depth = Math.max(0, depth - 1); else if (depth === 0) outer += c; } const out = []; for (const match of outer.matchAll(/\.((?:\\[0-9a-fA-F]{1,6}\s?|\\[\s\S]|[\w-])+)/g)) out.push(unescapeCssIdent(match[1])); return out; } /** * A compiled stylesheet with the style rules nobody can use taken out: a rule (or one selector of * its list) goes when a class it requires is not kept (`options.keep`). Rules without classes (the * base: elements, `:root`, `*`), `@font-face`, `@keyframes`, `@property` and every other at-rule * stay; grouping at-rules (`@media`, `@supports`, `@layer`…) left empty go too. */ export function purgeCss(css, options) { const walk = (text, candidate) => { let out = ''; let start = 0; for (let index = 0; index < text.length; index++) { const c = text[index]; if (c === '"' || c === "'") { const close = text.indexOf(c, index + 1); index = close < 0 ? text.length : close; } else if (c === ';') { // A statement (`@layer theme, base;`, `@import …;`, `@charset …;`): kept as it is. out += text.slice(start, index + 1); start = index + 1; } else if (c === '}') { start = index + 1; } else if (c === '{') { const prelude = text.slice(start, index); const trimmed = prelude.trim(); const end = blockEnd(text, index); const body = text.slice(index + 1, end); if (trimmed.startsWith('@')) { if (GROUPING_AT_RULES.test(trimmed)) { const inner = walk(body, candidate || options.scope === 'all' || /^@layer\s+utilities\b/i.test(trimmed)); if (inner.trim() !== '') out += `${prelude}{${inner}}`; } else out += `${prelude}{${body}}`; } else if (!candidate || body.includes('{')) { out += `${prelude}{${body}}`; } else { const kept = splitSelectorList(prelude).filter((selector) => requiredClasses(selector).every((name) => options.keep(name))); if (kept.length > 0) out += `${kept.join(',')}{${body}}`; } index = end; start = end + 1; } } return out + text.slice(start); }; return walk(stripComments(css), options.scope === 'all'); } /** * The class-like tokens of a source text (a kit bundle): every run of characters between quotes, * backticks, whitespace and template braces — a superset of the class names it can put on an * element (`"inline-flex hover:bg-primary/90"` → `inline-flex`, `hover:bg-primary/90`). */ export function classTokensOf(source) { const tokens = new Set(); for (const raw of source.split(/[\s"'`]|\$\{|\\[nrt]/)) { if (raw === '' || raw.length > 200) continue; tokens.add(raw); const trimmed = raw.replace(/^[{(,;]+|[,;)}]+$/g, ''); if (trimmed !== raw && trimmed !== '') tokens.add(trimmed); } return tokens; } export const INLINE_ASSET_DEFAULTS = { limit: 128 * 1024, budget: 1024 * 1024 }; /** `data:` URI payload size in bytes (base64 decoded). */ function dataUriBytes(uri) { const comma = uri.indexOf(','); const payload = comma < 0 ? '' : uri.slice(comma + 1); return /;base64/i.test(uri.slice(0, comma)) ? Math.floor((payload.length * 3) / 4) : payload.length; } const EMPTY_URL = 'url("data:,")'; /** * Every `url()` of a stylesheet made network-less: `data:` URIs kept (within the budget), files * inlined as `data:` URIs when `resolve` finds them and they fit `limit` and `budget`, everything * else — remote urls, files too big, files not found — replaced by an empty `data:,` (the browser * falls back: the next font in the stack, no background image), each with a warning. */ export function inlineCssUrls(css, options) { const limit = options.limit ?? INLINE_ASSET_DEFAULTS.limit; const budget = options.budget ?? INLINE_ASSET_DEFAULTS.budget; let used = 0; const drop = (url, why) => { options.warn?.(`dropped url(${url.length > 80 ? `${url.slice(0, 77)}…` : url}): ${why}`); return EMPTY_URL; }; return css.replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]*))\s*\)/g, (whole, a, b, c) => { const url = (a ?? b ?? c ?? '').trim(); if (url === '' || url.startsWith('#')) return whole; if (/^data:/i.test(url)) { const bytes = dataUriBytes(url); if (bytes > limit) return drop(url.slice(0, 40), `inlined asset of ${kb(bytes)} is over the ${kb(limit)} limit`); if (used + bytes > budget) return drop(url.slice(0, 40), `over the ${kb(budget)} budget for inlined assets`); used += bytes; return whole; } if (/^blob:/i.test(url)) return whole; const found = options.resolve(url); if (found === null) return drop(url, /^(https?:)?\/\//i.test(url) ? 'the sandbox has no network' : 'not found'); if ('dropped' in found) return drop(url, found.dropped); if (found.bytes > limit) return drop(url, `${kb(found.bytes)} is over the ${kb(limit)} limit`); if (used + found.bytes > budget) return drop(url, `over the ${kb(budget)} budget for inlined assets`); used += found.bytes; return `url("data:${found.mime};base64,${found.data}")`; }); } function kb(bytes) { return `${Math.round(bytes / 1024)} KB`; } //# sourceMappingURL=sandbox-styles.js.map