gulp-mu-css 2.0.0

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.
Binary file
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "gulp-mu-css",
3
+ "version": "2.0.0",
4
+ "description": "µCSS 2 (package gulp-mu-css) - Node module to compile µCSS-enhanced stylesheets (sprites, cursors, image generation via µPS / gulp-mu-ps). Successor of the 2013 Photoshop-script based µCSS 1.",
5
+ "type": "module",
6
+ "main": "src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "docs/microCSS.pdf",
14
+ "docs/manual"
15
+ ],
16
+ "keywords": [
17
+ "css",
18
+ "sprite",
19
+ "ucss",
20
+ "gulp-mu-css",
21
+ "microcss"
22
+ ],
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "dependencies": {
28
+ "gulp-mu-ps": "^1.0.0",
29
+ "postcss": "^8.5.15"
30
+ }
31
+ }
@@ -0,0 +1,260 @@
1
+ // Color helper functions ported from the legacy µCSS.jsx. The "hsl" model of
2
+ // Lighten is bit-exact to the old implementation (verified against compiled
3
+ // outputs of the old toolchain); "oklch" is the perceptually uniform
4
+ // alternative for new skins (see docs/CONCEPT.md, section 9.2).
5
+ //
6
+ // Internal color representation: unsigned 32 bit integer 0xAARRGGBB.
7
+
8
+ // Standard CSS color keywords (CSS Color Module level 4 list).
9
+ const COLOR_NAMES = {
10
+ transparent: 0x00000000,
11
+ aliceblue: 0xFFF0F8FF, antiquewhite: 0xFFFAEBD7, aqua: 0xFF00FFFF, aquamarine: 0xFF7FFFD4,
12
+ azure: 0xFFF0FFFF, beige: 0xFFF5F5DC, bisque: 0xFFFFE4C4, black: 0xFF000000,
13
+ blanchedalmond: 0xFFFFEBCD, blue: 0xFF0000FF, blueviolet: 0xFF8A2BE2, brown: 0xFFA52A2A,
14
+ burlywood: 0xFFDEB887, cadetblue: 0xFF5F9EA0, chartreuse: 0xFF7FFF00, chocolate: 0xFFD2691E,
15
+ coral: 0xFFFF7F50, cornflowerblue: 0xFF6495ED, cornsilk: 0xFFFFF8DC, crimson: 0xFFDC143C,
16
+ cyan: 0xFF00FFFF, darkblue: 0xFF00008B, darkcyan: 0xFF008B8B, darkgoldenrod: 0xFFB8860B,
17
+ darkgray: 0xFFA9A9A9, darkgreen: 0xFF006400, darkgrey: 0xFFA9A9A9, darkkhaki: 0xFFBDB76B,
18
+ darkmagenta: 0xFF8B008B, darkolivegreen: 0xFF556B2F, darkorange: 0xFFFF8C00, darkorchid: 0xFF9932CC,
19
+ darkred: 0xFF8B0000, darksalmon: 0xFFE9967A, darkseagreen: 0xFF8FBC8F, darkslateblue: 0xFF483D8B,
20
+ darkslategray: 0xFF2F4F4F, darkslategrey: 0xFF2F4F4F, darkturquoise: 0xFF00CED1, darkviolet: 0xFF9400D3,
21
+ deeppink: 0xFFFF1493, deepskyblue: 0xFF00BFFF, dimgray: 0xFF696969, dimgrey: 0xFF696969,
22
+ dodgerblue: 0xFF1E90FF, firebrick: 0xFFB22222, floralwhite: 0xFFFFFAF0, forestgreen: 0xFF228B22,
23
+ fuchsia: 0xFFFF00FF, gainsboro: 0xFFDCDCDC, ghostwhite: 0xFFF8F8FF, gold: 0xFFFFD700,
24
+ goldenrod: 0xFFDAA520, gray: 0xFF808080, green: 0xFF008000, greenyellow: 0xFFADFF2F,
25
+ grey: 0xFF808080, honeydew: 0xFFF0FFF0, hotpink: 0xFFFF69B4, indianred: 0xFFCD5C5C,
26
+ indigo: 0xFF4B0082, ivory: 0xFFFFFFF0, khaki: 0xFFF0E68C, lavender: 0xFFE6E6FA,
27
+ lavenderblush: 0xFFFFF0F5, lawngreen: 0xFF7CFC00, lemonchiffon: 0xFFFFFACD, lightblue: 0xFFADD8E6,
28
+ lightcoral: 0xFFF08080, lightcyan: 0xFFE0FFFF, lightgoldenrodyellow: 0xFFFAFAD2, lightgray: 0xFFD3D3D3,
29
+ lightgreen: 0xFF90EE90, lightgrey: 0xFFD3D3D3, lightpink: 0xFFFFB6C1, lightsalmon: 0xFFFFA07A,
30
+ lightseagreen: 0xFF20B2AA, lightskyblue: 0xFF87CEFA, lightslategray: 0xFF778899, lightslategrey: 0xFF778899,
31
+ lightsteelblue: 0xFFB0C4DE, lightyellow: 0xFFFFFFE0, lime: 0xFF00FF00, limegreen: 0xFF32CD32,
32
+ linen: 0xFFFAF0E6, magenta: 0xFFFF00FF, maroon: 0xFF800000, mediumaquamarine: 0xFF66CDAA,
33
+ mediumblue: 0xFF0000CD, mediumorchid: 0xFFBA55D3, mediumpurple: 0xFF9370DB, mediumseagreen: 0xFF3CB371,
34
+ mediumslateblue: 0xFF7B68EE, mediumspringgreen: 0xFF00FA9A, mediumturquoise: 0xFF48D1CC, mediumvioletred: 0xFFC71585,
35
+ midnightblue: 0xFF191970, mintcream: 0xFFF5FFFA, mistyrose: 0xFFFFE4E1, moccasin: 0xFFFFE4B5,
36
+ navajowhite: 0xFFFFDEAD, navy: 0xFF000080, oldlace: 0xFFFDF5E6, olive: 0xFF808000,
37
+ olivedrab: 0xFF6B8E23, orange: 0xFFFFA500, orangered: 0xFFFF4500, orchid: 0xFFDA70D6,
38
+ palegoldenrod: 0xFFEEE8AA, palegreen: 0xFF98FB98, paleturquoise: 0xFFAFEEEE, palevioletred: 0xFFDB7093,
39
+ papayawhip: 0xFFFFEFD5, peachpuff: 0xFFFFDAB9, peru: 0xFFCD853F, pink: 0xFFFFC0CB,
40
+ plum: 0xFFDDA0DD, powderblue: 0xFFB0E0E6, purple: 0xFF800080, rebeccapurple: 0xFF663399,
41
+ red: 0xFFFF0000, rosybrown: 0xFFBC8F8F, royalblue: 0xFF4169E1, saddlebrown: 0xFF8B4513,
42
+ salmon: 0xFFFA8072, sandybrown: 0xFFF4A460, seagreen: 0xFF2E8B57, seashell: 0xFFFFF5EE,
43
+ sienna: 0xFFA0522D, silver: 0xFFC0C0C0, skyblue: 0xFF87CEEB, slateblue: 0xFF6A5ACD,
44
+ slategray: 0xFF708090, slategrey: 0xFF708090, snow: 0xFFFFFAFA, springgreen: 0xFF00FF7F,
45
+ steelblue: 0xFF4682B4, tan: 0xFFD2B48C, teal: 0xFF008080, thistle: 0xFFD8BFD8,
46
+ tomato: 0xFFFF6347, turquoise: 0xFF40E0D0, violet: 0xFFEE82EE, wheat: 0xFFF5DEB3,
47
+ white: 0xFFFFFFFF, whitesmoke: 0xFFF5F5F5, yellow: 0xFFFFFF00, yellowgreen: 0xFF9ACD32
48
+ };
49
+
50
+ function _Clamp255(_v) {
51
+ return _v < 0 ? 0 : (_v > 255 ? 255 : _v);
52
+ }
53
+
54
+ function _ChannelValue(_v) {
55
+ const text = _v.trim();
56
+ if (text.includes("%")) return Math.floor((parseFloat(text) * 255) / 100);
57
+ return parseInt(text, 10);
58
+ }
59
+
60
+ // Parses a CSS color (name, #rgb, #rrggbb, #rrggbbaa, rgb(), rgba()) into the
61
+ // internal 0xAARRGGBB representation. Numbers pass through unchanged.
62
+ export function ParseColor(_color) {
63
+ if (typeof _color !== "string") return _color >>> 0;
64
+ const c = _color.trim().toLowerCase();
65
+ if (c in COLOR_NAMES) return COLOR_NAMES[c] >>> 0;
66
+
67
+ let r = 0, g = 0, b = 0, a = 0xFF;
68
+ if (c.startsWith("#")) {
69
+ const hex = c.slice(1);
70
+ if (hex.length >= 8) {
71
+ r = parseInt(hex.slice(0, 2), 16);
72
+ g = parseInt(hex.slice(2, 4), 16);
73
+ b = parseInt(hex.slice(4, 6), 16);
74
+ a = parseInt(hex.slice(6, 8), 16);
75
+ } else if (hex.length >= 6) {
76
+ r = parseInt(hex.slice(0, 2), 16);
77
+ g = parseInt(hex.slice(2, 4), 16);
78
+ b = parseInt(hex.slice(4, 6), 16);
79
+ } else {
80
+ r = parseInt(hex[0] + hex[0], 16);
81
+ g = parseInt(hex[1] + hex[1], 16);
82
+ b = parseInt(hex[2] + hex[2], 16);
83
+ }
84
+ } else if (c.startsWith("rgb")) {
85
+ const inner = c.slice(c.indexOf("(") + 1, c.lastIndexOf(")"));
86
+ const parts = inner.split(",");
87
+ if (parts.length >= 3) {
88
+ r = _ChannelValue(parts[0]);
89
+ g = _ChannelValue(parts[1]);
90
+ b = _ChannelValue(parts[2]);
91
+ a = parts.length >= 4 ? Math.floor(parseFloat(parts[3]) * 255) : 0xFF;
92
+ }
93
+ } else {
94
+ throw new Error(`ParseColor: unsupported color "${_color}"`);
95
+ }
96
+ return (((_Clamp255(a) & 0xFF) << 24) | ((_Clamp255(r) & 0xFF) << 16) | ((_Clamp255(g) & 0xFF) << 8) | (_Clamp255(b) & 0xFF)) >>> 0;
97
+ }
98
+
99
+ function _Hex2(_v) {
100
+ return _v.toString(16).padStart(2, "0");
101
+ }
102
+
103
+ // Serializes the internal representation back to CSS. Opaque colors become
104
+ // #rrggbb, others rgba(r,g,b,a) with the legacy 3-decimal alpha.
105
+ export function FormatColor(_c, _alphaDecimals = 3) {
106
+ const a = (_c >>> 24) & 0xFF;
107
+ const r = (_c >>> 16) & 0xFF;
108
+ const g = (_c >>> 8) & 0xFF;
109
+ const b = _c & 0xFF;
110
+ if (a === 0xFF) return `#${_Hex2(r)}${_Hex2(g)}${_Hex2(b)}`;
111
+ return `rgba(${r},${g},${b},${(a / 255).toFixed(_alphaDecimals)})`;
112
+ }
113
+
114
+ // ----------------------------------------------------------- HSL (legacy)
115
+
116
+ function _RgbToHsl(_r, _g, _b) {
117
+ const r = _r / 255, g = _g / 255, b = _b / 255;
118
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
119
+ const l = (max + min) / 2;
120
+ let h, s;
121
+ if (max === min) {
122
+ h = s = 0;
123
+ } else {
124
+ const d = max - min;
125
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
126
+ switch (max) {
127
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
128
+ case g: h = (b - r) / d + 2; break;
129
+ default: h = (r - g) / d + 4; break;
130
+ }
131
+ h /= 6;
132
+ }
133
+ return [h, s, l];
134
+ }
135
+
136
+ function _HslToRgb(_h, _s, _l) {
137
+ if (_s === 0) {
138
+ const v = Math.round(_l * 255);
139
+ return [v, v, v];
140
+ }
141
+ const hue2rgb = (_p, _q, _t) => {
142
+ let t = _t;
143
+ if (t < 0) t += 1;
144
+ if (t > 1) t -= 1;
145
+ if (t < 1 / 6) return _p + (_q - _p) * 6 * t;
146
+ if (t < 1 / 2) return _q;
147
+ if (t < 2 / 3) return _p + (_q - _p) * (2 / 3 - t) * 6;
148
+ return _p;
149
+ };
150
+ const q = _l < 0.5 ? _l * (1 + _s) : _l + _s - _l * _s;
151
+ const p = 2 * _l - q;
152
+ return [
153
+ Math.round(hue2rgb(p, q, _h + 1 / 3) * 255),
154
+ Math.round(hue2rgb(p, q, _h) * 255),
155
+ Math.round(hue2rgb(p, q, _h - 1 / 3) * 255)
156
+ ];
157
+ }
158
+
159
+ // ----------------------------------------------------------- OKLCH model
160
+
161
+ function _SrgbToLinear(_v) {
162
+ const c = _v / 255;
163
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
164
+ }
165
+
166
+ function _LinearToSrgb(_v) {
167
+ const c = _v <= 0.0031308 ? _v * 12.92 : 1.055 * (_v ** (1 / 2.4)) - 0.055;
168
+ return _Clamp255(Math.round(c * 255));
169
+ }
170
+
171
+ // sRGB -> OKLab (Björn Ottosson's reference matrices).
172
+ function _RgbToOklab(_r, _g, _b) {
173
+ const r = _SrgbToLinear(_r), g = _SrgbToLinear(_g), b = _SrgbToLinear(_b);
174
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
175
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
176
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
177
+ return [
178
+ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
179
+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
180
+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
181
+ ];
182
+ }
183
+
184
+ function _OklabToRgb(_L, _a, _b) {
185
+ const l = (_L + 0.3963377774 * _a + 0.2158037573 * _b) ** 3;
186
+ const m = (_L - 0.1055613458 * _a - 0.0638541728 * _b) ** 3;
187
+ const s = (_L - 0.0894841775 * _a - 1.2914855480 * _b) ** 3;
188
+ return [
189
+ _LinearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
190
+ _LinearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
191
+ _LinearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
192
+ ];
193
+ }
194
+
195
+ // ----------------------------------------------------------- public API
196
+
197
+ // Lightens (positive step) or darkens (negative step) a color by relative
198
+ // scaling of its lightness: L' = clamp(L + L*step). The "hsl" model matches
199
+ // the legacy µCSS behavior bit-exactly; "oklch" scales the OKLab lightness
200
+ // (perceptually uniform) while keeping chroma and hue.
201
+ export function Lighten(_color, _step, _model = "hsl") {
202
+ const c = ParseColor(_color);
203
+ const r = (c >>> 16) & 0xFF, g = (c >>> 8) & 0xFF, b = c & 0xFF;
204
+ let rgb;
205
+ if (_model === "hsl") {
206
+ const hsl = _RgbToHsl(r, g, b);
207
+ let l = hsl[2] + hsl[2] * _step;
208
+ if (l < 0) l = 0;
209
+ if (l > 1) l = 1;
210
+ rgb = _HslToRgb(hsl[0], hsl[1], l);
211
+ } else if (_model === "oklch") {
212
+ const lab = _RgbToOklab(r, g, b);
213
+ let l = lab[0] + lab[0] * _step;
214
+ if (l < 0) l = 0;
215
+ if (l > 1) l = 1;
216
+ rgb = _OklabToRgb(l, lab[1], lab[2]);
217
+ } else {
218
+ throw new Error(`Lighten: unknown color model "${_model}" (use "hsl" or "oklch")`);
219
+ }
220
+ return FormatColor(((c & 0xFF000000) | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]) >>> 0);
221
+ }
222
+
223
+ // Converts an alpha specification to a byte (legacy AlphaValue). Accepts
224
+ // fractions (0..1), bytes (0..255) and the keywords transparent/opaque/
225
+ // translucent. Unlike the legacy code, 1.0 means fully opaque.
226
+ export function AlphaValue(_alpha) {
227
+ let a = _alpha;
228
+ if (typeof a === "string") {
229
+ const text = a.trim().toLowerCase();
230
+ if (text === "transparent") a = 0;
231
+ else if (text === "opaque") a = 255;
232
+ else if (text === "translucent") a = 128;
233
+ else if (text.includes("%")) a = Math.floor((255 * parseFloat(text)) / 100);
234
+ else if (text.includes(".")) a = Math.floor(parseFloat(text) * 255);
235
+ else if (text.includes("x")) a = parseInt(text, 16);
236
+ else a = parseInt(text, 10);
237
+ } else if (a > 0 && a < 1) {
238
+ a = Math.floor(a * 255);
239
+ } else if (a === 1) {
240
+ a = 255;
241
+ }
242
+ return _Clamp255(Math.floor(a));
243
+ }
244
+
245
+ // Replaces the alpha component of a color.
246
+ export function Alpha(_color, _alpha) {
247
+ const c = ParseColor(_color);
248
+ return FormatColor(((c & 0x00FFFFFF) | (AlphaValue(_alpha) << 24)) >>> 0);
249
+ }
250
+
251
+ // Channel-wise average of two colors (including alpha) - legacy MixColors.
252
+ export function MixColors(_color1, _color2) {
253
+ const c1 = ParseColor(_color1);
254
+ const c2 = ParseColor(_color2);
255
+ const a = (((c1 >>> 24) & 0xFF) + ((c2 >>> 24) & 0xFF)) >> 1;
256
+ const r = (((c1 >>> 16) & 0xFF) + ((c2 >>> 16) & 0xFF)) >> 1;
257
+ const g = (((c1 >>> 8) & 0xFF) + ((c2 >>> 8) & 0xFF)) >> 1;
258
+ const b = ((c1 & 0xFF) + (c2 & 0xFF)) >> 1;
259
+ return FormatColor(((a << 24) | (r << 16) | (g << 8) | b) >>> 0);
260
+ }
@@ -0,0 +1,90 @@
1
+ // Cursor directive support (legacy µ.DefCursor / µ.Cursor): named cursor
2
+ // definitions with image, hotspot and standard-cursor fallback. The directive
3
+ // form writes a url() declaration plus an unprefixed image-set() variant when
4
+ // a @2x source exists (vendor prefixes dropped per CONCEPT.md, D6).
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ // "imgs/cursors/zoom.png" -> "imgs/cursors/zoom@2x.png"
10
+ function _RetinaUrl(_url) {
11
+ const dot = _url.lastIndexOf(".");
12
+ return dot < 0 ? `${_url}@2x` : `${_url.slice(0, dot)}@2x${_url.slice(dot)}`;
13
+ }
14
+
15
+ export class CursorManager {
16
+ // _definitions: array of { name, fallback, image, hotspot: [x, y],
17
+ // forceFallback } - fallback defaults to the name, image is optional
18
+ // (definition then only maps to the fallback).
19
+ // _options: { baseDir, preload } - baseDir resolves image URLs for the
20
+ // @2x existence check; preload is an optional PreloadRegistry.
21
+ constructor(_definitions = [], _options = {}) {
22
+ this.baseDir = _options.baseDir ?? ".";
23
+ this.preload = _options.preload ?? null;
24
+ this.cursors = new Map();
25
+ this.warned = new Set();
26
+ _definitions.forEach((_def) => this.Define(_def));
27
+ }
28
+
29
+ Define(_def) {
30
+ if (!_def?.name) throw new Error("CursorManager.Define: a cursor needs a name.");
31
+ const def = {
32
+ name: _def.name,
33
+ fallback: _def.fallback ?? _def.name,
34
+ image: _def.image ?? "",
35
+ hotspot: _def.hotspot ?? [0, 0],
36
+ forceFallback: !!_def.forceFallback
37
+ };
38
+ this.cursors.set(def.name, def);
39
+ if (def.image && this.preload) this.preload.Add(def.image);
40
+ return def;
41
+ }
42
+
43
+ _Hotspot(_def) {
44
+ const [x, y] = _def.hotspot;
45
+ return (x === 0 && y === 0) ? "" : ` ${x} ${y}`;
46
+ }
47
+
48
+ _HasRetina(_def) {
49
+ return existsSync(join(this.baseDir, _RetinaUrl(_def.image)));
50
+ }
51
+
52
+ // Value form, used as cursor: µ(Cursor("zoom")) - single url() value
53
+ // (matches the legacy output format, e.g.
54
+ // "url(imgs/general/gui/cursors/wait.png) 12 12, wait").
55
+ Value(_name) {
56
+ const def = this.cursors.get(_name);
57
+ if (!def) return _name;
58
+ if (!def.image) return def.fallback;
59
+ this._WarnIfImageMissing(def);
60
+ return `url(${def.image})${this._Hotspot(def)}, ${def.fallback}`;
61
+ }
62
+
63
+ // The CSS still works via the fallback cursor when the image is missing,
64
+ // so this is a warning (once per cursor), not a hard error.
65
+ _WarnIfImageMissing(_def) {
66
+ if (!_def.image || this.warned.has(_def.name)) return;
67
+ if (!existsSync(join(this.baseDir, _def.image))) {
68
+ this.warned.add(_def.name);
69
+ console.warn(`microCSS: cursor image not found: "${_def.image}" (cursor "${_def.name}", baseDir ${this.baseDir})`);
70
+ }
71
+ }
72
+
73
+ // Directive form, used as -µ: Cursor("zoom") - rewrites the cursor
74
+ // property of the rule, adding an image-set() variant when @2x exists.
75
+ Apply(_rule, _name) {
76
+ const def = this.cursors.get(_name);
77
+ if (!def || !def.image) {
78
+ _rule.ChangeProperty("cursor", def ? def.fallback : _name);
79
+ return;
80
+ }
81
+ this._WarnIfImageMissing(def);
82
+ _rule.RemoveProperty("cursor");
83
+ _rule.AddProperty("cursor", this.Value(_name));
84
+ if (this._HasRetina(def)) {
85
+ _rule.AddProperty("cursor",
86
+ `image-set(url(${def.image})1x, url(${_RetinaUrl(def.image)})2x)${this._Hotspot(def)}, ${def.fallback}`);
87
+ }
88
+ if (def.forceFallback) _rule.AddProperty("cursor", def.fallback);
89
+ }
90
+ }
@@ -0,0 +1,35 @@
1
+ // Preload registry: collects image URLs (cursor images, explicit
2
+ // PreloadImage calls) and writes them into a single "div.csspreload" rule so
3
+ // the browser fetches them ahead of first use (legacy createPreLoadRule).
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export const PRELOAD_SELECTOR = "div.csspreload";
9
+
10
+ export class PreloadRegistry {
11
+ // _baseDir: directory the image URLs are relative to (skin output dir).
12
+ // URLs pointing to files that do not exist (yet) are skipped at rule
13
+ // creation time, like in the legacy implementation.
14
+ constructor(_baseDir = ".") {
15
+ this.baseDir = _baseDir;
16
+ this.images = [];
17
+ }
18
+
19
+ Add(_url) {
20
+ if (_url && !this.images.includes(_url)) this.images.push(_url);
21
+ }
22
+
23
+ // Creates (or replaces the content of) the preload rule in the document.
24
+ CreateRule(_document) {
25
+ const urls = this.images
26
+ .filter((_url) => existsSync(join(this.baseDir, _url)))
27
+ .map((_url) => `url("${_url}")`);
28
+ let rule = _document.FindRule(PRELOAD_SELECTOR);
29
+ if (!rule) rule = _document.AddRule(PRELOAD_SELECTOR);
30
+ rule.node.removeAll();
31
+ if (urls.length) rule.AddProperty("background-image", urls.join(","));
32
+ rule.AddProperty("display", "none");
33
+ return rule;
34
+ }
35
+ }
@@ -0,0 +1,171 @@
1
+ // Sprite directive support (legacy µ.Sprite): rules register their image
2
+ // reference during compilation; Resolve() then packs all images into one
3
+ // atlas (microPS SpriteAtlas, incl. @2x) and rewrites the registered rules to
4
+ // background-image/image-set/background-position/width/height. Output format
5
+ // matches the legacy compiled CSS, minus the vendor-prefixed image-set lines
6
+ // (CONCEPT.md, D6).
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { SpriteAtlas } from "gulp-mu-ps";
11
+
12
+ // "imgs/sprites.png" -> "imgs/sprites@2x.png" (CSS URL, forward slashes)
13
+ function _RetinaUrl(_url) {
14
+ const dot = _url.lastIndexOf(".");
15
+ return dot < 0 ? `${_url}@2x` : `${_url.slice(0, dot)}@2x${_url.slice(dot)}`;
16
+ }
17
+
18
+ // Source location of the rule that registered a sprite, for error messages:
19
+ // ' - selector "div.logo" (src.µ.css:12)'.
20
+ function _Origin(_rule) {
21
+ if (!_rule?.node) return "";
22
+ const parts = [`selector "${_rule.selector}"`];
23
+ const start = _rule.node.source?.start;
24
+ const file = _rule.node.source?.input?.file;
25
+ if (file) parts.push(`(${file}${start ? `:${start.line}` : ""})`);
26
+ return ` - ${parts.join(" ")}`;
27
+ }
28
+
29
+ export class SpriteManager {
30
+ // _options: {
31
+ // baseDir = "." skin output dir; sprite URLs and the atlas
32
+ // file are resolved against it
33
+ // atlasFile = "imgs/sprites.png" atlas URL as it appears in the CSS;
34
+ // a ".webp" extension produces a WebP atlas
35
+ // retina = true build "<atlas>@2x" from "<image>@2x" sources
36
+ // padding = 0 spacing between sprites in the atlas
37
+ // preloadRule = false write the div.csspreload rule on Resolve
38
+ // preload = null PreloadRegistry (required for preloadRule)
39
+ // writeMapFile = false persist "<atlas>.json" mapping data
40
+ // }
41
+ constructor(_options = {}) {
42
+ this.options = {
43
+ baseDir: ".",
44
+ atlasFile: "imgs/sprites.png",
45
+ retina: true,
46
+ padding: 0,
47
+ preloadRule: false,
48
+ preload: null,
49
+ writeMapFile: false,
50
+ ..._options
51
+ };
52
+ this.registrations = [];
53
+ }
54
+
55
+ // Called by the -µ: Sprite(...) directive. _spriteOptions:
56
+ // { offsetWidth, offsetHeight, offsetPosX, offsetPosY, afterWork }
57
+ Register(_rule, _url, _spriteOptions = {}) {
58
+ if (!_url) throw new Error("Sprite(): an image URL is required.");
59
+ this.registrations.push({
60
+ rule: _rule,
61
+ url: _url,
62
+ offsetWidth: _spriteOptions.offsetWidth ?? 0,
63
+ offsetHeight: _spriteOptions.offsetHeight ?? 0,
64
+ offsetPosX: _spriteOptions.offsetPosX ?? 0,
65
+ offsetPosY: _spriteOptions.offsetPosY ?? 0,
66
+ afterWork: _spriteOptions.afterWork ?? null
67
+ });
68
+ }
69
+
70
+ // Verifies that all registered sprite images (and their @2x variants when
71
+ // retina is on) exist before the expensive packing starts. Reports ALL
72
+ // missing files at once, each with the rule that referenced it.
73
+ _CheckSourceImages(_files) {
74
+ const origins = new Map();
75
+ for (const reg of this.registrations) {
76
+ if (!origins.has(reg.url)) origins.set(reg.url, _Origin(reg.rule));
77
+ }
78
+ const missing = [];
79
+ for (const [url, file] of _files) {
80
+ if (!existsSync(file)) {
81
+ missing.push(`"${url}" -> ${file}${origins.get(url)}`);
82
+ } else if (this.options.retina && !existsSync(join(this.options.baseDir, _RetinaUrl(url)))) {
83
+ missing.push(`"${_RetinaUrl(url)}" (@2x variant of "${url}") -> ${join(this.options.baseDir, _RetinaUrl(url))}${origins.get(url)}`);
84
+ }
85
+ }
86
+ if (missing.length) {
87
+ throw new Error(
88
+ `SpriteManager: ${missing.length} sprite image(s) not found:\n - ${missing.join("\n - ")}`
89
+ );
90
+ }
91
+ }
92
+
93
+ // Unique image URLs in registration order (used for cache fingerprints).
94
+ ImageUrls() {
95
+ const urls = [];
96
+ for (const reg of this.registrations) {
97
+ if (!urls.includes(reg.url)) urls.push(reg.url);
98
+ }
99
+ return urls;
100
+ }
101
+
102
+ // Builds the atlas and rewrites all registered rules. _document is the
103
+ // primary document (receives the preload rule); returns the atlas mapping
104
+ // (sprites keyed by URL) or null when nothing was registered.
105
+ // _resolveOptions.cached: a previous Resolve() result - the atlas images
106
+ // are then assumed up to date and only the rules are rewritten (skips the
107
+ // expensive packing/encoding, see CONCEPT.md D7).
108
+ async Resolve(_document, _resolveOptions = {}) {
109
+ let atlas = null;
110
+ if (this.registrations.length) {
111
+ if (_resolveOptions.cached) {
112
+ atlas = _resolveOptions.cached;
113
+ } else {
114
+ const files = new Map();
115
+ for (const reg of this.registrations) {
116
+ if (!files.has(reg.url)) files.set(reg.url, join(this.options.baseDir, reg.url));
117
+ }
118
+ this._CheckSourceImages(files);
119
+ const packed = await SpriteAtlas.Create({
120
+ images: [...files.values()],
121
+ outputFile: join(this.options.baseDir, this.options.atlasFile),
122
+ retina: this.options.retina,
123
+ padding: this.options.padding,
124
+ writeMapFile: this.options.writeMapFile
125
+ });
126
+ atlas = { width: packed.width, height: packed.height, retina: packed.retina, sprites: {} };
127
+ for (const [url, file] of files) atlas.sprites[url] = packed.sprites[file];
128
+ }
129
+
130
+ const atlasUrl = this.options.atlasFile;
131
+ const retinaUrl = _RetinaUrl(atlasUrl);
132
+ for (const reg of this.registrations) {
133
+ const sprite = atlas.sprites[reg.url];
134
+ if (!sprite) throw new Error(`SpriteManager: no atlas position for "${reg.url}".`);
135
+ const rule = reg.rule;
136
+ ["width", "height", "background-repeat", "background-position", "background-image"]
137
+ .forEach((_prop) => rule.RemoveProperty(_prop));
138
+ rule.AddProperty("background-image", `url(${atlasUrl})`);
139
+ if (this.options.retina) {
140
+ rule.AddProperty("background-image", `image-set(url(${atlasUrl})1x, url(${retinaUrl})2x)`);
141
+ }
142
+ rule.AddProperty("background-repeat", "no-repeat");
143
+ rule.AddProperty("background-position",
144
+ `${-(sprite.x + reg.offsetPosX)}px ${-(sprite.y + reg.offsetPosY)}px`);
145
+ rule.AddProperty("width", `${sprite.width + reg.offsetWidth}px`);
146
+ rule.AddProperty("height", `${sprite.height + reg.offsetHeight}px`);
147
+ if (reg.afterWork) {
148
+ try {
149
+ reg.afterWork({
150
+ rule,
151
+ document: _document,
152
+ url: reg.url,
153
+ baseDir: this.options.baseDir,
154
+ sprite: { ...sprite },
155
+ atlas: { file: atlasUrl, retinaFile: this.options.retina ? retinaUrl : null, width: atlas.width, height: atlas.height }
156
+ });
157
+ } catch (error) {
158
+ throw new Error(
159
+ `afterWork hook for sprite "${reg.url}" failed${_Origin(reg.rule)}: ${error.message}`,
160
+ { cause: error }
161
+ );
162
+ }
163
+ }
164
+ }
165
+ }
166
+ if (this.options.preloadRule && this.options.preload && _document) {
167
+ this.options.preload.CreateRule(_document);
168
+ }
169
+ return atlas;
170
+ }
171
+ }
@@ -0,0 +1,81 @@
1
+ // Incremental build cache (CONCEPT.md, D7): one JSON file per skin in
2
+ // <outputDir>/.cache/build.json that stores mtime/size fingerprints and
3
+ // result data (atlas positions, generated file lists) of the last successful
4
+ // run. Replaces the legacy *.css.cache files and the sprite MD5 log.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
9
+ export const CACHE_SCHEMA = 1;
10
+
11
+ // [mtimeMs, size] of a file, or null when it does not exist.
12
+ export function FileFingerprint(_path) {
13
+ try {
14
+ const stats = statSync(_path);
15
+ return [Math.round(stats.mtimeMs), stats.size];
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ // { path: [mtimeMs, size] } for a list of files (missing files map to null).
22
+ export function FingerprintFiles(_paths) {
23
+ const result = {};
24
+ for (const path of _paths) result[path] = FileFingerprint(path);
25
+ return result;
26
+ }
27
+
28
+ // Compares two fingerprint maps (order-insensitive, exact values).
29
+ export function FingerprintsMatch(_a, _b) {
30
+ if (!_a || !_b) return false;
31
+ const keysA = Object.keys(_a);
32
+ if (keysA.length !== Object.keys(_b).length) return false;
33
+ for (const key of keysA) {
34
+ const a = _a[key], b = _b[key];
35
+ if (a === null || b === null) {
36
+ if (a !== b) return false;
37
+ } else if (a[0] !== b[0] || a[1] !== b[1]) {
38
+ return false;
39
+ }
40
+ }
41
+ return true;
42
+ }
43
+
44
+ export class BuildCache {
45
+ constructor(_file, _meta) {
46
+ this.file = _file;
47
+ this.data = { meta: _meta, steps: {} };
48
+ }
49
+
50
+ // Loads the cache file; mismatching meta data (schema version, tool
51
+ // version) discards the content, which forces a full rebuild.
52
+ static Load(_file, _meta) {
53
+ const cache = new BuildCache(_file, _meta);
54
+ if (existsSync(_file)) {
55
+ try {
56
+ const stored = JSON.parse(readFileSync(_file, "utf8"));
57
+ if (JSON.stringify(stored.meta) === JSON.stringify(_meta)) cache.data = stored;
58
+ } catch {
59
+ // Corrupt cache file: keep the empty cache (full rebuild).
60
+ }
61
+ }
62
+ return cache;
63
+ }
64
+
65
+ Clear() {
66
+ this.data.steps = {};
67
+ }
68
+
69
+ Get(_key) {
70
+ return this.data.steps[_key] ?? null;
71
+ }
72
+
73
+ Set(_key, _entry) {
74
+ this.data.steps[_key] = _entry;
75
+ }
76
+
77
+ Save() {
78
+ mkdirSync(dirname(this.file), { recursive: true });
79
+ writeFileSync(this.file, JSON.stringify(this.data, null, "\t"));
80
+ }
81
+ }