@camunda/design-system 0.63.0 → 0.64.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/dist/src/components/ui/data-table.d.ts +17 -2
- package/dist/src/components/ui/data-table.d.ts.map +1 -1
- package/dist/src/components/ui/data-table.js +16 -7
- package/dist/src/components/ui/data-table.js.map +1 -1
- package/dist/src/components/ui/input.d.ts.map +1 -1
- package/dist/src/components/ui/input.js +7 -4
- package/dist/src/components/ui/input.js.map +1 -1
- package/dist/src/components/ui/select.d.ts.map +1 -1
- package/dist/src/components/ui/select.js +11 -8
- package/dist/src/components/ui/select.js.map +1 -1
- package/dist/src/components/ui/textarea.d.ts.map +1 -1
- package/dist/src/components/ui/textarea.js +8 -5
- package/dist/src/components/ui/textarea.js.map +1 -1
- package/eslint-plugin/index.d.ts +18 -0
- package/eslint-plugin/index.js +102 -0
- package/eslint-rules/button-has-type.js +56 -0
- package/eslint-rules/class-literal.js +20 -0
- package/eslint-rules/ds-imports.js +131 -0
- package/eslint-rules/icon-button-needs-label.js +110 -0
- package/eslint-rules/index.js +31 -0
- package/eslint-rules/navigation.js +295 -0
- package/eslint-rules/no-adhoc-typography.js +77 -0
- package/eslint-rules/no-deep-import.js +59 -0
- package/eslint-rules/no-direct-lucide-import.js +67 -0
- package/eslint-rules/no-hardcoded-i18n-prop.js +78 -0
- package/eslint-rules/no-hardcoded-user-facing-string.js +82 -0
- package/eslint-rules/no-primitive-tailwind-colors.js +36 -0
- package/eslint-rules/no-raw-color-in-classname.js +34 -0
- package/eslint-rules/prefer-link-props.js +107 -0
- package/eslint-rules/require-browsable-link.js +236 -0
- package/eslint-rules/require-shared-sidebar-provider.js +165 -0
- package/package.json +18 -3
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags ad-hoc typography that should use the DS <Heading>/<Text> primitives.
|
|
3
|
+
* Moderate scope: bare <h1>-<h6>, Carbon Heading/Section imports, and elements
|
|
4
|
+
* whose className string literal combines a font-size AND a font-weight utility.
|
|
5
|
+
* Only string-literal classNames are inspected (cn()/template-literals/cva not).
|
|
6
|
+
*/
|
|
7
|
+
import { classLiteral } from "./class-literal.js";
|
|
8
|
+
|
|
9
|
+
const SIZE = /\btext-(xs|sm|base|lg|xl|[2-9]xl|\[[^\]]+\])\b/;
|
|
10
|
+
const WEIGHT =
|
|
11
|
+
/\bfont-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black|\[\d+\])\b/;
|
|
12
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
13
|
+
const PRIMITIVE_NAMES = new Set(["Heading", "Text"]);
|
|
14
|
+
const SVG_TEXT_ELEMENTS = new Set(["text", "tspan", "textPath"]);
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
meta: {
|
|
18
|
+
type: "suggestion",
|
|
19
|
+
docs: {
|
|
20
|
+
description:
|
|
21
|
+
"Use the DS Heading/Text primitives instead of bare headings or ad-hoc type-scale classes.",
|
|
22
|
+
},
|
|
23
|
+
messages: {
|
|
24
|
+
bareHeading:
|
|
25
|
+
'Use <Heading as="{{tag}}" variant="…"> instead of a bare <{{tag}}>.',
|
|
26
|
+
carbonImport:
|
|
27
|
+
"Use the DS <Heading> primitive; drop <Section>/<HeadingLevel> and set the level via `as`.",
|
|
28
|
+
adhocClasses:
|
|
29
|
+
"Use a <Heading>/<Text> variant instead of hand-written type-scale classes (font-size + font-weight combo).",
|
|
30
|
+
},
|
|
31
|
+
schema: [],
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
return {
|
|
35
|
+
JSXOpeningElement(node) {
|
|
36
|
+
if (node.name.type !== "JSXIdentifier") return;
|
|
37
|
+
const name = node.name.name;
|
|
38
|
+
if (HEADING_TAGS.has(name)) {
|
|
39
|
+
context.report({
|
|
40
|
+
node,
|
|
41
|
+
messageId: "bareHeading",
|
|
42
|
+
data: { tag: name },
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (PRIMITIVE_NAMES.has(name)) return;
|
|
47
|
+
// Ad-hoc type-scale only matters on raw HTML host elements. Capitalized React
|
|
48
|
+
// components (DS *Title composites that consume typographyVariants, and consumer
|
|
49
|
+
// components) own their className semantics — an override is deliberate, like the
|
|
50
|
+
// Heading/Text allowance. SVG text nodes can't host Heading/Text.
|
|
51
|
+
if (!/^[a-z]/.test(name) || SVG_TEXT_ELEMENTS.has(name)) return;
|
|
52
|
+
const classAttr = node.attributes.find(
|
|
53
|
+
(a) => a.type === "JSXAttribute" && a.name.name === "className",
|
|
54
|
+
);
|
|
55
|
+
if (!classAttr) return;
|
|
56
|
+
const raw = classLiteral(classAttr.value);
|
|
57
|
+
if (raw && SIZE.test(raw) && WEIGHT.test(raw)) {
|
|
58
|
+
// Anchor to the opening element (like `bareHeading`) so the audit
|
|
59
|
+
// gate prints a consistent line:col for both heading findings.
|
|
60
|
+
context.report({ node, messageId: "adhocClasses" });
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
ImportDeclaration(node) {
|
|
64
|
+
if (node.source.value !== "@carbon/react") return;
|
|
65
|
+
for (const spec of node.specifiers) {
|
|
66
|
+
if (
|
|
67
|
+
spec.type === "ImportSpecifier" &&
|
|
68
|
+
(spec.imported.name === "Heading" ||
|
|
69
|
+
spec.imported.name === "Section")
|
|
70
|
+
) {
|
|
71
|
+
context.report({ node: spec, messageId: "carbonImport" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags imports of `@camunda/design-system/<subpath>` where the subpath is not
|
|
3
|
+
* a public entry point (e.g. `/dist/src/icons`, `/src/components/ui/button`).
|
|
4
|
+
* Deep paths are internal layout and break on any refactor.
|
|
5
|
+
*
|
|
6
|
+
* The allowed subpaths are read from this package's own `exports` map at load
|
|
7
|
+
* time, so a new public entry point is allowed without touching this rule.
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
|
|
11
|
+
const PACKAGE = "@camunda/design-system";
|
|
12
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
13
|
+
const PUBLIC_SUBPATHS = new Set(
|
|
14
|
+
Object.keys(pkg.exports)
|
|
15
|
+
.filter((k) => k !== ".")
|
|
16
|
+
.map((k) => k.slice(2)),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
function publicList() {
|
|
20
|
+
return [PACKAGE, ...[...PUBLIC_SUBPATHS].map((s) => `${PACKAGE}/${s}`)]
|
|
21
|
+
.map((s) => `"${s}"`)
|
|
22
|
+
.join(", ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
meta: {
|
|
27
|
+
type: "problem",
|
|
28
|
+
docs: {
|
|
29
|
+
description: `Import ${PACKAGE} only through its public entry points.`,
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
deepImport: `"{{source}}" is not a public entry point of ${PACKAGE}. Import from one of: {{allowed}}.`,
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
function checkSource(node) {
|
|
38
|
+
const source = node.source?.value;
|
|
39
|
+
if (typeof source !== "string" || !source.startsWith(`${PACKAGE}/`)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (PUBLIC_SUBPATHS.has(source.slice(PACKAGE.length + 1))) return;
|
|
43
|
+
context.report({
|
|
44
|
+
node: node.source,
|
|
45
|
+
messageId: "deepImport",
|
|
46
|
+
data: { source, allowed: publicList() },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
ImportDeclaration: checkSource,
|
|
52
|
+
ExportNamedDeclaration: checkSource,
|
|
53
|
+
ExportAllDeclaration: checkSource,
|
|
54
|
+
ImportExpression(node) {
|
|
55
|
+
if (node.source.type === "Literal") checkSource(node);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags any import or re-export sourced directly from "lucide-react" (or one of
|
|
3
|
+
* its subpaths). Glyphs must flow through the DS icon proxy instead, so the
|
|
4
|
+
* concrete glyph behind a name can be swapped centrally.
|
|
5
|
+
*
|
|
6
|
+
* Two variants share one implementation and differ only in the proxy path:
|
|
7
|
+
* - default export: DS repo source, proxy is the internal "icons/lucide"
|
|
8
|
+
* alias. The proxy module itself (src/icons/lucide.ts) is exempted via an
|
|
9
|
+
* eslint.config.js block, not via logic in this rule.
|
|
10
|
+
* - `consumerNoDirectLucideImport`: product repos, proxy is the public
|
|
11
|
+
* "@camunda/design-system/icons" subpath. Shipped in the ESLint plugin.
|
|
12
|
+
*
|
|
13
|
+
* Autofix rewrites a bare "lucide-react" source to the proxy path — safe
|
|
14
|
+
* because the proxy re-exports the whole lucide-react surface (`export *`).
|
|
15
|
+
* Subpath sources ("lucide-react/dynamic", …) are reported but not fixed: the
|
|
16
|
+
* proxy only mirrors the package root.
|
|
17
|
+
*/
|
|
18
|
+
function createNoDirectLucideImport(proxyPath) {
|
|
19
|
+
return {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: {
|
|
23
|
+
description: `Import Lucide glyphs from the DS icon proxy (${proxyPath}), not lucide-react directly.`,
|
|
24
|
+
},
|
|
25
|
+
fixable: "code",
|
|
26
|
+
messages: {
|
|
27
|
+
noDirectLucideImport: `Import glyphs from the DS icon proxy ("${proxyPath}"), not lucide-react directly.`,
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
function checkSource(node) {
|
|
33
|
+
const source = node.source?.value;
|
|
34
|
+
if (typeof source !== "string") return;
|
|
35
|
+
if (source !== "lucide-react" && !source.startsWith("lucide-react/")) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
context.report({
|
|
39
|
+
node,
|
|
40
|
+
messageId: "noDirectLucideImport",
|
|
41
|
+
fix:
|
|
42
|
+
source === "lucide-react"
|
|
43
|
+
? (fixer) => {
|
|
44
|
+
const quote = node.source.raw[0];
|
|
45
|
+
return fixer.replaceText(
|
|
46
|
+
node.source,
|
|
47
|
+
`${quote}${proxyPath}${quote}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
: null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
ImportDeclaration: checkSource,
|
|
56
|
+
ExportNamedDeclaration: checkSource,
|
|
57
|
+
ExportAllDeclaration: checkSource,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const consumerNoDirectLucideImport = createNoDirectLucideImport(
|
|
64
|
+
"@camunda/design-system/icons",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export default createNoDirectLucideImport("icons/lucide");
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags a string literal passed to an i18n-overridable prop of a DS component
|
|
3
|
+
* (`<Dialog closeLabel="Close" />`). Every DS component already renders its
|
|
4
|
+
* own English default from the i18n dictionary; a literal there only restates
|
|
5
|
+
* it and blocks a future translation. Route the consumer's own translated
|
|
6
|
+
* value through the prop, or leave it unset.
|
|
7
|
+
*
|
|
8
|
+
* "Overridable" is matched by naming convention — the DS names every override
|
|
9
|
+
* prop `…Label`, `…LabelText`, `…Message`, `…Prefix` or `searchPlaceholder`
|
|
10
|
+
* (kept in sync with DS source by no-hardcoded-i18n-prop.test.mjs). Bare
|
|
11
|
+
* `label`/`title`/`placeholder` are deliberately excluded: on most components
|
|
12
|
+
* they carry caller content, not a DS default. So are the few convention-named
|
|
13
|
+
* content props listed in CONTENT_PROPS.
|
|
14
|
+
*
|
|
15
|
+
* Heuristic, so shipped at `warn`: a caller-specific message
|
|
16
|
+
* (`emptyMessage="No workspaces yet"`) is legitimate in an app without i18n.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
importSourcesSchema,
|
|
20
|
+
staticString,
|
|
21
|
+
trackDsImports,
|
|
22
|
+
} from "./ds-imports.js";
|
|
23
|
+
|
|
24
|
+
const OVERRIDE_PROP = /^[a-z][A-Za-z]*(Label|LabelText|Message|Prefix)$/;
|
|
25
|
+
const EXTRA_OVERRIDE_PROPS = new Set(["searchPlaceholder"]);
|
|
26
|
+
// Match the naming convention but carry caller content with no DS default.
|
|
27
|
+
const CONTENT_PROPS = new Set([
|
|
28
|
+
"centerLabel", // PieChart center text
|
|
29
|
+
"ctaLabel", // MultiStepDialog visual CTA
|
|
30
|
+
"secondaryLabel", // carbon-compat Tabs
|
|
31
|
+
"rangeLabel", // carbon-compat DatePicker field labels
|
|
32
|
+
"startLabel",
|
|
33
|
+
"endLabel",
|
|
34
|
+
]);
|
|
35
|
+
const HAS_WORDS = /\p{L}{2,}/u;
|
|
36
|
+
|
|
37
|
+
export function isOverridableProp(name) {
|
|
38
|
+
if (CONTENT_PROPS.has(name)) return false;
|
|
39
|
+
return OVERRIDE_PROP.test(name) || EXTRA_OVERRIDE_PROPS.has(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
meta: {
|
|
44
|
+
type: "suggestion",
|
|
45
|
+
docs: {
|
|
46
|
+
description:
|
|
47
|
+
"Don't hardcode string literals on DS i18n-overridable props (closeLabel, emptyMessage, …); pass a translated value or leave the DS default.",
|
|
48
|
+
heuristic: true,
|
|
49
|
+
},
|
|
50
|
+
messages: {
|
|
51
|
+
hardcoded:
|
|
52
|
+
'`{{prop}}="{{value}}"` hardcodes copy on a DS i18n-overridable prop. Pass your app\'s translated string instead, or omit the prop to fall back to the DS default where one exists.',
|
|
53
|
+
},
|
|
54
|
+
schema: [importSourcesSchema],
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const ds = trackDsImports(context);
|
|
58
|
+
return {
|
|
59
|
+
ImportDeclaration: ds.ImportDeclaration,
|
|
60
|
+
JSXOpeningElement(node) {
|
|
61
|
+
if (!ds.importedName(node.name)) return;
|
|
62
|
+
for (const attr of node.attributes) {
|
|
63
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
64
|
+
if (attr.name.type !== "JSXIdentifier") continue;
|
|
65
|
+
const prop = attr.name.name;
|
|
66
|
+
if (!isOverridableProp(prop)) continue;
|
|
67
|
+
const value = staticString(attr.value);
|
|
68
|
+
if (value === null || !HAS_WORDS.test(value)) continue;
|
|
69
|
+
context.report({
|
|
70
|
+
node: attr,
|
|
71
|
+
messageId: "hardcoded",
|
|
72
|
+
data: { prop, value },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags hardcoded English copy that should be routed through the i18n
|
|
3
|
+
* dictionary (`useC4Dictionary()` / `t("key")`, see src/lib/i18n/dictionary.ts)
|
|
4
|
+
* instead of a raw string literal.
|
|
5
|
+
*
|
|
6
|
+
* Scope is narrow and precise, not exhaustive:
|
|
7
|
+
* - JSX text content — a component rendering its own static prose.
|
|
8
|
+
* - String-literal values on `aria-label` / `title` / `placeholder` / `alt`
|
|
9
|
+
* — the four attributes whose native HTML/ARIA semantics guarantee the
|
|
10
|
+
* value is always user-facing text, so no attribute-name allowlist is
|
|
11
|
+
* needed to avoid false positives on structural props (`variant`,
|
|
12
|
+
* `type`, `size`, `data-*`, ...).
|
|
13
|
+
*
|
|
14
|
+
* A value wrapped in a JSXExpressionContainer around anything other than a
|
|
15
|
+
* bare string Literal (a `t("key")` call, a prop, a variable, a ternary) is
|
|
16
|
+
* never flagged. Whitespace-only JSX text and text with no letters (glyphs,
|
|
17
|
+
* punctuation, numbers) are not flagged either — neither needs translation.
|
|
18
|
+
*/
|
|
19
|
+
const WATCHED_ATTRIBUTES = new Set([
|
|
20
|
+
"aria-label",
|
|
21
|
+
"title",
|
|
22
|
+
"placeholder",
|
|
23
|
+
"alt",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// At least two letters — excludes whitespace-only text and bare punctuation/
|
|
27
|
+
// glyphs/numbers ("…", "·", "42", "100%"), none of which need translation.
|
|
28
|
+
const HAS_WORDS = /[A-Za-z]{2,}/;
|
|
29
|
+
|
|
30
|
+
function isHardcodedLiteral(value) {
|
|
31
|
+
return typeof value === "string" && HAS_WORDS.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "suggestion",
|
|
37
|
+
docs: {
|
|
38
|
+
description:
|
|
39
|
+
"Route user-facing copy through the i18n dictionary (useC4Dictionary()/t()) instead of a hardcoded string literal.",
|
|
40
|
+
},
|
|
41
|
+
messages: {
|
|
42
|
+
hardcodedText:
|
|
43
|
+
'Hardcoded text content — add a key to src/lib/i18n/dictionary.ts and render it via t("key") instead.',
|
|
44
|
+
hardcodedAttribute:
|
|
45
|
+
'Hardcoded "{{attr}}" string — add a key to src/lib/i18n/dictionary.ts and pass t("key") instead.',
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
return {
|
|
51
|
+
JSXText(node) {
|
|
52
|
+
if (!isHardcodedLiteral(node.value)) return;
|
|
53
|
+
context.report({ node, messageId: "hardcodedText" });
|
|
54
|
+
},
|
|
55
|
+
JSXAttribute(node) {
|
|
56
|
+
if (node.name.type !== "JSXIdentifier") return;
|
|
57
|
+
if (!WATCHED_ATTRIBUTES.has(node.name.name)) return;
|
|
58
|
+
const value = node.value;
|
|
59
|
+
if (!value) return;
|
|
60
|
+
if (value.type === "Literal" && isHardcodedLiteral(value.value)) {
|
|
61
|
+
context.report({
|
|
62
|
+
node: value,
|
|
63
|
+
messageId: "hardcodedAttribute",
|
|
64
|
+
data: { attr: node.name.name },
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
value.type === "JSXExpressionContainer" &&
|
|
70
|
+
value.expression.type === "Literal" &&
|
|
71
|
+
isHardcodedLiteral(value.expression.value)
|
|
72
|
+
) {
|
|
73
|
+
context.report({
|
|
74
|
+
node: value.expression,
|
|
75
|
+
messageId: "hardcodedAttribute",
|
|
76
|
+
data: { attr: node.name.name },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags primitive Tailwind color utilities (bg-blue-500, text-gray-100, …) in a
|
|
3
|
+
* className. DS components must use semantic tokens (bg-primary,
|
|
4
|
+
* text-neutral-foreground-subtle, …) so theming/dark-mode stay centralized.
|
|
5
|
+
* Only string-literal classNames are inspected (cn()/template-literals not).
|
|
6
|
+
*/
|
|
7
|
+
import { classLiteral } from "./class-literal.js";
|
|
8
|
+
|
|
9
|
+
const PRIMITIVE_TW_PATTERN =
|
|
10
|
+
/\b(bg|text|border|ring|fill|stroke|outline|shadow|placeholder|accent|caret|divide)-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: "suggestion",
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
"Use semantic color tokens instead of primitive Tailwind color classes.",
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
primitiveColor:
|
|
21
|
+
"Primitive Tailwind color class forbidden. Use semantic tokens (bg-primary, text-neutral-foreground-subtle, …).",
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
return {
|
|
27
|
+
JSXAttribute(node) {
|
|
28
|
+
if (node.name.name !== "className") return;
|
|
29
|
+
const raw = classLiteral(node.value);
|
|
30
|
+
if (raw && PRIMITIVE_TW_PATTERN.test(raw)) {
|
|
31
|
+
context.report({ node, messageId: "primitiveColor" });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags raw hex / rgb() color literals in a className (#ff0000, rgba(0,0,0,.5)).
|
|
3
|
+
* DS components must use semantic tokens from src/index.css so theming/dark-mode
|
|
4
|
+
* stay centralized. Only string-literal classNames are inspected.
|
|
5
|
+
*/
|
|
6
|
+
import { classLiteral } from "./class-literal.js";
|
|
7
|
+
|
|
8
|
+
const HEX_RGB_PATTERN = /#[0-9a-fA-F]{3,8}\b|rgba?\s*\(/;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
meta: {
|
|
12
|
+
type: "suggestion",
|
|
13
|
+
docs: {
|
|
14
|
+
description:
|
|
15
|
+
"Use semantic color tokens instead of raw hex/rgb() in className.",
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
rawColor:
|
|
19
|
+
"Raw hex/rgb in className forbidden. Use semantic tokens from src/index.css.",
|
|
20
|
+
},
|
|
21
|
+
schema: [],
|
|
22
|
+
},
|
|
23
|
+
create(context) {
|
|
24
|
+
return {
|
|
25
|
+
JSXAttribute(node) {
|
|
26
|
+
if (node.name.name !== "className") return;
|
|
27
|
+
const raw = classLiteral(node.value);
|
|
28
|
+
if (raw && HEX_RGB_PATTERN.test(raw)) {
|
|
29
|
+
context.report({ node, messageId: "rawColor" });
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft companion to `require-browsable-link`: flags DS components wired with a
|
|
3
|
+
* click prop but no link prop when the handler's intent is NOT visible (a prop,
|
|
4
|
+
* an imported function, a callback from context). Covers `DataTable`
|
|
5
|
+
* (`onRowClick` / `rowHref`), `NavBreadcrumbItem` (`onClick` / `linkProps`) and
|
|
6
|
+
* DS descriptor objects (sidebar items, breadcrumb segments, switcher entries,
|
|
7
|
+
* notifications: `onClick`|`navigate` / `linkProps`|`href`). Without the link
|
|
8
|
+
* prop the target renders as a `<button>` — no hoverable href, no
|
|
9
|
+
* Cmd/middle-click, no "open in new tab".
|
|
10
|
+
*
|
|
11
|
+
* When the handler visibly navigates, `require-browsable-link` reports it (as
|
|
12
|
+
* an error) and this rule stays silent, so a site is never reported twice.
|
|
13
|
+
* Heuristic, so shipped at `warn`: the click may legitimately not navigate
|
|
14
|
+
* (open a panel, select a row). Disable per line when that is the case.
|
|
15
|
+
* Elements with a spread are skipped — the link prop may be in it.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
findAttribute,
|
|
19
|
+
hasActiveAttribute,
|
|
20
|
+
hasSpread,
|
|
21
|
+
trackDsImports,
|
|
22
|
+
} from "./ds-imports.js";
|
|
23
|
+
import {
|
|
24
|
+
attributeNavigation,
|
|
25
|
+
descriptorRoots,
|
|
26
|
+
findNavigation,
|
|
27
|
+
forEachLinklessDescriptor,
|
|
28
|
+
navigationSchemaProperties,
|
|
29
|
+
} from "./navigation.js";
|
|
30
|
+
|
|
31
|
+
// component -> [click prop, link prop]
|
|
32
|
+
const PAIRS = {
|
|
33
|
+
DataTable: ["onRowClick", "rowHref"],
|
|
34
|
+
NavBreadcrumbItem: ["onClick", "linkProps"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
meta: {
|
|
39
|
+
type: "suggestion",
|
|
40
|
+
docs: {
|
|
41
|
+
description:
|
|
42
|
+
"Prefer the link prop (rowHref / linkProps / href) over click-only handlers on DS components that support both.",
|
|
43
|
+
heuristic: true,
|
|
44
|
+
},
|
|
45
|
+
messages: {
|
|
46
|
+
preferLink:
|
|
47
|
+
"<{{component}}> has `{{click}}` but no `{{link}}`. If this navigates, pass `{{link}}` so it renders a real link (href, Cmd/middle-click, open in new tab), and remove the navigation from `{{click}}`.",
|
|
48
|
+
preferDescriptorLink:
|
|
49
|
+
"This item has `{{click}}` but no `linkProps`/`href`. If it navigates, add the link prop so it renders a real link, and remove the navigation from `{{click}}`.",
|
|
50
|
+
},
|
|
51
|
+
schema: [
|
|
52
|
+
{
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
importSources: {
|
|
56
|
+
type: "array",
|
|
57
|
+
items: { type: "string" },
|
|
58
|
+
minItems: 1,
|
|
59
|
+
uniqueItems: true,
|
|
60
|
+
},
|
|
61
|
+
...navigationSchemaProperties,
|
|
62
|
+
},
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const ds = trackDsImports(context);
|
|
69
|
+
const reported = new Set();
|
|
70
|
+
|
|
71
|
+
function checkDescriptors(node) {
|
|
72
|
+
for (const root of descriptorRoots(ds, node)) {
|
|
73
|
+
forEachLinklessDescriptor(context, root, (_obj, click) => {
|
|
74
|
+
if (reported.has(click)) return;
|
|
75
|
+
reported.add(click);
|
|
76
|
+
if (findNavigation(context, click.value)) return;
|
|
77
|
+
context.report({
|
|
78
|
+
node: click,
|
|
79
|
+
messageId: "preferDescriptorLink",
|
|
80
|
+
data: { click: click.key.name ?? click.key.value },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ImportDeclaration: ds.ImportDeclaration,
|
|
88
|
+
JSXOpeningElement(node) {
|
|
89
|
+
checkDescriptors(node);
|
|
90
|
+
const component = ds.importedName(node.name);
|
|
91
|
+
const pair = component && PAIRS[component];
|
|
92
|
+
if (!pair) return;
|
|
93
|
+
const [click, link] = pair;
|
|
94
|
+
const clickAttr = findAttribute(node, click);
|
|
95
|
+
if (!clickAttr) return;
|
|
96
|
+
if (hasActiveAttribute(node, link) || hasSpread(node)) return;
|
|
97
|
+
if (attributeNavigation(context, clickAttr)) return;
|
|
98
|
+
context.report({
|
|
99
|
+
node,
|
|
100
|
+
messageId: "preferLink",
|
|
101
|
+
data: { component, click, link },
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
CallExpression: checkDescriptors,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|