@starci/eslint-canon-fe 9.10.0 → 9.12.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 +10 -0
- package/dist/fe-stack.d.ts +5 -0
- package/dist/fe-stack.js +34 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +18 -2
- package/dist/lib/config.d.ts +13 -0
- package/dist/lib/config.js +18 -0
- package/dist/lib/hfs.d.ts +16 -0
- package/dist/lib/hfs.js +17 -1
- package/dist/lib/scope.d.ts +0 -8
- package/dist/lib/scope.js +0 -10
- package/dist/lib/why.d.ts +5 -0
- package/dist/lib/why.js +7 -2
- package/dist/next-conventions.d.ts +4 -4
- package/dist/next-conventions.js +9 -24
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +4 -28
- package/docs/next-conventions.md +8 -8
- package/package.json +1 -1
- package/runtime/bundle.json +1 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +9 -9
- package/runtime/knowledge/hfs/slots.yaml +168 -28
- package/runtime/scripts/hfs/architecture/config.mjs +25 -1
- package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
- package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
- package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +1 -1
- package/runtime/scripts/hfs/view.mjs +68 -0
- package/runtime/scripts/lib/declaration-surface.mjs +55 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.12.0 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Changed: the runtime copies carry R368's acceptance of the module-definition helpers, the module-per-transport confinement of the helpers and the `@starci/nest-kit` and `@starci/next-i18n` pins; `i18n-stack-in-one-module` refuses every product-source call of a next-intl layer builder (the stack is `@starci/next-i18n`'s).
|
|
6
|
+
- Added: `starciFePackagesConfig`, `loadPackagesHfs` and `scopeToPackages`: the rules of the front end over `packages/<pkg>/src/workbench/**` and `src/public/**` of the root workspace packages, over the package view of the app root, for the managed root `eslint.config.mjs`; the workbench contract stays with the back-end canon and the project-graph rules with the side folders.
|
|
7
|
+
- Changed: the runtime copy of the slot manifest carries the slots `app.packages`, `app.module-registry` and `app.packages-lint`.
|
|
8
|
+
|
|
9
|
+
## 9.11.0 - 2026-11-03
|
|
10
|
+
|
|
11
|
+
- Changed: `modules/auth` holds the front-end transport rules like `modules/api`: no auth door file takes axios or `fetch`, and no file keeps module-level mutable state (the access token lives in the `@starci/auth` client); the stack concern `auth` names `@starci/auth`.
|
|
12
|
+
|
|
3
13
|
## 9.10.0
|
|
4
14
|
|
|
5
15
|
- Added: `starci-fe/directive-first` (R369, `FE_DIRECTIVE_FIRST`) at error: a `"use client"` or `"use server"` directive is the first statement of its module; below an import it does nothing and `next build` refuses the file.
|
package/dist/fe-stack.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { type Rules, type Recommended } from "./lib/rule.js";
|
|
|
4
4
|
* No import (static, re-export, dynamic or `require`) of a package the fe-stack catalog forbids.
|
|
5
5
|
*/
|
|
6
6
|
export declare const feStackImport: import("./lib/rule.js").Rule<"forbidden", []>;
|
|
7
|
+
/**
|
|
8
|
+
* The SWR extension points belong to `@starci/next-data`: `pollingRestart` is the one middleware and `createViewerCache` the one
|
|
9
|
+
* binding of the viewer's cache, so an app that imports the `Middleware` or `ScopedMutator` type is writing a second one.
|
|
10
|
+
*/
|
|
11
|
+
export declare const swrExtensionFromPackage: import("./lib/rule.js").Rule<"own", []>;
|
|
7
12
|
/** The rules this law contributes to the plugin. */
|
|
8
13
|
export declare const rules: Rules;
|
|
9
14
|
/** Every rule is an error: a second library for a concern is the defect, not a style. */
|
package/dist/fe-stack.js
CHANGED
|
@@ -59,9 +59,43 @@ export const feStackImport = defineRule({
|
|
|
59
59
|
};
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
|
+
/** The SWR types an app needs only to write its own middleware or to bind the writes of a viewer's cache by hand. */
|
|
63
|
+
const PACKAGE_OWNED_SWR_TYPES = new Set(["Middleware", "ScopedMutator"]);
|
|
64
|
+
/**
|
|
65
|
+
* The SWR extension points belong to `@starci/next-data`: `pollingRestart` is the one middleware and `createViewerCache` the one
|
|
66
|
+
* binding of the viewer's cache, so an app that imports the `Middleware` or `ScopedMutator` type is writing a second one.
|
|
67
|
+
*/
|
|
68
|
+
export const swrExtensionFromPackage = defineRule({
|
|
69
|
+
meta: {
|
|
70
|
+
type: "problem",
|
|
71
|
+
docs: { description: "An app writes no SWR middleware and no cache binding of its own." },
|
|
72
|
+
schema: [],
|
|
73
|
+
messages: {
|
|
74
|
+
own: "`{{name}}` of `swr` is the type of an SWR extension the stack already ships. Use `pollingRestart`, `viewerSwrConfig` and `createViewerCache` of `@starci/next-data` (FE-DATA-SWR, FE-DATA-KEY) instead of writing a second one.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
create(context) {
|
|
78
|
+
if (!isProductSource(context))
|
|
79
|
+
return {};
|
|
80
|
+
return {
|
|
81
|
+
ImportDeclaration(node) {
|
|
82
|
+
if (node.source.value !== "swr")
|
|
83
|
+
return;
|
|
84
|
+
for (const specifier of node.specifiers) {
|
|
85
|
+
if (specifier.type !== "ImportSpecifier")
|
|
86
|
+
continue;
|
|
87
|
+
const name = specifier.imported.type === "Identifier" ? specifier.imported.name : String(specifier.imported.value);
|
|
88
|
+
if (PACKAGE_OWNED_SWR_TYPES.has(name))
|
|
89
|
+
context.report({ node: specifier, messageId: "own", data: { name } });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
});
|
|
62
95
|
/** The rules this law contributes to the plugin. */
|
|
63
96
|
export const rules = {
|
|
64
97
|
"fe-stack-import": feStackImport,
|
|
98
|
+
"swr-extension-from-package": swrExtensionFromPackage,
|
|
65
99
|
};
|
|
66
100
|
/** Every rule is an error: a second library for a concern is the defect, not a style. */
|
|
67
101
|
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
package/dist/index.d.ts
CHANGED
|
@@ -43,7 +43,8 @@ export declare const rules: Record<string, Rule>;
|
|
|
43
43
|
export declare const recommended: Record<string, "error" | "warn">;
|
|
44
44
|
/** Flat-config options that make inline directives ineffective rather than merely forbidden. */
|
|
45
45
|
export { linterOptions } from "./lib/config.js";
|
|
46
|
-
export { loadHfs } from "./lib/hfs.js";
|
|
46
|
+
export { loadHfs, loadPackagesHfs } from "./lib/hfs.js";
|
|
47
|
+
export { scopeToPackages } from "./lib/config.js";
|
|
47
48
|
/**
|
|
48
49
|
* The Vietnamese why of each rule that carries a catalogue code: `{ code, vi, fixVi }`.
|
|
49
50
|
*
|
|
@@ -81,6 +82,18 @@ export declare const reactHooksRules: (reactHooks: unknown) => Record<string, "e
|
|
|
81
82
|
export declare const starciFeConfig: ({ hfs }?: {
|
|
82
83
|
readonly hfs?: unknown;
|
|
83
84
|
}) => Linter.Config[];
|
|
85
|
+
/**
|
|
86
|
+
* The flat config of the front-end subpaths of an app's root workspace packages (`packages/<pkg>/src/workbench/**` and `src/public/**`), for the
|
|
87
|
+
* managed root `eslint.config.mjs`: `starciFePackagesConfig({ hfs: loadPackagesHfs(import.meta.url) })`, composed with the Grammar family's
|
|
88
|
+
* `...PackagesConfig`. The very rules of `starciFeConfig` over the package view, but the project-graph rules, which judge the import graph of a
|
|
89
|
+
* side folder that a package is not part of.
|
|
90
|
+
*
|
|
91
|
+
* @param input - The package view of the app root.
|
|
92
|
+
* @returns The flat config.
|
|
93
|
+
*/
|
|
94
|
+
export declare const starciFePackagesConfig: ({ hfs }?: {
|
|
95
|
+
readonly hfs?: unknown;
|
|
96
|
+
}) => Linter.Config[];
|
|
84
97
|
/**
|
|
85
98
|
* The flat config of a TypeScript front-end tree that is no HFS app (a runtime package, the harness UI):
|
|
86
99
|
* `export default starciPlainSourceConfig({ root: import.meta.dirname })`. It lints the tree's `src/` with types, with the inline-directive fence, the
|
package/dist/index.js
CHANGED
|
@@ -49,7 +49,7 @@ import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "
|
|
|
49
49
|
import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.js";
|
|
50
50
|
import { recommended as sonarParityRecommended, rules as sonarParityRules } from "./sonar-parity.js";
|
|
51
51
|
import { recommended as spacingRecommended, rules as spacingRules } from "./spacing.js";
|
|
52
|
-
import { buildFeConfig, buildPlainSourceConfig, COUNTING } from "./lib/config.js";
|
|
52
|
+
import { buildFeConfig, buildPlainSourceConfig, COUNTING, scopeToPackages } from "./lib/config.js";
|
|
53
53
|
import { why } from "./lib/why.js";
|
|
54
54
|
import { recommended as uiRulingsRecommended, rules as uiRulingsRules } from "./ui-rulings.js";
|
|
55
55
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
@@ -134,7 +134,8 @@ export const rules = Object.fromEntries(CONTRIBUTIONS.flatMap((entry) => Object.
|
|
|
134
134
|
export const recommended = Object.fromEntries(CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)));
|
|
135
135
|
/** Flat-config options that make inline directives ineffective rather than merely forbidden. */
|
|
136
136
|
export { linterOptions } from "./lib/config.js";
|
|
137
|
-
export { loadHfs } from "./lib/hfs.js";
|
|
137
|
+
export { loadHfs, loadPackagesHfs } from "./lib/hfs.js";
|
|
138
|
+
export { scopeToPackages } from "./lib/config.js";
|
|
138
139
|
/**
|
|
139
140
|
* The Vietnamese why of each rule that carries a catalogue code: `{ code, vi, fixVi }`.
|
|
140
141
|
*
|
|
@@ -246,6 +247,21 @@ export const starciFeConfig = ({ hfs } = {}) => {
|
|
|
246
247
|
source: { ...recommended, ...reactHooksRules(reactHooks) },
|
|
247
248
|
});
|
|
248
249
|
};
|
|
250
|
+
/**
|
|
251
|
+
* The flat config of the front-end subpaths of an app's root workspace packages (`packages/<pkg>/src/workbench/**` and `src/public/**`), for the
|
|
252
|
+
* managed root `eslint.config.mjs`: `starciFePackagesConfig({ hfs: loadPackagesHfs(import.meta.url) })`, composed with the Grammar family's
|
|
253
|
+
* `...PackagesConfig`. The very rules of `starciFeConfig` over the package view, but the project-graph rules, which judge the import graph of a
|
|
254
|
+
* side folder that a package is not part of.
|
|
255
|
+
*
|
|
256
|
+
* @param input - The package view of the app root.
|
|
257
|
+
* @returns The flat config.
|
|
258
|
+
*/
|
|
259
|
+
export const starciFePackagesConfig = ({ hfs } = {}) => {
|
|
260
|
+
const reactHooks = loadReactHooks();
|
|
261
|
+
const graph = new Set(Object.keys(projectGraphRecommended));
|
|
262
|
+
const source = { ...Object.fromEntries(Object.entries(recommended).filter(([name]) => !graph.has(name))), ...reactHooksRules(reactHooks) };
|
|
263
|
+
return scopeToPackages(buildFeConfig({ hfs, plugin, reactHooks, tsPlugin: loadTsPlugin(), sonar: loadSonarPlugins(), source }));
|
|
264
|
+
};
|
|
249
265
|
/**
|
|
250
266
|
* The flat config of a TypeScript front-end tree that is no HFS app (a runtime package, the harness UI):
|
|
251
267
|
* `export default starciPlainSourceConfig({ root: import.meta.dirname })`. It lints the tree's `src/` with types, with the inline-directive fence, the
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -88,3 +88,16 @@ export declare const buildPlainSourceConfig: ({ root, plugin, hooks, reactHooks,
|
|
|
88
88
|
readonly sonar: SonarPlugins;
|
|
89
89
|
readonly canon?: boolean;
|
|
90
90
|
}) => Linter.Config[];
|
|
91
|
+
/** The front-end subpaths of a root workspace package: its workbench (drawn with Grammar) and its public face; the React-free workbench contract stays back end. */
|
|
92
|
+
export declare const PACKAGE_SOURCE_FILES: readonly string[];
|
|
93
|
+
/** What a package folder never lints: installed packages, build output, generated wire types. */
|
|
94
|
+
export declare const PACKAGE_IGNORED: readonly string[];
|
|
95
|
+
/**
|
|
96
|
+
* A front-end flat config (the canon's or a Grammar family's) narrowed to the front-end subpaths of the root workspace packages of an app:
|
|
97
|
+
* the very rules of the fe side, over `packages/<pkg>/src/workbench/**` and `src/public/**` of the app root, the workbench contract left to the
|
|
98
|
+
* back-end canon. The global ignore block becomes the package one. The config is read, never changed.
|
|
99
|
+
*
|
|
100
|
+
* @param config - The flat config of a front end built over the package view.
|
|
101
|
+
* @returns The same blocks with `files` narrowed to the package subpaths.
|
|
102
|
+
*/
|
|
103
|
+
export declare const scopeToPackages: (config: readonly Linter.Config[]) => Linter.Config[];
|
package/dist/lib/config.js
CHANGED
|
@@ -148,3 +148,21 @@ export const buildPlainSourceConfig = ({ root, plugin, hooks, reactHooks, tsPlug
|
|
|
148
148
|
{ files: [...PLAIN_SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
|
|
149
149
|
];
|
|
150
150
|
};
|
|
151
|
+
/** The front-end subpaths of a root workspace package: its workbench (drawn with Grammar) and its public face; the React-free workbench contract stays back end. */
|
|
152
|
+
export const PACKAGE_SOURCE_FILES = Object.freeze(["packages/*/src/workbench/**/*.{ts,tsx}", "packages/*/src/public/**/*.{ts,tsx}"]);
|
|
153
|
+
const PACKAGE_CONTRACT = Object.freeze(["packages/*/src/workbench/contract/**"]);
|
|
154
|
+
/** What a package folder never lints: installed packages, build output, generated wire types. */
|
|
155
|
+
export const PACKAGE_IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**"]);
|
|
156
|
+
/**
|
|
157
|
+
* A front-end flat config (the canon's or a Grammar family's) narrowed to the front-end subpaths of the root workspace packages of an app:
|
|
158
|
+
* the very rules of the fe side, over `packages/<pkg>/src/workbench/**` and `src/public/**` of the app root, the workbench contract left to the
|
|
159
|
+
* back-end canon. The global ignore block becomes the package one. The config is read, never changed.
|
|
160
|
+
*
|
|
161
|
+
* @param config - The flat config of a front end built over the package view.
|
|
162
|
+
* @returns The same blocks with `files` narrowed to the package subpaths.
|
|
163
|
+
*/
|
|
164
|
+
export const scopeToPackages = (config) => config.map((block) => {
|
|
165
|
+
if (block.files === undefined)
|
|
166
|
+
return { ...block, ignores: [...PACKAGE_IGNORED] };
|
|
167
|
+
return { ...block, files: [...PACKAGE_SOURCE_FILES], ignores: [...(block.ignores ?? []), ...PACKAGE_CONTRACT] };
|
|
168
|
+
});
|
package/dist/lib/hfs.d.ts
CHANGED
|
@@ -16,6 +16,22 @@ export declare const loadHfs: (configUrl: string | URL) => HfsView;
|
|
|
16
16
|
* @returns The frozen HFS view.
|
|
17
17
|
*/
|
|
18
18
|
export declare const hfsFromDeclaration: (declaration: unknown, repoRoot: string) => HfsView;
|
|
19
|
+
/**
|
|
20
|
+
* The HFS view of the root workspace packages of an app, judged by the laws of the fe side: the managed root `eslint.config.mjs`
|
|
21
|
+
* (`packages/*` of the app root) calls this. Paths are the app's own; a package file is of the tier `package`.
|
|
22
|
+
*
|
|
23
|
+
* @param configUrl - `import.meta.url` of the app root's `eslint.config.mjs`.
|
|
24
|
+
* @returns The frozen HFS view (profile fe over the paths of the app root).
|
|
25
|
+
*/
|
|
26
|
+
export declare const loadPackagesHfs: (configUrl: string | URL) => HfsView;
|
|
27
|
+
/**
|
|
28
|
+
* The HFS view of the root packages of an in-memory app declaration, for rule tests.
|
|
29
|
+
*
|
|
30
|
+
* @param declaration - An app `hfs.json` object.
|
|
31
|
+
* @param repoRoot - The absolute app root the linted filenames live under.
|
|
32
|
+
* @returns The frozen HFS view.
|
|
33
|
+
*/
|
|
34
|
+
export declare const packagesHfsFromDeclaration: (declaration: unknown, repoRoot: string) => HfsView;
|
|
19
35
|
/** True for a value that carries the view's resolver (`slotOf`), the test the canon has always made of `settings.starci.hfs`. */
|
|
20
36
|
export declare const isHfsView: (value: unknown) => value is HfsView;
|
|
21
37
|
/**
|
package/dist/lib/hfs.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { declaredHfsView, openHfsView } from "#runtime/scripts/hfs/view.mjs";
|
|
16
|
+
import { declaredHfsView, declaredPackagesHfsView, openHfsView, openPackagesHfsView } from "#runtime/scripts/hfs/view.mjs";
|
|
17
17
|
import { RUNTIME_ROOT } from "./package-root.js";
|
|
18
18
|
const RUNTIME = RUNTIME_ROOT;
|
|
19
19
|
/**
|
|
@@ -31,6 +31,22 @@ export const loadHfs = (configUrl) => openHfsView({ runtimeRoot: RUNTIME, repoRo
|
|
|
31
31
|
* @returns The frozen HFS view.
|
|
32
32
|
*/
|
|
33
33
|
export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot, side: "fe" });
|
|
34
|
+
/**
|
|
35
|
+
* The HFS view of the root workspace packages of an app, judged by the laws of the fe side: the managed root `eslint.config.mjs`
|
|
36
|
+
* (`packages/*` of the app root) calls this. Paths are the app's own; a package file is of the tier `package`.
|
|
37
|
+
*
|
|
38
|
+
* @param configUrl - `import.meta.url` of the app root's `eslint.config.mjs`.
|
|
39
|
+
* @returns The frozen HFS view (profile fe over the paths of the app root).
|
|
40
|
+
*/
|
|
41
|
+
export const loadPackagesHfs = (configUrl) => openPackagesHfsView({ runtimeRoot: RUNTIME, repoRoot: dirname(fileURLToPath(configUrl)), side: "fe" });
|
|
42
|
+
/**
|
|
43
|
+
* The HFS view of the root packages of an in-memory app declaration, for rule tests.
|
|
44
|
+
*
|
|
45
|
+
* @param declaration - An app `hfs.json` object.
|
|
46
|
+
* @param repoRoot - The absolute app root the linted filenames live under.
|
|
47
|
+
* @returns The frozen HFS view.
|
|
48
|
+
*/
|
|
49
|
+
export const packagesHfsFromDeclaration = (declaration, repoRoot) => declaredPackagesHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot, side: "fe" });
|
|
34
50
|
/** True for a value that carries the view's resolver (`slotOf`), the test the canon has always made of `settings.starci.hfs`. */
|
|
35
51
|
export const isHfsView = (value) => typeof value === "object" && value !== null && "slotOf" in value && typeof value.slotOf === "function";
|
|
36
52
|
/** The `starci.hfs` entry of a settings object, read without trusting its shape. */
|
package/dist/lib/scope.d.ts
CHANGED
|
@@ -39,14 +39,6 @@ export declare const isProductSource: (context: AnyContext) => boolean;
|
|
|
39
39
|
export declare const isComponentFile: (context: AnyContext) => boolean;
|
|
40
40
|
/** True when the given file (an absolute or repo-relative name, e.g. where an import resolves to) sits in a component layer of slot `fe.components` or `fe.package.ui`. */
|
|
41
41
|
export declare const isComponentPath: (context: AnyContext, file: string) => boolean;
|
|
42
|
-
/**
|
|
43
|
-
* The one auth door of the session issuer's client: `modules/auth/auth-door.ts` (slot `fe.modules.auth`). It alone calls the app's
|
|
44
|
-
* own back-end auth doors (login, signup, refresh, logout, password reset, resend verification) with the REST client, credentials
|
|
45
|
-
* included; every other file of `modules/auth` and every other module stays outside the transport.
|
|
46
|
-
*/
|
|
47
|
-
export declare const isAuthDoor: (context: AnyContext) => boolean;
|
|
48
|
-
/** The in-memory access-token store of `modules/auth` (`modules/auth/token-store.ts`), the one module-level mutable state the auth client keeps. */
|
|
49
|
-
export declare const isAuthTokenStore: (context: AnyContext) => boolean;
|
|
50
42
|
/** The one place that may read the environment: slot `fe.modules.config`. */
|
|
51
43
|
export declare const isConfigModule: (context: AnyContext) => boolean;
|
|
52
44
|
/**
|
package/dist/lib/scope.js
CHANGED
|
@@ -48,16 +48,6 @@ export const isComponentPath = (context, file) => {
|
|
|
48
48
|
const found = hfsOf(context).classify(file);
|
|
49
49
|
return (found.slot === "fe.components" || found.slot === "fe.package.ui") && found.kind !== undefined;
|
|
50
50
|
};
|
|
51
|
-
/** The base name of the linted file. */
|
|
52
|
-
const baseNameOf = (context) => (context.filename || context.getFilename()).split(/[\\/]/).at(-1) ?? "";
|
|
53
|
-
/**
|
|
54
|
-
* The one auth door of the session issuer's client: `modules/auth/auth-door.ts` (slot `fe.modules.auth`). It alone calls the app's
|
|
55
|
-
* own back-end auth doors (login, signup, refresh, logout, password reset, resend verification) with the REST client, credentials
|
|
56
|
-
* included; every other file of `modules/auth` and every other module stays outside the transport.
|
|
57
|
-
*/
|
|
58
|
-
export const isAuthDoor = (context) => inSlot(context, "fe.modules.auth") && baseNameOf(context) === "auth-door.ts";
|
|
59
|
-
/** The in-memory access-token store of `modules/auth` (`modules/auth/token-store.ts`), the one module-level mutable state the auth client keeps. */
|
|
60
|
-
export const isAuthTokenStore = (context) => inSlot(context, "fe.modules.auth") && baseNameOf(context) === "token-store.ts";
|
|
61
51
|
/** The one place that may read the environment: slot `fe.modules.config`. */
|
|
62
52
|
export const isConfigModule = (context) => inSlot(context, "fe.modules.config");
|
|
63
53
|
/**
|
package/dist/lib/why.d.ts
CHANGED
package/dist/lib/why.js
CHANGED
|
@@ -384,14 +384,19 @@ export const why = {
|
|
|
384
384
|
},
|
|
385
385
|
"i18n-stack-in-one-module": {
|
|
386
386
|
code: "FE_I18N_PLACEMENT",
|
|
387
|
-
en: "`<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once
|
|
388
|
-
fix: "
|
|
387
|
+
en: "`<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once, in `@starci/next-i18n`.",
|
|
388
|
+
fix: "Describe the languages with `defineI18n` and call `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment` of `@starci/next-i18n`; the proxy imports no navigation.",
|
|
389
389
|
},
|
|
390
390
|
"no-app-relative-clock": {
|
|
391
391
|
code: "FE_SIZE_AND_STATE_BUDGET",
|
|
392
392
|
en: "`<file>` starts a recurring timer that reads the wall clock for live time labels.",
|
|
393
393
|
fix: "Render Grammar RelativeTime with the target dateTime, locale and mode; its shared ticker owns live relative time and countdown updates.",
|
|
394
394
|
},
|
|
395
|
+
"swr-extension-from-package": {
|
|
396
|
+
code: "FE_STACK_FORBIDDEN",
|
|
397
|
+
en: "`<file>` imports the `Middleware` or `ScopedMutator` type of `swr`, so it writes an SWR middleware or a cache binding the stack already ships.",
|
|
398
|
+
fix: "List `viewerSwrConfig` (it carries `pollingRestart`) in the app's SWRConfig and write through the `createViewerCache()` instance of `@starci/next-data`.",
|
|
399
|
+
},
|
|
395
400
|
"fe-stack-import": {
|
|
396
401
|
code: "FE_STACK_FORBIDDEN",
|
|
397
402
|
en: "`<file>` imports `<what>`, a package or scoped package family the front-end stack forbids.",
|
|
@@ -30,13 +30,13 @@ export declare const noNativeAnchor: import("./lib/rule.js").Rule<"internal" | "
|
|
|
30
30
|
/** No route written by hand outside `modules/routes`. */
|
|
31
31
|
export declare const noHardcodedRoute: import("./lib/rule.js").Rule<"route", []>;
|
|
32
32
|
/**
|
|
33
|
-
* The next-intl stack is written once
|
|
33
|
+
* The next-intl stack is written once, in `@starci/next-i18n`.
|
|
34
34
|
*
|
|
35
35
|
* `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
|
|
36
36
|
* `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
|
|
37
|
-
* member counts),
|
|
38
|
-
* config
|
|
39
|
-
* evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
|
|
37
|
+
* member counts), are called by that package and by no product source: an app describes its languages with `defineI18n` and builds
|
|
38
|
+
* the proxy, the navigation, the request config and the layout's locale read with the package's factories. The proxy imports the
|
|
39
|
+
* routing without evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
|
|
40
40
|
*
|
|
41
41
|
* It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
|
|
42
42
|
* next-intl's own stack. Different evidence, different remedy.
|
package/dist/next-conventions.js
CHANGED
|
@@ -374,13 +374,13 @@ const stackFactoryOf = (context, callee) => {
|
|
|
374
374
|
return stackFactory(definition, importedName(definition.node));
|
|
375
375
|
};
|
|
376
376
|
/**
|
|
377
|
-
* The next-intl stack is written once
|
|
377
|
+
* The next-intl stack is written once, in `@starci/next-i18n`.
|
|
378
378
|
*
|
|
379
379
|
* `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
|
|
380
380
|
* `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
|
|
381
|
-
* member counts),
|
|
382
|
-
* config
|
|
383
|
-
* evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
|
|
381
|
+
* member counts), are called by that package and by no product source: an app describes its languages with `defineI18n` and builds
|
|
382
|
+
* the proxy, the navigation, the request config and the layout's locale read with the package's factories. The proxy imports the
|
|
383
|
+
* routing without evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
|
|
384
384
|
*
|
|
385
385
|
* It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
|
|
386
386
|
* next-intl's own stack. Different evidence, different remedy.
|
|
@@ -388,20 +388,15 @@ const stackFactoryOf = (context, callee) => {
|
|
|
388
388
|
export const i18nStackInOneModule = defineRule({
|
|
389
389
|
meta: {
|
|
390
390
|
type: "problem",
|
|
391
|
-
docs: { description: "
|
|
391
|
+
docs: { description: "The next-intl stack is built by @starci/next-i18n; an app calls its factories and never next-intl's own, and its proxy never evaluates the navigation." },
|
|
392
392
|
schema: [],
|
|
393
393
|
messages: {
|
|
394
|
-
stack: "`{{name}}` builds the next-intl stack
|
|
395
|
-
proxyNavigation: "The proxy imports navigation runtime through {{source}}.
|
|
394
|
+
stack: "`{{name}}` builds the next-intl stack in the app. Describe the languages with `defineI18n` and build the proxy, the navigation, the request config and the locale read of the layout with `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment` of `@starci/next-i18n` (FE-I18N-1).",
|
|
395
|
+
proxyNavigation: "The proxy imports navigation runtime through {{source}}. Build the proxy with `createProxy` of `@starci/next-i18n/proxy` over the i18n description; the proxy must not evaluate the routes/navigation barrel.",
|
|
396
396
|
},
|
|
397
397
|
},
|
|
398
398
|
create(context) {
|
|
399
|
-
const
|
|
400
|
-
const slot = slotOfFile(context);
|
|
401
|
-
const single = hfs.apps.length === 1;
|
|
402
|
-
const proxy = single && slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
|
|
403
|
-
const proxyFile = slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
|
|
404
|
-
const where = single ? "routing/request config in modules/i18n and navigation in modules/routes" : "the stack in the shared i18n package that every app calls";
|
|
399
|
+
const proxyFile = slotOfFile(context) === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
|
|
405
400
|
const safeProxyImport = (node) => {
|
|
406
401
|
if (!proxyFile || !node.source || (node.type === "ImportDeclaration" ? node.importKind === "type" : node.exportKind === "type"))
|
|
407
402
|
return;
|
|
@@ -419,18 +414,8 @@ export const i18nStackInOneModule = defineRule({
|
|
|
419
414
|
ExportAllDeclaration: safeProxyImport,
|
|
420
415
|
CallExpression(node) {
|
|
421
416
|
const factory = stackFactoryOf(context, node.callee);
|
|
422
|
-
// In a one-app repository the proxy (role `proxy`) mounts the locale middleware built from the one routing; a shared repository
|
|
423
|
-
// builds it in the i18n package and the proxy re-exports it.
|
|
424
|
-
if (factory?.name === "createMiddleware" && proxy)
|
|
425
|
-
return;
|
|
426
|
-
if (slot === "fe.package.i18n")
|
|
427
|
-
return;
|
|
428
|
-
if (single && factory?.name === "createNavigation" && slot === "fe.modules.routes")
|
|
429
|
-
return;
|
|
430
|
-
if (single && factory?.name !== "createNavigation" && slot === "fe.modules.i18n")
|
|
431
|
-
return;
|
|
432
417
|
if (factory)
|
|
433
|
-
context.report({ node, messageId: "stack", data: { name: factory.name
|
|
418
|
+
context.report({ node, messageId: "stack", data: { name: factory.name } });
|
|
434
419
|
},
|
|
435
420
|
};
|
|
436
421
|
},
|
package/dist/transport.d.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* standing in for a status.
|
|
20
20
|
*/
|
|
21
21
|
import { type Recommended, type Rules } from "./lib/rule.js";
|
|
22
|
+
/** The HTTP methods of the REST client whose first argument is the request path. */
|
|
22
23
|
/**
|
|
23
24
|
* `fetch` is reached only in the API layer, each canonical transport library only in its home, and no other HTTP library exists.
|
|
24
25
|
*
|
|
@@ -28,7 +29,7 @@ import { type Recommended, type Rules } from "./lib/rule.js";
|
|
|
28
29
|
* `socket.io-client` only by `hooks/socketio/`. Native `WebSocket` is refused everywhere, including the API layer.
|
|
29
30
|
* Every other file reaches the back end through an SWR hook over an operation.
|
|
30
31
|
*/
|
|
31
|
-
export declare const fetchOnlyInApiClient: import("./lib/rule.js").Rule<"library" | "outside" | "channel" | "websocket" | "xhr" | "placed"
|
|
32
|
+
export declare const fetchOnlyInApiClient: import("./lib/rule.js").Rule<"library" | "outside" | "channel" | "websocket" | "xhr" | "placed", []>;
|
|
32
33
|
/** Every `fetch` of the API layer (a custom fetch handed to the Apollo http link, a raw call) carries an abort signal, so a hung backend cannot hang a reader. */
|
|
33
34
|
export declare const clientFetchHasSignal: import("./lib/rule.js").Rule<"signal", []>;
|
|
34
35
|
/** No module-level mutable state in the API layer: a token or locale in a `let` leaks across requests. */
|
package/dist/transport.js
CHANGED
|
@@ -23,7 +23,7 @@ import { isNode } from "./lib/ast.js";
|
|
|
23
23
|
import { hfsOf } from "./lib/hfs.js";
|
|
24
24
|
import { forbiddingConcern, packageNameOf } from "./lib/fe-stack.js";
|
|
25
25
|
import { defineRule } from "./lib/rule.js";
|
|
26
|
-
import { globalReferences, inSlot, isApiClient, isApiLayer, isApiRest,
|
|
26
|
+
import { globalReferences, inSlot, isApiClient, isApiLayer, isApiRest, isOutcomeModule } from "./lib/scope.js";
|
|
27
27
|
import { typed } from "./lib/types.js";
|
|
28
28
|
/**
|
|
29
29
|
* The canonical transport libraries and the one place each may be imported (fe-stack graphql, rest and realtime): Apollo in
|
|
@@ -31,7 +31,7 @@ import { typed } from "./lib/types.js";
|
|
|
31
31
|
*/
|
|
32
32
|
const PLACED_TRANSPORTS = new Map([
|
|
33
33
|
["@apollo/client", { where: "the API layer (`modules/api`)", allowed: (context) => isApiLayer(context) }],
|
|
34
|
-
["axios", { where: "the REST calls of the API layer (`modules/api/rest/`)
|
|
34
|
+
["axios", { where: "the REST calls of the API layer (`modules/api/rest/`)", allowed: (context) => isApiRest(context) }],
|
|
35
35
|
["socket.io-client", { where: "`hooks/socketio/`", allowed: (context) => inSlot(context, "fe.hooks.socketio") }],
|
|
36
36
|
["graphql-ws", { where: "the Apollo clients of the API layer (`modules/api/graphql/clients/`, or the shared api package's `graphql/clients/`)", allowed: (context) => isApiClient(context) }],
|
|
37
37
|
]);
|
|
@@ -140,26 +140,6 @@ const transportUses = (context) => {
|
|
|
140
140
|
};
|
|
141
141
|
// -- FE-TRANSPORT-1 --------------------------------------------------------------------------------
|
|
142
142
|
/** The HTTP methods of the REST client whose first argument is the request path. */
|
|
143
|
-
const REST_METHODS = new Set(["get", "post", "put", "patch", "delete", "request"]);
|
|
144
|
-
/** The static text of a path argument (a literal, or a template's fixed parts), or null when nothing about it is fixed. */
|
|
145
|
-
const staticPath = (node) => {
|
|
146
|
-
if (node?.type === "Literal" && typeof node.value === "string")
|
|
147
|
-
return node.value;
|
|
148
|
-
if (node?.type === "TemplateLiteral")
|
|
149
|
-
return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("<expression>");
|
|
150
|
-
return null;
|
|
151
|
-
};
|
|
152
|
-
/** A path segment `auth/`: the app's own back-end auth doors. */
|
|
153
|
-
const AUTH_DOOR_PATH = /(?:^|\/)auth\//;
|
|
154
|
-
/** In the auth door, a REST call whose path the rule can read must name an `/auth/` door; a fully dynamic path is left to review. */
|
|
155
|
-
const judgeAuthDoorCall = (context, node) => {
|
|
156
|
-
if (node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.type !== "Identifier" || !REST_METHODS.has(node.callee.property.name))
|
|
157
|
-
return;
|
|
158
|
-
const path = staticPath(node.arguments[0]);
|
|
159
|
-
if (path === null || AUTH_DOOR_PATH.test(path))
|
|
160
|
-
return;
|
|
161
|
-
context.report({ node, messageId: "authDoor", data: { call: `.${node.callee.property.name}()`, path } });
|
|
162
|
-
};
|
|
163
143
|
/**
|
|
164
144
|
* `fetch` is reached only in the API layer, each canonical transport library only in its home, and no other HTTP library exists.
|
|
165
145
|
*
|
|
@@ -180,13 +160,11 @@ export const fetchOnlyInApiClient = defineRule({
|
|
|
180
160
|
library: "`{{name}}` is a second HTTP transport. The front end talks to its back end through Apollo (`modules/api/graphql`) and the axios REST calls (`modules/api/rest`); another library is another set of timeout, retry and error rules. Use an operation of `modules/api`.",
|
|
181
161
|
placed: "`{{name}}` is imported outside {{where}}, the one place the front-end stack gives it. A component or hook that holds the transport itself skips the link chain (token, locale, retry, timeout) and the one result vocabulary. Call an operation of `modules/api` through its SWR hook (`hooks/swr/`), or listen through `hooks/socketio/`.",
|
|
182
162
|
xhr: "`XMLHttpRequest` is a second transport. Use an operation of the API layer (`modules/api`).",
|
|
183
|
-
authDoor: "`{{call}}` calls `{{path}}` from the auth door. `modules/auth/auth-door.ts` calls only the app's own back-end auth doors (`/auth/...`: login, signup, refresh, logout, password reset, resend verification); every other REST call is `modules/api/rest/`.",
|
|
184
163
|
websocket: "Native `WebSocket` is a second realtime transport. Use socket.io-client in `hooks/socketio/`, the front end's realtime owner.",
|
|
185
164
|
},
|
|
186
165
|
},
|
|
187
166
|
create(context) {
|
|
188
167
|
const layer = isApiLayer(context);
|
|
189
|
-
const authDoor = isAuthDoor(context);
|
|
190
168
|
const library = (node, source) => {
|
|
191
169
|
const name = packageNameOf(source);
|
|
192
170
|
if (name === null || forbiddingConcern(source))
|
|
@@ -231,8 +209,6 @@ export const fetchOnlyInApiClient = defineRule({
|
|
|
231
209
|
library(node, node.source.value);
|
|
232
210
|
},
|
|
233
211
|
CallExpression(node) {
|
|
234
|
-
if (authDoor)
|
|
235
|
-
judgeAuthDoorCall(context, node);
|
|
236
212
|
// `require("axios")` where `require` is the CommonJS global.
|
|
237
213
|
const [first] = node.arguments;
|
|
238
214
|
if (node.callee.type !== "Identifier" || node.callee.name !== "require" || first?.type !== "Literal")
|
|
@@ -290,14 +266,14 @@ const isTransportFile = (context) => isApiLayer(context) && !hfsOf(context).rela
|
|
|
290
266
|
export const noSharedTransportState = defineRule({
|
|
291
267
|
meta: {
|
|
292
268
|
type: "problem",
|
|
293
|
-
docs: { description: "The API layer (`modules/api/**`) and `modules/auth` hold no module-level `let`/`var
|
|
269
|
+
docs: { description: "The API layer (`modules/api/**`) and `modules/auth` hold no module-level `let`/`var`." },
|
|
294
270
|
schema: [],
|
|
295
271
|
messages: {
|
|
296
272
|
shared: "A module-level `{{kind}}` in the API layer. A token or locale kept there is shared by every request on the server and by every reader in one tab, so one reader's credential can answer another's request. Pass the credential as a parameter or read it from context.",
|
|
297
273
|
},
|
|
298
274
|
},
|
|
299
275
|
create(context) {
|
|
300
|
-
if (!isTransportFile(context) && !
|
|
276
|
+
if (!isTransportFile(context) && !inSlot(context, "fe.modules.auth"))
|
|
301
277
|
return {};
|
|
302
278
|
const check = (node) => {
|
|
303
279
|
const declaration = node.type === "VariableDeclaration" ? node : node.type === "ExportNamedDeclaration" ? node.declaration : undefined;
|
package/docs/next-conventions.md
CHANGED
|
@@ -207,9 +207,7 @@ router.push(routes.newWorkspace())
|
|
|
207
207
|
|
|
208
208
|
## `starci-fe/i18n-stack-in-one-module`
|
|
209
209
|
|
|
210
|
-
The
|
|
211
|
-
|
|
212
|
-
`defineRouting` and `getRequestConfig` remain in the i18n owner. `createNavigation` belongs to a shared `fe.package.i18n` owner or the single app's `fe.modules.routes` owner. A single app's proxy mounts locale middleware using the routing-only public i18n entry. A shared package exposes its routing-only proxy entry as a declared package export. The proxy does not import `next-intl/navigation` or the app's routes/navigation barrel. Named import aliases and namespace imports keep their factory identity.
|
|
210
|
+
The next-intl stack is built by `@starci/next-i18n`, never by a product source. `defineRouting`, `createNavigation`, `getRequestConfig` and `createMiddleware` are called by no file of an app or of a shared package, wherever it sits. An app states its languages with `defineI18n` and builds the proxy, the navigation, the request config and the layout's locale read with `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment`. The proxy does not import `next-intl/navigation` or the app's routes/navigation barrel. Named import aliases and namespace imports keep their factory identity.
|
|
213
211
|
|
|
214
212
|
**Invalid** (`apps/web/src/modules/i18n/request.ts`, a repository with two apps)
|
|
215
213
|
|
|
@@ -218,15 +216,17 @@ import { getRequestConfig } from "next-intl/server"
|
|
|
218
216
|
export default getRequestConfig(async () => ({ locale: "vi", messages: {} }))
|
|
219
217
|
```
|
|
220
218
|
|
|
221
|
-
**Valid** (`apps/web/src/modules/i18n/
|
|
219
|
+
**Valid** (`apps/web/src/modules/i18n/request.ts`)
|
|
222
220
|
|
|
223
221
|
```ts
|
|
224
|
-
import {
|
|
225
|
-
|
|
222
|
+
import { createRequestConfig } from "@starci/next-i18n/request"
|
|
223
|
+
import { i18n } from "./routing"
|
|
224
|
+
|
|
225
|
+
export default createRequestConfig(i18n, { app: "web", loadMessages: (locale) => import(`./messages/${locale}.json`) })
|
|
226
226
|
```
|
|
227
227
|
|
|
228
228
|
**Finding code:** `FE_I18N_PLACEMENT`
|
|
229
229
|
|
|
230
|
-
**Why:** `<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack
|
|
230
|
+
**Why:** `<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once, in `@starci/next-i18n`.
|
|
231
231
|
|
|
232
|
-
**Fix:**
|
|
232
|
+
**Fix:** Describe the languages with `defineI18n` and call `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment` of `@starci/next-i18n`. Keep navigation imports out of the proxy.
|
package/package.json
CHANGED
package/runtime/bundle.json
CHANGED
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
"scripts/hfs/architecture/render-graph.mjs",
|
|
102
102
|
"scripts/hfs/architecture/render-prop-across-server.mjs",
|
|
103
103
|
"scripts/hfs/architecture/required-files.mjs",
|
|
104
|
+
"scripts/hfs/architecture/root-packages.mjs",
|
|
104
105
|
"scripts/hfs/architecture/route-files-thin.mjs",
|
|
105
106
|
"scripts/hfs/architecture/schema-owner.mjs",
|
|
106
107
|
"scripts/hfs/architecture/server-action.mjs",
|