@jesscss/plugin-less 2.0.0-alpha.7 → 2.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # jess-plugin-less
1
+ # @jesscss/plugin-less
2
2
 
3
- Provides the Less parser and evaluator to Jess.
3
+ **The Less language engine for Jess — the Less parser wired in with Less v5
4
+ rendering defaults.**
5
+
6
+ This is the Less language engine behind the shipping alpha surface.
7
+ `plugin-less` layers the Less grammar (`@jesscss/less-parser`) onto the Jess
8
+ compiler, registers the Less built-in functions (`@jesscss/fns`), and sets the
9
+ current output defaults. The `jess` CLI loads it by default, so if you render
10
+ `.less` you are already using it — you don't need to install this package
11
+ separately for normal CLI use.
12
+
13
+ ## What it does
14
+
15
+ - Parses `.less` into the Jess AST and hands it to the engine for a single
16
+ evaluate-and-emit pass.
17
+ - Registers the Less/Sass style-function library so `lighten()`, `percentage()`,
18
+ string and list helpers, etc. are available during evaluation.
19
+ - Owns the current Less-facing output defaults used across the alpha surface.
20
+
21
+ ## Current output defaults
22
+
23
+ The defaults this plugin applies:
24
+
25
+ - `collapseNesting: false` — **nesting is preserved by default.** Less 4.x
26
+ flattened selectors; in v5 that flattening is an explicit opt-in
27
+ (`--collapse-nesting` on the CLI).
28
+ - `mathMode: 'parens-division'`, `unitMode: 'preserve'`, `equalityMode: 'less'`,
29
+ `leakyScope: true`, `bubbleRootAtRules: true`.
30
+
31
+ These keep the current Less-facing surface aligned on one set of output
32
+ semantics.
33
+
34
+ ## Status
35
+
36
+ **Alpha.** This is the shipping Less-facing engine in the current Jess alpha. It
37
+ renders real Less, but it is early software with known rendering gaps and
38
+ expected failures; don't ship it to production yet, and please
39
+ [report bugs](https://github.com/jesscss/jess/issues).
40
+
41
+ The programmatic plugin/compiler API is **not yet stabilized** — the `jess` CLI
42
+ is the documented public surface for the alpha. Watch the
43
+ [docs site](https://jesscss.github.io/) for the API once it settles.
44
+
45
+ - Project overview & positioning: <https://github.com/jesscss/jess#readme>
46
+ - Docs: <https://jesscss.github.io/> (currently pre-alpha content)
47
+ - Issues: <https://github.com/jesscss/jess/issues>
48
+ - License: MIT
package/lib/index.cjs CHANGED
@@ -25,14 +25,129 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
  }) : target, mod));
26
26
  //#endregion
27
27
  let _jesscss_core = require("@jesscss/core");
28
- let _jesscss_fns = require("@jesscss/fns");
29
- _jesscss_fns = __toESM(_jesscss_fns);
30
- let _jesscss_less_parser_jess = require("@jesscss/less-parser/jess");
28
+ let _jesscss_core_value = require("@jesscss/core/value");
31
29
  let node_path = require("node:path");
32
30
  node_path = __toESM(node_path);
33
31
  let node_module = require("node:module");
34
32
  let _jesscss_style_resolver = require("@jesscss/style-resolver");
33
+ let _jesscss_less_parser = require("@jesscss/less-parser");
35
34
  //#region src/index.ts
35
+ const nativeNil = () => {
36
+ const nil = {
37
+ type: "Nil",
38
+ value: "",
39
+ eval: () => nil
40
+ };
41
+ return nil;
42
+ };
43
+ function isRawSequence(value) {
44
+ return Array.isArray(value);
45
+ }
46
+ function isPluginDetached(value) {
47
+ return !isRawSequence(value) && value.type === "DetachedRuleset";
48
+ }
49
+ function toNativeLessValue(value) {
50
+ if (isRawSequence(value)) return {
51
+ type: "Expression",
52
+ value: value.map(toNativeLessValue),
53
+ valueOf: () => (0, _jesscss_core_value.emitValue)(value)
54
+ };
55
+ if (isPluginDetached(value)) {
56
+ const mixin = {
57
+ type: "Mixin",
58
+ name: nativeNil(),
59
+ args: nativeNil(),
60
+ ruleset: { rules: value.rules.map((rule) => ({
61
+ type: "Declaration",
62
+ name: rule.name,
63
+ value: toNativeLessValue(rule.value),
64
+ eval() {
65
+ return this;
66
+ }
67
+ })) },
68
+ eval() {
69
+ return mixin;
70
+ }
71
+ };
72
+ return mixin;
73
+ }
74
+ return toNativeValue(value);
75
+ }
76
+ function toNativeValue(value) {
77
+ switch (value.type) {
78
+ case "Dimension": return {
79
+ type: "Dimension",
80
+ value: value.number,
81
+ unit: value.unit,
82
+ valueOf: () => value.number
83
+ };
84
+ case "Quoted": return {
85
+ type: "Quoted",
86
+ value: value.value,
87
+ quote: value.quote,
88
+ escaped: value.escaped,
89
+ valueOf: () => value.bytes
90
+ };
91
+ case "Color": return {
92
+ type: "Color",
93
+ rgb: value.rgb,
94
+ alpha: value.alpha,
95
+ valueOf: () => value.bytes
96
+ };
97
+ case "List": return value.sep === "," || value.sep === "/" ? {
98
+ type: "Value",
99
+ value: value.value.map(toNativeLessValue),
100
+ separator: value.sep,
101
+ valueOf: () => value.bytes
102
+ } : {
103
+ type: "Anonymous",
104
+ value: value.bytes,
105
+ valueOf: () => value.bytes
106
+ };
107
+ default: return {
108
+ type: "Anonymous",
109
+ value: value.bytes,
110
+ valueOf: () => value.bytes
111
+ };
112
+ }
113
+ }
114
+ function isNativeValue(value) {
115
+ return value !== null && typeof value === "object" && "bytes" in value && typeof value.bytes === "string";
116
+ }
117
+ function fromNativeLessValue(value) {
118
+ if (isNativeValue(value)) return value;
119
+ if (typeof value === "number") return (0, _jesscss_core_value.makeDimension)(value);
120
+ if (typeof value === "string") return (0, _jesscss_core_value.makeKeyword)(value);
121
+ if (value && typeof value === "object") {
122
+ const candidate = value;
123
+ if ((candidate.type === "Dimension" || candidate.type === "Num") && typeof candidate.value === "number") return (0, _jesscss_core_value.makeDimension)(candidate.value, typeof candidate.unit === "string" ? candidate.unit : "");
124
+ if (candidate.type === "Quoted" && typeof candidate.value === "string") return (0, _jesscss_core_value.makeQuoted)(candidate.value, candidate.quote === "'" ? "'" : "\"", candidate.escaped === true);
125
+ if (candidate.type === "Expression" && Array.isArray(candidate.value)) return candidate.value.map(fromNativeLessValue);
126
+ if (candidate.type === "Value" && Array.isArray(candidate.value)) {
127
+ const separator = candidate.separator === "/" ? "/" : ",";
128
+ return (0, _jesscss_core_value.makeList)(candidate.value.map((item) => fromNativeLessValue(item)), separator);
129
+ }
130
+ if (typeof candidate.value === "string") return (0, _jesscss_core_value.makeKeyword)(candidate.value);
131
+ if (typeof candidate.valueOf === "function") return (0, _jesscss_core_value.makeKeyword)(String(candidate.valueOf()));
132
+ }
133
+ return (0, _jesscss_core_value.makeKeyword)(value == null ? "" : String(value));
134
+ }
135
+ function invokeNativeLessFunction(fn, args) {
136
+ const result = fn(...args.map(toNativeLessValue));
137
+ return result !== null && typeof result === "object" && "then" in result && typeof result.then === "function" ? Promise.resolve(result).then(fromNativeLessValue) : fromNativeLessValue(result);
138
+ }
139
+ function nativeLessFn(name, fn) {
140
+ return (0, _jesscss_core_value.defineFunction)(name.toLowerCase(), {
141
+ variadic: true,
142
+ params: [],
143
+ body: (value) => invokeNativeLessFunction(fn, (0, _jesscss_core_value.groupItems)(value))
144
+ });
145
+ }
146
+ function isLoadedPluginModule(value) {
147
+ if (typeof value !== "object" || value === null) return false;
148
+ if (!("functions" in value) || value.functions === void 0) return true;
149
+ return typeof value.functions === "object" && value.functions !== null && Object.values(value.functions).every((fn) => typeof fn === "function");
150
+ }
36
151
  /**
37
152
  * The Less plugin's default option values — the single source of truth for the
38
153
  * v5 defaults. The `LessPlugin` constructor fills any unset option from here,
@@ -48,16 +163,55 @@ const lessPluginDefaults = {
48
163
  bubbleRootAtRules: true,
49
164
  collapseNesting: false
50
165
  };
166
+ /** Match Less's URL normalization without treating URL text as an import path. */
167
+ function normalizeUrlPath(url) {
168
+ const segments = url.split("/");
169
+ const normalized = [];
170
+ for (const segment of segments) {
171
+ if (segment === ".") continue;
172
+ if (segment === "..") {
173
+ if (normalized.length === 0 || normalized[normalized.length - 1] === "..") normalized.push(segment);
174
+ else normalized.pop();
175
+ continue;
176
+ }
177
+ normalized.push(segment);
178
+ }
179
+ return normalized.join("/");
180
+ }
181
+ function isUrlRelative(url) {
182
+ if (url.startsWith("/") || url.startsWith("#")) return false;
183
+ const colon = url.indexOf(":");
184
+ if (colon < 0) return true;
185
+ for (let index = 0; index < colon; index++) {
186
+ const code = url.charCodeAt(index);
187
+ if (!(code >= 65 && code <= 90 || code >= 97 && code <= 122) && url[index] !== "-") return true;
188
+ }
189
+ return false;
190
+ }
191
+ function rewriteUrlPath(url, rootpath) {
192
+ const rewritten = normalizeUrlPath(rootpath + url);
193
+ return url.startsWith(".") && isUrlRelative(rootpath) && !rewritten.startsWith(".") ? `./${rewritten}` : rewritten;
194
+ }
195
+ function escapeUnquotedUrlPath(pathValue) {
196
+ let escaped = "";
197
+ for (const char of pathValue) escaped += char === "(" || char === ")" || char === "'" || char === "\"" || " \n\r\f".includes(char) ? `\\${char}` : char;
198
+ return escaped;
199
+ }
200
+ function jsDelivrPackageSpecifier(candidate) {
201
+ const absolute = candidate.match(/^https?:\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i);
202
+ if (absolute?.[1]) return absolute[1];
203
+ return candidate.match(/^\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i)?.[1] ?? null;
204
+ }
51
205
  var LessPlugin = class extends _jesscss_core.AbstractPlugin {
52
206
  name = "less";
53
207
  supportedExtensions = [".less"];
54
- parser;
55
208
  mathMode;
56
209
  unitMode;
57
210
  equalityMode;
58
211
  leakyScope;
59
212
  bubbleRootAtRules;
60
213
  collapseNesting;
214
+ pluginHosts = /* @__PURE__ */ new WeakMap();
61
215
  constructor(opts = {}) {
62
216
  super();
63
217
  this.opts = opts;
@@ -78,41 +232,96 @@ var LessPlugin = class extends _jesscss_core.AbstractPlugin {
78
232
  this.leakyScope = opts.leakyScope ?? lessPluginDefaults.leakyScope;
79
233
  this.bubbleRootAtRules = opts.bubbleRootAtRules ?? lessPluginDefaults.bubbleRootAtRules;
80
234
  this.collapseNesting = opts.collapseNesting ?? lessPluginDefaults.collapseNesting;
81
- this.parser = new _jesscss_less_parser_jess.Parser();
82
- }
83
- createTreeContext(filePath, source) {
84
- return new _jesscss_core.TreeContext({
85
- file: {
86
- name: node_path.default.basename(filePath),
87
- path: node_path.default.dirname(filePath),
88
- fullPath: filePath,
89
- source
90
- },
91
- mathMode: this.mathMode,
92
- unitMode: this.unitMode,
93
- equalityMode: this.equalityMode,
94
- plugin: this,
95
- allowExtendSelectors: this.opts.allowExtendSelectors,
96
- collapseNesting: this.collapseNesting,
97
- leakyScope: this.leakyScope,
98
- bubbleRootAtRules: this.bubbleRootAtRules
99
- });
100
235
  }
101
- _registerFunctions(tree) {
102
- const registeredNames = [];
103
- for (const [key, value] of Object.entries(_jesscss_fns)) {
104
- if (typeof value !== "function") continue;
105
- const runtimeName = value.name || key;
106
- tree.setFunctionBinding(runtimeName, new _jesscss_core.JsFunction({
107
- name: runtimeName,
108
- fn: value
109
- }));
110
- registeredNames.push(runtimeName);
236
+ transformUrl({ value, quoted, fromFilePath, entryFilePath }) {
237
+ let transformed;
238
+ if (isUrlRelative(value)) {
239
+ const rewriteUrls = this.opts.rewriteUrls;
240
+ const local = value.startsWith(".");
241
+ if (rewriteUrls !== "local" || local) {
242
+ const rebasesImportedUrl = rewriteUrls === true || rewriteUrls === "all" || rewriteUrls === "local" && local;
243
+ let rootpath = this.opts.rootpath ?? "";
244
+ if (!quoted) rootpath = escapeUnquotedUrlPath(rootpath);
245
+ if (rebasesImportedUrl && fromFilePath && entryFilePath) {
246
+ const relativeDirectory = node_path.default.relative(node_path.default.dirname(entryFilePath), node_path.default.dirname(fromFilePath));
247
+ if (relativeDirectory) rootpath += `${relativeDirectory.split(node_path.default.sep).join("/")}/`;
248
+ }
249
+ transformed = rewriteUrlPath(value, rootpath);
250
+ } else transformed = normalizeUrlPath(value);
251
+ } else transformed = normalizeUrlPath(value);
252
+ if (this.opts.urlArgs && !value.trimStart().toLowerCase().startsWith("data:")) {
253
+ const args = `${transformed.includes("?") ? "&" : "?"}${this.opts.urlArgs}`;
254
+ const fragment = transformed.indexOf("#");
255
+ transformed = fragment < 0 ? transformed + args : transformed.slice(0, fragment) + args + transformed.slice(fragment);
111
256
  }
257
+ return transformed;
112
258
  }
113
259
  expandImport(importPath, currentDir) {
114
260
  return (0, _jesscss_style_resolver.expandLessImportCandidates)(importPath);
115
261
  }
262
+ setContext(context) {
263
+ if (context.opts.mathMode === void 0) context.setOption("mathMode", this.mathMode);
264
+ if (context.opts.unitMode === void 0) context.setOption("unitMode", this.unitMode);
265
+ if (context.opts.equalityMode === void 0) context.setOption("equalityMode", this.equalityMode);
266
+ if (context.opts.leakyScope === void 0) context.setOption("leakyScope", this.leakyScope);
267
+ if (context.opts.bubbleRootAtRules === void 0) context.setOption("bubbleRootAtRules", this.bubbleRootAtRules);
268
+ let host = this.pluginHosts.get(context);
269
+ if (!host) {
270
+ const fns = [];
271
+ const nativeFns = /* @__PURE__ */ new WeakMap();
272
+ const addNativeFn = (name, fn) => {
273
+ const adapted = nativeLessFn(name, fn);
274
+ nativeFns.set(adapted, fn);
275
+ fns.push(adapted);
276
+ return adapted;
277
+ };
278
+ const registry = {
279
+ add: (name, fn) => {
280
+ addNativeFn(name, fn);
281
+ },
282
+ addMultiple: (functions) => {
283
+ for (const [name, fn] of Object.entries(functions)) registry.add(name, fn);
284
+ }
285
+ };
286
+ const less = {
287
+ functions: { functionRegistry: registry },
288
+ tree: {
289
+ Dimension: class {
290
+ type = "Dimension";
291
+ constructor(value, unit = "") {
292
+ this.value = value;
293
+ this.unit = unit;
294
+ }
295
+ },
296
+ Quoted: class {
297
+ type = "Quoted";
298
+ constructor(quote, value, escaped = false) {
299
+ this.quote = quote;
300
+ this.value = value;
301
+ this.escaped = escaped;
302
+ }
303
+ }
304
+ }
305
+ };
306
+ const configured = this.opts.plugins ?? [];
307
+ for (const plugin of configured) plugin.install?.(less, void 0, registry);
308
+ host = {
309
+ ...fns.length === 0 ? {} : { globalFns: fns },
310
+ loadPlugin: async ({ specifier, options }) => {
311
+ const loaded = await context.getPluginModule(specifier, options);
312
+ const functions = isLoadedPluginModule(loaded.module) ? loaded.module.functions : void 0;
313
+ if (!functions) return [];
314
+ return Object.entries(functions).map(([name, fn]) => addNativeFn(name, fn));
315
+ },
316
+ invokeRawFunction: (fn, args) => {
317
+ const native = nativeFns.get(fn);
318
+ return native ? invokeNativeLessFunction(native, args) : void 0;
319
+ }
320
+ };
321
+ this.pluginHosts.set(context, host);
322
+ }
323
+ context.pluginHost = host;
324
+ }
116
325
  resolve(filePath, currentDir, searchPaths) {
117
326
  const mapped = (Array.isArray(filePath) ? filePath : [filePath]).map((candidate) => {
118
327
  if (candidate.startsWith("@less/test-import-module/")) {
@@ -124,11 +333,7 @@ var LessPlugin = class extends _jesscss_core.AbstractPlugin {
124
333
  return node_path.default.join(packagesRoot, "test-import-module", after);
125
334
  }
126
335
  }
127
- const m = candidate.match(/^https?:\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i);
128
- if (m?.[1]) return m[1];
129
- const mProtocolRelative = candidate.match(/^\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i);
130
- if (mProtocolRelative?.[1]) return mProtocolRelative[1];
131
- return candidate;
336
+ return jsDelivrPackageSpecifier(candidate) ?? candidate;
132
337
  });
133
338
  const out = [...super.resolve(mapped, currentDir, searchPaths)];
134
339
  const bases = [
@@ -156,72 +361,27 @@ var LessPlugin = class extends _jesscss_core.AbstractPlugin {
156
361
  }
157
362
  return out;
158
363
  }
159
- safeParse(filePath, source, _parseOptions) {
160
- const context = this.createTreeContext(filePath, source);
161
- const errors = [];
162
- const warnings = [];
163
- let tree;
364
+ canResolveImport(specifier) {
365
+ return jsDelivrPackageSpecifier(specifier) !== null;
366
+ }
367
+ safeParse(filePath, source, parseOptions) {
164
368
  try {
165
- const parseResult = this.parser.parse(source, "Stylesheet", { context });
166
- tree = parseResult.tree;
167
- const parsedContext = parseResult.context ?? context;
168
- if (tree) tree._treeContext = parsedContext;
169
- context.opts.trivia = parseResult.trivia;
170
- context.opts.liftedCommentRanges = parseResult.liftedCommentRanges;
171
- if ("warnings" in parseResult && parseResult.warnings) for (const warning of parseResult.warnings) {
172
- const line = warning.token?.startLine ?? 1;
173
- const column = warning.token?.startColumn ?? 1;
174
- warnings.push({
175
- code: "parse/deprecated",
176
- phase: "parse",
177
- message: warning.message,
178
- reason: warning.message,
179
- fix: "Update your code to use the recommended syntax.",
180
- file: context.file,
181
- filePath,
182
- line,
183
- column,
184
- lines: (0, _jesscss_core.extractRelevantLines)(source, line)
185
- });
186
- }
187
- if (parseResult.errors.length) for (const error of parseResult.errors) {
188
- const line = error.token?.startLine ?? 1;
189
- const diagnostic = (0, _jesscss_core.toDiagnostic)((0, _jesscss_core.getErrorFromParser)([error], void 0, filePath, source, { file: context.file }));
190
- if (!diagnostic.lines) diagnostic.lines = (0, _jesscss_core.extractRelevantLines)(source, line);
191
- if ("errors" in diagnostic) errors.push(diagnostic);
192
- else warnings.push(diagnostic);
193
- }
369
+ return {
370
+ document: (0, _jesscss_less_parser.parse)(source),
371
+ errors: [],
372
+ warnings: []
373
+ };
194
374
  } catch (error) {
195
- if (error instanceof _jesscss_core.JessError) {
196
- const diagnostic = (0, _jesscss_core.toDiagnostic)(error);
197
- if ("errors" in diagnostic) errors.push(diagnostic);
198
- else warnings.push(diagnostic);
199
- } else {
200
- const message = error instanceof Error ? error.message : "Unknown parsing error";
201
- errors.push({
202
- code: "internal/unknown",
203
- phase: "parse",
204
- message,
205
- reason: message || "An unexpected error occurred during parsing.",
206
- fix: "Check the file syntax and ensure it is valid.",
207
- file: context.file,
208
- filePath,
209
- line: 1,
210
- column: 1,
211
- lines: (0, _jesscss_core.extractRelevantLines)(source, 1)
212
- });
213
- }
214
375
  return {
215
- errors,
216
- warnings
376
+ errors: [(0, _jesscss_core.parserDiagnostic)({
377
+ dialect: "Less",
378
+ error,
379
+ filePath,
380
+ source
381
+ })],
382
+ warnings: []
217
383
  };
218
384
  }
219
- if (tree && errors.length === 0) this._registerFunctions(tree);
220
- return {
221
- tree,
222
- errors,
223
- warnings
224
- };
225
385
  }
226
386
  };
227
387
  const lessPlugin = ((opts) => {
package/lib/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { AbstractPlugin, type ISafeParseResult, type SafeParseOptions } from '@jesscss/core';
1
+ import { AbstractPlugin, type Context, type UrlTransformRequest, type ISafeParseResult, type SafeParseOptions } from '@jesscss/core';
2
2
  import type { EqualityMode, MathMode, UnitMode, LessOptions } from 'styles-config';
3
- import { Parser } from '@jesscss/less-parser/jess';
4
3
  export type LessPluginOptions = LessOptions;
5
4
  /**
6
5
  * The Less plugin's default option values — the single source of truth for the
@@ -21,19 +20,20 @@ export declare class LessPlugin extends AbstractPlugin {
21
20
  opts: LessPluginOptions;
22
21
  name: string;
23
22
  supportedExtensions: string[];
24
- parser: Parser;
25
23
  mathMode: MathMode;
26
24
  unitMode: UnitMode;
27
25
  equalityMode: EqualityMode;
28
26
  leakyScope: boolean;
29
27
  bubbleRootAtRules: boolean;
30
28
  collapseNesting: boolean;
29
+ private readonly pluginHosts;
31
30
  constructor(opts?: LessPluginOptions);
32
- private createTreeContext;
33
- private _registerFunctions;
31
+ transformUrl({ value, quoted, fromFilePath, entryFilePath }: UrlTransformRequest): string;
34
32
  expandImport(importPath: string, currentDir: string): string[];
33
+ setContext(context: Context): void;
35
34
  resolve(filePath: string | string[], currentDir: string, searchPaths: string[]): string[];
36
- safeParse(filePath: string, source: string, _parseOptions?: SafeParseOptions): ISafeParseResult;
35
+ canResolveImport(specifier: string): boolean;
36
+ safeParse(filePath: string, source: string, parseOptions?: SafeParseOptions): ISafeParseResult;
37
37
  }
38
38
  export type { LessOptions } from 'styles-config';
39
39
  declare const lessPlugin: (opts?: LessPluginOptions) => LessPlugin;
package/lib/index.js CHANGED
@@ -1,10 +1,126 @@
1
1
  import { createRequire } from "node:module";
2
- import { AbstractPlugin, JessError, JsFunction, TreeContext, extractRelevantLines, getErrorFromParser, toDiagnostic } from "@jesscss/core";
3
- import * as lessFunctions from "@jesscss/fns";
4
- import { Parser } from "@jesscss/less-parser/jess";
2
+ import { AbstractPlugin, parserDiagnostic } from "@jesscss/core";
3
+ import { defineFunction, emitValue, groupItems, makeDimension, makeKeyword, makeList, makeQuoted } from "@jesscss/core/value";
5
4
  import path from "node:path";
6
5
  import { expandLessImportCandidates } from "@jesscss/style-resolver";
6
+ import { parse } from "@jesscss/less-parser";
7
7
  //#region src/index.ts
8
+ const nativeNil = () => {
9
+ const nil = {
10
+ type: "Nil",
11
+ value: "",
12
+ eval: () => nil
13
+ };
14
+ return nil;
15
+ };
16
+ function isRawSequence(value) {
17
+ return Array.isArray(value);
18
+ }
19
+ function isPluginDetached(value) {
20
+ return !isRawSequence(value) && value.type === "DetachedRuleset";
21
+ }
22
+ function toNativeLessValue(value) {
23
+ if (isRawSequence(value)) return {
24
+ type: "Expression",
25
+ value: value.map(toNativeLessValue),
26
+ valueOf: () => emitValue(value)
27
+ };
28
+ if (isPluginDetached(value)) {
29
+ const mixin = {
30
+ type: "Mixin",
31
+ name: nativeNil(),
32
+ args: nativeNil(),
33
+ ruleset: { rules: value.rules.map((rule) => ({
34
+ type: "Declaration",
35
+ name: rule.name,
36
+ value: toNativeLessValue(rule.value),
37
+ eval() {
38
+ return this;
39
+ }
40
+ })) },
41
+ eval() {
42
+ return mixin;
43
+ }
44
+ };
45
+ return mixin;
46
+ }
47
+ return toNativeValue(value);
48
+ }
49
+ function toNativeValue(value) {
50
+ switch (value.type) {
51
+ case "Dimension": return {
52
+ type: "Dimension",
53
+ value: value.number,
54
+ unit: value.unit,
55
+ valueOf: () => value.number
56
+ };
57
+ case "Quoted": return {
58
+ type: "Quoted",
59
+ value: value.value,
60
+ quote: value.quote,
61
+ escaped: value.escaped,
62
+ valueOf: () => value.bytes
63
+ };
64
+ case "Color": return {
65
+ type: "Color",
66
+ rgb: value.rgb,
67
+ alpha: value.alpha,
68
+ valueOf: () => value.bytes
69
+ };
70
+ case "List": return value.sep === "," || value.sep === "/" ? {
71
+ type: "Value",
72
+ value: value.value.map(toNativeLessValue),
73
+ separator: value.sep,
74
+ valueOf: () => value.bytes
75
+ } : {
76
+ type: "Anonymous",
77
+ value: value.bytes,
78
+ valueOf: () => value.bytes
79
+ };
80
+ default: return {
81
+ type: "Anonymous",
82
+ value: value.bytes,
83
+ valueOf: () => value.bytes
84
+ };
85
+ }
86
+ }
87
+ function isNativeValue(value) {
88
+ return value !== null && typeof value === "object" && "bytes" in value && typeof value.bytes === "string";
89
+ }
90
+ function fromNativeLessValue(value) {
91
+ if (isNativeValue(value)) return value;
92
+ if (typeof value === "number") return makeDimension(value);
93
+ if (typeof value === "string") return makeKeyword(value);
94
+ if (value && typeof value === "object") {
95
+ const candidate = value;
96
+ if ((candidate.type === "Dimension" || candidate.type === "Num") && typeof candidate.value === "number") return makeDimension(candidate.value, typeof candidate.unit === "string" ? candidate.unit : "");
97
+ if (candidate.type === "Quoted" && typeof candidate.value === "string") return makeQuoted(candidate.value, candidate.quote === "'" ? "'" : "\"", candidate.escaped === true);
98
+ if (candidate.type === "Expression" && Array.isArray(candidate.value)) return candidate.value.map(fromNativeLessValue);
99
+ if (candidate.type === "Value" && Array.isArray(candidate.value)) {
100
+ const separator = candidate.separator === "/" ? "/" : ",";
101
+ return makeList(candidate.value.map((item) => fromNativeLessValue(item)), separator);
102
+ }
103
+ if (typeof candidate.value === "string") return makeKeyword(candidate.value);
104
+ if (typeof candidate.valueOf === "function") return makeKeyword(String(candidate.valueOf()));
105
+ }
106
+ return makeKeyword(value == null ? "" : String(value));
107
+ }
108
+ function invokeNativeLessFunction(fn, args) {
109
+ const result = fn(...args.map(toNativeLessValue));
110
+ return result !== null && typeof result === "object" && "then" in result && typeof result.then === "function" ? Promise.resolve(result).then(fromNativeLessValue) : fromNativeLessValue(result);
111
+ }
112
+ function nativeLessFn(name, fn) {
113
+ return defineFunction(name.toLowerCase(), {
114
+ variadic: true,
115
+ params: [],
116
+ body: (value) => invokeNativeLessFunction(fn, groupItems(value))
117
+ });
118
+ }
119
+ function isLoadedPluginModule(value) {
120
+ if (typeof value !== "object" || value === null) return false;
121
+ if (!("functions" in value) || value.functions === void 0) return true;
122
+ return typeof value.functions === "object" && value.functions !== null && Object.values(value.functions).every((fn) => typeof fn === "function");
123
+ }
8
124
  /**
9
125
  * The Less plugin's default option values — the single source of truth for the
10
126
  * v5 defaults. The `LessPlugin` constructor fills any unset option from here,
@@ -20,16 +136,55 @@ const lessPluginDefaults = {
20
136
  bubbleRootAtRules: true,
21
137
  collapseNesting: false
22
138
  };
139
+ /** Match Less's URL normalization without treating URL text as an import path. */
140
+ function normalizeUrlPath(url) {
141
+ const segments = url.split("/");
142
+ const normalized = [];
143
+ for (const segment of segments) {
144
+ if (segment === ".") continue;
145
+ if (segment === "..") {
146
+ if (normalized.length === 0 || normalized[normalized.length - 1] === "..") normalized.push(segment);
147
+ else normalized.pop();
148
+ continue;
149
+ }
150
+ normalized.push(segment);
151
+ }
152
+ return normalized.join("/");
153
+ }
154
+ function isUrlRelative(url) {
155
+ if (url.startsWith("/") || url.startsWith("#")) return false;
156
+ const colon = url.indexOf(":");
157
+ if (colon < 0) return true;
158
+ for (let index = 0; index < colon; index++) {
159
+ const code = url.charCodeAt(index);
160
+ if (!(code >= 65 && code <= 90 || code >= 97 && code <= 122) && url[index] !== "-") return true;
161
+ }
162
+ return false;
163
+ }
164
+ function rewriteUrlPath(url, rootpath) {
165
+ const rewritten = normalizeUrlPath(rootpath + url);
166
+ return url.startsWith(".") && isUrlRelative(rootpath) && !rewritten.startsWith(".") ? `./${rewritten}` : rewritten;
167
+ }
168
+ function escapeUnquotedUrlPath(pathValue) {
169
+ let escaped = "";
170
+ for (const char of pathValue) escaped += char === "(" || char === ")" || char === "'" || char === "\"" || " \n\r\f".includes(char) ? `\\${char}` : char;
171
+ return escaped;
172
+ }
173
+ function jsDelivrPackageSpecifier(candidate) {
174
+ const absolute = candidate.match(/^https?:\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i);
175
+ if (absolute?.[1]) return absolute[1];
176
+ return candidate.match(/^\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i)?.[1] ?? null;
177
+ }
23
178
  var LessPlugin = class extends AbstractPlugin {
24
179
  name = "less";
25
180
  supportedExtensions = [".less"];
26
- parser;
27
181
  mathMode;
28
182
  unitMode;
29
183
  equalityMode;
30
184
  leakyScope;
31
185
  bubbleRootAtRules;
32
186
  collapseNesting;
187
+ pluginHosts = /* @__PURE__ */ new WeakMap();
33
188
  constructor(opts = {}) {
34
189
  super();
35
190
  this.opts = opts;
@@ -50,41 +205,96 @@ var LessPlugin = class extends AbstractPlugin {
50
205
  this.leakyScope = opts.leakyScope ?? lessPluginDefaults.leakyScope;
51
206
  this.bubbleRootAtRules = opts.bubbleRootAtRules ?? lessPluginDefaults.bubbleRootAtRules;
52
207
  this.collapseNesting = opts.collapseNesting ?? lessPluginDefaults.collapseNesting;
53
- this.parser = new Parser();
54
- }
55
- createTreeContext(filePath, source) {
56
- return new TreeContext({
57
- file: {
58
- name: path.basename(filePath),
59
- path: path.dirname(filePath),
60
- fullPath: filePath,
61
- source
62
- },
63
- mathMode: this.mathMode,
64
- unitMode: this.unitMode,
65
- equalityMode: this.equalityMode,
66
- plugin: this,
67
- allowExtendSelectors: this.opts.allowExtendSelectors,
68
- collapseNesting: this.collapseNesting,
69
- leakyScope: this.leakyScope,
70
- bubbleRootAtRules: this.bubbleRootAtRules
71
- });
72
208
  }
73
- _registerFunctions(tree) {
74
- const registeredNames = [];
75
- for (const [key, value] of Object.entries(lessFunctions)) {
76
- if (typeof value !== "function") continue;
77
- const runtimeName = value.name || key;
78
- tree.setFunctionBinding(runtimeName, new JsFunction({
79
- name: runtimeName,
80
- fn: value
81
- }));
82
- registeredNames.push(runtimeName);
209
+ transformUrl({ value, quoted, fromFilePath, entryFilePath }) {
210
+ let transformed;
211
+ if (isUrlRelative(value)) {
212
+ const rewriteUrls = this.opts.rewriteUrls;
213
+ const local = value.startsWith(".");
214
+ if (rewriteUrls !== "local" || local) {
215
+ const rebasesImportedUrl = rewriteUrls === true || rewriteUrls === "all" || rewriteUrls === "local" && local;
216
+ let rootpath = this.opts.rootpath ?? "";
217
+ if (!quoted) rootpath = escapeUnquotedUrlPath(rootpath);
218
+ if (rebasesImportedUrl && fromFilePath && entryFilePath) {
219
+ const relativeDirectory = path.relative(path.dirname(entryFilePath), path.dirname(fromFilePath));
220
+ if (relativeDirectory) rootpath += `${relativeDirectory.split(path.sep).join("/")}/`;
221
+ }
222
+ transformed = rewriteUrlPath(value, rootpath);
223
+ } else transformed = normalizeUrlPath(value);
224
+ } else transformed = normalizeUrlPath(value);
225
+ if (this.opts.urlArgs && !value.trimStart().toLowerCase().startsWith("data:")) {
226
+ const args = `${transformed.includes("?") ? "&" : "?"}${this.opts.urlArgs}`;
227
+ const fragment = transformed.indexOf("#");
228
+ transformed = fragment < 0 ? transformed + args : transformed.slice(0, fragment) + args + transformed.slice(fragment);
83
229
  }
230
+ return transformed;
84
231
  }
85
232
  expandImport(importPath, currentDir) {
86
233
  return expandLessImportCandidates(importPath);
87
234
  }
235
+ setContext(context) {
236
+ if (context.opts.mathMode === void 0) context.setOption("mathMode", this.mathMode);
237
+ if (context.opts.unitMode === void 0) context.setOption("unitMode", this.unitMode);
238
+ if (context.opts.equalityMode === void 0) context.setOption("equalityMode", this.equalityMode);
239
+ if (context.opts.leakyScope === void 0) context.setOption("leakyScope", this.leakyScope);
240
+ if (context.opts.bubbleRootAtRules === void 0) context.setOption("bubbleRootAtRules", this.bubbleRootAtRules);
241
+ let host = this.pluginHosts.get(context);
242
+ if (!host) {
243
+ const fns = [];
244
+ const nativeFns = /* @__PURE__ */ new WeakMap();
245
+ const addNativeFn = (name, fn) => {
246
+ const adapted = nativeLessFn(name, fn);
247
+ nativeFns.set(adapted, fn);
248
+ fns.push(adapted);
249
+ return adapted;
250
+ };
251
+ const registry = {
252
+ add: (name, fn) => {
253
+ addNativeFn(name, fn);
254
+ },
255
+ addMultiple: (functions) => {
256
+ for (const [name, fn] of Object.entries(functions)) registry.add(name, fn);
257
+ }
258
+ };
259
+ const less = {
260
+ functions: { functionRegistry: registry },
261
+ tree: {
262
+ Dimension: class {
263
+ type = "Dimension";
264
+ constructor(value, unit = "") {
265
+ this.value = value;
266
+ this.unit = unit;
267
+ }
268
+ },
269
+ Quoted: class {
270
+ type = "Quoted";
271
+ constructor(quote, value, escaped = false) {
272
+ this.quote = quote;
273
+ this.value = value;
274
+ this.escaped = escaped;
275
+ }
276
+ }
277
+ }
278
+ };
279
+ const configured = this.opts.plugins ?? [];
280
+ for (const plugin of configured) plugin.install?.(less, void 0, registry);
281
+ host = {
282
+ ...fns.length === 0 ? {} : { globalFns: fns },
283
+ loadPlugin: async ({ specifier, options }) => {
284
+ const loaded = await context.getPluginModule(specifier, options);
285
+ const functions = isLoadedPluginModule(loaded.module) ? loaded.module.functions : void 0;
286
+ if (!functions) return [];
287
+ return Object.entries(functions).map(([name, fn]) => addNativeFn(name, fn));
288
+ },
289
+ invokeRawFunction: (fn, args) => {
290
+ const native = nativeFns.get(fn);
291
+ return native ? invokeNativeLessFunction(native, args) : void 0;
292
+ }
293
+ };
294
+ this.pluginHosts.set(context, host);
295
+ }
296
+ context.pluginHost = host;
297
+ }
88
298
  resolve(filePath, currentDir, searchPaths) {
89
299
  const mapped = (Array.isArray(filePath) ? filePath : [filePath]).map((candidate) => {
90
300
  if (candidate.startsWith("@less/test-import-module/")) {
@@ -96,11 +306,7 @@ var LessPlugin = class extends AbstractPlugin {
96
306
  return path.join(packagesRoot, "test-import-module", after);
97
307
  }
98
308
  }
99
- const m = candidate.match(/^https?:\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i);
100
- if (m?.[1]) return m[1];
101
- const mProtocolRelative = candidate.match(/^\/\/cdn\.jsdelivr\.net\/npm\/([^?#]+)(?:[?#].*)?$/i);
102
- if (mProtocolRelative?.[1]) return mProtocolRelative[1];
103
- return candidate;
309
+ return jsDelivrPackageSpecifier(candidate) ?? candidate;
104
310
  });
105
311
  const out = [...super.resolve(mapped, currentDir, searchPaths)];
106
312
  const bases = [
@@ -128,72 +334,27 @@ var LessPlugin = class extends AbstractPlugin {
128
334
  }
129
335
  return out;
130
336
  }
131
- safeParse(filePath, source, _parseOptions) {
132
- const context = this.createTreeContext(filePath, source);
133
- const errors = [];
134
- const warnings = [];
135
- let tree;
337
+ canResolveImport(specifier) {
338
+ return jsDelivrPackageSpecifier(specifier) !== null;
339
+ }
340
+ safeParse(filePath, source, parseOptions) {
136
341
  try {
137
- const parseResult = this.parser.parse(source, "Stylesheet", { context });
138
- tree = parseResult.tree;
139
- const parsedContext = parseResult.context ?? context;
140
- if (tree) tree._treeContext = parsedContext;
141
- context.opts.trivia = parseResult.trivia;
142
- context.opts.liftedCommentRanges = parseResult.liftedCommentRanges;
143
- if ("warnings" in parseResult && parseResult.warnings) for (const warning of parseResult.warnings) {
144
- const line = warning.token?.startLine ?? 1;
145
- const column = warning.token?.startColumn ?? 1;
146
- warnings.push({
147
- code: "parse/deprecated",
148
- phase: "parse",
149
- message: warning.message,
150
- reason: warning.message,
151
- fix: "Update your code to use the recommended syntax.",
152
- file: context.file,
153
- filePath,
154
- line,
155
- column,
156
- lines: extractRelevantLines(source, line)
157
- });
158
- }
159
- if (parseResult.errors.length) for (const error of parseResult.errors) {
160
- const line = error.token?.startLine ?? 1;
161
- const diagnostic = toDiagnostic(getErrorFromParser([error], void 0, filePath, source, { file: context.file }));
162
- if (!diagnostic.lines) diagnostic.lines = extractRelevantLines(source, line);
163
- if ("errors" in diagnostic) errors.push(diagnostic);
164
- else warnings.push(diagnostic);
165
- }
342
+ return {
343
+ document: parse(source),
344
+ errors: [],
345
+ warnings: []
346
+ };
166
347
  } catch (error) {
167
- if (error instanceof JessError) {
168
- const diagnostic = toDiagnostic(error);
169
- if ("errors" in diagnostic) errors.push(diagnostic);
170
- else warnings.push(diagnostic);
171
- } else {
172
- const message = error instanceof Error ? error.message : "Unknown parsing error";
173
- errors.push({
174
- code: "internal/unknown",
175
- phase: "parse",
176
- message,
177
- reason: message || "An unexpected error occurred during parsing.",
178
- fix: "Check the file syntax and ensure it is valid.",
179
- file: context.file,
180
- filePath,
181
- line: 1,
182
- column: 1,
183
- lines: extractRelevantLines(source, 1)
184
- });
185
- }
186
348
  return {
187
- errors,
188
- warnings
349
+ errors: [parserDiagnostic({
350
+ dialect: "Less",
351
+ error,
352
+ filePath,
353
+ source
354
+ })],
355
+ warnings: []
189
356
  };
190
357
  }
191
- if (tree && errors.length === 0) this._registerFunctions(tree);
192
- return {
193
- tree,
194
- errors,
195
- warnings
196
- };
197
358
  }
198
359
  };
199
360
  const lessPlugin = ((opts) => {
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "access": "public"
6
6
  },
7
7
  "description": "A Less stylesheet engine for Jess",
8
- "version": "2.0.0-alpha.7",
8
+ "version": "2.0.0-alpha.9",
9
9
  "main": "lib/index.cjs",
10
10
  "types": "lib/index.d.ts",
11
11
  "exports": {
@@ -20,11 +20,12 @@
20
20
  "lib"
21
21
  ],
22
22
  "dependencies": {
23
- "@jesscss/core": "2.0.0-alpha.7",
24
- "@jesscss/fns": "2.0.0-alpha.7",
25
- "@jesscss/less-parser": "2.0.0-alpha.7",
26
- "@jesscss/style-resolver": "2.0.0-alpha.7",
27
- "styles-config": "2.0.0-alpha.7"
23
+ "@jesscss/fns": "2.0.0-alpha.9",
24
+ "@jesscss/less-parser": "2.0.0-alpha.9",
25
+ "@jesscss/style-resolver": "2.0.0-alpha.9",
26
+ "@jesscss/core": "2.0.0-alpha.9",
27
+ "@jesscss/plugin-node-modules": "2.0.0-alpha.9",
28
+ "styles-config": "2.0.0-alpha.9"
28
29
  },
29
30
  "devDependencies": {},
30
31
  "author": "Matthew Dean <matthew-dean@users.noreply.github.com>",
@@ -37,7 +38,7 @@
37
38
  "scripts": {
38
39
  "ci": "pnpm build && pnpm test",
39
40
  "build": "pnpm compile",
40
- "compile": "tsdown --tsconfig tsconfig.build.json --no-dts && tsc -p tsconfig.build.json --emitDeclarationOnly --noCheck",
41
+ "compile": "tsdown --tsconfig tsconfig.build.json --no-dts && tsc -p tsconfig.build.json --emitDeclarationOnly",
41
42
  "test": "vitest --run --passWithNoTests",
42
43
  "lint:fix": "eslint --fix '**/*.{js,ts}'",
43
44
  "lint": "eslint '**/*.{js,ts}'"