@grailwind/eslint-plugin 0.1.0-beta.0 → 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/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.5.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
@@ -59,13 +63,17 @@ No metal (`or`, `argent`) on metal, and no colour on colour.
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
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.1",
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.5.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,6 +10,7 @@ import {
9
10
  FURS,
10
11
  LINES,
11
12
  METALS,
13
+ OF_THE_FIRST,
12
14
  ORDINARIES,
13
15
  POINTS,
14
16
  ROUNDELS,
@@ -20,9 +22,11 @@ const COLOUR = new Set(COLOURS);
20
22
  const FUR = new Set(Object.keys(FURS));
21
23
  // Anything a field, ordinary or charge can be painted in.
22
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]);
23
27
  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"]);
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"]);
26
30
  const POINT_CLASSES = new Set(Object.keys(POINTS).map((point) => `in-${point}`));
27
31
  export const ALL_ARRANGEMENTS = ["between", "on", ...ARRANGEMENTS];
28
32
 
@@ -30,7 +34,7 @@ export const ALL_ARRANGEMENTS = ["between", "on", ...ARRANGEMENTS];
30
34
  // `bordure-or`, mapped to its parts. A counterchanged ordinary has no tincture of its own.
31
35
  const ORDINARY_CLASSES = new Map();
32
36
  for (const ordinary of ORDINARIES) {
33
- for (const tincture of TINCTURE) {
37
+ for (const tincture of PAINT) {
34
38
  const fur = FUR.has(tincture);
35
39
  ORDINARY_CLASSES.set(`${ordinary}-${tincture}`, { ordinary, tincture, fur });
36
40
  for (const line of LINES) {
@@ -43,17 +47,17 @@ for (const ordinary of ORDINARIES) {
43
47
  counterchanged: true,
44
48
  });
45
49
  }
46
- for (const tincture of TINCTURES) {
50
+ for (const tincture of [...TINCTURES, OF_THE_FIRST]) {
47
51
  ORDINARY_CLASSES.set(`bordure-${tincture}`, { ordinary: "bordure", tincture });
48
52
  }
49
53
 
50
- // Two furs in one division aren't generated.
51
- const DIVISION_CLASSES = new Set();
54
+ // Each division class, mapped to its first tincture. Two furs in one division aren't generated.
55
+ const DIVISION_CLASSES = new Map();
52
56
  for (const division of DIVISIONS) {
53
57
  for (const a of TINCTURE) {
54
58
  for (const b of TINCTURE) {
55
59
  if (a !== b && !(FUR.has(a) && FUR.has(b))) {
56
- DIVISION_CLASSES.add(`${division}-${a}-${b}`);
60
+ DIVISION_CLASSES.set(`${division}-${a}-${b}`, a);
57
61
  }
58
62
  }
59
63
  }
@@ -128,17 +132,66 @@ export function fieldTincture(classes) {
128
132
  if (isDivided(classes)) {
129
133
  return null;
130
134
  }
131
- const tinctures = [...classes].filter((cls) => TINCTURE.has(cls));
135
+ const tinctures = [...classes].filter((cls) => PAINT.has(cls));
132
136
  return tinctures.length === 1 ? tinctures[0] : null;
133
137
  }
134
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
+
135
188
  // A charge's tincture, and the class that gives it, e.g. `bezant` → or.
136
189
  export function chargeTincture(classes) {
137
190
  const roundel = [...classes].find((cls) => ROUNDELS[cls]);
138
191
  if (roundel) {
139
192
  return { cls: roundel, tincture: ROUNDELS[roundel] };
140
193
  }
141
- const tinctures = [...classes].filter((cls) => TINCTURE.has(cls));
194
+ const tinctures = [...classes].filter((cls) => PAINT.has(cls));
142
195
  return tinctures.length === 1 ? { cls: tinctures[0], tincture: tinctures[0] } : null;
143
196
  }
144
197
 
@@ -158,7 +211,11 @@ export const isCharge = (classes) => chargeName(classes) !== null;
158
211
 
159
212
  export const pointOf = (classes) => [...classes].find((cls) => POINT_CLASSES.has(cls));
160
213
 
161
- export const atChiefPoint = (classes) => [...classes].some((cls) => CHIEF_POINTS.has(cls));
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");
162
219
 
163
220
  export const arrangementsOf = (classes) => ALL_ARRANGEMENTS.filter((name) => classes.has(name));
164
221
 
@@ -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,
@@ -64,22 +66,40 @@ export default {
64
66
  messages: {
65
67
  violation:
66
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",
67
73
  },
68
74
  },
69
75
 
70
76
  create(context) {
71
77
  const furs = furKinds(context.options[0]?.furs);
72
- const check = (node, cls, thing, tincture, ground) => {
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
+ }
73
93
  if (!sameKind(tincture, ground, furs)) {
74
94
  return;
75
95
  }
76
96
  context.report({
77
97
  node,
78
- messageId: "violation",
98
+ messageId,
79
99
  data: {
80
100
  cls,
81
101
  thing: article(thing),
82
- tincture: titleCase(tincture),
102
+ tincture: titleCase(tincture) + (painted === OF_THE_FIRST ? " (of the first)" : ""),
83
103
  ground: titleCase(ground),
84
104
  kind: kind(tincture, furs),
85
105
  note: furNote([ground, tincture], kind(tincture, furs)),
@@ -96,14 +116,22 @@ export default {
96
116
  const field = fieldTincture(el.classes);
97
117
  const { ordinaries, chiefs, bordures } = ordinariesOf(el.classes);
98
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
+ }
99
126
  const isFimbriated = fimbriation && ordinaries.some(isFimbriable);
100
127
 
101
128
  if (isFimbriated) {
102
- check(el.reportNode, fimbriation.cls, "fimbriation", fimbriation.tincture, field);
129
+ check(el, el.reportNode, fimbriation.cls, "fimbriation", fimbriation.tincture, field);
103
130
  }
104
131
 
105
132
  for (const o of ordinaries) {
106
133
  check(
134
+ el,
107
135
  el.reportNode,
108
136
  o.cls,
109
137
  name(o),
@@ -114,7 +142,7 @@ export default {
114
142
 
115
143
  // An ordinary abuts the chief rather than lying on it, so both are checked against the field.
116
144
  for (const o of [...chiefs, ...bordures]) {
117
- check(el.reportNode, o.cls, name(o), o.tincture, field);
145
+ check(el, el.reportNode, o.cls, name(o), o.tincture, field);
118
146
  }
119
147
 
120
148
  for (const container of el.children) {
@@ -137,12 +165,12 @@ export default {
137
165
  let ground = field;
138
166
  if (container.classes.has("on") && ordinaries.length) {
139
167
  ground = ordinaries[0].tincture;
140
- } else if (chiefs.length && atChiefPoint(charge.classes)) {
168
+ } else if (chiefs.length && inChief(container, charge)) {
141
169
  ground = chiefs[0].tincture;
142
170
  }
143
171
 
144
172
  const thing = describe(chargeName(charge.classes) ?? "charge");
145
- check(charge.reportNode, found.cls, thing, found.tincture, ground);
173
+ check(el, charge.reportNode, found.cls, thing, found.tincture, ground);
146
174
  }
147
175
  }
148
176
  }