@teacss/engine 0.0.0-stage → 0.8.5-alpha.2
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 +104 -2
- package/binding.d.ts +51 -0
- package/binding.js +728 -0
- package/index.d.ts +387 -0
- package/index.js +2 -0
- package/package.json +113 -4
- package/src/facade/config-view.mjs +82 -0
- package/src/facade/constants.mjs +24 -0
- package/src/facade/countable-set.mjs +179 -0
- package/src/facade/errors.mjs +22 -0
- package/src/facade/generator.mjs +791 -0
- package/src/facade/index.mjs +19 -0
- package/src/facade/result.mjs +157 -0
- package/src/facade/select.mjs +600 -0
- package/src/facade/transformer.mjs +56 -0
- package/src/facade/ts-fallback.mjs +88 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Selects the configurations the Rust engine can serve: the first item it cannot translate comes
|
|
3
|
+
// back as `{ ok: false, reason, path }` (v02.9 §4.1); later cards (F7.07-F7.12) append the rest.
|
|
4
|
+
|
|
5
|
+
import { TeacssEngineError } from "./errors.mjs";
|
|
6
|
+
import { CONFIG_ENTRY_PROVENANCE, CONFIG_SOURCE_DECLARATIONS, ENGINE_COMPAT, ENGINE_PACKAGE_VERSION, PRESET_OPTIONS } from "./constants.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ ok: false, reason: string, path: string }} Failure */
|
|
9
|
+
/** @template T @typedef {{ ok: true, value: T }} Success */
|
|
10
|
+
|
|
11
|
+
/** @param {string} reason @param {string} path @returns {Failure} */
|
|
12
|
+
export function fail(reason, path) {
|
|
13
|
+
return { ok: false, reason, path };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @template T @param {T} value @returns {Success<T>} */
|
|
17
|
+
export function ok(value) {
|
|
18
|
+
return { ok: true, value };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Port of core/config.ts:48-59: `defaults` underneath, then the user config's own enumerable keys
|
|
23
|
+
* whose value is not `undefined`. Only string keys are copied (v02.9 §4.4); values are defined
|
|
24
|
+
* rather than assigned so a `__proto__` key stays a plain property.
|
|
25
|
+
* @param {object} defaults @param {object} userConfig @returns {Record<string, unknown>}
|
|
26
|
+
*/
|
|
27
|
+
export function mergeDefinedConfig(defaults, userConfig) {
|
|
28
|
+
/** @type {Record<string, unknown>} */
|
|
29
|
+
const merged = { ...defaults };
|
|
30
|
+
for (const key of Object.keys(userConfig)) {
|
|
31
|
+
const value = /** @type {Record<string, unknown>} */ (userConfig)[key];
|
|
32
|
+
if (value === undefined) continue;
|
|
33
|
+
Object.defineProperty(merged, key, { value, configurable: true, enumerable: true, writable: true });
|
|
34
|
+
}
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const KNOWN_KEYS = new Set([
|
|
39
|
+
"presets", "shortcuts", "safelist", "blocklist", "theme", "layers", "content", "envMode", "unmatched", "shortcutsLayer", "virtualModulePrefix", "outputToCssLayers",
|
|
40
|
+
"mergeSelectors", "warn", "details", "separators", "transformers", "extractors", "extractorDefault", "rules", "variants", "preflights", "preprocess", "postprocess",
|
|
41
|
+
"extendTheme", "configResolved", "autocomplete", "sortLayers", "cli", "engine",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Keys whose presence with a non-empty value means fallback; `transformers` is checked by name (F7.09). */
|
|
45
|
+
export const DYNAMIC_KEYS = Object.freeze([
|
|
46
|
+
"rules", "variants", "extractors", "extractorDefault", "preflights", "preprocess", "postprocess", "extendTheme", "configResolved", "autocomplete", "cli",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** A function, a non-empty array or object, or any other defined non-null value. @param {unknown} value */
|
|
50
|
+
export function isNonEmpty(value) {
|
|
51
|
+
if (value === undefined || value === null) return false;
|
|
52
|
+
if (typeof value === "function") return true;
|
|
53
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
54
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {unknown} separators */
|
|
59
|
+
function isDefaultSeparators(separators) {
|
|
60
|
+
return Array.isArray(separators) && separators.length === 2 && separators[0] === ":" && separators[1] === "-";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* v02.9 §4.2 step 2: unknown keys, then the dynamic keys in order, then the static options only
|
|
65
|
+
* the TypeScript engine honours. `engine` is a hook option and its value is not checked here.
|
|
66
|
+
* @param {Record<string, unknown>} merged @returns {Success<null> | Failure}
|
|
67
|
+
*/
|
|
68
|
+
export function checkTopLevelKeys(merged) {
|
|
69
|
+
for (const key of Object.keys(merged)) {
|
|
70
|
+
if (!KNOWN_KEYS.has(key)) return fail("unknown-key", key);
|
|
71
|
+
}
|
|
72
|
+
for (const key of DYNAMIC_KEYS) {
|
|
73
|
+
if (isNonEmpty(merged[key])) return fail(key, key);
|
|
74
|
+
}
|
|
75
|
+
if (merged.separators !== undefined && !isDefaultSeparators(merged.separators)) return fail("separators", "separators");
|
|
76
|
+
if (merged.mergeSelectors === false) return fail("mergeSelectors", "mergeSelectors");
|
|
77
|
+
if (merged.details !== undefined) return fail("details", "details");
|
|
78
|
+
if (merged.sortLayers !== undefined) return fail("sortLayers", "sortLayers");
|
|
79
|
+
return ok(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {unknown} value */
|
|
83
|
+
const isThenable = (value) => value !== null && (typeof value === "object" || typeof value === "function") && typeof (/** @type {any} */ (value)).then === "function";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* v02.9 §4.2 step 3: nested presets flattened depth first, deduplicated by name keeping the first
|
|
87
|
+
* (core/config.ts:26-36 — a missing name is a name too), then ordered pre, normal, post
|
|
88
|
+
* (config.ts:216-220; any other `enforce` value drops the preset, as there).
|
|
89
|
+
* @param {unknown} presetsValue @returns {Success<object[]> | Failure}
|
|
90
|
+
*/
|
|
91
|
+
export function flattenPresets(presetsValue) {
|
|
92
|
+
const list = presetsValue === undefined ? [] : Array.isArray(presetsValue) ? presetsValue : [presetsValue];
|
|
93
|
+
/** @type {object[]} */
|
|
94
|
+
const flat = [];
|
|
95
|
+
/** @param {unknown} entry @param {string} path @param {Set<unknown>} ancestors @returns {Failure | undefined} */
|
|
96
|
+
const visit = (entry, path, ancestors) => {
|
|
97
|
+
if (Array.isArray(entry)) {
|
|
98
|
+
for (let index = 0; index < entry.length; index++) {
|
|
99
|
+
const failure = visit(entry[index], `${path}[${index}]`, ancestors);
|
|
100
|
+
if (failure) return failure;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (typeof entry === "function" || isThenable(entry) || entry === null || typeof entry !== "object") return fail("preset-not-instantiated", path);
|
|
105
|
+
if (ancestors.has(entry)) return fail("preset-cycle", path);
|
|
106
|
+
flat.push(entry);
|
|
107
|
+
const children = Object.hasOwn(entry, "presets") ? /** @type {any} */ (entry).presets : undefined;
|
|
108
|
+
if (children === undefined) return undefined;
|
|
109
|
+
const nested = Array.isArray(children) ? children : [children];
|
|
110
|
+
const nextAncestors = new Set([...ancestors, entry]);
|
|
111
|
+
for (let index = 0; index < nested.length; index++) {
|
|
112
|
+
const failure = visit(nested[index], `${path}.presets[${index}]`, nextAncestors);
|
|
113
|
+
if (failure) return failure;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
for (let index = 0; index < list.length; index++) {
|
|
118
|
+
const failure = visit(list[index], `presets[${index}]`, new Set());
|
|
119
|
+
if (failure) return failure;
|
|
120
|
+
}
|
|
121
|
+
const names = new Set();
|
|
122
|
+
const unique = flat.filter((preset) => {
|
|
123
|
+
const name = /** @type {any} */ (preset).name;
|
|
124
|
+
if (names.has(name)) return false;
|
|
125
|
+
names.add(name);
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
/** @param {object} preset */
|
|
129
|
+
const enforce = (preset) => /** @type {any} */ (preset).enforce;
|
|
130
|
+
return ok([...unique.filter((preset) => enforce(preset) === "pre"), ...unique.filter((preset) => enforce(preset) == null), ...unique.filter((preset) => enforce(preset) === "post")]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The oracle's `PresetOptions` base (types.ts:53) has no named fields, so these five are Standard's whole option set. */
|
|
134
|
+
export const STANDARD_OPTION_KEYS = new Set(["modeSelectors", "containers", "preflight", "variablePrefix", "compileClass"]);
|
|
135
|
+
export const COMPILE_CLASS_KEYS = ["classPrefix", "alwaysHash", "keepUnknown", "layer"];
|
|
136
|
+
export const ICONS_OPTION_KEYS = ["scale", "mode", "extraProperties", "warn", "layer", "unit"];
|
|
137
|
+
export const BUNDLED_ICON_COLLECTIONS = new Set(["carbon", "crypto", "flagpack", "logo", "lucide"]);
|
|
138
|
+
export const ROLE_PALETTES_PRESET = "@teacss/config/role-palettes";
|
|
139
|
+
|
|
140
|
+
/** @param {Record<string, unknown>} obj @param {Iterable<string>} keys @returns {Record<string, unknown>} */
|
|
141
|
+
export function pick(obj, keys) {
|
|
142
|
+
/** @type {Record<string, unknown>} */
|
|
143
|
+
const out = {};
|
|
144
|
+
for (const key of keys) if (obj[key] !== undefined) out[key] = obj[key];
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {object} preset @param {symbol} marker */
|
|
149
|
+
const markedOptions = (preset, marker) => /** @type {Record<string, any>} */ (Object.getOwnPropertyDescriptor(preset, marker)?.value ?? {});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* v02.9 §4.2 step 4, first four items: the marked built-in presets and the role-palettes marker.
|
|
153
|
+
* `ok(null)` means the preset yields no `PresetSpec`; `undefined` means it is not one of these
|
|
154
|
+
* (unmarked presets go to F7.08's static translation).
|
|
155
|
+
* @param {object} preset @param {string} path such as `presets[1]` @param {{ hasEntry: boolean }} ctx
|
|
156
|
+
* @returns {Success<object | null> | Failure | undefined}
|
|
157
|
+
*/
|
|
158
|
+
export function translateTaggedPreset(preset, path, ctx) {
|
|
159
|
+
if (Object.getOwnPropertyDescriptor(preset, PRESET_OPTIONS.standard) !== undefined) {
|
|
160
|
+
const options = markedOptions(preset, PRESET_OPTIONS.standard);
|
|
161
|
+
for (const key of Object.keys(options)) {
|
|
162
|
+
if (!STANDARD_OPTION_KEYS.has(key)) return fail("standard.unknown-option", `${path}.options.${key}`);
|
|
163
|
+
}
|
|
164
|
+
const compileClass = options.compileClass;
|
|
165
|
+
if (compileClass !== null && typeof compileClass === "object") {
|
|
166
|
+
if (Object.hasOwn(compileClass, "hashFn")) return fail("standard.compileClass.hashFn", `${path}.options.compileClass.hashFn`);
|
|
167
|
+
if (Object.hasOwn(compileClass, "trigger")) return fail("standard.compileClass.trigger", `${path}.options.compileClass.trigger`);
|
|
168
|
+
}
|
|
169
|
+
const spec = { kind: "standard", options: pick(options, STANDARD_OPTION_KEYS) };
|
|
170
|
+
if (compileClass !== null && typeof compileClass === "object") spec.options.compileClass = pick(compileClass, COMPILE_CLASS_KEYS);
|
|
171
|
+
return ok(spec);
|
|
172
|
+
}
|
|
173
|
+
if (Object.getOwnPropertyDescriptor(preset, PRESET_OPTIONS.icons) !== undefined) {
|
|
174
|
+
const options = markedOptions(preset, PRESET_OPTIONS.icons);
|
|
175
|
+
const collections = Object.keys(options.collections ?? {});
|
|
176
|
+
for (const key of collections) {
|
|
177
|
+
if (!BUNDLED_ICON_COLLECTIONS.has(key)) return fail("icons.collections", `${path}.options.collections.${key}`);
|
|
178
|
+
}
|
|
179
|
+
if (isNonEmpty(options.customizations)) return fail("icons.customizations", `${path}.options.customizations`);
|
|
180
|
+
if (options.processor !== undefined) return fail("icons.processor", `${path}.options.processor`);
|
|
181
|
+
const spec = { kind: "icons", options: pick(options, ICONS_OPTION_KEYS) };
|
|
182
|
+
if (options.collections !== undefined) spec.options.collections = collections;
|
|
183
|
+
return ok(spec);
|
|
184
|
+
}
|
|
185
|
+
if (Object.getOwnPropertyDescriptor(preset, PRESET_OPTIONS.official) !== undefined) return ok({ kind: "official" });
|
|
186
|
+
if (/** @type {any} */ (preset).name === ROLE_PALETTES_PRESET) return ctx.hasEntry ? ok(null) : fail("role-palettes-without-entry", path);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Keys of an unmarked preset whose non-empty value means fallback. */
|
|
191
|
+
export const PRESET_DYNAMIC_KEYS = Object.freeze([
|
|
192
|
+
"rules", "variants", "extractors", "preflights", "preprocess", "postprocess", "extendTheme", "configResolved", "transformers", "autocomplete", "extractorDefault",
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
196
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
197
|
+
|
|
198
|
+
/** Values `undefined` are left out. @param {Record<string, unknown>} entries */
|
|
199
|
+
const defined = (entries) => Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined));
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* v02.9 §4.5: the static shortcut forms as `[name, expansion]` pairs. A shortcut meta (`[n, e, meta]`)
|
|
203
|
+
* has no place in `StaticPreset.shortcuts`, so any meta key falls back.
|
|
204
|
+
* @param {unknown} value @param {string} path @param {string} reasonOnFail
|
|
205
|
+
* @returns {Success<Array<[string, string]> | undefined> | Failure}
|
|
206
|
+
*/
|
|
207
|
+
export function translateShortcuts(value, path, reasonOnFail) {
|
|
208
|
+
if (value === undefined) return ok(undefined);
|
|
209
|
+
/** @type {Array<[string, string]>} */
|
|
210
|
+
const pairs = [];
|
|
211
|
+
/** @param {Record<string, unknown>} object @param {string} objectPath @returns {Failure | undefined} */
|
|
212
|
+
const fromObject = (object, objectPath) => {
|
|
213
|
+
for (const [key, expansion] of Object.entries(object)) {
|
|
214
|
+
if (typeof expansion !== "string") return fail(reasonOnFail, `${objectPath}.${key}`);
|
|
215
|
+
pairs.push([key, expansion]);
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
};
|
|
219
|
+
if (isPlainObject(value)) {
|
|
220
|
+
const failure = fromObject(value, path);
|
|
221
|
+
if (failure) return failure;
|
|
222
|
+
} else if (Array.isArray(value)) {
|
|
223
|
+
for (let index = 0; index < value.length; index++) {
|
|
224
|
+
const item = value[index];
|
|
225
|
+
const itemPath = `${path}[${index}]`;
|
|
226
|
+
if (Array.isArray(item)) {
|
|
227
|
+
if (typeof item[0] !== "string" || typeof item[1] !== "string") return fail(reasonOnFail, itemPath);
|
|
228
|
+
if (item.length > 2 && item[2] !== undefined) {
|
|
229
|
+
const meta = item[2];
|
|
230
|
+
if (!isPlainObject(meta)) return fail(reasonOnFail, `${itemPath}[2]`);
|
|
231
|
+
const key = Object.keys(meta)[0];
|
|
232
|
+
if (key !== undefined) return fail(reasonOnFail, `${itemPath}[2].${key}`);
|
|
233
|
+
}
|
|
234
|
+
pairs.push([item[0], item[1]]);
|
|
235
|
+
} else if (isPlainObject(item)) {
|
|
236
|
+
const failure = fromObject(item, itemPath);
|
|
237
|
+
if (failure) return failure;
|
|
238
|
+
} else {
|
|
239
|
+
return fail(reasonOnFail, itemPath);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
return fail(reasonOnFail, path);
|
|
244
|
+
}
|
|
245
|
+
return ok(pairs.length > 0 ? pairs : undefined);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* v02.9 §4.5: a theme as dotted keys to string leaves (numbers stringified); empty objects vanish.
|
|
250
|
+
* @param {unknown} theme @param {string} path @param {string} reasonOnFail
|
|
251
|
+
* @returns {Success<Record<string, string> | undefined> | Failure}
|
|
252
|
+
*/
|
|
253
|
+
export function flattenTheme(theme, path, reasonOnFail) {
|
|
254
|
+
if (theme === undefined) return ok(undefined);
|
|
255
|
+
if (!isPlainObject(theme)) return fail(reasonOnFail, path);
|
|
256
|
+
/** @type {Record<string, string>} */
|
|
257
|
+
const flat = {};
|
|
258
|
+
/** @param {Record<string, unknown>} object @param {string} prefix @returns {Failure | undefined} */
|
|
259
|
+
const visit = (object, prefix) => {
|
|
260
|
+
for (const [key, value] of Object.entries(object)) {
|
|
261
|
+
const dotted = prefix ? `${prefix}.${key}` : key;
|
|
262
|
+
if (typeof value === "string") flat[dotted] = value;
|
|
263
|
+
else if (typeof value === "number") flat[dotted] = String(value);
|
|
264
|
+
else if (isPlainObject(value)) {
|
|
265
|
+
const failure = visit(value, dotted);
|
|
266
|
+
if (failure) return failure;
|
|
267
|
+
} else return fail(reasonOnFail, `${path}.${dotted}`);
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
};
|
|
271
|
+
const failure = visit(theme, "");
|
|
272
|
+
if (failure) return failure;
|
|
273
|
+
return ok(Object.keys(flat).length > 0 ? flat : undefined);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {unknown} list @param {string} path @param {string} reasonOnFail @returns {Success<string[] | undefined> | Failure} */
|
|
277
|
+
export function translateStringList(list, path, reasonOnFail) {
|
|
278
|
+
if (list === undefined) return ok(undefined);
|
|
279
|
+
if (!Array.isArray(list)) return fail(reasonOnFail, path);
|
|
280
|
+
for (let index = 0; index < list.length; index++) {
|
|
281
|
+
if (typeof list[index] !== "string") return fail(reasonOnFail, `${path}[${index}]`);
|
|
282
|
+
}
|
|
283
|
+
return ok(list.length > 0 ? [...list] : undefined);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @param {unknown} layers @param {string} path @param {string} reasonOnFail @returns {Success<Record<string, number> | undefined> | Failure} */
|
|
287
|
+
export function translateLayers(layers, path, reasonOnFail) {
|
|
288
|
+
if (layers === undefined) return ok(undefined);
|
|
289
|
+
if (!isPlainObject(layers)) return fail(reasonOnFail, path);
|
|
290
|
+
for (const [key, order] of Object.entries(layers)) {
|
|
291
|
+
if (typeof order !== "number") return fail(reasonOnFail, `${path}.${key}`);
|
|
292
|
+
}
|
|
293
|
+
return ok({ .../** @type {Record<string, number>} */ (layers) });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* v02.9 §4.2 step 4, "other objects": an unmarked preset as a `StaticPreset`.
|
|
298
|
+
* @param {Record<string, any>} preset @param {string} path @returns {Success<{ kind: "static", preset: object }> | Failure}
|
|
299
|
+
*/
|
|
300
|
+
export function translateStaticPreset(preset, path) {
|
|
301
|
+
if (typeof preset.name !== "string") return fail("preset.name", `${path}.name`);
|
|
302
|
+
for (const key of PRESET_DYNAMIC_KEYS) {
|
|
303
|
+
if (isNonEmpty(preset[key])) return fail(`preset.${key}`, `${path}.${key}`);
|
|
304
|
+
}
|
|
305
|
+
const shortcuts = translateShortcuts(preset.shortcuts, `${path}.shortcuts`, "preset.shortcuts");
|
|
306
|
+
if (!shortcuts.ok) return shortcuts;
|
|
307
|
+
const safelist = translateStringList(preset.safelist, `${path}.safelist`, "preset.safelist");
|
|
308
|
+
if (!safelist.ok) return safelist;
|
|
309
|
+
const blocklist = translateStringList(preset.blocklist, `${path}.blocklist`, "preset.blocklist");
|
|
310
|
+
if (!blocklist.ok) return blocklist;
|
|
311
|
+
const theme = flattenTheme(preset.theme, `${path}.theme`, "preset.theme");
|
|
312
|
+
if (!theme.ok) return theme;
|
|
313
|
+
const layers = translateLayers(preset.layers, `${path}.layers`, "preset.layers");
|
|
314
|
+
if (!layers.ok) return layers;
|
|
315
|
+
if (preset.enforce !== undefined && preset.enforce !== "pre" && preset.enforce !== "post") return fail("preset.enforce", `${path}.enforce`);
|
|
316
|
+
if (preset.layer !== undefined && typeof preset.layer !== "string") return fail("preset.layer", `${path}.layer`);
|
|
317
|
+
return ok({
|
|
318
|
+
kind: "static",
|
|
319
|
+
preset: defined({ name: preset.name, enforce: preset.enforce, layer: preset.layer, layers: layers.value, shortcuts: shortcuts.value, safelist: safelist.value, blocklist: blocklist.value, theme: theme.value }),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* v02.9 §4.2 step 5: the merged config's static fields as a `StaticConfig` (possibly empty).
|
|
325
|
+
* @param {Record<string, any>} merged @returns {Success<object> | Failure}
|
|
326
|
+
*/
|
|
327
|
+
export function translateInline(merged) {
|
|
328
|
+
const shortcuts = translateShortcuts(merged.shortcuts, "shortcuts", "shortcuts");
|
|
329
|
+
if (!shortcuts.ok) return shortcuts;
|
|
330
|
+
const safelist = translateStringList(merged.safelist, "safelist", "safelist");
|
|
331
|
+
if (!safelist.ok) return safelist;
|
|
332
|
+
const blocklist = translateStringList(merged.blocklist, "blocklist", "blocklist");
|
|
333
|
+
if (!blocklist.ok) return blocklist;
|
|
334
|
+
const theme = flattenTheme(merged.theme, "theme", "theme");
|
|
335
|
+
if (!theme.ok) return theme;
|
|
336
|
+
const layers = translateLayers(merged.layers, "layers", "layers");
|
|
337
|
+
if (!layers.ok) return layers;
|
|
338
|
+
let content;
|
|
339
|
+
if (merged.content !== undefined) {
|
|
340
|
+
if (!isPlainObject(merged.content)) return fail("content", "content");
|
|
341
|
+
if (Object.hasOwn(merged.content, "pipeline") && merged.content.pipeline !== undefined) return fail("content.pipeline", "content.pipeline");
|
|
342
|
+
if (isNonEmpty(merged.content.inline)) return fail("content.inline", "content.inline");
|
|
343
|
+
const filesystem = merged.content.filesystem;
|
|
344
|
+
if (filesystem !== undefined) {
|
|
345
|
+
if (!Array.isArray(filesystem) || filesystem.some((pattern) => typeof pattern !== "string")) return fail("content", "content.filesystem");
|
|
346
|
+
content = { filesystem: [...filesystem] };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return ok(defined({ shortcuts: shortcuts.value, safelist: safelist.value, blocklist: blocklist.value, theme: theme.value, layers: layers.value, content }));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const ALLOWED_TRANSFORMER_NAMES = new Set(["@teacss/transformer-compile-class", "@teacss/directives"]);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* v02.2 §4: allow-listed transformers stay with the host (kept in the facade's `config.transformers`)
|
|
356
|
+
* and never reach Rust; any other name falls back. Repeated names are kept in order; the config view
|
|
357
|
+
* applies `uniqueNamed` later (F7.20). `@teacss/directives` options live in its closure and are
|
|
358
|
+
* checked per call (v02.9 §6.3), so none are read here.
|
|
359
|
+
* @param {unknown} list @returns {Success<object[]> | Failure}
|
|
360
|
+
*/
|
|
361
|
+
export function translateTransformers(list) {
|
|
362
|
+
if (list === undefined || list === null) return ok([]);
|
|
363
|
+
if (!Array.isArray(list)) return fail("transformers", "transformers");
|
|
364
|
+
const kept = [];
|
|
365
|
+
for (let index = 0; index < list.length; index++) {
|
|
366
|
+
const transformer = list[index];
|
|
367
|
+
if (!transformer || typeof transformer !== "object" || typeof transformer.name !== "string" || !ALLOWED_TRANSFORMER_NAMES.has(transformer.name)) {
|
|
368
|
+
return fail("transformers", `transformers[${index}]`);
|
|
369
|
+
}
|
|
370
|
+
kept.push(transformer);
|
|
371
|
+
}
|
|
372
|
+
return ok(kept);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @typedef {{ base: string, entryFile: string | undefined, entryCss: string | undefined, derivedKeys: string[] }} EntryProvenance */
|
|
376
|
+
/** @typedef {{ root: string, entry: { path: string, css: string | null } | null, declarationFiles: string[], derivedKeys: string[] }} EntrySource */
|
|
377
|
+
|
|
378
|
+
/** Test seam for the scanner; the default dynamically imports `@teacss/config/internal/source-scan`. */
|
|
379
|
+
export const scanners = {
|
|
380
|
+
/** @type {(() => Promise<{ scanSourceFiles: (patterns: readonly string[], options: { cwd: string, ignore?: readonly string[], expandDirectories: boolean }) => Promise<string[]> }>) | null} */
|
|
381
|
+
load: null,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
async function loadScanner() {
|
|
385
|
+
if (scanners.load) return scanners.load();
|
|
386
|
+
// @ts-ignore optional peer
|
|
387
|
+
return import("@teacss/config/internal/source-scan");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Entry source and declaration files (v02.9 §4.4). The symbols are read from `userConfig`, not
|
|
392
|
+
* `merged`: `mergeDefinedConfig` copies no symbols.
|
|
393
|
+
* @param {object} userConfig @param {Record<string, unknown>} merged @returns {Promise<Success<EntrySource> | Failure>}
|
|
394
|
+
*/
|
|
395
|
+
export async function resolveEntrySource(userConfig, merged) {
|
|
396
|
+
const provenance = /** @type {EntryProvenance | undefined} */ (/** @type {any} */ (userConfig)[CONFIG_ENTRY_PROVENANCE]);
|
|
397
|
+
const state = /** @type {any} */ (userConfig)[CONFIG_SOURCE_DECLARATIONS];
|
|
398
|
+
if (!provenance || typeof provenance.entryFile !== "string") return ok({ root: process.cwd(), entry: null, declarationFiles: [], derivedKeys: [] });
|
|
399
|
+
|
|
400
|
+
const root = provenance.base;
|
|
401
|
+
const entry = { path: provenance.entryFile, css: provenance.entryCss ?? null };
|
|
402
|
+
const derivedKeys = Array.isArray(provenance.derivedKeys) ? [...new Set(provenance.derivedKeys)] : [];
|
|
403
|
+
/** @type {string[]} */
|
|
404
|
+
let declarationFiles = [];
|
|
405
|
+
const content = /** @type {any} */ (merged.content);
|
|
406
|
+
if (state?.consumed?.status === "read") {
|
|
407
|
+
declarationFiles = state.consumed.members.map((/** @type {{ path: string }} */ member) => member.path);
|
|
408
|
+
} else if (content != null && typeof content === "object" && Array.isArray(content.filesystem)) {
|
|
409
|
+
const filesystem = /** @type {string[]} */ (content.filesystem);
|
|
410
|
+
const includes = filesystem.filter((glob) => !glob.startsWith("!"));
|
|
411
|
+
const excludes = filesystem.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
|
|
412
|
+
let files;
|
|
413
|
+
try {
|
|
414
|
+
const { scanSourceFiles } = await loadScanner();
|
|
415
|
+
files = await scanSourceFiles(includes, { cwd: root, ignore: excludes, expandDirectories: false });
|
|
416
|
+
} catch {
|
|
417
|
+
return fail("declarations-unavailable", "content.filesystem");
|
|
418
|
+
}
|
|
419
|
+
declarationFiles = files.filter((file) => file.endsWith(".css") && file !== entry.path).sort();
|
|
420
|
+
}
|
|
421
|
+
return ok({ root, entry, declarationFiles, derivedKeys });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const ENV_MODES = new Set(["dev", "build"]);
|
|
425
|
+
const UNMATCHED_MODES = new Set(["warn", "error", "off"]);
|
|
426
|
+
|
|
427
|
+
/** @param {Record<string, any>} merged @returns {Success<object> | Failure} */
|
|
428
|
+
function translateScalars(merged) {
|
|
429
|
+
const envMode = merged.envMode ?? "build";
|
|
430
|
+
if (!ENV_MODES.has(envMode)) return fail("envMode", "envMode");
|
|
431
|
+
const unmatched = merged.unmatched ?? "off";
|
|
432
|
+
if (!UNMATCHED_MODES.has(unmatched)) return fail("unmatched", "unmatched");
|
|
433
|
+
const layers = merged.outputToCssLayers;
|
|
434
|
+
let outputToCssLayers;
|
|
435
|
+
if (layers === undefined) outputToCssLayers = null;
|
|
436
|
+
else if (typeof layers === "boolean") outputToCssLayers = layers;
|
|
437
|
+
else if (isPlainObject(layers)) {
|
|
438
|
+
if (Object.hasOwn(layers, "cssLayerName") && layers.cssLayerName !== undefined) return fail("outputToCssLayers.cssLayerName", "outputToCssLayers.cssLayerName");
|
|
439
|
+
outputToCssLayers = { allLayers: Boolean(layers.allLayers) };
|
|
440
|
+
} else return fail("outputToCssLayers", "outputToCssLayers");
|
|
441
|
+
return ok({
|
|
442
|
+
envMode,
|
|
443
|
+
unmatched,
|
|
444
|
+
outputToCssLayers,
|
|
445
|
+
shortcutsLayer: typeof merged.shortcutsLayer === "string" ? merged.shortcutsLayer : null,
|
|
446
|
+
virtualModulePrefix: typeof merged.virtualModulePrefix === "string" ? merged.virtualModulePrefix : null,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** @param {object} userConfig @param {object} defaults @returns {Promise<{ ok: true, request: Record<string, any>, presets: object[], transformers: object[] } | Failure>} */
|
|
451
|
+
async function translate(userConfig, defaults) {
|
|
452
|
+
const merged = mergeDefinedConfig(defaults, userConfig);
|
|
453
|
+
delete merged.engine;
|
|
454
|
+
const keys = checkTopLevelKeys(merged);
|
|
455
|
+
if (!keys.ok) return keys;
|
|
456
|
+
const source = await resolveEntrySource(userConfig, merged);
|
|
457
|
+
if (!source.ok) return source;
|
|
458
|
+
const hasEntry = source.value.entry !== null;
|
|
459
|
+
|
|
460
|
+
const flat = flattenPresets(merged.presets);
|
|
461
|
+
if (!flat.ok) return flat;
|
|
462
|
+
const presetSpecs = [];
|
|
463
|
+
for (let index = 0; index < flat.value.length; index++) {
|
|
464
|
+
const preset = flat.value[index];
|
|
465
|
+
const path = `presets[${index}]`;
|
|
466
|
+
const spec = translateTaggedPreset(preset, path, { hasEntry }) ?? translateStaticPreset(preset, path);
|
|
467
|
+
if (!spec.ok) return spec;
|
|
468
|
+
if (spec.value !== null) presetSpecs.push(spec.value);
|
|
469
|
+
}
|
|
470
|
+
const presets = flat.value.filter((preset) => /** @type {any} */ (preset).name !== ROLE_PALETTES_PRESET);
|
|
471
|
+
|
|
472
|
+
const transformers = translateTransformers(merged.transformers);
|
|
473
|
+
if (!transformers.ok) return transformers;
|
|
474
|
+
const inline = translateInline(merged);
|
|
475
|
+
if (!inline.ok) return inline;
|
|
476
|
+
for (const key of source.value.derivedKeys) delete (/** @type {Record<string, unknown>} */ (inline.value))[key];
|
|
477
|
+
const scalars = translateScalars(merged);
|
|
478
|
+
if (!scalars.ok) return scalars;
|
|
479
|
+
|
|
480
|
+
const { root, entry, declarationFiles } = source.value;
|
|
481
|
+
const request = {
|
|
482
|
+
root,
|
|
483
|
+
entry,
|
|
484
|
+
presets: presetSpecs,
|
|
485
|
+
declarationFiles,
|
|
486
|
+
inline: Object.keys(inline.value).length === 0 ? null : inline.value,
|
|
487
|
+
...scalars.value,
|
|
488
|
+
};
|
|
489
|
+
return { ok: true, request, presets, transformers: transformers.value };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* v02.9 §4: `UserConfig` → `LoadProjectRequest`; the first item that cannot be translated is
|
|
494
|
+
* returned as a `Failure`.
|
|
495
|
+
* @param {object} userConfig @param {object} defaults
|
|
496
|
+
* @returns {Promise<{ ok: true, request: Record<string, any>, presets: object[], transformers: object[] } | Failure>}
|
|
497
|
+
*/
|
|
498
|
+
export async function translateConfig(userConfig = {}, defaults = {}) {
|
|
499
|
+
const outcome = await translate(userConfig, defaults);
|
|
500
|
+
debugTranslate(outcome, userConfig);
|
|
501
|
+
return outcome;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* v02.9 §9: with `TEACSS_ENGINE_DEBUG=1`, the request or the failure goes to stderr.
|
|
506
|
+
* @param {{ ok: true, request: Record<string, any> } | Failure} outcome @param {object} userConfig
|
|
507
|
+
*/
|
|
508
|
+
export function debugTranslate(outcome, userConfig) {
|
|
509
|
+
if (process.env.TEACSS_ENGINE_DEBUG !== "1") return;
|
|
510
|
+
if (outcome.ok) {
|
|
511
|
+
process.stderr.write(`[teacss] engine: request for ${outcome.request.entry?.path ?? "<inline>"}\n`);
|
|
512
|
+
process.stderr.write(`${JSON.stringify(outcome.request, null, 2)}\n`);
|
|
513
|
+
} else {
|
|
514
|
+
process.stderr.write(`[teacss] engine: translate failed ${outcome.reason} at ${outcome.path} (keys: ${Object.keys(userConfig).join(", ")})\n`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export const VALID_MODES = new Set(["auto", "rust", "ts"]);
|
|
519
|
+
|
|
520
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
521
|
+
|
|
522
|
+
/** @param {string} value */
|
|
523
|
+
function parseSemver(value) {
|
|
524
|
+
const match = SEMVER_RE.exec(value);
|
|
525
|
+
if (!match) throw new TypeError(`[@teacss/engine] invalid semver: ${value}`);
|
|
526
|
+
return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] === undefined ? undefined : match[4].split(".") };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* SemVer 2.0 precedence: the numeric triple, then a release above any of its prereleases, then
|
|
531
|
+
* the prerelease identifiers in order (numeric ones by value and below alphanumeric ones, a shorter
|
|
532
|
+
* list first when one is a prefix of the other).
|
|
533
|
+
* @param {string} a @param {string} b @returns {-1 | 0 | 1}
|
|
534
|
+
*/
|
|
535
|
+
export function compareSemver(a, b) {
|
|
536
|
+
const left = parseSemver(a);
|
|
537
|
+
const right = parseSemver(b);
|
|
538
|
+
for (let index = 0; index < 3; index++) {
|
|
539
|
+
if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index] ? -1 : 1;
|
|
540
|
+
}
|
|
541
|
+
if (!left.prerelease || !right.prerelease) return left.prerelease === right.prerelease ? 0 : left.prerelease ? -1 : 1;
|
|
542
|
+
const length = Math.min(left.prerelease.length, right.prerelease.length);
|
|
543
|
+
for (let index = 0; index < length; index++) {
|
|
544
|
+
const x = left.prerelease[index];
|
|
545
|
+
const y = right.prerelease[index];
|
|
546
|
+
if (x === y) continue;
|
|
547
|
+
const xNumeric = /^\d+$/.test(x);
|
|
548
|
+
const yNumeric = /^\d+$/.test(y);
|
|
549
|
+
if (xNumeric && yNumeric) return Number(x) < Number(y) ? -1 : 1;
|
|
550
|
+
if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
|
|
551
|
+
return x < y ? -1 : 1;
|
|
552
|
+
}
|
|
553
|
+
if (left.prerelease.length === right.prerelease.length) return 0;
|
|
554
|
+
return left.prerelease.length < right.prerelease.length ? -1 : 1;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Only the `>=A <B` form `engineCompat.core` uses (v02.11 §7.3). @param {string} version @param {string} range */
|
|
558
|
+
export function satisfiesRange(version, range) {
|
|
559
|
+
const parts = range.trim().split(/\s+/);
|
|
560
|
+
if (parts.length !== 2 || !parts[0].startsWith(">=") || !parts[1].startsWith("<") || parts[1].startsWith("<=")) throw new TypeError(`[@teacss/engine] unsupported engineCompat range: ${range}`);
|
|
561
|
+
return compareSemver(version, parts[0].slice(2)) >= 0 && compareSemver(version, parts[1].slice(1)) < 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* v02.9 §8.1: `TEACSS_ENGINE` over the hook option over `"ts"`; an `"auto"` option stays on TS while
|
|
566
|
+
* `defaultMode` is `"opt-in"`; a host core outside `engineCompat.core` keeps TS, or throws when
|
|
567
|
+
* Rust was asked for.
|
|
568
|
+
* @param {{ hostCoreVersion?: string | undefined, option?: unknown, env?: string | undefined, compat?: { core?: string, defaultMode?: "opt-in" | "auto" } }} args
|
|
569
|
+
* @returns {{ engine: "rust" | "ts", mode: "auto" | "rust" | "ts", source: "env" | "option" | "default", invalidEnv?: string, resolvedBy?: "opt-in", reason?: string, path?: string, detail?: Record<string, unknown> }}
|
|
570
|
+
*/
|
|
571
|
+
export function selectEngineRequest({ hostCoreVersion, option, env, compat = ENGINE_COMPAT }) {
|
|
572
|
+
/** @type {"auto" | "rust" | "ts"} */
|
|
573
|
+
let mode;
|
|
574
|
+
/** @type {"env" | "option" | "default"} */
|
|
575
|
+
let source;
|
|
576
|
+
/** @type {string | undefined} */
|
|
577
|
+
let invalidEnv;
|
|
578
|
+
if (env !== undefined && env !== "" && VALID_MODES.has(env)) {
|
|
579
|
+
mode = /** @type {"auto" | "rust" | "ts"} */ (env);
|
|
580
|
+
source = "env";
|
|
581
|
+
} else {
|
|
582
|
+
if (env !== undefined && env !== "") invalidEnv = env;
|
|
583
|
+
if (typeof option === "string" && VALID_MODES.has(option)) {
|
|
584
|
+
mode = /** @type {"auto" | "rust" | "ts"} */ (option);
|
|
585
|
+
source = "option";
|
|
586
|
+
} else {
|
|
587
|
+
mode = "ts";
|
|
588
|
+
source = "default";
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const base = invalidEnv === undefined ? { mode, source } : { mode, source, invalidEnv };
|
|
592
|
+
if (mode === "ts") return { engine: "ts", ...base };
|
|
593
|
+
if (mode === "auto" && source === "option" && compat.defaultMode === "opt-in") return { engine: "ts", ...base, resolvedBy: "opt-in" };
|
|
594
|
+
if (compat.core && typeof hostCoreVersion === "string" && !satisfiesRange(hostCoreVersion, compat.core)) {
|
|
595
|
+
const detail = { hostCoreVersion, engineVersion: ENGINE_PACKAGE_VERSION, range: compat.core };
|
|
596
|
+
if (mode === "rust") throw new TeacssEngineError("E_ENGINE_COMPAT", `[@teacss/engine] @teacss/core ${hostCoreVersion} is outside engineCompat.core ${compat.core} (engine ${ENGINE_PACKAGE_VERSION})`, detail);
|
|
597
|
+
return { engine: "ts", ...base, reason: "engine-compat", path: "@teacss/core", detail };
|
|
598
|
+
}
|
|
599
|
+
return { engine: "rust", ...base };
|
|
600
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { ENGINE_INSTANCE } from "./constants.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Replays v02.2 `TransformResult.edits` on the host MagicString. A zero-length range is an insert:
|
|
6
|
+
* magic-string throws on a zero-length `update`.
|
|
7
|
+
* @param {{ update(start: number, end: number, text: string): unknown, appendLeft(index: number, text: string): unknown }} s
|
|
8
|
+
* @param {{ start: number, end: number, text: string }[]} edits
|
|
9
|
+
*/
|
|
10
|
+
export function replayEdits(s, edits) {
|
|
11
|
+
for (const { start, end, text } of edits) {
|
|
12
|
+
if (start === end) s.appendLeft(start, text);
|
|
13
|
+
else s.update(start, end, text);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {any} ctx */
|
|
18
|
+
const engineOf = (ctx) => ctx?.generator?.[ENGINE_INSTANCE] ?? null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The object shape of preset-standard/transformers/compile-class.ts:178-274, backed by Rust
|
|
22
|
+
* `transformSource`. It has no `idFilter`: the runtime's pipeline filter picks the modules.
|
|
23
|
+
*/
|
|
24
|
+
export function createCompileClassTransformer() {
|
|
25
|
+
return {
|
|
26
|
+
name: "@teacss/transformer-compile-class",
|
|
27
|
+
enforce: "pre",
|
|
28
|
+
/** @param {any} s @param {string} id @param {any} ctx */
|
|
29
|
+
async transform(s, id, ctx) {
|
|
30
|
+
const engine = engineOf(ctx);
|
|
31
|
+
if (!engine) return;
|
|
32
|
+
const result = engine.transformSource(id, s.original);
|
|
33
|
+
if (!result) return;
|
|
34
|
+
replayEdits(s, result.edits);
|
|
35
|
+
if (ctx.tokens) for (const name of result.compiledClasses) ctx.tokens.add(name);
|
|
36
|
+
if (result.definitionsChanged) ctx.invalidate?.();
|
|
37
|
+
return { highlightAnnotations: result.highlightAnnotations };
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* Releases the module's definitions only: the runtime also calls this when a module stays
|
|
41
|
+
* (context switches, failed transforms), so its tokens are kept (v02.9 §6.1 step 8).
|
|
42
|
+
* @param {string} id @param {any} ctx
|
|
43
|
+
*/
|
|
44
|
+
onModuleRemoved(id, ctx) {
|
|
45
|
+
const engine = engineOf(ctx);
|
|
46
|
+
if (!engine) return;
|
|
47
|
+
let changed = false;
|
|
48
|
+
try {
|
|
49
|
+
changed = engine.clearModuleDefinitions(id);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (/** @type {any} */ (error)?.code !== "E_DISPOSED") throw error;
|
|
52
|
+
}
|
|
53
|
+
if (changed) ctx.invalidate?.();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|