@starci/eslint-canon-fe 1.4.1 → 1.4.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.
@@ -12,7 +12,7 @@
12
12
  * a shape is findable.
13
13
  */
14
14
 
15
- import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier } from "./contract.mjs"
15
+ import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier, isTestFile } from "./contract.mjs"
16
16
 
17
17
  /** True when a parameter type contains an anonymous object shape, including inside intersections. */
18
18
  const isInlineObjectType = (node) => {
@@ -58,9 +58,6 @@ export const noInlineParameterType = {
58
58
 
59
59
  // -- SLOTS-4 ---------------------------------------------------------------------------------------
60
60
 
61
- /** The only three components where a React `children` hole is lawful. */
62
- const CHILDREN_SHELLS = ["ModalShell", "DrawerShell", "DropdownShell", "RouteShell"]
63
-
64
61
  /**
65
62
  * True for a component file the slot fence governs.
66
63
  *
@@ -74,15 +71,11 @@ const CHILDREN_SHELLS = ["ModalShell", "DrawerShell", "DropdownShell", "RouteShe
74
71
  * NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
75
72
  * slot to stop describing what it admits instead.
76
73
  *
77
- * The three shells stay exempt for the reason canon already gives them: they pass real children
78
- * through and arrange nothing.
79
- *
80
74
  * @param filename - the file being linted.
81
75
  */
82
76
  const isGoverned = (filename) => {
83
77
  const path = String(filename || "").replace(/\\/g, "/")
84
78
  if (isContractTableFile(path)) return false
85
- if (CHILDREN_SHELLS.some((shell) => isInComponentTier(path, `shells/${shell}`))) return false
86
79
  /*
87
80
  * The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
88
81
  * catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
@@ -92,6 +85,69 @@ const isGoverned = (filename) => {
92
85
  return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
93
86
  }
94
87
 
88
+ /**
89
+ * True when a function's whole body does nothing but turn `children` into a component
90
+ * reference and hand that reference to exactly one other component under a named prop.
91
+ *
92
+ * THIS IS SLOTS-4'S OWN EXCEPTION, READ FROM SHAPE, NOT FROM A NAME. The rule's message
93
+ * already promises it: a framework route layout may receive `ReactNode` outside the
94
+ * component tier, but must close it into a named projection before a component sees it.
95
+ * A shell that crosses `children` over a client/server boundary - wrapping it in
96
+ * `useCallback` so it survives as a function reference rather than an unserialisable
97
+ * inline component - IS that closing act; there is no contract key to name because the
98
+ * shell is deliberately generic across callers. What makes the exception SAFE to keep
99
+ * narrow is that the shape disqualifies itself the moment the body does one more thing:
100
+ * a second return, a conditional, a data read, an element of its own, or a forwarded
101
+ * value that never passes through the closure all fall through to the ordinary report.
102
+ *
103
+ * - Every statement but the last is a `const x = ...` naming either a plain member/param
104
+ * read (`const Frame = input.frame`) or a `useCallback(...)` call - nothing else.
105
+ * - The last statement is the only `return`, and it returns exactly one JSX element.
106
+ * - That element's attributes only spread or forward existing bindings, and at least one
107
+ * of them carries the `useCallback` result - the children-derived reference - by name.
108
+ */
109
+ const isChildrenBoundaryConverter = (fn) => {
110
+ if (!fn || fn.body?.type !== "BlockStatement") return false
111
+ const statements = fn.body.body
112
+ let closureVar = null
113
+ let returnNode = null
114
+
115
+ for (const statement of statements) {
116
+ if (statement.type === "ReturnStatement") {
117
+ if (returnNode) return false // a second return means this does more than one thing
118
+ returnNode = statement.argument
119
+ continue
120
+ }
121
+ if (statement.type !== "VariableDeclaration" || statement.declarations.length !== 1) return false
122
+ const [declarator] = statement.declarations
123
+ const init = declarator.init
124
+ if (declarator.id.type !== "Identifier" || !init) return false
125
+ if (init.type === "MemberExpression" || init.type === "Identifier") continue // a plain prop read
126
+ if (init.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "useCallback") {
127
+ const callback = init.arguments[0]
128
+ if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) {
129
+ return false
130
+ }
131
+ closureVar = declarator.id.name
132
+ continue
133
+ }
134
+ return false // any other statement is the component doing something of its own
135
+ }
136
+
137
+ if (!closureVar || !returnNode || returnNode.type !== "JSXElement") return false
138
+
139
+ let carriesClosure = false
140
+ for (const attribute of returnNode.openingElement.attributes || []) {
141
+ if (attribute.type === "JSXSpreadAttribute") continue
142
+ if (attribute.type !== "JSXAttribute" || !attribute.value) return false
143
+ const raw = attribute.value.type === "JSXExpressionContainer" ? attribute.value.expression : attribute.value
144
+ const value = raw?.type === "TSAsExpression" ? raw.expression : raw
145
+ if (value?.type !== "Identifier") return false
146
+ if (value.name === closureVar) carriesClosure = true
147
+ }
148
+ return carriesClosure
149
+ }
150
+
95
151
  /**
96
152
  * A container takes `contract` and `render`, never `children`.
97
153
  *
@@ -99,25 +155,65 @@ const isGoverned = (filename) => {
99
155
  * aliases it defines, but nothing stops a file declaring its own props shape by hand and putting
100
156
  * `children` in it. What the alias makes unrepresentable, a hand-written interface makes ordinary.
101
157
  *
102
- * ModalShell, DrawerShell and DropdownShell are exempt because they ignore the interior shape and
103
- * pass it directly to the vendor body. No folder-wide exemption exists.
158
+ * Vendor mechanics branches still take a named contract/render projection. Framework route
159
+ * layouts sit outside the component tier and close their ReactNode into that projection before it
160
+ * reaches a component.
161
+ *
162
+ * ONE CLOSED EXCEPTION LIVES INSIDE THE COMPONENT TIER: a boundary-converter shell whose
163
+ * whole body matches {@link isChildrenBoundaryConverter}. It is closed by shape, checked
164
+ * against the actual function the props type belongs to, never by filename or export name
165
+ * - so nothing can opt in by being called `RouteShell`, and nothing loses the exemption by
166
+ * being renamed.
104
167
  */
105
168
  export const noChildrenSlot = {
106
169
  meta: {
107
170
  type: "problem",
108
- docs: { description: "A container takes contract and render; only the three closed shells may take children." },
171
+ docs: { description: "Every component container takes contract and render, never children." },
109
172
  schema: [],
110
173
  messages: {
111
174
  slot:
112
- "`children` accepts markup that has already been built, so its shape cannot be checked. Take contract + render instead. Only ModalShell, DrawerShell, DropdownShell and RouteShell may take one - the first three pass an interior straight to vendor mechanics, and RouteShell converts the children a framework layout is handed.",
175
+ "`children` accepts markup that has already been built, so its shape cannot be checked. Take contract + render instead. A framework route layout may receive ReactNode outside the component tier, but it must close that value into a named contract projection before handing it to a component.",
113
176
  },
114
177
  },
115
178
  create(context) {
116
179
  if (!isGoverned(context.filename || context.getFilename())) return {}
180
+
181
+ // Deferred to Program:exit so the boundary-converter exemption - which has to look at
182
+ // the whole file to find the function a props type belongs to - can clear a candidate
183
+ // before it is ever reported.
184
+ const candidates = []
185
+ const componentsByPropsType = new Map()
186
+
187
+ const enclosingFunction = (node) => {
188
+ for (let current = node.parent; current; current = current.parent) {
189
+ if (["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"].includes(current.type)) {
190
+ return current
191
+ }
192
+ }
193
+ return null
194
+ }
195
+
196
+ const enclosingTypeName = (node) => {
197
+ for (let current = node.parent; current; current = current.parent) {
198
+ if (current.type === "TSInterfaceDeclaration" || current.type === "TSTypeAliasDeclaration") {
199
+ return current.id?.name || null
200
+ }
201
+ }
202
+ return null
203
+ }
204
+
205
+ /** The name of the type a function's single parameter is annotated with, if any. */
206
+ const parameterTypeName = (fn) => {
207
+ const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
208
+ return annotation?.type === "TSTypeReference" && annotation.typeName?.type === "Identifier"
209
+ ? annotation.typeName.name
210
+ : null
211
+ }
212
+
117
213
  return {
118
214
  TSPropertySignature(node) {
119
215
  if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
120
- context.report({ node: node.key, messageId: "slot" })
216
+ candidates.push({ node: node.key, typeName: enclosingTypeName(node) })
121
217
  }
122
218
  },
123
219
  Property(node) {
@@ -125,9 +221,173 @@ export const noChildrenSlot = {
125
221
  if (node.parent && node.parent.type !== "ObjectPattern") return
126
222
  if (node.parent.parent && node.parent.parent.type === "VariableDeclarator") return
127
223
  if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
128
- context.report({ node: node.key, messageId: "slot" })
224
+ candidates.push({ node: node.key, fn: enclosingFunction(node) })
225
+ }
226
+ },
227
+ ":function"(fn) {
228
+ const typeName = parameterTypeName(fn)
229
+ if (typeName) componentsByPropsType.set(typeName, fn)
230
+ },
231
+ "Program:exit"() {
232
+ for (const candidate of candidates) {
233
+ const fn = candidate.fn || (candidate.typeName && componentsByPropsType.get(candidate.typeName))
234
+ if (fn && isChildrenBoundaryConverter(fn)) continue
235
+ context.report({ node: candidate.node, messageId: "slot" })
236
+ }
237
+ },
238
+ }
239
+ },
240
+ }
241
+
242
+ /** Name of a plain TypeScript property key. */
243
+ const propertyName = (node) => {
244
+ const key = node?.key ?? node?.property
245
+ if (!key) return null
246
+ if (key.type === "Identifier") return key.name
247
+ if (key.type === "Literal" && typeof key.value === "string") return key.value
248
+ return null
249
+ }
250
+
251
+ /** Product source inside a supported component root. */
252
+ const isComponentSource = (filename) => {
253
+ const path = String(filename || "").replace(/\\/g, "/")
254
+ return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
255
+ }
256
+
257
+ // -- SLOTS-5 · SLOTS-6 ---------------------------------------------------------------------------
258
+
259
+ /** A caller cannot reach into one named internal part and style it. */
260
+ export const noPerPartClassNameProp = {
261
+ meta: {
262
+ type: "problem",
263
+ docs: { description: "No <part>ClassName prop exposes a component's internal nodes." },
264
+ schema: [],
265
+ messages: {
266
+ perPart:
267
+ "`{{prop}}` lets a caller restyle a node it does not own. Replace the CSS door with a named semantic prop, and keep the appearance decision inside the component or its contract.",
268
+ },
269
+ },
270
+ create(context) {
271
+ const filename = context.filename || context.getFilename()
272
+ if (isTestFile(filename) || !isComponentSource(filename)) return {}
273
+ return {
274
+ TSPropertySignature(node) {
275
+ const name = propertyName(node)
276
+ if (!name || name === "className" || !/^[a-z][A-Za-z0-9]*ClassName$/.test(name)) return
277
+ context.report({ node, messageId: "perPart", data: { prop: name } })
278
+ },
279
+ }
280
+ },
281
+ }
282
+
283
+ /** Public house components never expose className/classNames placement doors. */
284
+ export const noPublicClassNameProp = {
285
+ meta: {
286
+ type: "problem",
287
+ docs: { description: "House components own appearance and expose no public className/classNames prop." },
288
+ schema: [],
289
+ messages: {
290
+ declaration:
291
+ "Public component prop `{{prop}}` is a CSS placement door. Expose a semantic variant or a named contract instead.",
292
+ usage:
293
+ "Do not pass `{{prop}}` to house component `{{component}}`; its owner or parent contract decides appearance.",
294
+ },
295
+ },
296
+ create(context) {
297
+ const filename = context.filename || context.getFilename()
298
+ if (isTestFile(filename)) return {}
299
+ const inComponent = isComponentSource(filename)
300
+ const bindings = new Set()
301
+ return {
302
+ ImportDeclaration(node) {
303
+ const source = String(node.source?.value || "").replace(/\\/g, "/")
304
+ if (!/(?:^|\/)components\//.test(source)) return
305
+ for (const specifier of node.specifiers || []) {
306
+ if (specifier.local?.name) bindings.add(specifier.local.name)
129
307
  }
130
308
  },
309
+ TSPropertySignature(node) {
310
+ if (!inComponent) return
311
+ const name = propertyName(node)
312
+ if (name === "className" || name === "classNames") {
313
+ context.report({ node, messageId: "declaration", data: { prop: name } })
314
+ }
315
+ },
316
+ JSXOpeningElement(node) {
317
+ const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
318
+ if (!component || !bindings.has(component)) return
319
+ for (const attribute of node.attributes || []) {
320
+ if (attribute.type !== "JSXAttribute") continue
321
+ const prop = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null
322
+ if (prop === "className" || prop === "classNames") {
323
+ context.report({ node: attribute, messageId: "usage", data: { prop, component } })
324
+ }
325
+ }
326
+ },
327
+ }
328
+ },
329
+ }
330
+
331
+ /** CSS-shaped layout props are not public API above the atomic leaf tier. */
332
+ const FRAME_CSS_PROPS = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"])
333
+
334
+ export const noPublicFrameCssProps = {
335
+ meta: {
336
+ type: "problem",
337
+ docs: { description: "Non-leaf component contracts expose semantic decisions, not CSS-shaped frame props." },
338
+ schema: [],
339
+ messages: {
340
+ css:
341
+ "`{{prop}}` is a public CSS/frame decision above the leaf tier. Move the arrangement into a named contract or expose the semantic state that selects one.",
342
+ },
343
+ },
344
+ create(context) {
345
+ const filename = context.filename || context.getFilename()
346
+ if (isTestFile(filename) || !isComponentSource(filename) || isInComponentTier(filename, "leaves")) return {}
347
+ return {
348
+ TSPropertySignature(node) {
349
+ const name = propertyName(node)
350
+ if (name && FRAME_CSS_PROPS.has(name)) context.report({ node, messageId: "css", data: { prop: name } })
351
+ },
352
+ }
353
+ },
354
+ }
355
+
356
+ /** String literal keys named by Omit/Pick/Exclude. */
357
+ const typeKeys = (node, out = []) => {
358
+ if (!node) return out
359
+ if (node.type === "TSUnionType") {
360
+ for (const member of node.types || []) typeKeys(member, out)
361
+ return out
362
+ }
363
+ if (node.type === "TSLiteralType" && node.literal?.type === "Literal" && typeof node.literal.value === "string") {
364
+ out.push(node.literal.value)
365
+ }
366
+ return out
367
+ }
368
+
369
+ /** Hiding a CSS door with a utility type is not closing the owner that exposed it. */
370
+ export const noCssDoorTypeLaundering = {
371
+ meta: {
372
+ type: "problem",
373
+ docs: { description: "Omit/Pick/Exclude cannot hide className/classNames/style doors." },
374
+ schema: [],
375
+ messages: {
376
+ utility:
377
+ "`{{utility}}` of `{{prop}}` launders a CSS door instead of closing it. Remove the prop from the owning public contract and every consumer.",
378
+ },
379
+ },
380
+ create(context) {
381
+ const file = String(context.filename || context.getFilename()).replace(/\\/g, "/")
382
+ if (isTestFile(file) || !file.includes("/src/")) return {}
383
+ return {
384
+ TSTypeReference(node) {
385
+ const utility = node.typeName?.type === "Identifier" ? node.typeName.name : null
386
+ if (!utility || !["Omit", "Pick", "Exclude"].includes(utility)) return
387
+ const params = node.typeArguments?.params || node.typeParameters?.params || []
388
+ const prop = typeKeys(params[1]).find((key) => key === "className" || key === "classNames" || key === "style")
389
+ if (prop) context.report({ node, messageId: "utility", data: { utility, prop } })
390
+ },
131
391
  }
132
392
  },
133
393
  }
@@ -176,6 +436,10 @@ export const noSurfaceListItemsSlot = {
176
436
  export const rules = {
177
437
  "no-inline-parameter-type": noInlineParameterType,
178
438
  "no-children-slot": noChildrenSlot,
439
+ "no-per-part-classname-prop": noPerPartClassNameProp,
440
+ "no-public-classname-prop": noPublicClassNameProp,
441
+ "no-public-frame-css-props": noPublicFrameCssProps,
442
+ "no-css-door-type-laundering": noCssDoorTypeLaundering,
179
443
  "no-surface-list-items-slot": noSurfaceListItemsSlot,
180
444
  }
181
445
 
package/props.ts CHANGED
@@ -61,13 +61,13 @@ export type LeafProps<D extends ComponentData, A extends ComponentActions = Comp
61
61
  /**
62
62
  * A COMPOSITE's props. The same three slots a leaf has, and the absence of a fourth is the point.
63
63
  *
64
- * A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
65
- * cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
66
- * there is no `render` here, so an author who wanted a slot cannot express one and has to decide
67
- * which tier they are actually writing. A boundary the compiler holds does not drift.
68
- * CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
69
- * raw structural markup would create an unnamed layout outside the registry. If a caller may
70
- * supply the content or recipe, the component is a branch rather than a composite.
64
+ * A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
65
+ * cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
66
+ * there is no `render` here, so an author who wanted a slot cannot express one and has to decide
67
+ * which tier they are actually writing. A boundary the compiler holds does not drift.
68
+ * CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
69
+ * raw structural markup would create an unnamed layout outside the registry. If a caller may
70
+ * supply the content or recipe, the component is a branch rather than a composite.
71
71
  *
72
72
  * It is structurally identical to {@link LeafProps} and deliberately NOT an alias of it: the two
73
73
  * tiers answer different questions, and a shared name would let a reader conclude they are the same
@@ -85,18 +85,18 @@ export type CompositeProps<D extends ComponentData, A extends ComponentActions =
85
85
  *
86
86
  * THIS IS THE SLOT THAT USED TO BE `children`, AND THE CHANGE IS NOT COSMETIC. `children` is markup
87
87
  * that has already been built: it can be a `.map`, a ternary, a fragment, or a subtree nobody named,
88
- * so nothing above it can say what is inside a node and no rule can check. `render` is a contract
89
- * value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
90
- * named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
91
- * to receive a stable component type and pass changing runtime `props` into it.
88
+ * so nothing above it can say what is inside a node and no rule can check. `render` is a contract
89
+ * value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
90
+ * named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
91
+ * to receive a stable component type and pass changing runtime `props` into it.
92
92
  *
93
93
  * `contract` and `render` are one decision written twice, and `ContractComponent<K>` ties them together:
94
94
  * the key fixes which slots exist and what may fill each, so a card cannot say one thing and hold
95
95
  * another. A wrong leaf, wrong leaf props, a node where a leaf was asked for, a missing slot and a
96
96
  * slot nobody declared are five compile errors rather than five things a reviewer has to spot.
97
97
  *
98
- * A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
99
- * `contract` and `render` one checked decision.
98
+ * A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
99
+ * `contract` and `render` one checked decision.
100
100
  *
101
101
  * THERE IS NO LIST VARIANT, and there does not need to be. A slot that holds many says so in the
102
102
  * table with `repeats: true`; that slot becomes an array. `restingCount` separately fixes how many
@@ -110,26 +110,26 @@ export type BranchProps<
110
110
  readonly props: D
111
111
  readonly on?: A
112
112
  readonly contract: K
113
- readonly render: ContractComponent<NoInfer<K>>
114
- readonly isLoading?: boolean
115
- }
116
-
117
- /**
118
- * A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
119
- * component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
120
- * domain collection must not be closed into a new callback descriptor at every host call site.
121
- */
122
- export type ContractRenderBranchProps<
123
- D extends ComponentData,
124
- K extends ContractKey,
125
- A extends ComponentActions = ComponentActions,
126
- > = {
127
- readonly props: D
128
- readonly on?: A
129
- readonly contract: K
130
- readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
131
- readonly isLoading?: boolean
132
- }
113
+ readonly render: ContractComponent<NoInfer<K>>
114
+ readonly isLoading?: boolean
115
+ }
116
+
117
+ /**
118
+ * A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
119
+ * component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
120
+ * domain collection must not be closed into a new callback descriptor at every host call site.
121
+ */
122
+ export type ContractRenderBranchProps<
123
+ D extends ComponentData,
124
+ K extends ContractKey,
125
+ A extends ComponentActions = ComponentActions,
126
+ > = {
127
+ readonly props: D
128
+ readonly on?: A
129
+ readonly contract: K
130
+ readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
131
+ readonly isLoading?: boolean
132
+ }
133
133
 
134
134
  /**
135
135
  * A BLOCK's presentational half. Two slots.
package/served-locale.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Rules for the SERVED LOCALE law - `fe/canon/patterns/served-locale.md`.
2
+ * Rules for the SERVED LOCALE law - `patterns/fe/served-locale/INDEX.md`.
3
3
  *
4
4
  * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
5
  * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
package/the-split.mjs CHANGED
@@ -66,7 +66,7 @@ export const presentationalPurity = {
66
66
  export const connectedBlockHasPresentationalTwin = {
67
67
  meta: {
68
68
  type: "problem",
69
- docs: { description: "A connected block index renders only its exact `_X` twin from component.tsx." },
69
+ docs: { description: "A connected block index renders only its exact `XBase` twin from component.tsx." },
70
70
  schema: [],
71
71
  messages: {
72
72
  missing:
@@ -81,7 +81,7 @@ export const connectedBlockHasPresentationalTwin = {
81
81
  const block = connectedBlock(context.filename || context.getFilename())
82
82
  if (!block) return {}
83
83
 
84
- const twin = `_${block}`
84
+ const twin = `${block}Base`
85
85
  const worldBindings = new Set()
86
86
  const rendered = []
87
87
  let importsTwin = false
package/typography.mjs CHANGED
@@ -1,83 +1,83 @@
1
- /**
2
- * The rule that holds `typography.md`.
3
- *
4
- * MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
5
- * written by hand. The level union stops a fifth step being asked for; nothing in the type system
6
- * notices an `<h2>` in a file that never mentions the heading component at all.
7
- *
8
- * The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
9
- * size and a heavy weight on some other element. Between them they cover both ways a heading gets
10
- * hand-rolled - with the right tag and the wrong look, or the right look and no tag.
11
- */
12
-
13
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
14
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
15
-
16
- /** Product source lives under `src/`; tooling and config are out of scope. */
17
- const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
18
-
19
- /** A twin test may build heading markup by hand to assert against it. */
20
- const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
21
-
22
- /** The one component that owns the tag and the size together, and therefore may write the tag. */
23
- const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
24
-
25
- /** The tags a document outline is built from. */
26
- const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
27
-
28
- /** The deepest level the component draws; below this a page has nested too far. */
29
- const DEEPEST_LEVEL = 4
30
-
31
- /** Intrinsic (lowercase) tag name, or null for a component. */
32
- const tagName = (opening) => {
33
- const name = opening && opening.name
34
- if (!name || name.type !== "JSXIdentifier") return null
35
- return name.name === name.name.toLowerCase() ? name.name : null
36
- }
37
-
38
- // -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
39
-
40
- /** A heading comes from the component that owns its tag and its size as one decision. */
41
- export const noHeadingTagOutsideHeadingComponent = {
42
- meta: {
43
- type: "problem",
44
- docs: { description: "Heading tags are written by the heading component, from a level." },
45
- schema: [],
46
- messages: {
47
- tag:
48
- "`<{{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.",
49
- tooDeep:
50
- "`<{{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.",
51
- },
52
- },
53
- create(context) {
54
- const file = normalizePath(context.filename || context.getFilename())
55
- if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
56
- return {
57
- JSXOpeningElement(node) {
58
- const tag = tagName(node)
59
- if (!tag || !HEADING_TAGS.has(tag)) return
60
- const level = Number(tag.slice(1))
61
- if (level > DEEPEST_LEVEL) {
62
- context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
63
- return
64
- }
65
- context.report({ node, messageId: "tag", data: { tag, level } })
66
- },
67
- }
68
- },
69
- }
70
-
71
- /** The rules this law contributes to the plugin. */
72
- export const rules = {
73
- "no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
74
- }
75
-
76
- /**
77
- * The level this law asks for, as the plugin's own opinion.
78
- *
79
- * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
80
- * with history should expect the deeper levels to report a structure problem rather than a styling
81
- * one, and those take longer to fix than the message suggests.
82
- */
83
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rule that holds `typography.md`.
3
+ *
4
+ * MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
5
+ * written by hand. The level union stops a fifth step being asked for; nothing in the type system
6
+ * notices an `<h2>` in a file that never mentions the heading component at all.
7
+ *
8
+ * The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
9
+ * size and a heavy weight on some other element. Between them they cover both ways a heading gets
10
+ * hand-rolled - with the right tag and the wrong look, or the right look and no tag.
11
+ */
12
+
13
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
14
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
15
+
16
+ /** Product source lives under `src/`; tooling and config are out of scope. */
17
+ const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
18
+
19
+ /** A twin test may build heading markup by hand to assert against it. */
20
+ const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
21
+
22
+ /** The one component that owns the tag and the size together, and therefore may write the tag. */
23
+ const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
24
+
25
+ /** The tags a document outline is built from. */
26
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
27
+
28
+ /** The deepest level the component draws; below this a page has nested too far. */
29
+ const DEEPEST_LEVEL = 4
30
+
31
+ /** Intrinsic (lowercase) tag name, or null for a component. */
32
+ const tagName = (opening) => {
33
+ const name = opening && opening.name
34
+ if (!name || name.type !== "JSXIdentifier") return null
35
+ return name.name === name.name.toLowerCase() ? name.name : null
36
+ }
37
+
38
+ // -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
39
+
40
+ /** A heading comes from the component that owns its tag and its size as one decision. */
41
+ export const noHeadingTagOutsideHeadingComponent = {
42
+ meta: {
43
+ type: "problem",
44
+ docs: { description: "Heading tags are written by the heading component, from a level." },
45
+ schema: [],
46
+ messages: {
47
+ tag:
48
+ "`<{{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.",
49
+ tooDeep:
50
+ "`<{{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.",
51
+ },
52
+ },
53
+ create(context) {
54
+ const file = normalizePath(context.filename || context.getFilename())
55
+ if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
56
+ return {
57
+ JSXOpeningElement(node) {
58
+ const tag = tagName(node)
59
+ if (!tag || !HEADING_TAGS.has(tag)) return
60
+ const level = Number(tag.slice(1))
61
+ if (level > DEEPEST_LEVEL) {
62
+ context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
63
+ return
64
+ }
65
+ context.report({ node, messageId: "tag", data: { tag, level } })
66
+ },
67
+ }
68
+ },
69
+ }
70
+
71
+ /** The rules this law contributes to the plugin. */
72
+ export const rules = {
73
+ "no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
74
+ }
75
+
76
+ /**
77
+ * The level this law asks for, as the plugin's own opinion.
78
+ *
79
+ * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
80
+ * with history should expect the deeper levels to report a structure problem rather than a styling
81
+ * one, and those take longer to fix than the message suggests.
82
+ */
83
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))