@jskit-ai/jskit-catalog 0.2.30 → 0.2.32
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/catalog/packages.json +260 -255
- package/package.json +3 -2
- package/scripts/jskit.mjs +22 -3
- package/scripts/material3-source.mjs +83 -0
- package/scripts/material3.mjs +61 -16
- package/scripts/navigation.mjs +297 -57
- package/scripts/package-routes.mjs +93 -0
- package/scripts/test-budget.mjs +19 -0
- package/scripts/test-command.mjs +116 -0
- package/scripts/test-runs.mjs +148 -0
- package/scripts/verify-packages.mjs +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/jskit-catalog",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.32",
|
|
4
4
|
"description": "Published metadata catalog for JSKIT package metadata.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
".": "./catalog/packages.json",
|
|
15
15
|
"./packages": "./catalog/packages.json",
|
|
16
16
|
"./project-packages": "./scripts/project-packages.mjs",
|
|
17
|
+
"./test-budget": "./scripts/test-budget.mjs",
|
|
17
18
|
"./package.json": "./package.json"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
21
|
"catalog:build": "node ./scripts/build-catalog.mjs",
|
|
21
22
|
"jskit:check": "node ./scripts/jskit.mjs check",
|
|
22
23
|
"verify:packages": "node ./scripts/verify-packages.mjs",
|
|
23
|
-
"test": "
|
|
24
|
+
"test": "jskit test"
|
|
24
25
|
},
|
|
25
26
|
"engines": {
|
|
26
27
|
"node": "^22.13.0 || ^24.0.0 || ^26.0.0"
|
package/scripts/jskit.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
} from "./i18n.mjs";
|
|
14
14
|
import { checkMaterial3, formatMaterial3Report, formatNavigationReport } from "./material3.mjs";
|
|
15
15
|
import { checkProject, updateProject } from "./project-packages.mjs";
|
|
16
|
+
import { runTests } from "./test-command.mjs";
|
|
17
|
+
import { checkTestRuns, formatTestRunsReport } from "./test-runs.mjs";
|
|
16
18
|
|
|
17
19
|
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
18
20
|
const PACKAGE_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
|
|
@@ -30,13 +32,17 @@ function usage() {
|
|
|
30
32
|
" jskit check m3 [<path> ...] [--root <path>]",
|
|
31
33
|
" jskit i18n export <locale> [--root <path>]",
|
|
32
34
|
" jskit i18n check <locale> [--root <path>]",
|
|
35
|
+
" jskit test [node options] [files or globs]",
|
|
33
36
|
"",
|
|
34
37
|
"Commands:",
|
|
35
38
|
" update Align root and workspace @jskit-ai/* declarations to this published catalog, then run npm install.",
|
|
36
39
|
" check Fail when manifests or package-lock.json contain stale, mixed, or private singleton JSKIT stacks,",
|
|
37
40
|
" or when application source breaks a JSKIT navigation rule (nav/*): history or window.location",
|
|
38
41
|
" navigation, a hand-built in-app path, a back control other than the shell's Up arrow, an inner",
|
|
39
|
-
" page without a declared parent, or a main-navigation page that declares one.",
|
|
42
|
+
" page without a declared parent, or a main-navigation page that declares one. It also fails when",
|
|
43
|
+
" a package script runs tests outside the machine's test budget (test/*): node --test instead of",
|
|
44
|
+
" jskit test, a fixed test concurrency or worker count, or a Playwright or Vitest config whose",
|
|
45
|
+
" workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget.",
|
|
40
46
|
" check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
|
|
41
47
|
" guide section: the whole app, or only the given files and directories (a section, or a task's",
|
|
42
48
|
" changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
|
|
@@ -44,6 +50,10 @@ function usage() {
|
|
|
44
50
|
" for translators, keeping existing translations and extra keys.",
|
|
45
51
|
" i18n check Compare src/locales/<locale>/jskit.json with the installed packages' English; fail on missing keys,",
|
|
46
52
|
" invalid messages or placeholder mismatches, and list extra keys and untranslated messages.",
|
|
53
|
+
" test Run node --test with these arguments, test files in parallel within the machine's test budget:",
|
|
54
|
+
" V64_TEST_CONCURRENCY when it is a positive whole number, otherwise half of the machine's CPUs",
|
|
55
|
+
" (at least 1). A file whose first line is `// jskit-test: alone - <reason>` runs after the others,",
|
|
56
|
+
" on its own.",
|
|
47
57
|
"",
|
|
48
58
|
"<locale> is a BCP 47 language tag, such as fr or pt-BR."
|
|
49
59
|
].join("\n");
|
|
@@ -55,6 +65,9 @@ function parseArgs(argv = []) {
|
|
|
55
65
|
const command = commandArgument === "--help" || commandArgument === "-h"
|
|
56
66
|
? "help"
|
|
57
67
|
: commandArgument;
|
|
68
|
+
if (command === "test") {
|
|
69
|
+
return { command, args };
|
|
70
|
+
}
|
|
58
71
|
const options = {
|
|
59
72
|
command,
|
|
60
73
|
projectRoot: process.cwd(),
|
|
@@ -200,6 +213,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
200
213
|
await runMaterial3(options);
|
|
201
214
|
return;
|
|
202
215
|
}
|
|
216
|
+
if (options.command === "test") {
|
|
217
|
+
process.exitCode = await runTests(options.args);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
203
220
|
|
|
204
221
|
const catalog = await readCatalog();
|
|
205
222
|
if (options.command === "update") {
|
|
@@ -230,14 +247,16 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
230
247
|
catalog
|
|
231
248
|
});
|
|
232
249
|
const ui = await checkMaterial3({ projectRoot: options.projectRoot, navigationOnly: !MATERIAL3_GATE });
|
|
250
|
+
const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
|
|
233
251
|
const reports = [
|
|
234
252
|
result.ok
|
|
235
253
|
? `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
|
|
236
254
|
`${result.installations.length} resolved package installation(s).`
|
|
237
255
|
: `JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`,
|
|
238
|
-
MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui)
|
|
256
|
+
MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui),
|
|
257
|
+
formatTestRunsReport(tests)
|
|
239
258
|
];
|
|
240
|
-
if (!result.ok || !ui.ok) {
|
|
259
|
+
if (!result.ok || !ui.ok || !tests.ok) {
|
|
241
260
|
throw new Error(reports.join("\n"));
|
|
242
261
|
}
|
|
243
262
|
process.stdout.write(`${reports.join("\n")}\n`);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// so these read Vue, JavaScript and CSS just far enough for the Material 3 rules: blocks, tags with attributes,
|
|
3
3
|
// declarations, and comments blanked to spaces so every offset still maps to its original line and column.
|
|
4
4
|
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
5
7
|
const HEX_DIGITS = "[0-9a-fA-F]";
|
|
6
8
|
// #rgb, #rgba, #rrggbb and #rrggbbaa, not followed by another word character.
|
|
7
9
|
const HEX_COLOUR = `#(?:${HEX_DIGITS}{8}|${HEX_DIGITS}{6}|${HEX_DIGITS}{3,4})(?![\\w-])`;
|
|
@@ -121,6 +123,10 @@ function splitVue(source) {
|
|
|
121
123
|
|
|
122
124
|
const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/gu;
|
|
123
125
|
const TAG = /<([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
|
|
126
|
+
// An opening or closing tag in a template, with its attributes; HTML void elements never close.
|
|
127
|
+
const TEMPLATE_TAG = /<(\/?)([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
|
|
128
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
129
|
+
const CONDITIONAL = /(?:^|\s)v-(if|else-if|else)(?=[\s=]|$)/u;
|
|
124
130
|
|
|
125
131
|
function kebab(name) {
|
|
126
132
|
return name.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
|
|
@@ -172,6 +178,39 @@ function readTags(template) {
|
|
|
172
178
|
return tags;
|
|
173
179
|
}
|
|
174
180
|
|
|
181
|
+
// The v-if chains each element of a template (comments already blanked) renders under, by its offset: a list of
|
|
182
|
+
// [chain, branch] pairs, outermost first. A v-if starts a chain among its siblings, each v-else-if or v-else right
|
|
183
|
+
// after it is the chain's next branch, so two elements on different branches of one chain never render together.
|
|
184
|
+
function readConditionalBranches(template) {
|
|
185
|
+
const branches = new Map();
|
|
186
|
+
const open = [{ name: "", path: [], chain: null }];
|
|
187
|
+
let chains = 0;
|
|
188
|
+
for (const match of template.text.matchAll(TEMPLATE_TAG)) {
|
|
189
|
+
const [, closing, rawName, attributes, selfClosing] = match;
|
|
190
|
+
const name = kebab(rawName);
|
|
191
|
+
if (closing) {
|
|
192
|
+
const index = open.findLastIndex((entry) => entry.name === name);
|
|
193
|
+
if (index > 0) open.length = index;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const parent = open.at(-1);
|
|
197
|
+
const directive = CONDITIONAL.exec(attributes)?.[1];
|
|
198
|
+
if (directive === "if") parent.chain = { id: (chains += 1), branch: 0 };
|
|
199
|
+
else if (directive && parent.chain) parent.chain.branch += 1;
|
|
200
|
+
else parent.chain = null;
|
|
201
|
+
const path = parent.chain ? [...parent.path, [parent.chain.id, parent.chain.branch]] : parent.path;
|
|
202
|
+
if (directive === "else") parent.chain = null;
|
|
203
|
+
branches.set(template.offset + match.index, path);
|
|
204
|
+
if (!selfClosing && !VOID_TAGS.has(name)) open.push({ name, path, chain: null });
|
|
205
|
+
}
|
|
206
|
+
return branches;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Whether two elements sit on different branches of one v-if chain, so at most one of them renders.
|
|
210
|
+
function exclusiveBranches(left, right) {
|
|
211
|
+
return left.some(([chain, branch]) => right.some(([other, otherBranch]) => other === chain && otherBranch !== branch));
|
|
212
|
+
}
|
|
213
|
+
|
|
175
214
|
// CSS declarations with the selectors that enclose them, for flat CSS, nested SCSS and at-rule blocks.
|
|
176
215
|
function readDeclarations(css, offset) {
|
|
177
216
|
const declarations = [];
|
|
@@ -207,13 +246,57 @@ function readInlineDeclarations(text, offset) {
|
|
|
207
246
|
return readDeclarations(`x{${text}}`, offset - 2);
|
|
208
247
|
}
|
|
209
248
|
|
|
249
|
+
// A default import: `import Name from "specifier"`, its name and specifier.
|
|
250
|
+
const DEFAULT_IMPORT = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*["']([^"']+)["']/gu;
|
|
251
|
+
|
|
252
|
+
// The value of the first balanced { ... } or [ ... ] starting at or after `start`.
|
|
253
|
+
function balancedFrom(text, start, open = "{", close = "}") {
|
|
254
|
+
const first = text.indexOf(open, start);
|
|
255
|
+
if (first < 0) return null;
|
|
256
|
+
let depth = 0;
|
|
257
|
+
for (let index = first; index < text.length; index += 1) {
|
|
258
|
+
if (text[index] === open) depth += 1;
|
|
259
|
+
else if (text[index] === close && --depth === 0) return { start: first, end: index + 1, text: text.slice(first, index + 1) };
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The object literal that encloses `offset`, as { start, end, text }, or null.
|
|
265
|
+
function enclosingObject(text, offset) {
|
|
266
|
+
let depth = 0;
|
|
267
|
+
for (let index = offset; index >= 0; index -= 1) {
|
|
268
|
+
if (text[index] === "}") depth += 1;
|
|
269
|
+
else if (text[index] === "{") {
|
|
270
|
+
if (depth === 0) return balancedFrom(text, index);
|
|
271
|
+
depth -= 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The app's .vue file an import names: "@/components/Bar.vue" is src/components/Bar.vue; "./Bar" is beside the file.
|
|
278
|
+
function localComponent(file, specifier, known) {
|
|
279
|
+
const base = specifier.startsWith("@/")
|
|
280
|
+
? path.posix.join("src", specifier.slice(2))
|
|
281
|
+
: path.posix.join(path.posix.dirname(file), specifier);
|
|
282
|
+
return [base, `${base}.vue`].find((candidate) => candidate.endsWith(".vue") && known.has(candidate)) || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
210
285
|
export {
|
|
286
|
+
DEFAULT_IMPORT,
|
|
287
|
+
TEMPLATE_TAG,
|
|
288
|
+
VOID_TAGS,
|
|
211
289
|
HEX_COLOUR,
|
|
290
|
+
balancedFrom,
|
|
212
291
|
blankCssComments,
|
|
213
292
|
blankHtmlComments,
|
|
214
293
|
blankJsComments,
|
|
294
|
+
enclosingObject,
|
|
295
|
+
exclusiveBranches,
|
|
215
296
|
kebab,
|
|
216
297
|
lineIndex,
|
|
298
|
+
localComponent,
|
|
299
|
+
readConditionalBranches,
|
|
217
300
|
readDeclarations,
|
|
218
301
|
readExceptions,
|
|
219
302
|
readInlineDeclarations,
|
package/scripts/material3.mjs
CHANGED
|
@@ -7,14 +7,17 @@ import {
|
|
|
7
7
|
checkLinkAttribute,
|
|
8
8
|
checkNavigationCode,
|
|
9
9
|
checkPageParents,
|
|
10
|
-
checkScriptBackNames
|
|
10
|
+
checkScriptBackNames,
|
|
11
|
+
readTitledFiles
|
|
11
12
|
} from "./navigation.mjs";
|
|
12
13
|
import {
|
|
13
14
|
HEX_COLOUR,
|
|
14
15
|
blankCssComments,
|
|
15
16
|
blankHtmlComments,
|
|
16
17
|
blankJsComments,
|
|
18
|
+
exclusiveBranches,
|
|
17
19
|
lineIndex,
|
|
20
|
+
readConditionalBranches,
|
|
18
21
|
readDeclarations,
|
|
19
22
|
readExceptions,
|
|
20
23
|
readInlineDeclarations,
|
|
@@ -50,12 +53,27 @@ const SKIPPED_DIRECTORIES = new Set([
|
|
|
50
53
|
"fixtures", "server", "migrations"
|
|
51
54
|
]);
|
|
52
55
|
const SKIPPED_FILE = /\.(?:test|spec)\.[cm]?[jt]s$|\.d\.ts$/u;
|
|
56
|
+
// In JSKIT's own repository, packages/ holds the framework, which implements the navigation, Up and reloads the rules
|
|
57
|
+
// send an app to (ShellUpButton, reloadAppPage(), leaveApp(), the system Back button). A whole-app run reads only the
|
|
58
|
+
// app code a JSKIT package carries, its patterns and templates; an app's own packages are read in full.
|
|
59
|
+
const FRAMEWORK_SCOPE = "@jskit-ai/";
|
|
60
|
+
const FRAMEWORK_APP_CODE = new Set(["patterns", "templates"]);
|
|
53
61
|
// The generated colour scheme is the one home of hex values.
|
|
54
62
|
const THEME_DIRECTORY = "theme";
|
|
63
|
+
// Colours that are content, not UI, keep one value in both schemes, so they are not theme colours (which are toned per
|
|
64
|
+
// scheme): they are named hex exports of src/theme/palettes.js, which this check, like all of src/theme/, reads as
|
|
65
|
+
// the place for hex values.
|
|
66
|
+
const CONTENT_COLOUR = "A content colour (an identity palette, artwork, a brand mark, a chart series) is a named export of src/theme/palettes.js, used as it is.";
|
|
55
67
|
const WHOLE_APP_ROOTS = Object.freeze(["src", "packages"]);
|
|
56
68
|
|
|
57
69
|
const hexPattern = () => new RegExp(HEX_COLOUR, "gu");
|
|
58
70
|
const exactHex = new RegExp(`^${HEX_COLOUR}$`, "u");
|
|
71
|
+
// A colour written as channels, as a hand-made shadow is: rgba(0, 0, 0, 0.15), rgb(0 0 0 / 20%), hsl(...). One that
|
|
72
|
+
// reads a theme role, rgba(var(--v-theme-shadow), 0.2), is not.
|
|
73
|
+
const CHANNEL_COLOUR = String.raw`\b(?:rgba?|hsla?)\(\s*[\d.][^()]*\)`;
|
|
74
|
+
const channelColour = () => new RegExp(CHANNEL_COLOUR, "giu");
|
|
75
|
+
const exactChannelColour = new RegExp(`^${CHANNEL_COLOUR}$`, "iu");
|
|
76
|
+
const CHANNEL_FIX = "Use rgb(var(--v-theme-<role>)), with an alpha for a shadow or scrim: rgba(var(--v-theme-shadow), 0.2).";
|
|
59
77
|
const quotedHex = () => new RegExp(`(["'\`])(${HEX_COLOUR})\\1`, "gu");
|
|
60
78
|
const MDI_STRING = /\bmdi-[a-z][a-z0-9-]*/gu;
|
|
61
79
|
const MDI_IMPORT = /(?:from\s*|import\s*|require\(\s*)["'](@mdi\/(?:js|font)[^"']*)["']/gu;
|
|
@@ -67,6 +85,7 @@ const GENERATED_SCHEMES = /\bcreateMaterialVuetifyOptions\s*\(/u;
|
|
|
67
85
|
const PINNED_LIGHT = /\bdefaultTheme\s*:\s*["']light["']/u;
|
|
68
86
|
const SHELL_PACKAGE = "@jskit-ai/shell-web";
|
|
69
87
|
const isNavigationRule = (rule) => rule.startsWith("nav/");
|
|
88
|
+
const SMALLER_BUTTON = "A smaller button is size=\"small\" or \"x-small\", which keeps the touch target.";
|
|
70
89
|
|
|
71
90
|
function isSkippedDirectory(name) {
|
|
72
91
|
return SKIPPED_DIRECTORIES.has(name);
|
|
@@ -77,7 +96,18 @@ function isSourceFile(relativePath) {
|
|
|
77
96
|
return !relativePath.split("/").slice(0, -1).some(isSkippedDirectory);
|
|
78
97
|
}
|
|
79
98
|
|
|
80
|
-
async function
|
|
99
|
+
async function isFrameworkPackage(projectRoot, directory) {
|
|
100
|
+
if (!/^packages\/[^/]+$/u.test(directory)) return false;
|
|
101
|
+
try {
|
|
102
|
+
const manifest = JSON.parse(await readFile(path.join(projectRoot, directory, "package.json"), "utf8"));
|
|
103
|
+
return String(manifest?.name || "").startsWith(FRAMEWORK_SCOPE);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error?.code === "ENOENT") return false;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function walk(projectRoot, directory, files, wholeApp = false) {
|
|
81
111
|
let entries;
|
|
82
112
|
try {
|
|
83
113
|
entries = await readdir(path.join(projectRoot, directory), { withFileTypes: true });
|
|
@@ -85,10 +115,12 @@ async function walk(projectRoot, directory, files) {
|
|
|
85
115
|
if (error?.code === "ENOENT") return;
|
|
86
116
|
throw error;
|
|
87
117
|
}
|
|
118
|
+
const framework = wholeApp && await isFrameworkPackage(projectRoot, directory);
|
|
88
119
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
89
120
|
const relative = directory ? `${directory}/${entry.name}` : entry.name;
|
|
121
|
+
if (framework && !(entry.isDirectory() && FRAMEWORK_APP_CODE.has(entry.name))) continue;
|
|
90
122
|
if (entry.isDirectory()) {
|
|
91
|
-
if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files);
|
|
123
|
+
if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files, wholeApp);
|
|
92
124
|
} else if (entry.isFile() && isSourceFile(relative)) {
|
|
93
125
|
files.add(relative);
|
|
94
126
|
}
|
|
@@ -114,7 +146,7 @@ async function collectFiles(projectRoot, paths) {
|
|
|
114
146
|
if (paths.length > 0) missing.push(target);
|
|
115
147
|
continue;
|
|
116
148
|
}
|
|
117
|
-
if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files);
|
|
149
|
+
if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files, paths.length === 0);
|
|
118
150
|
else if (isSourceFile(relative)) files.add(relative);
|
|
119
151
|
}
|
|
120
152
|
return { files: [...files].sort(), missing };
|
|
@@ -156,7 +188,10 @@ function createReporter(file, source, { navigationOnly = false } = {}) {
|
|
|
156
188
|
function checkCode(code, offset, { report, themed }) {
|
|
157
189
|
if (!themed) {
|
|
158
190
|
for (const match of code.matchAll(quotedHex())) {
|
|
159
|
-
report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role
|
|
191
|
+
report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role. ${CONTENT_COLOUR}`);
|
|
192
|
+
}
|
|
193
|
+
for (const match of code.matchAll(channelColour())) {
|
|
194
|
+
report("m3/raw-hex", offset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
|
|
160
195
|
}
|
|
161
196
|
}
|
|
162
197
|
checkNavigationCode(code, offset, report);
|
|
@@ -190,12 +225,15 @@ function checkDeclaration(declaration, context) {
|
|
|
190
225
|
const { report, themed } = context;
|
|
191
226
|
if (!themed) {
|
|
192
227
|
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>))
|
|
228
|
+
report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. Use rgb(var(--v-theme-<role>)). ${CONTENT_COLOUR}`);
|
|
229
|
+
}
|
|
230
|
+
for (const match of declaration.value.matchAll(channelColour())) {
|
|
231
|
+
report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
|
|
194
232
|
}
|
|
195
233
|
}
|
|
196
234
|
const buttonRule = declaration.selectors.some((selector) => /\.v-btn\b/u.test(selector));
|
|
197
235
|
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
|
|
236
|
+
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. ${SMALLER_BUTTON}`);
|
|
199
237
|
}
|
|
200
238
|
}
|
|
201
239
|
|
|
@@ -209,7 +247,9 @@ function checkTemplate(template, context) {
|
|
|
209
247
|
for (const match of template.text.matchAll(MDI_STRING)) {
|
|
210
248
|
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
249
|
}
|
|
212
|
-
|
|
250
|
+
// The branches each h1 renders under: h1s on different branches of one v-if chain never show together.
|
|
251
|
+
const branches = readConditionalBranches(template);
|
|
252
|
+
const headings = [];
|
|
213
253
|
for (const tag of tags) {
|
|
214
254
|
for (const entry of tag.attributes) {
|
|
215
255
|
checkAttribute(tag, entry, context);
|
|
@@ -218,9 +258,10 @@ function checkTemplate(template, context) {
|
|
|
218
258
|
if (tag.name === "v-btn") checkButtonSize(tag, report);
|
|
219
259
|
checkBackControl(tag, report, context.script);
|
|
220
260
|
if (tag.name === "h1") {
|
|
221
|
-
|
|
222
|
-
if (headings
|
|
223
|
-
else if (context.
|
|
261
|
+
const branch = branches.get(tag.offset) || [];
|
|
262
|
+
if (headings.some((other) => !exclusiveBranches(other, branch))) report("m3/duplicate-h1", tag.offset, "A second h1 in one template. A page has one title, carried by the top app bar.");
|
|
263
|
+
else if (context.titled) report("m3/duplicate-h1", tag.offset, "An in-page h1 under the top app bar, which carries the page title; use a title role (text-title-large) for a section title.");
|
|
264
|
+
headings.push(branch);
|
|
224
265
|
}
|
|
225
266
|
}
|
|
226
267
|
}
|
|
@@ -233,8 +274,8 @@ function checkAttribute(tag, entry, context) {
|
|
|
233
274
|
for (const declaration of readInlineDeclarations(entry.value, entry.valueOffset)) {
|
|
234
275
|
checkDeclaration({ ...declaration, selectors: tag.name === "v-btn" ? [".v-btn"] : [] }, context);
|
|
235
276
|
}
|
|
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"
|
|
277
|
+
} else if (!themed && (exactHex.test(entry.value.trim()) || exactChannelColour.test(entry.value.trim()))) {
|
|
278
|
+
report("m3/raw-hex", entry.valueOffset, `Raw colour ${entry.value.trim()}. Use a theme colour role such as color="primary". ${CONTENT_COLOUR}`);
|
|
238
279
|
}
|
|
239
280
|
checkLinkAttribute(tag, entry, context.report);
|
|
240
281
|
}
|
|
@@ -253,7 +294,7 @@ function checkSurface(tag, report) {
|
|
|
253
294
|
function checkButtonSize(tag, report) {
|
|
254
295
|
for (const name of ["height", "min-height"]) {
|
|
255
296
|
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
|
|
297
|
+
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. ${SMALLER_BUTTON}`);
|
|
257
298
|
}
|
|
258
299
|
}
|
|
259
300
|
|
|
@@ -320,11 +361,15 @@ async function checkMaterial3({ projectRoot = process.cwd(), paths = [], navigat
|
|
|
320
361
|
const root = path.resolve(projectRoot);
|
|
321
362
|
const { files, missing } = await collectFiles(root, paths);
|
|
322
363
|
const appFiles = paths.length === 0 ? files : (await collectFiles(root, [])).files;
|
|
323
|
-
const context = {
|
|
364
|
+
const context = { navigationOnly };
|
|
365
|
+
// Only a page under the shell's top app bar has its title there; one without a bar has its own h1.
|
|
366
|
+
const titledFiles = usesShell(await readProjectManifest(root))
|
|
367
|
+
? await readTitledFiles({ root, appFiles: [...new Set([...appFiles, ...files])] })
|
|
368
|
+
: new Set();
|
|
324
369
|
const findings = [];
|
|
325
370
|
const exceptions = [];
|
|
326
371
|
for (const file of files) {
|
|
327
|
-
const checked = checkFile(file, await readFile(path.join(root, file), "utf8"), context);
|
|
372
|
+
const checked = checkFile(file, await readFile(path.join(root, file), "utf8"), { ...context, titled: titledFiles.has(file) });
|
|
328
373
|
findings.push(...checked.findings);
|
|
329
374
|
exceptions.push(...checked.exceptions);
|
|
330
375
|
}
|