diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json index ee2c87ed0..3e96ed168 100644 --- a/packages/lang-core/package.json +++ b/packages/lang-core/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/lang-core", - "version": "0.2.5", + "version": "0.2.6", "description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions", "license": "MIT", "type": "module", diff --git a/packages/openui-cli/package.json b/packages/openui-cli/package.json index 82cd60bbb..eb35fd1a3 100644 --- a/packages/openui-cli/package.json +++ b/packages/openui-cli/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/cli", - "version": "0.0.7", + "version": "0.0.8", "description": "CLI for OpenUI — scaffold generative UI chat apps and generate LLM system prompts from component libraries", "bin": { "openui": "dist/index.js" diff --git a/packages/react-ui/check-css-artifacts.js b/packages/react-ui/check-css-artifacts.js index 1f972fb88..dfa0001db 100644 --- a/packages/react-ui/check-css-artifacts.js +++ b/packages/react-ui/check-css-artifacts.js @@ -55,6 +55,14 @@ for (const name of unlayered) { assert(!/^\s*@layer/.test(read(path.join("styles", name))), `styles/${name} must stay unlayered`); } +// Unlayered default exports must also be BOM-free. A leading BOM is harmless in +// an unlayered file (the decoder drops it at byte 0), but cp-css.js strips it, +// and a regression re-arms the 2026-06 incident the moment these files are ever +// wrapped. Covers ./components.css and every ./styles/* (incl. openui-defaults). +for (const rel of ["components/index.css", ...unlayered.map((n) => path.join("styles", n))]) { + assert(!read(rel).includes(""), `${rel} contains a BOM (unlayered export must be BOM-free)`); +} + for (const f of [ ...layered.map((n) => path.join("layered", "styles", n)), "layered/components/index.css", diff --git a/packages/react-ui/cp-css.js b/packages/react-ui/cp-css.js index 2c645f860..321559c75 100644 --- a/packages/react-ui/cp-css.js +++ b/packages/react-ui/cp-css.js @@ -2,7 +2,7 @@ import fs from "fs"; import { camelCase } from "lodash-es"; import path from "path"; import { fileURLToPath } from "url"; -import { mirrorStylesWithLayer, writeLayeredCopy } from "./css-layer-utils.mjs"; +import { mirrorStylesWithLayer, stripBom, writeLayeredCopy } from "./css-layer-utils.mjs"; const dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -33,6 +33,23 @@ function fixScssImportsInJs(dir) { }); } +// Strip Sass's leading UTF-8 BOM (compressed-mode output for non-ASCII) from +// every *.css under dir, in place — so the unlayered default exports +// (./components.css, ./styles/*) ship BOM-free. The layered mirror strips it +// separately via wrapInLayer. +function stripBomFromCssInDir(dir) { + for (const entry of fs.readdirSync(dir)) { + const full = path.join(dir, entry); + if (fs.statSync(full).isDirectory()) { + stripBomFromCssInDir(full); + } else if (entry.endsWith(".css")) { + const content = fs.readFileSync(full, "utf8"); + const stripped = stripBom(content); + if (stripped !== content) fs.writeFileSync(full, stripped, "utf8"); + } + } +} + // Copy CSS files from src to dist function copyCssFiles() { const srcDir = path.join(dirname, "dist", "components"); @@ -41,6 +58,16 @@ function copyCssFiles() { // Ensure the dist/styles directory exists ensureDirectoryExists(distDir); + // Strip Sass's leading BOM from the unlayered sass output before copying, so + // the default exports (./components.css, ./styles/*) ship BOM-free. + stripBomFromCssInDir(srcDir); + const defaultsCssSrc = path.join(dirname, "dist", "openui-defaults.css"); + if (fs.existsSync(defaultsCssSrc)) { + const content = fs.readFileSync(defaultsCssSrc, "utf8"); + const stripped = stripBom(content); + if (stripped !== content) fs.writeFileSync(defaultsCssSrc, stripped, "utf8"); + } + // Read all component directories const components = fs.readdirSync(srcDir); diff --git a/packages/react-ui/css-layer-utils.mjs b/packages/react-ui/css-layer-utils.mjs index a4799c68d..fa4acd912 100644 --- a/packages/react-ui/css-layer-utils.mjs +++ b/packages/react-ui/css-layer-utils.mjs @@ -1,14 +1,21 @@ import fs from "fs"; import path from "path"; +// Strip a leading UTF-8 BOM (U+FEFF). Sass emits one in compressed mode for +// files with non-ASCII output. A browser drops it at byte 0, but it is noise in +// shipped artifacts and becomes fatal if the content is ever wrapped \u2014 the BOM +// would land mid-stylesheet inside a layer block, where U+FEFF parses as an +// identifier and kills the first rule (e.g. the :root theme tokens; the 2026-06 +// incident). Used for both the unlayered defaults (cp-css.js) and the layered +// mirror (wrapInLayer below). +export function stripBom(content) { + return content.replace(/^\uFEFF/, ""); +} + // Wrap a CSS file's contents in @layer openui { ... } if not already wrapped. // Idempotency check protects watch-mode and back-to-back builds. export function wrapInLayer(content) { - // Sass emits a UTF-8 BOM for files with non-ASCII output. At byte 0 the - // decoder strips it, but wrapping would push it inside the layer block, - // where U+FEFF parses as an identifier and kills the first rule - // (e.g. the :root theme tokens). Strip it before wrapping. - content = content.replace(/^\uFEFF/, ""); + content = stripBom(content); if (content.trim() === "") return content; if (/^\s*@layer\s+openui\b/.test(content)) return content; return `@layer openui{${content}}`; diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 570080efa..3dec6c192 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@openuidev/react-ui", "license": "MIT", - "version": "0.11.8", + "version": "0.11.9", "description": "Component library for Generative UI SDK", "main": "dist/index.cjs", "module": "dist/index.mjs",