@starci/eslint-canon-fe 4.0.0 → 6.0.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 +111 -0
- package/README.md +33 -16
- package/brand-values.mjs +172 -0
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +287 -0
- package/comments.mjs +137 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +124 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/grammar-boundary.md +33 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +122 -0
- package/docs/lint-escape-hatch.md +34 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +232 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +147 -0
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +54 -0
- package/docs/transport.md +195 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +345 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +105 -104
- package/formatting.mjs +104 -0
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +120 -0
- package/hygiene.mjs +513 -0
- package/icon.mjs +212 -212
- package/index.mjs +243 -142
- package/landmark.mjs +0 -11
- package/lib/ast.mjs +94 -0
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/path.mjs +2 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +142 -0
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +334 -0
- package/lint-escape-hatch.mjs +93 -56
- package/lists.mjs +197 -0
- package/loading.mjs +156 -164
- package/naming.mjs +149 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +430 -0
- package/package.json +15 -6
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +174 -160
- package/shape-slot.mjs +22 -21
- package/size-and-state-budget.mjs +214 -0
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +318 -0
- package/status-colors.mjs +139 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +259 -268
- package/translation.mjs +295 -122
- package/transport.mjs +752 -0
- package/type-safety.mjs +147 -74
- package/typography.mjs +78 -83
- package/vendor-boundary.mjs +5 -27
- package/lint-adoption.mjs +0 -116
package/file-layout.mjs
CHANGED
|
@@ -6,36 +6,59 @@
|
|
|
6
6
|
* a constants folder, a utils folder and three hand-copied resting shapes, at which point the
|
|
7
7
|
* screen is a second codebase with its own private vocabulary that nobody else can reuse.
|
|
8
8
|
*
|
|
9
|
-
* Every rule here
|
|
10
|
-
* exact. The cost of that choice is stated where it
|
|
11
|
-
* a helper, only a folder from a folder, so each rule below names the
|
|
12
|
-
* to rather than merely refusing.
|
|
9
|
+
* Every rule here asks the HFS slot view where the file sits (slot, layer kind, role, tier) rather than
|
|
10
|
+
* reading the contents, which is what makes them cheap and exact. The cost of that choice is stated where it
|
|
11
|
+
* bites: a slot cannot tell a component from a helper, only a folder from a folder, so each rule below names the
|
|
12
|
+
* destination it sends things to rather than merely refusing.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
16
|
+
import { classOf, fileOf, inSlot, isComponentFile, isSpecFile, kindOfFile, roleOfFile, tierOfFile } from "./lib/scope.mjs"
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/(?:components|features)\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
18
|
+
/** The path segments of the linted file below the folder of its owner (the slot's `root`), directories only. */
|
|
19
|
+
const dirsBelowRoot = (context) => {
|
|
20
|
+
const { root } = classOf(context)
|
|
21
|
+
const relative = hfsOf(context).relative(fileOf(context))
|
|
22
|
+
return relative.slice(root.length + 1).split("/").slice(0, -1)
|
|
23
|
+
}
|
|
25
24
|
|
|
26
|
-
/** The
|
|
27
|
-
const
|
|
25
|
+
/** The folder segment that sits where a component layer folder is expected but is not one, or null: `components/shells/`, `src/shells/`. */
|
|
26
|
+
const unknownLayerFolder = (context) => {
|
|
27
|
+
const hfs = hfsOf(context)
|
|
28
|
+
const file = fileOf(context)
|
|
29
|
+
const found = hfs.classify(file)
|
|
30
|
+
const relative = hfs.relative(file)
|
|
31
|
+
if (found.status === "no-slot" && found.nearest?.slot === "fe.components") {
|
|
32
|
+
const prefix = found.nearest.matchedPrefix
|
|
33
|
+
return relative.startsWith(`${prefix}/`) ? (relative.slice(prefix.length + 1).split("/")[0] ?? null) : null
|
|
34
|
+
}
|
|
35
|
+
if (found.slot === "fe.package.ui" && !found.kind) {
|
|
36
|
+
const [folder, layer, ...rest] = relative.slice(found.root.length + 1).split("/")
|
|
37
|
+
return folder === "src" && rest.length > 0 ? layer : null
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
28
41
|
|
|
29
|
-
/** Folders
|
|
30
|
-
const NON_COMPONENT_FOLDERS =
|
|
42
|
+
/** Folders whose contents are not component code, whatever they are nested inside. */
|
|
43
|
+
const NON_COMPONENT_FOLDERS = new Set(["constants", "utils", "types", "hooks"])
|
|
31
44
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The surface folder a feature-kind file sits in: its kind (`pages`, `layouts`, `overlays`), the surface name and the
|
|
47
|
+
* file's path inside it. A page or layout owner IS the surface folder; an overlay owner is its category and the
|
|
48
|
+
* surface is the folder below it.
|
|
49
|
+
*/
|
|
50
|
+
const surfaceFolder = (context) => {
|
|
51
|
+
if (!inSlot(context, "fe.feature")) return null
|
|
52
|
+
const { kind, bindings, root } = classOf(context)
|
|
53
|
+
const below = hfsOf(context).relative(fileOf(context)).slice(root.length + 1).split("/")
|
|
54
|
+
if (kind === "overlays") return below.length > 1 ? { tier: kind, name: below[0], rest: below.slice(1).join("/") } : null
|
|
55
|
+
return { tier: kind, name: bindings.name, rest: below.join("/") }
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/** A file a surface folder may hold: one of the slot's roles (entry, drawing, styles) or a `.spec.` twin, directly in the folder. */
|
|
59
|
+
const belongsInSurfaceFolder = (context, folder) =>
|
|
60
|
+
!folder.rest.includes("/") && (roleOfFile(context) !== null || (isSpecFile(folder.rest) && folder.rest.includes(".spec.")))
|
|
61
|
+
|
|
39
62
|
// -- FILE-2 --------------------------------------------------------------------------------------
|
|
40
63
|
|
|
41
64
|
/** A page, layout or overlay folder holds its two halves and its colocated class-name module. */
|
|
@@ -50,8 +73,8 @@ export const surfaceFolderTwoFilesOnly = {
|
|
|
50
73
|
},
|
|
51
74
|
},
|
|
52
75
|
create(context) {
|
|
53
|
-
const folder = surfaceFolder(context
|
|
54
|
-
if (!folder ||
|
|
76
|
+
const folder = surfaceFolder(context)
|
|
77
|
+
if (!folder || belongsInSurfaceFolder(context, folder)) return {}
|
|
55
78
|
return {
|
|
56
79
|
Program(node) {
|
|
57
80
|
context.report({ node, messageId: "extra", data: folder })
|
|
@@ -70,15 +93,19 @@ export const noHelperFolderInComponents = {
|
|
|
70
93
|
schema: [],
|
|
71
94
|
messages: {
|
|
72
95
|
helper:
|
|
73
|
-
"`{{kind}}/` under
|
|
96
|
+
"`{{kind}}/` under a component owner - this is not component code, so it does not live in the component tree. A fetch is a `hooks/`, a pure function is a `modules/utils/`, a shape is a `modules/types/`, copy or a config map is a `resources/`. Left here it stays invisible to everyone who would have reused it, so the second author writes it again and the two drift.",
|
|
74
97
|
},
|
|
75
98
|
},
|
|
76
99
|
create(context) {
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
if (!isComponentFile(context)) return {}
|
|
101
|
+
const dirs = dirsBelowRoot(context)
|
|
102
|
+
// a package owner sits at the package root, so its `src/<layer>/<name>` prefix is not part of the owner
|
|
103
|
+
const inside = inSlot(context, "fe.package.ui") ? dirs.slice(3) : dirs
|
|
104
|
+
const folder = inside.find((name) => NON_COMPONENT_FOLDERS.has(name))
|
|
105
|
+
if (!folder) return {}
|
|
79
106
|
return {
|
|
80
107
|
Program(node) {
|
|
81
|
-
context.report({ node, messageId: "helper", data: { kind:
|
|
108
|
+
context.report({ node, messageId: "helper", data: { kind: folder } })
|
|
82
109
|
},
|
|
83
110
|
}
|
|
84
111
|
},
|
|
@@ -98,9 +125,9 @@ export const exportMatchesFolder = {
|
|
|
98
125
|
},
|
|
99
126
|
},
|
|
100
127
|
create(context) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
if (roleOfFile(context) !== "entry" || kindOfFile(context) === null) return {}
|
|
129
|
+
const folder = hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
|
|
130
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(folder)) return {}
|
|
104
131
|
const names = new Set()
|
|
105
132
|
const belongsToFamily = (name) =>
|
|
106
133
|
name === folder || (name.startsWith(folder) && name.length > folder.length && /^[A-Z]/.test(name.slice(folder.length)))
|
|
@@ -162,18 +189,28 @@ export const noRuntimeNamespace = {
|
|
|
162
189
|
|
|
163
190
|
// -- FILE-5 --------------------------------------------------------------------------------------
|
|
164
191
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Whether the repository declares the shared UI package (slot `fe.package.ui` is opt-in): the classification of a file
|
|
194
|
+
* of the package's own root is `owned` only then.
|
|
195
|
+
*/
|
|
196
|
+
const uiPackageDeclared = (hfs) => {
|
|
197
|
+
const root = hfs.slot("fe.package.ui").path.replace(/<[^>]+>/g, "probe").replace(/\/$/, "")
|
|
198
|
+
return hfs.classify(`${root}/package.json`).status === "owned"
|
|
199
|
+
}
|
|
167
200
|
|
|
168
|
-
/**
|
|
169
|
-
const
|
|
201
|
+
/** The layer folders that know a feature: those a component owner or a feature owner has and the shared package does not. */
|
|
202
|
+
const featureTiers = (hfs) => {
|
|
203
|
+
const shared = new Set(hfs.slot("fe.package.ui").kinds)
|
|
204
|
+
return new Set([...hfs.slot("fe.components").layers, ...hfs.slot("fe.feature").kinds].filter((name) => !shared.has(name)))
|
|
205
|
+
}
|
|
170
206
|
|
|
171
207
|
/**
|
|
172
208
|
* In a monorepo, the shared package stops below the block.
|
|
173
209
|
*
|
|
174
|
-
* WHY
|
|
210
|
+
* WHY THE SLOT IS ENOUGH HERE. The feature line is already drawn by the layer: a leaf knows
|
|
175
211
|
* no domain and a block is a domain sentence, so which side of the workspace each belongs on
|
|
176
|
-
* follows from the
|
|
212
|
+
* follows from the layer the slot reports. The layers a shared package may hold are the `kinds` of
|
|
213
|
+
* `fe.package.ui`; an app holds the shared ones only when the repository declares that package.
|
|
177
214
|
*
|
|
178
215
|
* WHAT IT COST TO LEARN. A shared package carried one `blocks/FleetRow/` while its own header
|
|
179
216
|
* insisted that "a block carries feature meaning and therefore belongs to the app that owns the
|
|
@@ -196,16 +233,18 @@ export const monorepoTierBelongsToItsSide = {
|
|
|
196
233
|
},
|
|
197
234
|
},
|
|
198
235
|
create(context) {
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
const
|
|
236
|
+
const hfs = hfsOf(context)
|
|
237
|
+
const folder = unknownLayerFolder(context)
|
|
238
|
+
const inPackage = inSlot(context, "fe.package.ui") && folder !== null && featureTiers(hfs).has(folder) ? folder : null
|
|
239
|
+
const tier = kindOfFile(context)
|
|
240
|
+
const inApp = inSlot(context, "fe.components") && tier !== null && hfs.slot("fe.package.ui").kinds.includes(tier) && uiPackageDeclared(hfs) ? tier : null
|
|
202
241
|
return {
|
|
203
242
|
Program(node) {
|
|
204
243
|
if (inPackage) {
|
|
205
|
-
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage
|
|
244
|
+
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage } })
|
|
206
245
|
return
|
|
207
246
|
}
|
|
208
|
-
if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp
|
|
247
|
+
if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp } })
|
|
209
248
|
},
|
|
210
249
|
}
|
|
211
250
|
},
|
|
@@ -213,25 +252,6 @@ export const monorepoTierBelongsToItsSide = {
|
|
|
213
252
|
|
|
214
253
|
// -- FILE-6 --------------------------------------------------------------------------------------
|
|
215
254
|
|
|
216
|
-
/** Anything under a routing tree, in either workspace shape. */
|
|
217
|
-
const ROUTE_TREE = /\/src\/app\/(.+)$/
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* What a routing tree is allowed to contain.
|
|
221
|
-
*
|
|
222
|
-
* Next decides these names, not this law: `page`, `layout`, `template`, `loading`, `error`,
|
|
223
|
-
* `not-found`, `global-error`, `default`, `route`, `sitemap`, `robots`, `manifest`, `opengraph-image`
|
|
224
|
-
* and `icon` are framework slots. Two more are admitted because the root layout mounts them and
|
|
225
|
-
* there is nowhere else they could go: `providers`, the client boundary above every route, and
|
|
226
|
-
* `globals`, the stylesheet the document imports. Everything else in that folder is a file somebody
|
|
227
|
-
* put there.
|
|
228
|
-
*/
|
|
229
|
-
const ROUTE_TREE_FILES =
|
|
230
|
-
/^(?:page|layout|template|loading|error|not-found|global-error|default|route|providers|globals|middleware|sitemap|robots|manifest|opengraph-image|twitter-image|apple-icon|icon|favicon)(?:\.[a-z-]+)?\.(?:tsx?|jsx?|css|json)$/
|
|
231
|
-
|
|
232
|
-
/** `app/api/**` is server code, not a screen; `_private/**` is Next's own opt-out folder. */
|
|
233
|
-
const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
|
|
234
|
-
|
|
235
255
|
/**
|
|
236
256
|
* A test sitting beside the thing it tests.
|
|
237
257
|
*
|
|
@@ -242,10 +262,9 @@ const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
|
|
|
242
262
|
*
|
|
243
263
|
* The name is deliberately not required to match `page` or `layout`. A route's tests split by
|
|
244
264
|
* CONCERN - what the screen renders, who may reach it, where the boundary sits - and forcing them
|
|
245
|
-
* into one `page.spec.tsx` would buy nothing but a longer file.
|
|
265
|
+
* into one `page.spec.tsx` would buy nothing but a longer file. A spec is named `.spec.` by the slot
|
|
266
|
+
* definitions themselves, so that stays a file-name test.
|
|
246
267
|
*/
|
|
247
|
-
const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
248
|
-
|
|
249
268
|
/**
|
|
250
269
|
* A file under `app/` names which page renders at which URL, and does nothing else.
|
|
251
270
|
*
|
|
@@ -255,9 +274,9 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
|
255
274
|
* typecheck, four sealed screenshots and an approval, and arrived at the edge of a production write
|
|
256
275
|
* with every gate green - because every gate was reading rules, and this one was only prose.
|
|
257
276
|
*
|
|
258
|
-
* WHY IT READS THE
|
|
277
|
+
* WHY IT READS THE SLOT AND NOT THE CONTENTS. "Drawing" is not a property this can measure: a
|
|
259
278
|
* route that mounts one component and a route that arranges six both return JSX. What CAN be
|
|
260
|
-
* measured exactly is whether a file in the routing tree is one of the framework's own slots. A page
|
|
279
|
+
* measured exactly is whether a file in the routing tree (slot `fe.route`) is one of the framework's own slots. A page
|
|
261
280
|
* owner is a component, a component has a name, and a named component belongs in the tier that
|
|
262
281
|
* groups it with its siblings - `components/pages/<Name>/`, where the next author looks for it.
|
|
263
282
|
*
|
|
@@ -267,7 +286,7 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
|
267
286
|
export const routeTreeHoldsRoutesOnly = {
|
|
268
287
|
meta: {
|
|
269
288
|
type: "problem",
|
|
270
|
-
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name
|
|
289
|
+
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name>/`." },
|
|
271
290
|
schema: [],
|
|
272
291
|
messages: {
|
|
273
292
|
stray:
|
|
@@ -275,14 +294,11 @@ export const routeTreeHoldsRoutesOnly = {
|
|
|
275
294
|
},
|
|
276
295
|
},
|
|
277
296
|
create(context) {
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
281
|
-
const rest =
|
|
282
|
-
if (ROUTE_TREE_EXEMPT.test(rest)) return {}
|
|
297
|
+
if (!inSlot(context, "fe.route")) return {}
|
|
298
|
+
const verdict = hfsOf(context).allows(fileOf(context))
|
|
299
|
+
if (!verdict || verdict.allowed) return {}
|
|
300
|
+
const rest = verdict.relative
|
|
283
301
|
const basename = rest.slice(rest.lastIndexOf("/") + 1)
|
|
284
|
-
if (ROUTE_TREE_TEST.test(basename)) return {}
|
|
285
|
-
if (ROUTE_TREE_FILES.test(basename)) return {}
|
|
286
302
|
return {
|
|
287
303
|
Program(node) {
|
|
288
304
|
context.report({ node, messageId: "stray", data: { rest, basename } })
|
|
@@ -291,9 +307,6 @@ export const routeTreeHoldsRoutesOnly = {
|
|
|
291
307
|
},
|
|
292
308
|
}
|
|
293
309
|
|
|
294
|
-
/** The rules this law contributes to the plugin. */
|
|
295
|
-
const TIER_PATH = /\/(?:src\/components|src\/features|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/|features\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
|
|
296
|
-
|
|
297
310
|
function propertyName(node) {
|
|
298
311
|
if (node.key?.type === "Identifier") return node.key.name
|
|
299
312
|
if (node.key?.type === "Literal") return node.key.value
|
|
@@ -312,8 +325,8 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
312
325
|
},
|
|
313
326
|
},
|
|
314
327
|
create(context) {
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
328
|
+
const tier = kindOfFile(context)
|
|
329
|
+
if (tier === null) return {}
|
|
317
330
|
const expected = {
|
|
318
331
|
blocks: "block",
|
|
319
332
|
branches: "branch",
|
|
@@ -322,7 +335,7 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
322
335
|
leaves: "leaf",
|
|
323
336
|
overlays: "overlay",
|
|
324
337
|
pages: "page",
|
|
325
|
-
}[
|
|
338
|
+
}[tier]
|
|
326
339
|
return {
|
|
327
340
|
ExportNamedDeclaration(node) {
|
|
328
341
|
const declaration = node.declaration
|
|
@@ -336,7 +349,7 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
336
349
|
property.type === "Property" && !property.computed && propertyName(property) === "shape")
|
|
337
350
|
const shape = shapeProperty?.value?.type === "Literal" ? shapeProperty.value.value : null
|
|
338
351
|
if (typeof shape === "string" && shape !== expected) {
|
|
339
|
-
context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier
|
|
352
|
+
context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier, shape } })
|
|
340
353
|
}
|
|
341
354
|
}
|
|
342
355
|
},
|
|
@@ -344,8 +357,6 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
344
357
|
},
|
|
345
358
|
}
|
|
346
359
|
|
|
347
|
-
const SHELL_TIER = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/shells\//
|
|
348
|
-
|
|
349
360
|
/** `shells/` no longer exists; vendor mechanics are closed named branches. */
|
|
350
361
|
export const noShellTier = {
|
|
351
362
|
meta: {
|
|
@@ -358,16 +369,13 @@ export const noShellTier = {
|
|
|
358
369
|
},
|
|
359
370
|
},
|
|
360
371
|
create(context) {
|
|
361
|
-
if (
|
|
372
|
+
if (unknownLayerFolder(context) !== "shells") return {}
|
|
362
373
|
return { Program(node) { context.report({ node, messageId: "shell" }) } }
|
|
363
374
|
},
|
|
364
375
|
}
|
|
365
376
|
|
|
366
377
|
// -- FILE-9 --------------------------------------------------------------------------------------
|
|
367
378
|
|
|
368
|
-
const UNIT_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
369
|
-
const SEPARATE_FRONTEND_TEST_TREE = /\/(?:src\/tests|tests?\/unit|e2e)(?:\/|$)/
|
|
370
|
-
|
|
371
379
|
/** Frontend units are `.spec.` twins beside the source they exercise. */
|
|
372
380
|
export const unitTestColocated = {
|
|
373
381
|
meta: {
|
|
@@ -380,13 +388,13 @@ export const unitTestColocated = {
|
|
|
380
388
|
},
|
|
381
389
|
},
|
|
382
390
|
create(context) {
|
|
383
|
-
const file =
|
|
384
|
-
|
|
385
|
-
|
|
391
|
+
const file = fileOf(context)
|
|
392
|
+
if (!isSpecFile(file)) return {}
|
|
393
|
+
const name = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1)
|
|
386
394
|
return {
|
|
387
395
|
Program(node) {
|
|
388
|
-
if (
|
|
389
|
-
if (
|
|
396
|
+
if (name.includes(".test.")) context.report({ node, messageId: "suffix", data: { name } })
|
|
397
|
+
if (tierOfFile(context) === "e2e") context.report({ node, messageId: "bucket", data: { path: hfsOf(context).relative(file) } })
|
|
390
398
|
},
|
|
391
399
|
}
|
|
392
400
|
},
|
|
@@ -417,10 +425,6 @@ const ROUTE_SLOT_NAME = {
|
|
|
417
425
|
"not-found": "NotFound",
|
|
418
426
|
}
|
|
419
427
|
|
|
420
|
-
const ROUTE_SLOT_FILE = new RegExp(
|
|
421
|
-
`^(?:${Object.keys(ROUTE_SLOT_NAME).join("|")})(?:\\.[a-z-]+)?\\.tsx$`,
|
|
422
|
-
)
|
|
423
|
-
|
|
424
428
|
export const routeSlotFixedName = {
|
|
425
429
|
meta: {
|
|
426
430
|
type: "problem",
|
|
@@ -436,14 +440,11 @@ export const routeSlotFixedName = {
|
|
|
436
440
|
},
|
|
437
441
|
},
|
|
438
442
|
create(context) {
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
if (!
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
const slot = basename.match(ROUTE_SLOT_FILE)
|
|
445
|
-
if (!slot) return {}
|
|
446
|
-
const expected = ROUTE_SLOT_NAME[slot[0].split(".")[0]]
|
|
443
|
+
if (!inSlot(context, "fe.route")) return {}
|
|
444
|
+
const expected = ROUTE_SLOT_NAME[roleOfFile(context)]
|
|
445
|
+
if (!expected) return {}
|
|
446
|
+
const file = hfsOf(context).relative(fileOf(context))
|
|
447
|
+
const basename = file.slice(file.lastIndexOf("/") + 1)
|
|
447
448
|
let seen = false
|
|
448
449
|
const report = (node, actual) =>
|
|
449
450
|
context.report({
|
package/formatting.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `formatting.md` (HFS R59 `FE_I18N_PLACEMENT`, sub-check `FE_I18N_FORMATTER`).
|
|
3
|
+
*
|
|
4
|
+
* THE LOCALE HAS ONE OWNER. `next-intl` knows the reader's locale and time zone and hands them to
|
|
5
|
+
* `useFormatter()` / `getFormatter()`. Every other way of turning a number or a date into text either
|
|
6
|
+
* guesses the locale (`toLocaleString()` with no argument uses the SERVER's on the server and the
|
|
7
|
+
* browser's on the client, which is the hydration mismatch that makes a price flicker), hard-codes
|
|
8
|
+
* one (`"en-US"` in a Vietnamese product), or builds a private formatter that ignores the app's
|
|
9
|
+
* currency and date styles. Money and dates are the two places a wrong format is a wrong fact.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
|
|
12
|
+
* `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
|
|
13
|
+
* substitution in a template; and the date libraries (`moment`, `dayjs`, `date-fns`, `luxon`).
|
|
14
|
+
* `modules/i18n/**` is exempt: it is where the formatters are configured.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
|
|
18
|
+
|
|
19
|
+
/** The locale-sensitive `Date` and `Number` methods. */
|
|
20
|
+
const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
|
|
21
|
+
|
|
22
|
+
/** The `Intl` formatters a product must not build itself. */
|
|
23
|
+
const INTL_FORMATTERS = new Set(["NumberFormat", "DateTimeFormat", "RelativeTimeFormat", "ListFormat", "PluralRules"])
|
|
24
|
+
|
|
25
|
+
/** Date libraries that format on their own locale. */
|
|
26
|
+
const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs|date-fns(?:\/.+)?|luxon)$/
|
|
27
|
+
|
|
28
|
+
/** A currency mark or code beside a substitution. */
|
|
29
|
+
const CURRENCY = /[₫€£¥]|\b(?:VND|USD|EUR)\b/
|
|
30
|
+
|
|
31
|
+
/** True when `node` is displayed: it sits in a JSX expression or a template literal. */
|
|
32
|
+
const isDisplayed = (node) => {
|
|
33
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
34
|
+
if (current.type === "JSXExpressionContainer" || current.type === "TemplateLiteral") return true
|
|
35
|
+
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression" || current.type === "FunctionDeclaration") return false
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Numbers, money and dates are formatted by the `next-intl` formatter. */
|
|
41
|
+
export const useIntlFormatter = {
|
|
42
|
+
meta: {
|
|
43
|
+
type: "problem",
|
|
44
|
+
docs: { description: "Format numbers, money and dates with `useFormatter` / `getFormatter` from `next-intl`." },
|
|
45
|
+
schema: [],
|
|
46
|
+
messages: {
|
|
47
|
+
method:
|
|
48
|
+
"`{{name}}` formats with a locale this file does not own: with no argument it is the server's on the server and the browser's in the browser (a price that flickers on hydration), with a string it is one language for every reader. Use `useFormatter().number(...)` / `.dateTime(...)` (or `getFormatter()` on the server).",
|
|
49
|
+
intl:
|
|
50
|
+
"`new Intl.{{name}}` builds a private formatter that ignores the app's locale, time zone and named formats. Use the `next-intl` formatter, and put a shared format in `modules/i18n` once.",
|
|
51
|
+
fixed:
|
|
52
|
+
"`.toFixed()` in displayed text writes a number with a dot decimal separator and no grouping to every reader. Use `useFormatter().number(value, { maximumFractionDigits })`.",
|
|
53
|
+
currency:
|
|
54
|
+
"A currency mark glued to a substitution in a template writes the symbol on the wrong side for half of the locales and skips grouping. Use `useFormatter().number(value, { style: \"currency\", currency })`.",
|
|
55
|
+
library:
|
|
56
|
+
"`{{name}}` formats dates on its own locale data, next to `next-intl`'s. One library decides the language of a date: use the `next-intl` formatter (`dateTime`, `relativeTime`).",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
create(context) {
|
|
60
|
+
if (isSpecFile(fileOf(context)) || inSlot(context, "fe.modules.i18n")) return {}
|
|
61
|
+
return {
|
|
62
|
+
ImportDeclaration(node) {
|
|
63
|
+
const source = String(node.source.value)
|
|
64
|
+
if (DATE_LIBRARIES.test(source)) context.report({ node, messageId: "library", data: { name: source } })
|
|
65
|
+
},
|
|
66
|
+
CallExpression(node) {
|
|
67
|
+
const callee = node.callee
|
|
68
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
|
|
69
|
+
const name = callee.property.name
|
|
70
|
+
if (LOCALE_METHODS.has(name)) context.report({ node, messageId: "method", data: { name } })
|
|
71
|
+
else if (name === "toFixed" && isDisplayed(node)) context.report({ node, messageId: "fixed" })
|
|
72
|
+
},
|
|
73
|
+
NewExpression(node) {
|
|
74
|
+
const callee = node.callee
|
|
75
|
+
if (
|
|
76
|
+
callee.type === "MemberExpression" &&
|
|
77
|
+
callee.object.type === "Identifier" &&
|
|
78
|
+
callee.object.name === "Intl" &&
|
|
79
|
+
callee.property.type === "Identifier" &&
|
|
80
|
+
INTL_FORMATTERS.has(callee.property.name)
|
|
81
|
+
) {
|
|
82
|
+
context.report({ node, messageId: "intl", data: { name: callee.property.name } })
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
TemplateLiteral(node) {
|
|
86
|
+
if (node.expressions.length === 0) return
|
|
87
|
+
const glued = node.quasis.some((quasi, index) => {
|
|
88
|
+
const text = quasi.value.cooked ?? ""
|
|
89
|
+
if (index < node.expressions.length && text.endsWith("$")) return true
|
|
90
|
+
return CURRENCY.test(text)
|
|
91
|
+
})
|
|
92
|
+
if (glued) context.report({ node, messageId: "currency" })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The rules this law contributes to the plugin. */
|
|
99
|
+
export const rules = {
|
|
100
|
+
"use-intl-formatter": useIntlFormatter,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every rule is an error. */
|
|
104
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/grammar-boundary.mjs
CHANGED
|
@@ -1,16 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The boundary between product code and `@starci/grammar`.
|
|
3
3
|
*
|
|
4
|
-
* Grammar is a business-neutral HeroUI-backed component package. Product code
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Grammar is a business-neutral HeroUI-backed component package. Product code composes its components with ordinary React
|
|
5
|
+
* children and typed props. A page's structure and text are made of those components, not of raw HTML: a raw `<section>`, `<p>`
|
|
6
|
+
* or `<ul>` carries no density, no focus or landmark contract and no dark value from the design system, and the class names
|
|
7
|
+
* written by hand to compensate are where a second visual language starts.
|
|
8
|
+
*
|
|
9
|
+
* Which tags are refused is decided by the package: a tag is in the list only when `@starci/grammar` exports a component that
|
|
10
|
+
* renders it (`GRAMMAR_OWNERS` below, each entry verified against the package source by the twin test). A tag the grammar has no
|
|
11
|
+
* component for (`div`, `br`, `aside`, `article`) is not refused: there is nothing to compose instead, and refusing it would
|
|
12
|
+
* demand a workaround. When the grammar gains a component for one, the tag joins the list.
|
|
13
|
+
*
|
|
14
|
+
* WHERE IT APPLIES. App source that composes the grammar (slots `fe.feature`, `fe.components`, `fe.route`, `fe.hooks`) and any
|
|
15
|
+
* workspace package except the `<family>-ui` package (`fe.package.ui`), whose components are the renderers and are the one place
|
|
16
|
+
* a raw tag belongs. Spec files render raw HTML as a harness and are not judged. A file no slot owns is not judged either: the
|
|
17
|
+
* rule refuses to guess a scope.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
21
|
+
import { isSpecFile } from "./lib/scope.mjs"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The intrinsic elements of page structure and text, each with the grammar components that render it.
|
|
25
|
+
* Read against packages/grammar/src (`export const <Component>`); `grammar-boundary.test.mjs` fails when one is not exported.
|
|
26
|
+
*/
|
|
27
|
+
export const GRAMMAR_OWNERS = Object.freeze({
|
|
28
|
+
h1: "Heading", h2: "Heading", h3: "Heading", h4: "Heading", h5: "Heading", h6: "Heading",
|
|
29
|
+
p: "Text", span: "Text",
|
|
30
|
+
hr: "Divider",
|
|
31
|
+
label: "Label",
|
|
32
|
+
form: "Form",
|
|
33
|
+
fieldset: "Fieldset",
|
|
34
|
+
dl: "DescriptionList", dt: "DescriptionList", dd: "DescriptionList",
|
|
35
|
+
table: "DataTable", thead: "DataTable", tbody: "DataTable", tr: "DataTable", td: "DataTable", th: "DataTable",
|
|
36
|
+
ul: "SurfaceListCard or StaticStateRow", ol: "SurfaceListCard or StaticStateRow", li: "SurfaceListCard or StaticStateRow",
|
|
37
|
+
header: "SectionHeader or TopBar",
|
|
38
|
+
footer: "Footer",
|
|
39
|
+
nav: "Subnav, Breadcrumbs or NavigationFeatureNav",
|
|
40
|
+
section: "SurfaceCard",
|
|
41
|
+
main: "WorkspaceShell",
|
|
42
|
+
figure: "MediaFrame", figcaption: "MediaFrame",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** The slots of app source that compose the grammar: features, components, routes and hooks. */
|
|
46
|
+
const APP_SLOTS = new Set(["fe.feature", "fe.components", "fe.route", "fe.hooks"])
|
|
47
|
+
|
|
48
|
+
/** The slot of the package whose leaves ARE the renderers: the only place raw structural tags belong. */
|
|
49
|
+
const RENDERER_SLOT = "fe.package.ui"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True for a file that composes the grammar: app source (`fe.feature`, `fe.components`, `fe.route`, `fe.hooks`) and any
|
|
53
|
+
* workspace package but the `<family>-ui` package, whose components are the renderers.
|
|
7
54
|
*/
|
|
55
|
+
const composesGrammar = (context, file) => {
|
|
56
|
+
const hfs = hfsOf(context)
|
|
57
|
+
const slot = hfs.slotOf(file)
|
|
58
|
+
if (slot === null || slot === RENDERER_SLOT) return false
|
|
59
|
+
return APP_SLOTS.has(slot) || hfs.tierOf(file) === "package"
|
|
60
|
+
}
|
|
8
61
|
|
|
9
|
-
/** No
|
|
10
|
-
export const
|
|
62
|
+
/** No raw structural or text element in app source or in a package that composes the grammar. */
|
|
63
|
+
export const noRawStructuralElement = {
|
|
64
|
+
meta: {
|
|
65
|
+
type: "problem",
|
|
66
|
+
docs: { description: "Page structure and text are grammar components, not raw `div`-family HTML; only `fe.package.ui` renderers draw raw tags." },
|
|
67
|
+
schema: [],
|
|
68
|
+
messages: {
|
|
69
|
+
raw:
|
|
70
|
+
"A raw `<{{tag}}>`. Page structure and text are composed from the grammar (`@starci/grammar`, or the repository's `<family>-ui` package): use {{owner}}. A raw tag brings none of the design system's density, landmark and state contract, and the class names written to make up for it start a second visual language. If the grammar cannot express this structure, add the component to the grammar or the ui package rather than drawing it here.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
create(context) {
|
|
74
|
+
const file = context.filename || context.getFilename()
|
|
75
|
+
if (isSpecFile(file) || !composesGrammar(context, file)) return {}
|
|
76
|
+
return {
|
|
77
|
+
JSXOpeningElement(node) {
|
|
78
|
+
if (node.name.type !== "JSXIdentifier") return
|
|
79
|
+
const owner = Object.hasOwn(GRAMMAR_OWNERS, node.name.name) ? GRAMMAR_OWNERS[node.name.name] : null
|
|
80
|
+
if (owner !== null) context.report({ node, messageId: "raw", data: { tag: node.name.name, owner: `\`${owner}\`` } })
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
11
85
|
|
|
12
|
-
/**
|
|
13
|
-
export const
|
|
86
|
+
/** The rules this law contributes to the plugin. */
|
|
87
|
+
export const rules = {
|
|
88
|
+
"no-raw-structural-element": noRawStructuralElement,
|
|
89
|
+
}
|
|
14
90
|
|
|
15
|
-
/**
|
|
16
|
-
export const recommended = {}
|
|
91
|
+
/** Every rule is an error. */
|
|
92
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|