@krizaka/config 0.0.0-stage → 0.2.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.
@@ -0,0 +1,41 @@
1
+ // A component never chooses its text with `locale === "fr" ? … : …`: every user-facing string lives in the
2
+ // message catalogues. Only routing code compares locales. Same selectors as the rule in force on krizaka.com,
3
+ // shipped as a plugin rule (`krizaka/no-locale-ternary`) so it never collides with a repository's own
4
+ // `no-restricted-syntax` options.
5
+
6
+ import { krizakaPlugin } from "./plugin.js";
7
+
8
+ export { krizakaPlugin } from "./plugin.js";
9
+ export { LOCALE_TERNARY_SELECTORS } from "./rules.js";
10
+
11
+ /** Routing files that legitimately compare locales (the krizaka.com layout, a common convention). */
12
+ export const DEFAULT_ROUTING_FILES = Object.freeze([
13
+ "**/proxy.ts",
14
+ "**/middleware.ts",
15
+ "**/i18n.ts",
16
+ "**/seo.ts",
17
+ "**/app/[[]locale[]]/layout.tsx",
18
+ "**/I18nProvider.tsx",
19
+ ]);
20
+
21
+ /**
22
+ * Flat config: `krizaka/no-locale-ternary` on the given files, except routing code.
23
+ * @param {{ files?: string[], routing?: string[] }} [options] `routing`: files allowed to compare locales.
24
+ * @returns {import("eslint").Linter.Config[]}
25
+ */
26
+ export function noLocaleTernary({
27
+ files = ["**/*.{js,jsx,ts,tsx}"],
28
+ routing = [...DEFAULT_ROUTING_FILES],
29
+ } = {}) {
30
+ return [
31
+ {
32
+ name: "krizaka/no-locale-ternary",
33
+ files,
34
+ ignores: routing,
35
+ plugins: { krizaka: krizakaPlugin },
36
+ rules: { "krizaka/no-locale-ternary": "error" },
37
+ },
38
+ ];
39
+ }
40
+
41
+ export default noLocaleTernary;
@@ -0,0 +1,16 @@
1
+ // The `krizaka` ESLint plugin: one object carrying every rule, so that configs registering it compose (flat config
2
+ // refuses two different objects under the same plugin name).
3
+ import { noDirectTanstackImport, noFetchInClientEffect, noLocaleTernaryRule,noLocalTableOrForm } from "./rules.js";
4
+
5
+ /** @type {import("eslint").ESLint.Plugin} */
6
+ export const krizakaPlugin = {
7
+ meta: { name: "@krizaka/config" },
8
+ rules: {
9
+ "no-locale-ternary": noLocaleTernaryRule,
10
+ "no-fetch-in-client-effect": noFetchInClientEffect,
11
+ "no-direct-tanstack-import": noDirectTanstackImport,
12
+ "no-local-table-or-form": noLocalTableOrForm,
13
+ },
14
+ };
15
+
16
+ export default krizakaPlugin;
@@ -0,0 +1,129 @@
1
+ // The rule modules of the `krizaka` ESLint plugin (eslint/plugin.js). Patterns come from ../patterns.js, which the
2
+ // ratchet reads too: a rule and its counter never diverge.
3
+ import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, TANSTACK_SCOPE } from "../patterns.js";
4
+
5
+ /** @typedef {import("eslint").Rule.RuleModule} RuleModule */
6
+ /** @typedef {import("estree").Node} Node */
7
+
8
+ /** The selectors of `no-locale-ternary`, also usable in `no-restricted-syntax`. */
9
+ export const LOCALE_TERNARY_SELECTORS = Object.freeze([
10
+ "ConditionalExpression[test.type='BinaryExpression'][test.right.value=/^(fr|en)$/]",
11
+ "ConditionalExpression > Identifier.test[name=/^(isFr|isEn|fr)$/]",
12
+ ]);
13
+
14
+ /** @type {RuleModule} */
15
+ export const noLocaleTernaryRule = {
16
+ meta: {
17
+ type: "problem",
18
+ docs: { description: "Forbid choosing a text with a locale ternary outside routing code." },
19
+ schema: [],
20
+ messages: {
21
+ localeTernary:
22
+ "Put the text in the message catalogues (messages/<locale>.json) and read it from the dictionary (t.…).",
23
+ },
24
+ },
25
+ create(context) {
26
+ /** @type {Record<string, (node: Node) => void>} */
27
+ const visitors = {};
28
+ for (const selector of LOCALE_TERNARY_SELECTORS) {
29
+ visitors[selector] = (node) => context.report({ node, messageId: "localeTernary" });
30
+ }
31
+ return visitors;
32
+ },
33
+ };
34
+
35
+ /**
36
+ * True when the file starts with a `"use client"` directive.
37
+ * @param {import("eslint").Rule.RuleContext} context
38
+ */
39
+ function isClientFile(context) {
40
+ for (const statement of context.sourceCode.ast.body) {
41
+ if (statement.type !== "ExpressionStatement" || !("directive" in statement)) return false;
42
+ if (statement.directive === "use client") return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ const callee = (/** @type {string} */ names) => `:matches([callee.name=/${names}/], [callee.property.name=/${names}/])`;
48
+
49
+ /** S1: the first screen is a Server Component, the interaction is Query — never a fetch in a client effect. */
50
+ /** @type {RuleModule} */
51
+ export const noFetchInClientEffect = {
52
+ meta: {
53
+ type: "suggestion",
54
+ docs: { description: "Forbid a fetch (or krizakaFetch) inside a useEffect of a \"use client\" file." },
55
+ schema: [],
56
+ messages: {
57
+ fetchInEffect:
58
+ "A fetch in a client effect: render the first screen in a Server Component and read the rest with @krizaka/query (useQuery / useMutation).",
59
+ },
60
+ },
61
+ create(context) {
62
+ if (!isClientFile(context)) return {};
63
+ return {
64
+ [`CallExpression${callee(EFFECT_NAMES_SOURCE)} CallExpression${callee(FETCH_NAMES_SOURCE)}`]: (/** @type {Node} */ node) =>
65
+ context.report({ node, messageId: "fetchInEffect" }),
66
+ };
67
+ },
68
+ };
69
+
70
+ /** S11: `@tanstack/*` is imported by the `@krizaka/*` packages only — the version is the contract. */
71
+ /** @type {RuleModule} */
72
+ export const noDirectTanstackImport = {
73
+ meta: {
74
+ type: "problem",
75
+ docs: { description: "Forbid importing @tanstack/* outside the @krizaka/* packages." },
76
+ schema: [],
77
+ messages: {
78
+ tanstack:
79
+ "{{source}} is imported through its @krizaka facade (@krizaka/query, @krizaka/ui/data-table, @krizaka/ui/form, @krizaka/ui/virtual, @krizaka/pacer…), never directly.",
80
+ },
81
+ },
82
+ create(context) {
83
+ /** @param {Node | null | undefined} source */
84
+ const check = (source) => {
85
+ if (source?.type === "Literal" && typeof source.value === "string" && source.value.startsWith(TANSTACK_SCOPE)) {
86
+ context.report({ node: source, messageId: "tanstack", data: { source: source.value } });
87
+ }
88
+ };
89
+ return {
90
+ ImportDeclaration: (node) => check(node.source),
91
+ ExportNamedDeclaration: (node) => check(node.source),
92
+ ExportAllDeclaration: (node) => check(node.source),
93
+ ImportExpression: (node) => check(node.source),
94
+ "CallExpression[callee.name='require']": (/** @type {import("estree").CallExpression} */ node) =>
95
+ check(/** @type {Node} */ (node.arguments[0])),
96
+ };
97
+ },
98
+ };
99
+
100
+ /** S2, S3: a table is `@krizaka/ui/data-table`, a form is `@krizaka/ui/form` — except a form of a single field. */
101
+ /** @type {RuleModule} */
102
+ export const noLocalTableOrForm = {
103
+ meta: {
104
+ type: "suggestion",
105
+ docs: { description: "Forbid a hand-written <table> or <form> in an app (single-field forms excepted)." },
106
+ schema: [
107
+ {
108
+ type: "object",
109
+ properties: { maxFormFields: { type: "integer", minimum: 0 } },
110
+ additionalProperties: false,
111
+ },
112
+ ],
113
+ messages: {
114
+ table: "A hand-written <table>: use DataTable from @krizaka/ui/data-table.",
115
+ form: "A hand-written <form> of {{fields}} fields: use createKrizakaForm from @krizaka/ui/form.",
116
+ },
117
+ },
118
+ create(context) {
119
+ const maxFormFields = context.options[0]?.maxFormFields ?? 1;
120
+ return {
121
+ /** @param {any} node JSXOpeningElement */
122
+ "JSXOpeningElement[name.type='JSXIdentifier']:matches([name.name='table'], [name.name='form'])"(node) {
123
+ if (node.name.name === "table") return context.report({ node, messageId: "table" });
124
+ const fields = countFormFields(context.sourceCode.getText(node.parent));
125
+ if (fields > maxFormFields) context.report({ node, messageId: "form", data: { fields: String(fields) } });
126
+ },
127
+ };
128
+ },
129
+ };
@@ -0,0 +1,40 @@
1
+ // The three stack rules (the stack ADR, decisions S1–S3 and S11): server state through @krizaka/query, tables and forms
2
+ // through @krizaka/ui, TanStack only behind a @krizaka facade. Warnings: krizaka-ratchet counts the same patterns, so an
3
+ // app adopts them now and brings each count to 0 by the end of its adoption.
4
+ import { krizakaPlugin } from "./plugin.js";
5
+
6
+ /** The rule names, as configured. */
7
+ export const STACK_RULES = Object.freeze([
8
+ "krizaka/no-fetch-in-client-effect",
9
+ "krizaka/no-direct-tanstack-import",
10
+ "krizaka/no-local-table-or-form",
11
+ ]);
12
+
13
+ /**
14
+ * Flat config: the three stack rules on an app's sources.
15
+ * @param {{ files?: string[], ignores?: string[], severity?: "warn" | "error", maxFormFields?: number }} [options]
16
+ * `maxFormFields`: a form with at most this many fields is allowed (default 1 — a search box, a newsletter).
17
+ * @returns {import("eslint").Linter.Config[]}
18
+ */
19
+ export function krizakaStack({
20
+ files = ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
21
+ ignores = [],
22
+ severity = "warn",
23
+ maxFormFields = 1,
24
+ } = {}) {
25
+ return [
26
+ {
27
+ name: "krizaka/stack",
28
+ files,
29
+ ...(ignores.length > 0 ? { ignores } : {}),
30
+ plugins: { krizaka: krizakaPlugin },
31
+ rules: {
32
+ "krizaka/no-fetch-in-client-effect": severity,
33
+ "krizaka/no-direct-tanstack-import": severity,
34
+ "krizaka/no-local-table-or-form": [severity, { maxFormFields }],
35
+ },
36
+ },
37
+ ];
38
+ }
39
+
40
+ export default krizakaStack;
package/package.json CHANGED
@@ -1,6 +1,120 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
5
+ "keywords": [
6
+ "krizaka",
7
+ "eslint-config",
8
+ "tsconfig",
9
+ "prettier-config",
10
+ "ratchet",
11
+ "design-system",
12
+ "dependency-cruiser",
13
+ "tanstack"
14
+ ],
15
+ "homepage": "https://github.com/krizaka/krizaka-ui/tree/main/packages/config#readme",
16
+ "bugs": "https://github.com/krizaka/krizaka-ui/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/krizaka/krizaka-ui.git",
20
+ "directory": "packages/config"
21
+ },
22
+ "license": "Apache-2.0",
23
+ "author": "Krizaka (https://www.krizaka.com)",
24
+ "type": "module",
25
+ "exports": {
26
+ "./eslint": "./eslint/index.js",
27
+ "./eslint/next": "./eslint/next.js",
28
+ "./eslint/krizaka-ui": "./eslint/krizaka-ui.js",
29
+ "./eslint/no-locale-ternary": "./eslint/no-locale-ternary.js",
30
+ "./eslint/stack": "./eslint/stack.js",
31
+ "./eslint/plugin": "./eslint/plugin.js",
32
+ "./dependency-cruiser": "./dependency-cruiser.cjs",
33
+ "./patterns": "./patterns.js",
34
+ "./ratchet": "./ratchet/ratchet.js",
35
+ "./prettier": "./prettier.config.js",
36
+ "./tsconfig/base.json": "./tsconfig/base.json",
37
+ "./tsconfig/react-library.json": "./tsconfig/react-library.json",
38
+ "./tsconfig/next.json": "./tsconfig/next.json",
39
+ "./tsconfig/react-native.json": "./tsconfig/react-native.json",
40
+ "./editorconfig": "./.editorconfig",
41
+ "./package.json": "./package.json"
42
+ },
43
+ "bin": {
44
+ "krizaka-ratchet": "./bin/krizaka-ratchet.mjs"
45
+ },
46
+ "files": [
47
+ "bin",
48
+ "eslint",
49
+ "ratchet",
50
+ "tsconfig",
51
+ "patterns.js",
52
+ "dependency-cruiser.cjs",
53
+ "prettier.config.js",
54
+ ".editorconfig",
55
+ "README.md",
56
+ "LICENSE"
57
+ ],
58
+ "sideEffects": false,
59
+ "dependencies": {
60
+ "@eslint/js": "^9.39.0",
61
+ "eslint-plugin-react-hooks": "^7.1.1",
62
+ "eslint-plugin-simple-import-sort": "^14.0.0",
63
+ "globals": "^17.13.0",
64
+ "tinyglobby": "^0.2.17",
65
+ "typescript-eslint": "^8.71.1"
66
+ },
67
+ "peerDependencies": {
68
+ "eslint": "^9",
69
+ "eslint-config-next": ">=16",
70
+ "prettier": "^3",
71
+ "typescript": "^5"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "eslint-config-next": {
75
+ "optional": true
76
+ },
77
+ "prettier": {
78
+ "optional": true
79
+ }
80
+ },
81
+ "devDependencies": {
82
+ "@size-limit/file": "^14.2.0",
83
+ "@types/estree": "^1.0.8",
84
+ "@types/node": "^22.15.0",
85
+ "dependency-cruiser": "^18.6.0",
86
+ "eslint": "^9.39.0",
87
+ "eslint-config-next": "^16.4.0",
88
+ "next": "^16.4.0",
89
+ "prettier": "^3.9.9",
90
+ "publint": "^0.3.25",
91
+ "size-limit": "^14.2.0",
92
+ "typescript": "^5.9.0",
93
+ "vitest": "^4.1.11"
94
+ },
95
+ "engines": {
96
+ "node": ">=22"
97
+ },
98
+ "publishConfig": {
99
+ "access": "public"
100
+ },
101
+ "size-limit": [
102
+ {
103
+ "name": "krizaka-ratchet",
104
+ "path": [
105
+ "bin/krizaka-ratchet.mjs",
106
+ "ratchet/ratchet.js",
107
+ "patterns.js"
108
+ ],
109
+ "limit": "8 kB",
110
+ "gzip": true
111
+ }
112
+ ],
113
+ "scripts": {
114
+ "lint": "eslint . --max-warnings 0 && tsc --noEmit --noUnusedLocals --noUnusedParameters --noFallthroughCasesInSwitch --noImplicitReturns",
115
+ "typecheck": "tsc --noEmit",
116
+ "test": "vitest run",
117
+ "size": "size-limit",
118
+ "publint": "publint"
119
+ }
6
120
  }
package/patterns.js ADDED
@@ -0,0 +1,117 @@
1
+ // The four debts the UI refactor removes, as regex sources. One definition, read by the ESLint rules
2
+ // (eslint/krizaka-ui.js, on className literals) and by the ratchet (bin/krizaka-ratchet.mjs, on the source text).
3
+
4
+ /** Tailwind palette families a product never writes: a component reads a role (bg-surface-1, text-accent…). */
5
+ export const PALETTE_FAMILIES = Object.freeze([
6
+ "zinc", "slate", "gray", "neutral", "stone",
7
+ "fuchsia", "violet", "purple", "pink", "rose",
8
+ "emerald", "green", "amber", "yellow", "red",
9
+ "blue", "sky", "cyan", "indigo",
10
+ ]);
11
+
12
+ /** Utilities that take a colour. */
13
+ export const COLOR_UTILITIES = Object.freeze(["bg", "text", "border", "from", "via", "to", "ring", "shadow", "fill", "stroke"]);
14
+
15
+ /**
16
+ * `bg-zinc-900`, `text-violet-400/80`… — a raw palette step.
17
+ * @param {readonly string[]} [allow] families allowed for documented, isolated cases (e.g. `["emerald"]`).
18
+ * @returns {string} regex source
19
+ */
20
+ export function paletteSource(allow = []) {
21
+ const families = PALETTE_FAMILIES.filter((family) => !allow.includes(family));
22
+ if (families.length === 0) return "(?!)"; // everything allowed: matches nothing
23
+ return `\\b(${COLOR_UTILITIES.join("|")})-(${families.join("|")})-[0-9]`;
24
+ }
25
+
26
+ /** `light:bg-white` — a theme is a set of tokens, never a variant. */
27
+ export const LIGHT_VARIANT_SOURCE = "\\blight:";
28
+
29
+ /** `bg-[var(--surface-1)]` — the variable has a utility in @krizaka/tailwind. */
30
+ export const ARBITRARY_VAR_SOURCE = "\\[var\\(--";
31
+
32
+ /** `className={`…${x}`}` — the ratchet's text approximation of the TemplateLiteral rule. */
33
+ export const CLASSNAME_TEMPLATE_SOURCE = "className=\\{\\s*`";
34
+
35
+ // ── The stack debts (decisions S1–S3, S11 of the stack ADR) ──────────────────────────────────────────────────────
36
+ // Read by the ESLint rules (eslint/stack.js) and by the ratchet, like the UI patterns above.
37
+
38
+ /** A `"use client"` directive heading the file (after comments). */
39
+ export const USE_CLIENT_SOURCE = String.raw`^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use client["']`;
40
+
41
+ /** The opening of an effect call: `useEffect(`, `React.useLayoutEffect(`. */
42
+ export const EFFECT_CALL_SOURCE = String.raw`\buse(?:Layout)?Effect\s*\(`;
43
+
44
+ /** A fetch call: `fetch(`, `window.fetch(`, `krizakaFetch(` — never `refetch(`. */
45
+ export const FETCH_CALL_SOURCE = String.raw`\b(?:fetch|krizakaFetch)\s*\(`;
46
+
47
+ /** The names the ESLint rule matches (callee identifier or member property). */
48
+ export const EFFECT_NAMES_SOURCE = "^use(Layout)?Effect$";
49
+ export const FETCH_NAMES_SOURCE = "^(fetch|krizakaFetch)$";
50
+
51
+ /** The headless engines that only enter through a `@krizaka/*` package. */
52
+ export const TANSTACK_SCOPE = "@tanstack/";
53
+
54
+ /**
55
+ * An import of one of the given packages (or scopes ending in `/`): `from "x"`, `import "x"`, `import("x")`,
56
+ * `require("x")`, `export … from "x"`.
57
+ * @param {readonly string[]} specifiers package names (`framer-motion`) or scope prefixes (`@tanstack/`)
58
+ * @returns {string} regex source
59
+ */
60
+ export function importSource(specifiers) {
61
+ if (specifiers.length === 0) return "(?!)"; // nothing listed: matches nothing
62
+ const escape = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ const names = specifiers.map((s) => (s.endsWith("/") ? `${escape(s)}[^"']+` : `${escape(s)}(?:/[^"']*)?`));
64
+ return String.raw`(?:\bfrom|\bimport|\brequire)\s*(?:\(\s*)?["'](?:${names.join("|")})["']`;
65
+ }
66
+
67
+ /** A `<table` or `<form` element written in an app (JSX or HTML). */
68
+ export const TABLE_OR_FORM_SOURCE = String.raw`<(table|form)(?=[\s>/])`;
69
+
70
+ /** The elements that hold a value in a form: native fields and the field primitives of `@krizaka/ui`. */
71
+ export const FORM_FIELD_TAGS = Object.freeze([
72
+ "input", "textarea", "select",
73
+ "Input", "Textarea", "Select", "Combobox", "DatePicker", "DateRangePicker", "TagInput", "Checkbox", "Switch",
74
+ "RadioGroup", "Slider", "SearchField", "Dropzone", "FileUpload", "OtpInput",
75
+ ]);
76
+
77
+ /** `<input type="hidden|submit|button|reset|image">` holds no value the user types. */
78
+ const NOT_A_FIELD = /\btype=(?:\{\s?)?["'](?:hidden|submit|button|reset|image)["']/;
79
+
80
+ /**
81
+ * The value-holding fields inside a form's source text.
82
+ * @param {string} text
83
+ * @returns {number}
84
+ */
85
+ export function countFormFields(text) {
86
+ const tags = new RegExp(String.raw`<(${FORM_FIELD_TAGS.join("|")})(?=[\s/>])([^>]*)`, "g");
87
+ let fields = 0;
88
+ for (const [, tag, attributes] of text.matchAll(tags)) {
89
+ if (tag === "input" && NOT_A_FIELD.test(attributes ?? "")) continue;
90
+ fields += 1;
91
+ }
92
+ return fields;
93
+ }
94
+
95
+ /**
96
+ * The text between the parenthesis that opens at `open` and the one that closes it, skipping strings and comments.
97
+ * @param {string} source
98
+ * @param {number} open index of the `(`
99
+ * @returns {string}
100
+ */
101
+ export function balancedArguments(source, open) {
102
+ let depth = 0;
103
+ for (let i = open; i < source.length; i += 1) {
104
+ const c = source[i];
105
+ if (c === '"' || c === "'" || c === "`") {
106
+ for (i += 1; i < source.length && source[i] !== c; i += 1) if (source[i] === "\\") i += 1;
107
+ } else if (c === "/" && source[i + 1] === "/") {
108
+ i = source.indexOf("\n", i);
109
+ if (i < 0) break;
110
+ } else if (c === "/" && source[i + 1] === "*") {
111
+ i = source.indexOf("*/", i + 2) + 1;
112
+ if (i <= 0) break;
113
+ } else if (c === "(") depth += 1;
114
+ else if (c === ")" && --depth === 0) return source.slice(open + 1, i);
115
+ }
116
+ return source.slice(open + 1);
117
+ }
@@ -0,0 +1,15 @@
1
+ // The Krizaka Prettier config: `export { default } from "@krizaka/config/prettier";` in prettier.config.js.
2
+ /** @type {import("prettier").Config} */
3
+ const config = {
4
+ printWidth: 120,
5
+ singleQuote: false,
6
+ jsxSingleQuote: false,
7
+ trailingComma: "all",
8
+ semi: true,
9
+ tabWidth: 2,
10
+ useTabs: false,
11
+ arrowParens: "always",
12
+ endOfLine: "lf",
13
+ };
14
+
15
+ export default config;