Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/web/client/src/components/store/editor/font/css-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { addFontToCssTheme, removeFontFromCssTheme } from '@onlook/fonts';
import type { Font, RouterConfig } from '@onlook/models';
import type { EditorEngine } from '@/components/store/editor/engine';
import type { SandboxManager } from '@/components/store/editor/sandbox';
import { normalizePath } from '@/components/store/editor/sandbox/helpers';

const GLOBAL_CSS_FILE = 'globals.css';

function rankGlobalCssPath(path: string, routerConfig: RouterConfig): number {
const normalizedPath = normalizePath(path);

if (normalizedPath === normalizePath(`${routerConfig.basePath}/${GLOBAL_CSS_FILE}`)) {
return 0;
}

if (normalizedPath === normalizePath(`src/${GLOBAL_CSS_FILE}`)) {
return 1;
}

if (normalizedPath === GLOBAL_CSS_FILE) {
return 2;
}

return 3;
}

export const getGlobalCssPath = async (sandbox: SandboxManager): Promise<string | null> => {
const routerConfig = await sandbox.getRouterConfig();
if (!routerConfig) {
return null;
}

const files = await sandbox.listAllFiles();
const globalCssFiles = files
.filter(
(file) =>
file.type === 'file' &&
(file.path === GLOBAL_CSS_FILE || file.path.endsWith(`/${GLOBAL_CSS_FILE}`)),
)
.sort(
(a, b) =>
rankGlobalCssPath(a.path, routerConfig) - rankGlobalCssPath(b.path, routerConfig),
);

return globalCssFiles[0]?.path ?? null;
};
Comment on lines +27 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=ts -C5 '\blistAllFiles\s*\('

Repository: onlook-dev/onlook

Length of output: 7377


🏁 Script executed:

#!/bin/bash
# Find the fs implementation used in SandboxManager
rg -nP --type=ts 'this\.fs\s*=' apps/web/client/src/components/store/editor/sandbox/index.ts -A 3

Repository: onlook-dev/onlook

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Search for listAll implementation
rg -nP --type=ts 'listAll\s*\(' apps/web/client/src/components/store/editor/ -A 5 | head -100

Repository: onlook-dev/onlook

Length of output: 659


🏁 Script executed:

#!/bin/bash
# Check the full SandboxManager class to understand fs initialization
sed -n '1,100p' apps/web/client/src/components/store/editor/sandbox/index.ts

Repository: onlook-dev/onlook

Length of output: 3716


🏁 Script executed:

#!/bin/bash
# Find EXCLUDED_SYNC_PATHS definition
rg -nP --type=ts 'EXCLUDED_SYNC_PATHS' apps/web/client/src/ -B 2 -A 5

Repository: onlook-dev/onlook

Length of output: 2688


🏁 Script executed:

#!/bin/bash
# Search for CodeFileSystem definition and listAll method
rg -nP --type=ts 'class CodeFileSystem|interface CodeFileSystem|listAll' --glob='*.ts' --glob='*.tsx'

Repository: onlook-dev/onlook

Length of output: 1703


🏁 Script executed:

#!/bin/bash
# Look in packages for file-system module
find . -name 'file-system' -type d 2>/dev/null | grep -E '(packages|node_modules)' | head -5

Repository: onlook-dev/onlook

Length of output: 83


🏁 Script executed:

#!/bin/bash
# Check the listAll() implementation in FileSystem
sed -n '536,580p' packages/file-system/src/fs.ts

Repository: onlook-dev/onlook

Length of output: 1643


🏁 Script executed:

#!/bin/bash
# Find EXCLUDED_SYNC_PATHS in `@onlook/constants`
find . -path '*/@onlook/constants*' -type f -name '*.ts' 2>/dev/null | head -5

Repository: onlook-dev/onlook

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Also check CodeFileSystem implementation
sed -n '31,100p' packages/file-system/src/code-fs.ts

Repository: onlook-dev/onlook

Length of output: 2650


getGlobalCssPath rescans the entire sandbox on every call and may select non-source globals.css files.

  1. Performance: sandbox.listAllFiles() walks the entire file tree on every invocation—in addFont, removeFont, and per-font during syncFontsWithConfigs. For larger projects, this is expensive and runs serially in loops. Consider memoizing the result per sync cycle or invalidating on file system events.

  2. Correctness: The filter file.path === GLOBAL_CSS_FILE || file.path.endsWith('/globals.css') matches any globals.css anywhere, including node_modules/, dist/, or vendored directories. If none of the preferred paths ({basePath}/globals.css, src/globals.css, globals.css) exist, the ranking fallback will select from whatever listAllFiles() returns first, which could be a dependency's file. Restrict the search to source directories or exclude node_modules and build output explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/client/src/components/store/editor/font/css-theme.ts` around lines
27 - 46, The getGlobalCssPath function currently calls sandbox.listAllFiles() on
every invocation (used by addFont, removeFont, and per-font in
syncFontsWithConfigs), which is expensive and also matches globals.css in
vendor/build dirs; to fix, cache/memoize the file list for the duration of a
single sync operation (or accept an optional files list param) so listAllFiles()
is called once per sync cycle and invalidate that cache on filesystem events,
and tighten the filter in getGlobalCssPath/rankGlobalCssPath to prefer exact
source locations (e.g., check paths like "globals.css", "src/globals.css",
"<basePath>/globals.css") and explicitly exclude paths containing
"node_modules", "dist", "build" or other vendored output before falling back to
other matches.


const updateGlobalCss = async (
editorEngine: EditorEngine,
transform: (css: string) => string,
errorLabel: string,
): Promise<boolean> => {
try {
const sandbox = editorEngine.activeSandbox;
const cssPath = await getGlobalCssPath(sandbox);
if (!cssPath) {
return false;
}

const file = await sandbox.readFile(cssPath);
if (typeof file !== 'string') {
return false;
}

const result = transform(file);
if (result === file) {
return true;
}

await sandbox.writeFile(cssPath, result);
return true;
} catch (error) {
console.error(errorLabel, error);
return false;
}
};
Comment on lines +48 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

updateGlobalCss swallows the "file is not text" case silently.

On line 61-63, a non-string readFile result returns false without any log, while all other non-happy paths either log or are benign. If globals.css ever comes back as Uint8Array (e.g., encoding detection glitch), callers will see the font add/remove fail generically with no diagnostic. Add a console.error(errorLabel, 'globals.css was not readable as text') before returning so the failure is attributable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/client/src/components/store/editor/font/css-theme.ts` around lines
48 - 76, The updateGlobalCss function currently returns false silently when
sandbox.readFile(cssPath) yields a non-string (the "file is not text" case);
modify updateGlobalCss to log this failure before returning by calling
console.error(errorLabel, 'globals.css was not readable as text') (or similar)
right before the existing "return false" that follows the typeof file !==
'string' check so callers can diagnose encoding/binary read issues; reference
the updateGlobalCss function, the file variable from sandbox.readFile(cssPath),
and the errorLabel parameter when adding the log.


export const addFontToGlobalCss = async (
font: Font,
editorEngine: EditorEngine,
): Promise<boolean> => {
return updateGlobalCss(
editorEngine,
(css) => addFontToCssTheme(font, css),
'Error adding font to global CSS:',
);
};

export const removeFontFromGlobalCss = async (
fontId: string,
editorEngine: EditorEngine,
): Promise<boolean> => {
return updateGlobalCss(
editorEngine,
(css) => removeFontFromCssTheme(fontId, css),
'Error removing font from global CSS:',
);
};
33 changes: 27 additions & 6 deletions apps/web/client/src/components/store/editor/font/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type CodeDiff, type FontUploadFile } from '@onlook/models';
import type { Font } from '@onlook/models/assets';
import { generate } from '@onlook/parser';
import { makeAutoObservable } from 'mobx';
import { addFontToGlobalCss, removeFontFromGlobalCss } from '@/components/store/editor/font/css-theme';
import type { EditorEngine } from '../engine';
import { addFontToConfig, ensureFontConfigFileExists, getFontConfigPath, readFontConfigFile, removeFontFromConfig, scanExistingFonts, scanFontConfig } from './font-config';
import { FontSearchManager } from './font-search-manager';
Expand Down Expand Up @@ -101,8 +102,20 @@ export class FontManager {
console.error('No font config path found');
return false;
}
await this.ensureConfigFilesExist();
const success = await addFontToConfig(font, fontConfigPath, this.editorEngine);
if (success) {
const [tailwindUpdated, layoutUpdated, globalCssUpdated] = await Promise.all([
addFontToTailwindConfig(font, this.editorEngine.activeSandbox),
addFontVariableToRootLayout(font.id, this.editorEngine),
addFontToGlobalCss(font, this.editorEngine),
]);

if (!tailwindUpdated || !layoutUpdated || !globalCssUpdated) {
console.error('Failed to apply font to one or more project config files');
return false;
}

// Update the fonts array
this._fonts.push(font);

Expand Down Expand Up @@ -137,6 +150,17 @@ export class FontManager {
const result = await removeFontFromConfig(font, fontConfigPath, this.editorEngine);

if (result) {
const [layoutUpdated, tailwindUpdated, globalCssUpdated] = await Promise.all([
removeFontVariableFromRootLayout(font.id, this.editorEngine),
removeFontFromTailwindConfig(font, this.editorEngine.activeSandbox),
removeFontFromGlobalCss(font.id, this.editorEngine),
]);

if (!layoutUpdated || !tailwindUpdated || !globalCssUpdated) {
console.error('Failed to remove font from one or more project config files');
return false;
}

// Remove from fonts array
this._fonts = this._fonts.filter((f) => f.id !== font.id);

Expand All @@ -147,12 +171,6 @@ export class FontManager {
this._defaultFont = null;
}

// Remove font variable and font class from layout file
await removeFontVariableFromRootLayout(font.id, this.editorEngine);

// Remove font from Tailwind config
await removeFontFromTailwindConfig(font, this.editorEngine.activeSandbox);

return result;
}
return false;
Expand Down Expand Up @@ -247,6 +265,7 @@ export class FontManager {
fontConfigPath,
code,
);
await this.syncFontsWithConfigs();
}

return result.success;
Expand Down Expand Up @@ -348,13 +367,15 @@ export class FontManager {
for (const font of removedFonts) {
await removeFontFromTailwindConfig(font, sandbox);
await removeFontVariableFromRootLayout(font.id, this.editorEngine);
await removeFontFromGlobalCss(font.id, this.editorEngine);
}
}

if (addedFonts.length > 0) {
for (const font of addedFonts) {
await addFontToTailwindConfig(font, sandbox);
await addFontVariableToRootLayout(font.id, this.editorEngine);
await addFontToGlobalCss(font, this.editorEngine);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const removeFontVariableFromRootLayout = async (
await editorEngine.activeSandbox.writeFile(layoutPath, newContent);
return true;
}
return false;
return true;
} catch (error) {
console.error(`Error removing font variable`, error);
return false;
Expand Down
93 changes: 93 additions & 0 deletions packages/fonts/src/helpers/css-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Font } from '@onlook/models';

const THEME_BLOCK_REGEX = /@theme(?:\s+inline)?\s*\{/g;

function findThemeBlock(content: string): { bodyStart: number; bodyEnd: number } | null {
const match = THEME_BLOCK_REGEX.exec(content);
THEME_BLOCK_REGEX.lastIndex = 0;

if (!match) {
return null;
}

const bodyStart = match.index + match[0].length;
let depth = 1;

for (let index = bodyStart; index < content.length; index++) {
const char = content[index];
if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
if (depth === 0) {
return {
bodyStart,
bodyEnd: index,
};
}
}
}

return null;
}
Comment on lines +5 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Tailwind CSS v4 multiple @theme blocks in same stylesheet

💡 Result:

Yes, Tailwind CSS v4 supports multiple @theme blocks in the same stylesheet. They are merged together, with later blocks overriding values from earlier ones if there are conflicts. This allows organizing theme definitions across multiple blocks, such as separating base theme values from inline or theme-specific ones. For example, from official Tailwind CSS sources and GitHub discussions: @theme { --color-primary: #aab9ff; } @theme inline { --color-secondary: var(--some-var); } This generates utilities for both, combining the definitions. The @theme directive processes all blocks top-level, extending or overriding the theme cumulatively. Official docs emphasize @theme must be top-level (not nested), but multiple sequential blocks are valid and commonly used in practice for modularity, as seen in PR #14095 and discussions like #18560. When importing external CSS files with theme(reference) or theme(inline), they can contain @theme blocks that merge similarly.

Citations:


🏁 Script executed:

cat -n packages/fonts/src/helpers/css-theme.ts

Repository: onlook-dev/onlook

Length of output: 3788


Only the first @theme block is ever considered — can cause missed removals and wrong-block additions.

Tailwind v4 explicitly supports multiple @theme blocks in one stylesheet (commonly one for colors, another @theme inline { … } dedicated to fonts). findThemeBlock returns only the first match, so:

  • addFontToCssTheme will insert the font variable into the first @theme block even if a downstream @theme inline { } is the intended font block — mixing font tokens into the colors block.
  • removeFontFromCssTheme will leave orphaned --font-* declarations in any @theme block other than the first.

Consider iterating over all @theme blocks: for add, prefer an existing block that already contains a --font-* declaration (or an @theme inline) and fall back to the last block; for remove, strip the declaration from every @theme body.

Additionally, the brace-depth loop doesn't skip CSS comments or strings — /* } */ inside a @theme body would terminate the scan early. Unlikely in practice for generated globals.css, but worth a defensive comment-stripping pass if user-authored input is expected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/fonts/src/helpers/css-theme.ts` around lines 5 - 32, findThemeBlock
currently returns only the first `@theme` match which causes addFontToCssTheme and
removeFontFromCssTheme to target the wrong block; update the logic to enumerate
all `@theme` blocks (use THEME_BLOCK_REGEX.exec in a loop resetting lastIndex) and
return their body ranges instead of a single range, then change
addFontToCssTheme to prefer a block that already contains a --font- declaration
or an `@theme` inline block and otherwise fall back to the last `@theme` block, and
change removeFontFromCssTheme to remove the --font- declaration from every
`@theme` body; also make the brace-depth scan in findThemeBlock robust to
comments/strings by stripping or skipping CSS comments (/* */) and string
literals before counting braces.


function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function getThemeFontVariableName(fontId: string): string {
return `--font-${fontId}`;
}

function getFontThemeDeclaration(font: Font): string {
const themeVariable = getThemeFontVariableName(font.id);
const fontVariable = font.variable || themeVariable;

return `${themeVariable}: var(${fontVariable});`;
}

/**
* Adds or updates a Tailwind CSS `@theme` font token for an Onlook-managed font.
*/
export function addFontToCssTheme(font: Font, content: string): string {
const declaration = getFontThemeDeclaration(font);
const themeVariable = getThemeFontVariableName(font.id);

const block = findThemeBlock(content);
if (!block) {
const separator = content.endsWith('\n') ? '\n' : '\n\n';
return `${content}${separator}@theme inline {\n ${declaration}\n}\n`;
}

const beforeBody = content.slice(0, block.bodyStart);
const body = content.slice(block.bodyStart, block.bodyEnd);
const afterBody = content.slice(block.bodyEnd);
const escapedThemeVariable = escapeRegExp(themeVariable);
const existingDeclarationRegex = new RegExp(`(^\\s*)${escapedThemeVariable}\\s*:[^;]+;`, 'm');

if (existingDeclarationRegex.test(body)) {
return `${beforeBody}${body.replace(existingDeclarationRegex, `$1${declaration}`)}${afterBody}`;
}

const trimmedBody = body.replace(/\s*$/, '');
return `${beforeBody}${trimmedBody}\n ${declaration}\n${afterBody}`;
}

/**
* Removes a Tailwind CSS `@theme` font token for an Onlook-managed font.
*/
export function removeFontFromCssTheme(fontId: string, content: string): string {
const block = findThemeBlock(content);
if (!block) {
return content;
}

const themeVariable = getThemeFontVariableName(fontId);
const escapedThemeVariable = escapeRegExp(themeVariable);
const declarationRegex = new RegExp(`\\n?\\s*${escapedThemeVariable}\\s*:[^;]+;`, 'g');
const beforeBody = content.slice(0, block.bodyStart);
const body = content.slice(block.bodyStart, block.bodyEnd);
const afterBody = content.slice(block.bodyEnd);

return `${beforeBody}${body.replace(declarationRegex, '')}${afterBody}`;
}
1 change: 1 addition & 0 deletions packages/fonts/src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './class-utils';
export * from './ast-generators';
export * from './ast-manipulators';
export * from './css-theme';
export * from './font-extractors';
export * from './validators';
export * from './import-export-manager';
47 changes: 47 additions & 0 deletions packages/fonts/test/css-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe } from 'bun:test';
import type { Font } from '@onlook/models';
import { addFontToCssTheme, removeFontFromCssTheme } from '../src/helpers/css-theme';
import { runDataDrivenTests } from './test-utils';
import path from 'path';

const __dirname = import.meta.dir;

describe('addFontToCssTheme', () => {
runDataDrivenTests(
{
casesDir: path.resolve(__dirname, 'data/css-theme/add-font-to-css-theme'),
inputFileName: 'config',
expectedFileName: 'expected',
},
async (input: { font: Font; content: string }) => {
return addFontToCssTheme(input.font, input.content);
},
async (content: string, filePath?: string) => {
const config = JSON.parse(content);
const inputContent = await Bun.file(
path.resolve(path.dirname(filePath || ''), 'input.css'),
).text();
return { font: config.font, content: inputContent };
},
);
});

describe('removeFontFromCssTheme', () => {
runDataDrivenTests(
{
casesDir: path.resolve(__dirname, 'data/css-theme/remove-font-from-css-theme'),
inputFileName: 'config',
expectedFileName: 'expected',
},
async (input: { fontId: string; content: string }) => {
return removeFontFromCssTheme(input.fontId, input.content);
},
async (content: string, filePath?: string) => {
const config = JSON.parse(content);
const inputContent = await Bun.file(
path.resolve(path.dirname(filePath || ''), 'input.css'),
).text();
return { fontId: config.fontId, content: inputContent };
},
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"font": {
"id": "inter",
"family": "Inter",
"variable": "--font-inter",
"subsets": ["latin"],
"weight": ["400"],
"styles": ["normal"],
"type": "google"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@import 'tailwindcss';

@theme {
--color-background: #ffffff;
--font-inter: var(--font-inter);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import 'tailwindcss';

@theme {
--color-background: #ffffff;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"font": {
"id": "open-sans",
"family": "Open Sans",
"variable": "--font-open-sans",
"subsets": ["latin"],
"weight": ["400"],
"styles": ["normal"],
"type": "google"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@import 'tailwindcss';

body {
margin: 0;
}

@theme inline {
--font-open-sans: var(--font-open-sans);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import 'tailwindcss';

body {
margin: 0;
}
Loading