File size: 20,395 Bytes
c3fdf10 794b22f c3fdf10 794b22f d9e058a 47b8a5f c3fdf10 794b22f c3fdf10 47b8a5f 444a402 c3fdf10 47b8a5f c3fdf10 6859477 47b8a5f 794b22f 6859477 c3fdf10 794b22f c3fdf10 e6c76da c3fdf10 e6c76da 444a402 6c00f0c 444a402 c3fdf10 47b8a5f c3fdf10 794b22f d9e058a 6859477 c3fdf10 e6c76da c3fdf10 e6c76da c3fdf10 e6c76da c3fdf10 794b22f c3fdf10 e6c76da c3fdf10 e6c76da c3fdf10 6859477 c3fdf10 794b22f c3fdf10 794b22f c3fdf10 794b22f c3fdf10 e6c76da c3fdf10 d9e058a c3fdf10 47b8a5f 444a402 c3fdf10 6c00f0c 444a402 e6c76da 444a402 e6c76da 444a402 6c00f0c 444a402 c3fdf10 444a402 c3fdf10 444a402 c3fdf10 444a402 c3fdf10 6c00f0c c3fdf10 444a402 c3fdf10 6c00f0c c3fdf10 08132d7 e6c76da 794b22f c3fdf10 444a402 c3fdf10 794b22f d9e058a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | #!/usr/bin/env node
/* =========================================================================
* dssl - the command line for the DSSL style language
*
* dssl check styles.dssl [more.dssl …] [--json] [--strict] [--quiet]
* Compile files with the REAL parser + resolver (the same engine the
* browser runs). Prints file:line diagnostics with did-you-mean hints.
* Exit 1 on errors, 2 on usage problems. --strict makes warnings fatal.
* --json emits machine-readable diagnostics for CI and editors.
*
* dssl import styles.css [-o out.dssl] [--stdout] [--json]
* Convert CSS to DSSL (semicolons, px, transition order, font:, @keyframes,
* !important, raw blocks) and report what could not be carried over.
*
* dssl dev [dir] [--port 3000] [--inspect]
* A tiny static server that never caches and adds DSSL.watch() to every page it
* serves, so saving a .dssl file restyles the open page in place. Pages that load
* /dssl/boot.js get it from the engine folder if the served folder has none.
*
* dssl build styles.dssl [-o out.css] [--json] [--strict] [--no-banner]
* The exit lane: compile the static subset to a real .css file.
* Tokens become custom properties, states become :hover, layers become
* @layer, motions become @keyframes, raw { } passes through verbatim.
* @signal rules and animate:/engine-native properties stay in the
* runtime - the report says exactly what was left behind.
*
* Files are compiled in the order given; every bundle's tokens fold into the
* next (name a tokens.dssl first, exactly like <link> order in the browser).
* ========================================================================= */
import { readFileSync, writeFileSync, statSync, existsSync } from 'node:fs';
import { dirname, resolve as pathResolve, basename, join, relative as pathRelative } from 'node:path';
import { fileURLToPath } from 'node:url';
// the engine is plain ESM, import it directly, no build step. In a repo checkout
// it lives in public/dssl/. In the downloaded package (dssl-engine.zip) it sits
// next to cli/ as dssl/. 0.8.0 only knew the first layout, so the packaged CLI
// crashed with "module not found" for everyone who installed it.
const here = pathResolve(dirname(fileURLToPath(import.meta.url)), '..');
// 0.15.0: `--engine ./dssl` (or DSSL_ENGINE=./dssl) points at the engine folder wherever it is, so the CLI file can live anywhere
let engineDir = existsSync(join(here, 'public/dssl/parser.js')) ? join(here, 'public/dssl') : join(here, 'dssl');
{
const i = process.argv.indexOf('--engine');
let want = process.env.DSSL_ENGINE || null;
if (i !== -1) { want = process.argv[i + 1] || null; process.argv.splice(i, want === null ? 1 : 2); if (want === null) { console.error('dssl: --engine needs a folder, e.g. --engine ./dssl'); process.exit(2); } }
if (want) {
const dir = pathResolve(want);
if (!existsSync(join(dir, 'parser.js'))) { console.error(`dssl: ${dir} has no parser.js, so it is not the engine folder (the folder that holds parser.js, resolver.js, index.js...)`); process.exit(2); }
engineDir = dir;
}
}
const { parse } = await import('file://' + join(engineDir, 'parser.js'));
const { resolve } = await import('file://' + join(engineDir, 'resolver.js'));
const { toCSS } = await import('file://' + join(engineDir, 'static.js'));
const { VERSION } = await import('file://' + join(engineDir, 'index.js'));
const { importCss } = await import('file://' + join(engineDir, 'import.js'));
const { shadowWarnings } = await import('file://' + join(engineDir, 'lint.js'));
const USAGE = `dssl v${VERSION} - the DSSL style language
dssl check <files…> compile with the real engine, print diagnostics. A page (index.html) works too:
it takes the stylesheets in the order the page loads them
--json machine-readable output
--strict warnings are errors too (exit 1)
--quiet only print diagnostics, no summary
dssl build <files…> (or a page, index.html) compile the static subset to a real .css file (several files: in load order, tokens first)
-o <out.css> output path (default: <first file>.css)
--sourcemap write <out.css>.map so devtools show the .dssl file and line of each rule
--map-name <f> file name for the map when your host will not serve *.map (default <out.css>.map)
--reset include the small reset, as a layer that loses to everything
--inline-tokens write each token's value in place of var(--dssl-x) where the token is defined once and
never changed: the fastest CSS, but a token changed at run time will not reach it
--native-specificity keep selectors as written. By default every selector is wrapped in :where(),
so source order wins exactly like in the engine, not CSS specificity
--json machine-readable report
--strict warnings are errors too (exit 1)
--no-banner omit the generated-file header
dssl import <file.css> convert existing CSS to DSSL and report what could not carry over
-o <out.dssl> output path (default: <file>.dssl)
--stdout print the DSSL instead of writing a file
--json machine-readable report
dssl dev [dir] serve a folder with hot reload: edit a .dssl file and the page restyles with no reload
--port <n> port (default 3000)
--inspect also open the in-page inspector (hover an element to see its rules)
dssl --version print the version
every command: --engine <dir> uses the engine in that folder (default: the one next to this file; or set DSSL_ENGINE)`;
const readArg = (args, flag) => {
const i = args.indexOf(flag);
if (i === -1) return null;
args.splice(i, 1);
return args.splice(i, 1)[0] || null;
};
const readSource = (file) => {
try {
statSync(file);
return readFileSync(file, 'utf8');
} catch {
console.error(`dssl: cannot read ${file}`);
process.exit(2);
}
};
/**
* 0.14.0: `dssl check index.html` and `dssl build index.html` read the page and take its stylesheets in the order the browser
* loads them: every <link rel="stylesheet/dssl"> and every inline <style type="text/dssl"> (or <script type="text/dssl">),
* top to bottom. Relative links are resolved against the page's folder; a leading / is tried against that folder first.
* Nobody keeps a second list of files in a script any more, and the order cannot drift from what the runtime does.
*/
function expandInputs(args) {
const out = [];
for (const a of args) {
if (!/\.html?$/i.test(a)) { out.push(a); continue; }
const html = readSource(a);
const dir = dirname(pathResolve(a));
const found = [];
const re = /<link\b[^>]*>|<(style|script)\b[^>]*type\s*=\s*["']text\/dssl["'][^>]*>([\s\S]*?)<\/\1>/gi;
let n = 0, m;
while ((m = re.exec(html))) {
if (m[0].toLowerCase().startsWith('<link')) {
if (!/rel\s*=\s*["']stylesheet\/dssl["']/i.test(m[0])) continue;
const href = (m[0].match(/href\s*=\s*["']([^"']+)["']/i) || [])[1];
if (!href || /^(https?:)?\/\//i.test(href)) continue;
const clean = href.split(/[?#]/)[0];
const cands = [join(dir, clean), join(dir, clean.replace(/^\//, '')), pathResolve(clean)];
const hit = cands.find((c) => existsSync(c));
if (!hit) { console.error(`dssl: ${a} links ${href}, which is not on disk (looked in ${dir})`); process.exit(2); }
found.push(hit);
} else {
n++;
const nm = (m[0].match(/data-dssl-name\s*=\s*["']([^"']+)["']/i) || [])[1] || `${basename(a)}#inline-${n}`;
found.push({ name: nm, text: m[2], file: pathResolve(a) });
}
}
if (!found.length) { console.error(`dssl: ${a} has no <link rel="stylesheet/dssl"> or <style type="text/dssl"> in it`); process.exit(2); }
out.push(...found);
}
return out;
}
/** compile files in order, folding tokens forward like the browser does */
function compileAll(files) {
const out = [];
let tokenBase = null, motionBase = {};
for (const item of expandInputs(files)) {
const inline = typeof item === 'object';
const file = inline ? item.file : item;
const src = inline ? item.text : readSource(file);
const name = inline ? item.name : basename(file);
const { ast, errors: parseErrors } = parse(src, name);
let doc;
try {
doc = resolve(ast, name, tokenBase, motionBase);
} catch (e) {
// the compiler must never throw - this is a bug report if it happens
doc = { rules: [], shadowRules: [], raws: [], errors: [{ file: name, line: 0, message: `internal compiler error: ${e.message}`, hint: 'please report this - a snippet that crashes the compiler is a bug in DSSL, not in your code' }], warnings: [], tokens: {} };
}
const all = [...parseErrors.map(e => ({ ...e, file: name })), ...doc.errors.map(e => ({ ...e, file: e.file || name }))];
out.push({ file, name, doc, errors: all, warnings: doc.warnings || [], text: src });
tokenBase = { ...(tokenBase || {}), ...doc.tokens };
motionBase = { ...motionBase, ...(doc.motionFrames || {}) };
}
// 0.15.0: a rule that can never win because a rule in a later file (or later in the same one) matches all it matches and sets the same thing.
// Per file this is invisible; across the page's files it is the commonest silent loss.
try {
const all = [];
out.forEach((x, i) => { for (const r of x.doc.rules || []) all.push({ ...r, file: r.file || x.name, order: i * 1e6 + (r.order || 0) }); });
for (const w of shadowWarnings(all)) {
const owner = out.find((x) => x.name === w.file) || out[0];
owner.warnings = [...owner.warnings, w];
}
} catch { /* a lint must never break a compile */ }
return out;
}
const printDiags = (results, quiet) => {
let errCount = 0, warnCount = 0;
for (const r of results) {
for (const e of r.errors) {
errCount++;
console.error(`${e.file || r.name}:${e.line ?? 0} - error - ${e.message}${e.hint ? `\n ${e.hint}` : ''}`);
}
for (const w of r.warnings) {
warnCount++;
console.error(`${w.file || r.name}:${w.line ?? 0} - warning - ${w.message}${w.hint ? `\n ${w.hint}` : ''}`);
}
}
if (!quiet && results.length > 1) {
console.error(`\n${results.length} file(s), ${errCount} error(s), ${warnCount} warning(s)`);
}
return { errCount, warnCount };
};
const args = process.argv.slice(2);
const cmd = args.shift();
if (!cmd || cmd === '--help' || cmd === '-h') {
console.log(USAGE);
process.exit(cmd ? 0 : 2);
}
if (args.includes('--help') || args.includes('-h')) {
console.log(USAGE);
process.exit(0);
}
if (cmd === '--version' || cmd === '-v') {
console.log(VERSION);
process.exit(0);
}
if (cmd === 'check') {
const json = args.includes('--json');
const strict = args.includes('--strict');
const quiet = args.includes('--quiet');
for (const f of ['--json', '--strict', '--quiet']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
if (!args.length) { console.error('dssl check: give me at least one .dssl file, or a page (index.html) to read the stylesheet order from'); process.exit(2); }
const results = compileAll(args);
if (json) {
console.log(JSON.stringify({
version: VERSION,
files: results.map(r => ({
file: r.file,
ok: r.errors.length === 0 && (!strict || r.warnings.length === 0),
errors: r.errors.map(e => ({ file: e.file || r.name, line: e.line ?? 0, message: e.message, hint: e.hint || null })),
warnings: r.warnings.map(w => ({ file: w.file || r.name, line: w.line ?? 0, message: w.message, hint: w.hint || null })),
})),
}, null, 2));
} else {
printDiags(results, quiet);
}
const bad = results.some(r => r.errors.length || (strict && r.warnings.length));
process.exit(bad ? 1 : 0);
}
if (cmd === 'dev') {
const { createServer } = await import('node:http');
const { extname } = await import('node:path');
const { readFile } = await import('node:fs/promises');
const port = Number(readArg(args, '--port') || 3000);
const inspectOn = args.includes('--inspect'); if (inspectOn) args.splice(args.indexOf('--inspect'), 1);
const dir = pathResolve(args[0] || '.');
if (!existsSync(dir)) { console.error(`dssl dev: ${dir} does not exist`); process.exit(2); }
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.dssl': 'text/plain; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain' };
const inject = `<script>addEventListener('dssl-ready',function(){if(window.DSSL){DSSL.watch();${inspectOn ? 'DSSL.inspect();' : ''}}});</script>`;
createServer(async (req, res) => {
let url = decodeURIComponent((req.url || '/').split('?')[0]);
if (url.endsWith('/')) url += 'index.html';
const inside = (p) => p.startsWith(dir);
let file = pathResolve(join(dir, url));
if (!inside(file)) { res.writeHead(403); return res.end('no'); }
if (!existsSync(file) && url.startsWith('/dssl/')) file = pathResolve(join(engineDir, url.slice(6)));
try {
if (statSync(file).isDirectory()) file = join(file, 'index.html');
let body = await readFile(file);
if (extname(file) === '.html') body = Buffer.from(String(body).includes('</body>') ? String(body).replace('</body>', inject + '</body>') : String(body) + inject);
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' });
res.end(body);
} catch { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found: ' + url); }
}).listen(port, () => {
console.log(`dssl dev: serving ${dir}`);
console.log(` http://localhost:${port}/ (hot reload on${inspectOn ? ', inspector on' : ''}; Ctrl+C to stop)`);
});
} else if (cmd === 'build') {
const json = args.includes('--json');
const banner = !args.includes('--no-banner');
const strict = args.includes('--strict');
const sourcemap = args.includes('--sourcemap');
const reset = args.includes('--reset');
const nativeSpecificity = args.includes('--native-specificity');
const inlineTokens = args.includes('--inline-tokens');
for (const f of ['--json', '--no-banner', '--strict', '--sourcemap', '--reset', '--native-specificity', '--inline-tokens']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
const outPath = readArg(args, '-o') || readArg(args, '--out');
const mapName = readArg(args, '--map-name'); // some hosts refuse to serve *.map; a different name is the same map
if (!args.length) { console.error('dssl build: give me a .dssl file to build (or several, in load order: tokens first)'); process.exit(2); }
const inputs = [...args];
const results = compileAll(inputs);
inputs.length = 0; inputs.push(...results.map((x) => x.file));
const errors = results.flatMap((x) => x.errors), warnings = results.flatMap((x) => x.warnings);
const outputPath = outPath || (inputs[0].replace(/\.dssl$/i, '') + '.css');
const outDir = dirname(pathResolve(outputPath));
const sources = {};
for (const x of results) sources[x.name] = { path: pathRelative(outDir, pathResolve(x.file)).split('\\').join('/'), content: x.text };
const built = toCSS(results.map((x) => x.doc), {
file: inputs.map((f) => basename(f)).join(' + '), banner, version: VERSION, reset, nativeSpecificity, inlineTokens,
sourceMap: sourcemap, sources, mapFile: basename(outputPath), sourceMapUrl: sourcemap ? (mapName || basename(outputPath) + '.map') : undefined,
});
if (json) {
console.log(JSON.stringify({
version: VERSION,
input: results[0].file, inputs: results.map((x) => x.file),
output: outputPath,
ok: errors.length === 0 && (!strict || warnings.length === 0),
errors: errors.map(e => ({ file: e.file, line: e.line ?? 0, message: e.message, hint: e.hint || null })),
warnings: warnings.map(w => ({ file: w.file, line: w.line ?? 0, message: w.message, hint: w.hint || null })),
report: built.report,
css: built.css, map: built.map,
}, null, 2));
} else {
writeFileSync(outputPath, built.css);
if (built.map) writeFileSync(mapName ? join(dirname(outputPath), mapName) : outputPath + '.map', JSON.stringify(built.map));
const rep = built.report;
console.log(`built ${outputPath} from ${results.map((x) => x.file).join(', ')}`);
console.log(` ${rep.rules} rule(s), ${rep.shadowRules} pseudo-element rule(s), ${rep.keyframes} @keyframes, ${rep.raws} raw block(s)`);
if (rep.layers.length) console.log(` layers: ${rep.layers.join(' < ')}`);
if (built.map) console.log(` source map: ${mapName ? join(dirname(outputPath), mapName) : outputPath + '.map'} (browser devtools show the .dssl file and line for each rule)`);
if (rep.seenStripped) console.log(` ${rep.seenStripped} seen rule(s) applied unconditionally (the no-JS state)`);
if (rep.bakedTextures) console.log(` ${rep.bakedTextures} texture(s) baked into the CSS as background images`);
for (const n of rep.notBaked || []) console.log(` texture on "${n.sel}" stays in the runtime: ${n.why}`);
if (rep.needsBox && rep.needsBox.length) {
console.log(` ${rep.needsBox.length} rule(s) align their children without saying display, so the engine would make them grids and this CSS will not:`);
for (const n of rep.needsBox.slice(0, 8)) console.log(` ${n.file || ''}:${n.line} ${n.sel} { ${n.prop} } -> add display: grid (or flex)`);
if (rep.needsBox.length > 8) console.log(` ...and ${rep.needsBox.length - 8} more`);
}
if (rep.skippedSignals || rep.engineDecls) {
const kinds = rep.engineKinds.length ? ` (${rep.engineKinds.join(', ')})` : '';
console.log(` stays in the runtime: ${rep.skippedSignals} @signal rule(s), ${rep.engineDecls} engine-native declaration(s)${kinds}`);
}
printDiags(results, true);
}
process.exit(errors.length || (strict && warnings.length) ? 1 : 0);
}
if (cmd === 'import') {
const json = args.includes('--json');
const toStdout = args.includes('--stdout');
for (const f of ['--json', '--stdout']) { const i = args.indexOf(f); if (i !== -1) args.splice(i, 1); }
const outPath = readArg(args, '-o') || readArg(args, '--out');
if (!args.length) { console.error('dssl import: give me a .css file to convert'); process.exit(2); }
const input = args[0];
const { dssl, report } = importCss(readSource(input));
const target = outPath || (input.replace(/\.css$/i, '') + '.dssl');
if (json) {
console.log(JSON.stringify({ version: VERSION, input, output: toStdout ? null : target, report, dssl: toStdout ? dssl : undefined }, null, 2));
} else if (toStdout) {
process.stdout.write(dssl);
} else {
writeFileSync(target, dssl);
console.log(`imported ${target} from ${input}`);
console.log(` ${report.rules} rule(s), ${report.declarations} declaration(s), ${report.converted} rewritten, ${report.pinned} moved to pin, ${report.motions} motion(s), ${report.raw} at-rule(s) kept in raw { }`);
for (const d of report.dropped) console.log(` dropped line ${d.line}: ${d.text}\n ${d.reason}`);
for (const n of report.notes) console.log(` note line ${n.line}: ${n.message}${n.hint ? `\n ${n.hint}` : ''}`);
console.log(report.compile.ok ? ` the result compiles${report.compile.warnings ? ` (${report.compile.warnings} warning(s))` : ''}` : ` the result still has ${report.compile.errors.length} error(s):\n` + report.compile.errors.map((e) => ` line ${e.line}: ${e.message}`).join('\n'));
}
process.exit(report.compile.ok ? 0 : 1);
}
if (cmd !== 'dev') { // dev keeps the process alive
console.error(`dssl: unknown command "${cmd}"\n\n${USAGE}`);
process.exit(2);
}
|