diff --git a/apps/web/client/src/components/store/editor/font/css-manager.ts b/apps/web/client/src/components/store/editor/font/css-manager.ts new file mode 100644 index 0000000000..7152f9d5ae --- /dev/null +++ b/apps/web/client/src/components/store/editor/font/css-manager.ts @@ -0,0 +1,154 @@ +import type { Font } from '@onlook/models'; +import type { EditorEngine } from '../engine'; + +export class CSSManager { + private readonly cssFileName = 'globals.css'; + private readonly fontVariablesComment = '/* Onlook Font Variables */'; + + constructor(private editorEngine: EditorEngine) {} + + /** + * Injects a CSS variable for a font into the globals.css file + */ + async addFontVariable(font: Font): Promise { + try { + const globalsPath = await this.findGlobalsPath(); + if (!globalsPath) { + console.warn('globals.css not found, skipping CSS variable injection'); + return true; // Not critical, fonts can still work via WebFont loading + } + + const file = await this.editorEngine.activeSandbox.readFile(globalsPath); + if (!file || file.type === 'binary') { + console.error(`Failed to read globals.css at ${globalsPath}`); + return false; + } + + let content = file.content; + const cssVariable = ` ${font.variable}: '${font.family}', sans-serif;`; + + // Check if font variable already exists + if (content.includes(font.variable)) { + return true; // Already exists + } + + // Find or create the :root section for font variables + content = this.injectFontVariableIntoRoot(content, cssVariable); + + return await this.editorEngine.activeSandbox.writeFile(globalsPath, content); + } catch (error) { + console.error('Error adding font CSS variable:', error); + return false; + } + } + + /** + * Removes a CSS variable for a font from the globals.css file + */ + async removeFontVariable(font: Font): Promise { + try { + const globalsPath = await this.findGlobalsPath(); + if (!globalsPath) { + return true; // Nothing to remove + } + + const file = await this.editorEngine.activeSandbox.readFile(globalsPath); + if (!file || file.type === 'binary') { + console.error(`Failed to read globals.css at ${globalsPath}`); + return false; + } + + let content = file.content; + + // Remove the font variable line + const cssVariablePattern = new RegExp(`\\s*${this.escapeRegex(font.variable)}:\\s*[^;]+;\\s*\\n?`, 'g'); + content = content.replace(cssVariablePattern, ''); + + // Clean up empty root section if no more font variables exist + content = this.cleanupEmptyFontSection(content); + + return await this.editorEngine.activeSandbox.writeFile(globalsPath, content); + } catch (error) { + console.error('Error removing font CSS variable:', error); + return false; + } + } + + /** + * Finds the globals.css file path in common locations + */ + private async findGlobalsPath(): Promise { + const commonPaths = [ + 'src/styles/globals.css', + 'styles/globals.css', + 'src/app/globals.css', + 'app/globals.css', + 'src/globals.css', + 'globals.css' + ]; + + for (const path of commonPaths) { + const exists = await this.editorEngine.activeSandbox.fileExists(path); + if (exists) { + return path; + } + } + + return null; + } + + /** + * Injects a font variable into the :root selector, creating one if it doesn't exist + */ + private injectFontVariableIntoRoot(content: string, cssVariable: string): string { + // Look for existing Onlook font variables section + const fontSectionMatch = content.match(/(\/\* Onlook Font Variables \*\/\s*:root\s*\{[^}]*\})/s); + + if (fontSectionMatch) { + // Add to existing font variables section + const existingSection = fontSectionMatch[1]; + if (existingSection) { + const newSection = existingSection.replace(/(\s*\})$/, `\n${cssVariable}\n$1`); + return content.replace(existingSection, newSection); + } + } + + // Look for any existing :root selector + const rootMatch = content.match(/(:root\s*\{[^}]*\})/s); + + if (rootMatch) { + // Add to existing :root section with font variables comment + const existingRoot = rootMatch[1]; + if (existingRoot) { + const newRoot = existingRoot.replace(/(\s*\})$/, `\n\n ${this.fontVariablesComment.slice(3, -3)}\n${cssVariable}\n$1`); + return content.replace(existingRoot, newRoot); + } + } + + // Create new :root section at the top of the file + const fontSection = `${this.fontVariablesComment}\n:root {\n${cssVariable}\n}\n\n`; + return fontSection + content; + } + + /** + * Removes empty font variables sections after font removal + */ + private cleanupEmptyFontSection(content: string): string { + // Remove empty Onlook font variables sections + const emptyFontSectionPattern = /\/\* Onlook Font Variables \*\/\s*:root\s*\{\s*\}\s*\n?/g; + content = content.replace(emptyFontSectionPattern, ''); + + // Remove orphaned comment if root section is empty + const orphanedCommentPattern = /\/\* Onlook Font Variables \*\/\s*\n?/g; + content = content.replace(orphanedCommentPattern, ''); + + return content; + } + + /** + * Escapes special regex characters in font variable names + */ + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} \ No newline at end of file diff --git a/apps/web/client/src/components/store/editor/font/index.ts b/apps/web/client/src/components/store/editor/font/index.ts index 03d6094ef3..bd49b671d3 100644 --- a/apps/web/client/src/components/store/editor/font/index.ts +++ b/apps/web/client/src/components/store/editor/font/index.ts @@ -6,6 +6,7 @@ import { generate } from '@onlook/parser'; import { makeAutoObservable, reaction } from 'mobx'; import type { EditorEngine } from '../engine'; import type { FileEvent } from '../sandbox/file-event-bus'; +import { CSSManager } from './css-manager'; import { FontConfigManager } from './font-config-manager'; import { FontSearchManager } from './font-search-manager'; import { FontUploadManager } from './font-upload-manager'; @@ -29,6 +30,7 @@ export class FontManager { private fontConfigManager: FontConfigManager; private layoutManager: LayoutManager; private fontUploadManager: FontUploadManager; + private cssManager: CSSManager; private sandboxReactionDisposer?: () => void; @@ -40,6 +42,7 @@ export class FontManager { this.fontConfigManager = new FontConfigManager(editorEngine); this.layoutManager = new LayoutManager(editorEngine); this.fontUploadManager = new FontUploadManager(editorEngine); + this.cssManager = new CSSManager(editorEngine); } init() { @@ -165,6 +168,9 @@ export class FontManager { // Load the new font in the search manager await this.fontSearchManager.loadFontFromBatch([font]); + // Add CSS variable to globals.css + await this.cssManager.addFontVariable(font); + return true; } return false; @@ -195,6 +201,9 @@ export class FontManager { this._defaultFont = null; } + // Remove CSS variable from globals.css + await this.cssManager.removeFontVariable(font); + return result; } return false; @@ -328,6 +337,7 @@ export class FontManager { this.fontSearchManager.updateFontsList([]); this.fontUploadManager.clear(); this.fontConfigManager.clear(); + // Note: cssManager doesn't need explicit clearing as it's stateless // Clean up file watcher this.cleanupFontConfigFileWatcher(); @@ -374,6 +384,7 @@ export class FontManager { for (const font of removedFonts) { await removeFontFromTailwindConfig(font, sandbox); await this.layoutManager.removeFontVariableFromRootLayout(font.id); + await this.cssManager.removeFontVariable(font); } } @@ -381,6 +392,7 @@ export class FontManager { for (const font of addedFonts) { await addFontToTailwindConfig(font, sandbox); await this.layoutManager.addFontVariableToRootLayout(font.id); + await this.cssManager.addFontVariable(font); } } diff --git a/apps/web/client/test/font/css-manager.test.ts b/apps/web/client/test/font/css-manager.test.ts new file mode 100644 index 0000000000..15fa2804e7 --- /dev/null +++ b/apps/web/client/test/font/css-manager.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Font } from '@onlook/models'; +import { CSSManager } from '../../src/components/store/editor/font/css-manager'; +import type { EditorEngine } from '../../src/components/store/editor/engine'; + +// Mock the editor engine +const mockSandbox = { + readFile: vi.fn(), + writeFile: vi.fn(), + fileExists: vi.fn(), +}; + +const mockEditorEngine = { + activeSandbox: mockSandbox, +} as unknown as EditorEngine; + +describe('CSSManager', () => { + let cssManager: CSSManager; + let testFont: Font; + + beforeEach(() => { + cssManager = new CSSManager(mockEditorEngine); + testFont = { + id: 'inter', + family: 'Inter', + variable: '--font-inter', + subsets: ['latin'], + weight: ['400', '700'], + styles: ['normal'], + type: 'google', + }; + vi.clearAllMocks(); + }); + + describe('addFontVariable', () => { + it('should create new :root section when globals.css is empty', async () => { + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: '' + }); + mockSandbox.writeFile.mockResolvedValue(true); + + const result = await cssManager.addFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining('/* Onlook Font Variables */') + ); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining(':root {') + ); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining("--font-inter: 'Inter', sans-serif;") + ); + }); + + it('should add to existing :root section', async () => { + const existingContent = `:root { + --color-primary: blue; +}`; + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: existingContent + }); + mockSandbox.writeFile.mockResolvedValue(true); + + const result = await cssManager.addFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining('--color-primary: blue;') + ); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining("--font-inter: 'Inter', sans-serif;") + ); + }); + + it('should not add duplicate font variables', async () => { + const existingContent = `/* Onlook Font Variables */ +:root { + --font-inter: 'Inter', sans-serif; +}`; + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: existingContent + }); + + const result = await cssManager.addFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).not.toHaveBeenCalled(); + }); + + it('should handle missing globals.css gracefully', async () => { + mockSandbox.fileExists.mockResolvedValue(false); + + const result = await cssManager.addFontVariable(testFont); + + expect(result).toBe(true); // Should still return true as it's not critical + expect(mockSandbox.writeFile).not.toHaveBeenCalled(); + }); + }); + + describe('removeFontVariable', () => { + it('should remove font variable from :root section', async () => { + const existingContent = `/* Onlook Font Variables */ +:root { + --font-inter: 'Inter', sans-serif; + --font-roboto: 'Roboto', sans-serif; +}`; + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: existingContent + }); + mockSandbox.writeFile.mockResolvedValue(true); + + const result = await cssManager.removeFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.not.stringContaining("--font-inter: 'Inter', sans-serif;") + ); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining("--font-roboto: 'Roboto', sans-serif;") + ); + }); + + it('should clean up empty font section', async () => { + const existingContent = `/* Onlook Font Variables */ +:root { + --font-inter: 'Inter', sans-serif; +}`; + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: existingContent + }); + mockSandbox.writeFile.mockResolvedValue(true); + + const result = await cssManager.removeFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.not.stringContaining('/* Onlook Font Variables */') + ); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.not.stringContaining(':root {') + ); + }); + + it('should handle missing globals.css gracefully', async () => { + mockSandbox.fileExists.mockResolvedValue(false); + + const result = await cssManager.removeFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).not.toHaveBeenCalled(); + }); + }); + + describe('findGlobalsPath', () => { + it('should find globals.css in common locations', async () => { + // Make fileExists return true for the first path it checks + mockSandbox.fileExists + .mockResolvedValueOnce(true) // src/styles/globals.css + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: '' + }); + mockSandbox.writeFile.mockResolvedValue(true); + + const result = await cssManager.addFontVariable(testFont); + + expect(result).toBe(true); + expect(mockSandbox.fileExists).toHaveBeenCalledWith('src/styles/globals.css'); + }); + }); +}); \ No newline at end of file diff --git a/apps/web/client/test/font/integration.test.ts b/apps/web/client/test/font/integration.test.ts new file mode 100644 index 0000000000..284c9f4fcb --- /dev/null +++ b/apps/web/client/test/font/integration.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Font } from '@onlook/models'; +import { CSSManager } from '../../src/components/store/editor/font/css-manager'; +import type { EditorEngine } from '../../src/components/store/editor/engine'; + +describe('Font CSS Variable Integration', () => { + let cssManager: CSSManager; + let mockSandbox: any; + let mockEditorEngine: EditorEngine; + + beforeEach(() => { + mockSandbox = { + readFile: vi.fn(), + writeFile: vi.fn(), + fileExists: vi.fn(), + }; + + mockEditorEngine = { + activeSandbox: mockSandbox, + } as unknown as EditorEngine; + + cssManager = new CSSManager(mockEditorEngine); + }); + + it('should handle complete font lifecycle: add -> add duplicate -> remove', async () => { + const testFont: Font = { + id: 'open-sans', + family: 'Open Sans', + variable: '--font-open-sans', + subsets: ['latin'], + weight: ['400', '600'], + styles: ['normal'], + type: 'google', + }; + + // Step 1: Initial file with no font variables + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValueOnce({ + type: 'text', + content: `html, body { margin: 0; }` + }); + mockSandbox.writeFile.mockResolvedValue(true); + + // Add font variable + const addResult = await cssManager.addFontVariable(testFont); + expect(addResult).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining("--font-open-sans: 'Open Sans', sans-serif;") + ); + + // Step 2: Try to add the same font again (should skip) + mockSandbox.readFile.mockResolvedValueOnce({ + type: 'text', + content: `/* Onlook Font Variables */ +:root { + --font-open-sans: 'Open Sans', sans-serif; +} + +html, body { margin: 0; }` + }); + + const addDuplicateResult = await cssManager.addFontVariable(testFont); + expect(addDuplicateResult).toBe(true); + // writeFile should not be called again since font already exists + expect(mockSandbox.writeFile).toHaveBeenCalledTimes(1); + + // Step 3: Remove the font variable + mockSandbox.readFile.mockResolvedValueOnce({ + type: 'text', + content: `/* Onlook Font Variables */ +:root { + --font-open-sans: 'Open Sans', sans-serif; +} + +html, body { margin: 0; }` + }); + + const removeResult = await cssManager.removeFontVariable(testFont); + expect(removeResult).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.not.stringContaining("--font-open-sans: 'Open Sans', sans-serif;") + ); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.not.stringContaining('/* Onlook Font Variables */') + ); + }); + + it('should demonstrate the problem this fix solves', async () => { + // Before the fix: Users select a font from font panel + // Font gets added to Tailwind config and layout files + // But CSS variables are NOT injected into globals.css + // Result: font-{id} classes don't work because var(--font-{id}) is undefined + + const problematicFont: Font = { + id: 'playfair-display', + family: 'Playfair Display', + variable: '--font-playfair-display', + subsets: ['latin'], + weight: ['400', '700'], + styles: ['normal', 'italic'], + type: 'google', + }; + + // After the fix: CSS variables are properly injected + mockSandbox.fileExists.mockResolvedValue(true); + mockSandbox.readFile.mockResolvedValue({ + type: 'text', + content: `@tailwind base; +@tailwind components; +@tailwind utilities;` + }); + mockSandbox.writeFile.mockResolvedValue(true); + + const result = await cssManager.addFontVariable(problematicFont); + + expect(result).toBe(true); + expect(mockSandbox.writeFile).toHaveBeenCalledWith( + 'src/styles/globals.css', + expect.stringContaining("--font-playfair-display: 'Playfair Display', sans-serif;") + ); + + // This ensures that when users use font-playfair-display in their Tailwind classes, + // the CSS variable var(--font-playfair-display) is properly defined + }); +}); \ No newline at end of file