/* =========================================================================
* DSSL: import.js
*
* dssl import styles.css -o styles.dssl
*
* Turns an existing CSS file into DSSL, and tells you honestly what it could
* not carry over. It is a translator for the mechanical differences, not a
* magic wand:
*
* semicolons dropped, bare numbers for pixel lengths (16px -> 16)
* transition: color .2s ease -> transition: 200 ease color
* font: italic 700 14px/1.5 Arial -> the five longhands (DSSL's font: means something else)
* @keyframes -> motion { from / at 40% / to }
* !important -> the same declaration inside pin { }
* @font-face, @supports, @import, @property ... -> one raw { } block, verbatim
* properties DSSL does not know -> commented out, and listed in the report
*
* Everything else is kept as written, because DSSL selectors and CSS values are
* CSS. The result is then run through the real compiler and any error it still
* has is in the report, so you know before you open the file.
*
* Pure: no DOM, no filesystem. importCss(text) -> { dssl, report }.
* ========================================================================= */
import { parse } from './parser.js';
import { resolve, KNOWN_PROPS, UNITLESS_PROPS, EASINGS } from './resolver.js';
const RAW_AT_RULES = new Set(['font-face', 'supports', 'import', 'property', 'page', 'charset', 'namespace', 'counter-style', 'font-feature-values', 'font-palette-values', 'view-transition', 'scope', 'starting-style', 'position-try']);
const SEMANTIC_NAMES = new Set(['size', 'place', 'center', 'cover', 'sticky', 'radius', 'shadow', 'circle', 'z', 'grow', 'shrink', 'basis', 'stack', 'fluid', 'texture', 'aura', 'slider', 'bend', 'fold', 'stretch', 'gradient', 'animate', 'stagger', 'overlap']);
/* ------------------------------------------------------------------ *
* 1. a small CSS parser
* ------------------------------------------------------------------ */
/**
* Parse CSS into a tree. Handles comments, strings, url(), parentheses and
* nesting. Nodes:
* { type:'decl', prop, value, important, line }
* { type:'rule', sel, body:[nodes], line }
* { type:'at', name, prelude, body:[nodes]|null, raw, line }
* { type:'comment', text, line }
*/
export function parseCss(src) {
let i = 0;
const n = src.length;
let line = 1;
const lineAt = (idx) => { let l = 1; for (let k = 0; k < idx && k < n; k++) if (src.charCodeAt(k) === 10) l++; return l; };
function skipString(q) {
i++;
while (i < n && src[i] !== q) { if (src[i] === '\\') i++; i++; }
i++;
}
function parseBody(inBlock) {
const nodes = [];
let buf = '';
let bufStart = i;
let paren = 0;
const flushDecl = (end) => {
const text = buf.trim();
buf = '';
if (!text) return;
const colon = text.indexOf(':');
if (colon === -1) { nodes.push({ type: 'junk', text, line: lineAt(end) }); return; }
const prop = text.slice(0, colon).trim();
let value = text.slice(colon + 1).trim();
let important = false;
const im = /\s*!\s*important\s*$/i.exec(value);
if (im) { important = true; value = value.slice(0, im.index).trim(); }
nodes.push({ type: 'decl', prop, value, important, line: lineAt(bufStart) });
};
while (i < n) {
const c = src[i];
if (c === '/' && src[i + 1] === '*') {
const end = src.indexOf('*/', i + 2);
const stop = end === -1 ? n : end + 2;
if (!buf.trim()) nodes.push({ type: 'comment', text: src.slice(i + 2, end === -1 ? n : end).trim(), line: lineAt(i) });
i = stop;
continue;
}
if (c === '"' || c === "'") { const s = i; skipString(c); buf += src.slice(s, i); continue; }
if (c === '(') { paren++; buf += c; i++; continue; }
if (c === ')') { paren--; buf += c; i++; continue; }
if (paren > 0) { buf += c; i++; continue; }
if (c === ';') { const at = i; i++; flushAt(at); continue; }
if (c === '{') {
const prelude = buf.trim();
const startIdx = bufStart;
buf = '';
const startLine = lineAt(startIdx);
i++;
const body = parseBody(true);
const node = { line: startLine };
const atm = prelude.startsWith('@') ? /^@([\w-]+)\s*([\s\S]*)$/.exec(prelude) : null;
if (atm) {
Object.assign(node, { type: 'at', name: atm[1].toLowerCase(), prelude: atm[2].trim(), body, raw: src.slice(startIdx, i).trim() });
} else if (prelude && !prelude.startsWith('@')) {
Object.assign(node, { type: 'rule', sel: prelude, body });
} else {
// "@@@ {" or a block with no selector at all: nothing sensible to convert
Object.assign(node, { type: 'junk', text: prelude || '{ ... }' });
}
nodes.push(node);
bufStart = i;
continue;
}
if (c === '}') {
if (inBlock) { flushDecl(i); i++; return nodes; }
i++; continue; // a stray brace at the top level
}
if (!buf.trim()) bufStart = i;
buf += c; i++;
}
flushDecl(i);
return nodes;
// a ";" ended something: a declaration, or an at-rule with no block (@import ...;)
function flushAt(at) {
const text = buf.trim();
const m = text.startsWith('@') ? /^@([\w-]+)\s*([\s\S]*)$/.exec(text) : null;
if (text.startsWith('@') && !m) { nodes.push({ type: 'junk', text, line: lineAt(bufStart) }); buf = ''; }
else if (m) {
nodes.push({ type: 'at', name: m[1].toLowerCase(), prelude: m[2].trim(), body: null, raw: text + ';', line: lineAt(bufStart) });
buf = '';
} else flushDecl(at);
bufStart = i;
}
}
return parseBody(false);
}
/* ------------------------------------------------------------------ *
* 2. value conversion
* ------------------------------------------------------------------ */
/** split on top-level whitespace or commas, keeping rgb(1 2 3), url(a b) etc. whole */
function topSplit(s, sep) {
const out = []; let depth = 0, cur = '', q = null;
for (const ch of s) {
if (q) { cur += ch; if (ch === q) q = null; continue; }
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
if (ch === '(') depth++;
if (ch === ')') depth--;
const hit = sep === ',' ? ch === ',' : /\s/.test(ch);
if (hit && depth === 0) { if (cur.trim()) out.push(cur.trim()); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}
const PX = /^(-?\d*\.?\d+)px$/;
function stripPx(prop, value) {
if (prop.startsWith('--') || UNITLESS_PROPS.has(prop)) return value;
// only top-level words: a px inside calc() or min() is left as written
return topSplit(value, ' ').map((w) => (PX.test(w) ? w.replace(PX, '$1') : w)).join(' ');
}
const msOf = (t) => {
const m = /^(\d*\.?\d+)(ms|s)$/.exec(t);
if (!m) return null;
return m[2] === 's' ? Math.round(parseFloat(m[1]) * 1000) : Math.round(parseFloat(m[1]));
};
function convertTransition(value, note) {
if (value.trim() === 'none') return 'none';
const out = [];
for (const seg of topSplit(value, ',')) {
const atoms = topSplit(seg, ' ');
const times = atoms.filter((a) => msOf(a) !== null);
const ease = atoms.find((a) => EASINGS[a] || /^(cubic-bezier|steps)\(/.test(a));
const props = atoms.filter((a) => msOf(a) === null && a !== ease);
if (!times.length) { note(`transition "${seg}" has no duration, so it was dropped`); continue; }
if (times.length > 1) note('a transition delay has no DSSL form here, so it was dropped (use transition-delay)');
const prop = props.join(' ');
out.push([String(msOf(times[0])), ease, prop && prop !== 'all' ? prop : ''].filter(Boolean).join(' '));
}
return out.join(', ');
}
/** font: [style] [weight] size[/line-height] family -> longhands */
function convertFont(value, note) {
const SYSTEM = /^(caption|icon|menu|message-box|small-caption|status-bar|inherit|initial|unset)$/;
if (SYSTEM.test(value.trim())) { note(`font: ${value} is a system font keyword with no DSSL equivalent`); return null; }
const atoms = topSplit(value, ' ');
const sizeIdx = atoms.findIndex((a) => /^(-?\d*\.?\d+)(px|em|rem|%|pt|vw|vh)?(\/.+)?$/.test(a) && !/^\d{3}$/.test(a) && !/^\d00$/.test(a)
|| /^(xx-small|x-small|small|medium|large|x-large|xx-large)(\/.+)?$/.test(a));
if (sizeIdx === -1) { note(`font: ${value} has no size, so it could not be split`); return null; }
const out = [];
for (const a of atoms.slice(0, sizeIdx)) {
if (/^(italic|oblique)$/.test(a)) out.push(['font-style', a]);
else if (/^(\d{3}|bold|bolder|lighter)$/.test(a)) out.push(['font-weight', a]);
else if (a === 'small-caps') out.push(['font-variant', a]);
}
const [size, lh] = atoms[sizeIdx].split('/');
out.push(['font-size', size]);
if (lh) out.push(['line-height', lh]);
const fam = atoms.slice(sizeIdx + 1).join(' ');
if (fam) out.push(['font-family', fam]);
return out;
}
/* ------------------------------------------------------------------ *
* 3. the converter
* ------------------------------------------------------------------ */
export function importCss(css, options = {}) {
const tree = parseCss(String(css));
const report = { rules: 0, declarations: 0, converted: 0, pinned: 0, raw: 0, motions: 0, dropped: [], notes: [], compile: null };
const rawChunks = [];
const pinBlocks = [];
const note = (line, message, hint) => report.notes.push({ line, message, hint });
const pad = (d) => ' '.repeat(d);
const emitDecl = (d, depth, out, collectPin) => {
report.declarations++;
const line = d.line;
const prop = d.prop.toLowerCase();
if (!prop.startsWith('--') && !KNOWN_PROPS.has(prop) && prop !== 'transition' && prop !== 'font' && prop !== 'animation' && !SEMANTIC_NAMES.has(prop)) {
out.push(`${pad(depth)}// dssl import dropped (unknown to DSSL): ${d.prop}: ${d.value}`);
report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'DSSL does not know this property' });
return;
}
if (SEMANTIC_NAMES.has(prop) && prop !== 'font') {
note(line, `"${d.prop}" is also a DSSL shorthand with a different meaning`, 'check this line after import');
}
if (d.important) {
collectPin.push({ prop, value: d.value, line });
report.pinned++;
return;
}
let value = d.value;
if (prop === 'transition') {
value = convertTransition(value, (m) => note(line, m));
if (!value) { report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'no usable duration' }); out.push(`${pad(depth)}// dssl import dropped: ${d.prop}: ${d.value}`); return; }
report.converted++;
out.push(`${pad(depth)}transition: ${value}`);
return;
}
if (prop === 'font') {
const parts = convertFont(value, (m) => note(line, m));
if (!parts) { report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'font shorthand could not be split' }); out.push(`${pad(depth)}// dssl import dropped: ${d.prop}: ${d.value}`); return; }
report.converted++;
for (const [p, v] of parts) out.push(`${pad(depth)}${p}: ${stripPx(p, v)}`);
return;
}
if (prop === 'grid') note(line, 'grid: is a CSS shorthand here but a different sugar in DSSL', 'it was kept as written; rewrite it with grid-template-* if the compiler complains');
const next = stripPx(prop, value);
if (next !== value) report.converted++;
out.push(`${pad(depth)}${d.prop}: ${next}`);
};
const emitNodes = (nodes, depth, out) => {
for (const node of nodes) {
if (node.type === 'comment') { if (depth === 0 || true) out.push(`${pad(depth)}// ${node.text.replace(/\n\s*/g, ' ')}`); continue; }
if (node.type === 'junk') { note(node.line, `could not read "${node.text.slice(0, 40)}"`); continue; }
if (node.type === 'decl') { emitDecl(node, depth, out, out.__pin || (out.__pin = [])); continue; }
if (node.type === 'rule') {
report.rules++;
const lines = [];
lines.__pin = [];
emitNodes(node.body, depth + 1, lines);
out.push(`${pad(depth)}${node.sel} {`, ...lines, `${pad(depth)}}`);
if (lines.__pin.length) pinBlocks.push({ sel: node.sel, decls: lines.__pin, depth });
continue;
}
if (node.type === 'at') {
const name = node.name;
if (name === 'keyframes' || name.endsWith('-keyframes')) {
if (name !== 'keyframes') { note(node.line, `@${name} is a vendor copy; the plain @keyframes is used`); continue; }
report.motions++;
out.push(`${pad(depth)}motion ${node.prelude} {`);
for (const fr of node.body || []) {
if (fr.type !== 'rule') continue;
for (const label of fr.sel.split(',').map((s) => s.trim())) {
const key = label === '0%' ? 'from' : label === '100%' ? 'to' : /%$/.test(label) ? `at ${label}` : label;
const inner = [];
inner.__pin = [];
emitNodes(fr.body, depth + 2, inner);
out.push(`${pad(depth + 1)}${key} {`, ...inner, `${pad(depth + 1)}}`);
}
}
out.push(`${pad(depth)}}`);
continue;
}
if (RAW_AT_RULES.has(name)) {
report.raw++;
rawChunks.push(node.raw);
if (name === 'import') note(node.line, '@import was moved into raw { }', 'load other stylesheets with or instead');
continue;
}
if (name === 'media' || name === 'container') {
const lines = [];
lines.__pin = [];
emitNodes(node.body || [], depth + 1, lines);
out.push(`${pad(depth)}@${name} ${node.prelude} {`, ...lines, `${pad(depth)}}`);
if (lines.__pin.length) note(node.line, `!important inside @${name} was moved to pin { } and loses the media condition`, 'wrap it in @media inside pin by hand');
continue;
}
if (name === 'layer') {
if (!node.body) { note(node.line, `@layer ${node.prelude}; is dropped: DSSL's layers are fixed (tokens, base, components, utilities, pin)`); continue; }
const lines = [];
lines.__pin = [];
emitNodes(node.body, depth + 1, lines);
out.push(`${pad(depth)}@layer ${node.prelude} {`, ...lines, `${pad(depth)}}`);
continue;
}
report.raw++;
rawChunks.push(node.raw);
note(node.line, `@${name} was kept verbatim in raw { }`);
}
}
};
const lines = [];
lines.__pin = [];
emitNodes(tree, 0, lines);
let dssl = lines.join('\n');
if (pinBlocks.length) {
dssl += `\n\n// !important in CSS is pin { } in DSSL: the layer that always wins\npin {\n`;
for (const b of pinBlocks) {
dssl += ` ${b.sel} {\n${b.decls.map((d) => ` ${d.prop}: ${stripPx(d.prop, d.value)}`).join('\n')}\n }\n`;
}
dssl += `}`;
}
if (rawChunks.length) {
dssl = `// raw { } is real CSS, so semicolons are required inside it\nraw {\n${rawChunks.map((c) => ' ' + c.replace(/\n/g, '\n ')).join('\n')}\n}\n\n` + dssl;
}
dssl = dssl.trim() + '\n';
// Run the real compiler on the result. A declaration it rejects (valid CSS that DSSL's
// stricter value checking does not accept, like scroll-snap-type: x mandatory) is commented
// out and listed, so the file you get always compiles.
const check = () => {
try {
const { ast, errors: parseErrors } = parse(dssl, 'imported.dssl');
const doc = resolve(ast, 'imported.dssl');
return { errors: [...parseErrors, ...doc.errors], warnings: doc.warnings.length };
} catch (e) {
return { errors: [{ line: 0, message: `the compiler threw: ${e.message}` }], warnings: 0 };
}
};
let result = check();
for (let pass = 0; pass < 4 && result.errors.length; pass++) {
const lines = dssl.split('\n');
const seen = new Set();
let changed = false;
for (const e of result.errors) {
const idx = (e.line || 0) - 1;
if (idx < 0 || idx >= lines.length || seen.has(idx)) continue;
const text = lines[idx];
if (!/^\s*[-\w]+\s*:/.test(text) || /^\s*\/\//.test(text)) continue; // only declaration lines
seen.add(idx);
const indent = text.match(/^\s*/)[0];
lines[idx] = `${indent}// dssl import dropped (the compiler rejected it: ${e.message}): ${text.trim()}`;
report.dropped.push({ line: e.line, text: text.trim(), reason: e.message });
changed = true;
}
if (!changed) break;
dssl = lines.join('\n');
result = check();
}
report.compile = {
ok: result.errors.length === 0,
errors: result.errors.map((e) => ({ line: e.line, message: e.message, hint: e.hint })),
warnings: result.warnings,
};
return { dssl, report };
}