@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.
- package/contract.mjs +1363 -1295
- package/contracts.ts +1 -1
- package/file-layout.mjs +414 -310
- package/icon.mjs +10 -96
- package/landmark.mjs +242 -242
- package/lint-adoption.mjs +116 -116
- package/naming.mjs +36 -7
- package/package.json +38 -38
- package/props-and-slots.mjs +278 -14
- package/props.ts +33 -33
- package/served-locale.mjs +1 -1
- package/the-split.mjs +2 -2
- package/typography.mjs +83 -83
- package/vendor-boundary.mjs +85 -62
package/props-and-slots.mjs
CHANGED
|
@@ -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
|
-
*
|
|
103
|
-
*
|
|
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: "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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 `
|
|
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 =
|
|
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"]))
|