@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
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `client-boundary.md` (HFS R55, `FE_CLIENT_BOUNDARY`).
|
|
3
|
+
*
|
|
4
|
+
* SERVER FIRST. A route file is a server component; `"use client"` is a cost the app pays where a
|
|
5
|
+
* reader interacts, and it is paid once, at the top of the interactive subtree, not sprinkled
|
|
6
|
+
* wherever a hook happened to be needed. The measured alternative: 155 client files in one app,
|
|
7
|
+
* a client layout that shipped a 132 KB message catalog to every page, against 3 client files in a
|
|
8
|
+
* sibling app that did the same job server-first.
|
|
9
|
+
*
|
|
10
|
+
* The directive is allowed in exactly the places the framework or the interaction requires:
|
|
11
|
+
* - the `index.tsx` of an interactive block (or overlay), which is the connected half;
|
|
12
|
+
* - a leaf, whose interactivity is intrinsic (a menu, a tooltip trigger);
|
|
13
|
+
* - `error.tsx` and `global-error.tsx`, which Next requires to be client components.
|
|
14
|
+
* Everything below a client block is already on the client and needs no directive; everything
|
|
15
|
+
* above it is a server component and must not have one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import ts from "typescript"
|
|
19
|
+
import { classifyImport, fileOf, isProductSource, isSpecFile, kindOfFile, roleOfFile, slotOfFile, tierOfFile } from "./lib/scope.mjs"
|
|
20
|
+
import { typed } from "./lib/types.mjs"
|
|
21
|
+
|
|
22
|
+
/** The route roles that are client-only by the framework's own rule: `error` and `global-error`. */
|
|
23
|
+
const FRAMEWORK_CLIENT_ROLES = new Set(["error", "global-error"])
|
|
24
|
+
|
|
25
|
+
/** True when the program opens with the `"use client"` directive. */
|
|
26
|
+
const clientDirective = (program) =>
|
|
27
|
+
program.body.find(
|
|
28
|
+
(statement) => statement.type === "ExpressionStatement" && statement.directive === "use client",
|
|
29
|
+
) ?? null
|
|
30
|
+
|
|
31
|
+
/** The layers whose files are legitimately client: a block's entry, a leaf (its interaction is intrinsic) and, in a shared package, the interactive layers (branches and leaves; a composite is presentation). */
|
|
32
|
+
const isInteractionBoundary = (context) => {
|
|
33
|
+
const slot = slotOfFile(context)
|
|
34
|
+
const kind = kindOfFile(context)
|
|
35
|
+
const role = roleOfFile(context)
|
|
36
|
+
if (slot === "fe.components") return kind === "leaves" || (kind === "blocks" && role === "entry")
|
|
37
|
+
if (slot === "fe.feature") return kind === "overlays" && role === "entry"
|
|
38
|
+
if (slot === "fe.package.ui") return kind === "branches" || kind === "leaves"
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where the directive is a legitimate boundary. */
|
|
43
|
+
const isBoundary = (context) => (slotOfFile(context) === "fe.route" && FRAMEWORK_CLIENT_ROLES.has(roleOfFile(context))) || isInteractionBoundary(context)
|
|
44
|
+
|
|
45
|
+
/** The slot a file fills in the route tree (its role in `fe.route`, or the page or layout owner of a feature), when it fills one. */
|
|
46
|
+
const routeFill = (context) => {
|
|
47
|
+
if (slotOfFile(context) === "fe.route" && roleOfFile(context) !== null) return roleOfFile(context)
|
|
48
|
+
if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "pages") return "page owner"
|
|
49
|
+
if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "layouts") return "layout owner"
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `"use client"` only at an interaction boundary; pages and layouts stay server components. */
|
|
54
|
+
export const useClientOnlyAtBoundary = {
|
|
55
|
+
meta: {
|
|
56
|
+
type: "problem",
|
|
57
|
+
docs: { description: "`\"use client\"` appears only at a block index, a leaf, or a Next error boundary." },
|
|
58
|
+
schema: [],
|
|
59
|
+
messages: {
|
|
60
|
+
slot:
|
|
61
|
+
"`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`.",
|
|
62
|
+
elsewhere:
|
|
63
|
+
"`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, or on `error.tsx`/`global-error.tsx`. Below a client block the file is already client; above one it must stay a server component.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
create(context) {
|
|
67
|
+
if (isSpecFile(fileOf(context)) || isBoundary(context)) return {}
|
|
68
|
+
const slot = routeFill(context)
|
|
69
|
+
return {
|
|
70
|
+
Program(program) {
|
|
71
|
+
const directive = clientDirective(program)
|
|
72
|
+
if (!directive) return
|
|
73
|
+
if (slot) context.report({ node: directive, messageId: "slot", data: { slot } })
|
|
74
|
+
else context.report({ node: directive, messageId: "elsewhere" })
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -- FE-CLIENT-2 -----------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Node built-ins a browser bundle cannot carry. */
|
|
83
|
+
const NODE_BUILTINS = /^(?:node:.+|fs|fs\/promises|path|os|net|tls|http|https|http2|child_process|cluster|crypto|stream|zlib|dns|worker_threads)$/
|
|
84
|
+
|
|
85
|
+
/** A server API: the request-scoped Next APIs, the server half of next-intl, a Node built-in; null for anything else. */
|
|
86
|
+
const serverApiReason = (source) => {
|
|
87
|
+
if (source === "next/headers") return "`next/headers`"
|
|
88
|
+
if (source === "next/server") return "`next/server`"
|
|
89
|
+
if (source === "next-intl/server") return "`next-intl/server`"
|
|
90
|
+
if (NODE_BUILTINS.test(source)) return `\`${source}\``
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A server-only source: the marker package, a server API, a server reader (role `reader` of slot `fe.modules.api`). */
|
|
95
|
+
const serverOnlyReason = (context, source) => {
|
|
96
|
+
if (source === "server-only") return "`server-only`"
|
|
97
|
+
const api = serverApiReason(source)
|
|
98
|
+
if (api) return api
|
|
99
|
+
const target = classifyImport(context, source)
|
|
100
|
+
if (target?.slot === "fe.modules.api" && target.role === "reader") return `the server reader \`${source}\``
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A client component imports nothing that only exists on the server. */
|
|
105
|
+
export const clientNoServerImport = {
|
|
106
|
+
meta: {
|
|
107
|
+
type: "problem",
|
|
108
|
+
docs: { description: "A `\"use client\"` file does not import `server-only`, `next/headers`, a Node built-in or a server reader." },
|
|
109
|
+
schema: [],
|
|
110
|
+
messages: {
|
|
111
|
+
server:
|
|
112
|
+
"This client component imports {{what}}, which exists only on the server. The build either fails or, worse, pulls server code and its secrets toward the browser bundle. Read the data in a server component or a server reader and pass the result down as props, or call the app client through SWR.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
create(context) {
|
|
116
|
+
if (isSpecFile(fileOf(context))) return {}
|
|
117
|
+
let client = false
|
|
118
|
+
return {
|
|
119
|
+
Program(program) {
|
|
120
|
+
client = clientDirective(program) !== null
|
|
121
|
+
},
|
|
122
|
+
ImportDeclaration(node) {
|
|
123
|
+
if (!client || node.importKind === "type") return
|
|
124
|
+
const what = serverOnlyReason(context, String(node.source.value))
|
|
125
|
+
if (what) context.report({ node, messageId: "server", data: { what } })
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// -- FE-CLIENT-2b ----------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** The slots whose files are Next route files: server components by construction, so the framework decides, not a marker. */
|
|
134
|
+
const ROUTE_FILE_SLOTS = new Set(["fe.route", "fe.source-root-pinned"])
|
|
135
|
+
|
|
136
|
+
/** True when a statement is `import "server-only"`: an import with no bindings from the marker package. */
|
|
137
|
+
const isMarkerStatement = (statement) =>
|
|
138
|
+
statement?.type === "ImportDeclaration" && statement.specifiers.length === 0 && statement.source.value === "server-only"
|
|
139
|
+
|
|
140
|
+
/** True when a TypeScript source file opens with `import "server-only"`. */
|
|
141
|
+
const tsFileIsMarked = (file) => {
|
|
142
|
+
const first = file.statements[0]
|
|
143
|
+
return Boolean(first) && ts.isImportDeclaration(first) && !first.importClause && ts.isStringLiteral(first.moduleSpecifier) && first.moduleSpecifier.text === "server-only"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The source file an import specifier resolves to: the checker's module symbol, so path aliases, extensions and index
|
|
148
|
+
* files resolve as the compiler resolves them. Null for a declaration file, a package or an unresolved specifier.
|
|
149
|
+
*/
|
|
150
|
+
const resolvedSourceFile = (context, specifier) => {
|
|
151
|
+
const { checker, toTs } = typed(context)
|
|
152
|
+
const tsNode = toTs(specifier)
|
|
153
|
+
if (!tsNode) return null
|
|
154
|
+
const symbol = checker.getSymbolAtLocation(tsNode)
|
|
155
|
+
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
|
|
156
|
+
return declaration && ts.isSourceFile(declaration) && !declaration.isDeclarationFile ? declaration : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A module that uses a server-only API, or imports a module that is itself server-only, opens with `import "server-only"`. */
|
|
160
|
+
/** Component tiers: a component is judged by where client code reaches it, not by a marker. */
|
|
161
|
+
const COMPONENT_TIERS = new Set(["components", "feature"])
|
|
162
|
+
|
|
163
|
+
export const serverModuleMarksServerOnly = {
|
|
164
|
+
meta: {
|
|
165
|
+
type: "problem",
|
|
166
|
+
docs: { description: "A module importing `next/headers`, `next/server`, `next-intl/server`, a Node built-in or a server-only module starts with `import \"server-only\"`." },
|
|
167
|
+
schema: [],
|
|
168
|
+
messages: {
|
|
169
|
+
mark:
|
|
170
|
+
"This module imports {{what}}, which exists only on the server, but it does not start with `import \"server-only\"`. Without the marker nothing stops a client component from importing this module (directly or through a hook): the build then fails far from the cause, or ships server code toward the browser. Make `import \"server-only\"` the first statement of the file. Route files (`page`, `layout`, `route`, `proxy`) are server components by construction and need no marker.",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
create(context) {
|
|
174
|
+
if (isSpecFile(fileOf(context))) return {}
|
|
175
|
+
const slot = slotOfFile(context)
|
|
176
|
+
if (!slot || ROUTE_FILE_SLOTS.has(slot)) return {}
|
|
177
|
+
// A block or a page is a server or client COMPONENT, not a server module: whether client code reaches the server through it is
|
|
178
|
+
// the machine's multi-hop question (FE_CLIENT_REACHES_SERVER). The marker belongs to the modules that hold server work.
|
|
179
|
+
if (COMPONENT_TIERS.has(tierOfFile(context))) return {}
|
|
180
|
+
const reader = roleOfFile(context) === "reader"
|
|
181
|
+
let client = false
|
|
182
|
+
let marked = false
|
|
183
|
+
const uses = []
|
|
184
|
+
return {
|
|
185
|
+
Program(program) {
|
|
186
|
+
client = clientDirective(program) !== null
|
|
187
|
+
marked = isMarkerStatement(program.body[0])
|
|
188
|
+
},
|
|
189
|
+
"ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration"(node) {
|
|
190
|
+
if (!node.source || node.importKind === "type" || node.exportKind === "type") return
|
|
191
|
+
const onlyTypes = node.specifiers?.length > 0 && node.specifiers.every((specifier) => specifier.importKind === "type" || specifier.exportKind === "type")
|
|
192
|
+
if (onlyTypes) return
|
|
193
|
+
const specifier = String(node.source.value)
|
|
194
|
+
if (specifier === "server-only") return
|
|
195
|
+
const api = serverApiReason(specifier)
|
|
196
|
+
if (api) {
|
|
197
|
+
uses.push({ node, what: api })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const target = resolvedSourceFile(context, node.source)
|
|
201
|
+
if (target && tsFileIsMarked(target)) uses.push({ node, what: `\`${specifier}\` (a module that is itself server-only)` })
|
|
202
|
+
},
|
|
203
|
+
"Program:exit"() {
|
|
204
|
+
// a "use client" file that imports the server is client-no-server-import's finding, not a missing marker
|
|
205
|
+
if (client || marked) return
|
|
206
|
+
// A server reader (`read-*.ts`, role `reader` of the api slot) is server-only by its role, whatever it imports.
|
|
207
|
+
if (reader && uses.length === 0) context.report({ node: context.sourceCode.ast, messageId: "mark", data: { what: "the request (it is a server reader)" } })
|
|
208
|
+
for (const use of uses) context.report({ node: use.node, messageId: "mark", data: { what: use.what } })
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// -- FE-CLIENT-3 -----------------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/** Web storage exists only in the browser and only under `modules/`, which guards it. */
|
|
217
|
+
const STORAGE = new Set(["localStorage", "sessionStorage"])
|
|
218
|
+
|
|
219
|
+
/** Web storage is read and written only inside `modules/`. */
|
|
220
|
+
export const webStorageOnlyInModules = {
|
|
221
|
+
meta: {
|
|
222
|
+
type: "problem",
|
|
223
|
+
docs: { description: "`localStorage` and `sessionStorage` are touched only inside `modules/`." },
|
|
224
|
+
schema: [],
|
|
225
|
+
messages: {
|
|
226
|
+
storage:
|
|
227
|
+
"`{{name}}` here, outside `modules/`. Storage does not exist on the server and throws in private windows and when the quota is full, so a block that reads it during render breaks server rendering and hydrates differently from the HTML it received. Put the read and write behind a hook or a module that guards both (`typeof window`, try/catch) and let the block call that.",
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
create(context) {
|
|
231
|
+
// Product source outside the module tiers (foundation modules, the other modules, the api transport), which are where storage is guarded.
|
|
232
|
+
if (!isProductSource(context) || ["foundation", "modules", "transport"].includes(tierOfFile(context))) return {}
|
|
233
|
+
return {
|
|
234
|
+
MemberExpression(node) {
|
|
235
|
+
if (node.computed || node.property.type !== "Identifier" || !STORAGE.has(node.property.name)) return
|
|
236
|
+
if (node.object.type !== "Identifier" || (node.object.name !== "window" && node.object.name !== "globalThis")) return
|
|
237
|
+
context.report({ node, messageId: "storage", data: { name: node.property.name } })
|
|
238
|
+
},
|
|
239
|
+
Identifier(node) {
|
|
240
|
+
if (!STORAGE.has(node.name)) return
|
|
241
|
+
const parent = node.parent
|
|
242
|
+
if (parent.type === "MemberExpression" && parent.property === node) return
|
|
243
|
+
if (parent.type === "Property" && parent.key === node && !parent.shorthand) return
|
|
244
|
+
if (parent.type === "TSPropertySignature" || parent.type === "TSTypeAnnotation") return
|
|
245
|
+
context.report({ node, messageId: "storage", data: { name: node.name } })
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// -- FE-CLIENT-4 -----------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/** Raw HTML is set only on a `<script>`, where the framework has no other way to inline JSON-LD or the theme bootstrap. */
|
|
254
|
+
export const noDangerousHtml = {
|
|
255
|
+
meta: {
|
|
256
|
+
type: "problem",
|
|
257
|
+
docs: { description: "`dangerouslySetInnerHTML` only on `<script>`; never on a visible element." },
|
|
258
|
+
schema: [],
|
|
259
|
+
messages: {
|
|
260
|
+
html:
|
|
261
|
+
"`dangerouslySetInnerHTML` on `<{{tag}}>` writes a string into the page as markup, so any text from a user, a provider or a message catalogue becomes script. Render the content as elements. The one legitimate use is a `<script>` carrying JSON-LD or the theme bootstrap, from a constant this app wrote.",
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
create(context) {
|
|
265
|
+
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
266
|
+
return {
|
|
267
|
+
JSXAttribute(node) {
|
|
268
|
+
if (node.name.type !== "JSXIdentifier" || node.name.name !== "dangerouslySetInnerHTML") return
|
|
269
|
+
const opening = node.parent
|
|
270
|
+
const tag = opening.name.type === "JSXIdentifier" ? opening.name.name : "element"
|
|
271
|
+
if (tag !== "script") context.report({ node, messageId: "html", data: { tag } })
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The rules this law contributes to the plugin. */
|
|
278
|
+
export const rules = {
|
|
279
|
+
"use-client-only-at-boundary": useClientOnlyAtBoundary,
|
|
280
|
+
"client-no-server-import": clientNoServerImport,
|
|
281
|
+
"server-module-marks-server-only": serverModuleMarksServerOnly,
|
|
282
|
+
"web-storage-only-in-modules": webStorageOnlyInModules,
|
|
283
|
+
"no-dangerous-html": noDangerousHtml,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Every rule is an error. */
|
|
287
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/comments.mjs
CHANGED
|
@@ -1,190 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `comments.md`.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/** The
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
},
|
|
67
|
-
|
|
68
|
-
report(node, node.
|
|
69
|
-
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
* the two placements the message's wording admits.
|
|
139
|
-
*/
|
|
140
|
-
const marked = new Set()
|
|
141
|
-
for (const comment of source.getAllComments()) {
|
|
142
|
-
if (!OK_PRAGMA.test(comment.value)) continue
|
|
143
|
-
marked.add(comment.loc.start.line)
|
|
144
|
-
marked.add(comment.loc.end.line + 1)
|
|
145
|
-
}
|
|
146
|
-
return proseVisitors(context, (node, text) => {
|
|
147
|
-
if (!text || !SECOND_LANGUAGE_LETTER.test(text)) return
|
|
148
|
-
if (ENDONYM.test(text) || OK_PRAGMA.test(text)) return
|
|
149
|
-
if (marked.has(node.loc.start.line)) return
|
|
150
|
-
context.report({ node, messageId: "second" })
|
|
151
|
-
})
|
|
152
|
-
},
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// -- COMMENTS-4 ------------------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
/** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
|
|
158
|
-
export const noEmojiInSource = {
|
|
159
|
-
meta: {
|
|
160
|
-
type: "problem",
|
|
161
|
-
docs: { description: "No emoji in source authoring." },
|
|
162
|
-
schema: [],
|
|
163
|
-
messages: {
|
|
164
|
-
emoji:
|
|
165
|
-
"Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
create(context) {
|
|
169
|
-
if (isContentFile(context.filename || context.getFilename())) return {}
|
|
170
|
-
return proseVisitors(context, (node, text) => {
|
|
171
|
-
if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
|
|
172
|
-
})
|
|
173
|
-
},
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** The rules this law contributes to the plugin. */
|
|
177
|
-
export const rules = {
|
|
178
|
-
"require-export-jsdoc": requireExportJsdoc,
|
|
179
|
-
"no-second-language-in-source": noSecondLanguageInSource,
|
|
180
|
-
"no-emoji-in-source": noEmojiInSource,
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
185
|
-
*
|
|
186
|
-
* `require-export-jsdoc` is the one a repository adopting this with history should expect a count
|
|
187
|
-
* from - every undocumented export in the tree reports at once. The other two are usually already
|
|
188
|
-
* at zero, because a second language in source is noticed by readers long before a rule arrives.
|
|
189
|
-
*/
|
|
190
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `comments.md`.
|
|
3
|
+
*
|
|
4
|
+
* Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
|
|
5
|
+
* would miss the one in a log message, so it walks comments, identifiers, string literals, template
|
|
6
|
+
* chunks and JSX text alike - everywhere prose can hide in a source file.
|
|
7
|
+
*
|
|
8
|
+
* THE SECOND-LANGUAGE RULE IS GONE. It carried a `vn-ok: <reason>` pragma, an escape hatch that was
|
|
9
|
+
* used 490 times in one repository, and one escape is enough to turn a rule into a comment
|
|
10
|
+
* convention. Its job is now held by `no-hardcoded-copy` in `translation.mjs`, which has no
|
|
11
|
+
* pragma: user-facing text comes from a `next-intl` catalogue, and the catalogue is the only place
|
|
12
|
+
* a second language is content.
|
|
13
|
+
*
|
|
14
|
+
* The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
|
|
15
|
+
* IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
|
|
16
|
+
* judgement-based exception would be argued per file forever.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
|
|
20
|
+
|
|
21
|
+
/** The letters that mark the second language a source string must not carry outside a catalogue. */
|
|
22
|
+
export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extended pictographs, or a regional-indicator pair.
|
|
26
|
+
*
|
|
27
|
+
* Two tests rather than one character class, so the class stays free of joiners and combining marks
|
|
28
|
+
* - a single class covering both trips the misleading-character-class rule, and a rule that has to
|
|
29
|
+
* be silenced to exist is one nobody trusts.
|
|
30
|
+
*/
|
|
31
|
+
export const hasEmoji = (text) =>
|
|
32
|
+
typeof text === "string" &&
|
|
33
|
+
(/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
|
|
37
|
+
*
|
|
38
|
+
* Three kinds only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
|
|
39
|
+
* which are the product's other language; the e2e support tree (slot `fe.e2e-support`: fixtures and support),
|
|
40
|
+
* and specs, which reproduce real strings and would be testing something else if they translated them. There is no
|
|
41
|
+
* "copy module": a `resources/` folder of strings is copy that skipped the catalogue.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} context - The ESLint rule context.
|
|
44
|
+
* @returns {boolean} True when the linted file holds content rather than authoring.
|
|
45
|
+
*/
|
|
46
|
+
export const isContentFile = (context) => {
|
|
47
|
+
const file = fileOf(context)
|
|
48
|
+
return isSpecFile(file) || inSlot(context, "fe.e2e-support") || (inSlot(context, "fe.modules.i18n", "fe.package.i18n") && file.endsWith(".json"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Walk every place prose can hide, and hand each to one check. */
|
|
52
|
+
const proseVisitors = (context, report) => {
|
|
53
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
54
|
+
return {
|
|
55
|
+
Program() {
|
|
56
|
+
for (const comment of source.getAllComments()) report(comment, comment.value)
|
|
57
|
+
},
|
|
58
|
+
Identifier(node) {
|
|
59
|
+
report(node, node.name)
|
|
60
|
+
},
|
|
61
|
+
Literal(node) {
|
|
62
|
+
if (typeof node.value === "string") report(node, node.value)
|
|
63
|
+
},
|
|
64
|
+
TemplateElement(node) {
|
|
65
|
+
report(node, node.value && node.value.cooked)
|
|
66
|
+
},
|
|
67
|
+
JSXText(node) {
|
|
68
|
+
report(node, node.value)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// -- COMMENTS-1 ------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** Every export opens with a documentation block. */
|
|
76
|
+
export const requireExportJsdoc = {
|
|
77
|
+
meta: {
|
|
78
|
+
type: "suggestion",
|
|
79
|
+
docs: { description: "Exported declarations open with a documentation block." },
|
|
80
|
+
schema: [],
|
|
81
|
+
messages: {
|
|
82
|
+
jsdoc:
|
|
83
|
+
"`{{name}}` is exported with no documentation block. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
create(context) {
|
|
87
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
88
|
+
const hasBlock = (node) =>
|
|
89
|
+
source.getCommentsBefore(node).some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
|
|
90
|
+
const check = (node) => {
|
|
91
|
+
const declaration = node.declaration
|
|
92
|
+
if (!declaration) return
|
|
93
|
+
const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
|
|
94
|
+
if (!kinds.includes(declaration.type) || hasBlock(node)) return
|
|
95
|
+
const id =
|
|
96
|
+
declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
|
|
97
|
+
context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
|
|
98
|
+
}
|
|
99
|
+
return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -- COMMENTS-4 ------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
|
|
106
|
+
export const noEmojiInSource = {
|
|
107
|
+
meta: {
|
|
108
|
+
type: "problem",
|
|
109
|
+
docs: { description: "No emoji in source authoring." },
|
|
110
|
+
schema: [],
|
|
111
|
+
messages: {
|
|
112
|
+
emoji:
|
|
113
|
+
"Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
create(context) {
|
|
117
|
+
if (isContentFile(context)) return {}
|
|
118
|
+
return proseVisitors(context, (node, text) => {
|
|
119
|
+
if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The rules this law contributes to the plugin. */
|
|
125
|
+
export const rules = {
|
|
126
|
+
"require-export-jsdoc": requireExportJsdoc,
|
|
127
|
+
"no-emoji-in-source": noEmojiInSource,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
132
|
+
*
|
|
133
|
+
* `require-export-jsdoc` is the one a repository adopting this with history should expect a count
|
|
134
|
+
* from - every undocumented export in the tree reports at once. The other two are usually already
|
|
135
|
+
* at zero, because a second language in source is noticed by readers long before a rule arrives.
|
|
136
|
+
*/
|
|
137
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|