@jskit-ai/jskit-catalog 0.2.26 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-catalog",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Published metadata catalog for JSKIT package metadata.",
5
5
  "type": "module",
6
6
  "files": [
package/scripts/jskit.mjs CHANGED
@@ -11,24 +11,35 @@ import {
11
11
  formatCheckReport,
12
12
  formatExportSummary
13
13
  } from "./i18n.mjs";
14
+ import { checkMaterial3, formatMaterial3Report, formatNavigationReport } from "./material3.mjs";
14
15
  import { checkProject, updateProject } from "./project-packages.mjs";
15
16
 
16
17
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
17
18
  const PACKAGE_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
18
19
  const CATALOG_PATH = path.join(PACKAGE_ROOT, "catalog", "packages.json");
19
20
  const I18N_SUBCOMMANDS = new Set(["export", "check"]);
21
+ // #241: plain `jskit check` fails on every nav/* rule. The JSKIT release that ships once every app has
22
+ // been converted sets this to true, so plain `jskit check` (and every app's verify) fails on every m3/* finding too.
23
+ const MATERIAL3_GATE = false;
20
24
 
21
25
  function usage() {
22
26
  return [
23
27
  "Usage:",
24
28
  " jskit update [--root <path>] [--no-install] [--manifest <path> ...]",
25
29
  " jskit check [--root <path>] [--manifest <path> ...]",
30
+ " jskit check m3 [<path> ...] [--root <path>]",
26
31
  " jskit i18n export <locale> [--root <path>]",
27
32
  " jskit i18n check <locale> [--root <path>]",
28
33
  "",
29
34
  "Commands:",
30
35
  " update Align root and workspace @jskit-ai/* declarations to this published catalog, then run npm install.",
31
- " check Fail when manifests or package-lock.json contain stale, mixed, or private singleton JSKIT stacks.",
36
+ " check Fail when manifests or package-lock.json contain stale, mixed, or private singleton JSKIT stacks,",
37
+ " or when application source breaks a JSKIT navigation rule (nav/*): history or window.location",
38
+ " 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.",
40
+ " check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
41
+ " guide section: the whole app, or only the given files and directories (a section, or a task's",
42
+ " changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
32
43
  " i18n export Write the English messages of the installed JSKIT packages to src/locales/<locale>/jskit.json",
33
44
  " for translators, keeping existing translations and extra keys.",
34
45
  " i18n check Compare src/locales/<locale>/jskit.json with the installed packages' English; fail on missing keys,",
@@ -57,7 +68,7 @@ function parseArgs(argv = []) {
57
68
  options.help = true;
58
69
  continue;
59
70
  }
60
- if (command === "i18n" && argument && !argument.startsWith("-")) {
71
+ if ((command === "i18n" || command === "check") && argument && !argument.startsWith("-")) {
61
72
  positionals.push(argument);
62
73
  continue;
63
74
  }
@@ -92,6 +103,9 @@ function parseArgs(argv = []) {
92
103
  if (command === "i18n") {
93
104
  return parseI18nArgs(options, positionals);
94
105
  }
106
+ if (command === "check" && positionals.length > 0) {
107
+ return parseMaterial3Args(options, positionals);
108
+ }
95
109
  if (command !== "update" && command !== "check") {
96
110
  throw new Error(usage());
97
111
  }
@@ -127,6 +141,33 @@ function parseI18nArgs(options, positionals) {
127
141
  };
128
142
  }
129
143
 
144
+ function parseMaterial3Args(options, positionals) {
145
+ const [check, ...paths] = positionals;
146
+ if (check !== "m3") {
147
+ throw new Error(`Unknown argument: ${check}`);
148
+ }
149
+ if (options.additionalManifests) {
150
+ throw new Error("--manifest is only valid with jskit update and jskit check.");
151
+ }
152
+ if (!options.install) {
153
+ throw new Error("--no-install is only valid with jskit update.");
154
+ }
155
+ return {
156
+ command: "check-m3",
157
+ paths,
158
+ projectRoot: options.projectRoot
159
+ };
160
+ }
161
+
162
+ async function runMaterial3({ projectRoot, paths }) {
163
+ const result = await checkMaterial3({ projectRoot, paths });
164
+ const report = formatMaterial3Report(result);
165
+ if (!result.ok) {
166
+ throw new Error(report);
167
+ }
168
+ process.stdout.write(`${report}\n`);
169
+ }
170
+
130
171
  async function runI18n({ subcommand, locale, projectRoot }) {
131
172
  if (subcommand === "export") {
132
173
  const result = await exportTranslations({ projectRoot, locale });
@@ -155,6 +196,10 @@ async function main(argv = process.argv.slice(2)) {
155
196
  await runI18n(options);
156
197
  return;
157
198
  }
199
+ if (options.command === "check-m3") {
200
+ await runMaterial3(options);
201
+ return;
202
+ }
158
203
 
159
204
  const catalog = await readCatalog();
160
205
  if (options.command === "update") {
@@ -184,13 +229,18 @@ async function main(argv = process.argv.slice(2)) {
184
229
  checkInstalled: true,
185
230
  catalog
186
231
  });
187
- if (!result.ok) {
188
- throw new Error(`JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`);
232
+ const ui = await checkMaterial3({ projectRoot: options.projectRoot, navigationOnly: !MATERIAL3_GATE });
233
+ const reports = [
234
+ result.ok
235
+ ? `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
236
+ `${result.installations.length} resolved package installation(s).`
237
+ : `JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`,
238
+ MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui)
239
+ ];
240
+ if (!result.ok || !ui.ok) {
241
+ throw new Error(reports.join("\n"));
189
242
  }
190
- process.stdout.write(
191
- `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
192
- `${result.installations.length} resolved package installation(s).\n`
193
- );
243
+ process.stdout.write(`${reports.join("\n")}\n`);
194
244
  }
195
245
 
196
246
  function isCliEntrypoint(argvPath = process.argv[1]) {
@@ -0,0 +1,222 @@
1
+ // Small source readers for `jskit check m3`. @jskit-ai/jskit-catalog has no dependencies (apps run it through npx),
2
+ // so these read Vue, JavaScript and CSS just far enough for the Material 3 rules: blocks, tags with attributes,
3
+ // declarations, and comments blanked to spaces so every offset still maps to its original line and column.
4
+
5
+ const HEX_DIGITS = "[0-9a-fA-F]";
6
+ // #rgb, #rgba, #rrggbb and #rrggbbaa, not followed by another word character.
7
+ const HEX_COLOUR = `#(?:${HEX_DIGITS}{8}|${HEX_DIGITS}{6}|${HEX_DIGITS}{3,4})(?![\\w-])`;
8
+ const EXCEPTION_RULE = /m3-check:\s*allow\s+(\S+?)(?:-->|\*\/)?(?:\s|$)/u;
9
+ const EXCEPTION_REASON = /m3-check:\s*allow\s+\S+\s+-\s+(.*?)\s*(?:-->|\*\/)?\s*$/u;
10
+
11
+ function lineIndex(text) {
12
+ const starts = [0];
13
+ for (let index = 0; index < text.length; index += 1) {
14
+ if (text[index] === "\n") starts.push(index + 1);
15
+ }
16
+ return (offset) => {
17
+ let low = 0;
18
+ let high = starts.length - 1;
19
+ while (low < high) {
20
+ const middle = Math.ceil((low + high) / 2);
21
+ if (starts[middle] <= offset) low = middle;
22
+ else high = middle - 1;
23
+ }
24
+ return { line: low + 1, column: offset - starts[low] + 1 };
25
+ };
26
+ }
27
+
28
+ function blank(text) {
29
+ return text.replace(/[^\n]/gu, " ");
30
+ }
31
+
32
+ // JavaScript with its comments replaced by spaces; strings and template literals are kept.
33
+ function blankJsComments(source) {
34
+ let output = "";
35
+ let index = 0;
36
+ while (index < source.length) {
37
+ const char = source[index];
38
+ const next = source[index + 1];
39
+ if (char === "/" && next === "/") {
40
+ const end = source.indexOf("\n", index);
41
+ const stop = end < 0 ? source.length : end;
42
+ output += blank(source.slice(index, stop));
43
+ index = stop;
44
+ continue;
45
+ }
46
+ if (char === "/" && next === "*") {
47
+ const end = source.indexOf("*/", index + 2);
48
+ const stop = end < 0 ? source.length : end + 2;
49
+ output += blank(source.slice(index, stop));
50
+ index = stop;
51
+ continue;
52
+ }
53
+ if (char === "\"" || char === "'" || char === "`") {
54
+ let stop = index + 1;
55
+ while (stop < source.length && source[stop] !== char) {
56
+ if (source[stop] === "\\") stop += 1;
57
+ if (char !== "`" && source[stop] === "\n") break;
58
+ stop += 1;
59
+ }
60
+ output += source.slice(index, stop + 1);
61
+ index = stop + 1;
62
+ continue;
63
+ }
64
+ output += char;
65
+ index += 1;
66
+ }
67
+ return output;
68
+ }
69
+
70
+ function blankMatches(source, pattern) {
71
+ return source.replace(pattern, (match) => blank(match));
72
+ }
73
+
74
+ const blankCssComments = (source) => blankMatches(source, /\/\*[\s\S]*?(?:\*\/|$)/gu);
75
+ const blankHtmlComments = (source) => blankMatches(source, /<!--[\s\S]*?(?:-->|$)/gu);
76
+
77
+ // Every line that mentions m3-check, read as an exception: `m3-check: allow <rule> - <reason>`. A line in any other
78
+ // form gives no rule or no reason, and the check reports it.
79
+ function readExceptions(source) {
80
+ const exceptions = [];
81
+ source.split("\n").forEach((text, index) => {
82
+ if (!text.includes("m3-check:")) return;
83
+ exceptions.push({
84
+ line: index + 1,
85
+ rule: EXCEPTION_RULE.exec(text)?.[1] || "",
86
+ reason: EXCEPTION_REASON.exec(text)?.[1] || ""
87
+ });
88
+ });
89
+ return exceptions;
90
+ }
91
+
92
+ function findBlocks(source, tag) {
93
+ const blocks = [];
94
+ const pattern = new RegExp(`<${tag}(\\s[^>]*)?>([\\s\\S]*?)</${tag}\\s*>`, "giu");
95
+ for (const match of source.matchAll(pattern)) {
96
+ const openLength = match[0].indexOf(">") + 1;
97
+ blocks.push({
98
+ attributes: match[1] || "",
99
+ offset: match.index + openLength,
100
+ text: match[2],
101
+ start: match.index,
102
+ end: match.index + match[0].length
103
+ });
104
+ }
105
+ return blocks;
106
+ }
107
+
108
+ // A single-file component's blocks. The template is the outermost <template>, so nested slot templates stay in it.
109
+ function splitVue(source) {
110
+ const scripts = findBlocks(source, "script");
111
+ const styles = findBlocks(source, "style");
112
+ const masked = [...scripts, ...styles]
113
+ .reduce((text, block) => text.slice(0, block.start) + blank(text.slice(block.start, block.end)) + text.slice(block.end), source);
114
+ const open = /<template(\s[^>]*)?>/iu.exec(masked);
115
+ const close = masked.lastIndexOf("</template");
116
+ const template = open && close > open.index
117
+ ? { offset: open.index + open[0].length, text: masked.slice(open.index + open[0].length, close) }
118
+ : null;
119
+ return { template, scripts, styles };
120
+ }
121
+
122
+ const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/gu;
123
+ const TAG = /<([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
124
+
125
+ function kebab(name) {
126
+ return name.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
127
+ }
128
+
129
+ function readAttributes(text, offset) {
130
+ const attributes = [];
131
+ for (const match of text.matchAll(ATTRIBUTE)) {
132
+ const value = match[2] ?? match[3] ?? match[4];
133
+ const valueStart = value === undefined ? -1 : match.index + match[0].lastIndexOf(value);
134
+ const raw = match[1];
135
+ const bound = raw.startsWith(":") || raw.startsWith("v-bind:");
136
+ attributes.push({
137
+ raw,
138
+ name: raw.replace(/^(?::|v-bind:)/u, "").toLowerCase(),
139
+ bound,
140
+ event: raw.startsWith("@") || raw.startsWith("v-on:"),
141
+ value: value ?? "",
142
+ present: true,
143
+ offset: offset + match.index,
144
+ valueOffset: valueStart < 0 ? offset + match.index : offset + valueStart
145
+ });
146
+ }
147
+ return attributes;
148
+ }
149
+
150
+ // The tags of a template (comments already blanked), each with its attributes and, for a closed element, its
151
+ // inner source up to the first matching close tag.
152
+ function readTags(template) {
153
+ const tags = [];
154
+ for (const match of template.text.matchAll(TAG)) {
155
+ const name = kebab(match[1]);
156
+ const attributesStart = match.index + 1 + match[1].length;
157
+ const selfClosing = match[3] === "/";
158
+ let inner = "";
159
+ if (!selfClosing) {
160
+ const closePattern = new RegExp(`</${match[1]}\\s*>`, "u");
161
+ const rest = template.text.slice(match.index + match[0].length);
162
+ const close = closePattern.exec(rest);
163
+ if (close) inner = rest.slice(0, close.index);
164
+ }
165
+ tags.push({
166
+ name,
167
+ offset: template.offset + match.index,
168
+ attributes: readAttributes(match[2], template.offset + attributesStart),
169
+ inner
170
+ });
171
+ }
172
+ return tags;
173
+ }
174
+
175
+ // CSS declarations with the selectors that enclose them, for flat CSS, nested SCSS and at-rule blocks.
176
+ function readDeclarations(css, offset) {
177
+ const declarations = [];
178
+ const selectors = [];
179
+ let start = 0;
180
+ for (let index = 0; index <= css.length; index += 1) {
181
+ const char = css[index];
182
+ if (char !== "{" && char !== "}" && char !== ";" && index !== css.length) continue;
183
+ const text = css.slice(start, index);
184
+ if (char === "{") {
185
+ selectors.push(text.trim());
186
+ } else {
187
+ const colon = text.indexOf(":");
188
+ if (colon > 0 && selectors.length > 0 && text.trim()) {
189
+ const leading = text.length - text.trimStart().length;
190
+ declarations.push({
191
+ property: text.slice(0, colon).trim().toLowerCase(),
192
+ value: text.slice(colon + 1).trim(),
193
+ selectors: [...selectors],
194
+ offset: offset + start + leading,
195
+ valueOffset: offset + start + colon + 1
196
+ });
197
+ }
198
+ if (char === "}") selectors.pop();
199
+ }
200
+ start = index + 1;
201
+ }
202
+ return declarations;
203
+ }
204
+
205
+ // Declarations written inline, as in a style attribute.
206
+ function readInlineDeclarations(text, offset) {
207
+ return readDeclarations(`x{${text}}`, offset - 2);
208
+ }
209
+
210
+ export {
211
+ HEX_COLOUR,
212
+ blankCssComments,
213
+ blankHtmlComments,
214
+ blankJsComments,
215
+ kebab,
216
+ lineIndex,
217
+ readDeclarations,
218
+ readExceptions,
219
+ readInlineDeclarations,
220
+ readTags,
221
+ splitVue
222
+ };