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
4 changes: 2 additions & 2 deletions packages/less/V5-STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ emits nested CSS) instead of always flattening. Opt into flattened output with
| `math` modes | ✅ | ✅ | `always` / `parens-division` (default) / `parens`. |
| `unitMode` (formerly `strictUnits`) | ✅ | ✅ | `loose` / `preserve` (default) / `strict`. |
| `compress` | ✅ | ✅ | Minified, but not byte-identical to Less 4 `-x` (nesting preserved by default). |
| Source maps (`sourceMap`) | ✅ | ✅ | Returns `result.map`; annotation + `outputSourceFiles` supported. Path-variant parity is ⏳. |
| Source maps (`sourceMap`) | ✅ | ✅ | Returns `result.map`; annotation, inline data URI, `outputSourceFiles`, and the `rootpath`/`basepath`/`url` path variants all supported. |
| URL rewriting (`rewriteUrls` / `rootpath` / `urlArgs`) | ✅ | ✅ | Rewrites `url(...)` references. |
| `globalVars` / `modifyVars` injection | ✅ | ❌ | Not supported — these throw rather than silently no-op. |
| `javascriptEnabled` | ✅ | ❌ | JavaScript evaluation is not supported. |
Expand All @@ -61,7 +61,7 @@ emits nested CSS) instead of always flattening. Opt into flattened output with
| `@use` / `@compose` modules | ➖ | ⏳ | Member access (namespaced functions/mixins) designed, not yet wired. |
| Browser build (`window.less`) | ✅ | ✅ | `dist/less-browser-dev.js` ships and powers the playground; full 4.x browser-API parity is ⏳. |
| `lessc` CLI (compile) | ✅ | ✅ | Compiles files. |
| `lessc` CLI **flags** for the newer options | ✅ | ⏳ | `--compress`, `--source-map`, `--rewrite-urls` / `--rootpath` / `--url-args`, `--math` are not wired yet — use the `less.render()` API for these until the CLI catches up. |
| `lessc` CLI **flags** for the newer options | ✅ | ✅ | `--compress`/`-x`, `--source-map[=file]` (+ `--source-map-inline` / `-include-source` / `-rootpath` / `-basepath` / `-url`), `--rewrite-urls` / `--rootpath` / `--url-args`, `--math` are all wired. |
| Diagnostics (`file:line:column` + excerpt) | ➖ | ✅ | Precise diagnostics, not raw parser offsets. |

---
Expand Down
130 changes: 117 additions & 13 deletions packages/less/bin/lessc
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,15 @@ let output = null;
let silent = false;
let quiet = false;
let verbose = false;
let sourceMapEnabled = false;
let sourceMapExplicitFile = null;
const sourceMapOptions = {};

const unsupportedOptions = new Map([
['--source-map', 'source maps are not supported'],
['--source-map-map-inline', 'source maps are not supported'],
['--source-map-include-source', 'source maps are not supported'],
['--source-map-rootpath', 'source maps are not supported'],
['--source-map-basepath', 'source maps are not supported'],
['--source-map-url', 'source maps are not supported'],
['--plugin', 'legacy lessc plugin flags are not supported'],
['--depends', 'dependency-only output is not supported'],
['--lint', 'lint-only mode is not supported'],
['--compress', 'compressed output is not supported'],
['-x', 'compressed output is not supported'],
['--clean-css', 'clean-css compression is not supported'],
['--rewrite-urls', 'URL rewriting is not supported'],
['--rootpath', 'URL rootpath rewriting is not supported'],
['--url-args', 'URL argument rewriting is not supported'],
['--global-var', 'global variable injection is not supported'],
['--modify-var', 'modify-var injection is not supported'],
['--js', 'JavaScript evaluation is not supported'],
Expand Down Expand Up @@ -136,8 +128,74 @@ function parseArgs() {
verbose = true;
continue;
}
if (arg === '--collapse-nesting') {
options.collapseNesting = true;
const collapseNestingMatch = arg.match(/^--collapse-nesting(?:=(.+))?$/);
if (collapseNestingMatch) {
options.collapseNesting = collapseNestingMatch[1] ?? true;
continue;
}
if (arg === '--compress' || arg === '-x') {
options.compress = true;
continue;
}
const mathMatch = arg.match(/^--math=(.+)$/);
if (mathMatch) {
options.math = mathMatch[1];
continue;
}
// URL rewriting (forwarded to the Less plugin). `--rewrite-urls` with no
// value is Less 4.x's `all`.
const rewriteUrlsMatch = arg.match(/^--rewrite-urls(?:=(.+))?$/);
if (rewriteUrlsMatch) {
options.rewriteUrls = rewriteUrlsMatch[1] ?? 'all';
continue;
}
const rootpathMatch = arg.match(/^--rootpath=(.+)$/);
if (rootpathMatch) {
options.rootpath = rootpathMatch[1];
continue;
}
const urlArgsMatch = arg.match(/^--url-args=(.+)$/);
if (urlArgsMatch) {
options.urlArgs = urlArgsMatch[1];
continue;
}
// Source maps. The `--source-map-*` sub-flags are matched before the bare
// `--source-map[=file]` (their `$`-anchored patterns don't overlap, but the
// ordering documents intent). Each sub-flag also enables source maps.
if (arg === '--source-map-inline' || arg === '--source-map-map-inline') {
sourceMapEnabled = true;
sourceMapOptions.sourceMapFileInline = true;
continue;
}
if (arg === '--source-map-include-source') {
sourceMapEnabled = true;
sourceMapOptions.outputSourceFiles = true;
continue;
}
const smRootpathMatch = arg.match(/^--source-map-rootpath=(.+)$/);
if (smRootpathMatch) {
sourceMapEnabled = true;
sourceMapOptions.sourceMapRootpath = smRootpathMatch[1];
continue;
}
const smBasepathMatch = arg.match(/^--source-map-basepath=(.+)$/);
if (smBasepathMatch) {
sourceMapEnabled = true;
sourceMapOptions.sourceMapBasepath = smBasepathMatch[1];
continue;
}
const smUrlMatch = arg.match(/^--source-map-url=(.+)$/);
if (smUrlMatch) {
sourceMapEnabled = true;
sourceMapOptions.sourceMapURL = smUrlMatch[1];
continue;
}
const sourceMapMatch = arg.match(/^--source-map(?:=(.+))?$/);
if (sourceMapMatch) {
sourceMapEnabled = true;
if (sourceMapMatch[1]) {
sourceMapExplicitFile = sourceMapMatch[1];
}
continue;
}
// Deprecated 4.x spellings (--strict-units, --strict-units=on|off, -su=on|off)
Expand Down Expand Up @@ -190,6 +248,44 @@ function parseArgs() {
}
}

// Assemble the `sourceMap` render option from the CLI source-map flags and
// return the sidecar `.map` path to write (null when inlined or unset). The
// engine writes the `sourceMappingURL` annotation into the CSS and returns
// `result.map`; the CLI owns writing that map to disk, mirroring Less 4.x lessc.
function resolveSourceMap() {
if (!sourceMapEnabled) {
return null;
}
let mapFilePath = null;
if (sourceMapOptions.sourceMapFileInline !== true) {
if (sourceMapExplicitFile) {
mapFilePath = path.resolve(process.cwd(), sourceMapExplicitFile);
} else if (output) {
mapFilePath = `${output}.map`;
} else {
// Writing CSS to stdout leaves nowhere for a sidecar; inline the map.
sourceMapOptions.sourceMapFileInline = true;
}
}
if (mapFilePath) {
// Annotation URL (unless --source-map-url set it) points at the map relative
// to the CSS output directory — so a map written to another directory still
// resolves (dist/app.css -> ../maps/app.map), and the same-dir common case
// stays a bare basename. With no output file (stdout) there is no directory
// to resolve against, so fall back to the basename.
if (sourceMapOptions.sourceMapURL === undefined) {
sourceMapOptions.sourceMapFilename = output
? path.relative(path.dirname(output), mapFilePath).split(path.sep).join('/')
: path.basename(mapFilePath);
}
if (output) {
sourceMapOptions.sourceMapOutputFilename = path.basename(output);
}
}
options.sourceMap = sourceMapOptions;
return mapFilePath;
}

async function run() {
parseArgs();

Expand All @@ -199,6 +295,8 @@ async function run() {
printUsage();
}

const mapFilePath = resolveSourceMap();

less.logger.addListener({
info(msg) {
if (verbose) console.log(msg);
Expand Down Expand Up @@ -237,6 +335,12 @@ async function run() {
if (!silent) outputJessDiagnostics([], result.warnings || []);
process.stdout.write(result.css);
}

if (mapFilePath && result.map) {
fs.mkdirSync(path.dirname(mapFilePath), { recursive: true });
fs.writeFileSync(mapFilePath, result.map, 'utf8');
if (!silent) console.log(`lessc: wrote ${mapFilePath}`);
}
} catch (err) {
if (!silent) {
if (!outputJessError(err)) {
Expand Down
16 changes: 13 additions & 3 deletions packages/less/lib/lessc-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,23 @@ const lesscHelper = {
console.log(' --quiet Suppresses output of warnings.');
console.log(' -v, --version Prints version number and exit.');
console.log(' --verbose Be verbose.');
console.log(' --collapse-nesting Flatten nested rules after preserving source-order cascade.');
console.log(' --collapse-nesting[=MODE] Flatten nested rules: native (default), or compact.');
console.log(' -x, --compress Compress output by removing whitespace.');
console.log(' --math=MODE Math mode: parens-division (default), always, or parens.');
console.log(' --unit-mode=MODE Unit handling in math: preserve (default), strict, or loose (Less 4.x guessing).');
console.log(' --strict-units[=on|off] Deprecated: on is --unit-mode=strict, off is the default (preserve).');
console.log(' --source-map[=FILE] Emit a source map (FILE, or <destination>.map by default).');
console.log(' --source-map-inline Embed the source map as a data URI instead of a file.');
console.log(' --source-map-include-source Embed the source files in the map (sourcesContent).');
console.log(' --source-map-rootpath=PATH Prepend PATH to every source in the map.');
console.log(' --source-map-basepath=PATH Strip PATH from the front of every source in the map.');
console.log(' --source-map-url=URL Override the sourceMappingURL annotation.');
console.log(' --rewrite-urls[=all|local|off] Rewrite url(...) references in imported files.');
console.log(' --rootpath=PATH Prepend PATH to url(...) and import references.');
console.log(' --url-args=ARGS Append ARGS (e.g. cache-buster) to every url(...).');
console.log('');
console.log('This release intentionally supports a smaller CLI surface.');
console.log('Source maps, browser compilation, legacy plugin flags, lint-only mode, and');
console.log('URL rewrite flags will be revisited in later alphas.');
console.log('Browser compilation, legacy plugin flags, and lint-only mode are not supported.');
console.log('');
console.log('Report bugs to: http://github.com/less/less.js/issues');
console.log('Home page: <http://lesscss.org/>');
Expand Down
86 changes: 83 additions & 3 deletions packages/less/test/lessc-alpha.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,14 @@ try {
'lessc help documents the supported alpha nesting flag');
assert.match(help.stdout, /This release intentionally supports a smaller CLI surface/,
'lessc help explicitly scopes the supported CLI surface');
assert.doesNotMatch(help.stdout, /--source-map/,
'lessc help must not advertise unsupported source-map flags in alpha.1');
assert.match(help.stdout, /--source-map\[=FILE\]/,
'lessc help documents the supported source-map flag');
assert.match(help.stdout, /--compress/,
'lessc help documents the supported compress flag');
assert.doesNotMatch(help.stdout, /--plugin=/,
'lessc help must not advertise unsupported plugin flags in alpha.1');

for (const flag of ['--source-map', '--plugin=less-plugin-clean-css', '--bogus']) {
for (const flag of ['--clean-css', '--plugin=less-plugin-clean-css', '--bogus']) {
const unsupported = await runLessc([flag, '-'], '.unsupported { color: red; }\n');
assert.equal(unsupported.code, 1, `${flag} must fail instead of silently no-oping`);
assert.equal(unsupported.stdout, '', `${flag} must not emit CSS after rejecting the option`);
Expand Down Expand Up @@ -279,6 +281,84 @@ try {
assert.equal(await readFile(nestedOutput, 'utf8'), collapsedCss,
'file-mode lessc preserves declaration source order while collapsing nesting');

// --compress / --math / --url-args / --rootpath forward to the render options
// the API already supports (see alpha-support.mjs for the API-level coverage).
const urlInput = path.join(tempDir, 'urls.less');
await writeFile(urlInput, '.a { color: red; background: url(img.png); }\n');

const compressed = await runLessc(['--compress', urlInput]);
assert.equal(compressed.code, 0, compressed.stderr);
assert.doesNotMatch(compressed.stdout, /\n\s*\n/, '--compress strips blank lines');
assert.match(compressed.stdout, /color:red/, '--compress minifies declarations');

const mathAlways = await runLessc(['--math=always', '-'], '.a { width: 2 + 3 * 4; }\n');
assert.equal(mathAlways.code, 0, mathAlways.stderr);
assert.match(mathAlways.stdout, /width:\s*14/, '--math=always evaluates unparenthesized math');

const urlArgs = await runLessc(['--url-args=v=9', urlInput]);
assert.equal(urlArgs.code, 0, urlArgs.stderr);
assert.match(urlArgs.stdout, /url\(img\.png\?v=9\)/, '--url-args appends the query');

const rootpath = await runLessc(['--rootpath=/cdn/', urlInput]);
assert.equal(rootpath.code, 0, rootpath.stderr);
assert.match(rootpath.stdout, /url\(\/cdn\/img\.png\)/, '--rootpath prepends to url() references');

// Source maps: `--source-map` writes a sidecar <output>.map and annotates the
// CSS with its basename; `--source-map-inline` embeds a data URI instead.
const smOutput = path.join(tempDir, 'sm.css');
const sm = await runLessc(['--source-map', input, smOutput]);
assert.equal(sm.code, 0, sm.stderr);
assert.match(sm.stdout, /lessc: wrote .+sm\.css\.map\n/, '--source-map reports the sidecar map it wrote');
const smCss = await readFile(smOutput, 'utf8');
assert.match(smCss, /\/\*# sourceMappingURL=sm\.css\.map \*\/\n$/,
'--source-map annotates the CSS with the sidecar map basename');
const smMap = JSON.parse(await readFile(`${smOutput}.map`, 'utf8'));
assert.equal(smMap.version, 3, 'the sidecar map is source-map v3');
assert.equal(smMap.file, 'sm.css', 'map.file is the CSS output name');
assert.ok(smMap.sources.some(s => s.endsWith('input.less')), 'the map carries the input source');

// A map written to a different directory than the CSS must be annotated
// relative to the CSS output directory, not as a bare basename.
const crossMap = path.join(tempDir, 'maps', 'app.map');
const crossCss = path.join(tempDir, 'dist', 'app.css');
const cross = await runLessc([`--source-map=${crossMap}`, input, crossCss]);
assert.equal(cross.code, 0, cross.stderr);
assert.match(await readFile(crossCss, 'utf8'), /sourceMappingURL=\.\.\/maps\/app\.map \*\//,
'a cross-directory --source-map annotates the CSS with a path relative to the output dir');

const inlineOut = path.join(tempDir, 'inline.css');
const inlineSm = await runLessc(['--source-map-inline', input, inlineOut]);
assert.equal(inlineSm.code, 0, inlineSm.stderr);
assert.match(await readFile(inlineOut, 'utf8'), /sourceMappingURL=data:application\/json;base64,/,
'--source-map-inline embeds the map as a data URI');
await assert.rejects(readFile(`${inlineOut}.map`, 'utf8'),
'--source-map-inline writes no sidecar .map file');

// Path variants: basepath strips, rootpath prepends, include-source embeds
// content, --source-map-url overrides the annotation. basepath must match the
// real (symlink-resolved) tempDir the compiler records for the source.
const realTempDir = await realpath(tempDir);
const variantMapPath = path.join(tempDir, 'variant.map');
const variantOut = path.join(tempDir, 'variant.css');
const variant = await runLessc([
`--source-map=${variantMapPath}`,
`--source-map-basepath=${realTempDir}`,
'--source-map-rootpath=http://cdn/',
'--source-map-include-source',
'--source-map-url=/assets/variant.map',
input, variantOut
]);
assert.equal(variant.code, 0, variant.stderr);
assert.match(await readFile(variantOut, 'utf8'), /sourceMappingURL=\/assets\/variant\.map \*\//,
'--source-map-url overrides the annotation URL');
const variantMap = JSON.parse(await readFile(variantMapPath, 'utf8'));
assert.ok(variantMap.sources.length > 0 && variantMap.sources.every(s => s.startsWith('http://cdn/')),
'--source-map-rootpath prepends every source');
assert.ok(variantMap.sources.every(s => !s.includes(realTempDir)),
'--source-map-basepath strips the base from every source');
assert.ok(Array.isArray(variantMap.sourcesContent) && variantMap.sourcesContent.length > 0,
'--source-map-include-source embeds sourcesContent');

const file = await runLessc([input, output]);
assert.equal(file.code, 0, file.stderr);
assert.match(file.stdout, /^lessc: wrote .+output\.css\n$/);
Expand Down
Loading