@grailwind/eslint-plugin 0.1.0-beta.0 → 0.1.0-beta.2

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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @grailwind/eslint-plugin
2
2
 
3
- ESLint rules for [Grailwind](https://pafirmin.github.io/grailwind/) markup in JSX and HTML.
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.
4
8
 
5
9
  > **Beta.** Rules, their options and the `recommended` config may change between releases.
6
10
 
@@ -9,7 +13,7 @@ npm install --save-dev @grailwind/eslint-plugin@beta
9
13
  ```
10
14
 
11
15
  It reads the tinctures, ordinaries and charges from your installed `grailwind`, so it needs
12
- `grailwind` 0.4.x. For typos and other class errors, see
16
+ `grailwind` 0.6.x. For typos and other class errors, see
13
17
  [Linting](https://github.com/pafirmin/grailwind#linting) in the Grailwind README.
14
18
 
15
19
  ## Setup
@@ -53,19 +57,23 @@ export default [
53
57
  No metal (`or`, `argent`) on metal, and no colour on colour.
54
58
 
55
59
  - An ordinary, chief or bordure is checked against the field on the same element:
56
- `escutcheon or fess-argent` is metal on metal. So are engrailed and invected ones
57
- (`fess-engrailed-argent`).
60
+ `escutcheon or fess-argent` is metal on metal. So are engrailed, invected and voided ones
61
+ (`fess-engrailed-argent`, `cross-voided-argent`).
58
62
  - A fimbriated ordinary is checked against its fimbriation, and the fimbriation against the
59
63
  field: `or chevron-sable fimbriated-argent` is metal on metal (Argent on Or).
60
64
  - Charges in a `charges` container are checked against the field.
61
65
  - Charges in a `charges on` container are checked against the ordinary they lie on.
62
- - Charges at `in-dexter-chief`, `in-chief` or `in-sinister-chief` are checked against the chief,
63
- if there is one.
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.
64
70
 
65
71
  A divided field (`per-pale-or-gules`) has no single tincture, so nothing is checked against it.
66
72
  Neither are `<img>` charges, charges with no tincture, or `bordure-*` with a non-heraldic colour.
67
73
  Counterchanged ordinaries and charges take the field's tinctures swapped, so they're skipped too.
68
74
 
75
+ `of-the-first` is checked as the shield's first tincture, and reported if it can't be seen.
76
+
69
77
  Furs count by their ground: `ermine` and `erminois` as metals, `ermines` and `pean` as colours.
70
78
  `ermine fess-argent` is metal on metal. Authorities differ, so the `furs` option changes this:
71
79
 
@@ -84,6 +92,8 @@ A fur left out of the map counts by its ground.
84
92
  Charges (`mullet`, `bezant`, `charge-[…]` and so on) must be direct children of a `charges`
85
93
  container.
86
94
 
95
+ An arrangement (`in-chief`, `in-pale`, `between` and so on) goes on the container, not a charge.
96
+
87
97
  ### `arrangement-needs-ordinary`
88
98
 
89
99
  `charges between` and `charges on` take their positions from the ordinary on the shield. Without
@@ -94,7 +104,7 @@ one, other than a chief, they have nowhere to go.
94
104
  An element draws one ordinary plus, optionally, a chief. With two, such as
95
105
  `fess-azure chevron-gules`, only one shows. Two chiefs are flagged too. A `charges` container
96
106
  takes one arrangement: `between`, `on`, `in-pale`, `in-fess`, `in-bend` or `in-bend-sinister`.
97
- Counterchanged, engrailed, invected and fur ordinaries count as ordinaries.
107
+ Counterchanged, engrailed, invected, voided and fur ordinaries count as ordinaries.
98
108
 
99
109
  A charge at a named point (`in-dexter-chief` and so on) is a separate group in the blazon, so it
100
110
  goes in its own `charges` container. Among other charges, it still counts towards their number
@@ -109,8 +119,8 @@ Modifiers that would otherwise do nothing:
109
119
  element, e.g. `per-pale-argent-gules`.
110
120
  - `counterchanged` on `charges`, or on a charge, needs a divided field on the shield that holds
111
121
  the container.
112
- - `fimbriated-*` needs a plain tinctured ordinary. A chief, and an engrailed, invected, fur or
113
- counterchanged ordinary, aren't fimbriated.
122
+ - `fimbriated-*` needs a plain tinctured ordinary. A chief, and an engrailed, invected, voided,
123
+ fur or counterchanged ordinary, aren't fimbriated.
114
124
  - `counterchanged` doesn't work on a charge with a transform (`rotate-*`, `scale-*`, `skew-*`,
115
125
  `translate-*`): the charge shows its own small copy of the field instead.
116
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grailwind/eslint-plugin",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "ESLint rules for Grailwind heraldry markup: the rule of tincture and charge structure",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -26,7 +26,7 @@
26
26
  "peerDependencies": {
27
27
  "@html-eslint/parser": "*",
28
28
  "eslint": "^9.0.0 || ^10.0.0",
29
- "grailwind": "^0.4.0"
29
+ "grailwind": "^0.6.0"
30
30
  },
31
31
  "peerDependenciesMeta": {
32
32
  "@html-eslint/parser": {
@@ -41,7 +41,7 @@
41
41
  "publishConfig": {
42
42
  "access": "public"
43
43
  },
44
- "homepage": "https://pafirmin.github.io/grailwind/",
44
+ "homepage": "https://grailwind.dev/",
45
45
  "repository": {
46
46
  "type": "git",
47
47
  "url": "git+https://github.com/pafirmin/grailwind.git",
package/src/blazon.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  * Reading heraldry from a set of class names.
3
3
  */
4
+ import { UNKNOWN } from "./tree.js";
4
5
  import {
5
6
  ARRANGEMENTS,
6
7
  CHARGES,
@@ -9,10 +10,12 @@ import {
9
10
  FURS,
10
11
  LINES,
11
12
  METALS,
13
+ OF_THE_FIRST,
12
14
  ORDINARIES,
13
15
  POINTS,
14
16
  ROUNDELS,
15
17
  TINCTURES,
18
+ VOIDABLE,
16
19
  } from "grailwind/data";
17
20
 
18
21
  const METAL = new Set(METALS);
@@ -20,9 +23,11 @@ const COLOUR = new Set(COLOURS);
20
23
  const FUR = new Set(Object.keys(FURS));
21
24
  // Anything a field, ordinary or charge can be painted in.
22
25
  const TINCTURE = new Set([...TINCTURES, ...FUR]);
26
+ // What a class can paint in: a tincture, or the field's first.
27
+ const PAINT = new Set([...TINCTURE, OF_THE_FIRST]);
23
28
  const CHARGE = new Set([...CHARGES, ...Object.keys(ROUNDELS)]);
24
- const FIMBRIATIONS = new Set(TINCTURES.map((t) => `fimbriated-${t}`));
25
- const CHIEF_POINTS = new Set(["in-dexter-chief", "in-chief", "in-sinister-chief"]);
29
+ const FIMBRIATIONS = new Set([...TINCTURES, OF_THE_FIRST].map((t) => `fimbriated-${t}`));
30
+ const CHIEF_POINTS = new Set(["in-dexter-chief", "in-chief-point", "in-sinister-chief"]);
26
31
  const POINT_CLASSES = new Set(Object.keys(POINTS).map((point) => `in-${point}`));
27
32
  export const ALL_ARRANGEMENTS = ["between", "on", ...ARRANGEMENTS];
28
33
 
@@ -30,7 +35,7 @@ export const ALL_ARRANGEMENTS = ["between", "on", ...ARRANGEMENTS];
30
35
  // `bordure-or`, mapped to its parts. A counterchanged ordinary has no tincture of its own.
31
36
  const ORDINARY_CLASSES = new Map();
32
37
  for (const ordinary of ORDINARIES) {
33
- for (const tincture of TINCTURE) {
38
+ for (const tincture of PAINT) {
34
39
  const fur = FUR.has(tincture);
35
40
  ORDINARY_CLASSES.set(`${ordinary}-${tincture}`, { ordinary, tincture, fur });
36
41
  for (const line of LINES) {
@@ -43,24 +48,41 @@ for (const ordinary of ORDINARIES) {
43
48
  counterchanged: true,
44
49
  });
45
50
  }
46
- for (const tincture of TINCTURES) {
51
+ for (const ordinary of VOIDABLE) {
52
+ for (const tincture of PAINT) {
53
+ const fur = FUR.has(tincture);
54
+ ORDINARY_CLASSES.set(`${ordinary}-voided-${tincture}`, {
55
+ ordinary,
56
+ tincture,
57
+ fur,
58
+ voided: true,
59
+ });
60
+ }
61
+ ORDINARY_CLASSES.set(`${ordinary}-voided-counterchanged`, {
62
+ ordinary,
63
+ tincture: null,
64
+ counterchanged: true,
65
+ voided: true,
66
+ });
67
+ }
68
+ for (const tincture of [...TINCTURES, OF_THE_FIRST]) {
47
69
  ORDINARY_CLASSES.set(`bordure-${tincture}`, { ordinary: "bordure", tincture });
48
70
  }
49
71
 
50
- // Two furs in one division aren't generated.
51
- const DIVISION_CLASSES = new Set();
72
+ // Each division class, mapped to its first tincture. Two furs in one division aren't generated.
73
+ const DIVISION_CLASSES = new Map();
52
74
  for (const division of DIVISIONS) {
53
75
  for (const a of TINCTURE) {
54
76
  for (const b of TINCTURE) {
55
77
  if (a !== b && !(FUR.has(a) && FUR.has(b))) {
56
- DIVISION_CLASSES.add(`${division}-${a}-${b}`);
78
+ DIVISION_CLASSES.set(`${division}-${a}-${b}`, a);
57
79
  }
58
80
  }
59
81
  }
60
82
  }
61
83
 
62
84
  // Only a plain, tinctured ordinary is fimbriated (see ordinaries.js).
63
- export const isFimbriable = (o) => !o.counterchanged && !o.line && !o.fur;
85
+ export const isFimbriable = (o) => !o.counterchanged && !o.line && !o.fur && !o.voided;
64
86
 
65
87
  export const isFur = (tincture) => FUR.has(tincture);
66
88
 
@@ -128,17 +150,66 @@ export function fieldTincture(classes) {
128
150
  if (isDivided(classes)) {
129
151
  return null;
130
152
  }
131
- const tinctures = [...classes].filter((cls) => TINCTURE.has(cls));
153
+ const tinctures = [...classes].filter((cls) => PAINT.has(cls));
132
154
  return tinctures.length === 1 ? tinctures[0] : null;
133
155
  }
134
156
 
157
+ const isEmptyNode = (node) => {
158
+ switch (node.type) {
159
+ case "Comment": {
160
+ return true;
161
+ }
162
+ case "JSXExpressionContainer": {
163
+ return node.expression.type === "JSXEmptyExpression";
164
+ }
165
+ case "Text":
166
+ case "JSXText": {
167
+ return node.value.trim() === "";
168
+ }
169
+ default: {
170
+ return false;
171
+ }
172
+ }
173
+ };
174
+
175
+ // A canton or inescutcheon with nothing on it. Cantons/inescutcheons with their
176
+ // own arms are exempt from rule-of-tincture.
177
+ export function isPlain(el) {
178
+ const { ordinaries, chiefs, bordures } = ordinariesOf(el.classes);
179
+ return (
180
+ fieldTincture(el.classes) !== null &&
181
+ !ordinaries.length &&
182
+ !chiefs.length &&
183
+ !bordures.length &&
184
+ (el.node.children ?? []).every(isEmptyNode)
185
+ );
186
+ }
187
+
188
+ // A field's first tincture: its bare tincture, or its division's first.
189
+ const fieldFirst = (classes) => {
190
+ const division = [...classes].find((cls) => DIVISION_CLASSES.has(cls));
191
+ return division ? DIVISION_CLASSES.get(division) : fieldTincture(classes);
192
+ };
193
+
194
+ // `of-the-first` on an element: the nearest escutcheon's or quarter's first (not a canton's).
195
+ // Null if the shield can't be seen.
196
+ export function firstOf(el) {
197
+ for (let node = el; node && node !== UNKNOWN; node = node.parent) {
198
+ if (node.classes?.has("escutcheon") || node.parent?.classes?.has("quarterly")) {
199
+ const first = fieldFirst(node.classes);
200
+ return first === OF_THE_FIRST ? null : first;
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
135
206
  // A charge's tincture, and the class that gives it, e.g. `bezant` → or.
136
207
  export function chargeTincture(classes) {
137
208
  const roundel = [...classes].find((cls) => ROUNDELS[cls]);
138
209
  if (roundel) {
139
210
  return { cls: roundel, tincture: ROUNDELS[roundel] };
140
211
  }
141
- const tinctures = [...classes].filter((cls) => TINCTURE.has(cls));
212
+ const tinctures = [...classes].filter((cls) => PAINT.has(cls));
142
213
  return tinctures.length === 1 ? { cls: tinctures[0], tincture: tinctures[0] } : null;
143
214
  }
144
215
 
@@ -158,7 +229,11 @@ export const isCharge = (classes) => chargeName(classes) !== null;
158
229
 
159
230
  export const pointOf = (classes) => [...classes].find((cls) => POINT_CLASSES.has(cls));
160
231
 
161
- export const atChiefPoint = (classes) => [...classes].some((cls) => CHIEF_POINTS.has(cls));
232
+ const atChiefPoint = (classes) => [...classes].some((cls) => CHIEF_POINTS.has(cls));
233
+
234
+ // A charge on the chief, if the shield has one: at a chief point, or arranged `in-chief`.
235
+ export const inChief = (container, charge) =>
236
+ atChiefPoint(charge.classes) || container.classes.has("in-chief");
162
237
 
163
238
  export const arrangementsOf = (classes) => ALL_ARRANGEMENTS.filter((name) => classes.has(name));
164
239
 
@@ -1,16 +1,27 @@
1
1
  import { getElements, UNKNOWN } from "../tree.js";
2
- import { chargeName } from "../blazon.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
+ };
3
13
 
4
14
  export default {
5
15
  meta: {
6
16
  type: "problem",
7
17
  docs: {
8
- description: "Require charges to be inside a `charges` container",
18
+ description: "Require charges to be inside a `charges` container, and arrangements on it",
9
19
  url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#charges-in-container",
10
20
  },
11
21
  schema: [],
12
22
  messages: {
13
23
  outside: "`{{charge}}` must be inside a `charges` container",
24
+ arranged: "`{{cls}}` arranges a `charges` container, not a charge{{hint}}",
14
25
  },
15
26
  },
16
27
 
@@ -21,10 +32,20 @@ export default {
21
32
  if (!el.classes || el.parent === UNKNOWN || !el.parent.classes) {
22
33
  continue;
23
34
  }
35
+ const inContainer = el.parent.classes.has("charges");
24
36
  const charge = chargeName(el.classes);
25
- if (charge && !el.parent.classes.has("charges")) {
37
+ if (charge && !inContainer) {
26
38
  context.report({ node: el.reportNode, messageId: "outside", data: { charge } });
27
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
+ }
28
49
  }
29
50
  },
30
51
  };
@@ -1,15 +1,17 @@
1
- import { FURS } from "grailwind/data";
2
- import { getElements } from "../tree.js";
1
+ import { FURS, OF_THE_FIRST } from "grailwind/data";
2
+ import { getElements, UNKNOWN } from "../tree.js";
3
3
  import {
4
- atChiefPoint,
5
4
  chargeName,
6
5
  chargeTincture,
7
6
  describe,
8
7
  fieldTincture,
9
8
  fimbriationOf,
9
+ firstOf,
10
10
  furKinds,
11
+ inChief,
11
12
  isFimbriable,
12
13
  isFur,
14
+ isPlain,
13
15
  kind,
14
16
  ordinariesOf,
15
17
  sameKind,
@@ -21,7 +23,8 @@ const article = (thing) => (/^[aeiou]/.test(thing) ? `an ${thing}` : `a ${thing}
21
23
  const KINDS = ["metal", "colour", "exempt"];
22
24
 
23
25
  // e.g. "fess engrailed", as blazoned.
24
- const name = ({ ordinary, line }) => describe(line ? `${ordinary} ${line}` : ordinary);
26
+ const name = ({ ordinary, line, voided }) =>
27
+ describe([ordinary, line, voided && "voided"].filter(Boolean).join(" "));
25
28
 
26
29
  // e.g. " (ermine counts as a metal)", or " (ermine and erminois count as metals)".
27
30
  const furNote = (tinctures, kind) => {
@@ -64,22 +67,40 @@ export default {
64
67
  messages: {
65
68
  violation:
66
69
  "`{{cls}}` puts {{thing}} {{tincture}} on {{ground}}: {{kind}} on {{kind}}{{note}}",
70
+ invisible:
71
+ "`{{cls}}` puts {{thing}} of the first on {{ground}}, its own tincture: it can't be seen",
72
+ plain:
73
+ "`{{cls}}` puts {{thing}} {{tincture}} on {{ground}}: {{kind}} on {{kind}}{{note}}. Change its tincture, or give it arms of its own",
67
74
  },
68
75
  },
69
76
 
70
77
  create(context) {
71
78
  const furs = furKinds(context.options[0]?.furs);
72
- const check = (node, cls, thing, tincture, ground) => {
79
+ // `el` is the element whose field or ordinary it is, for resolving `of-the-first`.
80
+ const check = (el, node, cls, thing, painted, ground, messageId = "violation") => {
81
+ const first = [painted, ground].includes(OF_THE_FIRST) ? firstOf(el) : null;
82
+ const tincture = painted === OF_THE_FIRST ? first : painted;
83
+ if (ground === OF_THE_FIRST) {
84
+ ground = first;
85
+ }
86
+ if (painted === OF_THE_FIRST && tincture && tincture === ground) {
87
+ context.report({
88
+ node,
89
+ messageId: "invisible",
90
+ data: { cls, thing: article(thing), ground: titleCase(ground) },
91
+ });
92
+ return;
93
+ }
73
94
  if (!sameKind(tincture, ground, furs)) {
74
95
  return;
75
96
  }
76
97
  context.report({
77
98
  node,
78
- messageId: "violation",
99
+ messageId,
79
100
  data: {
80
101
  cls,
81
102
  thing: article(thing),
82
- tincture: titleCase(tincture),
103
+ tincture: titleCase(tincture) + (painted === OF_THE_FIRST ? " (of the first)" : ""),
83
104
  ground: titleCase(ground),
84
105
  kind: kind(tincture, furs),
85
106
  note: furNote([ground, tincture], kind(tincture, furs)),
@@ -96,14 +117,22 @@ export default {
96
117
  const field = fieldTincture(el.classes);
97
118
  const { ordinaries, chiefs, bordures } = ordinariesOf(el.classes);
98
119
  const fimbriation = fimbriationOf(el.classes);
120
+
121
+ // A plain sub-shield lies on its parent's field. A charged or divided one is arms.
122
+ const sub = ["canton", "inescutcheon"].find((c) => el.classes.has(c));
123
+ if (sub && isPlain(el) && el.parent !== UNKNOWN && el.parent.classes) {
124
+ const ground = fieldTincture(el.parent.classes);
125
+ check(el, el.reportNode, field, `plain ${sub}`, field, ground, "plain");
126
+ }
99
127
  const isFimbriated = fimbriation && ordinaries.some(isFimbriable);
100
128
 
101
129
  if (isFimbriated) {
102
- check(el.reportNode, fimbriation.cls, "fimbriation", fimbriation.tincture, field);
130
+ check(el, el.reportNode, fimbriation.cls, "fimbriation", fimbriation.tincture, field);
103
131
  }
104
132
 
105
133
  for (const o of ordinaries) {
106
134
  check(
135
+ el,
107
136
  el.reportNode,
108
137
  o.cls,
109
138
  name(o),
@@ -114,7 +143,7 @@ export default {
114
143
 
115
144
  // An ordinary abuts the chief rather than lying on it, so both are checked against the field.
116
145
  for (const o of [...chiefs, ...bordures]) {
117
- check(el.reportNode, o.cls, name(o), o.tincture, field);
146
+ check(el, el.reportNode, o.cls, name(o), o.tincture, field);
118
147
  }
119
148
 
120
149
  for (const container of el.children) {
@@ -137,12 +166,12 @@ export default {
137
166
  let ground = field;
138
167
  if (container.classes.has("on") && ordinaries.length) {
139
168
  ground = ordinaries[0].tincture;
140
- } else if (chiefs.length && atChiefPoint(charge.classes)) {
169
+ } else if (chiefs.length && inChief(container, charge)) {
141
170
  ground = chiefs[0].tincture;
142
171
  }
143
172
 
144
173
  const thing = describe(chargeName(charge.classes) ?? "charge");
145
- check(charge.reportNode, found.cls, thing, found.tincture, ground);
174
+ check(el, charge.reportNode, found.cls, thing, found.tincture, ground);
146
175
  }
147
176
  }
148
177
  }