UNPKG

@adonis-agora/agent

Version:

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

445 lines • 19 kB
/** * The app's styles in the sandbox — the Node half: which files they are (given, or found from the * app's Vite entrypoints), what they are (Tailwind v3/v4 or plain CSS), and what they compile to * with the app's own Vite CSS pipeline (Sass, Less, PostCSS, CSS modules, Tailwind's plugin…). */ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { classTokensOf, extractCssVocabulary, isGeneratedClassName, purgeCss, tailwindDirectivesFromCss, tailwindTokensFromCss, tailwindV3ConfigToCss, tailwindVersionOf, } from '../sandbox-styles.js'; import { themeVarsFromCss } from '../sandbox-theme.js'; import { globFiles } from './files.js'; import { contentHash, runKitViteBuild } from './vite-build.js'; /** Extensions a style entry may have. */ export const STYLE_EXTENSIONS = [ '.css', '.scss', '.sass', '.less', '.styl', '.stylus', '.pcss', '.postcss', '.sss', ]; const isStyleFile = (file) => STYLE_EXTENSIONS.includes(extname(file.split('?')[0] ?? file).toLowerCase()); /** The package a bare specifier names, found from `fromDir` up. */ function packageDir(name, fromDir) { let dir = fromDir; for (;;) { const candidate = join(dir, 'node_modules', name); if (existsSync(join(candidate, 'package.json'))) return candidate; const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } /** An `exports` target under the conditions a stylesheet import uses (`style`, then the defaults). */ function exportTarget(value) { if (typeof value === 'string') return value; if (Array.isArray(value)) { for (const each of value) { const found = exportTarget(each); if (found !== undefined) return found; } return undefined; } if (typeof value === 'object' && value !== null) { const record = value; for (const condition of ['style', 'sass', 'import', 'default', 'require']) { if (condition in record) { const found = exportTarget(record[condition]); if (found !== undefined) return found; } } } return undefined; } /** * A stylesheet import as CSS resolves it: relative, absolute, an alias of the app's Vite config, or * a package (`bootstrap/dist/css/bootstrap.css`, `shadcn/tailwind.css`, `@fontsource-variable/geist` * — its `exports` under the `style` condition, its `style` field). */ export function resolveStyleImport(spec, fromDir, alias = {}) { let target = spec.replace(/^~(?!\/)/, ''); for (const [key, value] of Object.entries(alias)) { if (target === key || target.startsWith(`${key}/`)) { target = `${value}${target.slice(key.length)}`; break; } } const file = (path) => (existsSync(path) && statSync(path).isFile() ? path : undefined); if (target.startsWith('.') || isAbsolute(target)) { const path = resolve(fromDir, target); return file(path) ?? file(`${path}.css`) ?? file(join(path, 'index.css')); } const parts = target.split('/'); const name = target.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; const sub = parts.slice(target.startsWith('@') ? 2 : 1).join('/'); const dir = packageDir(name, fromDir); if (dir === undefined) return undefined; let manifest = {}; try { manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch { /* no manifest: paths only */ } const exportsField = manifest.exports; if (typeof exportsField === 'object' && exportsField !== null && !Array.isArray(exportsField)) { const map = exportsField; const key = sub === '' ? '.' : `./${sub}`; const exact = map[key] !== undefined ? exportTarget(map[key]) : undefined; if (exact !== undefined) return file(join(dir, exact)); for (const [pattern, value] of Object.entries(map)) { if (!pattern.includes('*')) continue; const [before, after] = pattern.split('*'); if (key.startsWith(before) && key.endsWith(after)) { const star = key.slice(before.length, key.length - after.length); const found = exportTarget(value); if (found !== undefined) return file(join(dir, found.replace('*', star))); } } } else if (typeof exportsField === 'string' && sub === '') return file(join(dir, exportsField)); if (sub !== '') return file(join(dir, sub)) ?? file(join(dir, `${sub}.css`)); if (manifest.style !== undefined) return file(join(dir, manifest.style)); if (manifest.main !== undefined && isStyleFile(manifest.main)) return file(join(dir, manifest.main)); return file(join(dir, 'index.css')); } /** The `@import`s of a stylesheet (plain CSS, and Sass/Less `@use`/`@forward`/`@import`). */ function importsOf(text) { const out = []; const clean = text.replace(/\/\*[\s\S]*?\*\//g, ''); for (const match of clean.matchAll(/@(?:import|use|forward)\s+(?:url\(\s*)?["']([^"']+)["']/g)) out.push(match[1]); return out; } /** * The style entries and every stylesheet they `@import` (plain CSS imports are followed through * packages; Sass/Less partials are read for detection only). The `tailwindcss` package itself is * not read (the frame brings its own runtime). */ export function collectStyleSources(entries, options = {}) { const seen = new Set(); const out = []; const visit = (given, depth) => { // Its real path: a workspace package linked (or injected) into node_modules resolves its own // imports (`shadcn/tailwind.css`) from where it really is, beside its own dependencies. let file = given; try { file = realpathSync(given); } catch { /* not there: read below fails */ } if (seen.has(file) || depth > 16) return; seen.add(file); let text; try { text = readFileSync(file, 'utf8'); } catch { return; } out.push({ file, text }); for (const spec of importsOf(text)) { if (/^(https?:)?\/\//.test(spec) || /^tailwindcss(\/|$)/.test(spec)) continue; const found = resolveStyleImport(spec, dirname(file), options.alias); if (found !== undefined && /\.(css|pcss|postcss)$/i.test(found)) visit(found, depth + 1); } }; for (const entry of entries) visit(entry, 0); return out; } /** The `styles` option as files: paths and globs relative to the root, or package stylesheets. */ export function resolveStyleEntries(root, styles) { const files = []; const missing = []; for (const spec of typeof styles === 'string' ? [styles] : styles) { if (/[*?{]/.test(spec)) { const matched = globFiles(root, spec); if (matched.length === 0) missing.push(spec); files.push(...matched); continue; } const path = isAbsolute(spec) ? spec : resolve(root, spec); if (existsSync(path) && statSync(path).isFile()) { files.push(path); continue; } const found = spec.startsWith('.') ? undefined : resolveStyleImport(spec, root); if (found !== undefined) files.push(found); else missing.push(spec); } return { files: [...new Set(files)], missing }; } function inputList(input) { if (typeof input === 'string') return [input]; if (Array.isArray(input)) return input.filter((each) => typeof each === 'string'); if (typeof input === 'object' && input !== null) return Object.values(input).filter((each) => typeof each === 'string'); return []; } /** * The app's style entries, found from its Vite setup — what `genuiSandboxKit()` uses without * `styles`: the stylesheets among `build.rollupOptions.input` (the AdonisJS plugin puts its * `entrypoints` there) or linked from `index.html`, and those the script entries import directly * (`import './app.css'`). CSS modules are left out. */ export function discoverStyleEntries(root, options = {}) { const entries = inputList(options.input).map((each) => isAbsolute(each) ? each : resolve(root, each)); if (entries.length === 0) { const html = join(root, 'index.html'); if (existsSync(html)) { const text = readFileSync(html, 'utf8'); for (const match of text.matchAll(/<link\b[^>]*>/gi)) { const tag = match[0]; if (!/rel\s*=\s*["']?stylesheet/i.test(tag)) continue; const href = /href\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]; if (href !== undefined && !/^(https?:)?\/\//.test(href)) entries.push(resolve(root, href.replace(/^\//, ''))); } for (const match of text.matchAll(/<script\b[^>]*type\s*=\s*["']module["'][^>]*>/gi)) { const src = /src\s*=\s*["']([^"']+)["']/i.exec(match[0])?.[1]; if (src !== undefined && !/^(https?:)?\/\//.test(src)) entries.push(resolve(root, src.replace(/^\//, ''))); } } } const styles = []; const add = (file) => { if (!/\.module\.[a-z]+$/i.test(file) && existsSync(file) && !styles.includes(file)) styles.push(file); }; for (const entry of entries) { if (isStyleFile(entry)) { add(entry); continue; } if (!existsSync(entry)) continue; let text; try { text = readFileSync(entry, 'utf8'); } catch { continue; } for (const match of text.matchAll(/^\s*import\s+["']([^"']+)["']/gm)) { const spec = match[1]; if (!isStyleFile(spec)) continue; const found = spec.startsWith('.') || isAbsolute(spec) ? resolve(dirname(entry), spec) : resolveStyleImport(spec, dirname(entry)); if (found !== undefined) add(found); } } return styles; } /** A Tailwind v3 config's theme as v4 directives (`tailwind.config.*` in the root, or `@config`). */ async function tailwindV3Css(root, sources, warn) { let path; for (const source of sources) { const spec = /@config\s+["']([^"']+)["']/.exec(source.text)?.[1]; if (spec !== undefined) { path = resolve(dirname(source.file), spec); break; } } path ??= ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'] .map((extension) => join(root, `tailwind.config.${extension}`)) .find((candidate) => existsSync(candidate)); if (path === undefined || !existsSync(path)) return ''; try { const mod = (await import(`${pathToFileURL(path).href}?t=${statSync(path).mtimeMs}`)); return tailwindV3ConfigToCss(mod.default ?? mod); } catch (error) { warn?.(`the Tailwind v3 config ${path} did not load (${error instanceof Error ? error.message : String(error)}); the frame's Tailwind has the default theme`); return ''; } } /** Aliases of a resolved Vite `resolve` (object or array form) as `find → replacement` strings. */ export function stringAliases(resolveOptions) { const alias = resolveOptions?.alias; const out = {}; if (Array.isArray(alias)) { for (const entry of alias) if (typeof entry.find === 'string' && typeof entry.replacement === 'string') out[entry.find] = entry.replacement; } else if (typeof alias === 'object' && alias !== null) { for (const [find, replacement] of Object.entries(alias)) if (typeof replacement === 'string') out[find] = replacement; } return out; } /** * Compile the style entries with the app's Vite CSS pipeline and work out what they are: Tailwind * (its version, tokens, and the directives the frame's runtime needs — v3's config theme mapped to * v4), or plain CSS (its class vocabulary). `null` with no entries. */ export async function buildSandboxKitStyles(options) { if (options.entries.length === 0) return null; const sources = collectStyleSources(options.entries, { alias: stringAliases(options.resolve) }); let css; if (options.compile === false) { css = sources .filter((source) => /\.css$/i.test(source.file)) .map((source) => source.text) .join('\n'); } else { const built = await runKitViteBuild({ root: options.root, entry: options.entries.map((file) => `import ${JSON.stringify(file)};`).join('\n'), ...(options.resolve !== undefined ? { resolve: options.resolve } : {}), ...(options.css !== undefined ? { css: options.css } : {}), ...(options.publicDir !== undefined ? { publicDir: options.publicDir } : {}), ...(options.plugins !== undefined ? { plugins: options.plugins } : {}), ...(options.inlineAssetLimit !== undefined ? { inlineAssetLimit: options.inlineAssetLimit } : {}), ...(options.inlineAssetBudget !== undefined ? { inlineAssetBudget: options.inlineAssetBudget } : {}), ...(options.warn !== undefined ? { warn: options.warn } : {}), }); css = built.css; } const versions = [ ...sources.map((source) => tailwindVersionOf(source.text)), tailwindVersionOf(css), ].filter((version) => version !== null); const version = versions.includes(4) ? 4 : versions.includes(3) ? 3 : undefined; const result = { css, hash: contentHash(css), files: sources.map((source) => source.file), vars: themeVarsFromCss(css, { rootOnly: true }), }; if (version !== undefined) { const parts = new Set(); if (version === 3) { const mapped = await tailwindV3Css(options.root, sources, options.warn); if (mapped !== '') parts.add(mapped); } for (const source of sources) { const directives = tailwindDirectivesFromCss(source.text); if (directives !== '') parts.add(directives); } const directives = [...parts].join('\n'); result.tailwind = { version, tokens: tailwindTokensFromCss(directives), directives }; // The theme is what the app's stylesheets declare on their root — not the variables Tailwind's // compiled theme layer adds (`--color-white`, `--spacing`, `--text-xs`…), which are no theme of // the app's. Off the compiled CSS only when the sources declare none. const declared = {}; for (const source of sources) for (const [name, value] of Object.entries(themeVarsFromCss(source.text, { rootOnly: true }))) declared[name] ??= value; if (Object.keys(declared).length > 0) result.vars = declared; } else { result.vocabulary = extractCssVocabulary(css); } return result; } /** * The compiled styles with what the frame can never use taken out (`genuiSandboxKit({ purge })`, * on by default): * * - Tailwind v4: the `utilities` layer keeps only the utilities the kit's own code names — the * frame's Tailwind runtime generates every other one the model writes, from the app's theme; the * theme, base and components layers, `@font-face`, `@keyframes` and `@property` stay whole; * - Tailwind v3 (no layers in its output): every rule naming a class the kit does not use goes — * the model writes utilities, which the runtime generates, and is told nothing of the rest; * - any other CSS: only generated CSS-modules classes the kit does not use go — every other class * is in the vocabulary the model is told, so it stays. * * `kitCode` is the kit bundle (what the components can put on an element); `safelist` keeps more. */ export function purgeSandboxKitStyles(styles, options = {}) { if (styles.css === '') return styles; const used = classTokensOf(options.kitCode ?? ''); const safe = (name) => (options.safelist ?? []).some((entry) => typeof entry === 'string' ? entry === name : entry.test(name)); const tailwind = styles.tailwind?.version; const keep = tailwind === undefined ? (name) => !isGeneratedClassName(name) || used.has(name) || safe(name) : (name) => used.has(name) || safe(name); const css = purgeCss(styles.css, { keep, scope: tailwind === 4 ? 'utilities' : 'all' }); if (css === styles.css) return styles; return { ...styles, css, hash: contentHash(css), purged: { before: styles.css.length } }; } /** The docs half of compiled styles (what the model is told). */ export function stylesDocs(styles) { if (styles === null) return {}; if (styles.tailwind !== undefined) return { tailwind: { version: styles.tailwind.version, tokens: styles.tailwind.tokens } }; return styles.vocabulary !== undefined ? { vocabulary: styles.vocabulary } : {}; } /** `stylesDocs`: a file (markdown, text) relative to the root, or the guidance itself. */ export function readStylesGuide(root, stylesDocs) { if (stylesDocs === undefined) return undefined; if (!stylesDocs.includes('\n') && stylesDocs.length < 400) { const path = isAbsolute(stylesDocs) ? stylesDocs : resolve(root, stylesDocs); if (existsSync(path) && statSync(path).isFile()) return readFileSync(path, 'utf8'); } return stylesDocs; } /** A fresh instance of the app's Tailwind Vite plugin, when the app has one and the kit has none. */ export async function tailwindVitePluginFor(root, appPlugins, given) { const named = (list) => list .flat(Number.POSITIVE_INFINITY) .map((plugin) => String(plugin?.name ?? '')); if (!appPlugins.some((plugin) => String(plugin.name ?? '').startsWith('@tailwindcss/vite'))) return []; if (named(given).some((name) => name.startsWith('@tailwindcss/vite'))) return []; try { const path = createRequire(resolve(root, 'package.json')).resolve('@tailwindcss/vite'); const mod = (await import(pathToFileURL(path).href)); return [mod.default()]; } catch { return []; } } //# sourceMappingURL=styles.js.map