@grailwind/eslint-plugin 0.0.0-stage → 0.1.0-beta.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Firmin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # @grailwind/eslint-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Test](https://github.com/pafirmin/grailwind/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/pafirmin/grailwind/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/@grailwind/eslint-plugin/beta)](https://www.npmjs.com/package/@grailwind/eslint-plugin)
5
+ [![License: MIT](https://img.shields.io/npm/l/@grailwind/eslint-plugin)](https://github.com/pafirmin/grailwind/blob/main/packages/eslint-plugin/LICENSE)
6
+
7
+ ESLint rules for [Grailwind](https://grailwind.dev/) markup in JSX and HTML.
8
+
9
+ > **Beta.** Rules, their options and the `recommended` config may change between releases.
10
+
11
+ ```sh
12
+ npm install --save-dev @grailwind/eslint-plugin@beta
13
+ ```
14
+
15
+ It reads the tinctures, ordinaries and charges from your installed `grailwind`, so it needs
16
+ `grailwind` 0.5.x. For typos and other class errors, see
17
+ [Linting](https://github.com/pafirmin/grailwind#linting) in the Grailwind README.
18
+
19
+ ## Setup
20
+
21
+ ESLint 9 or later, flat config only. `recommended` turns on every rule as an error.
22
+
23
+ JSX and TSX use ESLint's own parser:
24
+
25
+ ```js
26
+ // eslint.config.js
27
+ import grailwind from "@grailwind/eslint-plugin";
28
+
29
+ export default [
30
+ {
31
+ files: ["**/*.{jsx,tsx}"],
32
+ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
33
+ },
34
+ grailwind.configs.recommended,
35
+ ];
36
+ ```
37
+
38
+ HTML needs [`@html-eslint/parser`](https://html-eslint.org/):
39
+
40
+ ```js
41
+ import grailwind from "@grailwind/eslint-plugin";
42
+ import html from "@html-eslint/parser";
43
+
44
+ export default [
45
+ {
46
+ files: ["**/*.html"],
47
+ languageOptions: { parser: html },
48
+ ...grailwind.configs.recommended,
49
+ },
50
+ ];
51
+ ```
52
+
53
+ ## Rules
54
+
55
+ ### `rule-of-tincture`
56
+
57
+ No metal (`or`, `argent`) on metal, and no colour on colour.
58
+
59
+ - An ordinary, chief or bordure is checked against the field on the same element:
60
+ `escutcheon or fess-argent` is metal on metal. So are engrailed and invected ones
61
+ (`fess-engrailed-argent`).
62
+ - A fimbriated ordinary is checked against its fimbriation, and the fimbriation against the
63
+ field: `or chevron-sable fimbriated-argent` is metal on metal (Argent on Or).
64
+ - Charges in a `charges` container are checked against the field.
65
+ - Charges in a `charges on` container are checked against the ordinary they lie on.
66
+ - Charges at `in-dexter-chief`, `in-chief-point` or `in-sinister-chief`, or in a
67
+ `charges in-chief` container, are checked against the chief, if there is one.
68
+ - A plain canton or inescutcheon, one with nothing on it, is checked against the field it lies
69
+ on. One with charges, an ordinary or a division counts as arms of its own and is exempt.
70
+
71
+ A divided field (`per-pale-or-gules`) has no single tincture, so nothing is checked against it.
72
+ Neither are `<img>` charges, charges with no tincture, or `bordure-*` with a non-heraldic colour.
73
+ Counterchanged ordinaries and charges take the field's tinctures swapped, so they're skipped too.
74
+
75
+ `of-the-first` is checked as the shield's first tincture, and reported if it can't be seen.
76
+
77
+ Furs count by their ground: `ermine` and `erminois` as metals, `ermines` and `pean` as colours.
78
+ `ermine fess-argent` is metal on metal. Authorities differ, so the `furs` option changes this:
79
+
80
+ ```js
81
+ "grailwind/rule-of-tincture": ["error", { furs: "ground" }] // the default
82
+ "grailwind/rule-of-tincture": ["error", { furs: "exempt" }] // furs go with anything
83
+ "grailwind/rule-of-tincture": ["error", {
84
+ furs: { ermine: "metal", erminois: "exempt", ermines: "colour", pean: "colour" },
85
+ }]
86
+ ```
87
+
88
+ A fur left out of the map counts by its ground.
89
+
90
+ ### `charges-in-container`
91
+
92
+ Charges (`mullet`, `bezant`, `charge-[…]` and so on) must be direct children of a `charges`
93
+ container.
94
+
95
+ An arrangement (`in-chief`, `in-pale`, `between` and so on) goes on the container, not a charge.
96
+
97
+ ### `arrangement-needs-ordinary`
98
+
99
+ `charges between` and `charges on` take their positions from the ordinary on the shield. Without
100
+ one, other than a chief, they have nowhere to go.
101
+
102
+ ### `one-ordinary`
103
+
104
+ An element draws one ordinary plus, optionally, a chief. With two, such as
105
+ `fess-azure chevron-gules`, only one shows. Two chiefs are flagged too. A `charges` container
106
+ takes one arrangement: `between`, `on`, `in-pale`, `in-fess`, `in-bend` or `in-bend-sinister`.
107
+ Counterchanged, engrailed, invected and fur ordinaries count as ordinaries.
108
+
109
+ A charge at a named point (`in-dexter-chief` and so on) is a separate group in the blazon, so it
110
+ goes in its own `charges` container. Among other charges, it still counts towards their number
111
+ and shifts their positions: _three mullets, in dexter chief a crescent_ in one container lays
112
+ the mullets out as four.
113
+
114
+ ### `modifier-needs-target`
115
+
116
+ Modifiers that would otherwise do nothing:
117
+
118
+ - `chevron-counterchanged` (any `<ordinary>-counterchanged`) needs a divided field on the same
119
+ element, e.g. `per-pale-argent-gules`.
120
+ - `counterchanged` on `charges`, or on a charge, needs a divided field on the shield that holds
121
+ the container.
122
+ - `fimbriated-*` needs a plain tinctured ordinary. A chief, and an engrailed, invected, fur or
123
+ counterchanged ordinary, aren't fimbriated.
124
+ - `counterchanged` doesn't work on a charge with a transform (`rotate-*`, `scale-*`, `skew-*`,
125
+ `translate-*`): the charge shows its own small copy of the field instead.
126
+
127
+ ## Exceptions
128
+
129
+ Some real arms break the rule of tincture on purpose, e.g. Jerusalem, gold crosses on silver.
130
+ Mark these with a disable comment and a reason:
131
+
132
+ ```html
133
+ <!-- eslint-disable-next-line grailwind/rule-of-tincture -- armes à enquérir -->
134
+ <i class="charge-[url(cross-potent.svg)] or"></i>
135
+ ```
136
+
137
+ ```jsx
138
+ {
139
+ /* eslint-disable-next-line grailwind/rule-of-tincture -- armes à enquérir */
140
+ }
141
+ <i className="charge-[url(cross-potent.svg)] or" />;
142
+ ```
143
+
144
+ To downgrade a rule instead, override it after `recommended`, e.g.
145
+ `{ rules: { "grailwind/rule-of-tincture": "warn" } }`.
146
+
147
+ ## Limits
148
+
149
+ The rules read static class strings only: `class="…"`, `className="…"`, or a template literal
150
+ with no `${…}`. Anything dynamic, such as `clsx(…)` or a spread of props, is skipped rather than
151
+ guessed at. So are elements inside a component (`<Shield>…</Shield>`) or an expression
152
+ (`{items.map(…)}`), and classes with a variant (`md:gules`).
153
+
154
+ Two field tinctures on one element are a conflict, which this plugin leaves to
155
+ `better-tailwindcss/no-conflicting-classes`. It catches `or azure` and `ermine pean`, but not a
156
+ fur with a plain tincture (`ermine gules`) or with a division (`per-pale-ermine-gules
157
+ per-fess-or-azure`): their CSS properties differ, so it doesn't see them as conflicting
158
+ (eslint-plugin-better-tailwindcss 4.10.0).
159
+
160
+ The transform check knows Tailwind's transform utilities only. Other things that break
161
+ `counterchanged` the same way, such as `filter`, `perspective` or `will-change: transform`,
162
+ aren't flagged.
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@grailwind/eslint-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.1",
4
+ "description": "ESLint rules for Grailwind heraldry markup: the rule of tincture and charge structure",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "eslint",
18
+ "eslintplugin",
19
+ "eslint-plugin",
20
+ "grailwind",
21
+ "heraldry",
22
+ "tailwindcss"
23
+ ],
24
+ "author": "Paul Firmin",
25
+ "license": "MIT",
26
+ "peerDependencies": {
27
+ "@html-eslint/parser": "*",
28
+ "eslint": "^9.0.0 || ^10.0.0",
29
+ "grailwind": "^0.5.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@html-eslint/parser": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@html-eslint/parser": "^0.67.0",
38
+ "eslint": "^10.14.0",
39
+ "grailwind": "file:../.."
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "homepage": "https://grailwind.dev/",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/pafirmin/grailwind.git",
48
+ "directory": "packages/eslint-plugin"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/pafirmin/grailwind/issues"
52
+ }
53
+ }
package/src/blazon.js ADDED
@@ -0,0 +1,224 @@
1
+ /*
2
+ * Reading heraldry from a set of class names.
3
+ */
4
+ import { UNKNOWN } from "./tree.js";
5
+ import {
6
+ ARRANGEMENTS,
7
+ CHARGES,
8
+ COLOURS,
9
+ DIVISIONS,
10
+ FURS,
11
+ LINES,
12
+ METALS,
13
+ OF_THE_FIRST,
14
+ ORDINARIES,
15
+ POINTS,
16
+ ROUNDELS,
17
+ TINCTURES,
18
+ } from "grailwind/data";
19
+
20
+ const METAL = new Set(METALS);
21
+ const COLOUR = new Set(COLOURS);
22
+ const FUR = new Set(Object.keys(FURS));
23
+ // Anything a field, ordinary or charge can be painted in.
24
+ const TINCTURE = new Set([...TINCTURES, ...FUR]);
25
+ // What a class can paint in: a tincture, or the field's first.
26
+ const PAINT = new Set([...TINCTURE, OF_THE_FIRST]);
27
+ const CHARGE = new Set([...CHARGES, ...Object.keys(ROUNDELS)]);
28
+ const FIMBRIATIONS = new Set([...TINCTURES, OF_THE_FIRST].map((t) => `fimbriated-${t}`));
29
+ const CHIEF_POINTS = new Set(["in-dexter-chief", "in-chief-point", "in-sinister-chief"]);
30
+ const POINT_CLASSES = new Set(Object.keys(POINTS).map((point) => `in-${point}`));
31
+ export const ALL_ARRANGEMENTS = ["between", "on", ...ARRANGEMENTS];
32
+
33
+ // Every ordinary class, e.g. `fess-or`, `fess-engrailed-ermine`, `fess-counterchanged` and
34
+ // `bordure-or`, mapped to its parts. A counterchanged ordinary has no tincture of its own.
35
+ const ORDINARY_CLASSES = new Map();
36
+ for (const ordinary of ORDINARIES) {
37
+ for (const tincture of PAINT) {
38
+ const fur = FUR.has(tincture);
39
+ ORDINARY_CLASSES.set(`${ordinary}-${tincture}`, { ordinary, tincture, fur });
40
+ for (const line of LINES) {
41
+ ORDINARY_CLASSES.set(`${ordinary}-${line}-${tincture}`, { ordinary, tincture, fur, line });
42
+ }
43
+ }
44
+ ORDINARY_CLASSES.set(`${ordinary}-counterchanged`, {
45
+ ordinary,
46
+ tincture: null,
47
+ counterchanged: true,
48
+ });
49
+ }
50
+ for (const tincture of [...TINCTURES, OF_THE_FIRST]) {
51
+ ORDINARY_CLASSES.set(`bordure-${tincture}`, { ordinary: "bordure", tincture });
52
+ }
53
+
54
+ // Each division class, mapped to its first tincture. Two furs in one division aren't generated.
55
+ const DIVISION_CLASSES = new Map();
56
+ for (const division of DIVISIONS) {
57
+ for (const a of TINCTURE) {
58
+ for (const b of TINCTURE) {
59
+ if (a !== b && !(FUR.has(a) && FUR.has(b))) {
60
+ DIVISION_CLASSES.set(`${division}-${a}-${b}`, a);
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ // Only a plain, tinctured ordinary is fimbriated (see ordinaries.js).
67
+ export const isFimbriable = (o) => !o.counterchanged && !o.line && !o.fur;
68
+
69
+ export const isFur = (tincture) => FUR.has(tincture);
70
+
71
+ const groundKind = (fur) => kind(FURS[fur][0]);
72
+
73
+ // What the rule's `furs` option makes of one fur: "metal", "colour" or "exempt". The rule's
74
+ // schema has already checked the option.
75
+ const furChoice = (fur, option) => {
76
+ if (option === "ground") {
77
+ return groundKind(fur);
78
+ }
79
+ if (option === "exempt") {
80
+ return "exempt";
81
+ }
82
+ // A map: a fur left out counts by its ground.
83
+ return option[fur] ?? groundKind(fur);
84
+ };
85
+
86
+ // Each fur's kind for kind() and sameKind(). Exempt is null, which they treat as "no check".
87
+ export function furKinds(option = "ground") {
88
+ const kinds = {};
89
+ for (const fur of Object.keys(FURS)) {
90
+ const choice = furChoice(fur, option);
91
+ kinds[fur] = choice === "exempt" ? null : choice;
92
+ }
93
+ return kinds;
94
+ }
95
+
96
+ // `furs` is the map from furKinds().
97
+ export const kind = (tincture, furs) => {
98
+ if (FUR.has(tincture)) {
99
+ return furs[tincture];
100
+ }
101
+ return METAL.has(tincture) ? "metal" : COLOUR.has(tincture) ? "colour" : null;
102
+ };
103
+
104
+ export const sameKind = (a, b, furs) => {
105
+ const k = a && b && kind(a, furs);
106
+ return Boolean(k) && k === kind(b, furs);
107
+ };
108
+
109
+ // The ordinaries on an element, as { cls, ordinary, tincture }, by kind.
110
+ export function ordinariesOf(classes) {
111
+ const found = { ordinaries: [], chiefs: [], bordures: [] };
112
+ for (const cls of classes) {
113
+ const match = ORDINARY_CLASSES.get(cls);
114
+ if (!match) {
115
+ continue;
116
+ }
117
+ const list =
118
+ match.ordinary === "chief"
119
+ ? "chiefs"
120
+ : match.ordinary === "bordure"
121
+ ? "bordures"
122
+ : "ordinaries";
123
+ found[list].push({ cls, ...match });
124
+ }
125
+ return found;
126
+ }
127
+
128
+ export const isDivided = (classes) => [...classes].some((cls) => DIVISION_CLASSES.has(cls));
129
+
130
+ // The single bare tincture of a plain field. A divided field, or none, has no tincture.
131
+ export function fieldTincture(classes) {
132
+ if (isDivided(classes)) {
133
+ return null;
134
+ }
135
+ const tinctures = [...classes].filter((cls) => PAINT.has(cls));
136
+ return tinctures.length === 1 ? tinctures[0] : null;
137
+ }
138
+
139
+ const isEmptyNode = (node) => {
140
+ switch (node.type) {
141
+ case "Comment": {
142
+ return true;
143
+ }
144
+ case "JSXExpressionContainer": {
145
+ return node.expression.type === "JSXEmptyExpression";
146
+ }
147
+ case "Text":
148
+ case "JSXText": {
149
+ return node.value.trim() === "";
150
+ }
151
+ default: {
152
+ return false;
153
+ }
154
+ }
155
+ };
156
+
157
+ // A canton or inescutcheon with nothing on it. Cantons/inescutcheons with their
158
+ // own arms are exempt from rule-of-tincture.
159
+ export function isPlain(el) {
160
+ const { ordinaries, chiefs, bordures } = ordinariesOf(el.classes);
161
+ return (
162
+ fieldTincture(el.classes) !== null &&
163
+ !ordinaries.length &&
164
+ !chiefs.length &&
165
+ !bordures.length &&
166
+ (el.node.children ?? []).every(isEmptyNode)
167
+ );
168
+ }
169
+
170
+ // A field's first tincture: its bare tincture, or its division's first.
171
+ const fieldFirst = (classes) => {
172
+ const division = [...classes].find((cls) => DIVISION_CLASSES.has(cls));
173
+ return division ? DIVISION_CLASSES.get(division) : fieldTincture(classes);
174
+ };
175
+
176
+ // `of-the-first` on an element: the nearest escutcheon's or quarter's first (not a canton's).
177
+ // Null if the shield can't be seen.
178
+ export function firstOf(el) {
179
+ for (let node = el; node && node !== UNKNOWN; node = node.parent) {
180
+ if (node.classes?.has("escutcheon") || node.parent?.classes?.has("quarterly")) {
181
+ const first = fieldFirst(node.classes);
182
+ return first === OF_THE_FIRST ? null : first;
183
+ }
184
+ }
185
+ return null;
186
+ }
187
+
188
+ // A charge's tincture, and the class that gives it, e.g. `bezant` → or.
189
+ export function chargeTincture(classes) {
190
+ const roundel = [...classes].find((cls) => ROUNDELS[cls]);
191
+ if (roundel) {
192
+ return { cls: roundel, tincture: ROUNDELS[roundel] };
193
+ }
194
+ const tinctures = [...classes].filter((cls) => PAINT.has(cls));
195
+ return tinctures.length === 1 ? { cls: tinctures[0], tincture: tinctures[0] } : null;
196
+ }
197
+
198
+ // The element's fimbriation, e.g. `fimbriated-argent` → argent, or null.
199
+ export function fimbriationOf(classes) {
200
+ const found = [...classes].filter((cls) => FIMBRIATIONS.has(cls));
201
+ return found.length === 1
202
+ ? { cls: found[0], tincture: found[0].slice("fimbriated-".length) }
203
+ : null;
204
+ }
205
+
206
+ export const chargeName = (classes) =>
207
+ [...classes].find((cls) => CHARGE.has(cls)) ??
208
+ ([...classes].some((cls) => cls.startsWith("charge-[")) ? "charge" : null);
209
+
210
+ export const isCharge = (classes) => chargeName(classes) !== null;
211
+
212
+ export const pointOf = (classes) => [...classes].find((cls) => POINT_CLASSES.has(cls));
213
+
214
+ const atChiefPoint = (classes) => [...classes].some((cls) => CHIEF_POINTS.has(cls));
215
+
216
+ // A charge on the chief, if the shield has one: at a chief point, or arranged `in-chief`.
217
+ export const inChief = (container, charge) =>
218
+ atChiefPoint(charge.classes) || container.classes.has("in-chief");
219
+
220
+ export const arrangementsOf = (classes) => ALL_ARRANGEMENTS.filter((name) => classes.has(name));
221
+
222
+ export const titleCase = (tincture) => tincture[0].toUpperCase() + tincture.slice(1);
223
+
224
+ export const describe = (name) => name.replaceAll("-", " ");
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import arrangementNeedsOrdinary from "./rules/arrangement-needs-ordinary.js";
3
+ import chargesInContainer from "./rules/charges-in-container.js";
4
+ import modifierNeedsTarget from "./rules/modifier-needs-target.js";
5
+ import oneOrdinary from "./rules/one-ordinary.js";
6
+ import ruleOfTincture from "./rules/rule-of-tincture.js";
7
+
8
+ const { name, version } = JSON.parse(
9
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
10
+ );
11
+
12
+ const plugin = {
13
+ meta: { name, version, namespace: "grailwind" },
14
+ rules: {
15
+ "rule-of-tincture": ruleOfTincture,
16
+ "charges-in-container": chargesInContainer,
17
+ "arrangement-needs-ordinary": arrangementNeedsOrdinary,
18
+ "one-ordinary": oneOrdinary,
19
+ "modifier-needs-target": modifierNeedsTarget,
20
+ },
21
+ configs: {},
22
+ };
23
+
24
+ plugin.configs.recommended = {
25
+ name: "grailwind/recommended",
26
+ plugins: { grailwind: plugin },
27
+ rules: Object.fromEntries(
28
+ Object.keys(plugin.rules).map((rule) => [`grailwind/${rule}`, "error"]),
29
+ ),
30
+ };
31
+
32
+ export default plugin;
@@ -0,0 +1,37 @@
1
+ import { getElements, UNKNOWN } from "../tree.js";
2
+ import { ordinariesOf } from "../blazon.js";
3
+
4
+ export default {
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description: "Require an ordinary on the shield for `between` and `on`",
9
+ url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#arrangement-needs-ordinary",
10
+ },
11
+ schema: [],
12
+ messages: {
13
+ // A chief publishes no `between` or `on` points, so it doesn't count.
14
+ missing: "`{{arrangement}}` needs an ordinary other than a chief on the shield",
15
+ },
16
+ },
17
+
18
+ create(context) {
19
+ return {
20
+ Program() {
21
+ for (const el of getElements(context.sourceCode)) {
22
+ if (!el.classes?.has("charges") || el.parent === UNKNOWN || !el.parent.classes) {
23
+ continue;
24
+ }
25
+ if (ordinariesOf(el.parent.classes).ordinaries.length) {
26
+ continue;
27
+ }
28
+ for (const arrangement of ["between", "on"]) {
29
+ if (el.classes.has(arrangement)) {
30
+ context.report({ node: el.reportNode, messageId: "missing", data: { arrangement } });
31
+ }
32
+ }
33
+ }
34
+ },
35
+ };
36
+ },
37
+ };
@@ -0,0 +1,53 @@
1
+ import { getElements, UNKNOWN } from "../tree.js";
2
+ import { arrangementsOf, chargeName } from "../blazon.js";
3
+
4
+ // The tail of the `arranged` message: where to place one charge instead, or "" for none.
5
+ const hint = (cls) => {
6
+ return (
7
+ {
8
+ "in-chief": "; to place one charge, use `in-chief-point`",
9
+ "in-base": "; to place one charge, use `in-base-point`",
10
+ }[cls] ?? ""
11
+ );
12
+ };
13
+
14
+ export default {
15
+ meta: {
16
+ type: "problem",
17
+ docs: {
18
+ description: "Require charges to be inside a `charges` container, and arrangements on it",
19
+ url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#charges-in-container",
20
+ },
21
+ schema: [],
22
+ messages: {
23
+ outside: "`{{charge}}` must be inside a `charges` container",
24
+ arranged: "`{{cls}}` arranges a `charges` container, not a charge{{hint}}",
25
+ },
26
+ },
27
+
28
+ create(context) {
29
+ return {
30
+ Program() {
31
+ for (const el of getElements(context.sourceCode)) {
32
+ if (!el.classes || el.parent === UNKNOWN || !el.parent.classes) {
33
+ continue;
34
+ }
35
+ const inContainer = el.parent.classes.has("charges");
36
+ const charge = chargeName(el.classes);
37
+ if (charge && !inContainer) {
38
+ context.report({ node: el.reportNode, messageId: "outside", data: { charge } });
39
+ }
40
+ if (inContainer) {
41
+ for (const cls of arrangementsOf(el.classes)) {
42
+ context.report({
43
+ node: el.reportNode,
44
+ messageId: "arranged",
45
+ data: { cls, hint: hint(cls) },
46
+ });
47
+ }
48
+ }
49
+ }
50
+ },
51
+ };
52
+ },
53
+ };
@@ -0,0 +1,80 @@
1
+ import { getElements, UNKNOWN } from "../tree.js";
2
+ import { fimbriationOf, isDivided, isFimbriable, ordinariesOf } from "../blazon.js";
3
+
4
+ // Any transform makes a charge the containing block for its fixed, swapped layer.
5
+ const TRANSFORM = /^-?(rotate|scale|skew|translate)(-|$)/;
6
+
7
+ export default {
8
+ meta: {
9
+ type: "problem",
10
+ docs: {
11
+ description: "Disallow counterchanged or fimbriated classes that have nothing to apply to",
12
+ url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#modifier-needs-target",
13
+ },
14
+ schema: [],
15
+ messages: {
16
+ noDivision: "`{{cls}}` needs a divided field to swap, e.g. `per-pale-argent-gules`",
17
+ noOrdinary:
18
+ "`{{cls}}` needs a plain tinctured ordinary to edge: a chief, and an engrailed, invected, fur or counterchanged ordinary, aren't fimbriated",
19
+ transformed:
20
+ "`{{cls}}` breaks `counterchanged`: a transformed charge shows its own small copy of the field",
21
+ },
22
+ },
23
+
24
+ create(context) {
25
+ return {
26
+ Program() {
27
+ for (const el of getElements(context.sourceCode)) {
28
+ if (!el.classes) {
29
+ continue;
30
+ }
31
+ const report = (node, messageId, cls) =>
32
+ context.report({ node, messageId, data: { cls } });
33
+
34
+ // A counterchanged ordinary's ::before inherits the swap from its own element.
35
+ const { ordinaries, chiefs } = ordinariesOf(el.classes);
36
+ if (!isDivided(el.classes)) {
37
+ for (const { cls } of [...ordinaries, ...chiefs].filter((o) => o.counterchanged)) {
38
+ report(el.reportNode, "noDivision", cls);
39
+ }
40
+ }
41
+
42
+ const fimbriation = fimbriationOf(el.classes);
43
+ if (fimbriation && !ordinaries.some(isFimbriable)) {
44
+ report(el.reportNode, "noOrdinary", fimbriation.cls);
45
+ }
46
+
47
+ if (el.classes.has("charges")) {
48
+ for (const charge of el.children) {
49
+ if (!charge.classes) {
50
+ continue;
51
+ }
52
+ const transform = [...charge.classes].find((cls) => TRANSFORM.test(cls));
53
+ if (
54
+ transform &&
55
+ (el.classes.has("counterchanged") || charge.classes.has("counterchanged"))
56
+ ) {
57
+ report(charge.reportNode, "transformed", transform);
58
+ }
59
+ }
60
+ }
61
+
62
+ // Counterchanged charges get the swap from the container's parent only.
63
+ if (el.classes.has("charges") && el.parent !== UNKNOWN && el.parent.classes) {
64
+ if (isDivided(el.parent.classes)) {
65
+ continue;
66
+ }
67
+ if (el.classes.has("counterchanged")) {
68
+ report(el.reportNode, "noDivision", "counterchanged");
69
+ }
70
+ for (const charge of el.children) {
71
+ if (charge.classes?.has("counterchanged")) {
72
+ report(charge.reportNode, "noDivision", "counterchanged");
73
+ }
74
+ }
75
+ }
76
+ }
77
+ },
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,69 @@
1
+ import { getElements } from "../tree.js";
2
+ import { arrangementsOf, ordinariesOf, pointOf } from "../blazon.js";
3
+
4
+ const quote = (names) => names.map((n) => `\`${n}\``).join(" and ");
5
+
6
+ export default {
7
+ meta: {
8
+ type: "problem",
9
+ docs: {
10
+ description:
11
+ "Allow one ordinary plus a chief per element, and one arrangement per `charges` container",
12
+ url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#one-ordinary",
13
+ },
14
+ schema: [],
15
+ messages: {
16
+ ordinaries: "{{classes}} both draw an ordinary; only one will show.",
17
+ chiefs: "{{classes}} both draw a chief; only one will show",
18
+ arrangements: "{{classes}} both place these charges",
19
+ pointed:
20
+ "`{{cls}}` sets this charge apart from the others in its container; give it a `charges` container of its own",
21
+ },
22
+ },
23
+
24
+ create(context) {
25
+ return {
26
+ Program() {
27
+ for (const el of getElements(context.sourceCode)) {
28
+ if (!el.classes) {
29
+ continue;
30
+ }
31
+ const { ordinaries, chiefs } = ordinariesOf(el.classes);
32
+ const report = (messageId, names) =>
33
+ context.report({ node: el.reportNode, messageId, data: { classes: quote(names) } });
34
+
35
+ if (ordinaries.length > 1) {
36
+ report(
37
+ "ordinaries",
38
+ ordinaries.map((o) => o.cls),
39
+ );
40
+ }
41
+ if (chiefs.length > 1) {
42
+ report(
43
+ "chiefs",
44
+ chiefs.map((o) => o.cls),
45
+ );
46
+ }
47
+ if (el.classes.has("charges")) {
48
+ const arrangements = arrangementsOf(el.classes);
49
+ if (arrangements.length > 1) {
50
+ report("arrangements", arrangements);
51
+ }
52
+ // A pointed charge still counts towards, and shifts, the others' positions.
53
+ const charges = el.children.filter((child) => child.classes);
54
+ const pointed = charges.filter((child) => pointOf(child.classes));
55
+ if (pointed.length && pointed.length < charges.length) {
56
+ for (const charge of pointed) {
57
+ context.report({
58
+ node: charge.reportNode,
59
+ messageId: "pointed",
60
+ data: { cls: pointOf(charge.classes) },
61
+ });
62
+ }
63
+ }
64
+ }
65
+ }
66
+ },
67
+ };
68
+ },
69
+ };
@@ -0,0 +1,180 @@
1
+ import { FURS, OF_THE_FIRST } from "grailwind/data";
2
+ import { getElements, UNKNOWN } from "../tree.js";
3
+ import {
4
+ chargeName,
5
+ chargeTincture,
6
+ describe,
7
+ fieldTincture,
8
+ fimbriationOf,
9
+ firstOf,
10
+ furKinds,
11
+ inChief,
12
+ isFimbriable,
13
+ isFur,
14
+ isPlain,
15
+ kind,
16
+ ordinariesOf,
17
+ sameKind,
18
+ titleCase,
19
+ } from "../blazon.js";
20
+
21
+ const article = (thing) => (/^[aeiou]/.test(thing) ? `an ${thing}` : `a ${thing}`);
22
+
23
+ const KINDS = ["metal", "colour", "exempt"];
24
+
25
+ // e.g. "fess engrailed", as blazoned.
26
+ const name = ({ ordinary, line }) => describe(line ? `${ordinary} ${line}` : ordinary);
27
+
28
+ // e.g. " (ermine counts as a metal)", or " (ermine and erminois count as metals)".
29
+ const furNote = (tinctures, kind) => {
30
+ const furs = [...new Set(tinctures.filter(isFur))];
31
+ if (!furs.length) {
32
+ return "";
33
+ }
34
+ return furs.length === 1
35
+ ? ` (${furs[0]} counts as a ${kind})`
36
+ : ` (${furs.join(" and ")} count as ${kind}s)`;
37
+ };
38
+
39
+ export default {
40
+ meta: {
41
+ type: "problem",
42
+ docs: {
43
+ description: "Disallow metal on metal and colour on colour",
44
+ url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#rule-of-tincture",
45
+ },
46
+ schema: [
47
+ {
48
+ type: "object",
49
+ properties: {
50
+ furs: {
51
+ oneOf: [
52
+ { enum: ["ground", "exempt"] },
53
+ {
54
+ type: "object",
55
+ properties: Object.fromEntries(
56
+ Object.keys(FURS).map((fur) => [fur, { enum: KINDS }]),
57
+ ),
58
+ additionalProperties: false,
59
+ },
60
+ ],
61
+ },
62
+ },
63
+ additionalProperties: false,
64
+ },
65
+ ],
66
+ messages: {
67
+ violation:
68
+ "`{{cls}}` puts {{thing}} {{tincture}} on {{ground}}: {{kind}} on {{kind}}{{note}}",
69
+ invisible:
70
+ "`{{cls}}` puts {{thing}} of the first on {{ground}}, its own tincture: it can't be seen",
71
+ plain:
72
+ "`{{cls}}` puts {{thing}} {{tincture}} on {{ground}}: {{kind}} on {{kind}}{{note}}. Change its tincture, or give it arms of its own",
73
+ },
74
+ },
75
+
76
+ create(context) {
77
+ const furs = furKinds(context.options[0]?.furs);
78
+ // `el` is the element whose field or ordinary it is, for resolving `of-the-first`.
79
+ const check = (el, node, cls, thing, painted, ground, messageId = "violation") => {
80
+ const first = [painted, ground].includes(OF_THE_FIRST) ? firstOf(el) : null;
81
+ const tincture = painted === OF_THE_FIRST ? first : painted;
82
+ if (ground === OF_THE_FIRST) {
83
+ ground = first;
84
+ }
85
+ if (painted === OF_THE_FIRST && tincture && tincture === ground) {
86
+ context.report({
87
+ node,
88
+ messageId: "invisible",
89
+ data: { cls, thing: article(thing), ground: titleCase(ground) },
90
+ });
91
+ return;
92
+ }
93
+ if (!sameKind(tincture, ground, furs)) {
94
+ return;
95
+ }
96
+ context.report({
97
+ node,
98
+ messageId,
99
+ data: {
100
+ cls,
101
+ thing: article(thing),
102
+ tincture: titleCase(tincture) + (painted === OF_THE_FIRST ? " (of the first)" : ""),
103
+ ground: titleCase(ground),
104
+ kind: kind(tincture, furs),
105
+ note: furNote([ground, tincture], kind(tincture, furs)),
106
+ },
107
+ });
108
+ };
109
+
110
+ return {
111
+ Program() {
112
+ for (const el of getElements(context.sourceCode)) {
113
+ if (!el.classes) {
114
+ continue;
115
+ }
116
+ const field = fieldTincture(el.classes);
117
+ const { ordinaries, chiefs, bordures } = ordinariesOf(el.classes);
118
+ const fimbriation = fimbriationOf(el.classes);
119
+
120
+ // A plain sub-shield lies on its parent's field. A charged or divided one is arms.
121
+ const sub = ["canton", "inescutcheon"].find((c) => el.classes.has(c));
122
+ if (sub && isPlain(el) && el.parent !== UNKNOWN && el.parent.classes) {
123
+ const ground = fieldTincture(el.parent.classes);
124
+ check(el, el.reportNode, field, `plain ${sub}`, field, ground, "plain");
125
+ }
126
+ const isFimbriated = fimbriation && ordinaries.some(isFimbriable);
127
+
128
+ if (isFimbriated) {
129
+ check(el, el.reportNode, fimbriation.cls, "fimbriation", fimbriation.tincture, field);
130
+ }
131
+
132
+ for (const o of ordinaries) {
133
+ check(
134
+ el,
135
+ el.reportNode,
136
+ o.cls,
137
+ name(o),
138
+ o.tincture,
139
+ isFimbriated && isFimbriable(o) ? fimbriation.tincture : field,
140
+ );
141
+ }
142
+
143
+ // An ordinary abuts the chief rather than lying on it, so both are checked against the field.
144
+ for (const o of [...chiefs, ...bordures]) {
145
+ check(el, el.reportNode, o.cls, name(o), o.tincture, field);
146
+ }
147
+
148
+ for (const container of el.children) {
149
+ if (!container.classes?.has("charges")) {
150
+ continue;
151
+ }
152
+ for (const charge of container.children) {
153
+ if (!charge.classes || charge.tag === "img") {
154
+ continue;
155
+ }
156
+ // Counterchanged charges take the swapped field, not a tincture of their own.
157
+ if (charge.classes.has("counterchanged") || container.classes.has("counterchanged")) {
158
+ continue;
159
+ }
160
+ const found = chargeTincture(charge.classes);
161
+ if (!found) {
162
+ continue;
163
+ }
164
+
165
+ let ground = field;
166
+ if (container.classes.has("on") && ordinaries.length) {
167
+ ground = ordinaries[0].tincture;
168
+ } else if (chiefs.length && inChief(container, charge)) {
169
+ ground = chiefs[0].tincture;
170
+ }
171
+
172
+ const thing = describe(chargeName(charge.classes) ?? "charge");
173
+ check(el, charge.reportNode, found.cls, thing, found.tincture, ground);
174
+ }
175
+ }
176
+ }
177
+ },
178
+ };
179
+ },
180
+ };
package/src/tree.js ADDED
@@ -0,0 +1,129 @@
1
+ /*
2
+ * One element tree for JSX and HTML. `classes` is null when they can't be known statically;
3
+ * `parent` is UNKNOWN when the element renders somewhere this file can't see.
4
+ */
5
+
6
+ export const UNKNOWN = Symbol("unknown parent");
7
+
8
+ const cache = new WeakMap();
9
+
10
+ // Variants (`md:`, `*:`) apply only sometimes, so they're ignored. `charge-[url(…)]` has a
11
+ // colon, but no variant.
12
+ export const parseClasses = (value) =>
13
+ new Set(value.split(/\s+/).filter((c) => c && (!c.includes(":") || c.startsWith("charge-["))));
14
+
15
+ export function getElements(sourceCode) {
16
+ let elements = cache.get(sourceCode.ast);
17
+ if (!elements) {
18
+ elements = [];
19
+ walk(sourceCode.ast, UNKNOWN, elements, sourceCode.visitorKeys);
20
+ cache.set(sourceCode.ast, elements);
21
+ }
22
+ return elements;
23
+ }
24
+
25
+ function element(node, tag, attribute, classes, parent, elements) {
26
+ const el = { node, reportNode: attribute ?? node, tag, classes, parent, children: [] };
27
+ if (parent !== UNKNOWN) {
28
+ parent.children.push(el);
29
+ }
30
+ elements.push(el);
31
+ return el;
32
+ }
33
+
34
+ function walk(node, context, elements, visitorKeys) {
35
+ if (!node || typeof node.type !== "string") {
36
+ return;
37
+ }
38
+
39
+ switch (node.type) {
40
+ case "JSXElement": {
41
+ const { name, attributes } = node.openingElement;
42
+ const isComponent = name.type !== "JSXIdentifier" || /^[A-Z]/.test(name.name);
43
+ const attribute = attributes.find(
44
+ (a) =>
45
+ a.type === "JSXAttribute" && (a.name.name === "className" || a.name.name === "class"),
46
+ );
47
+ const hasSpread = attributes.some((a) => a.type === "JSXSpreadAttribute");
48
+ const classes = isComponent || hasSpread ? null : jsxClasses(attribute);
49
+ const el = element(
50
+ node,
51
+ isComponent ? null : name.name,
52
+ attribute,
53
+ classes,
54
+ context,
55
+ elements,
56
+ );
57
+ walkKeys(node.openingElement, UNKNOWN, elements, visitorKeys);
58
+ for (const child of node.children) {
59
+ walk(child, isComponent ? UNKNOWN : el, elements, visitorKeys);
60
+ }
61
+ return;
62
+ }
63
+ // Fragments don't render an element, so their children belong to the parent.
64
+ case "JSXFragment":
65
+ for (const child of node.children) {
66
+ walk(child, context, elements, visitorKeys);
67
+ }
68
+ return;
69
+ case "Tag": {
70
+ const attribute = node.attributes.find((a) => a.key?.value.toLowerCase() === "class");
71
+ const el = element(
72
+ node,
73
+ node.name.toLowerCase(),
74
+ attribute,
75
+ htmlClasses(attribute),
76
+ context,
77
+ elements,
78
+ );
79
+ for (const child of node.children) {
80
+ walk(child, el, elements, visitorKeys);
81
+ }
82
+ return;
83
+ }
84
+ default:
85
+ walkKeys(node, UNKNOWN, elements, visitorKeys);
86
+ }
87
+ }
88
+
89
+ function walkKeys(node, context, elements, visitorKeys) {
90
+ for (const key of visitorKeys[node.type] ?? []) {
91
+ const value = node[key];
92
+ if (Array.isArray(value)) {
93
+ value.forEach((child) => walk(child, context, elements, visitorKeys));
94
+ } else {
95
+ walk(value, context, elements, visitorKeys);
96
+ }
97
+ }
98
+ }
99
+
100
+ function jsxClasses(attribute) {
101
+ if (!attribute || attribute.value === null) {
102
+ return new Set();
103
+ }
104
+ let value = attribute.value;
105
+ if (value.type === "JSXExpressionContainer") {
106
+ value = value.expression;
107
+ }
108
+ if (value.type === "Literal" && typeof value.value === "string") {
109
+ return parseClasses(value.value);
110
+ }
111
+ if (value.type === "TemplateLiteral" && value.expressions.length === 0) {
112
+ return parseClasses(value.quasis[0].value.cooked);
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function htmlClasses(attribute) {
118
+ if (!attribute) {
119
+ return new Set();
120
+ }
121
+ if (!attribute.value) {
122
+ return new Set();
123
+ }
124
+ // Template engine syntax (`{{ x }}`) makes the value dynamic.
125
+ if (attribute.value.parts?.some((part) => part.type !== "Part")) {
126
+ return null;
127
+ }
128
+ return parseClasses(attribute.value.value);
129
+ }