@menapo/vicss 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +662 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1311 -0
- package/dist/config-loader-1kWS0cRU.js +162 -0
- package/dist/config-loader-CG76KA50.js +1 -0
- package/dist/config.d.ts +821 -0
- package/dist/config.js +2690 -0
- package/dist/index.d.ts +1528 -0
- package/dist/index.js +442 -0
- package/dist/lint.d.ts +114 -0
- package/dist/lint.js +0 -0
- package/dist/pool-F3qb30Fy.js +3389 -0
- package/dist/print-B_mn7wBu.js +5127 -0
- package/dist/snapshot-DAJ8FLU4.js +149 -0
- package/dist/theme-zBcr5DHi.js +2460 -0
- package/dist/types-file-D6Iu4kzv.js +2481 -0
- package/dist/vite.d.ts +270 -0
- package/dist/vite.js +2264 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +113 -0
- package/package.json +66 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { $ as isPaletteAtom, Ct as isArbitrary, D as flatLayerName, O as layerName, _ as normalizeStyle, _t as VicssError, b as resolveSystem, bt as compareStrings, c as paletteProblem, f as printTokens, l as printAtoms, t as baseTokens } from "./print-B_mn7wBu.js";
|
|
3
|
+
import { _ as isSfc, c as moduleLoader, g as frameworkOf, h as extractScripts, l as resolveFile, m as detectFramework, p as configuredJsxSource, v as compileModule, y as createModuleCache } from "./pool-F3qb30Fy.js";
|
|
4
|
+
import { u as baseAtoms } from "./theme-zBcr5DHi.js";
|
|
5
|
+
import { C as CompileCache, D as generateTypes, _ as critical, c as scan, f as loadLibraries, h as withLibraries, i as writeIfChanged, k as codeFrame, n as snapshotRoot, p as ownPackageName, r as typesFile, t as snapshotFile, x as hashesProblem } from "./types-file-D6Iu4kzv.js";
|
|
6
|
+
import { r as snapshotText } from "./snapshot-DAJ8FLU4.js";
|
|
7
|
+
import { n as loadSystem, t as CONFIG_FILES } from "./config-loader-1kWS0cRU.js";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { BuildEnvironment, createIdResolver, createServer, loadConfigFromFile, normalizePath, resolveConfig } from "vite";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
//#region src/cli/project.ts
|
|
14
|
+
/**
|
|
15
|
+
* What the commands share: the project's design system, loaded as the
|
|
16
|
+
* plugin loads it, and a compiler that resolves imports the way the app's
|
|
17
|
+
* Vite config does, so a constant read through an alias compiles in the
|
|
18
|
+
* CLI as it does in the build.
|
|
19
|
+
*/
|
|
20
|
+
/** `--config <file>`, relative to the root; else the first `vicss.config.*` there. */
|
|
21
|
+
function loadProject(io, config) {
|
|
22
|
+
return loadSystem(normalizePath(io.cwd), config, (message) => io.stderr(`${message}\n`));
|
|
23
|
+
}
|
|
24
|
+
/** The app's `vite.config.*`, or null when it has none; throws when it cannot be read. */
|
|
25
|
+
async function loadViteConfig(root) {
|
|
26
|
+
return (await loadConfigFromFile({
|
|
27
|
+
command: "build",
|
|
28
|
+
mode: "production"
|
|
29
|
+
}, void 0, root, "silent"))?.config ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The options the app's Vite config gives vicss's plugin, which exposes them
|
|
33
|
+
* as its `api`; empty without a config or the plugin.
|
|
34
|
+
*/
|
|
35
|
+
async function pluginOptions(config) {
|
|
36
|
+
return await registeredOptions(config) ?? {};
|
|
37
|
+
}
|
|
38
|
+
/** The options of the vicss plugin the Vite config registers, or undefined when it registers none. */
|
|
39
|
+
async function registeredOptions(config) {
|
|
40
|
+
for (const plugin of await flatPlugins(config?.plugins ?? [])) {
|
|
41
|
+
const api = plugin.name === "vicss" ? plugin.api : void 0;
|
|
42
|
+
if (typeof api === "object" && api !== null && "options" in api) return api.options;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* What the app's plugin would detect (PLAN §5.12): its `framework` option,
|
|
47
|
+
* else the root `package.json`'s dependencies, else vanilla; JSX by the Vite
|
|
48
|
+
* config's or tsconfig's import source.
|
|
49
|
+
*/
|
|
50
|
+
function frameworkSettings(root, config, options) {
|
|
51
|
+
return {
|
|
52
|
+
scripts: options.framework ?? detectFramework(root) ?? "vanilla",
|
|
53
|
+
jsxImportSource: configuredJsxSource(config ?? {}, root)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The plugin's options and frameworks for `vicss types`, which writes what
|
|
58
|
+
* the plugin writes: the `framework` it registers and its `types.output`.
|
|
59
|
+
* A Vite config that cannot be read leaves the defaults.
|
|
60
|
+
*/
|
|
61
|
+
async function projectSettings(io) {
|
|
62
|
+
const root = normalizePath(io.cwd);
|
|
63
|
+
let config = null;
|
|
64
|
+
try {
|
|
65
|
+
config = await loadViteConfig(root);
|
|
66
|
+
} catch {}
|
|
67
|
+
const registered = await registeredOptions(config);
|
|
68
|
+
const options = registered ?? {};
|
|
69
|
+
return {
|
|
70
|
+
options,
|
|
71
|
+
frameworks: frameworkSettings(root, config, options),
|
|
72
|
+
registered: registered !== void 0
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolution as the app's build resolves: through the plugins and `resolve`
|
|
77
|
+
* options of its `vite.config.*` (aliases, `vite-tsconfig-paths`, a custom
|
|
78
|
+
* `resolveId`), less vicss's own plugin. They run in a dev server with no
|
|
79
|
+
* HTTP server, watcher or dependency scan, so plugins applied only to builds
|
|
80
|
+
* (`apply: "build"`) are left out. Without a Vite config, or when it cannot
|
|
81
|
+
* be loaded, Vite's own resolver with its defaults, after a note.
|
|
82
|
+
*/
|
|
83
|
+
async function createResolver(io) {
|
|
84
|
+
const root = normalizePath(io.cwd);
|
|
85
|
+
const note = (what, error) => io.stderr(`vicss: ${what}, so imports resolve without its plugins and aliases: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
86
|
+
let viteConfig = null;
|
|
87
|
+
try {
|
|
88
|
+
viteConfig = await loadViteConfig(root);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
note("could not read the Vite config", error);
|
|
91
|
+
}
|
|
92
|
+
const frameworks = frameworkSettings(root, viteConfig, await pluginOptions(viteConfig));
|
|
93
|
+
if (viteConfig) {
|
|
94
|
+
const config = viteConfig;
|
|
95
|
+
try {
|
|
96
|
+
const server = await createServer({
|
|
97
|
+
...config,
|
|
98
|
+
configFile: false,
|
|
99
|
+
root: normalizePath(path.resolve(root, config.root ?? ".")),
|
|
100
|
+
mode: "production",
|
|
101
|
+
logLevel: "silent",
|
|
102
|
+
clearScreen: false,
|
|
103
|
+
appType: "custom",
|
|
104
|
+
plugins: (await flatPlugins(config.plugins ?? [])).filter((plugin) => !isVicss(plugin)),
|
|
105
|
+
server: {
|
|
106
|
+
...config.server,
|
|
107
|
+
middlewareMode: true,
|
|
108
|
+
ws: false,
|
|
109
|
+
watch: null,
|
|
110
|
+
hmr: false
|
|
111
|
+
},
|
|
112
|
+
optimizeDeps: {
|
|
113
|
+
...config.optimizeDeps,
|
|
114
|
+
noDiscovery: true,
|
|
115
|
+
include: []
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const container = server.environments.client.pluginContainer;
|
|
119
|
+
return {
|
|
120
|
+
resolve: async (source, importer) => await container.resolveId(source, importer) ?? null,
|
|
121
|
+
frameworks,
|
|
122
|
+
close: () => server.close()
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
note("could not start the Vite config's plugins", error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const config = await resolveConfig({
|
|
129
|
+
root,
|
|
130
|
+
configFile: false,
|
|
131
|
+
logLevel: "silent",
|
|
132
|
+
...viteConfig?.resolve ? { resolve: viteConfig.resolve } : {}
|
|
133
|
+
}, "build");
|
|
134
|
+
const environment = new BuildEnvironment("client", config);
|
|
135
|
+
const resolveId = createIdResolver(config, {});
|
|
136
|
+
return {
|
|
137
|
+
resolve: async (source, importer) => {
|
|
138
|
+
const id = await resolveId(environment, source, importer);
|
|
139
|
+
return id === void 0 ? null : { id };
|
|
140
|
+
},
|
|
141
|
+
frameworks,
|
|
142
|
+
close: async () => {}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** `plugins` as Vite flattens them: nested arrays and promises, falsy entries dropped. */
|
|
146
|
+
async function flatPlugins(options) {
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const item of options) {
|
|
149
|
+
const option = await item;
|
|
150
|
+
if (Array.isArray(option)) out.push(...await flatPlugins(option));
|
|
151
|
+
else if (option) out.push(option);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** vicss's own plugins: the CLI compiles itself, and they would write the types file. */
|
|
156
|
+
const isVicss = (plugin) => plugin.name === "vicss" || plugin.name.startsWith("vicss:");
|
|
157
|
+
/** Every call one file's compile refused, thrown together once the file is done. */
|
|
158
|
+
var FileErrors = class extends Error {
|
|
159
|
+
errors;
|
|
160
|
+
constructor(errors) {
|
|
161
|
+
super(errors.map((error) => error.message).join("\n"));
|
|
162
|
+
this.name = "FileErrors";
|
|
163
|
+
this.errors = errors;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
function createCompiler(system, resolve, root, inspect, frameworks = {
|
|
167
|
+
scripts: "vanilla",
|
|
168
|
+
jsxImportSource: void 0
|
|
169
|
+
}) {
|
|
170
|
+
const cache = createModuleCache();
|
|
171
|
+
const warnings = /* @__PURE__ */ new Map();
|
|
172
|
+
const pkg = ownPackageName(root);
|
|
173
|
+
return {
|
|
174
|
+
warnings,
|
|
175
|
+
async compile(id, code, script) {
|
|
176
|
+
const resolutions = {};
|
|
177
|
+
const own = [];
|
|
178
|
+
const errors = [];
|
|
179
|
+
const result = await compileModule(code, {
|
|
180
|
+
system,
|
|
181
|
+
id,
|
|
182
|
+
root,
|
|
183
|
+
...pkg ? { package: pkg } : {},
|
|
184
|
+
importSources: system.config.importSources ?? [],
|
|
185
|
+
loadModule: moduleLoader(resolve, resolutions),
|
|
186
|
+
framework: frameworkOf(id, code, frameworks),
|
|
187
|
+
...script ? {
|
|
188
|
+
lang: script.lang,
|
|
189
|
+
functionBody: script.functionBody === true
|
|
190
|
+
} : {},
|
|
191
|
+
sourceMap: false,
|
|
192
|
+
cache,
|
|
193
|
+
warn: (message, warning) => own.push({
|
|
194
|
+
message,
|
|
195
|
+
...warning
|
|
196
|
+
}),
|
|
197
|
+
onError: (error) => errors.push(error)
|
|
198
|
+
});
|
|
199
|
+
if (own.length > 0) warnings.set(id, [...warnings.get(id) ?? [], ...own]);
|
|
200
|
+
inspect?.(id, result);
|
|
201
|
+
if (errors.length > 0) throw new FileErrors(errors);
|
|
202
|
+
return {
|
|
203
|
+
...result,
|
|
204
|
+
resolutions,
|
|
205
|
+
warnings: own.map((w) => w.message)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Runs `write`, which writes `file`, and reports a failure (a missing
|
|
212
|
+
* permission, a file where a directory should be) as `vicss: cannot write …`
|
|
213
|
+
* rather than a stack trace. Returns whether it succeeded.
|
|
214
|
+
*/
|
|
215
|
+
function writeOutput(io, file, write) {
|
|
216
|
+
try {
|
|
217
|
+
write();
|
|
218
|
+
return true;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
221
|
+
io.stderr(`vicss: cannot write ${relativeTo(normalizePath(io.cwd), normalizePath(file))}: ${reason}\n`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** `src/card.ts` for an id under the root; other ids as they are. */
|
|
226
|
+
function relativeTo(root, file) {
|
|
227
|
+
const rel = path.relative(root, file);
|
|
228
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? normalizePath(rel) : file;
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/cli/report.ts
|
|
232
|
+
/**
|
|
233
|
+
* Diagnostics as the CLI prints them: `file:line:column severity
|
|
234
|
+
* vicss(CODE): message`, the hint and a code frame, as JSON, or as GitHub
|
|
235
|
+
* Actions annotations, which put each one on its line in a pull request.
|
|
236
|
+
*/
|
|
237
|
+
/** The diagnostics of anything a command caught: one, or one per call a file refused. */
|
|
238
|
+
function diagnosticsOf(error, root, severity = "error", file) {
|
|
239
|
+
return error instanceof FileErrors ? error.errors.map((each) => diagnosticOf(each, root, severity, file)) : [diagnosticOf(error, root, severity, file)];
|
|
240
|
+
}
|
|
241
|
+
/** A diagnostic for anything a command caught; other errors become `INTERNAL`. */
|
|
242
|
+
function diagnosticOf(error, root, severity = "error", file) {
|
|
243
|
+
if (!(error instanceof VicssError)) return {
|
|
244
|
+
severity,
|
|
245
|
+
code: "INTERNAL",
|
|
246
|
+
message: error instanceof Error ? error.message : String(error),
|
|
247
|
+
file: file && relativeTo(root, file)
|
|
248
|
+
};
|
|
249
|
+
const loc = error.loc;
|
|
250
|
+
const at = loc?.file ?? file;
|
|
251
|
+
return {
|
|
252
|
+
severity,
|
|
253
|
+
code: error.code,
|
|
254
|
+
message: error.message,
|
|
255
|
+
hint: error.hint,
|
|
256
|
+
file: at && relativeTo(root, at),
|
|
257
|
+
line: loc?.line,
|
|
258
|
+
column: loc?.column,
|
|
259
|
+
frame: loc && frameOf(loc.file, loc.line, loc.column)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function frameOf(file, line, column) {
|
|
263
|
+
try {
|
|
264
|
+
return codeFrame(readFileSync(file.replace(/[?#].*$/, ""), "utf8"), line, column);
|
|
265
|
+
} catch {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function where(d) {
|
|
270
|
+
if (d.file === void 0) return "";
|
|
271
|
+
if (d.line === void 0) return `${d.file} `;
|
|
272
|
+
return `${d.file}:${d.line}:${(d.column ?? 0) + 1} `;
|
|
273
|
+
}
|
|
274
|
+
/** Text for a terminal or a CI log. */
|
|
275
|
+
function formatText(d) {
|
|
276
|
+
const lines = [`${where(d)}${d.severity} vicss(${d.code}): ${d.message}`];
|
|
277
|
+
if (d.hint) lines.push(...d.hint.split("\n").map((line) => ` ${line}`));
|
|
278
|
+
if (d.frame) lines.push("", ...d.frame.split("\n").map((line) => ` ${line}`));
|
|
279
|
+
return `${lines.join("\n")}\n`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* A GitHub Actions workflow command (`::error file=…,line=…::message`).
|
|
283
|
+
* Data escapes `%`, CR and LF; properties also `:` and `,`.
|
|
284
|
+
*/
|
|
285
|
+
function formatAnnotation(d, fileOf = (file) => file) {
|
|
286
|
+
const data = (text) => text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
287
|
+
const property = (text) => data(text).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
288
|
+
const props = [
|
|
289
|
+
d.file !== void 0 && `file=${property(fileOf(d.file))}`,
|
|
290
|
+
d.line !== void 0 && `line=${d.line}`,
|
|
291
|
+
d.line !== void 0 && `col=${(d.column ?? 0) + 1}`,
|
|
292
|
+
`title=${property(`vicss(${d.code})`)}`
|
|
293
|
+
].filter(Boolean);
|
|
294
|
+
const message = d.hint ? `${d.message}\n${d.hint}` : d.message;
|
|
295
|
+
return `::${d.severity} ${props.join(",")}::${data(message)}\n`;
|
|
296
|
+
}
|
|
297
|
+
/** Plain data for `--json`. */
|
|
298
|
+
function toJson(d) {
|
|
299
|
+
return {
|
|
300
|
+
severity: d.severity,
|
|
301
|
+
code: d.code,
|
|
302
|
+
message: d.message,
|
|
303
|
+
...d.hint === void 0 ? {} : { hint: d.hint },
|
|
304
|
+
...d.file === void 0 ? {} : { file: d.file },
|
|
305
|
+
...d.line === void 0 ? {} : {
|
|
306
|
+
line: d.line,
|
|
307
|
+
column: (d.column ?? 0) + 1
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/cli/check.ts
|
|
313
|
+
/**
|
|
314
|
+
* `vicss check` (PLAN §5.15): the CI gate. It compiles `include` as the
|
|
315
|
+
* build's pre-scan does, `.vue`, `.svelte` and `.astro` files by their
|
|
316
|
+
* scripts (§5.4), but reports every error rather than the first, with the
|
|
317
|
+
* ordering invariant over all files and the installed libraries' manifests
|
|
318
|
+
* (§5.13), their contract with the design system, the budget of values
|
|
319
|
+
* outside the scales (D8), the share of hashed atoms (PLAN §6) and tokens
|
|
320
|
+
* nothing uses.
|
|
321
|
+
*/
|
|
322
|
+
/**
|
|
323
|
+
* PLAN §6: a warning above this share of hashed atoms (values outside the
|
|
324
|
+
* scales, and long or unusual literals), as a design system erodes.
|
|
325
|
+
*/
|
|
326
|
+
const HASHED_BUDGET = .1;
|
|
327
|
+
/** Unused tokens named in a message; `--json` lists them all. */
|
|
328
|
+
const LISTED = 12;
|
|
329
|
+
const EMPTY = {
|
|
330
|
+
files: 0,
|
|
331
|
+
atoms: 0,
|
|
332
|
+
bases: 0,
|
|
333
|
+
libraries: [],
|
|
334
|
+
arbitrary: {
|
|
335
|
+
count: 0,
|
|
336
|
+
byFile: {}
|
|
337
|
+
},
|
|
338
|
+
hashed: {
|
|
339
|
+
count: 0,
|
|
340
|
+
share: 0,
|
|
341
|
+
byFile: {}
|
|
342
|
+
},
|
|
343
|
+
unusedTokens: [],
|
|
344
|
+
diagnostics: []
|
|
345
|
+
};
|
|
346
|
+
async function check(io, options) {
|
|
347
|
+
const root = normalizePath(io.cwd);
|
|
348
|
+
let report;
|
|
349
|
+
try {
|
|
350
|
+
report = await run$1(io, root, options);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
report = {
|
|
353
|
+
...EMPTY,
|
|
354
|
+
diagnostics: [diagnosticOf(error, root)]
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
write(io, options, report);
|
|
358
|
+
return report.diagnostics.some((d) => d.severity === "error") ? 1 : 0;
|
|
359
|
+
}
|
|
360
|
+
async function run$1(io, root, options) {
|
|
361
|
+
const { system, config } = await loadProject(io, options.config);
|
|
362
|
+
const resolver = await createResolver(io);
|
|
363
|
+
try {
|
|
364
|
+
return await checkWith(root, options, system, resolver, presetTokens(config));
|
|
365
|
+
} finally {
|
|
366
|
+
await resolver.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function checkWith(root, options, system, { resolve, frameworks }, fromPresets = /* @__PURE__ */ new Set()) {
|
|
370
|
+
const arbitrary = {};
|
|
371
|
+
const hashedByFile = {};
|
|
372
|
+
const compiler = createCompiler(system, resolve, root, (id, compiled) => {
|
|
373
|
+
const rel = relativeTo(root, id);
|
|
374
|
+
if (compiled.arbitrary > 0) arbitrary[rel] = (arbitrary[rel] ?? 0) + compiled.arbitrary;
|
|
375
|
+
const hashed = withBases(compiled).filter(isArbitrary).length;
|
|
376
|
+
if (hashed > 0) hashedByFile[rel] = (hashedByFile[rel] ?? 0) + hashed;
|
|
377
|
+
}, frameworks);
|
|
378
|
+
const result = await scan({
|
|
379
|
+
root,
|
|
380
|
+
system,
|
|
381
|
+
configHash: system.hash,
|
|
382
|
+
importSources: system.config.importSources ?? [],
|
|
383
|
+
cache: new CompileCache(),
|
|
384
|
+
resolve: (source, importer) => resolveFile(resolve, source, importer),
|
|
385
|
+
keepGoing: true,
|
|
386
|
+
compile: (id, code, script) => compiler.compile(id, code, script)
|
|
387
|
+
});
|
|
388
|
+
const diagnostics = result.failures.flatMap((failure) => diagnosticsOf(failure.error, root, "error", failure.id));
|
|
389
|
+
let libraries = [];
|
|
390
|
+
let sheet = result;
|
|
391
|
+
try {
|
|
392
|
+
libraries = loadLibraries(root, system);
|
|
393
|
+
sheet = withLibraries(result, libraries, system);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
diagnostics.push(...diagnosticsOf(error, root, "error"));
|
|
396
|
+
}
|
|
397
|
+
const palette = paletteProblem({
|
|
398
|
+
system,
|
|
399
|
+
atoms: sheet.atoms,
|
|
400
|
+
rules: sheet.rules,
|
|
401
|
+
referenced: sheet.tokens,
|
|
402
|
+
tokens: "used"
|
|
403
|
+
});
|
|
404
|
+
if (palette) diagnostics.push(...diagnosticsOf(palette, root, "error"));
|
|
405
|
+
for (const [id, warnings] of compiler.warnings) for (const warning of warnings) diagnostics.push(warningOf(warning, root, id));
|
|
406
|
+
const offScale = Object.values(arbitrary).reduce((sum, n) => sum + n, 0);
|
|
407
|
+
if (options.maxArbitrary !== void 0 && offScale > options.maxArbitrary) diagnostics.push({
|
|
408
|
+
severity: "error",
|
|
409
|
+
code: "ARBITRARY_BUDGET",
|
|
410
|
+
message: `${offScale} values are outside the scales; the budget is ${options.maxArbitrary} (--max-arbitrary)`,
|
|
411
|
+
hint: `By file: ${counts(arbitrary)}. Add tokens for the values the design system lacks.`
|
|
412
|
+
});
|
|
413
|
+
const every = withBases(result);
|
|
414
|
+
const hashed = every.filter(isArbitrary).length;
|
|
415
|
+
const share = every.length === 0 ? 0 : hashed / every.length;
|
|
416
|
+
if (share > .1) diagnostics.push({
|
|
417
|
+
severity: "warning",
|
|
418
|
+
code: "HASHED_ATOMS",
|
|
419
|
+
message: `${percent(share)} of the atoms (${hashed} of ${every.length}) get hashed class names (\`~…\`): values outside the scales and long or unusual literals; the budget is ${HASHED_BUDGET * 100} %`,
|
|
420
|
+
hint: `By file: ${counts(hashedByFile)}.`
|
|
421
|
+
});
|
|
422
|
+
const unusedTokens = unused(system, sheet).filter((key) => !fromPresets.has(key));
|
|
423
|
+
if (unusedTokens.length > 0) {
|
|
424
|
+
const more = unusedTokens.length - LISTED;
|
|
425
|
+
const verb = unusedTokens.length === 1 ? "token is" : "tokens are";
|
|
426
|
+
diagnostics.push({
|
|
427
|
+
severity: "warning",
|
|
428
|
+
code: "UNUSED_TOKENS",
|
|
429
|
+
message: `${unusedTokens.length} ${verb} defined but never used: ${unusedTokens.slice(0, LISTED).join(", ")}${more > 0 ? ` and ${more} more` : ""}`,
|
|
430
|
+
hint: "No style, globalCss rule, keyframe, theme() or token() reads them, so they stay out of the CSS."
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const own = new Set(result.atoms.map((atom) => atom.className));
|
|
434
|
+
return {
|
|
435
|
+
files: result.files.length,
|
|
436
|
+
atoms: result.atoms.length,
|
|
437
|
+
bases: result.rules.filter((rule) => rule.kind === "base").length,
|
|
438
|
+
libraries: libraries.map((library) => ({
|
|
439
|
+
name: library.name,
|
|
440
|
+
atoms: library.atoms.filter((atom) => !own.has(atom.className)).length
|
|
441
|
+
})),
|
|
442
|
+
arbitrary: {
|
|
443
|
+
count: offScale,
|
|
444
|
+
byFile: arbitrary
|
|
445
|
+
},
|
|
446
|
+
hashed: {
|
|
447
|
+
count: hashed,
|
|
448
|
+
share,
|
|
449
|
+
byFile: hashedByFile
|
|
450
|
+
},
|
|
451
|
+
unusedTokens,
|
|
452
|
+
diagnostics: sorted(diagnostics)
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** Atoms and the atoms the flattened bases among the rules stand for, one per class. */
|
|
456
|
+
function withBases(styles) {
|
|
457
|
+
const own = new Set(styles.atoms.map((atom) => atom.className));
|
|
458
|
+
return [...styles.atoms, ...baseAtoms(styles.rules).filter((atom) => !own.has(atom.className))];
|
|
459
|
+
}
|
|
460
|
+
/** A strictness "warn" report: its message without the hint the compiler appended. */
|
|
461
|
+
function warningOf(warning, root, id) {
|
|
462
|
+
const { hint, loc } = warning;
|
|
463
|
+
const suffix = hint === void 0 ? "" : `. ${hint}`;
|
|
464
|
+
const message = suffix && warning.message.endsWith(suffix) ? warning.message.slice(0, -suffix.length) : warning.message;
|
|
465
|
+
return {
|
|
466
|
+
severity: "warning",
|
|
467
|
+
code: warning.code,
|
|
468
|
+
message,
|
|
469
|
+
hint,
|
|
470
|
+
file: relativeTo(root, loc?.file ?? id),
|
|
471
|
+
line: loc?.line,
|
|
472
|
+
column: loc?.column,
|
|
473
|
+
frame: loc && frameOf(loc.file, loc.line, loc.column)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* `scale.path` of every token nothing uses, in table order: what the sheet
|
|
478
|
+
* leaves out, less the breakpoints and containers a condition in use is made
|
|
479
|
+
* from, which shape queries rather than reaching the CSS as properties.
|
|
480
|
+
*/
|
|
481
|
+
/**
|
|
482
|
+
* The tokens the config's presets define. A preset's scales belong to its
|
|
483
|
+
* author: an app that uses ten of Tailwind's colours can do nothing about the
|
|
484
|
+
* other two hundred, so only the tokens the config declares are reported.
|
|
485
|
+
*/
|
|
486
|
+
function presetTokens(config) {
|
|
487
|
+
if (!config.presets?.length) return /* @__PURE__ */ new Set();
|
|
488
|
+
try {
|
|
489
|
+
const { tokens } = resolveSystem({ presets: config.presets });
|
|
490
|
+
return new Set([...tokens.values()].flatMap((table) => [...table.values()].map((token) => `${token.scale}.${token.path}`)));
|
|
491
|
+
} catch {
|
|
492
|
+
return /* @__PURE__ */ new Set();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function unused(system, result) {
|
|
496
|
+
const used = new Set(baseTokens({
|
|
497
|
+
system,
|
|
498
|
+
atoms: result.atoms,
|
|
499
|
+
rules: result.rules,
|
|
500
|
+
referenced: result.tokens,
|
|
501
|
+
tokens: "used"
|
|
502
|
+
}).map((token) => `${token.scale}.${token.path}`));
|
|
503
|
+
for (const name of conditionsInUse(system, result)) for (const ref of system.conditions.get(name)?.tokens ?? []) used.add(`${ref.scale}.${ref.path}`);
|
|
504
|
+
return [...system.tokens.values()].flatMap((table) => [...table.values()].filter((token) => !token.palette).map((token) => `${token.scale}.${token.path}`).filter((key) => !used.has(key)));
|
|
505
|
+
}
|
|
506
|
+
/** Condition names the styles, `globalCss` and the semantic tokens' modes use. */
|
|
507
|
+
function conditionsInUse(system, result) {
|
|
508
|
+
const names = /* @__PURE__ */ new Set();
|
|
509
|
+
for (const atom of withBases(result)) for (const name of atom.chain) names.add(name);
|
|
510
|
+
for (const [selector, style] of Object.entries(system.config.globalCss ?? {})) {
|
|
511
|
+
if (style === void 0) continue;
|
|
512
|
+
const atoms = normalizeStyle(style, {
|
|
513
|
+
system,
|
|
514
|
+
root: `globalCss[${selector}]`,
|
|
515
|
+
privateAccess: true
|
|
516
|
+
});
|
|
517
|
+
for (const atom of atoms) for (const name of atom.chain) names.add(name);
|
|
518
|
+
}
|
|
519
|
+
for (const table of system.tokens.values()) for (const token of table.values()) for (const mode of Object.keys(token.modes ?? {})) names.add(mode);
|
|
520
|
+
return names;
|
|
521
|
+
}
|
|
522
|
+
/** Errors first, then by file and line. */
|
|
523
|
+
function sorted(diagnostics) {
|
|
524
|
+
const rank = (d) => d.severity === "error" ? 0 : 1;
|
|
525
|
+
return diagnostics.sort((a, b) => rank(a) - rank(b) || compareStrings(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0));
|
|
526
|
+
}
|
|
527
|
+
const percent = (share) => `${(share * 100).toFixed(1)} %`;
|
|
528
|
+
function counts(byFile) {
|
|
529
|
+
const entries = Object.entries(byFile).sort(([a], [b]) => compareStrings(a, b));
|
|
530
|
+
return entries.length === 0 ? "none" : entries.map(([file, n]) => `${file} ${n}`).join(", ");
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* GitHub reads an annotation's file relative to the repository, which a
|
|
534
|
+
* project in a monorepo package is not the root of: relative to
|
|
535
|
+
* `GITHUB_WORKSPACE` when the file is inside it, else absolute.
|
|
536
|
+
*/
|
|
537
|
+
function workspacePath(root, workspace, file) {
|
|
538
|
+
const absolute = normalizePath(path.resolve(root, file));
|
|
539
|
+
if (workspace === void 0 || workspace === "") return file;
|
|
540
|
+
const rel = path.relative(workspace, absolute);
|
|
541
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? normalizePath(rel) : absolute;
|
|
542
|
+
}
|
|
543
|
+
function write(io, options, report) {
|
|
544
|
+
if (options.json) {
|
|
545
|
+
const { diagnostics, ...rest } = report;
|
|
546
|
+
const json = {
|
|
547
|
+
...rest,
|
|
548
|
+
arbitrary: {
|
|
549
|
+
...rest.arbitrary,
|
|
550
|
+
...options.maxArbitrary === void 0 ? {} : { max: options.maxArbitrary }
|
|
551
|
+
},
|
|
552
|
+
errors: diagnostics.filter((d) => d.severity === "error").map(toJson),
|
|
553
|
+
warnings: diagnostics.filter((d) => d.severity === "warning").map(toJson)
|
|
554
|
+
};
|
|
555
|
+
io.stdout(`${JSON.stringify(json, null, 2)}\n`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
for (const diagnostic of report.diagnostics) if (diagnostic.severity === "error") io.stderr(formatText(diagnostic));
|
|
559
|
+
else io.stdout(formatText(diagnostic));
|
|
560
|
+
if (io.env["GITHUB_ACTIONS"]) {
|
|
561
|
+
const root = normalizePath(io.cwd);
|
|
562
|
+
const workspace = io.env["GITHUB_WORKSPACE"];
|
|
563
|
+
for (const diagnostic of report.diagnostics) io.stdout(formatAnnotation(diagnostic, (file) => workspacePath(root, workspace, file)));
|
|
564
|
+
}
|
|
565
|
+
const errors = report.diagnostics.filter((d) => d.severity === "error").length;
|
|
566
|
+
const warnings = report.diagnostics.length - errors;
|
|
567
|
+
const budget = options.maxArbitrary === void 0 ? "" : ` (budget ${options.maxArbitrary})`;
|
|
568
|
+
const libraries = report.libraries.map((library) => `${library.atoms} from ${library.name}`);
|
|
569
|
+
const fromLibraries = libraries.length === 0 ? "" : ` (+ ${libraries.join(", ")})`;
|
|
570
|
+
const bases = report.bases === 0 ? "" : `, ${report.bases} flattened base${report.bases === 1 ? "" : "s"}`;
|
|
571
|
+
io.stdout(`vicss check: ${report.files} files, ${report.atoms} atoms${fromLibraries}${bases}, ${report.arbitrary.count} values outside the scales${budget}, ${report.hashed.count} hashed atoms (${percent(report.hashed.share)}); ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}\n`);
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/cli/critical.ts
|
|
575
|
+
/**
|
|
576
|
+
* `vicss critical <dir>` (PLAN D16): critical CSS for the pages a framework
|
|
577
|
+
* writes after `vite build`, which the plugin's HTML hook never sees. It
|
|
578
|
+
* reads only the output, so it needs neither the config nor a scan.
|
|
579
|
+
*/
|
|
580
|
+
const kB = (bytes) => `${(bytes / 1e3).toFixed(2)} kB`;
|
|
581
|
+
async function criticalCommand(io, dir, options) {
|
|
582
|
+
const { json, ...rest } = options;
|
|
583
|
+
let result;
|
|
584
|
+
try {
|
|
585
|
+
result = await critical(path.resolve(io.cwd, dir), rest);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
588
|
+
io.stderr(`error vicss(CRITICAL): ${message.replace(/^vicss: /, "")}\n`);
|
|
589
|
+
return 1;
|
|
590
|
+
}
|
|
591
|
+
if (json) {
|
|
592
|
+
const pages = result.pages.map(({ html: _html, ...page }) => ({
|
|
593
|
+
...page,
|
|
594
|
+
..._html === void 0 ? {} : { inlined: true }
|
|
595
|
+
}));
|
|
596
|
+
io.stdout(`${JSON.stringify({
|
|
597
|
+
...result,
|
|
598
|
+
pages
|
|
599
|
+
}, null, 2)}\n`);
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
const width = Math.max(0, ...result.pages.map((page) => page.page.length));
|
|
603
|
+
for (const page of result.pages) {
|
|
604
|
+
const name = page.page.padEnd(width);
|
|
605
|
+
io.stdout(page.html === void 0 ? `${name} left as it was: ${page.skipped}\n` : `${name} ${kB(page.bytes)} │ gzip: ${kB(page.gzip)} ${page.hashes.length} inline style${page.hashes.length === 1 ? "" : "s"}\n`);
|
|
606
|
+
}
|
|
607
|
+
for (const warning of result.warnings) io.stdout(`warning vicss(CRITICAL): ${warning}\n`);
|
|
608
|
+
io.stdout(`${result.summary.replace(/^vicss: /, "vicss critical: ")}${options.dryRun ? " (dry run: nothing written)" : ""}\n`);
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/cli/init.ts
|
|
613
|
+
/**
|
|
614
|
+
* `vicss init` and `vicss types` (PLAN §5.15): a starting config and the
|
|
615
|
+
* generated `vicss.d.ts`, which `vicss types` rewrites in CI where no dev
|
|
616
|
+
* server or build has written it, with the lint snapshot next to it.
|
|
617
|
+
*/
|
|
618
|
+
/**
|
|
619
|
+
* A small design system to start from when no preset or token file gives one
|
|
620
|
+
* (PLAN §4.1), its semantic tokens written with typed references (D32).
|
|
621
|
+
*/
|
|
622
|
+
const STARTER = `export default defineConfig(
|
|
623
|
+
{
|
|
624
|
+
tokens: {
|
|
625
|
+
colors: {
|
|
626
|
+
white: "#fff",
|
|
627
|
+
gray: { 50: "oklch(98.5% 0 0)", 900: "oklch(21% 0.034 264)", 950: "oklch(13% 0.028 262)" },
|
|
628
|
+
blue: { 600: "oklch(54.6% 0.245 263)" },
|
|
629
|
+
},
|
|
630
|
+
// A linear scale: padding: 4 is calc(var(--spacing) * 4).
|
|
631
|
+
spacing: { step: "0.25rem" },
|
|
632
|
+
radii: { sm: "0.25rem", md: "0.375rem", lg: "0.5rem", full: "9999px" },
|
|
633
|
+
breakpoints: { sm: "40rem", md: "48rem", lg: "64rem", xl: "80rem" },
|
|
634
|
+
},
|
|
635
|
+
// A modern reset in the vicss.reset layer.
|
|
636
|
+
reset: true,
|
|
637
|
+
},
|
|
638
|
+
// Meaning, not colour: components use these, and modes switch them. The
|
|
639
|
+
// tokens above arrive typed: colors.gray[950] is "{colors.gray.950}".
|
|
640
|
+
({ colors }) => ({
|
|
641
|
+
semanticTokens: {
|
|
642
|
+
colors: {
|
|
643
|
+
surface: { base: colors.white, _dark: colors.gray[950] },
|
|
644
|
+
text: { base: colors.gray[900], _dark: colors.gray[50] },
|
|
645
|
+
accent: colors.blue[600],
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
}),
|
|
649
|
+
);
|
|
650
|
+
`;
|
|
651
|
+
function configSource(preset, dtcg) {
|
|
652
|
+
const imports = [
|
|
653
|
+
"defineConfig",
|
|
654
|
+
...dtcg ? ["dtcg"] : [],
|
|
655
|
+
...preset === "tailwind" ? ["tailwind"] : []
|
|
656
|
+
];
|
|
657
|
+
const presets = [...preset === "tailwind" ? ["tailwind"] : [], ...dtcg ? [`dtcg(${JSON.stringify(dtcg)})`] : []];
|
|
658
|
+
const head = [`import { ${imports.join(", ")} } from "@menapo/vicss/config";`, ""];
|
|
659
|
+
if (presets.length === 0) return [...head, STARTER].join("\n");
|
|
660
|
+
return [
|
|
661
|
+
...head,
|
|
662
|
+
"export default defineConfig({",
|
|
663
|
+
` presets: [${presets.join(", ")}],`,
|
|
664
|
+
" // A modern reset in the vicss.reset layer.",
|
|
665
|
+
" reset: true,",
|
|
666
|
+
"});",
|
|
667
|
+
""
|
|
668
|
+
].join("\n");
|
|
669
|
+
}
|
|
670
|
+
const PLUGIN_SNIPPET = `Add the plugin to vite.config.ts:
|
|
671
|
+
|
|
672
|
+
import { vicss } from "@menapo/vicss/vite";
|
|
673
|
+
|
|
674
|
+
export default defineConfig({
|
|
675
|
+
plugins: [vicss()],
|
|
676
|
+
});
|
|
677
|
+
`;
|
|
678
|
+
async function init(io, options) {
|
|
679
|
+
const root = normalizePath(io.cwd);
|
|
680
|
+
const existing = CONFIG_FILES.find((name) => existsSync(path.join(root, name)));
|
|
681
|
+
if (existing && !options.force) {
|
|
682
|
+
io.stderr(`vicss: ${existing} already exists. Pass --force to replace it with vicss.config.ts.\n`);
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
let dtcg;
|
|
686
|
+
if (options.dtcg !== void 0) {
|
|
687
|
+
const file = path.resolve(root, options.dtcg);
|
|
688
|
+
if (!existsSync(file)) {
|
|
689
|
+
io.stderr(`vicss: ${options.dtcg} does not exist.\n`);
|
|
690
|
+
return 1;
|
|
691
|
+
}
|
|
692
|
+
const rel = normalizePath(path.relative(root, file));
|
|
693
|
+
dtcg = rel.startsWith(".") ? rel : `./${rel}`;
|
|
694
|
+
}
|
|
695
|
+
const preset = options.preset ?? (dtcg ? "none" : "tailwind");
|
|
696
|
+
const config = path.join(root, "vicss.config.ts");
|
|
697
|
+
if (!writeOutput(io, config, () => writeFileSync(config, configSource(preset, dtcg)))) return 1;
|
|
698
|
+
io.stdout("Wrote vicss.config.ts.\n");
|
|
699
|
+
const settings = await projectSettings(io);
|
|
700
|
+
const code = await types(io, {
|
|
701
|
+
config: void 0,
|
|
702
|
+
out: void 0
|
|
703
|
+
}, settings);
|
|
704
|
+
if (!settings.registered) io.stdout(`\n${PLUGIN_SNIPPET}`);
|
|
705
|
+
return code;
|
|
706
|
+
}
|
|
707
|
+
async function types(io, options, settings) {
|
|
708
|
+
const root = normalizePath(io.cwd);
|
|
709
|
+
let text;
|
|
710
|
+
let snapshot;
|
|
711
|
+
settings ??= await projectSettings(io);
|
|
712
|
+
const output = settings.options.types === false ? void 0 : settings.options.types?.output;
|
|
713
|
+
const file = typesFile(root, options.out ?? output);
|
|
714
|
+
const lintFile = snapshotFile(file);
|
|
715
|
+
try {
|
|
716
|
+
const { system } = await loadProject(io, options.config);
|
|
717
|
+
text = generateTypes(system, { framework: settings.frameworks.scripts });
|
|
718
|
+
snapshot = snapshotText(system, { root: snapshotRoot(lintFile, root) });
|
|
719
|
+
} catch (error) {
|
|
720
|
+
io.stderr(formatText(diagnosticOf(error, root)));
|
|
721
|
+
return 1;
|
|
722
|
+
}
|
|
723
|
+
for (const [target, content] of [[file, text], [lintFile, snapshot]]) {
|
|
724
|
+
const rel = relativeTo(root, target);
|
|
725
|
+
let wrote = false;
|
|
726
|
+
if (!writeOutput(io, target, () => wrote = writeIfChanged(target, content))) return 1;
|
|
727
|
+
io.stdout(wrote ? `Wrote ${rel}.\n` : `${rel} is up to date.\n`);
|
|
728
|
+
}
|
|
729
|
+
return 0;
|
|
730
|
+
}
|
|
731
|
+
//#endregion
|
|
732
|
+
//#region src/cli/inspect.ts
|
|
733
|
+
/**
|
|
734
|
+
* `vicss inspect <file>` (PLAN §5.14): the atoms a module compiles to, with
|
|
735
|
+
* the condition chain and sub-layer that decide each one's precedence, and
|
|
736
|
+
* the CSS the build emits for them. A `.vue`, `.svelte` or `.astro` file is
|
|
737
|
+
* read by its scripts, as the build's pre-scan reads it (§5.4).
|
|
738
|
+
*/
|
|
739
|
+
async function inspect(io, file, options) {
|
|
740
|
+
const root = normalizePath(io.cwd);
|
|
741
|
+
const id = normalizePath(path.resolve(root, file));
|
|
742
|
+
try {
|
|
743
|
+
let code;
|
|
744
|
+
try {
|
|
745
|
+
code = readFileSync(id, "utf8");
|
|
746
|
+
} catch {
|
|
747
|
+
io.stderr(`vicss: cannot read ${file}.\n`);
|
|
748
|
+
return 1;
|
|
749
|
+
}
|
|
750
|
+
const { system } = await loadProject(io, options.config);
|
|
751
|
+
const resolver = await createResolver(io);
|
|
752
|
+
const compiler = createCompiler(system, resolver.resolve, root, void 0, resolver.frameworks);
|
|
753
|
+
const result = await compileFile(compiler, id, code).finally(() => resolver.close());
|
|
754
|
+
const rel = relativeTo(root, id);
|
|
755
|
+
const rules = result.rules.length;
|
|
756
|
+
io.stdout(`${rel}: ${result.atoms.length} atom${result.atoms.length === 1 ? "" : "s"}${rules > 0 ? `, ${rules} rule${rules === 1 ? "" : "s"}` : ""}\n`);
|
|
757
|
+
if (result.atoms.length === 0 && rules === 0) return 0;
|
|
758
|
+
const chainOf = (atom) => atom.chain.length === 0 ? "base" : atom.chain.join(":");
|
|
759
|
+
const propertyOf = (atom) => isPaletteAtom(atom) ? "colorPalette" : atom.property;
|
|
760
|
+
const rows = [...result.atoms.map((atom) => [
|
|
761
|
+
atom.className,
|
|
762
|
+
propertyOf(atom),
|
|
763
|
+
atom.value,
|
|
764
|
+
chainOf(atom),
|
|
765
|
+
layerName(atom.chain, atom.tier, system)
|
|
766
|
+
]), ...result.rules.flatMap((rule) => (rule.atoms ?? []).map((atom) => [
|
|
767
|
+
rule.id,
|
|
768
|
+
propertyOf(atom),
|
|
769
|
+
atom.value,
|
|
770
|
+
chainOf(atom),
|
|
771
|
+
flatLayerName(atom.chain, system)
|
|
772
|
+
]))];
|
|
773
|
+
if (rows.length > 0) io.stdout(`\n${table([
|
|
774
|
+
"class",
|
|
775
|
+
"property",
|
|
776
|
+
"value",
|
|
777
|
+
"chain",
|
|
778
|
+
"layer"
|
|
779
|
+
], rows)}`);
|
|
780
|
+
const css = [printAtoms(result.atoms, system, result.rules), ...result.rules.filter((rule) => rule.kind !== "base").map((rule) => rule.css)].filter(Boolean).join("\n");
|
|
781
|
+
io.stdout(`\nCSS\n${css}\n`);
|
|
782
|
+
for (const warning of compiler.warnings.get(id) ?? []) io.stderr(`warning: ${warning.message}\n`);
|
|
783
|
+
return 0;
|
|
784
|
+
} catch (error) {
|
|
785
|
+
for (const diagnostic of diagnosticsOf(error, root, "error", id)) io.stderr(formatText(diagnostic));
|
|
786
|
+
return 1;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* A module's atoms and rules; a framework file's, of all its scripts, one
|
|
791
|
+
* per class and rule id. The template is the framework's output, which the
|
|
792
|
+
* build compiles but nothing here has: a style written there is the build's
|
|
793
|
+
* error to report.
|
|
794
|
+
*/
|
|
795
|
+
async function compileFile(compiler, id, code) {
|
|
796
|
+
if (!isSfc(id)) return compiler.compile(id, code);
|
|
797
|
+
const atoms = /* @__PURE__ */ new Map();
|
|
798
|
+
const rules = /* @__PURE__ */ new Map();
|
|
799
|
+
for (const { code: script, ...options } of extractScripts(id, code)) {
|
|
800
|
+
const result = await compiler.compile(id, script, options);
|
|
801
|
+
for (const atom of result.atoms) if (!atoms.has(atom.className)) atoms.set(atom.className, atom);
|
|
802
|
+
for (const rule of result.rules) if (!rules.has(rule.id)) rules.set(rule.id, rule);
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
atoms: [...atoms.values()].sort((a, b) => compareStrings(a.className, b.className)),
|
|
806
|
+
rules: [...rules.values()].sort((a, b) => compareStrings(a.id, b.id))
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/** Columns padded to their widest cell. */
|
|
810
|
+
function table(head, rows) {
|
|
811
|
+
const widths = head.map((title, i) => Math.max(title.length, ...rows.map((row) => row[i].length)));
|
|
812
|
+
const line = (cells) => cells.map((cell, i) => i === cells.length - 1 ? cell : cell.padEnd(widths[i])).join(" ");
|
|
813
|
+
return `${[line(head), ...rows.map(line)].join("\n")}\n`;
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/cli/tokens.ts
|
|
817
|
+
/**
|
|
818
|
+
* `vicss tokens export` (PLAN §5.15): the resolved design system for other
|
|
819
|
+
* tools. `css` is the token layer vicss itself prints (every token, with
|
|
820
|
+
* modes); `json` is a flat table per scale; `dtcg` a DTCG 2025.10
|
|
821
|
+
* `.tokens.json` that `dtcg()` reads back, modes included (in vicss's
|
|
822
|
+
* `$extensions`), text styles left out.
|
|
823
|
+
*/
|
|
824
|
+
async function exportTokens(io, options) {
|
|
825
|
+
const root = normalizePath(io.cwd);
|
|
826
|
+
let text;
|
|
827
|
+
try {
|
|
828
|
+
const { system } = await loadProject(io, options.config);
|
|
829
|
+
text = options.format === "css" ? `${printTokens(system)}\n` : `${JSON.stringify(options.format === "json" ? tokenTable(system) : dtcgTokens(system), null, 2)}\n`;
|
|
830
|
+
const styles = [...system.textStyles.keys()];
|
|
831
|
+
if (options.format === "dtcg" && styles.length > 0) {
|
|
832
|
+
const count = styles.length === 1 ? "1 text style" : `${styles.length} text styles`;
|
|
833
|
+
io.stderr(`vicss: ${count} (${styles.join(", ")}) ${styles.length === 1 ? "is" : "are"} not exported: keep them in vicss.config.ts beside dtcg().\n`);
|
|
834
|
+
}
|
|
835
|
+
} catch (error) {
|
|
836
|
+
io.stderr(formatText(diagnosticOf(error, root)));
|
|
837
|
+
return 1;
|
|
838
|
+
}
|
|
839
|
+
if (options.out === void 0) {
|
|
840
|
+
io.stdout(text);
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
const file = path.resolve(root, options.out);
|
|
844
|
+
if (!writeOutput(io, file, () => {
|
|
845
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
846
|
+
writeFileSync(file, text);
|
|
847
|
+
})) return 1;
|
|
848
|
+
io.stdout(`Wrote ${relativeTo(root, normalizePath(file))}.\n`);
|
|
849
|
+
return 0;
|
|
850
|
+
}
|
|
851
|
+
/** Scale → path → its custom property and CSS value, with a value per mode. */
|
|
852
|
+
function tokenTable(system) {
|
|
853
|
+
const out = {};
|
|
854
|
+
for (const [scale, table] of system.tokens) {
|
|
855
|
+
const tokens = [...table.values()].filter((token) => !token.palette);
|
|
856
|
+
if (tokens.length === 0) continue;
|
|
857
|
+
out[scale] = Object.fromEntries(tokens.map((token) => [token.path, {
|
|
858
|
+
cssVar: token.cssVar,
|
|
859
|
+
value: token.value,
|
|
860
|
+
...token.modes ? { modes: token.modes } : {},
|
|
861
|
+
...token.semantic ? { semantic: true } : {},
|
|
862
|
+
...token.private ? { private: true } : {}
|
|
863
|
+
}]));
|
|
864
|
+
}
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
/** `$extensions` key whose `value` holds a CSS value, as `dtcg()` reads it. */
|
|
868
|
+
const EXTENSION = "dev.menapo.vicss";
|
|
869
|
+
/** Values per mode, which a `.tokens.json` file cannot express without a resolver. */
|
|
870
|
+
const MODES_EXTENSION = "dev.menapo.vicss.modes";
|
|
871
|
+
/** `true` on a token app styles may not name (`tokenVisibility`), which `dtcg()` reads back. */
|
|
872
|
+
const PRIVATE_EXTENSION = "dev.menapo.vicss.private";
|
|
873
|
+
const DTCG_TYPES = {
|
|
874
|
+
colors: "color",
|
|
875
|
+
spacing: "dimension",
|
|
876
|
+
sizes: "dimension",
|
|
877
|
+
radii: "dimension",
|
|
878
|
+
fonts: "fontFamily",
|
|
879
|
+
fontSizes: "dimension",
|
|
880
|
+
fontWeights: "fontWeight",
|
|
881
|
+
lineHeights: "number",
|
|
882
|
+
letterSpacings: "dimension",
|
|
883
|
+
shadows: "shadow",
|
|
884
|
+
textShadows: "shadow",
|
|
885
|
+
durations: "duration",
|
|
886
|
+
easings: "cubicBezier",
|
|
887
|
+
zIndices: "number",
|
|
888
|
+
opacities: "number",
|
|
889
|
+
blurs: "dimension",
|
|
890
|
+
aspectRatios: "number",
|
|
891
|
+
breakpoints: "dimension",
|
|
892
|
+
containers: "dimension"
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* One group per scale, named like the scale so `dtcg()` places it again,
|
|
896
|
+
* and a token per path, typed by its scale. A value in a format the DTCG
|
|
897
|
+
* types define is written in it; any other CSS value (oklch(), calc(), a
|
|
898
|
+
* shadow) is the `$value` as a string and vicss's `$extensions` entry,
|
|
899
|
+
* which `dtcg()` reads instead.
|
|
900
|
+
* Aliases stay aliases (`{colors.blue.600}`). Values per mode go in
|
|
901
|
+
* `$extensions["dev.menapo.vicss.modes"]`: modes need a resolver document.
|
|
902
|
+
*/
|
|
903
|
+
function dtcgTokens(system) {
|
|
904
|
+
const byVar = /* @__PURE__ */ new Map();
|
|
905
|
+
for (const table of system.tokens.values()) for (const token of table.values()) byVar.set(token.cssVar, token);
|
|
906
|
+
const references = (css) => css.replace(/var\((--[\w-]+)\)/g, (match, name) => {
|
|
907
|
+
const target = byVar.get(name);
|
|
908
|
+
return target ? `{${target.scale}.${target.path}}` : match;
|
|
909
|
+
});
|
|
910
|
+
const root = {};
|
|
911
|
+
for (const [scale, table] of system.tokens) for (const token of table.values()) {
|
|
912
|
+
if (token.palette) continue;
|
|
913
|
+
const node = {};
|
|
914
|
+
const css = references(token.value);
|
|
915
|
+
const literal = /^\{[^{}]+\}$/.test(css) ? css : typed(scale, css);
|
|
916
|
+
node.$type = DTCG_TYPES[scale];
|
|
917
|
+
if (literal === void 0) {
|
|
918
|
+
node.$value = css;
|
|
919
|
+
node.$extensions = { [EXTENSION]: { value: css } };
|
|
920
|
+
} else node.$value = literal;
|
|
921
|
+
if (token.modes) {
|
|
922
|
+
const modes = Object.fromEntries(Object.entries(token.modes).map(([mode, value]) => [mode, references(value)]));
|
|
923
|
+
node.$extensions = {
|
|
924
|
+
...node.$extensions,
|
|
925
|
+
[MODES_EXTENSION]: modes
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (token.private) node.$extensions = {
|
|
929
|
+
...node.$extensions,
|
|
930
|
+
[PRIVATE_EXTENSION]: true
|
|
931
|
+
};
|
|
932
|
+
place(root, [scale, ...token.path.split(".")], node);
|
|
933
|
+
}
|
|
934
|
+
return root;
|
|
935
|
+
}
|
|
936
|
+
/** Puts `node` at `segments`; a group that is also a token keeps the token as `$root`. */
|
|
937
|
+
function place(root, segments, node) {
|
|
938
|
+
let group = root;
|
|
939
|
+
for (const segment of segments.slice(0, -1)) {
|
|
940
|
+
let next = group[segment];
|
|
941
|
+
if (next === void 0 || typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
942
|
+
next = {};
|
|
943
|
+
group[segment] = next;
|
|
944
|
+
} else if ("$value" in next) {
|
|
945
|
+
next = { $root: next };
|
|
946
|
+
group[segment] = next;
|
|
947
|
+
}
|
|
948
|
+
group = next;
|
|
949
|
+
}
|
|
950
|
+
const last = segments.at(-1);
|
|
951
|
+
const existing = group[last];
|
|
952
|
+
if (existing && typeof existing === "object" && !Array.isArray(existing) && !("$value" in existing)) existing.$root = node;
|
|
953
|
+
else group[last] = node;
|
|
954
|
+
}
|
|
955
|
+
const DIMENSION = /^(-?(?:\d+\.?\d*|\.\d+))(px|rem)$/;
|
|
956
|
+
const DURATION = /^(\d+\.?\d*|\.\d+)(ms|s)$/;
|
|
957
|
+
const NUMBER = /^-?(?:\d+\.?\d*|\.\d+)$/;
|
|
958
|
+
/** The DTCG form of a CSS value of `scale`'s type, or undefined when it has none. */
|
|
959
|
+
function typed(scale, css) {
|
|
960
|
+
const value = css.trim();
|
|
961
|
+
switch (DTCG_TYPES[scale]) {
|
|
962
|
+
case "dimension": {
|
|
963
|
+
if (value === "0") return {
|
|
964
|
+
value: 0,
|
|
965
|
+
unit: "px"
|
|
966
|
+
};
|
|
967
|
+
const match = DIMENSION.exec(value);
|
|
968
|
+
return match ? {
|
|
969
|
+
value: Number(match[1]),
|
|
970
|
+
unit: match[2]
|
|
971
|
+
} : void 0;
|
|
972
|
+
}
|
|
973
|
+
case "duration": {
|
|
974
|
+
const match = DURATION.exec(value);
|
|
975
|
+
return match ? {
|
|
976
|
+
value: Number(match[1]),
|
|
977
|
+
unit: match[2]
|
|
978
|
+
} : void 0;
|
|
979
|
+
}
|
|
980
|
+
case "number":
|
|
981
|
+
case "fontWeight": return NUMBER.test(value) ? Number(value) : void 0;
|
|
982
|
+
case "fontFamily": {
|
|
983
|
+
const names = value.split(",").map((name) => name.trim().replace(/^(["'])(.*)\1$/, "$2"));
|
|
984
|
+
return names.some((name) => name === "" || /[(){}]/.test(name)) ? void 0 : names.length === 1 ? names[0] : names;
|
|
985
|
+
}
|
|
986
|
+
case "cubicBezier": {
|
|
987
|
+
const points = /^cubic-bezier\(([^)]*)\)$/.exec(value)?.[1].split(",").map((n) => Number(n.trim()));
|
|
988
|
+
return points?.length === 4 && points.every(Number.isFinite) ? points : void 0;
|
|
989
|
+
}
|
|
990
|
+
case "color": return hexColor(value);
|
|
991
|
+
default: return;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/** `#rgb`, `#rrggbb` (and with alpha) as a DTCG sRGB color. */
|
|
995
|
+
function hexColor(value) {
|
|
996
|
+
const match = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value);
|
|
997
|
+
if (!match) return void 0;
|
|
998
|
+
let hex = match[1].toLowerCase();
|
|
999
|
+
if (hex.length <= 4) hex = hex.replace(/./g, (c) => c + c);
|
|
1000
|
+
const channel = (i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
1001
|
+
const round = (n) => Math.round(n * 1e4) / 1e4;
|
|
1002
|
+
const color = {
|
|
1003
|
+
colorSpace: "srgb",
|
|
1004
|
+
components: [
|
|
1005
|
+
round(channel(0)),
|
|
1006
|
+
round(channel(2)),
|
|
1007
|
+
round(channel(4))
|
|
1008
|
+
],
|
|
1009
|
+
hex: `#${hex.slice(0, 6)}`
|
|
1010
|
+
};
|
|
1011
|
+
if (hex.length === 8) color.alpha = round(channel(6));
|
|
1012
|
+
return color;
|
|
1013
|
+
}
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/cli/main.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* The `vicss` command line (PLAN §5.15), run in-process by tests through
|
|
1018
|
+
* `main(argv, io)`. Arguments are parsed here rather than by a dependency
|
|
1019
|
+
* (D20): commands take a few `--name value` options and flags each.
|
|
1020
|
+
*/
|
|
1021
|
+
const CONFIG_OPTION = " --config <file> The vicss config, relative to the current directory\n";
|
|
1022
|
+
const HELP = `Usage: vicss <command> [options]
|
|
1023
|
+
|
|
1024
|
+
Commands:
|
|
1025
|
+
init Write vicss.config.ts and the generated types
|
|
1026
|
+
types Write the generated types (vicss.d.ts) and vicss.lint.json
|
|
1027
|
+
check Check every styled file: errors, off-scale values, unused tokens
|
|
1028
|
+
inspect <file> Print the atoms and CSS of one file
|
|
1029
|
+
tokens export Write the resolved tokens as CSS, JSON or DTCG
|
|
1030
|
+
critical <dir> Inline the critical CSS of the prerendered pages in <dir>
|
|
1031
|
+
|
|
1032
|
+
Options:
|
|
1033
|
+
-h, --help Show help for a command: vicss <command> --help
|
|
1034
|
+
-v, --version Print the version
|
|
1035
|
+
|
|
1036
|
+
Run it in the project root, where vite.config.ts and vicss.config.ts are.
|
|
1037
|
+
`;
|
|
1038
|
+
const string = (parsed, name) => {
|
|
1039
|
+
const value = parsed.options[name];
|
|
1040
|
+
return typeof value === "string" ? value : void 0;
|
|
1041
|
+
};
|
|
1042
|
+
const COMMANDS = {
|
|
1043
|
+
init: {
|
|
1044
|
+
spec: {
|
|
1045
|
+
preset: "string",
|
|
1046
|
+
dtcg: "string",
|
|
1047
|
+
force: "boolean"
|
|
1048
|
+
},
|
|
1049
|
+
help: `Usage: vicss init [--preset tailwind|none] [--dtcg <file>] [--force]
|
|
1050
|
+
|
|
1051
|
+
Writes vicss.config.ts, then the generated types, and prints how to add the
|
|
1052
|
+
plugin to vite.config.ts.
|
|
1053
|
+
|
|
1054
|
+
Options:
|
|
1055
|
+
--preset <name> tailwind (Tailwind v4's theme; the default) or none
|
|
1056
|
+
(a small starting design system; the default with --dtcg)
|
|
1057
|
+
--dtcg <file> Read tokens from a DTCG .tokens.json or .resolver.json file
|
|
1058
|
+
--force Replace an existing vicss.config.*
|
|
1059
|
+
`,
|
|
1060
|
+
async run(io, parsed) {
|
|
1061
|
+
const preset = string(parsed, "preset");
|
|
1062
|
+
if (preset !== void 0 && preset !== "tailwind" && preset !== "none") return usage(io, `--preset takes tailwind or none, not ${preset}`, this.help);
|
|
1063
|
+
return init(io, {
|
|
1064
|
+
preset,
|
|
1065
|
+
dtcg: string(parsed, "dtcg"),
|
|
1066
|
+
force: parsed.options["force"] === true
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
types: {
|
|
1071
|
+
spec: {
|
|
1072
|
+
config: "string",
|
|
1073
|
+
out: "string"
|
|
1074
|
+
},
|
|
1075
|
+
help: `Usage: vicss types [--out <file>] [--config <file>]
|
|
1076
|
+
|
|
1077
|
+
Writes the generated types for the design system in vicss.config.ts, as the
|
|
1078
|
+
Vite plugin does whenever the config loads: src/vicss.d.ts, else
|
|
1079
|
+
app/vicss.d.ts, else vicss.d.ts when there is neither directory; and
|
|
1080
|
+
vicss.lint.json next to them, which the
|
|
1081
|
+
rules of @menapo/vicss/lint read. Run it in CI before type-checking and linting.
|
|
1082
|
+
|
|
1083
|
+
Options:
|
|
1084
|
+
--out <file> Where to write the types, relative to the current directory
|
|
1085
|
+
${CONFIG_OPTION}`,
|
|
1086
|
+
run: (io, parsed) => types(io, {
|
|
1087
|
+
config: string(parsed, "config"),
|
|
1088
|
+
out: string(parsed, "out")
|
|
1089
|
+
})
|
|
1090
|
+
},
|
|
1091
|
+
check: {
|
|
1092
|
+
spec: {
|
|
1093
|
+
config: "string",
|
|
1094
|
+
"max-arbitrary": "string",
|
|
1095
|
+
json: "boolean"
|
|
1096
|
+
},
|
|
1097
|
+
help: `Usage: vicss check [--max-arbitrary <n>] [--json] [--config <file>]
|
|
1098
|
+
|
|
1099
|
+
Compiles every file under include as the build does and reports every error
|
|
1100
|
+
with its file and line, invariant violations across files, the values outside
|
|
1101
|
+
the scales, the share of hashed atoms, and tokens nothing uses. Exits with 1 on
|
|
1102
|
+
errors. In GitHub Actions (GITHUB_ACTIONS set) it also prints annotations.
|
|
1103
|
+
|
|
1104
|
+
Options:
|
|
1105
|
+
--max-arbitrary <n> Fail when more than n values are outside the scales
|
|
1106
|
+
--json Print the report as JSON
|
|
1107
|
+
${CONFIG_OPTION}`,
|
|
1108
|
+
run(io, parsed) {
|
|
1109
|
+
const max = string(parsed, "max-arbitrary");
|
|
1110
|
+
const maxArbitrary = max === void 0 ? void 0 : Number(max);
|
|
1111
|
+
if (maxArbitrary !== void 0 && !(Number.isInteger(maxArbitrary) && maxArbitrary >= 0)) return Promise.resolve(usage(io, `--max-arbitrary takes a whole number, not ${max}`, this.help));
|
|
1112
|
+
return check(io, {
|
|
1113
|
+
config: string(parsed, "config"),
|
|
1114
|
+
maxArbitrary,
|
|
1115
|
+
json: parsed.options["json"] === true
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
inspect: {
|
|
1120
|
+
spec: { config: "string" },
|
|
1121
|
+
help: `Usage: vicss inspect <file> [--config <file>]
|
|
1122
|
+
|
|
1123
|
+
Prints the atoms a file compiles to (class, property, value, condition
|
|
1124
|
+
chain, cascade layer) and the CSS the build emits for them.
|
|
1125
|
+
|
|
1126
|
+
Options:
|
|
1127
|
+
${CONFIG_OPTION}`,
|
|
1128
|
+
run(io, parsed) {
|
|
1129
|
+
const [file, ...rest] = parsed.positionals;
|
|
1130
|
+
if (file === void 0 || rest.length > 0) return Promise.resolve(usage(io, "inspect takes one file", this.help));
|
|
1131
|
+
return inspect(io, file, { config: string(parsed, "config") });
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
tokens: {
|
|
1135
|
+
spec: {
|
|
1136
|
+
config: "string",
|
|
1137
|
+
format: "string",
|
|
1138
|
+
out: "string"
|
|
1139
|
+
},
|
|
1140
|
+
help: `Usage: vicss tokens export [--format css|json|dtcg] [--out <file>] [--config <file>]
|
|
1141
|
+
|
|
1142
|
+
Writes every token of the design system, resolved:
|
|
1143
|
+
css The tokens layer vicss prints, with every mode (default)
|
|
1144
|
+
json Each scale's tokens: custom property, value, values per mode
|
|
1145
|
+
dtcg A DTCG 2025.10 .tokens.json file that dtcg() reads back,
|
|
1146
|
+
values per mode included; text styles are not exported
|
|
1147
|
+
|
|
1148
|
+
Options:
|
|
1149
|
+
--format <format> css, json or dtcg
|
|
1150
|
+
--out <file> Write to a file rather than to stdout
|
|
1151
|
+
${CONFIG_OPTION}`,
|
|
1152
|
+
run(io, parsed) {
|
|
1153
|
+
const [action, ...rest] = parsed.positionals;
|
|
1154
|
+
if (action !== "export" || rest.length > 0) return Promise.resolve(usage(io, "the tokens command is: vicss tokens export", this.help));
|
|
1155
|
+
const format = string(parsed, "format") ?? "css";
|
|
1156
|
+
if (format !== "css" && format !== "json" && format !== "dtcg") return Promise.resolve(usage(io, `--format takes css, json or dtcg, not ${format}`, this.help));
|
|
1157
|
+
return exportTokens(io, {
|
|
1158
|
+
config: string(parsed, "config"),
|
|
1159
|
+
format,
|
|
1160
|
+
out: string(parsed, "out")
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
critical: {
|
|
1165
|
+
spec: {
|
|
1166
|
+
budget: "string",
|
|
1167
|
+
hashes: "string",
|
|
1168
|
+
"no-hashes": "boolean",
|
|
1169
|
+
"dry-run": "boolean",
|
|
1170
|
+
json: "boolean"
|
|
1171
|
+
},
|
|
1172
|
+
help: `Usage: vicss critical <dir> [--budget <bytes>] [--hashes <file>] [--no-hashes] [--dry-run] [--json]
|
|
1173
|
+
|
|
1174
|
+
Gives the prerendered pages under <dir> critical CSS, for pages a framework
|
|
1175
|
+
writes after vite build (Astro, SvelteKit, Vike, React Router prerendering);
|
|
1176
|
+
the plugin's critical option covers the HTML Vite itself emits. In each page,
|
|
1177
|
+
every stylesheet holding vicss's CSS becomes an inline <style> with what the
|
|
1178
|
+
page's markup uses, and loads from the end of <body>, where it no longer
|
|
1179
|
+
blocks the first paint. Pages are rewritten in place; a page done before is
|
|
1180
|
+
left alone. Each page's CSP hashes go into vicss-critical.json in <dir>.
|
|
1181
|
+
|
|
1182
|
+
Options:
|
|
1183
|
+
--budget <bytes> Warn about pages whose inline CSS is over this many
|
|
1184
|
+
gzip bytes (default 14000: the first round trip)
|
|
1185
|
+
--hashes <file> Where the CSP hashes go, relative to <dir>
|
|
1186
|
+
--no-hashes Write no hashes file
|
|
1187
|
+
--dry-run Report what would change and write nothing
|
|
1188
|
+
--json Print the report as JSON
|
|
1189
|
+
`,
|
|
1190
|
+
run(io, parsed) {
|
|
1191
|
+
const [dir, ...rest] = parsed.positionals;
|
|
1192
|
+
if (dir === void 0 || rest.length > 0) return Promise.resolve(usage(io, "critical takes one directory", this.help));
|
|
1193
|
+
const raw = string(parsed, "budget");
|
|
1194
|
+
const budget = raw === void 0 ? void 0 : Number(raw);
|
|
1195
|
+
if (budget !== void 0 && !(Number.isFinite(budget) && budget > 0)) return Promise.resolve(usage(io, `--budget takes a number of bytes, not ${raw}`, this.help));
|
|
1196
|
+
const file = string(parsed, "hashes");
|
|
1197
|
+
if (file !== void 0 && parsed.options["no-hashes"] === true) return Promise.resolve(usage(io, "--hashes and --no-hashes exclude each other", this.help));
|
|
1198
|
+
const problem = file === void 0 ? void 0 : hashesProblem(file);
|
|
1199
|
+
if (problem !== void 0) return Promise.resolve(usage(io, `--hashes ${problem}, not ${file}`, this.help));
|
|
1200
|
+
return criticalCommand(io, dir, {
|
|
1201
|
+
...budget === void 0 ? {} : { budget },
|
|
1202
|
+
...parsed.options["no-hashes"] === true ? { hashes: false } : file === void 0 ? {} : { hashes: file },
|
|
1203
|
+
dryRun: parsed.options["dry-run"] === true,
|
|
1204
|
+
json: parsed.options["json"] === true
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
/**
|
|
1210
|
+
* Runs the command line `argv` (without `node` and the script) and returns
|
|
1211
|
+
* the exit code. A failure no command reports itself is an INTERNAL error
|
|
1212
|
+
* with exit code 1, not a rejected promise the binary would print as a
|
|
1213
|
+
* stack trace.
|
|
1214
|
+
*/
|
|
1215
|
+
async function main(argv, io) {
|
|
1216
|
+
try {
|
|
1217
|
+
return await run(argv, io);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1220
|
+
try {
|
|
1221
|
+
io.stderr(`error vicss(INTERNAL): ${message}\n`);
|
|
1222
|
+
} catch {}
|
|
1223
|
+
return 1;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function run(argv, io) {
|
|
1227
|
+
const [name, ...args] = argv;
|
|
1228
|
+
if (name === void 0 || name === "-h" || name === "--help" || name === "help") {
|
|
1229
|
+
const topic = name === "help" ? args[0] : void 0;
|
|
1230
|
+
const command = topic === void 0 ? void 0 : COMMANDS[topic];
|
|
1231
|
+
io.stdout(command?.help ?? HELP);
|
|
1232
|
+
return 0;
|
|
1233
|
+
}
|
|
1234
|
+
if (name === "-v" || name === "--version") {
|
|
1235
|
+
io.stdout(`${version()}\n`);
|
|
1236
|
+
return 0;
|
|
1237
|
+
}
|
|
1238
|
+
const command = COMMANDS[name];
|
|
1239
|
+
if (!command) return usage(io, `unknown command ${name}`, HELP);
|
|
1240
|
+
const parsed = parse(args, command.spec);
|
|
1241
|
+
if (typeof parsed === "string") return usage(io, parsed, command.help);
|
|
1242
|
+
if (parsed.options["help"] === true) {
|
|
1243
|
+
io.stdout(command.help);
|
|
1244
|
+
return 0;
|
|
1245
|
+
}
|
|
1246
|
+
return command.run(io, parsed);
|
|
1247
|
+
}
|
|
1248
|
+
/** Exit code 2: the command line itself is wrong. */
|
|
1249
|
+
function usage(io, message, help) {
|
|
1250
|
+
io.stderr(`vicss: ${message}\n\n${help}`);
|
|
1251
|
+
return 2;
|
|
1252
|
+
}
|
|
1253
|
+
/** `--name value`, `--name=value`, flags, `-h`, and `--` before positionals that look like options. */
|
|
1254
|
+
function parse(args, spec) {
|
|
1255
|
+
const positionals = [];
|
|
1256
|
+
const options = {};
|
|
1257
|
+
for (let i = 0; i < args.length; i++) {
|
|
1258
|
+
const arg = args[i];
|
|
1259
|
+
if (arg === "--") {
|
|
1260
|
+
positionals.push(...args.slice(i + 1));
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
if (arg === "-h" || arg === "--help") {
|
|
1264
|
+
options["help"] = true;
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (!arg.startsWith("--")) {
|
|
1268
|
+
if (arg.startsWith("-") && arg !== "-") return `unknown option ${arg}`;
|
|
1269
|
+
positionals.push(arg);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const eq = arg.indexOf("=");
|
|
1273
|
+
const name = arg.slice(2, eq === -1 ? void 0 : eq);
|
|
1274
|
+
const kind = spec[name];
|
|
1275
|
+
if (kind === void 0) return `unknown option --${name}`;
|
|
1276
|
+
if (kind === "boolean") {
|
|
1277
|
+
if (eq !== -1) return `--${name} takes no value`;
|
|
1278
|
+
options[name] = true;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const value = eq === -1 ? args[++i] : arg.slice(eq + 1);
|
|
1282
|
+
if (value === void 0 || eq === -1 && value.startsWith("--")) return `--${name} needs a value`;
|
|
1283
|
+
options[name] = value;
|
|
1284
|
+
}
|
|
1285
|
+
return {
|
|
1286
|
+
positionals,
|
|
1287
|
+
options
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
/** By the package's own name, which resolves from the source and from `dist/` alike. */
|
|
1291
|
+
function version() {
|
|
1292
|
+
return createRequire(import.meta.url)("@menapo/vicss/package.json").version;
|
|
1293
|
+
}
|
|
1294
|
+
//#endregion
|
|
1295
|
+
//#region src/cli/bin.ts
|
|
1296
|
+
/**
|
|
1297
|
+
* The `vicss` binary (PLAN §5.15), built as `dist/cli.js`. Everything but
|
|
1298
|
+
* wiring the process lives in `main.ts`, which tests run in-process.
|
|
1299
|
+
*/
|
|
1300
|
+
for (const stream of [process.stdout, process.stderr]) stream.on("error", (error) => {
|
|
1301
|
+
if (error.code !== "EPIPE") throw error;
|
|
1302
|
+
process.exit(process.exitCode ?? 0);
|
|
1303
|
+
});
|
|
1304
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
1305
|
+
cwd: process.cwd(),
|
|
1306
|
+
env: process.env,
|
|
1307
|
+
stdout: (text) => process.stdout.write(text),
|
|
1308
|
+
stderr: (text) => process.stderr.write(text)
|
|
1309
|
+
});
|
|
1310
|
+
//#endregion
|
|
1311
|
+
export {};
|