diff --git a/packages/less/V5-STATUS.md b/packages/less/V5-STATUS.md index 7015ba498..c1dea1a3c 100644 --- a/packages/less/V5-STATUS.md +++ b/packages/less/V5-STATUS.md @@ -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. | @@ -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. | --- diff --git a/packages/less/bin/lessc b/packages/less/bin/lessc index 694396558..90693435c 100755 --- a/packages/less/bin/lessc +++ b/packages/less/bin/lessc @@ -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'], @@ -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) @@ -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(); @@ -199,6 +295,8 @@ async function run() { printUsage(); } + const mapFilePath = resolveSourceMap(); + less.logger.addListener({ info(msg) { if (verbose) console.log(msg); @@ -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)) { diff --git a/packages/less/lib/lessc-helper.js b/packages/less/lib/lessc-helper.js index d097cf121..04e4571bb 100644 --- a/packages/less/lib/lessc-helper.js +++ b/packages/less/lib/lessc-helper.js @@ -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 .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: '); diff --git a/packages/less/test/lessc-alpha.mjs b/packages/less/test/lessc-alpha.mjs index 34f7b3ae4..918f137dc 100644 --- a/packages/less/test/lessc-alpha.mjs +++ b/packages/less/test/lessc-alpha.mjs @@ -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`); @@ -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 .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$/);