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.
- package/README.md +135 -0
- package/docs/manual/imgs/bc_alu.png +0 -0
- package/docs/manual/imgs/bc_aqua.png +0 -0
- package/docs/manual/imgs/logo.png +0 -0
- package/docs/manual/imgs/strip_result_smoke.png +0 -0
- package/docs/manual/microCSS-Handbuch.md +774 -0
- package/docs/microCSS.pdf +0 -0
- package/package.json +31 -0
- package/src/api/Colors.mjs +260 -0
- package/src/api/Cursors.mjs +90 -0
- package/src/api/Preload.mjs +35 -0
- package/src/api/Sprites.mjs +171 -0
- package/src/build/BuildCache.mjs +81 -0
- package/src/build/MediaSteps.mjs +171 -0
- package/src/build/SkinBuilder.mjs +197 -0
- package/src/compile/Compiler.mjs +168 -0
- package/src/css/CssDocument.mjs +244 -0
- package/src/eval/MuContext.mjs +62 -0
- package/src/index.mjs +13 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Bridge from manifest "media" steps to microPS (CONCEPT.md, D3): renders
|
|
2
|
+
// PSD-based image series, app icons and sequence strips into the skin output
|
|
3
|
+
// directory and copies static assets. Generator steps are skipped when their
|
|
4
|
+
// configuration, sources and outputs are unchanged (D7); copy steps use a
|
|
5
|
+
// classic make-style mtime comparison.
|
|
6
|
+
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join, basename, dirname } from "node:path";
|
|
9
|
+
import { ButtonAndIconCreator, AppIconMaker, SequenceStrip } from "gulp-mu-ps";
|
|
10
|
+
import { FileFingerprint, FingerprintFiles, FingerprintsMatch } from "./BuildCache.mjs";
|
|
11
|
+
|
|
12
|
+
// "imgs/smoke.png" + "webp" -> "imgs/smoke.webp"
|
|
13
|
+
function _SwapExtension(_file, _format) {
|
|
14
|
+
const dot = _file.lastIndexOf(".");
|
|
15
|
+
return dot < 0 ? `${_file}.${_format}` : `${_file.slice(0, dot)}.${_format}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function _ListFilesRecursive(_dir) {
|
|
19
|
+
const result = [];
|
|
20
|
+
for (const entry of readdirSync(_dir, { withFileTypes: true }).sort((_a, _b) => _a.name.localeCompare(_b.name))) {
|
|
21
|
+
const path = join(_dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) result.push(..._ListFilesRecursive(path));
|
|
23
|
+
else result.push(path);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Copies _source to _target when the target is missing or older (make-style).
|
|
29
|
+
function _CopyIfNewer(_source, _target) {
|
|
30
|
+
const sourceStat = statSync(_source);
|
|
31
|
+
if (existsSync(_target) && statSync(_target).mtimeMs >= sourceStat.mtimeMs) return false;
|
|
32
|
+
mkdirSync(dirname(_target), { recursive: true });
|
|
33
|
+
copyFileSync(_source, _target);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolves the base directory for step outputs. Default is the skin output
|
|
38
|
+
// directory; outputBase: "project" targets the project root instead, so a
|
|
39
|
+
// step can generate intermediate assets (raw -> final, e.g. sequence strips)
|
|
40
|
+
// that later copy/copyFolder steps pick up.
|
|
41
|
+
function _OutputBase(_step, _ctx) {
|
|
42
|
+
const base = _step.outputBase ?? "skin";
|
|
43
|
+
if (base === "skin") return _ctx.outputDir;
|
|
44
|
+
if (base === "project") return _ctx.rootDir;
|
|
45
|
+
throw new Error(`invalid outputBase "${_step.outputBase}" (use "skin" or "project")`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Runs one media step. _ctx: { rootDir, outputDir, imageFormat, cache, index }
|
|
49
|
+
// Returns { type, skipped, outputs }.
|
|
50
|
+
export async function RunMediaStep(_step, _ctx) {
|
|
51
|
+
if (_step.copy) return _RunCopy(_step, _ctx);
|
|
52
|
+
if (_step.copyFolder) return _RunCopyFolder(_step, _ctx);
|
|
53
|
+
if (_step.buttonsAndIcons) return _RunGenerator(_step, _ctx, "buttonsAndIcons", _ButtonsAndIcons);
|
|
54
|
+
if (_step.appIcons) return _RunGenerator(_step, _ctx, "appIcons", _AppIcons);
|
|
55
|
+
if (_step.sequenceStrip) return _RunGenerator(_step, _ctx, "sequenceStrip", _SequenceStrip);
|
|
56
|
+
throw new Error(`unknown media step: ${JSON.stringify(_step)}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _RunCopy(_step, _ctx) {
|
|
60
|
+
const source = join(_ctx.rootDir, _step.copy);
|
|
61
|
+
if (!existsSync(source)) {
|
|
62
|
+
throw new Error(`copy: source file not found: ${source}`);
|
|
63
|
+
}
|
|
64
|
+
const target = join(_OutputBase(_step, _ctx), _step.to ?? ".", basename(_step.copy));
|
|
65
|
+
const copied = _CopyIfNewer(source, target);
|
|
66
|
+
return { type: "copy", skipped: !copied, outputs: [target] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _RunCopyFolder(_step, _ctx) {
|
|
70
|
+
const sourceDir = join(_ctx.rootDir, _step.copyFolder);
|
|
71
|
+
if (!existsSync(sourceDir)) {
|
|
72
|
+
throw new Error(`copyFolder: source folder not found: ${sourceDir} `
|
|
73
|
+
+ "(was the generation step that produces this folder run?)");
|
|
74
|
+
}
|
|
75
|
+
const targetDir = join(_OutputBase(_step, _ctx), _step.to ?? basename(_step.copyFolder));
|
|
76
|
+
// Optional filename filter (regex source string), e.g. "\\.(png|json)$".
|
|
77
|
+
const filter = _step.filter ? new RegExp(_step.filter, "i") : null;
|
|
78
|
+
const outputs = [];
|
|
79
|
+
let copiedAny = false;
|
|
80
|
+
for (const source of _ListFilesRecursive(sourceDir)) {
|
|
81
|
+
const relative = source.slice(sourceDir.length + 1);
|
|
82
|
+
if (filter && !filter.test(relative.replace(/\\/g, "/"))) continue;
|
|
83
|
+
const target = join(targetDir, relative);
|
|
84
|
+
if (_CopyIfNewer(source, target)) copiedAny = true;
|
|
85
|
+
outputs.push(target);
|
|
86
|
+
}
|
|
87
|
+
return { type: "copyFolder", skipped: !copiedAny, outputs };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Generic generator step with D7 cache check: skip when the step config is
|
|
91
|
+
// unchanged, all sources have unchanged mtime/size and all outputs exist.
|
|
92
|
+
async function _RunGenerator(_step, _ctx, _type, _Runner) {
|
|
93
|
+
const format = _step.format ?? _ctx.imageFormat;
|
|
94
|
+
const sources = _CollectSources(_step, _ctx, _type);
|
|
95
|
+
const signature = JSON.stringify({ step: _step, format });
|
|
96
|
+
const fingerprints = FingerprintFiles(sources);
|
|
97
|
+
const cacheKey = `media:${_ctx.index}`;
|
|
98
|
+
|
|
99
|
+
const cached = _ctx.cache?.Get(cacheKey);
|
|
100
|
+
if (cached
|
|
101
|
+
&& cached.signature === signature
|
|
102
|
+
&& FingerprintsMatch(cached.sources, fingerprints)
|
|
103
|
+
&& cached.outputs.every((_file) => existsSync(_file))) {
|
|
104
|
+
return { type: _type, skipped: true, outputs: cached.outputs };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const outputs = await _Runner(_step, _ctx, format, sources);
|
|
108
|
+
_ctx.cache?.Set(cacheKey, { signature, sources: fingerprints, outputs });
|
|
109
|
+
return { type: _type, skipped: false, outputs };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _CollectSources(_step, _ctx, _type) {
|
|
113
|
+
const source = join(_ctx.rootDir, _step[_type]);
|
|
114
|
+
if (!existsSync(source)) {
|
|
115
|
+
throw new Error(`${_type}: source not found: ${source}`);
|
|
116
|
+
}
|
|
117
|
+
if (_type === "sequenceStrip" && statSync(source).isDirectory()) {
|
|
118
|
+
return _ListFilesRecursive(source);
|
|
119
|
+
}
|
|
120
|
+
return [source];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function _ButtonsAndIcons(_step, _ctx, _format, _sources) {
|
|
124
|
+
if (!_step.outputDir) throw new Error("buttonsAndIcons: outputDir is required.");
|
|
125
|
+
const outputDir = join(_OutputBase(_step, _ctx), _step.outputDir);
|
|
126
|
+
// mode "topLayerSets": one image per top child group (legacy
|
|
127
|
+
// CreateByTopLayerSets); default mode is the icon x state matrix.
|
|
128
|
+
if (_step.mode === "topLayerSets") {
|
|
129
|
+
return ButtonAndIconCreator.CreateByTopLayerSets(_sources[0], {
|
|
130
|
+
layout: _step.layout,
|
|
131
|
+
outputDir,
|
|
132
|
+
retina: _step.retina ?? true,
|
|
133
|
+
format: _format,
|
|
134
|
+
...(_step.setPattern ? { setPattern: _step.setPattern } : {})
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return ButtonAndIconCreator.Create(_sources[0], {
|
|
138
|
+
layout: _step.layout,
|
|
139
|
+
outputDir,
|
|
140
|
+
retina: _step.retina ?? true,
|
|
141
|
+
format: _format
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function _AppIcons(_step, _ctx, _format, _sources) {
|
|
146
|
+
return AppIconMaker.Create(_sources[0], {
|
|
147
|
+
outputDir: join(_OutputBase(_step, _ctx), _step.outputDir ?? "."),
|
|
148
|
+
...(_step.profiles ? { profiles: _step.profiles } : {}),
|
|
149
|
+
...(_step.layout ? { layout: _step.layout } : {}),
|
|
150
|
+
...(_step.background ? { background: _step.background } : {}),
|
|
151
|
+
...(_step.appName ? { appName: _step.appName } : {}),
|
|
152
|
+
...(_step.themeColor ? { themeColor: _step.themeColor } : {}),
|
|
153
|
+
...(_step.shortName ? { shortName: _step.shortName } : {})
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function _SequenceStrip(_step, _ctx, _format, _sources) {
|
|
158
|
+
if (!_step.outputFile) throw new Error("sequenceStrip: outputFile is required.");
|
|
159
|
+
const outputFile = join(_OutputBase(_step, _ctx), _format === "png"
|
|
160
|
+
? _step.outputFile
|
|
161
|
+
: _SwapExtension(_step.outputFile, _format));
|
|
162
|
+
// A directory source is a frame sequence, a single file is a DSD image.
|
|
163
|
+
const sourceIsDir = statSync(join(_ctx.rootDir, _step.sequenceStrip)).isDirectory();
|
|
164
|
+
const result = await SequenceStrip.Create({
|
|
165
|
+
...(sourceIsDir ? { images: _sources } : { dsdImage: _sources[0] }),
|
|
166
|
+
outputFile,
|
|
167
|
+
retina: _step.retina ?? true,
|
|
168
|
+
writeMapFile: _step.writeMapFile ?? true
|
|
169
|
+
});
|
|
170
|
+
return result.files;
|
|
171
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Skin build orchestration (CONCEPT.md, D3/D7): loads a skin manifest
|
|
2
|
+
// (skins/src/<skinname>.µcss.mjs), runs the media steps (microPS bridge, cached),
|
|
3
|
+
// compiles all µCSS source files (*.µ.css), resolves the sprite atlas (with position cache)
|
|
4
|
+
// and writes the results into the skin output directory.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join, dirname, parse, resolve } from "node:path";
|
|
8
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
9
|
+
import { CompileMcss } from "../compile/Compiler.mjs";
|
|
10
|
+
import { SpriteManager } from "../api/Sprites.mjs";
|
|
11
|
+
import { CursorManager } from "../api/Cursors.mjs";
|
|
12
|
+
import { PreloadRegistry } from "../api/Preload.mjs";
|
|
13
|
+
import { BuildCache, FingerprintFiles, FingerprintsMatch, CACHE_SCHEMA } from "./BuildCache.mjs";
|
|
14
|
+
import { RunMediaStep } from "./MediaSteps.mjs";
|
|
15
|
+
|
|
16
|
+
const ownPackage = JSON.parse(
|
|
17
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf8")
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// "imgs/sprites.png" + "webp" -> "imgs/sprites.webp"
|
|
21
|
+
function _SwapExtension(_file, _format) {
|
|
22
|
+
const dot = _file.lastIndexOf(".");
|
|
23
|
+
return dot < 0 ? `${_file}.${_format}` : `${_file.slice(0, dot)}.${_format}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _RetinaUrl(_url) {
|
|
27
|
+
const dot = _url.lastIndexOf(".");
|
|
28
|
+
return dot < 0 ? `${_url}@2x` : `${_url.slice(0, dot)}@2x${_url.slice(dot)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Declares a skin configuration (manifest default export). Performs basic
|
|
32
|
+
// validation and fills in nothing - the structure is plain data plus helper
|
|
33
|
+
// functions, see CONCEPT.md D3.
|
|
34
|
+
export function DefineSkin(_config) {
|
|
35
|
+
if (!_config || typeof _config !== "object") {
|
|
36
|
+
throw new Error("DefineSkin: a configuration object is required.");
|
|
37
|
+
}
|
|
38
|
+
for (const entry of _config.files ?? []) {
|
|
39
|
+
if (!entry.source || !entry.target) {
|
|
40
|
+
throw new Error(`DefineSkin: files entries need source and target (${JSON.stringify(entry)}).`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return _config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Imports the manifest module; the mtime query defeats Node's module cache
|
|
47
|
+
// so watch runs pick up manifest edits.
|
|
48
|
+
async function _LoadManifest(_manifestFile) {
|
|
49
|
+
const stamp = Math.round(statSync(_manifestFile).mtimeMs);
|
|
50
|
+
const module = await import(`${pathToFileURL(_manifestFile).href}?mtime=${stamp}`);
|
|
51
|
+
if (!module.default || typeof module.default !== "object") {
|
|
52
|
+
throw new Error(`BuildSkin: manifest "${_manifestFile}" has no default export (DefineSkin({...})).`);
|
|
53
|
+
}
|
|
54
|
+
return module.default;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolves the sprite atlas with the D7 position cache: when the image set,
|
|
58
|
+
// the source images (incl. @2x) and the atlas options are unchanged and the
|
|
59
|
+
// atlas files still exist, the cached positions are reused and no packing or
|
|
60
|
+
// encoding happens.
|
|
61
|
+
async function _ResolveSprites(_sprites, _document, _cache, _force) {
|
|
62
|
+
const urls = _sprites.ImageUrls();
|
|
63
|
+
if (!urls.length) {
|
|
64
|
+
await _sprites.Resolve(_document);
|
|
65
|
+
return { atlas: null, skipped: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const options = _sprites.options;
|
|
69
|
+
const sourceFiles = [];
|
|
70
|
+
for (const url of urls) {
|
|
71
|
+
sourceFiles.push(join(options.baseDir, url));
|
|
72
|
+
if (options.retina) sourceFiles.push(join(options.baseDir, _RetinaUrl(url)));
|
|
73
|
+
}
|
|
74
|
+
const signature = JSON.stringify({
|
|
75
|
+
atlasFile: options.atlasFile,
|
|
76
|
+
retina: options.retina,
|
|
77
|
+
padding: options.padding,
|
|
78
|
+
urls
|
|
79
|
+
});
|
|
80
|
+
const fingerprints = FingerprintFiles(sourceFiles);
|
|
81
|
+
const atlasFiles = [join(options.baseDir, options.atlasFile)];
|
|
82
|
+
if (options.retina) atlasFiles.push(join(options.baseDir, _RetinaUrl(options.atlasFile)));
|
|
83
|
+
|
|
84
|
+
const cached = _cache.Get("atlas");
|
|
85
|
+
if (!_force
|
|
86
|
+
&& cached
|
|
87
|
+
&& cached.signature === signature
|
|
88
|
+
&& FingerprintsMatch(cached.sources, fingerprints)
|
|
89
|
+
&& atlasFiles.every((_file) => existsSync(_file))) {
|
|
90
|
+
const atlas = await _sprites.Resolve(_document, { cached: cached.atlas });
|
|
91
|
+
return { atlas, skipped: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const atlas = await _sprites.Resolve(_document);
|
|
95
|
+
_cache.Set("atlas", { signature, sources: fingerprints, atlas });
|
|
96
|
+
return { atlas, skipped: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Builds one skin from its manifest file.
|
|
100
|
+
//
|
|
101
|
+
// Directory conventions (CONCEPT.md, D3): for skins/src/std.µcss.mjs the skin
|
|
102
|
+
// name is "std", the output directory skins/std/ and the project root (base of
|
|
103
|
+
// media source paths like dev/media/...) two levels above the manifest.
|
|
104
|
+
// All three are overridable via _options { outputDir, rootDir, force }.
|
|
105
|
+
export async function BuildSkin(_manifestPath, _options = {}) {
|
|
106
|
+
const startedAt = Date.now();
|
|
107
|
+
const manifestFile = resolve(_manifestPath);
|
|
108
|
+
const srcDir = dirname(manifestFile);
|
|
109
|
+
// Strip the manifest marker (".µcss" or ASCII ".mucss") from the basename.
|
|
110
|
+
const skinName = parse(manifestFile).name.replace(/\.(µcss|mucss)$/i, "");
|
|
111
|
+
const rootDir = _options.rootDir ? resolve(_options.rootDir) : resolve(srcDir, "..", "..");
|
|
112
|
+
const outputDir = _options.outputDir ? resolve(_options.outputDir) : resolve(srcDir, "..", skinName);
|
|
113
|
+
const force = !!_options.force;
|
|
114
|
+
|
|
115
|
+
const config = await _LoadManifest(manifestFile);
|
|
116
|
+
mkdirSync(outputDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const cache = BuildCache.Load(join(outputDir, ".cache", "build.json"), {
|
|
119
|
+
schema: CACHE_SCHEMA,
|
|
120
|
+
package: ownPackage.version
|
|
121
|
+
});
|
|
122
|
+
if (force) cache.Clear();
|
|
123
|
+
|
|
124
|
+
// 1. Media steps (microPS bridge) - the images must exist before the
|
|
125
|
+
// sprite atlas and the cursor @2x checks run. skipMedia builds CSS only,
|
|
126
|
+
// assuming the media outputs already exist (e.g. migration comparisons).
|
|
127
|
+
const imageFormat = config.imageFormat ?? "png";
|
|
128
|
+
const media = [];
|
|
129
|
+
if (!_options.skipMedia) {
|
|
130
|
+
for (let index = 0; index < (config.media ?? []).length; index++) {
|
|
131
|
+
const step = config.media[index];
|
|
132
|
+
try {
|
|
133
|
+
media.push(await RunMediaStep(step, { rootDir, outputDir, imageFormat, cache, index }));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const label = ["copy", "copyFolder", "buttonsAndIcons", "appIcons", "sequenceStrip"]
|
|
136
|
+
.filter((_key) => step[_key]).map((_key) => `${_key}: "${step[_key]}"`).join(", ");
|
|
137
|
+
throw new Error(`BuildSkin: media step ${index + 1} of ${config.media.length} (${label || JSON.stringify(step)}) failed: ${error.message}`, { cause: error });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. Managers for the µ-directives.
|
|
143
|
+
const preload = new PreloadRegistry(outputDir);
|
|
144
|
+
const cursors = new CursorManager(config.cursors ?? [], { baseDir: outputDir, preload });
|
|
145
|
+
const spritesConfig = config.sprites ?? {};
|
|
146
|
+
const atlasFile = imageFormat === "png"
|
|
147
|
+
? (spritesConfig.file ?? "imgs/sprites.png")
|
|
148
|
+
: _SwapExtension(spritesConfig.file ?? "imgs/sprites.png", imageFormat);
|
|
149
|
+
const sprites = new SpriteManager({
|
|
150
|
+
baseDir: outputDir,
|
|
151
|
+
atlasFile,
|
|
152
|
+
retina: spritesConfig.retina ?? true,
|
|
153
|
+
padding: spritesConfig.padding ?? 0,
|
|
154
|
+
preloadRule: !!spritesConfig.preloadRule,
|
|
155
|
+
preload
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 3. Compile all stylesheets (sprite rules register, nothing is packed yet).
|
|
159
|
+
const compiled = [];
|
|
160
|
+
for (const entry of config.files ?? []) {
|
|
161
|
+
const sourceFile = join(srcDir, entry.source);
|
|
162
|
+
if (!existsSync(sourceFile)) {
|
|
163
|
+
throw new Error(`BuildSkin: source file "${entry.source}" (files entry for target "${entry.target}") not found: ${sourceFile}`);
|
|
164
|
+
}
|
|
165
|
+
const document = CompileMcss(readFileSync(sourceFile, "utf8"), {
|
|
166
|
+
vars: config.vars ?? {},
|
|
167
|
+
helpers: config.helpers ?? {},
|
|
168
|
+
sprites,
|
|
169
|
+
cursors,
|
|
170
|
+
from: sourceFile
|
|
171
|
+
});
|
|
172
|
+
compiled.push({ entry, document });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 4. Sprite atlas (cached) - rewrites the registered rules in all
|
|
176
|
+
// documents; the preload rule goes into the first stylesheet.
|
|
177
|
+
const atlasResult = await _ResolveSprites(sprites, compiled[0]?.document ?? null, cache, force);
|
|
178
|
+
|
|
179
|
+
// 5. Emit.
|
|
180
|
+
const files = [];
|
|
181
|
+
for (const { entry, document } of compiled) {
|
|
182
|
+
const target = join(outputDir, entry.target);
|
|
183
|
+
await document.ToFile(target);
|
|
184
|
+
files.push({ source: entry.source, target });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
cache.Save();
|
|
188
|
+
return {
|
|
189
|
+
skin: skinName,
|
|
190
|
+
outputDir,
|
|
191
|
+
media,
|
|
192
|
+
files,
|
|
193
|
+
atlas: atlasResult.atlas,
|
|
194
|
+
atlasSkipped: atlasResult.skipped,
|
|
195
|
+
duration: Date.now() - startedAt
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The µCSS source compiler core (*.µ.css): resolves µ(expression) interpolations in values
|
|
2
|
+
// and executes -µ:/-mu: directive declarations (docs/CONCEPT.md, D2).
|
|
3
|
+
// Sprite/cursor/media directives are added in later milestones; this core
|
|
4
|
+
// provides the mechanism plus the rule-bound manipulation builtins.
|
|
5
|
+
|
|
6
|
+
import { CssDocument, CssRule } from "../css/CssDocument.mjs";
|
|
7
|
+
import { MuContext } from "../eval/MuContext.mjs";
|
|
8
|
+
|
|
9
|
+
const DIRECTIVE_PROP = /^-(?:µ|mu)$/i;
|
|
10
|
+
|
|
11
|
+
// Finds the next µ( / mu( interpolation start in a text, returns
|
|
12
|
+
// { start, exprStart } or null. "mu" must stand on a word boundary so that
|
|
13
|
+
// e.g. "emu(" is not treated as an interpolation.
|
|
14
|
+
function _FindStart(_text, _from) {
|
|
15
|
+
for (let i = _from; i < _text.length; i++) {
|
|
16
|
+
if (_text[i] === "\u00B5" && _text[i + 1] === "(") {
|
|
17
|
+
return { start: i, exprStart: i + 2 };
|
|
18
|
+
}
|
|
19
|
+
if (_text[i] === "m" && _text[i + 1] === "u" && _text[i + 2] === "(") {
|
|
20
|
+
const before = i > 0 ? _text[i - 1] : "";
|
|
21
|
+
if (!/[A-Za-z0-9_-]/.test(before)) return { start: i, exprStart: i + 3 };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns the index of the parenthesis closing the expression that starts at
|
|
28
|
+
// _exprStart (which is just behind the opening paren), honoring nested parens
|
|
29
|
+
// and quoted strings.
|
|
30
|
+
function _FindEnd(_text, _exprStart) {
|
|
31
|
+
let depth = 1;
|
|
32
|
+
let quote = null;
|
|
33
|
+
for (let i = _exprStart; i < _text.length; i++) {
|
|
34
|
+
const ch = _text[i];
|
|
35
|
+
if (quote) {
|
|
36
|
+
if (ch === "\\") i++;
|
|
37
|
+
else if (ch === quote) quote = null;
|
|
38
|
+
} else if (ch === "'" || ch === "\"" || ch === "`") {
|
|
39
|
+
quote = ch;
|
|
40
|
+
} else if (ch === "(") {
|
|
41
|
+
depth++;
|
|
42
|
+
} else if (ch === ")") {
|
|
43
|
+
depth--;
|
|
44
|
+
if (depth === 0) return i;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Replaces every µ(expr) / mu(expr) occurrence in _text with the evaluated
|
|
51
|
+
// result. _evaluate receives the raw expression source.
|
|
52
|
+
export function ReplaceInterpolations(_text, _evaluate) {
|
|
53
|
+
let text = _text;
|
|
54
|
+
let searchFrom = 0;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const hit = _FindStart(text, searchFrom);
|
|
57
|
+
if (!hit) return text;
|
|
58
|
+
const end = _FindEnd(text, hit.exprStart);
|
|
59
|
+
if (end < 0) throw new Error(`unbalanced parentheses in interpolation: ${text.slice(hit.start)}`);
|
|
60
|
+
const expression = text.slice(hit.exprStart, end);
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = _evaluate(expression);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// Name the failing expression - a value may contain several µ().
|
|
66
|
+
throw new Error(`µ(${expression}): ${error.message}`, { cause: error });
|
|
67
|
+
}
|
|
68
|
+
if (result === null || result === undefined) {
|
|
69
|
+
throw new Error(`interpolation µ(${expression}) returned ${result}`);
|
|
70
|
+
}
|
|
71
|
+
const replacement = String(result);
|
|
72
|
+
text = text.slice(0, hit.start) + replacement + text.slice(end + 1);
|
|
73
|
+
searchFrom = hit.start + replacement.length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Builds the extra scope for a directive: manipulation builtins bound to the
|
|
78
|
+
// rule that contains the directive declaration, plus the sprite/cursor
|
|
79
|
+
// directives when their managers are configured.
|
|
80
|
+
function _DirectiveScope(_decl, _document, _options, _insertAnchors) {
|
|
81
|
+
const parent = _decl.parent;
|
|
82
|
+
const rule = (parent && parent.type !== "root") ? new CssRule(parent) : null;
|
|
83
|
+
const scope = {
|
|
84
|
+
rule,
|
|
85
|
+
document: _document,
|
|
86
|
+
AddProperty: (_prop, _value, _important) => rule.AddProperty(_prop, _value, _important),
|
|
87
|
+
ChangeProperty: (_prop, _value, _important) => rule.ChangeProperty(_prop, _value, _important),
|
|
88
|
+
RemoveProperty: (_prop) => rule.RemoveProperty(_prop),
|
|
89
|
+
AddRule: (_selector) => _document.AddRule(_selector),
|
|
90
|
+
// InsertRule places generated rules directly behind the rule that
|
|
91
|
+
// contains the directive; consecutive calls keep their order, even
|
|
92
|
+
// across multiple directives in the same rule (legacy AddBlock with
|
|
93
|
+
// element index).
|
|
94
|
+
InsertRule: (_selector) => {
|
|
95
|
+
const anchor = _insertAnchors.get(parent) ?? rule;
|
|
96
|
+
const inserted = _document.AddRule(_selector, { after: anchor });
|
|
97
|
+
_insertAnchors.set(parent, inserted);
|
|
98
|
+
return inserted;
|
|
99
|
+
},
|
|
100
|
+
Sprite: (_url, _spriteOptions) => {
|
|
101
|
+
if (!_options.sprites) throw new Error("Sprite(): no sprite manager configured (options.sprites).");
|
|
102
|
+
_options.sprites.Register(rule, _url, _spriteOptions);
|
|
103
|
+
},
|
|
104
|
+
Cursor: (_name) => {
|
|
105
|
+
if (!_options.cursors) throw new Error("Cursor(): no cursor manager configured (options.cursors).");
|
|
106
|
+
_options.cursors.Apply(rule, _name);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return scope;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Extra scope for value interpolations: Cursor(...) as value function.
|
|
113
|
+
function _ValueScope(_options) {
|
|
114
|
+
if (!_options.cursors) return {};
|
|
115
|
+
return { Cursor: (_name) => _options.cursors.Value(_name) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Compiles µCSS source text (*.µ.css) into a CssDocument:
|
|
119
|
+
// 1. -µ:/-mu: directive declarations are evaluated (document order) and
|
|
120
|
+
// removed from the output.
|
|
121
|
+
// 2. µ(expr)/mu(expr) interpolations in declaration values and at-rule
|
|
122
|
+
// params are replaced by their evaluated result.
|
|
123
|
+
//
|
|
124
|
+
// _options: { vars, helpers, from, context, sprites, cursors } - pass an
|
|
125
|
+
// existing MuContext via context, or vars/helpers to create one. sprites
|
|
126
|
+
// (SpriteManager) and cursors (CursorManager) enable the Sprite()/Cursor()
|
|
127
|
+
// directives; sprite rules are only registered here and rewritten later by
|
|
128
|
+
// sprites.Resolve(document).
|
|
129
|
+
export function CompileMcss(_source, _options = {}) {
|
|
130
|
+
const document = _source instanceof CssDocument
|
|
131
|
+
? _source
|
|
132
|
+
: CssDocument.FromString(_source, { from: _options.from });
|
|
133
|
+
const context = _options.context ?? new MuContext(_options);
|
|
134
|
+
const valueScope = _ValueScope(_options);
|
|
135
|
+
// Last InsertRule() result per containing rule (see _DirectiveScope).
|
|
136
|
+
const insertAnchors = new Map();
|
|
137
|
+
|
|
138
|
+
// Snapshot first: directives may append nodes, which must not be walked.
|
|
139
|
+
const decls = [];
|
|
140
|
+
document.root.walkDecls((_decl) => decls.push(_decl));
|
|
141
|
+
const atrules = [];
|
|
142
|
+
document.root.walkAtRules((_atrule) => atrules.push(_atrule));
|
|
143
|
+
|
|
144
|
+
for (const decl of decls) {
|
|
145
|
+
try {
|
|
146
|
+
if (DIRECTIVE_PROP.test(decl.prop)) {
|
|
147
|
+
context.Evaluate(decl.value, _DirectiveScope(decl, document, _options, insertAnchors));
|
|
148
|
+
decl.remove();
|
|
149
|
+
} else if (decl.value.includes("(")) {
|
|
150
|
+
decl.value = ReplaceInterpolations(decl.value, (_expr) => context.Evaluate(_expr, valueScope));
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw decl.error(`microCSS: ${error.message}`, { word: decl.prop });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const atrule of atrules) {
|
|
158
|
+
try {
|
|
159
|
+
if (atrule.params && atrule.params.includes("(")) {
|
|
160
|
+
atrule.params = ReplaceInterpolations(atrule.params, (_expr) => context.Evaluate(_expr, valueScope));
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw atrule.error(`microCSS: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return document;
|
|
168
|
+
}
|