@starci/eslint-grammar-core 0.13.0 → 0.14.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -1
  3. package/dist/app-shell.d.ts +15 -0
  4. package/dist/app-shell.js +224 -0
  5. package/dist/chip-label.d.ts +14 -0
  6. package/dist/chip-label.js +117 -0
  7. package/dist/drawing.d.ts +8 -0
  8. package/dist/drawing.js +59 -0
  9. package/dist/grammar-boundary.d.ts +12 -0
  10. package/dist/grammar-boundary.js +88 -0
  11. package/dist/icon.d.ts +27 -0
  12. package/dist/icon.js +293 -0
  13. package/dist/index.d.ts +45 -0
  14. package/dist/index.js +73 -0
  15. package/dist/lib/app-shell-cluster.d.ts +6 -0
  16. package/dist/lib/app-shell-cluster.js +75 -0
  17. package/dist/lib/app-shell-create.d.ts +5 -0
  18. package/dist/lib/app-shell-create.js +37 -0
  19. package/dist/lib/app-shell-owner-source.d.ts +11 -0
  20. package/dist/lib/app-shell-owner-source.js +159 -0
  21. package/dist/lib/app-shell-owner.d.ts +7 -0
  22. package/dist/lib/app-shell-owner.js +97 -0
  23. package/dist/lib/app-shell-scope.d.ts +13 -0
  24. package/dist/lib/app-shell-scope.js +93 -0
  25. package/dist/lib/app-shell-visible.d.ts +13 -0
  26. package/dist/lib/app-shell-visible.js +117 -0
  27. package/dist/lib/app-shell.d.ts +27 -0
  28. package/dist/lib/app-shell.js +185 -0
  29. package/dist/lib/rule-contract.d.ts +12 -0
  30. package/dist/lib/rule-contract.js +23 -0
  31. package/dist/lib/status-tones.generated.d.ts +2 -0
  32. package/dist/lib/status-tones.generated.js +8 -0
  33. package/dist/lib/types.d.ts +113 -0
  34. package/dist/lib/types.js +2 -0
  35. package/dist/lib/why.d.ts +8 -0
  36. package/dist/lib/why.js +42 -0
  37. package/dist/menu-entries.d.ts +27 -0
  38. package/dist/menu-entries.js +142 -0
  39. package/dist/status-colors.d.ts +13 -0
  40. package/dist/status-colors.js +143 -0
  41. package/dist/tokens.d.ts +11 -0
  42. package/dist/tokens.js +166 -0
  43. package/dist/typography.d.ts +13 -0
  44. package/dist/typography.js +72 -0
  45. package/dist/vendor-boundary.d.ts +5 -0
  46. package/dist/vendor-boundary.js +25 -0
  47. package/docs/chip-label.md +30 -0
  48. package/docs/grammar-boundary.md +1 -1
  49. package/docs/status-colors.md +2 -2
  50. package/package.json +18 -15
  51. package/runtime/knowledge/hfs/canon-pins.yaml +11 -10
  52. package/runtime/knowledge/hfs/rules.yaml +23 -0
  53. package/app-shell.mjs +0 -208
  54. package/drawing.mjs +0 -60
  55. package/grammar-boundary.mjs +0 -94
  56. package/icon.mjs +0 -313
  57. package/index.d.ts +0 -28
  58. package/index.mjs +0 -78
  59. package/lib/app-shell-cluster.mjs +0 -66
  60. package/lib/app-shell-create.mjs +0 -33
  61. package/lib/app-shell-owner-source.mjs +0 -124
  62. package/lib/app-shell-owner.mjs +0 -83
  63. package/lib/app-shell-scope.mjs +0 -79
  64. package/lib/app-shell-visible.mjs +0 -92
  65. package/lib/app-shell.mjs +0 -148
  66. package/lib/rule-contract.mjs +0 -11
  67. package/lib/status-tones.generated.mjs +0 -9
  68. package/lib/why.mjs +0 -38
  69. package/menu-entries.mjs +0 -146
  70. package/status-colors.mjs +0 -140
  71. package/tokens.mjs +0 -164
  72. package/typography.mjs +0 -81
  73. package/vendor-boundary.mjs +0 -29
@@ -0,0 +1,185 @@
1
+ import { attributeValue, stringOf } from "@starci/eslint-canon-fe/lib/ast.mjs";
2
+ import { functionOf, importOf, unwrap, variableOf } from "@starci/eslint-canon-fe/lib/bindings.mjs";
3
+ import { isNode } from "./types.js";
4
+ const GRAMMAR_SOURCE = /^@starci\/grammar(?:\/|$)/;
5
+ /** Grammar controls whose visible hit target belongs to the navbar band's size contract. */
6
+ const BAR_CONTROLS = new Set(["Button", "IconButton", "Switch", "AvatarButton", "PlainPressable", "IdentityLockup", "SearchField", "Input", "TextField", "Select", "TextAction"]);
7
+ const BAR_WRAPPERS = new Set(["GrammarRoot", "WorkspaceShell", "TopBar", "NavigationFeatureNav", "Region", "PageContainer", "NavLandmark", "Form", "Fieldset", "Field", "ButtonGroup", "DropdownMenu", "Popover", "Drawer", "Tooltip"]);
8
+ const ACTION_PROPS = ["onPress", "onClick", "onAction", "onValueChange", "onSelectionChange", "onSelectedChange", "onChange", "onInputChange"];
9
+ const INTERACTION_PROPS = new Set(["label", "value", "defaultValue", "selectedKey", "selectedKeys", "selectionMode", "href", ...ACTION_PROPS]);
10
+ /** The name of a plain (non-namespaced) JSX attribute; null for a namespaced one. */
11
+ const attributeName = (entry) => (entry.name.type === "JSXIdentifier" ? entry.name.name : null);
12
+ /** Bar policy also counts labelled/value/selection/action field owners, so an extra control cannot vanish from the cluster. */
13
+ export const isBarControl = (context, opening, name) => {
14
+ if (name !== null && BAR_CONTROLS.has(name))
15
+ return true;
16
+ if (!name || !/^[A-Z]/.test(name) || BAR_WRAPPERS.has(name))
17
+ return false;
18
+ if (opening.attributes.some((entry) => {
19
+ if (entry.type !== "JSXAttribute")
20
+ return false;
21
+ const attribute = attributeName(entry);
22
+ return attribute !== null && INTERACTION_PROPS.has(attribute);
23
+ }))
24
+ return true;
25
+ return [...INTERACTION_PROPS].some((prop) => propOf(context, opening, prop).value !== null);
26
+ };
27
+ /** The identifier name of a member property, or undefined for any other kind of key. */
28
+ const memberPropertyName = (property) => ("name" in property && typeof property.name === "string" ? property.name : undefined);
29
+ /** A literal or constant's initializer, without type-only wrappers. */
30
+ export const resolveLocal = (context, expression, seen = new Set()) => {
31
+ const node = expression && isNode(expression) ? unwrap(expression) : expression;
32
+ if (node?.type === "MemberExpression")
33
+ return resolveMember(context, node, seen);
34
+ if (node?.type === "CallExpression")
35
+ return resolveCall(context, node, seen);
36
+ if (node?.type !== "Identifier" && node?.type !== "JSXIdentifier" || seen.has(node))
37
+ return node;
38
+ const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable");
39
+ if (!definition || definition.parent?.kind !== "const")
40
+ return node;
41
+ seen.add(node);
42
+ return resolveLocal(context, initializerOf(definition.node, node), seen);
43
+ };
44
+ const resolveCall = (context, node, seen) => {
45
+ if (seen.has(node))
46
+ return node;
47
+ seen.add(node);
48
+ const fn = functionOf(context, realNode(resolveLocal(context, node.callee, seen)));
49
+ if (!fn)
50
+ return node;
51
+ if (fn.body.type !== "BlockStatement")
52
+ return resolveLocal(context, fn.body, seen);
53
+ if (fn.body.body.some((statement) => !["VariableDeclaration", "ReturnStatement"].includes(statement.type)))
54
+ return node;
55
+ const returned = fn.body.body.find((statement) => statement.type === "ReturnStatement");
56
+ return returned ? resolveLocal(context, returned.argument, seen) : node;
57
+ };
58
+ /** A resolved value as a parsed node; a synthesized member stands for no function or value of the tree. */
59
+ export const realNode = (value) => (value && isNode(value) ? value : null);
60
+ const resolveMember = (context, node, seen) => {
61
+ const key = node.computed ? stringOf(node.property) : memberPropertyName(node.property);
62
+ if (!key)
63
+ return node;
64
+ const property = objectValue(context, node.object, key, seen);
65
+ return property.known && property.value !== null ? resolveLocal(context, property.value, seen) : node;
66
+ };
67
+ const initializerOf = (declaration, node) => {
68
+ if (declaration.id.type === "ObjectPattern") {
69
+ const property = declaration.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === node.name);
70
+ if (!property || property.type !== "Property")
71
+ return node;
72
+ return { type: "MemberExpression", object: declaration.init, property: property.key, computed: property.computed };
73
+ }
74
+ return declaration.init;
75
+ };
76
+ /** The imported Grammar component's name; aliases and namespace imports keep their identity. */
77
+ export const grammarName = (context, opening) => {
78
+ const name = opening.name;
79
+ if (name.type === "JSXIdentifier") {
80
+ const canonical = expressionName(context, name);
81
+ if (canonical)
82
+ return canonical;
83
+ return variableOf(context, name)?.defs.length ? null : name.name;
84
+ }
85
+ if (name.type !== "JSXMemberExpression" || name.object.type !== "JSXIdentifier")
86
+ return null;
87
+ return expressionName(context, { type: "MemberExpression", object: name.object, property: name.property, computed: false }) ?? null;
88
+ };
89
+ const expressionName = (context, expression) => {
90
+ const resolved = resolveLocal(context, expression);
91
+ if (resolved?.type === "MemberExpression") {
92
+ return namespaceName(context, resolved.object, resolved.computed ? stringOf(resolved.property) : memberPropertyName(resolved.property));
93
+ }
94
+ if (resolved?.type !== "Identifier" && resolved?.type !== "JSXIdentifier")
95
+ return null;
96
+ const imported = importOf(context, resolved);
97
+ return imported && GRAMMAR_SOURCE.test(imported.source) ? imported.imported : null;
98
+ };
99
+ const namespaceName = (context, expression, property) => {
100
+ const object = resolveLocal(context, expression);
101
+ if (object?.type !== "Identifier" && object?.type !== "JSXIdentifier")
102
+ return null;
103
+ const imported = importOf(context, object);
104
+ return imported && GRAMMAR_SOURCE.test(imported.source) ? property : null;
105
+ };
106
+ /** The value of a named property of an object expression: `known` is false when a spread or condition hides it. */
107
+ const objectValue = (context, expression, name, seen = new Set()) => {
108
+ const node = resolveLocal(context, expression);
109
+ if (node?.type === "ConditionalExpression")
110
+ return conditionalObjectValue(context, node, name, seen);
111
+ if (node?.type !== "ObjectExpression" || seen.has(node))
112
+ return { known: false, value: null };
113
+ seen.add(node);
114
+ let result = { known: true, value: null };
115
+ for (const property of node.properties) {
116
+ if (property.type === "SpreadElement") {
117
+ const spread = objectValue(context, property.argument, name, seen);
118
+ if (!spread.known || spread.value !== null)
119
+ result = spread;
120
+ }
121
+ else if (!property.computed && propertyKeyText(property.key) === name) {
122
+ result = { known: true, value: property.value };
123
+ }
124
+ }
125
+ return result;
126
+ };
127
+ /** The text a property key spells: an identifier's name or a string literal's value; null for any other key. */
128
+ export const propertyKeyText = (key) => {
129
+ if (key.type === "Identifier")
130
+ return key.name;
131
+ if (key.type === "Literal" && typeof key.value === "string")
132
+ return key.value;
133
+ return null;
134
+ };
135
+ const conditionalObjectValue = (context, node, name, seen) => {
136
+ if (seen.has(node))
137
+ return { known: false, value: null };
138
+ const visited = new Set(seen).add(node);
139
+ const values = [node.consequent, node.alternate].map((branch) => objectValue(context, branch, name, new Set(visited)));
140
+ return { known: values.every((value) => value.known && value.value === null), value: null };
141
+ };
142
+ /** The effective prop, respecting JSX spread order; unknown later spreads cannot prove a value. */
143
+ export const propOf = (context, opening, name) => {
144
+ let result = { known: true, value: null };
145
+ for (const entry of opening.attributes) {
146
+ if (entry.type === "JSXSpreadAttribute") {
147
+ const spread = objectValue(context, entry.argument, name);
148
+ if (!spread.known || spread.value !== null)
149
+ result = spread;
150
+ }
151
+ else if (entry.name.name === name)
152
+ result = { known: true, value: attributeValue(entry), present: true };
153
+ }
154
+ return result;
155
+ };
156
+ /** A statically proven string prop, or null for missing/unknown values. */
157
+ export const textProp = (context, opening, name) => {
158
+ const prop = propOf(context, opening, name);
159
+ return prop.known ? stringOf(realNode(resolveLocal(context, prop.value))) : null;
160
+ };
161
+ /** The source spelling is semantic evidence for an icon or action whose translated label is dynamic. */
162
+ export const semanticText = (context, opening) => {
163
+ const values = ["source", "icon", "label", "aria-label", "id", ...ACTION_PROPS].map((name) => {
164
+ const prop = propOf(context, opening, name);
165
+ return prop.value ? context.sourceCode.getText(prop.value) : "";
166
+ });
167
+ return values.join(" ");
168
+ };
169
+ /** The right-cluster role and the Grammar kind that must draw it. */
170
+ export const clusterRole = (context, opening, name) => {
171
+ if (name === "Switch")
172
+ return "theme";
173
+ if (name === "AvatarButton")
174
+ return "account";
175
+ const text = semanticText(context, opening);
176
+ if (/language|locale|translate/i.test(text))
177
+ return "language";
178
+ if (/notification|bell/i.test(text))
179
+ return "notifications";
180
+ if (/account|avatar|profile/i.test(text))
181
+ return "account";
182
+ if (/theme|darkmode|color.?mode/i.test(text))
183
+ return "theme";
184
+ return null;
185
+ };
@@ -0,0 +1,12 @@
1
+ import type { Rule } from "eslint";
2
+ import type { RuleDefinition } from "./types.ts";
3
+ /** The ordered ownership test that governs every Grammar-specific lint rule. */
4
+ export declare const RULE_OWNERSHIP = "knowledge/patterns/fe/index.yaml#FE-RULE-OWNERSHIP";
5
+ /**
6
+ * A rule written against the typescript-eslint tree, as the ESLint rule module that publishes it. Every rule of this
7
+ * package is linted by `@typescript-eslint/parser` (`coreGrammarConfig` sets it), so the context it receives and the
8
+ * visitor it returns carry that parser's node types; this is the one place that states so to ESLint's declarations.
9
+ */
10
+ export declare const defineRule: (definition: RuleDefinition) => Rule.RuleModule;
11
+ /** Attach the governing ownership rule and the rule's behavioral source without changing enforcement. */
12
+ export declare const defineFamilyRules: (rules: Readonly<Record<string, Rule.RuleModule>>, knowledge: string) => Readonly<Record<string, Rule.RuleModule>>;
@@ -0,0 +1,23 @@
1
+ /** The ordered ownership test that governs every Grammar-specific lint rule. */
2
+ export const RULE_OWNERSHIP = "knowledge/patterns/fe/index.yaml#FE-RULE-OWNERSHIP";
3
+ /**
4
+ * A rule written against the typescript-eslint tree, as the ESLint rule module that publishes it. Every rule of this
5
+ * package is linted by `@typescript-eslint/parser` (`coreGrammarConfig` sets it), so the context it receives and the
6
+ * visitor it returns carry that parser's node types; this is the one place that states so to ESLint's declarations.
7
+ */
8
+ export const defineRule = (definition) => ({
9
+ meta: definition.meta,
10
+ create: (context) => definition.create(context),
11
+ });
12
+ /** Attach the governing ownership rule and the rule's behavioral source without changing enforcement. */
13
+ export const defineFamilyRules = (rules, knowledge) => {
14
+ for (const rule of Object.values(rules)) {
15
+ const meta = rule.meta;
16
+ if (meta === undefined)
17
+ throw new Error("A family rule declares its meta");
18
+ const source = meta.docs?.url ?? knowledge;
19
+ const docs = { ...meta.docs, url: source, knowledge: source, ownership: RULE_OWNERSHIP };
20
+ meta.docs = docs;
21
+ }
22
+ return rules;
23
+ };
@@ -0,0 +1,2 @@
1
+ /** The status tones of the grammar: each has a solid pair and a soft pair (`--<tone>-soft`, `--<tone>-soft-foreground`). */
2
+ export declare const STATUS_TONES: readonly string[];
@@ -0,0 +1,8 @@
1
+ // Generated by packages/stylelint-canon/src/lib/write-vocabulary.ts from packages/grammar/src. Do not edit: run `npm run vocabulary` in packages/stylelint-canon.
2
+ /** The status tones of the grammar: each has a solid pair and a soft pair (`--<tone>-soft`, `--<tone>-soft-foreground`). */
3
+ export const STATUS_TONES = [
4
+ "success",
5
+ "warning",
6
+ "danger",
7
+ "info",
8
+ ];
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The AST and rule-context types this package's rules are written against.
3
+ *
4
+ * Every rule runs under `@typescript-eslint/parser` (`coreGrammarConfig` sets it), so the nodes it receives are the
5
+ * typescript-eslint tree (`TSESTree`: ESTree plus JSX and TypeScript nodes, each with its `parent`). ESLint's own
6
+ * declarations describe plain ESTree, so the rule context is restated here with the node type the parser produces.
7
+ */
8
+ import type { AST, Rule, Scope } from "eslint";
9
+ import type { TSESTree } from "@typescript-eslint/types";
10
+ /** Any node of the typescript-eslint tree. */
11
+ export type Node = TSESTree.Node;
12
+ /** The function-shaped nodes the readers follow. */
13
+ export type FunctionNode = TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression;
14
+ /** An identifier-like name a scope lookup accepts. */
15
+ export type NameNode = TSESTree.Identifier | TSESTree.JSXIdentifier;
16
+ /** What the scope manager records for a declaration: only the definition kinds the readers inspect are named. */
17
+ export type ScopeDefinition = {
18
+ readonly type: "Variable";
19
+ readonly node: TSESTree.VariableDeclarator;
20
+ readonly parent: TSESTree.VariableDeclaration;
21
+ } | {
22
+ readonly type: "ImportBinding";
23
+ readonly node: TSESTree.ImportSpecifier | TSESTree.ImportDefaultSpecifier | TSESTree.ImportNamespaceSpecifier;
24
+ readonly parent: TSESTree.ImportDeclaration;
25
+ } | {
26
+ readonly type: "FunctionName";
27
+ readonly node: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression;
28
+ readonly parent: null;
29
+ } | {
30
+ readonly type: "Parameter" | "ClassName" | "CatchClause" | "TSEnumName" | "TSModuleName" | "Type" | "ImportEqualsBinding" | "ImplicitGlobalVariable";
31
+ readonly node: Node;
32
+ readonly parent: Node | null;
33
+ };
34
+ /** A scope-manager variable: its name and where it is declared. */
35
+ export interface ScopeVariable {
36
+ readonly name: string;
37
+ readonly defs: readonly ScopeDefinition[];
38
+ }
39
+ /** The source-code object of a linted (or independently parsed) file, with the parser's node type. */
40
+ export interface SourceView {
41
+ readonly ast: AST.Program;
42
+ readonly text: string;
43
+ readonly visitorKeys: Readonly<Record<string, readonly string[] | undefined>>;
44
+ getText(node?: Node): string;
45
+ getScope(node: Node): Scope.Scope;
46
+ getCommentsBefore(node: Node): readonly SourceComment[];
47
+ }
48
+ /** A comment of the linted file: `Block` for `/* *\/`, `Line` for `//`. */
49
+ export interface SourceComment {
50
+ readonly type: "Block" | "Line";
51
+ readonly value: string;
52
+ readonly range: readonly [number, number];
53
+ }
54
+ /** The parts of a rule context a reader needs; an independently parsed file offers exactly these. */
55
+ export interface ReaderContext {
56
+ readonly filename: string;
57
+ readonly id?: string;
58
+ readonly settings: Rule.RuleContext["settings"];
59
+ readonly sourceCode: SourceView;
60
+ getFilename?(): string;
61
+ }
62
+ /** An independently parsed file: the reader context plus the typescript-eslint tree its parse produced. */
63
+ export interface ParsedContext extends ReaderContext {
64
+ readonly program: TSESTree.Program;
65
+ }
66
+ /** A finding a rule reports. */
67
+ export interface ReportDescriptor {
68
+ readonly node: Node;
69
+ readonly messageId: string;
70
+ readonly data?: Readonly<Record<string, unknown>>;
71
+ readonly fix?: (fixer: Rule.RuleFixer) => Rule.Fix | null;
72
+ }
73
+ /** The rule context of a rule that reports. */
74
+ export interface Context extends ReaderContext {
75
+ getFilename(): string;
76
+ report(descriptor: ReportDescriptor): void;
77
+ }
78
+ /** The visitor of a rule: a handler per node type, each receiving that node type. */
79
+ export type Listener = {
80
+ [K in Node["type"]]?: (node: Extract<Node, {
81
+ type: K;
82
+ }>) => void;
83
+ };
84
+ /** A rule whose `create` is written against the parser's node type. */
85
+ export interface RuleDefinition {
86
+ readonly meta: Rule.RuleMetaData;
87
+ create(context: Context): Listener;
88
+ }
89
+ /**
90
+ * A member access the readers synthesize for a destructured binding (`const { A: B } = ns` reads as `ns.A`) or a JSX member
91
+ * name (`<ns.A />`): it has no source position and no parent, so it is not a node of the parsed tree.
92
+ */
93
+ export interface SyntheticMember {
94
+ readonly type: "MemberExpression";
95
+ readonly object: Node | null;
96
+ readonly property: Node;
97
+ readonly computed: boolean;
98
+ }
99
+ /** What resolving a local expression yields: a real node, or a synthesized member access. */
100
+ export type Resolved = Node | SyntheticMember;
101
+ /** True for a node of the parsed tree; a synthesized member has no source range. */
102
+ export declare const isNode: (value: Resolved) => value is Node;
103
+ /** The opened slot manifest and resolver of an app or side folder (the runtime's `openHfs` result): what the layout loader reads. */
104
+ export interface OpenedHfs {
105
+ readonly repo: {
106
+ readonly profile: string;
107
+ readonly side?: string | null;
108
+ readonly apps: readonly {
109
+ readonly name: string;
110
+ readonly kind: string;
111
+ }[];
112
+ };
113
+ }
@@ -0,0 +1,2 @@
1
+ /** True for a node of the parsed tree; a synthesized member has no source range. */
2
+ export const isNode = (value) => "range" in value;
@@ -0,0 +1,8 @@
1
+ /** Failure-code guidance for the selected Grammar family contract. */
2
+ /** One failure code with its explanation and the way to fix it. */
3
+ export interface WhyEntry {
4
+ readonly code: string;
5
+ readonly en: string;
6
+ readonly fix: string;
7
+ }
8
+ export declare const why: Record<string, WhyEntry>;
@@ -0,0 +1,42 @@
1
+ export const why = {
2
+ "app-shell-frame": {
3
+ code: "FE_APP_SHELL_FRAME",
4
+ en: "The app frame must use Grammar WorkspaceShell with its fixed viewport.",
5
+ fix: "Compose WorkspaceShell viewport=\"fixed\" and supply its header, navigation and primary slots.",
6
+ },
7
+ "chip-label-starts-uppercase": {
8
+ code: "FE_CHIP_LABEL_CASE",
9
+ en: "A StatusDot or Badge label in `<file>` starts with a lowercase letter.",
10
+ fix: "Start the label with an uppercase letter (`Running`, not `running`); the rule fixes a literal label in place.",
11
+ },
12
+ "navbar-cluster": {
13
+ code: "FE_NAVBAR_CLUSTER",
14
+ en: "Navbar action component kinds or order differ from the app-shell canon.",
15
+ fix: "Compose Language IconButton, theme Switch, notification IconButton and account AvatarButton in that order.",
16
+ },
17
+ "navbar-control-size": {
18
+ code: "FE_NAVBAR_CONTROL_SIZE",
19
+ en: "A navbar control does not use Grammar's shared control height.",
20
+ fix: "Set size=\"control\" on every visible bar control.",
21
+ },
22
+ "no-navbar-create": {
23
+ code: "FE_NAVBAR_CREATE",
24
+ en: "A create action is composed into the global navbar.",
25
+ fix: "Move the create action to the page that owns it.",
26
+ },
27
+ "no-raw-structural-element": {
28
+ code: "FE_NATIVE_FORM_CONTROL",
29
+ en: "Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not hand-written HTML.",
30
+ fix: "Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if grammar lacks it, add it to grammar or the ui package instead of drawing it in place.",
31
+ },
32
+ "no-shell-bottom-tabs": {
33
+ code: "FE_APP_SHELL_BOTTOM_TABS",
34
+ en: "Bottom tab navigation replaces the required mobile sidebar drawer.",
35
+ fix: "Use WorkspaceShell navigationDrawer and Sidebar presentation=\"drawer\".",
36
+ },
37
+ "status-text-uses-soft-foreground": {
38
+ code: "FE_STYLE_TOKEN_ONLY",
39
+ en: "`<file>` paints text or an icon with a solid status tone (`<what>`, e.g. `text-success`). A solid tone is a background colour; used as a text colour it reads below 4.5:1 on the page background.",
40
+ fix: "Switch to the soft pair: `text-<tone>-soft-foreground` (with `bg-<tone>-soft` when it sits on a light surface); a solid tone is used only with `bg-<tone>` and `text-<tone>-foreground`.",
41
+ },
42
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The menu entries a product hands to Grammar's `DropdownMenu`.
3
+ *
4
+ * HeroUI colours a destructive menu item through its `danger` variant, which Grammar exposes as `tone: "danger"`. An entry the app
5
+ * forgets to tone reads like any other command: sign out and delete sit beside "Settings" in the same ink, and the one click that
6
+ * cannot be taken back gets no mark. The decision is the entry's meaning, so it is checked where the entry is written: an entry whose
7
+ * id or label says it signs out, deletes, removes, leaves or revokes carries `tone: "danger"`.
8
+ *
9
+ * The terms are this canon's own list, English and Vietnamese. They are compared as whole words after the text is lower-cased, split
10
+ * at `-`, `_` and camelCase, and stripped of diacritics (the Vietnamese for "sign out" with its tone marks is `dang xuat`), so `signOut`, `sign-out`,
11
+ * `Sign out` and the tone-marked Vietnamese spelling are one term.
12
+ *
13
+ * WHERE IT LOOKS. A menu entry is an object literal in the `entries` attribute of a `DropdownMenu`, or in a declaration typed as
14
+ * `DropdownMenuEntry`, `DropdownMenuItem` or `DropdownMenuSection` (an array of them too), including the `items` of a section and the
15
+ * `submenu` of an item. Anything it cannot see through (a spread, a call) it leaves alone: the rule refuses to guess.
16
+ */
17
+ import type { Rule } from "eslint";
18
+ /** The text lower-cased, split at separators and camelCase, with diacritics stripped. */
19
+ export declare const normalizeText: (text: string) => string;
20
+ /** True when the id or label text names a destructive command. */
21
+ export declare const isDestructiveText: (text: string) => boolean;
22
+ /** A destructive menu entry carries `tone: "danger"`. */
23
+ export declare const destructiveMenuEntryNeedsDangerTone: Rule.RuleModule;
24
+ /** The rules this law contributes to the plugin. */
25
+ export declare const rules: Record<string, Rule.RuleModule>;
26
+ /** Every rule is an error. */
27
+ export declare const recommended: Record<string, "error">;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The menu entries a product hands to Grammar's `DropdownMenu`.
3
+ *
4
+ * HeroUI colours a destructive menu item through its `danger` variant, which Grammar exposes as `tone: "danger"`. An entry the app
5
+ * forgets to tone reads like any other command: sign out and delete sit beside "Settings" in the same ink, and the one click that
6
+ * cannot be taken back gets no mark. The decision is the entry's meaning, so it is checked where the entry is written: an entry whose
7
+ * id or label says it signs out, deletes, removes, leaves or revokes carries `tone: "danger"`.
8
+ *
9
+ * The terms are this canon's own list, English and Vietnamese. They are compared as whole words after the text is lower-cased, split
10
+ * at `-`, `_` and camelCase, and stripped of diacritics (the Vietnamese for "sign out" with its tone marks is `dang xuat`), so `signOut`, `sign-out`,
11
+ * `Sign out` and the tone-marked Vietnamese spelling are one term.
12
+ *
13
+ * WHERE IT LOOKS. A menu entry is an object literal in the `entries` attribute of a `DropdownMenu`, or in a declaration typed as
14
+ * `DropdownMenuEntry`, `DropdownMenuItem` or `DropdownMenuSection` (an array of them too), including the `items` of a section and the
15
+ * `submenu` of an item. Anything it cannot see through (a spread, a call) it leaves alone: the rule refuses to guess.
16
+ */
17
+ import { propertyKeyText, resolveLocal } from "./lib/app-shell.js";
18
+ import { stringOf } from "@starci/eslint-canon-fe/lib/ast.mjs";
19
+ import { isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs";
20
+ import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
21
+ import { isNode } from "./lib/types.js";
22
+ /** The English terms of a destructive entry. */
23
+ const DESTRUCTIVE_TERMS_EN = Object.freeze([
24
+ "sign out", "log out", "logout", "signout",
25
+ "delete", "remove", "erase", "destroy", "discard", "purge",
26
+ "leave workspace", "leave team", "leave group", "leave organization", "leave project", "leave channel", "leave course", "revoke", "deactivate", "disconnect", "unlink", "unsubscribe", "terminate",
27
+ "close account", "cancel subscription", "cancel plan",
28
+ ]);
29
+ /** The Vietnamese terms, written without diacritics (the text is compared after the diacritics are stripped). */
30
+ const DESTRUCTIVE_TERMS_VI = Object.freeze([
31
+ "dang xuat", "xoa", "xoa bo", "go bo", "loai bo", "roi khoi", "roi nhom", "thu hoi",
32
+ "vo hieu hoa", "ngat ket noi", "huy dang ky", "huy goi", "cham dut", "dong tai khoan",
33
+ ]);
34
+ /** A destructive term as a whole word or phrase of the normalized text. */
35
+ const DESTRUCTIVE_PATTERN = new RegExp(String.raw `(?:^|\s)(?:${[...DESTRUCTIVE_TERMS_EN, ...DESTRUCTIVE_TERMS_VI].map((term) => term.replaceAll(" ", String.raw `\s+`)).join("|")})(?=\s|$)`);
36
+ /** The text lower-cased, split at separators and camelCase, with diacritics stripped. */
37
+ export const normalizeText = (text) => text
38
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
39
+ .replaceAll(/[-_./:]+/g, " ")
40
+ .toLowerCase()
41
+ .replaceAll("\u0111", "d")
42
+ .normalize("NFD")
43
+ .replaceAll(/\p{M}+/gu, "")
44
+ .replaceAll(/\s+/g, " ")
45
+ .trim();
46
+ /** True when the id or label text names a destructive command. */
47
+ export const isDestructiveText = (text) => DESTRUCTIVE_PATTERN.test(normalizeText(text));
48
+ const MENU_TYPES = new Set(["DropdownMenuEntry", "DropdownMenuItem", "DropdownMenuSection"]);
49
+ /** The value node of the property called `name` in an object literal, or null. */
50
+ const propertyValue = (object, name) => object.properties.find((entry) => entry.type === "Property" && !entry.computed && propertyKeyText(entry.key) === name)?.value ?? null;
51
+ /** The element type of an array type (`T[]`, `readonly T[]`, `ReadonlyArray<T>`), else the type itself. */
52
+ const elementTypeOf = (type) => {
53
+ if (type.type === "TSTypeOperator")
54
+ return type.typeAnnotation ? elementTypeOf(type.typeAnnotation) : type;
55
+ if (type.type === "TSArrayType")
56
+ return type.elementType;
57
+ const isArrayName = type.type === "TSTypeReference" && type.typeName.type === "Identifier" && (type.typeName.name === "ReadonlyArray" || type.typeName.name === "Array");
58
+ return isArrayName ? type.typeArguments?.params[0] ?? type : type;
59
+ };
60
+ /** True when the type annotation names a menu entry type or an array of one. */
61
+ const isMenuType = (annotation) => {
62
+ const type = annotation?.typeAnnotation;
63
+ if (!type)
64
+ return false;
65
+ const element = elementTypeOf(type);
66
+ return element.type === "TSTypeReference" && element.typeName.type === "Identifier" && MENU_TYPES.has(element.typeName.name);
67
+ };
68
+ /** Every possible value must prove danger; an unresolved value cannot mark a destructive action. */
69
+ const provesDangerTone = (context, expression, seen = new Set()) => {
70
+ const node = resolveLocal(context, expression);
71
+ if (!node || seen.has(node))
72
+ return false;
73
+ if (node.type !== "ConditionalExpression")
74
+ return isNode(node) && stringOf(node) === "danger";
75
+ const visited = new Set(seen).add(node);
76
+ return [node.consequent, node.alternate].every((branch) => provesDangerTone(context, branch, visited));
77
+ };
78
+ /** Report an entry (an object literal), then walk the `items` of a section and the `submenu` of an item. */
79
+ function visitEntries(context, node, report) {
80
+ if (!node)
81
+ return;
82
+ if (node.type === "ArrayExpression") {
83
+ for (const element of node.elements)
84
+ visitEntries(context, element, report);
85
+ return;
86
+ }
87
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
88
+ visitEntries(context, node.expression, report);
89
+ return;
90
+ }
91
+ if (node.type !== "ObjectExpression")
92
+ return;
93
+ const kind = stringOf(propertyValue(node, "kind"));
94
+ if (kind === "section") {
95
+ visitEntries(context, propertyValue(node, "items"), report);
96
+ return;
97
+ }
98
+ const text = [stringOf(propertyValue(node, "id")), stringOf(propertyValue(node, "label"))].filter((value) => value !== null);
99
+ const tone = propertyValue(node, "tone");
100
+ const toned = provesDangerTone(context, tone);
101
+ const destructive = text.find(isDestructiveText);
102
+ if (!toned && destructive !== undefined)
103
+ report(node, destructive);
104
+ visitEntries(context, propertyValue(node, "submenu"), report);
105
+ }
106
+ /** A destructive menu entry carries `tone: "danger"`. */
107
+ export const destructiveMenuEntryNeedsDangerTone = defineRule({
108
+ meta: {
109
+ type: "problem",
110
+ docs: { description: "A menu entry whose id or label signs out, deletes, removes, leaves or revokes carries `tone: \"danger\"`." },
111
+ schema: [],
112
+ messages: {
113
+ tone: "The menu entry `{{text}}` is destructive but has no `tone: \"danger\"`. HeroUI colours the label and glyph of a danger item so the one click that cannot be taken back is marked; without the tone it reads like \"Settings\". Add `tone: \"danger\"` to the entry.",
114
+ },
115
+ },
116
+ create(context) {
117
+ if (!isProductSource(context))
118
+ return {};
119
+ const report = (node, text) => context.report({ node, messageId: "tone", data: { text } });
120
+ return {
121
+ JSXAttribute(node) {
122
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "entries")
123
+ return;
124
+ const element = node.parent?.name;
125
+ if (element?.type !== "JSXIdentifier" || element.name !== "DropdownMenu")
126
+ return;
127
+ visitEntries(context, node.value?.type === "JSXExpressionContainer" ? node.value.expression : null, report);
128
+ },
129
+ VariableDeclarator(node) {
130
+ if (isMenuType(node.id.typeAnnotation))
131
+ visitEntries(context, node.init, report);
132
+ },
133
+ };
134
+ },
135
+ });
136
+ /** The rules this law contributes to the plugin. */
137
+ export const rules = {
138
+ "destructive-menu-entry-needs-danger-tone": destructiveMenuEntryNeedsDangerTone,
139
+ };
140
+ defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R93");
141
+ /** Every rule is an error. */
142
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
@@ -0,0 +1,13 @@
1
+ import type { Rule } from "eslint";
2
+ /** Every solid-tone text class in one class string, as `{ token, family, tone }`. */
3
+ export declare const solidTextClasses: (text: string) => {
4
+ token: string;
5
+ family: string;
6
+ tone: string;
7
+ }[];
8
+ /** Text or an icon painted with a solid status tone is not readable at the contrast the soft pair was built for. */
9
+ export declare const statusTextUsesSoftForeground: Rule.RuleModule;
10
+ /** The rules this law contributes to the plugin. */
11
+ export declare const rules: Record<string, Rule.RuleModule>;
12
+ /** Every rule of this law is an error. */
13
+ export declare const recommended: Record<string, "error">;