@tonycodes-ui/mortar 0.12.0 → 0.13.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/cli/audit.mjs CHANGED
@@ -44,6 +44,8 @@ export const RULES = {
44
44
  "missing-theme-provider":
45
45
  "Mortar components are rendered but ThemeProvider is never rendered.",
46
46
  "local-copy": "A local copy of Mortar source.",
47
+ "raw-element":
48
+ "A raw HTML element (such as <table> or <select>) that a Mortar component replaces, in an app that uses Mortar.",
47
49
  "limited-data": "The audit could not use the installed version's own data.",
48
50
  };
49
51
 
@@ -292,6 +294,110 @@ export function jsxAttributes(src, i) {
292
294
  return attrs;
293
295
  }
294
296
 
297
+ /**
298
+ * The text with string and template-literal contents blanked, so HTML inside
299
+ * strings (an email template, say) is not read as JSX. Lengths and newlines
300
+ * are kept, so offsets still map to lines.
301
+ *
302
+ * A quote starts a string when it follows an operator, a bracket, `=>`, or
303
+ * `return`, `case`, `throw`, `from` or `import`. An apostrophe in JSX text
304
+ * (`Don't`) does not. Template literals recurse into `${…}`: strings and
305
+ * nested templates there are blanked, and a backtick quoted inside the
306
+ * interpolation does not end the outer template or blank the lines after it.
307
+ * JSX in an interpolation is left in place.
308
+ */
309
+ export function blankStrings(src) {
310
+ const out = src.split("");
311
+ const n = src.length;
312
+ // A quote in JSX text (Don't) is not a string.
313
+ const starts = (i) => {
314
+ const q = src[i];
315
+ if (q !== '"' && q !== "'" && q !== "`") return false;
316
+ if (q === "`") return true;
317
+ const before = src.slice(0, i).trimEnd();
318
+ return /(?:^|=>|[=(,:?&|[{}+;!]|\b(?:return|case|throw|from|import))$/.test(
319
+ before,
320
+ );
321
+ };
322
+ const blank = (from, to) => {
323
+ for (let k = from; k < Math.min(to, n); k++)
324
+ if (out[k] !== "\n") out[k] = " ";
325
+ };
326
+ // Blank the string whose opening quote is at `i`. Returns the index of the
327
+ // closing quote, the newline that ends a non-template, or `end`.
328
+ const blankOne = (i, end) => {
329
+ const q = src[i];
330
+ let j = i + 1;
331
+ let literal = j;
332
+ while (j < end) {
333
+ if (src[j] === "\\") {
334
+ j += 2;
335
+ continue;
336
+ }
337
+ if (q === "`" && src[j] === "$" && src[j + 1] === "{") {
338
+ blank(literal, j);
339
+ const close = blankCode(j + 2, end, true);
340
+ j = close < end ? close + 1 : end;
341
+ literal = j;
342
+ continue;
343
+ }
344
+ if (src[j] === q) break;
345
+ if (q !== "`" && src[j] === "\n") break;
346
+ j++;
347
+ }
348
+ blank(literal, j);
349
+ return j;
350
+ };
351
+ // Walk code in [start, end). With `expr`, the `{` of `${` is already
352
+ // counted and the walk stops at the matching `}`.
353
+ const blankCode = (start, end, expr) => {
354
+ let depth = expr ? 1 : 0;
355
+ let i = start;
356
+ while (i < end) {
357
+ const c = src[i];
358
+ if (expr && c === "{") depth++;
359
+ else if (expr && c === "}") {
360
+ if (--depth === 0) return i;
361
+ } else if ((c === '"' || c === "'" || c === "`") && starts(i)) {
362
+ i = blankOne(i, end);
363
+ if (i < end) i++;
364
+ continue;
365
+ }
366
+ i++;
367
+ }
368
+ return i;
369
+ };
370
+ blankCode(0, n, false);
371
+ return out.join("");
372
+ }
373
+
374
+ /**
375
+ * Raw element → the Mortar components that replace it, from each contract's
376
+ * `replaces` (an element name, optionally with `[type=…]` for inputs).
377
+ */
378
+ function replacements(data) {
379
+ const map = new Map();
380
+ for (const c of data.components ?? [])
381
+ for (const element of c.contract?.replaces ?? [])
382
+ map.set(element, [...(map.get(element) ?? []), c.export]);
383
+ return map;
384
+ }
385
+ // Inputs with no Mortar equivalent (hidden values, file pickers, sliders).
386
+ const NATIVE_ONLY = new Set(["hidden", "file", "range", "color", "image"]);
387
+ const TEXT_LIKE = new Set([
388
+ "text",
389
+ "email",
390
+ "search",
391
+ "url",
392
+ "tel",
393
+ "number",
394
+ "date",
395
+ "time",
396
+ "datetime-local",
397
+ "month",
398
+ "week",
399
+ ]);
400
+
295
401
  function lineAt(text) {
296
402
  const starts = [0];
297
403
  for (let i = 0; i < text.length; i++)
@@ -568,6 +674,13 @@ export async function auditApp(path = ".", { data: bundled } = {}) {
568
674
  );
569
675
  }
570
676
  const classes = new Set(data.agent.classes);
677
+ // An installed release older than the `replaces` field falls back to this
678
+ // tool's mapping; every component it names has existed since 0.1.
679
+ let rawElements = replacements(data);
680
+ if (!rawElements.size) rawElements = replacements(bundled);
681
+ // Raw elements are collected from every JSX file and reported once the
682
+ // whole app is known to use Mortar.
683
+ const rawHits = [];
571
684
 
572
685
  const files = walk(root);
573
686
  const hashes = data.agent.sourceHashes ?? {};
@@ -644,6 +757,30 @@ export async function auditApp(path = ".", { data: bundled } = {}) {
644
757
  }
645
758
 
646
759
  const src = blankComments(text);
760
+ if (JSX.has(ext)) {
761
+ const jsx = blankStrings(src);
762
+ for (const m of jsx.matchAll(/<([a-z][a-z0-9]*)(?=[\s/>])/g)) {
763
+ const tagName = m[1];
764
+ let element = tagName;
765
+ if (tagName === "input") {
766
+ const type = jsxAttributes(jsx, m.index + m[0].length).find(
767
+ (a) => a.name === "type",
768
+ );
769
+ // A dynamic type is read as a text-like input.
770
+ const value = type
771
+ ? src
772
+ .slice(type.index)
773
+ .match(/^type\s*=\s*(?:\{\s*)?(["'`])([^"'`]*)\1/)?.[2]
774
+ : undefined;
775
+ if (value && NATIVE_ONLY.has(value)) continue;
776
+ element =
777
+ value && !TEXT_LIKE.has(value) ? `input[type=${value}]` : "input";
778
+ }
779
+ const components = rawElements.get(element);
780
+ if (!components) continue;
781
+ rawHits.push({ file, line: line(m.index), tag: element, components });
782
+ }
783
+ }
647
784
  for (const m of src.matchAll(DEEP)) {
648
785
  usesMortar = true;
649
786
  add(
@@ -860,6 +997,21 @@ export async function auditApp(path = ".", { data: bundled } = {}) {
860
997
  }
861
998
  }
862
999
 
1000
+ if (usesMortar)
1001
+ for (const hit of rawHits) {
1002
+ const names = hit.components.map((c) => `<${c}>`);
1003
+ add(
1004
+ "raw-element",
1005
+ // A raw <button> is often a deliberate custom trigger; the rest are
1006
+ // contract breaks that ship unthemed, untested markup.
1007
+ hit.tag === "button" ? "warning" : "error",
1008
+ hit.file,
1009
+ hit.line,
1010
+ `Raw <${hit.tag.replace(/\[type=(.*)\]/, ' type="$1"')}> in an app that uses Mortar.`,
1011
+ `Use ${names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0]} from "${PKG}" (mortar component ${hit.components[0]}).`,
1012
+ );
1013
+ }
1014
+
863
1015
  // Package-level rules.
864
1016
  let pkgJson;
865
1017
  const pkgFile = join(root, "package.json");