Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/lang-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/openui-cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 8 additions & 0 deletions packages/react-ui/check-css-artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion packages/react-ui/cp-css.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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");
Expand All @@ -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);

Expand Down
17 changes: 12 additions & 5 deletions packages/react-ui/css-layer-utils.mjs
Original file line number Diff line number Diff line change
@@ -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}}`;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading