@starci/eslint-grammar-core 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -1
- package/dist/app-shell.d.ts +15 -0
- package/dist/app-shell.js +224 -0
- package/dist/chip-label.d.ts +14 -0
- package/dist/chip-label.js +117 -0
- package/dist/drawing.d.ts +8 -0
- package/dist/drawing.js +59 -0
- package/dist/grammar-boundary.d.ts +12 -0
- package/dist/grammar-boundary.js +88 -0
- package/dist/icon.d.ts +27 -0
- package/dist/icon.js +293 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +73 -0
- package/dist/lib/app-shell-cluster.d.ts +6 -0
- package/dist/lib/app-shell-cluster.js +75 -0
- package/dist/lib/app-shell-create.d.ts +5 -0
- package/dist/lib/app-shell-create.js +37 -0
- package/dist/lib/app-shell-owner-source.d.ts +11 -0
- package/dist/lib/app-shell-owner-source.js +159 -0
- package/dist/lib/app-shell-owner.d.ts +7 -0
- package/dist/lib/app-shell-owner.js +97 -0
- package/dist/lib/app-shell-scope.d.ts +13 -0
- package/dist/lib/app-shell-scope.js +93 -0
- package/dist/lib/app-shell-visible.d.ts +13 -0
- package/dist/lib/app-shell-visible.js +117 -0
- package/dist/lib/app-shell.d.ts +27 -0
- package/dist/lib/app-shell.js +185 -0
- package/dist/lib/rule-contract.d.ts +12 -0
- package/dist/lib/rule-contract.js +23 -0
- package/dist/lib/status-tones.generated.d.ts +2 -0
- package/dist/lib/status-tones.generated.js +8 -0
- package/dist/lib/types.d.ts +113 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/why.d.ts +8 -0
- package/dist/lib/why.js +42 -0
- package/dist/menu-entries.d.ts +27 -0
- package/dist/menu-entries.js +142 -0
- package/dist/status-colors.d.ts +13 -0
- package/dist/status-colors.js +143 -0
- package/dist/tokens.d.ts +11 -0
- package/dist/tokens.js +166 -0
- package/dist/typography.d.ts +13 -0
- package/dist/typography.js +72 -0
- package/dist/vendor-boundary.d.ts +5 -0
- package/dist/vendor-boundary.js +25 -0
- package/docs/chip-label.md +30 -0
- package/docs/grammar-boundary.md +1 -1
- package/docs/status-colors.md +2 -2
- package/package.json +18 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/app-shell.mjs +0 -208
- package/drawing.mjs +0 -60
- package/grammar-boundary.mjs +0 -94
- package/icon.mjs +0 -313
- package/index.d.ts +0 -28
- package/index.mjs +0 -78
- package/lib/app-shell-cluster.mjs +0 -66
- package/lib/app-shell-create.mjs +0 -33
- package/lib/app-shell-owner-source.mjs +0 -124
- package/lib/app-shell-owner.mjs +0 -83
- package/lib/app-shell-scope.mjs +0 -79
- package/lib/app-shell-visible.mjs +0 -92
- package/lib/app-shell.mjs +0 -148
- package/lib/rule-contract.mjs +0 -11
- package/lib/status-tones.generated.mjs +0 -9
- package/lib/why.mjs +0 -38
- package/menu-entries.mjs +0 -146
- package/status-colors.mjs +0 -140
- package/tokens.mjs +0 -164
- package/typography.mjs +0 -81
- package/vendor-boundary.mjs +0 -29
package/status-colors.mjs
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { defineFamilyRules } from "./lib/rule-contract.mjs"
|
|
2
|
-
/**
|
|
3
|
-
* The rule that holds `status-colors.md` (HFS R61 `FE_STYLE_TOKEN_ONLY`, the class-name half of the soft-pair contrast
|
|
4
|
-
* obligation; the brand layer's own contrast belongs to `@starci/stylelint-canon` `starci/status-contrast`).
|
|
5
|
-
*
|
|
6
|
-
* A STATUS TONE HAS TWO FACES. The SOLID tone (`--success`) is a fill: a button, a bar, a badge dot's disc, with
|
|
7
|
-
* `--success-foreground` ink on it. A status shown as TEXT or as an ICON uses the SOFT pair, the way a HeroUI flat Chip
|
|
8
|
-
* does: `--success-soft-foreground` ink, on `--success-soft` when it sits on a tint. The brand layer is checked to
|
|
9
|
-
* make that ink readable (3:1 on the tint and on the page); the solid tone is only the mid-luminance hue of the brand
|
|
10
|
-
* and reads 3.8:1 or worse on white, so as a text colour it fails the contrast the soft pair was built to give.
|
|
11
|
-
*
|
|
12
|
-
* WHAT IT READS. Every static class string a component writes: the `className` / `class` attribute of an element (through
|
|
13
|
-
* `cn()`, `clsx()`, conditionals, arrays, template text), and a string in a constant or in a `classes` entry. The tones
|
|
14
|
-
* are the grammar's `STATUS_TONES` (`lib/status-tones.generated.mjs`, generated from the grammar with the stylelint
|
|
15
|
-
* vocabulary), never a list typed here. A class is a finding when it paints text, an icon or a text decoration with a solid
|
|
16
|
-
* tone (`text-success`, `fill-danger`, `stroke-warning`, `decoration-info`, with any variant and opacity).
|
|
17
|
-
*
|
|
18
|
-
* WHAT IT LEAVES ALONE: the soft pair (`text-success-soft-foreground`, `bg-success-soft`), a solid fill with its own ink
|
|
19
|
-
* (`bg-success text-success-foreground`), a solid border, ring or outline (`border-danger`, `ring-warning`: a
|
|
20
|
-
* boundary is not text), and a class that only shares a prefix (`text-successful`).
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { attributeValue } from "@starci/eslint-canon-fe/lib/ast.mjs"
|
|
24
|
-
import { STATUS_TONES } from "./lib/status-tones.generated.mjs"
|
|
25
|
-
|
|
26
|
-
/** The utility families that paint text, an icon or a text decoration. */
|
|
27
|
-
const TEXT_PAINT = ["text", "fill", "stroke", "decoration"]
|
|
28
|
-
|
|
29
|
-
/** `text-success`, `text-success/60`, `text-success-hover`, with an optional `!` and any variants before it. */
|
|
30
|
-
const SOLID_TEXT = new RegExp(`^!?(${TEXT_PAINT.join("|")})-(${STATUS_TONES.join("|")})(?:-hover)?(?:/(?:\\d+|\\[[^\\]]*\\]))?$`)
|
|
31
|
-
|
|
32
|
-
/** The class after its variants: `md:hover:text-success` and `[&>svg]:text-success` both end at `text-success`. */
|
|
33
|
-
const utilityOf = (token) => {
|
|
34
|
-
let depth = 0
|
|
35
|
-
let cut = -1
|
|
36
|
-
for (let i = 0; i < token.length; i += 1) {
|
|
37
|
-
if (token[i] === "[") depth += 1
|
|
38
|
-
else if (token[i] === "]") depth -= 1
|
|
39
|
-
else if (token[i] === ":" && depth === 0) cut = i
|
|
40
|
-
}
|
|
41
|
-
return token.slice(cut + 1)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Every solid-tone text class in one class string, as `{ token, family, tone }`. */
|
|
45
|
-
export const solidTextClasses = (text) =>
|
|
46
|
-
text
|
|
47
|
-
.split(/\s+/)
|
|
48
|
-
.filter(Boolean)
|
|
49
|
-
.flatMap((token) => {
|
|
50
|
-
const match = SOLID_TEXT.exec(utilityOf(token))
|
|
51
|
-
return match ? [{ token, family: match[1], tone: match[2] }] : []
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
/** The string nodes inside an expression that builds a class string: literals, template text, keys, and everything a call, array or condition carries. */
|
|
55
|
-
function* stringNodes(node) {
|
|
56
|
-
if (!node || typeof node.type !== "string") return
|
|
57
|
-
switch (node.type) {
|
|
58
|
-
case "Literal":
|
|
59
|
-
if (typeof node.value === "string") yield { node, text: node.value }
|
|
60
|
-
return
|
|
61
|
-
case "TemplateLiteral":
|
|
62
|
-
yield { node, text: node.quasis.map((quasi) => quasi.value.cooked ?? "").join(" ") }
|
|
63
|
-
return
|
|
64
|
-
case "JSXExpressionContainer":
|
|
65
|
-
yield* stringNodes(node.expression)
|
|
66
|
-
return
|
|
67
|
-
case "ConditionalExpression":
|
|
68
|
-
yield* stringNodes(node.consequent)
|
|
69
|
-
yield* stringNodes(node.alternate)
|
|
70
|
-
return
|
|
71
|
-
case "LogicalExpression":
|
|
72
|
-
yield* stringNodes(node.left)
|
|
73
|
-
yield* stringNodes(node.right)
|
|
74
|
-
return
|
|
75
|
-
case "TSAsExpression":
|
|
76
|
-
case "TSSatisfiesExpression":
|
|
77
|
-
case "TSNonNullExpression":
|
|
78
|
-
yield* stringNodes(node.expression)
|
|
79
|
-
return
|
|
80
|
-
case "ArrayExpression":
|
|
81
|
-
for (const element of node.elements) yield* stringNodes(element)
|
|
82
|
-
return
|
|
83
|
-
case "CallExpression":
|
|
84
|
-
for (const argument of node.arguments) yield* stringNodes(argument)
|
|
85
|
-
return
|
|
86
|
-
case "ObjectExpression":
|
|
87
|
-
for (const property of node.properties) {
|
|
88
|
-
if (property.type !== "Property") continue
|
|
89
|
-
if (!property.computed && property.key.type === "Literal") yield* stringNodes(property.key)
|
|
90
|
-
yield* stringNodes(property.value)
|
|
91
|
-
}
|
|
92
|
-
return
|
|
93
|
-
default:
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Text or an icon painted with a solid status tone is not readable at the contrast the soft pair was built for. */
|
|
98
|
-
export const statusTextUsesSoftForeground = {
|
|
99
|
-
meta: {
|
|
100
|
-
type: "problem",
|
|
101
|
-
docs: { description: "Status text and icons take the soft foreground of the tone, never the solid tone." },
|
|
102
|
-
schema: [],
|
|
103
|
-
messages: {
|
|
104
|
-
solid:
|
|
105
|
-
"`{{token}}` paints text or an icon with the solid `{{tone}}` tone, which is a fill and reads under 4.5:1 on the page (3.8:1 on white for a mid-luminance brand). A status shown as text or an icon takes the soft pair: `{{family}}-{{tone}}-soft-foreground`, on `bg-{{tone}}-soft` when it sits on a tint. The solid tone is for a fill with `text-{{tone}}-foreground` on it.",
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
create(context) {
|
|
109
|
-
// A `classes` entry inside a constant is reached twice (as part of the constant, and as the entry): one finding per string.
|
|
110
|
-
const seen = new Set()
|
|
111
|
-
const check = (root) => {
|
|
112
|
-
for (const { node, text } of stringNodes(root)) {
|
|
113
|
-
if (seen.has(node)) continue
|
|
114
|
-
seen.add(node)
|
|
115
|
-
for (const { token, family, tone } of solidTextClasses(text)) context.report({ node, messageId: "solid", data: { token, family, tone } })
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return {
|
|
119
|
-
JSXAttribute(node) {
|
|
120
|
-
if (node.name.type === "JSXIdentifier" && (node.name.name === "className" || node.name.name === "class")) check(attributeValue(node))
|
|
121
|
-
},
|
|
122
|
-
VariableDeclarator(node) {
|
|
123
|
-
check(node.init)
|
|
124
|
-
},
|
|
125
|
-
Property(node) {
|
|
126
|
-
if (!node.computed && node.key.type === "Identifier" && node.key.name === "classes") check(node.value)
|
|
127
|
-
},
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** The rules this law contributes to the plugin. */
|
|
133
|
-
export const rules = {
|
|
134
|
-
"status-text-uses-soft-foreground": statusTextUsesSoftForeground,
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R61")
|
|
138
|
-
|
|
139
|
-
/** Every rule of this law is an error. */
|
|
140
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
|
package/tokens.mjs
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/** StarCi scale and theme-token choices; ownership follows FE-RULE-OWNERSHIP. */
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
3
|
-
import { join } from "node:path"
|
|
4
|
-
import { classTextVisitors } from "@starci/eslint-canon-fe/lib/class-text.mjs"
|
|
5
|
-
import { hfsOf } from "@starci/eslint-canon-fe/lib/hfs.mjs"
|
|
6
|
-
import { classOf, isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs"
|
|
7
|
-
import { defineFamilyRules } from "./lib/rule-contract.mjs"
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* A fractional step, in any family that measures.
|
|
11
|
-
*
|
|
12
|
-
* The rungs are whole and unevenly spaced, so half of one is not "between two rungs" - it is off
|
|
13
|
-
* the ladder, and it will match nothing else on any screen.
|
|
14
|
-
*/
|
|
15
|
-
const FRACTIONAL =
|
|
16
|
-
/\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|inset|top|bottom|left|right|size|w|h)-\d+\.\d+\b/
|
|
17
|
-
|
|
18
|
-
/** Large text. Paired with a heavy weight it is a heading, whatever element carries it. */
|
|
19
|
-
const LARGE_TEXT = /\btext-(?:xl|2xl|3xl|4xl|5xl)\b/
|
|
20
|
-
|
|
21
|
-
/** A heavy weight. */
|
|
22
|
-
const HEAVY_WEIGHT = /\bfont-(?:bold|extrabold|black)\b/
|
|
23
|
-
|
|
24
|
-
/** A fractional step is off the ladder, not between two rungs. */
|
|
25
|
-
export const noFractionalStep = {
|
|
26
|
-
meta: {
|
|
27
|
-
type: "problem",
|
|
28
|
-
docs: { description: "A measurement takes a whole rung; a fractional step is off the scale." },
|
|
29
|
-
schema: [],
|
|
30
|
-
messages: {
|
|
31
|
-
fractional:
|
|
32
|
-
"`{{cls}}` is half a rung. The scale is whole steps, unevenly spaced, so this is not between two rungs - it is off the ladder, and it will match nothing else on any screen. Use the nearest rung; there is no case where the right answer is half of one.",
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
create(context) {
|
|
36
|
-
return classTextVisitors(context, (node, text) => {
|
|
37
|
-
if (!text) return
|
|
38
|
-
const hit = text.match(FRACTIONAL)
|
|
39
|
-
if (hit) context.report({ node, messageId: "fractional", data: { cls: hit[0] } })
|
|
40
|
-
})
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Large text plus a heavy weight is a heading, and headings come from the component that owns both. */
|
|
45
|
-
export const noHandRolledHeading = {
|
|
46
|
-
meta: {
|
|
47
|
-
type: "problem",
|
|
48
|
-
docs: { description: "A heading comes from the heading component, not from raw type classes." },
|
|
49
|
-
schema: [],
|
|
50
|
-
messages: {
|
|
51
|
-
heading:
|
|
52
|
-
"Large text and a heavy weight together are a heading, whatever element carries them - and assembled here it is a heading nothing else knows about: the outline a screen reader builds does not contain it, and it stays behind the day the type scale changes. Render the heading component, which owns the tag and the size as one decision.",
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
create(context) {
|
|
56
|
-
return classTextVisitors(context, (node, text) => {
|
|
57
|
-
if (!text) return
|
|
58
|
-
if (LARGE_TEXT.test(text) && HEAVY_WEIGHT.test(text)) {
|
|
59
|
-
context.report({ node, messageId: "heading" })
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
},
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The class families whose names are PROMISES about a theme token, and the variable each needs.
|
|
67
|
-
*
|
|
68
|
-
* Tailwind resolves `max-w-app-lg` by looking for `--container-app-lg`. Nothing warns when that
|
|
69
|
-
* variable does not exist: the class is emitted, the element gets no width, and the type system
|
|
70
|
-
* is satisfied because the name IS in the union. That is the failure this rule exists for - a
|
|
71
|
-
* class can be typesafe and mean nothing at the same time, and the union is exactly what makes
|
|
72
|
-
* it look settled.
|
|
73
|
-
*
|
|
74
|
-
* Only families whose token name is DERIVABLE from the class name are listed. A colour or a
|
|
75
|
-
* spacing step resolves through Tailwind's own scale as well as the theme, so a rule guessing at
|
|
76
|
-
* those would report classes that work.
|
|
77
|
-
*/
|
|
78
|
-
/**
|
|
79
|
-
* Names Tailwind resolves ITSELF, which therefore promise nothing about this theme.
|
|
80
|
-
*
|
|
81
|
-
* `min-h-screen` is the viewport, not a variable; so are `full`, `fit`, `auto` and the viewport
|
|
82
|
-
* units. Reported as unresolved tokens they would send an author to define `--min-height-screen`,
|
|
83
|
-
* which is a variable nothing reads - the rule would have invented work and broken a working class.
|
|
84
|
-
* Measured on both repositories the first time this ran: two findings, both wrong, both this list.
|
|
85
|
-
*/
|
|
86
|
-
const TAILWIND_OWN_NAMES = new Set([
|
|
87
|
-
"screen", "full", "fit", "auto", "none", "min", "max", "prose",
|
|
88
|
-
"dvh", "svh", "lvh", "dvw", "svw", "lvw", "px",
|
|
89
|
-
])
|
|
90
|
-
const TOKEN_CLASS_FAMILIES = [
|
|
91
|
-
{ pattern: /^max-w-app-([a-z0-9]+)$/, variable: (name) => `--container-app-${name}` },
|
|
92
|
-
{ pattern: /^max-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--max-height-${name}` },
|
|
93
|
-
{ pattern: /^min-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--min-height-${name}` },
|
|
94
|
-
]
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The stylesheets an app declares its theme in: the route slot's `globals.css` (`fe.route`, named by its `allows`) and the brand slot's
|
|
98
|
-
* `brand.css` (`fe.modules.brand`), both with `<app>` filled from the linted file's slot. Null when the file belongs to no app
|
|
99
|
-
* (a shared package has no theme of its own).
|
|
100
|
-
*/
|
|
101
|
-
/** Every theme read once per lint run. */
|
|
102
|
-
const themeCache = new Map()
|
|
103
|
-
const themeText = (context) => {
|
|
104
|
-
const hfs = hfsOf(context)
|
|
105
|
-
const app = classOf(context).bindings?.app
|
|
106
|
-
if (app === undefined) return null
|
|
107
|
-
const candidates = [
|
|
108
|
-
join(hfs.slot("fe.route").path.replace("<app>", app), "globals.css"),
|
|
109
|
-
hfs.slot("fe.modules.brand").path.replace("<app>", app),
|
|
110
|
-
].map((relative) => join(hfs.repoRoot, relative))
|
|
111
|
-
const key = candidates.join("|")
|
|
112
|
-
if (!themeCache.has(key)) {
|
|
113
|
-
const found = candidates.filter((candidate) => existsSync(candidate)).map((candidate) => readFileSync(candidate, "utf8"))
|
|
114
|
-
themeCache.set(key, found.length > 0 ? found.join("\n") : null)
|
|
115
|
-
}
|
|
116
|
-
return themeCache.get(key)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** A class naming a token the theme never defines is a class that draws nothing. */
|
|
120
|
-
export const noUnresolvedTokenClass = {
|
|
121
|
-
meta: {
|
|
122
|
-
type: "problem",
|
|
123
|
-
docs: { description: "A class naming a theme token resolves only when the theme defines it." },
|
|
124
|
-
schema: [],
|
|
125
|
-
messages: {
|
|
126
|
-
unresolved:
|
|
127
|
-
"`{{value}}` reads as a token this theme defines, and `{{variable}}` is defined nowhere in it. Tailwind emits the class, the element gets nothing, and both the union and the compiler stay happy - which is why this is the one dead class no type can catch. Define `{{variable}}` in the stylesheet, or name a token that exists.",
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
create(context) {
|
|
131
|
-
if (!isProductSource(context)) return {}
|
|
132
|
-
const theme = themeText(context)
|
|
133
|
-
// A reader that cannot find the stylesheet stays quiet rather than calling every token dead.
|
|
134
|
-
if (theme === null) return {}
|
|
135
|
-
// The visitor hands (node, text) in that order; reversing them here is how a rule reports the
|
|
136
|
-
// node as its message and the message as its node, and neither complains.
|
|
137
|
-
const check = (node, value) => {
|
|
138
|
-
for (const raw of String(value).split(/\s+/)) {
|
|
139
|
-
const bare = raw.replace(/^[a-z-]+:/, "").replace(/^!/, "")
|
|
140
|
-
for (const family of TOKEN_CLASS_FAMILIES) {
|
|
141
|
-
const match = family.pattern.exec(bare)
|
|
142
|
-
if (match === null) continue
|
|
143
|
-
if (TAILWIND_OWN_NAMES.has(match[1])) continue
|
|
144
|
-
const variable = family.variable(match[1])
|
|
145
|
-
if (theme.includes(variable)) continue
|
|
146
|
-
context.report({ node, messageId: "unresolved", data: { value: bare, variable } })
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return classTextVisitors(context, check)
|
|
151
|
-
},
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** StarCi scale rules, bound to their knowledge owner. */
|
|
155
|
-
export const rules = {
|
|
156
|
-
"no-fractional-step": noFractionalStep,
|
|
157
|
-
"no-hand-rolled-heading": noHandRolledHeading,
|
|
158
|
-
"no-unresolved-token-class": noUnresolvedTokenClass,
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R61")
|
|
162
|
-
|
|
163
|
-
/** Every family scale rule is mandatory. */
|
|
164
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
|
package/typography.mjs
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { defineFamilyRules } from "./lib/rule-contract.mjs"
|
|
2
|
-
/**
|
|
3
|
-
* The rule that holds `typography.md`.
|
|
4
|
-
*
|
|
5
|
-
* MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
|
|
6
|
-
* written by hand. The level union stops a fifth step being asked for; nothing in the type system
|
|
7
|
-
* notices an `<h2>` in a file that never mentions the heading component at all.
|
|
8
|
-
*
|
|
9
|
-
* The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
|
|
10
|
-
* size and a heavy weight on some other element. Between them they cover both ways a heading gets
|
|
11
|
-
* hand-rolled - with the right tag and the wrong look, or the right look and no tag.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { classOf, inSlot, isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs"
|
|
15
|
-
|
|
16
|
-
/** The one owner that writes the heading tag: the component named `Heading` in the leaves layer of an app (slot `fe.components`; the owner name is the one name this rule keeps). */
|
|
17
|
-
const isHeadingComponent = (context) => {
|
|
18
|
-
const owner = classOf(context)
|
|
19
|
-
return inSlot(context, "fe.components") && owner.kind === "leaves" && owner.bindings?.name === "Heading"
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The tags a document outline is built from. */
|
|
23
|
-
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
|
|
24
|
-
|
|
25
|
-
/** The deepest level the component draws; below this a page has nested too far. */
|
|
26
|
-
const DEEPEST_LEVEL = 4
|
|
27
|
-
|
|
28
|
-
/** Intrinsic (lowercase) tag name, or null for a component. */
|
|
29
|
-
const tagName = (opening) => {
|
|
30
|
-
const name = opening && opening.name
|
|
31
|
-
if (!name || name.type !== "JSXIdentifier") return null
|
|
32
|
-
return name.name === name.name.toLowerCase() ? name.name : null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
/** A heading comes from the component that owns its tag and its size as one decision. */
|
|
38
|
-
export const noHeadingTagOutsideHeadingComponent = {
|
|
39
|
-
meta: {
|
|
40
|
-
type: "problem",
|
|
41
|
-
docs: { description: "Heading tags are written by the heading component, from a level." },
|
|
42
|
-
schema: [],
|
|
43
|
-
messages: {
|
|
44
|
-
tag:
|
|
45
|
-
"`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
|
|
46
|
-
tooDeep:
|
|
47
|
-
"`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
create(context) {
|
|
51
|
-
if (!isProductSource(context) || isHeadingComponent(context)) return {}
|
|
52
|
-
return {
|
|
53
|
-
JSXOpeningElement(node) {
|
|
54
|
-
const tag = tagName(node)
|
|
55
|
-
if (!tag || !HEADING_TAGS.has(tag)) return
|
|
56
|
-
const level = Number(tag.slice(1))
|
|
57
|
-
if (level > DEEPEST_LEVEL) {
|
|
58
|
-
context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
context.report({ node, messageId: "tag", data: { tag, level } })
|
|
62
|
-
},
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The rules this law contributes to the plugin. */
|
|
68
|
-
export const rules = {
|
|
69
|
-
"no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
defineFamilyRules(rules, "knowledge/ui/presentation/font.yaml")
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
76
|
-
*
|
|
77
|
-
* Exact: a tag name in a file that is not the component that owns it. A repository adopting this
|
|
78
|
-
* with history should expect the deeper levels to report a structure problem rather than a styling
|
|
79
|
-
* one, and those take longer to fix than the message suggests.
|
|
80
|
-
*/
|
|
81
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
|
package/vendor-boundary.mjs
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { defineFamilyRules } from "./lib/rule-contract.mjs"
|
|
2
|
-
/**
|
|
3
|
-
* Vendor ownership rules for the traditional React component hierarchy.
|
|
4
|
-
*
|
|
5
|
-
* Vendor primitives stay behind named leaves or mechanics branches. This boundary is deliberately
|
|
6
|
-
* independent of product composition protocols; ordinary React children and props remain valid.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { isComponentFile, kindOfFile, roleOfFile } from "@starci/eslint-canon-fe/lib/scope.mjs"
|
|
10
|
-
|
|
11
|
-
/** The layers that may hold vendor mechanics: leaves and branches. */
|
|
12
|
-
const MECHANICS_LAYERS = new Set(["leaves", "branches"])
|
|
13
|
-
|
|
14
|
-
/** Keep HeroUI mechanics inside leaves, branches, and styling-owner modules (the `styles` file of a component). */
|
|
15
|
-
export const vendorPrimitiveHasNamedOwner = {
|
|
16
|
-
meta: { type: "problem", docs: { description: "HeroUI primitives have a named component or styling owner." }, schema: [], messages: { owner: "Import HeroUI primitives from a named leaf, mechanics branch, or colocated classNames module." } },
|
|
17
|
-
create(context) {
|
|
18
|
-
if (!isComponentFile(context) || MECHANICS_LAYERS.has(kindOfFile(context)) || roleOfFile(context) === "styles") return {}
|
|
19
|
-
return { ImportDeclaration(node) { if (String(node.source.value) === "@heroui/react") context.report({ node, messageId: "owner" }) } }
|
|
20
|
-
},
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const rules = {
|
|
24
|
-
"vendor-primitive-has-named-owner": vendorPrimitiveHasNamedOwner,
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R93")
|
|
28
|
-
|
|
29
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
|