@jskit-ai/jskit-catalog 0.2.25 → 0.2.27

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.
@@ -0,0 +1,371 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { toPosixPath } from "./files.mjs";
4
+ import {
5
+ NAVIGATION_RULES,
6
+ checkBackControl,
7
+ checkLinkAttribute,
8
+ checkNavigationCode,
9
+ checkPageParents,
10
+ checkScriptBackNames
11
+ } from "./navigation.mjs";
12
+ import {
13
+ HEX_COLOUR,
14
+ blankCssComments,
15
+ blankHtmlComments,
16
+ blankJsComments,
17
+ lineIndex,
18
+ readDeclarations,
19
+ readExceptions,
20
+ readInlineDeclarations,
21
+ readTags,
22
+ splitVue
23
+ } from "./material3-source.mjs";
24
+
25
+ // `jskit check m3 [path ...]`: the conformance check of the JSKIT Material 3 guide. It reads application source,
26
+ // never runs it. With no paths it checks the whole app; paths narrow it to a section or a task's changed files. The
27
+ // navigation rules (nav/*) are also part of plain `jskit check`, which fails on them for every JSKIT app (#241); the
28
+ // visual rules (m3/*) join them there once every app has been converted. Nothing turns the check off for an app, a
29
+ // folder or a file: the only exception is one line's `m3-check: allow <m3 rule> - <reason>`.
30
+
31
+ const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/material-3";
32
+ const RULES = Object.freeze({
33
+ ...NAVIGATION_RULES,
34
+ "m3/duplicate-h1": "the-top-app-bar-carries-the-page",
35
+ "m3/raw-hex": "colour",
36
+ "m3/dark-scheme": "colour",
37
+ "m3/outlined-card": "colour",
38
+ "m3/mdi-icon": "foundation",
39
+ "m3/fixed-button-height": "exact-sizes",
40
+ "m3/allow-reason": "exceptions",
41
+ "m3/allow-rule": "exceptions",
42
+ "nav/allow": "exceptions"
43
+ });
44
+ // The rules one line's exception may excuse: every visual rule, never a navigation rule or an exception rule.
45
+ const EXCUSABLE_RULES = new Set(Object.keys(RULES).filter((rule) => rule.startsWith("m3/") && !rule.startsWith("m3/allow-")));
46
+ const SOURCE_EXTENSIONS = new Set([".vue", ".js", ".mjs", ".cjs", ".ts", ".mts", ".css", ".scss"]);
47
+ // Code that renders no application UI: dependencies, build output, tests and fixtures, server code and migrations.
48
+ const SKIPPED_DIRECTORIES = new Set([
49
+ "node_modules", "dist", "build", "coverage", ".git", ".vite", ".jskit", "test", "tests", "__tests__", "e2e",
50
+ "fixtures", "server", "migrations"
51
+ ]);
52
+ const SKIPPED_FILE = /\.(?:test|spec)\.[cm]?[jt]s$|\.d\.ts$/u;
53
+ // The generated colour scheme is the one home of hex values.
54
+ const THEME_DIRECTORY = "theme";
55
+ const WHOLE_APP_ROOTS = Object.freeze(["src", "packages"]);
56
+
57
+ const hexPattern = () => new RegExp(HEX_COLOUR, "gu");
58
+ const exactHex = new RegExp(`^${HEX_COLOUR}$`, "u");
59
+ const quotedHex = () => new RegExp(`(["'\`])(${HEX_COLOUR})\\1`, "gu");
60
+ const MDI_STRING = /\bmdi-[a-z][a-z0-9-]*/gu;
61
+ const MDI_IMPORT = /(?:from\s*|import\s*|require\(\s*)["'](@mdi\/(?:js|font)[^"']*)["']/gu;
62
+ const SURFACE_TAGS = new Set(["v-card", "v-sheet"]);
63
+ const VUETIFY_BOOTSTRAP = /\bcreateVuetify\s*\(/u;
64
+ const DARK_SCHEME = /(?:\bdark|"dark")\s*:\s*true\b/u;
65
+ // @jskit-ai/material-web generates the light and dark schemes from the app's seed at startup.
66
+ const GENERATED_SCHEMES = /\bcreateMaterialVuetifyOptions\s*\(/u;
67
+ const PINNED_LIGHT = /\bdefaultTheme\s*:\s*["']light["']/u;
68
+ const SHELL_PACKAGE = "@jskit-ai/shell-web";
69
+ const isNavigationRule = (rule) => rule.startsWith("nav/");
70
+
71
+ function isSkippedDirectory(name) {
72
+ return SKIPPED_DIRECTORIES.has(name);
73
+ }
74
+
75
+ function isSourceFile(relativePath) {
76
+ if (SKIPPED_FILE.test(relativePath) || !SOURCE_EXTENSIONS.has(path.extname(relativePath))) return false;
77
+ return !relativePath.split("/").slice(0, -1).some(isSkippedDirectory);
78
+ }
79
+
80
+ async function walk(projectRoot, directory, files) {
81
+ let entries;
82
+ try {
83
+ entries = await readdir(path.join(projectRoot, directory), { withFileTypes: true });
84
+ } catch (error) {
85
+ if (error?.code === "ENOENT") return;
86
+ throw error;
87
+ }
88
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
89
+ const relative = directory ? `${directory}/${entry.name}` : entry.name;
90
+ if (entry.isDirectory()) {
91
+ if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files);
92
+ } else if (entry.isFile() && isSourceFile(relative)) {
93
+ files.add(relative);
94
+ }
95
+ }
96
+ }
97
+
98
+ // The files a run checks and the paths it could not find.
99
+ async function collectFiles(projectRoot, paths) {
100
+ const files = new Set();
101
+ const missing = [];
102
+ const targets = paths.length > 0 ? paths : WHOLE_APP_ROOTS;
103
+ for (const target of targets) {
104
+ const absolute = path.resolve(projectRoot, target);
105
+ const relative = toPosixPath(path.relative(projectRoot, absolute));
106
+ if (relative.startsWith("../") || path.isAbsolute(relative)) {
107
+ throw new Error(`jskit check m3 checks paths inside the application only: ${target}`);
108
+ }
109
+ let info;
110
+ try {
111
+ info = await stat(absolute);
112
+ } catch (error) {
113
+ if (error?.code !== "ENOENT") throw error;
114
+ if (paths.length > 0) missing.push(target);
115
+ continue;
116
+ }
117
+ if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files);
118
+ else if (isSourceFile(relative)) files.add(relative);
119
+ }
120
+ return { files: [...files].sort(), missing };
121
+ }
122
+
123
+ // One line's `m3-check: allow <m3 rule> - <reason>` excuses that rule on its own line and the next. Any other
124
+ // m3-check comment is itself a finding: one naming a nav/* rule (nothing excuses those), an unknown rule, or no reason.
125
+ function createReporter(file, source, { navigationOnly = false } = {}) {
126
+ const position = lineIndex(source);
127
+ const findings = [];
128
+ const exceptions = [];
129
+ const excused = new Map();
130
+ for (const { line, rule, reason } of readExceptions(source)) {
131
+ const at = { file, line, column: 1 };
132
+ if (isNavigationRule(rule)) {
133
+ findings.push({ rule: "nav/allow", ...at, message: `Nothing excuses a navigation rule (${rule}). Delete the comment and fix the line.` });
134
+ } else if (navigationOnly) {
135
+ continue;
136
+ } else if (!EXCUSABLE_RULES.has(rule)) {
137
+ const what = rule ? `${rule} is not a Material 3 rule` : "The comment is not \"m3-check: allow <rule> - <reason>\"";
138
+ findings.push({ rule: "m3/allow-rule", ...at, message: `${what}, so it excuses nothing.` });
139
+ } else if (!reason) {
140
+ findings.push({ rule: "m3/allow-reason", ...at, message: `The exception for ${rule} gives no reason ("m3-check: allow ${rule} - <reason>"), so it excuses nothing.` });
141
+ } else {
142
+ exceptions.push({ file, line, rule, reason });
143
+ for (const excusedLine of [line, line + 1]) excused.set(excusedLine, [...(excused.get(excusedLine) || []), rule]);
144
+ }
145
+ }
146
+ function report(rule, offset, message) {
147
+ if (navigationOnly && !isNavigationRule(rule)) return;
148
+ const at = position(offset);
149
+ if (excused.get(at.line)?.includes(rule)) return;
150
+ findings.push({ rule, file, line: at.line, column: at.column, message });
151
+ }
152
+ return { report, findings, exceptions };
153
+ }
154
+
155
+ // Hex colours and navigation in any JavaScript: a script block, a module, or a bound or event attribute's expression.
156
+ function checkCode(code, offset, { report, themed }) {
157
+ if (!themed) {
158
+ for (const match of code.matchAll(quotedHex())) {
159
+ report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role; hex values live only in the generated scheme under src/theme/.`);
160
+ }
161
+ }
162
+ checkNavigationCode(code, offset, report);
163
+ }
164
+
165
+ function checkScript(code, offset, context) {
166
+ const { report } = context;
167
+ checkCode(code, offset, context);
168
+ checkScriptBackNames(code, offset, report);
169
+ for (const match of code.matchAll(/["'`](mdi-[a-z][a-z0-9-]*)/gu)) {
170
+ report("m3/mdi-icon", offset + match.index, `MDI icon "${match[1]}". Use a Material Symbols alias such as "$add" from the app's Vuetify icon aliases.`);
171
+ }
172
+ for (const match of code.matchAll(MDI_IMPORT)) {
173
+ report("m3/mdi-icon", offset + match.index, `Import of ${match[1]}. Icons are Material Symbols through the app's Vuetify "$" aliases.`);
174
+ }
175
+ for (const match of code.matchAll(/\bVCard\s*:\s*\{[^}]*\bvariant\s*:\s*["']outlined["']/gu)) {
176
+ report("m3/outlined-card", offset + match.index, "VCard defaults to the outlined variant. Material 3 cards are flat surface-container tiers; a default card colour becomes the text colour on outlined cards.");
177
+ }
178
+ for (const match of code.matchAll(/\bVBtn\s*:\s*\{[^}]*\b(?:height|minHeight)\s*:/gu)) {
179
+ report("m3/fixed-button-height", offset + match.index, "VBtn defaults set a height. JSKIT ships the 40dp button with its 48dp touch target.");
180
+ }
181
+ }
182
+
183
+ function checkCss(css, offset, context) {
184
+ for (const declaration of readDeclarations(css, offset)) {
185
+ checkDeclaration(declaration, context);
186
+ }
187
+ }
188
+
189
+ function checkDeclaration(declaration, context) {
190
+ const { report, themed } = context;
191
+ if (!themed) {
192
+ for (const match of declaration.value.matchAll(hexPattern())) {
193
+ report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. Use rgb(var(--v-theme-<role>)); hex values live only in the generated scheme under src/theme/.`);
194
+ }
195
+ }
196
+ const buttonRule = declaration.selectors.some((selector) => /\.v-btn\b/u.test(selector));
197
+ if (buttonRule && ["height", "min-height", "--v-btn-height"].includes(declaration.property)) {
198
+ report("m3/fixed-button-height", declaration.offset, `A fixed ${declaration.property} on buttons. JSKIT ships the 40dp button with its 48dp touch target; remove it.`);
199
+ }
200
+ }
201
+
202
+ function attribute(tag, name) {
203
+ return tag.attributes.find((entry) => entry.name === name);
204
+ }
205
+
206
+ function checkTemplate(template, context) {
207
+ const { report } = context;
208
+ const tags = readTags(template);
209
+ for (const match of template.text.matchAll(MDI_STRING)) {
210
+ report("m3/mdi-icon", template.offset + match.index, `MDI icon "${match[0]}". Use a Material Symbols alias such as "$add" from the app's Vuetify icon aliases.`);
211
+ }
212
+ let headings = 0;
213
+ for (const tag of tags) {
214
+ for (const entry of tag.attributes) {
215
+ checkAttribute(tag, entry, context);
216
+ }
217
+ if (SURFACE_TAGS.has(tag.name)) checkSurface(tag, report);
218
+ if (tag.name === "v-btn") checkButtonSize(tag, report);
219
+ checkBackControl(tag, report);
220
+ if (tag.name === "h1") {
221
+ headings += 1;
222
+ if (headings > 1) report("m3/duplicate-h1", tag.offset, "A second h1 in one template. A page has one title, carried by the top app bar.");
223
+ else if (context.shell) report("m3/duplicate-h1", tag.offset, "An in-page h1. The top app bar carries the page title; use a title role (text-title-large) for a section title.");
224
+ }
225
+ }
226
+ }
227
+
228
+ function checkAttribute(tag, entry, context) {
229
+ const { report, themed } = context;
230
+ if (entry.event || entry.bound) {
231
+ checkCode(entry.value, entry.valueOffset, context);
232
+ } else if (entry.name === "style") {
233
+ for (const declaration of readInlineDeclarations(entry.value, entry.valueOffset)) {
234
+ checkDeclaration({ ...declaration, selectors: tag.name === "v-btn" ? [".v-btn"] : [] }, context);
235
+ }
236
+ } else if (!themed && exactHex.test(entry.value.trim())) {
237
+ report("m3/raw-hex", entry.valueOffset, `Raw colour ${entry.value.trim()}. Use a theme colour role such as color="primary".`);
238
+ }
239
+ checkLinkAttribute(tag, entry, context.report);
240
+ }
241
+
242
+ function checkSurface(tag, report) {
243
+ const variant = attribute(tag, "variant");
244
+ if (variant && /(^|["'`\s])outlined(["'`\s]|$)/u.test(variant.value)) {
245
+ report("m3/outlined-card", variant.offset, `${tag.name} with variant="outlined". Material 3 separates surfaces by tone: use the flat default or a surface-container colour.`);
246
+ }
247
+ const border = attribute(tag, "border");
248
+ if (border) {
249
+ report("m3/outlined-card", border.offset, `${tag.name} with a border. Material 3 separates surfaces by tone: use the flat default or a surface-container colour.`);
250
+ }
251
+ }
252
+
253
+ function checkButtonSize(tag, report) {
254
+ for (const name of ["height", "min-height"]) {
255
+ const entry = attribute(tag, name);
256
+ if (entry) report("m3/fixed-button-height", entry.offset, `A fixed ${name} on v-btn. JSKIT ships the 40dp button with its 48dp touch target; remove it.`);
257
+ }
258
+ }
259
+
260
+ function checkFile(file, source, context) {
261
+ const { report, findings, exceptions } = createReporter(file, source, context);
262
+ const fileContext = { ...context, report, file, themed: file.split("/").slice(0, -1).includes(THEME_DIRECTORY) };
263
+ const extension = path.extname(file);
264
+ if (extension === ".vue") {
265
+ const { template, scripts, styles } = splitVue(source);
266
+ if (template) checkTemplate({ offset: template.offset, text: blankHtmlComments(template.text) }, fileContext);
267
+ for (const block of scripts) checkScript(blankJsComments(block.text), block.offset, fileContext);
268
+ for (const block of styles) checkCss(blankCssComments(block.text), block.offset, fileContext);
269
+ } else if (extension === ".css" || extension === ".scss") {
270
+ checkCss(blankCssComments(source), 0, fileContext);
271
+ } else {
272
+ checkScript(blankJsComments(source), 0, fileContext);
273
+ }
274
+ return { findings, exceptions };
275
+ }
276
+
277
+ async function readProjectManifest(projectRoot) {
278
+ try {
279
+ return JSON.parse(await readFile(path.join(projectRoot, "package.json"), "utf8"));
280
+ } catch (error) {
281
+ if (error?.code === "ENOENT") return {};
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ function usesShell(manifest) {
287
+ return ["dependencies", "devDependencies"].some((field) => Boolean(manifest?.[field]?.[SHELL_PACKAGE]));
288
+ }
289
+
290
+ // The whole-application rule: Vuetify's bootstrap must offer a dark scheme and follow the person's preference.
291
+ async function checkDarkScheme(projectRoot, checkedFiles, wholeApp) {
292
+ const { files } = await collectFiles(projectRoot, ["src"]);
293
+ const sources = new Map();
294
+ for (const file of files) sources.set(file, blankJsComments(await readFile(path.join(projectRoot, file), "utf8")));
295
+ const bootstrap = files.find((file) => VUETIFY_BOOTSTRAP.test(sources.get(file)));
296
+ if (!bootstrap) return [];
297
+ const themeFiles = new Set([bootstrap, ...files.filter((file) => file.split("/").includes(THEME_DIRECTORY))]);
298
+ if (!wholeApp && !checkedFiles.some((file) => themeFiles.has(file))) return [];
299
+ const source = await readFile(path.join(projectRoot, bootstrap), "utf8");
300
+ const { report, findings } = createReporter(bootstrap, source);
301
+ const offset = VUETIFY_BOOTSTRAP.exec(source)?.index ?? 0;
302
+ const all = [...sources.values()];
303
+ if (!all.some((text) => DARK_SCHEME.test(text) || GENERATED_SCHEMES.test(text))) {
304
+ report("m3/dark-scheme", offset, "No dark colour scheme. Generate light and dark schemes from the app's seed colour.");
305
+ }
306
+ if (all.some((text) => PINNED_LIGHT.test(text))) {
307
+ report("m3/dark-scheme", offset, "The theme is pinned to light. Use defaultTheme: \"system\" so the person's light, dark or system choice applies.");
308
+ }
309
+ return findings.filter((finding) => finding.rule === "m3/dark-scheme");
310
+ }
311
+
312
+ /**
313
+ * Checks application source against the Material 3 guide.
314
+ * @param {{ projectRoot?: string, paths?: string[], navigationOnly?: boolean }} options paths are files or directories
315
+ * relative to the root; navigationOnly keeps only the nav/* rules, as plain jskit check does for the whole app
316
+ */
317
+ async function checkMaterial3({ projectRoot = process.cwd(), paths = [], navigationOnly = false } = {}) {
318
+ const root = path.resolve(projectRoot);
319
+ const { files, missing } = await collectFiles(root, paths);
320
+ const appFiles = paths.length === 0 ? files : (await collectFiles(root, [])).files;
321
+ const context = { shell: usesShell(await readProjectManifest(root)), navigationOnly };
322
+ const findings = [];
323
+ const exceptions = [];
324
+ for (const file of files) {
325
+ const checked = checkFile(file, await readFile(path.join(root, file), "utf8"), context);
326
+ findings.push(...checked.findings);
327
+ exceptions.push(...checked.exceptions);
328
+ }
329
+ const pages = await checkPageParents({ root, appFiles, pageFiles: files, report: (finding) => findings.push(finding) });
330
+ if (!navigationOnly) findings.push(...await checkDarkScheme(root, files, paths.length === 0));
331
+ findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column);
332
+ const errors = findings.filter((finding) => isNavigationRule(finding.rule)).length;
333
+ return { projectRoot: root, wholeApp: paths.length === 0, navigationOnly, files, missing, pages, findings, exceptions, errors, ok: findings.length === 0 };
334
+ }
335
+
336
+ function formatFindings(result) {
337
+ const lines = [];
338
+ for (const finding of result.findings) {
339
+ lines.push(`${finding.file}:${finding.line}:${finding.column} ${finding.rule} ${finding.message}`);
340
+ lines.push(` ${GUIDE}#${RULES[finding.rule]}`);
341
+ }
342
+ for (const target of result.missing) lines.push(`Skipped ${target}: not found.`);
343
+ return lines;
344
+ }
345
+
346
+ function formatMaterial3Report(result) {
347
+ const lines = formatFindings(result);
348
+ const scope = result.wholeApp ? "the whole app" : "the given paths";
349
+ const fileCount = `${result.files.length} file(s)`;
350
+ const withFindings = new Set(result.findings.map((finding) => finding.file)).size;
351
+ const errors = result.errors > 0 ? ` ${result.errors} of them nav/* rules, which plain jskit check fails on.` : "";
352
+ if (result.exceptions.length > 0) lines.push(`Exceptions (${result.exceptions.length}):`);
353
+ for (const { file, line, rule, reason } of result.exceptions) lines.push(`${file}:${line} ${rule} ${reason}`);
354
+ const exceptions = ` Exceptions: ${result.exceptions.length}.`;
355
+ lines.push(result.ok
356
+ ? `Material 3 check: no findings in ${fileCount} of ${scope}.${exceptions}`
357
+ : `Material 3 check: ${result.findings.length} finding(s) in ${withFindings} of ${fileCount} of ${scope}.${errors}${exceptions}`);
358
+ return lines.join("\n");
359
+ }
360
+
361
+ // The navigation part of plain jskit check: errors, with no exemptions.
362
+ function formatNavigationReport(result) {
363
+ const lines = result.ok ? [] : formatFindings(result);
364
+ const pages = `${result.pages.length} shell page(s)`;
365
+ lines.push(result.ok
366
+ ? `JSKIT navigation rules pass in ${result.files.length} file(s) and ${pages}.`
367
+ : `JSKIT navigation rules: ${result.findings.length} error(s) in ${result.files.length} file(s) and ${pages}. Fix each one; no comment excuses a navigation rule.`);
368
+ return lines.join("\n");
369
+ }
370
+
371
+ export { RULES as MATERIAL3_RULES, checkMaterial3, formatMaterial3Report, formatNavigationReport };