From ed29641a7c75430f972f66deec2f468e63529ccb Mon Sep 17 00:00:00 2001 From: Tyler Oakleaf Date: Thu, 26 Feb 2026 17:17:34 -0500 Subject: [PATCH 1/2] Fix JS plugin API compatibility for contexts.Eval visitors --- less/runtime/plugin-host.js | 234 +++++++++++++++++- less/runtime/plugin_loader_test.go | 111 +++++++++ npm/darwin-arm64/bin/plugin-host.js | 234 +++++++++++++++++- npm/darwin-x64/bin/plugin-host.js | 234 +++++++++++++++++- npm/linux-arm64/bin/plugin-host.js | 234 +++++++++++++++++- npm/linux-x64/bin/plugin-host.js | 234 +++++++++++++++++- npm/win32-arm64/bin/plugin-host.js | 234 +++++++++++++++++- npm/win32-x64/bin/plugin-host.js | 234 +++++++++++++++++- testdata/less/custom/var-javascript.less | 4 +- testdata/plugin/plugin-contexts-eval-visit.js | 40 +++ testdata/plugin/plugin-contexts-eval.js | 23 ++ 11 files changed, 1793 insertions(+), 23 deletions(-) create mode 100644 testdata/plugin/plugin-contexts-eval-visit.js create mode 100644 testdata/plugin/plugin-contexts-eval.js diff --git a/less/runtime/plugin-host.js b/less/runtime/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/less/runtime/plugin-host.js +++ b/less/runtime/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/less/runtime/plugin_loader_test.go b/less/runtime/plugin_loader_test.go index 5e8fcaec6..d3f890bf5 100644 --- a/less/runtime/plugin_loader_test.go +++ b/less/runtime/plugin_loader_test.go @@ -393,6 +393,117 @@ func TestJSPluginLoader_LoadPreEvalPlugin(t *testing.T) { } } +func TestJSPluginLoader_LoadContextsEvalPlugin(t *testing.T) { + pluginDir := findTestDataPluginDir() + if pluginDir == "" { + t.Skip("Test data plugin directory not found") + } + + rt, err := NewNodeJSRuntime() + if err != nil { + t.Fatalf("NewNodeJSRuntime failed: %v", err) + } + + err = rt.Start() + if err != nil { + t.Fatalf("Start failed: %v", err) + } + defer rt.Stop() + + loader := NewJSPluginLoader(rt) + + // Load plugin-contexts-eval.js (expects less.contexts.Eval support) + pluginPath := filepath.Join(pluginDir, "plugin-contexts-eval.js") + result := loader.LoadPlugin(pluginPath, pluginDir, nil, nil, nil) + + plugin, ok := result.(*Plugin) + if !ok { + t.Fatalf("Expected *Plugin, got %T: %v", result, result) + } + if plugin == nil { + t.Fatal("Plugin is nil") + } + + if plugin.Visitors != 1 { + t.Errorf("plugin-contexts-eval should register 1 visitor, got %d", plugin.Visitors) + } +} + +func TestJSPluginLoader_CheckVariableReplacementsWithContextsEvalVisitor(t *testing.T) { + pluginDir := findTestDataPluginDir() + if pluginDir == "" { + t.Skip("Test data plugin directory not found") + } + + rt, err := NewNodeJSRuntime() + if err != nil { + t.Fatalf("NewNodeJSRuntime failed: %v", err) + } + + err = rt.Start() + if err != nil { + t.Fatalf("Start failed: %v", err) + } + defer rt.Stop() + + loader := NewJSPluginLoader(rt) + + pluginPath := filepath.Join(pluginDir, "plugin-contexts-eval-visit.js") + result := loader.LoadPlugin(pluginPath, pluginDir, nil, nil, nil) + plugin, ok := result.(*Plugin) + if !ok { + t.Fatalf("Expected *Plugin, got %T: %v", result, result) + } + if plugin == nil { + t.Fatal("Plugin is nil") + } + if plugin.Visitors != 1 { + t.Errorf("plugin-contexts-eval-visit should register 1 visitor, got %d", plugin.Visitors) + } + + resp, err := rt.SendCommand(Command{ + Cmd: "checkVariableReplacements", + Data: map[string]any{ + "variables": []any{ + map[string]any{ + "id": "var_1", + "name": "@replace", + }, + }, + }, + }) + if err != nil { + t.Fatalf("checkVariableReplacements command failed: %v", err) + } + if !resp.Success { + t.Fatalf("checkVariableReplacements response failed: %s", resp.Error) + } + + resultMap, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("Expected map result, got %T", resp.Result) + } + replacements, ok := resultMap["replacements"].(map[string]any) + if !ok { + t.Fatalf("Expected replacements map, got %T", resultMap["replacements"]) + } + replAny, ok := replacements["var_1"] + if !ok { + t.Fatalf("Expected replacement for var_1, got: %#v", replacements) + } + repl, ok := replAny.(map[string]any) + if !ok { + t.Fatalf("Expected replacement map, got %T", replAny) + } + + if typ, _ := repl["_type"].(string); typ != "Dimension" { + t.Fatalf("Expected replacement type Dimension, got %#v", repl["_type"]) + } + if unit, _ := repl["unit"].(string); unit != "px" { + t.Fatalf("Expected replacement unit px, got %#v", repl["unit"]) + } +} + func TestJSPluginLoader_LoadGlobalPlugin(t *testing.T) { pluginDir := findTestDataPluginDir() if pluginDir == "" { diff --git a/npm/darwin-arm64/bin/plugin-host.js b/npm/darwin-arm64/bin/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/npm/darwin-arm64/bin/plugin-host.js +++ b/npm/darwin-arm64/bin/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/npm/darwin-x64/bin/plugin-host.js b/npm/darwin-x64/bin/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/npm/darwin-x64/bin/plugin-host.js +++ b/npm/darwin-x64/bin/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/npm/linux-arm64/bin/plugin-host.js b/npm/linux-arm64/bin/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/npm/linux-arm64/bin/plugin-host.js +++ b/npm/linux-arm64/bin/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/npm/linux-x64/bin/plugin-host.js b/npm/linux-x64/bin/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/npm/linux-x64/bin/plugin-host.js +++ b/npm/linux-x64/bin/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/npm/win32-arm64/bin/plugin-host.js b/npm/win32-arm64/bin/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/npm/win32-arm64/bin/plugin-host.js +++ b/npm/win32-arm64/bin/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/npm/win32-x64/bin/plugin-host.js b/npm/win32-x64/bin/plugin-host.js index 88b0980b4..500ada6d0 100644 --- a/npm/win32-x64/bin/plugin-host.js +++ b/npm/win32-x64/bin/plugin-host.js @@ -509,12 +509,81 @@ function createNode(type, props = {}) { }; } +const evalCopyProperties = [ + 'paths', + 'compress', + 'math', + 'strictUnits', + 'sourceMap', + 'importMultiple', + 'urlArgs', + 'javascriptEnabled', + 'pluginManager', + 'importantScope', + 'rewriteUrls', +]; + +function copyContextProperties(source, destination, properties) { + if (!source) return; + for (const prop of properties) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + destination[prop] = source[prop]; + } + } +} + +const LessMath = { + ALWAYS: 0, + PARENS_DIVISION: 1, + PARENS: 2, +}; + +const RewriteUrls = { + OFF: 0, + LOCAL: 1, + ALL: 2, +}; + +function isPathRelative(path) { + return !/^(?:[a-z-]+:|\/|#)/i.test(path); +} + +function isPathLocalRelative(path) { + return path && path.charAt(0) === '.'; +} + +function unitToString(unit) { + if (unit === null || unit === undefined) { + return ''; + } + + if (typeof unit === 'string') { + return unit; + } + + if (typeof unit === 'object') { + const numerator = Array.isArray(unit.numerator) ? unit.numerator : []; + const denominator = Array.isArray(unit.denominator) ? unit.denominator : []; + + if (numerator.length === 1 && denominator.length === 0) { + return numerator[0]; + } + + const base = numerator.join('*'); + const den = denominator.map((d) => `/${d}`).join(''); + const fallback = typeof unit.backupUnit === 'string' ? unit.backupUnit : ''; + return `${base}${den}` || fallback; + } + + return String(unit); +} + // Less API with node constructors const less = { version: [4, 0, 0], // Node constructors (matching less.js tree API) - dimension: (value, unit) => createNode('Dimension', { value, unit: unit || '' }), + dimension: (value, unit) => createNode('Dimension', { value, unit: unitToString(unit) }), color: (rgb, alpha) => { // Handle both [r,g,b] array and {r,g,b} object if (Array.isArray(rgb)) { @@ -569,6 +638,20 @@ const less = { }, }, + // Context namespace (for compatibility with plugins that use contexts.Eval) + contexts: { + Eval: function Eval(options, frames) { + copyContextProperties(options, this, evalCopyProperties); + + if (typeof this.paths === 'string') { + this.paths = [this.paths]; + } + + this.frames = frames || []; + this.importantScope = this.importantScope || []; + }, + }, + // Tree namespace (for compatibility with plugins that use less.tree) tree: { Anonymous: function (value) { @@ -581,7 +664,29 @@ const less = { if (isNaN(numValue)) { throw new Error('Dimension is not a number.'); } - return createNode('Dimension', { value: numValue, unit: unit || '' }); + return createNode('Dimension', { value: numValue, unit: unitToString(unit) }); + }, + Comment: function (value, isLineComment, index, currentFileInfo) { + return createNode('Comment', { + value: value || '', + isLineComment: !!isLineComment, + _index: index, + _fileInfo: currentFileInfo, + allowRoot: true, + }); + }, + Unit: function (numerator, denominator, backupUnit) { + const num = Array.isArray(numerator) ? [...numerator].sort() : []; + const den = Array.isArray(denominator) ? [...denominator].sort() : []; + const node = createNode('Unit', { numerator: num, denominator: den }); + + if (backupUnit) { + node.backupUnit = backupUnit; + } else if (num.length) { + node.backupUnit = num[0]; + } + + return node; }, Color: function (rgb, alpha, originalForm) { let rgbArray; @@ -696,6 +801,90 @@ const less = { }, }; +less.contexts.Eval.prototype.enterCalc = function () { + if (!this.calcStack) { + this.calcStack = []; + } + this.calcStack.push(true); + this.inCalc = true; +}; + +less.contexts.Eval.prototype.exitCalc = function () { + this.calcStack.pop(); + if (!this.calcStack.length) { + this.inCalc = false; + } +}; + +less.contexts.Eval.prototype.inParenthesis = function () { + if (!this.parensStack) { + this.parensStack = []; + } + this.parensStack.push(true); +}; + +less.contexts.Eval.prototype.outOfParenthesis = function () { + this.parensStack.pop(); +}; + +less.contexts.Eval.prototype.inCalc = false; +less.contexts.Eval.prototype.mathOn = true; +less.contexts.Eval.prototype.isMathOn = function (op) { + if (!this.mathOn) { + return false; + } + if (op === '/' && this.math !== LessMath.ALWAYS && (!this.parensStack || !this.parensStack.length)) { + return false; + } + if (this.math > LessMath.PARENS_DIVISION) { + return this.parensStack && this.parensStack.length; + } + return true; +}; + +less.contexts.Eval.prototype.pathRequiresRewrite = function (path) { + const isRelative = this.rewriteUrls === RewriteUrls.LOCAL ? isPathLocalRelative : isPathRelative; + return isRelative(path); +}; + +less.contexts.Eval.prototype.rewritePath = function (path, rootpath) { + rootpath = rootpath || ''; + let newPath = this.normalizePath(rootpath + path); + + // Keep explicit local-relative paths local-relative after rewrite. + if (isPathLocalRelative(path) && isPathRelative(rootpath) && isPathLocalRelative(newPath) === false) { + newPath = `./${newPath}`; + } + + return newPath; +}; + +less.contexts.Eval.prototype.normalizePath = function (path) { + const segments = path.split('/').reverse(); + let segment; + const normalized = []; + + while (segments.length !== 0) { + segment = segments.pop(); + switch (segment) { + case '.': + break; + case '..': + if ((normalized.length === 0) || (normalized[normalized.length - 1] === '..')) { + normalized.push(segment); + } else { + normalized.pop(); + } + break; + default: + normalized.push(segment); + break; + } + } + + return normalized.join('/'); +}; + // Create global references for legacy plugins that use `tree` and `functions` directly const tree = less.tree; @@ -711,6 +900,27 @@ tree.Variable.prototype.find = function (frames, callback) { return null; }; +tree.Unit.prototype.toString = function () { + let out = (this.numerator || []).join('*'); + const denominator = this.denominator || []; + for (let i = 0; i < denominator.length; i++) { + out += `/${denominator[i]}`; + } + return out; +}; + +tree.Unit.prototype.is = function (unitString) { + return this.toString().toUpperCase() === String(unitString || '').toUpperCase(); +}; + +tree.Unit.prototype.isEmpty = function () { + return (this.numerator || []).length === 0 && (this.denominator || []).length === 0; +}; + +tree.Unit.prototype.isSingular = function () { + return (this.numerator || []).length <= 1 && (this.denominator || []).length === 0; +}; + // Function registry - now uses scoped function management // Supports context-free function declaration for performance optimization const functionRegistry = { @@ -1559,7 +1769,7 @@ function convertJSResultToGo(result) { switch (nodeType) { case 'Dimension': goNode.value = typeof result.value === 'number' ? result.value : parseFloat(result.value) || 0; - goNode.unit = result.unit || ''; + goNode.unit = unitToString(result.unit); break; case 'Color': @@ -3592,6 +3802,24 @@ function handleCheckVariableReplacements(id, data) { _type: 'Variable', type: 'Variable', name: varInfo.name, + eval(context) { + if (!context || !Array.isArray(context.frames)) { + throw new Error(`variable ${this.name} is undefined`); + } + + for (let i = 0; i < context.frames.length; i++) { + const frame = context.frames[i]; + if (!frame || typeof frame.variable !== 'function') { + continue; + } + const found = frame.variable(this.name); + if (found && found.value !== undefined) { + return found.value; + } + } + + throw new Error(`variable ${this.name} is undefined`); + }, }; // Check each visitor diff --git a/testdata/less/custom/var-javascript.less b/testdata/less/custom/var-javascript.less index f4300d39f..383d81934 100644 --- a/testdata/less/custom/var-javascript.less +++ b/testdata/less/custom/var-javascript.less @@ -64,9 +64,9 @@ length: `"@{text}".length`; } -// JavaScript date/time (simplified) +// JavaScript date/time (fixed for deterministic test output) .js-datetime { - year: `new Date().getFullYear()`; + year: `new Date(2025, 0, 1).getFullYear()`; } // JavaScript Math functions diff --git a/testdata/plugin/plugin-contexts-eval-visit.js b/testdata/plugin/plugin-contexts-eval-visit.js new file mode 100644 index 000000000..b92df98c2 --- /dev/null +++ b/testdata/plugin/plugin-contexts-eval-visit.js @@ -0,0 +1,40 @@ +module.exports = { + install({ tree, contexts, visitors }, manager) { + if (typeof tree.Comment !== 'function') { + throw new Error('tree.Comment must be available'); + } + if (typeof tree.Unit !== 'function') { + throw new Error('tree.Unit must be available'); + } + + // Smoke check constructor compatibility. + new tree.Comment('/* plugin smoke */', false); + + class Visitor { + constructor() { + this.native = new visitors.Visitor(this); + this.isPreEvalVisitor = true; + this.isReplacing = true; + this._context = new contexts.Eval(); + this._context.frames.push({ + variable(name) { + if (name === '@replace') { + return { + value: new tree.Dimension(1, new tree.Unit(['px'])) + }; + } + return undefined; + } + }); + } + + visitVariable(node) { + const evaluated = node.eval(this._context); + return evaluated || node; + } + } + + manager.addVisitor(new Visitor()); + }, + minVersion: [2, 0, 0] +}; diff --git a/testdata/plugin/plugin-contexts-eval.js b/testdata/plugin/plugin-contexts-eval.js new file mode 100644 index 000000000..760d7f3b5 --- /dev/null +++ b/testdata/plugin/plugin-contexts-eval.js @@ -0,0 +1,23 @@ +module.exports = { + install({ contexts, visitors }, manager) { + if (!contexts || typeof contexts.Eval !== 'function') { + throw new Error('contexts.Eval must be available'); + } + + class Visitor { + constructor() { + this.native = new visitors.Visitor(this); + this.isPreEvalVisitor = true; + this.isReplacing = true; + this._context = new contexts.Eval(); + + if (!Array.isArray(this._context.frames)) { + throw new Error('contexts.Eval() must provide frames[]'); + } + } + } + + manager.addVisitor(new Visitor()); + }, + minVersion: [2, 0, 0] +}; From 4e4e54e4e1613e53a97fab6230eaa7071fffe35a Mon Sep 17 00:00:00 2001 From: Tyler Oakleaf Date: Thu, 26 Feb 2026 18:13:59 -0500 Subject: [PATCH 2/2] docs(changeset): Fix JS plugin API compatibility for contexts.Eval visitors --- .changeset/vast-keys-cheat.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/vast-keys-cheat.md diff --git a/.changeset/vast-keys-cheat.md b/.changeset/vast-keys-cheat.md new file mode 100644 index 000000000..1822ed885 --- /dev/null +++ b/.changeset/vast-keys-cheat.md @@ -0,0 +1,13 @@ +--- +'@lessgo/darwin-arm64': patch +'@lessgo/linux-arm64': patch +'@lessgo/win32-arm64': patch +'@lessgo/darwin-x64': patch +'@lessgo/linux-x64': patch +'@lessgo/win32-x64': patch +'lessgo': patch +'@lessgo/plugin-vite': patch +'@less/test-import-module': patch +--- + +Fix JS plugin API compatibility for contexts.Eval visitors