Lightweight Less Build Pipeline Using @import (reference) and Caching
A practical guide to building a fast, cache‑driven Less pipeline that uses @import (reference) to share mixins without emitting CSS, enforces strict path resolution, blocks JS evaluation, and detects import cycles.
21 Apr 2026, 17:04 UTC

Requirements
The goal is a fast, reproducible Less compilation that shares mixins and variables across many entry points without emitting duplicate CSS. The pipeline must:
- Import utilities once per build using
@import (reference)so no rules are emitted. - Leverage file-based timestamp caching to skip unchanged files.
- Enforce strict, relative path resolution to avoid OS-specific case-sensitivity bugs.
- Prevent import cycles and disallow untrusted JavaScript evaluation.
- Produce source maps only for development builds.
Minimal Design
Directory layout
project/
less/
utilities.less
components/
button.less
card.less
pages/
home.less
about.less
build/
cache/
css/
maps/
Core Less files
utilities.less contains only mixins and variables:
// utilities.less
@base-spacing: 8px;
.mixin-center() {
display: flex;
align-items: center;
justify-content: center;
}
components/button.less consumes utilities via reference import:
// components/button.less
@import (reference) "../utilities.less";
.btn {
padding: @base-spacing;
.mixin-center();
background: #0066cc;
color: #fff;
}
pages/home.less is an entry point that imports components normally so their rules appear in the final CSS:
// pages/home.less
@import "../components/button.less";
@import "../components/card.less";
.hero {
margin-top: @base-spacing * 2;
}
Build script (Node.js)
Run from the repository root with node build-less.js. The script reads a JSON cache, walks less/pages/ for entry files, hashes each entry with its resolved imports, skips unchanged entries, and compiles changed ones with lessc.
// build-less.js (excerpt)
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const CACHE_FILE = 'build/cache/timestamps.json';
const LESS_ENTRY_DIR = 'less/pages';
const OUT_DIR = 'build/css';
const MAP_DIR = 'build/maps';
const IS_DEV = process.env.NODE_ENV !== 'production';
function loadCache() {
try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); }
catch { return {}; }
}
function saveCache(cache) {
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
}
function hashEntry(entryPath) {
const out = execSync('lessc --verbose "' + entryPath + '" 2>&1', { encoding: 'utf8' });
const importLines = out.match(/^Importing: .+$/gm) || [];
const files = [entryPath, ...importLines.map(l => l.replace('Importing: ', '').trim())];
const stats = files.map(f => f + ':' + fs.statSync(f).mtimeMs).join('|');
return require('crypto').createHash('sha1').update(stats).digest('hex');
}
function compile(entryPath, cache) {
const rel = path.relative(LESS_ENTRY_DIR, entryPath).replace('.less$', '');
const cssOut = path.join(OUT_DIR, rel + '.css');
const mapOut = IS_DEV ? path.join(MAP_DIR, rel + '.css.map') : '';
const mapFlag = IS_DEV ? '--source-map=' + mapOut + ' --source-map-basepath=' + path.resolve(LESS_ENTRY_DIR) : '';
const cmd = 'lessc --strict-imports --no-js ' + mapFlag + ' "' + entryPath + '" "' + cssOut + '"';
execSync(cmd, { stdio: 'inherit' });
cache[entryPath] = hashEntry(entryPath);
}
function main() {
const cache = loadCache();
const entries = fs.readdirSync(LESS_ENTRY_DIR).filter(f => f.endsWith('.less'));
for (const entry of entries) {
const full = path.join(LESS_ENTRY_DIR, entry);
const currentHash = hashEntry(full);
if (cache[full] === currentHash) {
console.log('[skip] ' + entry + ' unchanged');
continue;
}
console.log('[build] ' + entry);
compile(full, cache);
}
saveCache(cache);
}
main();
Trust and Data Boundaries
- Less source: trusted developer-authored files only. No user-supplied Less is compiled.
- JavaScript evaluation: disabled globally via
--no-js. If a legacy feature requires JS, isolate it in a separate, audited file and compile with a dedicated, sandboxed process. - File system: the build script reads only under
less/and writes underbuild/. No network access. - Source maps: emitted only when
NODE_ENV !== 'production'. In production they are omitted to avoid leaking internal paths.
Operational Checks
- Reference import emits no CSS. After a build, grep the output for
.mixin-centeror@base-spacing. They must be absent.grep -n "\.mixin-center" build/css/home.css || echo "OK: mixin not emitted" - Cache effectiveness. Run the script twice without changes; the second run should log
[skip]for every entry.node build-less.js 2>&1 | grep -c "\[skip\]" - Cycle detection. Temporarily create a cycle and run
lesscdirectly; it must abort with a clear “Import loop” error.lessc --strict-imports less/components/button.less 2>&1 | head -5 - Path case-sensitivity. On a Unix CI runner, rename
utilities.lesstoUtilities.lessand verify the build fails, confirming strict resolution.
Failure Modes
| Trigger | Symptom | Mitigation |
|---|---|---|
| Import cycle introduced | lessc exits with stack overflow or “Import loop” error | Enforce a lint step that builds the import graph and fails CI on cycles. |
| JavaScript evaluation enabled | Arbitrary code runs in the build host | Always pass --no-js; audit any legacy @import (less) "js:…" usages. |
| Case-mismatched import on Windows to Unix | File not found error on CI | Adopt a lower-kebab naming convention and enforce it with a filename lint rule. |
| Cache corruption | Stale CSS served despite source changes | Delete build/cache/timestamps.json and rebuild. |
| Source maps shipped to production | Internal file paths exposed | Guard map generation with NODE_ENV; verify build/maps/ is empty in release artifacts. |
When to Re-evaluate the Design
- Project adopts CSS-in-JS or a bundler that handles Less natively; the custom script becomes redundant.
- Need for dynamic theming at runtime; reference imports cannot express runtime variable overrides. Consider Less variables injected via
modifyVars. - Import graph exceeds a few hundred files; incremental caching may no longer be sufficient. Switch to a watch-mode daemon or a bundler with persistent cache.
- Security policy changes to allow sandboxed JS evaluation; only then re-enable
--jsfor a vetted subset.
Verification Checklist (run on every CI build)
npm run build:lesscompletes with exit code 0.- No
.mixin-or variable definitions appear inbuild/css/*.css. - Second consecutive run logs only
[skip]entries. lessc --strict-imports --no-js less/pages/home.lessproduces identical CSS to the cached output.- Production artifact lacks
build/maps/directory.
Following this architecture gives a deterministic, fast Less pipeline that shares utilities safely, avoids common pitfalls, and degrades gracefully when requirements shift.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.