@jskit-ai/jskit-catalog 0.2.31 → 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 +258 -254
- package/package.json +3 -2
- package/scripts/jskit.mjs +22 -3
- package/scripts/material3-source.mjs +83 -0
- package/scripts/material3.mjs +42 -7
- package/scripts/navigation.mjs +195 -53
- 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
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
blankCssComments,
|
|
16
16
|
blankHtmlComments,
|
|
17
17
|
blankJsComments,
|
|
18
|
+
exclusiveBranches,
|
|
18
19
|
lineIndex,
|
|
20
|
+
readConditionalBranches,
|
|
19
21
|
readDeclarations,
|
|
20
22
|
readExceptions,
|
|
21
23
|
readInlineDeclarations,
|
|
@@ -51,6 +53,11 @@ const SKIPPED_DIRECTORIES = new Set([
|
|
|
51
53
|
"fixtures", "server", "migrations"
|
|
52
54
|
]);
|
|
53
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"]);
|
|
54
61
|
// The generated colour scheme is the one home of hex values.
|
|
55
62
|
const THEME_DIRECTORY = "theme";
|
|
56
63
|
// Colours that are content, not UI, keep one value in both schemes, so they are not theme colours (which are toned per
|
|
@@ -61,6 +68,12 @@ const WHOLE_APP_ROOTS = Object.freeze(["src", "packages"]);
|
|
|
61
68
|
|
|
62
69
|
const hexPattern = () => new RegExp(HEX_COLOUR, "gu");
|
|
63
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).";
|
|
64
77
|
const quotedHex = () => new RegExp(`(["'\`])(${HEX_COLOUR})\\1`, "gu");
|
|
65
78
|
const MDI_STRING = /\bmdi-[a-z][a-z0-9-]*/gu;
|
|
66
79
|
const MDI_IMPORT = /(?:from\s*|import\s*|require\(\s*)["'](@mdi\/(?:js|font)[^"']*)["']/gu;
|
|
@@ -83,7 +96,18 @@ function isSourceFile(relativePath) {
|
|
|
83
96
|
return !relativePath.split("/").slice(0, -1).some(isSkippedDirectory);
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
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) {
|
|
87
111
|
let entries;
|
|
88
112
|
try {
|
|
89
113
|
entries = await readdir(path.join(projectRoot, directory), { withFileTypes: true });
|
|
@@ -91,10 +115,12 @@ async function walk(projectRoot, directory, files) {
|
|
|
91
115
|
if (error?.code === "ENOENT") return;
|
|
92
116
|
throw error;
|
|
93
117
|
}
|
|
118
|
+
const framework = wholeApp && await isFrameworkPackage(projectRoot, directory);
|
|
94
119
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
95
120
|
const relative = directory ? `${directory}/${entry.name}` : entry.name;
|
|
121
|
+
if (framework && !(entry.isDirectory() && FRAMEWORK_APP_CODE.has(entry.name))) continue;
|
|
96
122
|
if (entry.isDirectory()) {
|
|
97
|
-
if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files);
|
|
123
|
+
if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files, wholeApp);
|
|
98
124
|
} else if (entry.isFile() && isSourceFile(relative)) {
|
|
99
125
|
files.add(relative);
|
|
100
126
|
}
|
|
@@ -120,7 +146,7 @@ async function collectFiles(projectRoot, paths) {
|
|
|
120
146
|
if (paths.length > 0) missing.push(target);
|
|
121
147
|
continue;
|
|
122
148
|
}
|
|
123
|
-
if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files);
|
|
149
|
+
if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files, paths.length === 0);
|
|
124
150
|
else if (isSourceFile(relative)) files.add(relative);
|
|
125
151
|
}
|
|
126
152
|
return { files: [...files].sort(), missing };
|
|
@@ -164,6 +190,9 @@ function checkCode(code, offset, { report, themed }) {
|
|
|
164
190
|
for (const match of code.matchAll(quotedHex())) {
|
|
165
191
|
report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role. ${CONTENT_COLOUR}`);
|
|
166
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}`);
|
|
195
|
+
}
|
|
167
196
|
}
|
|
168
197
|
checkNavigationCode(code, offset, report);
|
|
169
198
|
}
|
|
@@ -198,6 +227,9 @@ function checkDeclaration(declaration, context) {
|
|
|
198
227
|
for (const match of declaration.value.matchAll(hexPattern())) {
|
|
199
228
|
report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. Use rgb(var(--v-theme-<role>)). ${CONTENT_COLOUR}`);
|
|
200
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}`);
|
|
232
|
+
}
|
|
201
233
|
}
|
|
202
234
|
const buttonRule = declaration.selectors.some((selector) => /\.v-btn\b/u.test(selector));
|
|
203
235
|
if (buttonRule && ["height", "min-height", "--v-btn-height"].includes(declaration.property)) {
|
|
@@ -215,7 +247,9 @@ function checkTemplate(template, context) {
|
|
|
215
247
|
for (const match of template.text.matchAll(MDI_STRING)) {
|
|
216
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.`);
|
|
217
249
|
}
|
|
218
|
-
|
|
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 = [];
|
|
219
253
|
for (const tag of tags) {
|
|
220
254
|
for (const entry of tag.attributes) {
|
|
221
255
|
checkAttribute(tag, entry, context);
|
|
@@ -224,9 +258,10 @@ function checkTemplate(template, context) {
|
|
|
224
258
|
if (tag.name === "v-btn") checkButtonSize(tag, report);
|
|
225
259
|
checkBackControl(tag, report, context.script);
|
|
226
260
|
if (tag.name === "h1") {
|
|
227
|
-
|
|
228
|
-
if (headings
|
|
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.");
|
|
229
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);
|
|
230
265
|
}
|
|
231
266
|
}
|
|
232
267
|
}
|
|
@@ -239,7 +274,7 @@ function checkAttribute(tag, entry, context) {
|
|
|
239
274
|
for (const declaration of readInlineDeclarations(entry.value, entry.valueOffset)) {
|
|
240
275
|
checkDeclaration({ ...declaration, selectors: tag.name === "v-btn" ? [".v-btn"] : [] }, context);
|
|
241
276
|
}
|
|
242
|
-
} else if (!themed && exactHex.test(entry.value.trim())) {
|
|
277
|
+
} else if (!themed && (exactHex.test(entry.value.trim()) || exactChannelColour.test(entry.value.trim()))) {
|
|
243
278
|
report("m3/raw-hex", entry.valueOffset, `Raw colour ${entry.value.trim()}. Use a theme colour role such as color="primary". ${CONTENT_COLOUR}`);
|
|
244
279
|
}
|
|
245
280
|
checkLinkAttribute(tag, entry, context.report);
|