@lar-works/config 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # @lar-works/config
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The toolchain settings every Lar web repository shares: Oxlint rules (with Lar's own `lar/*` rules for architecture
4
+ boundaries, design-system ownership and blank lines), Oxfmt options, TypeScript configs and test settings, for
5
+ [Vite+](https://viteplus.dev).
6
+
7
+ ```ts
8
+ // vite.config.ts
9
+ import { fmt, lint, test } from '@lar-works/config';
10
+ import { defineConfig } from 'vite-plus';
11
+
12
+ export default defineConfig({
13
+ fmt,
14
+ lint: lint({ apps: ['.'] }),
15
+ test: { ...test, environment: 'jsdom' },
16
+ });
17
+ ```
18
+
19
+ ```json
20
+ // tsconfig.json
21
+ { "extends": "@lar-works/config/tsconfig/app.json" }
22
+ ```
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@lar-works/config",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Lar's shared toolchain settings: Oxlint rules and plugins, Oxfmt options, TypeScript and test settings.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/maddec01/lar-web.git",
9
+ "directory": "packages/config"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.d.ts",
15
+ "default": "./src/index.js"
16
+ },
17
+ "./lint-plugin": "./src/plugins/lar.js",
18
+ "./tsconfig/app.json": "./tsconfig/app.json",
19
+ "./tsconfig/library.json": "./tsconfig/library.json",
20
+ "./tsconfig/node.json": "./tsconfig/node.json"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "tsconfig"
25
+ ],
26
+ "peerDependencies": {
27
+ "vite-plus": "^1.5.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^24.19.2",
31
+ "typescript": "^7.0.2",
32
+ "vite-plus": "1.5.0"
33
+ },
34
+ "scripts": {
35
+ "test": "vp test run"
36
+ }
6
37
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { UserConfig } from 'vite-plus';
2
+
3
+ type LintConfig = NonNullable<UserConfig['lint']>;
4
+ type FmtConfig = NonNullable<UserConfig['fmt']>;
5
+ type TestConfig = NonNullable<UserConfig['test']>;
6
+
7
+ /** Oxfmt: Prettier's output with Lar's widths and quotes, imports sorted, Tailwind classes in Tailwind's order. */
8
+ export declare const fmt: FmtConfig;
9
+
10
+ export interface LintOptions {
11
+ /** Each app's root, relative to the repository root (`web`, `apps/web`). */
12
+ apps?: string[];
13
+ /** The roots of design-system packages, which own every visual recipe. */
14
+ designSystems?: string[];
15
+ /** Extra paths to skip, such as generated API clients. */
16
+ ignorePatterns?: string[];
17
+ }
18
+
19
+ /** Oxlint settings for a repository. */
20
+ export declare function lint(options?: LintOptions): LintConfig;
21
+
22
+ /** Test settings every package's `test` block spreads. */
23
+ export declare const test: TestConfig;
package/src/index.js ADDED
@@ -0,0 +1,107 @@
1
+ // The settings every Lar web repository spreads into its root vite.config.ts, so all of them format, lint and test
2
+ // the same way. Plain JavaScript: Vite+ loads config dependencies from node_modules, where Node strips no types.
3
+
4
+ /** Oxfmt: Prettier's output with Lar's widths and quotes, imports sorted, Tailwind classes in Tailwind's order. */
5
+ export const fmt = {
6
+ printWidth: 120,
7
+ tabWidth: 4,
8
+ singleQuote: true,
9
+ trailingComma: 'all',
10
+ sortImports: {
11
+ groups: [
12
+ 'side_effect_style',
13
+ 'side_effect',
14
+ ['builtin', 'external', 'type-builtin', 'type-external'],
15
+ ['internal', 'type-internal', 'parent', 'type-parent', 'sibling', 'type-sibling', 'index', 'type-index'],
16
+ 'unknown',
17
+ ],
18
+ internalPattern: ['@/'],
19
+ },
20
+ sortTailwindcss: {
21
+ functions: ['clsx', 'cn', 'tv'],
22
+ },
23
+ sortPackageJson: false,
24
+ ignorePatterns: ['**/generated/**', '**/CHANGELOG.md', 'pnpm-lock.yaml'],
25
+ };
26
+
27
+ const baseRules = {
28
+ 'no-duplicate-imports': ['error', { allowSeparateTypeImports: true }],
29
+ 'no-unused-expressions': 'error',
30
+ 'no-empty': ['error', { allowEmptyCatch: true }],
31
+ 'no-unused-vars': [
32
+ 'error',
33
+ {
34
+ vars: 'all',
35
+ varsIgnorePattern: '^_',
36
+ args: 'after-used',
37
+ argsIgnorePattern: '^_',
38
+ caughtErrorsIgnorePattern: '^_',
39
+ },
40
+ ],
41
+ 'typescript/no-explicit-any': 'error',
42
+ 'typescript/no-require-imports': 'error',
43
+ 'typescript/no-empty-object-type': 'error',
44
+ 'react/rules-of-hooks': 'error',
45
+ 'react/exhaustive-deps': 'warn',
46
+ 'react/only-export-components': ['warn', { allowConstantExport: true }],
47
+ // Lar apps don't run the React Compiler, so libraries it can't memoize (TanStack Virtual) are fine.
48
+ 'react/incompatible-library': 'off',
49
+ 'lar/padding-lines': 'error',
50
+ };
51
+
52
+ /** Globs under an app's root; `.` is the repository root itself. */
53
+ const under = (root, glob) => (root === '.' ? glob : `${root}/${glob}`);
54
+ const sourceFiles = (root) => [under(root, 'src/**/*.ts'), under(root, 'src/**/*.tsx')];
55
+
56
+ /**
57
+ * Oxlint settings for a repository.
58
+ *
59
+ * @param {object} options
60
+ * @param {string[]} [options.apps] Each app's root, relative to the repository root (`.`, `web`, `apps/web`).
61
+ * Its `src` follows Lar's layers (app, features, shared, services) and holds no visual recipes.
62
+ * @param {string[]} [options.designSystems] The roots of design-system packages, which own every visual recipe.
63
+ * @param {string[]} [options.ignorePatterns] Extra paths to skip, such as generated API clients.
64
+ */
65
+ export function lint({ apps = [], designSystems = [], ignorePatterns = [] } = {}) {
66
+ return {
67
+ plugins: ['typescript', 'react', 'unicorn', 'oxc', 'import', 'vitest'],
68
+ jsPlugins: ['@lar-works/config/lint-plugin'],
69
+ categories: { correctness: 'error' },
70
+ env: { browser: true, es2024: true },
71
+ ignorePatterns: ['**/dist/**', '**/storybook-static/**', '**/coverage/**', ...ignorePatterns],
72
+ options: { typeAware: true, typeCheck: true },
73
+ rules: baseRules,
74
+ overrides: [
75
+ ...apps.map((app) => ({
76
+ files: sourceFiles(app),
77
+ rules: {
78
+ 'lar/boundaries': ['error', { role: 'app' }],
79
+ 'lar/no-visual-recipes': 'error',
80
+ },
81
+ })),
82
+ ...apps.map((app) => ({
83
+ files: [under(app, 'src/app/**/*.tsx'), under(app, 'src/features/**/*.tsx')],
84
+ rules: { 'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }] },
85
+ })),
86
+ ...designSystems.map((designSystem) => ({
87
+ files: sourceFiles(designSystem),
88
+ rules: { 'lar/boundaries': ['error', { role: 'design-system' }] },
89
+ })),
90
+ {
91
+ files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.mjs', '**/test/**', '**/*.stories.tsx'],
92
+ rules: {
93
+ 'max-lines': 'off',
94
+ 'react/only-export-components': 'off',
95
+ 'vitest/require-mock-type-parameters': 'off',
96
+ 'vitest/no-conditional-expect': 'off',
97
+ },
98
+ },
99
+ ],
100
+ };
101
+ }
102
+
103
+ /** Test settings every package's `test` block spreads: logs only from failing tests, no performance hints. */
104
+ export const test = {
105
+ silent: 'passed-only',
106
+ experimental: { diagnostics: false },
107
+ };
@@ -0,0 +1,274 @@
1
+ // Lar's own Oxlint rules. Oxlint loads this file as a JS plugin (`jsPlugins`), so it is plain JavaScript with no
2
+ // imports: it runs from whichever repository's node_modules holds it.
3
+ //
4
+ // lar/boundaries keeps an app's layers (app, features, shared, services) and the design system apart.
5
+ // lar/no-visual-recipes keeps every visual decision inside the design system: apps compose its components.
6
+
7
+ import path from 'node:path';
8
+
9
+ const sourcePath = (filename) => filename.replaceAll(path.sep, '/').split('/src/').slice(1).join('/src/');
10
+ const isTest = (filename) => /(?:\.test[.-]|\.stories\.|\/test\/|\/testing\/|-test[.-])/.test(filename);
11
+ const isPublicEntry = (rest) => ['', '/index', '/index.ts', '/index.tsx'].includes(rest);
12
+
13
+ /** Resolves aliases and relative imports, so moving a file cannot bypass the boundaries. */
14
+ function importedPath(filename, specifier) {
15
+ if (specifier.startsWith('@/')) return specifier.slice(2);
16
+
17
+ if (specifier.startsWith('.')) return sourcePath(path.resolve(path.dirname(filename), specifier));
18
+
19
+ return undefined;
20
+ }
21
+
22
+ /** Packages only the design system may use: apps reach them through its public API. */
23
+ const designSystemOnly = /^(@ninna-ui\/|lucide-react(\/|$)|@radix-ui\/|@floating-ui\/|tailwind-merge$|clsx$)/;
24
+
25
+ /** Application state, routing and localization, which design-system components receive as props instead. */
26
+ const applicationState = /^(react-i18next|i18next|react-router|react-router-dom|@tanstack\/react-query|zustand)(\/|$)/;
27
+
28
+ const designSystemPackage = '@lar-works/design-system';
29
+
30
+ const boundaries = {
31
+ meta: {
32
+ type: 'problem',
33
+ schema: [
34
+ {
35
+ type: 'object',
36
+ properties: { role: { enum: ['app', 'design-system'] } },
37
+ additionalProperties: false,
38
+ },
39
+ ],
40
+ messages: { boundary: '{{reason}}' },
41
+ },
42
+ create(context) {
43
+ const role = context.options[0]?.role ?? 'app';
44
+ const filename = context.filename;
45
+ const source = sourcePath(filename);
46
+
47
+ if (!source || isTest(filename)) return {};
48
+
49
+ const report = (node, reason) => context.report({ node, messageId: 'boundary', data: { reason } });
50
+
51
+ function checkDesignSystem(node, specifier) {
52
+ if (applicationState.test(specifier))
53
+ report(
54
+ node,
55
+ 'Design-system components receive labels, values and callbacks; they do not own application state, routing or localization.',
56
+ );
57
+
58
+ const target = importedPath(filename, specifier);
59
+
60
+ if (
61
+ target &&
62
+ /^[^/]+(?:\/index(?:\.tsx?)?)?$/.test(target) &&
63
+ !target.startsWith('index') &&
64
+ ['ImportDeclaration', 'ImportExpression'].includes(node.type)
65
+ )
66
+ report(node, 'Inside the design system, import the owning module directly to avoid barrel cycles.');
67
+ }
68
+
69
+ function checkApp(node, specifier) {
70
+ const sourceFeature = source.match(/^features\/([^/]+)\//)?.[1];
71
+ const target = importedPath(filename, specifier);
72
+
73
+ if (!target) {
74
+ if (designSystemOnly.test(specifier))
75
+ report(
76
+ node,
77
+ `Use the ${designSystemPackage} public API; only the design system uses ${specifier}.`,
78
+ );
79
+
80
+ if (specifier.startsWith(designSystemPackage) && source.startsWith('services/'))
81
+ report(node, 'Services own data access and server state, never UI.');
82
+
83
+ return;
84
+ }
85
+
86
+ if (source.startsWith('shared/') && /^(app|features|services)\//.test(target))
87
+ report(node, 'Shared foundations cannot depend on application, features or services.');
88
+
89
+ if (source.startsWith('services/') && /^(app|features)\//.test(target))
90
+ report(node, 'Services own data access and server state, never UI.');
91
+
92
+ if (sourceFeature && target.startsWith('app/'))
93
+ report(node, 'Features cannot depend on the application composition root.');
94
+
95
+ const targetFeature = target.match(/^features\/([^/]+)(.*)$/);
96
+
97
+ if (targetFeature && sourceFeature !== targetFeature[1] && !isPublicEntry(targetFeature[2]))
98
+ report(node, 'Cross-feature consumers must use the feature public API.');
99
+
100
+ if (targetFeature && sourceFeature === targetFeature[1] && isPublicEntry(targetFeature[2]))
101
+ report(node, 'Inside a feature, import the owning module directly to avoid barrel cycles.');
102
+
103
+ if (target.startsWith('test/') || /\.test[.-]|-test[.-]/.test(target))
104
+ report(node, 'Test support must not enter the application dependency graph.');
105
+ }
106
+
107
+ function check(node) {
108
+ const specifier = node.source?.value;
109
+
110
+ if (typeof specifier !== 'string') return;
111
+
112
+ if (role === 'design-system') checkDesignSystem(node, specifier);
113
+ else checkApp(node, specifier);
114
+ }
115
+
116
+ return {
117
+ ImportDeclaration: check,
118
+ ImportExpression: check,
119
+ ExportNamedDeclaration: check,
120
+ ExportAllDeclaration: check,
121
+ };
122
+ },
123
+ };
124
+
125
+ const rawElements = /^(h1|h2|h3|h4|h5|h6|p|button|input|select|textarea|svg)$/;
126
+
127
+ /** Design-system components whose geometry and colour props are looks: apps pick a named recipe instead. */
128
+ const recipeComponents =
129
+ /^(Text|Heading|Link|Button|IconButton|Badge|Stack|Inline|Grid|Avatar|Loading|StatusIndicator|Alert)$/;
130
+ const lookProps = /^(size|color|weight|gap|radius|minChildWidth|fontSize|lineHeight|letterSpacing)$/;
131
+
132
+ /** Form controls, whose styling and geometry the design system owns entirely. */
133
+ const formControls = /^(Input|Select|Field|FormField|Textarea)$/;
134
+ const formLookProps = /^(fullWidth|size|variant|color)$/;
135
+
136
+ const noVisualRecipes = {
137
+ meta: {
138
+ type: 'problem',
139
+ schema: [],
140
+ messages: {
141
+ className:
142
+ 'Visual recipes belong in the design system, not application code. Use or add a component there.',
143
+ style: 'Inline styles belong in the design system. Use or add a component there.',
144
+ element:
145
+ 'Use the design-system component for <{{name}}> so typography, accessibility, styling and loading stay consistent.',
146
+ lookProp:
147
+ 'Choose a semantic recipe instead of `{{prop}}`: typography, geometry, colour and spacing belong to the design system.',
148
+ },
149
+ },
150
+ create(context) {
151
+ if (isTest(context.filename)) return {};
152
+
153
+ /** The local names this file imports from the design system; only their props are checked. */
154
+ const designSystemNames = new Set();
155
+
156
+ return {
157
+ ImportDeclaration(node) {
158
+ if (typeof node.source.value !== 'string' || !node.source.value.startsWith(designSystemPackage)) return;
159
+
160
+ for (const specifier of node.specifiers) designSystemNames.add(specifier.local.name);
161
+ },
162
+ JSXAttribute(node) {
163
+ const name = node.name?.name;
164
+ const element = node.parent?.name;
165
+
166
+ if (name === 'className') context.report({ node, messageId: 'className' });
167
+ else if (name === 'style') context.report({ node, messageId: 'style' });
168
+ else if (
169
+ element?.type === 'JSXIdentifier' &&
170
+ designSystemNames.has(element.name) &&
171
+ typeof name === 'string' &&
172
+ ((recipeComponents.test(element.name) && lookProps.test(name)) ||
173
+ (formControls.test(element.name) && formLookProps.test(name)))
174
+ )
175
+ context.report({ node, messageId: 'lookProp', data: { prop: name } });
176
+ },
177
+ JSXOpeningElement(node) {
178
+ if (node.name?.type === 'JSXIdentifier' && rawElements.test(node.name.name))
179
+ context.report({ node, messageId: 'element', data: { name: node.name.name } });
180
+ },
181
+ };
182
+ },
183
+ };
184
+
185
+ const blocks = new Set(['ClassDeclaration', 'IfStatement', 'WhileStatement', 'SwitchStatement', 'TryStatement']);
186
+
187
+ /** The statement kinds ESLint's padding-line-between-statements names, for the ones Lar's rule uses. */
188
+ function kind(node) {
189
+ if (node.type === 'ExpressionStatement' && typeof node.directive === 'string') return 'directive';
190
+
191
+ if (node.type === 'ImportDeclaration') return 'import';
192
+
193
+ if (node.type === 'VariableDeclaration') return 'variable';
194
+
195
+ if (node.type === 'ReturnStatement') return 'return';
196
+
197
+ if (node.type === 'FunctionDeclaration' || blocks.has(node.type)) return 'block';
198
+
199
+ return 'other';
200
+ }
201
+
202
+ /** Whether a blank line must, may or need not separate two statements: the last matching row wins, as in ESLint. */
203
+ function padding(prev, next) {
204
+ let result = 'any';
205
+
206
+ if (prev === 'directive') result = next === 'directive' ? 'any' : 'always';
207
+
208
+ if (prev === 'import') result = next === 'import' ? 'any' : 'always';
209
+
210
+ if (next === 'variable' || prev === 'variable') result = 'always';
211
+
212
+ if (prev === 'variable' && next === 'variable') result = 'any';
213
+
214
+ if (next === 'block' || prev === 'block') result = 'always';
215
+
216
+ if (next === 'return') result = 'always';
217
+
218
+ return result;
219
+ }
220
+
221
+ const range = (node) => node.range ?? [node.start, node.end];
222
+
223
+ /**
224
+ * Lar's blank lines between statements, which Prettier and Oxfmt leave alone: after directives and imports, around
225
+ * variable declarations, around blocks (class, function, if, while, switch, try) and before a return.
226
+ */
227
+ const paddingLines = {
228
+ meta: {
229
+ type: 'layout',
230
+ fixable: 'whitespace',
231
+ schema: [],
232
+ messages: { expected: 'Expected a blank line before this statement.' },
233
+ },
234
+ create(context) {
235
+ const text = context.sourceCode.text;
236
+
237
+ function checkList(statements) {
238
+ for (let index = 1; index < statements.length; index += 1) {
239
+ const prev = statements[index - 1];
240
+ const next = statements[index];
241
+
242
+ if (padding(kind(prev), kind(next)) !== 'always') continue;
243
+
244
+ const between = text.slice(range(prev)[1], range(next)[0]);
245
+
246
+ if (/\n[ \t]*\r?\n/.test(between)) continue;
247
+
248
+ context.report({
249
+ node: next,
250
+ messageId: 'expected',
251
+ fix(fixer) {
252
+ const lineEnd = text.indexOf('\n', range(prev)[1]);
253
+
254
+ if (lineEnd === -1 || lineEnd > range(next)[0]) return null;
255
+
256
+ return fixer.insertTextAfterRange([lineEnd, lineEnd], '\n');
257
+ },
258
+ });
259
+ }
260
+ }
261
+
262
+ return {
263
+ Program: (node) => checkList(node.body),
264
+ BlockStatement: (node) => checkList(node.body),
265
+ StaticBlock: (node) => checkList(node.body),
266
+ SwitchCase: (node) => checkList(node.consequent),
267
+ };
268
+ },
269
+ };
270
+
271
+ export default {
272
+ meta: { name: 'lar' },
273
+ rules: { boundaries, 'no-visual-recipes': noVisualRecipes, 'padding-lines': paddingLines },
274
+ };
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "Lar web app",
4
+ "compilerOptions": {
5
+ "target": "es2023",
6
+ "lib": ["es2023", "dom", "dom.iterable"],
7
+ "module": "preserve",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "types": ["vite-plus/client"],
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "allowImportingTsExtensions": true,
14
+ "erasableSyntaxOnly": true,
15
+ "verbatimModuleSyntax": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "resolveJsonModule": true,
19
+ "skipLibCheck": true
20
+ }
21
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "Lar web library",
4
+ "extends": "./app.json",
5
+ "compilerOptions": {
6
+ "noEmit": false,
7
+ "declaration": true,
8
+ "emitDeclarationOnly": true,
9
+ "allowImportingTsExtensions": false,
10
+ "isolatedDeclarations": false
11
+ }
12
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "Lar Node scripts and configs",
4
+ "compilerOptions": {
5
+ "target": "es2023",
6
+ "lib": ["es2023"],
7
+ "module": "nodenext",
8
+ "moduleResolution": "nodenext",
9
+ "types": ["node"],
10
+ "strict": true,
11
+ "noEmit": true,
12
+ "allowImportingTsExtensions": true,
13
+ "erasableSyntaxOnly": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noUncheckedIndexedAccess": true,
16
+ "skipLibCheck": true
17
+ }
18
+ }