Claude Sonnet 5.5
VS Code extension 0.9.0: aura, texture and slider designers, CSS conversion
e92b981 Download dssl-vscode/lib/engine/import.js from EHS-Studio/dssl: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/EHS-Studio/dssl/resolve/main/dssl-vscode/lib/engine/import.js
- Command line
-
hf download hf://EHS-Studio/dssl/dssl-vscode/lib/engine/import.js
-
curl -L -o import.js https://huggingface.co/EHS-Studio/dssl/resolve/main/dssl-vscode/lib/engine/import.js
16.9 kB
| /* ========================================================================= | |
| * 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 <link rel="stylesheet/dssl"> or <link rel="stylesheet"> 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 }; | |
| } | |