Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions ESLint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# @cratis/eslint-plugin-components

ESLint rules for projects that consume Cratis Components. Compose these on top of the
Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cratis/eslint-config).

| Rule | What it does |
|---|---|
| `no-root-barrel-import` | Disallows importing from the `@cratis/components` root barrel. Use a subpath export (`@cratis/components/CommandDialog`, `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …) — the root pulls the whole optional-peer-heavy surface and hides intent. |
| `no-primereact-dialog` | Disallows importing `Dialog` from `primereact/dialog`. Use `CommandDialog` from `@cratis/components/CommandDialog`, or `Dialog` from `@cratis/components/Dialogs` — the wrappers add Arc command binding, overlay/focus fixes, and theming. |

Both cover `import` and re-`export … from` forms.

## Install

```sh
yarn add -D @cratis/eslint-plugin-components @cratis/eslint-config eslint
```

## Use

```js
// eslint.config.mjs
import cratis from '@cratis/eslint-config';
import components from '@cratis/eslint-plugin-components';

export default [
...cratis.configs.consumer,
...components.configs.recommended,
// …your project rules
];
```

### Options

```js
'@cratis/components/no-root-barrel-import': ['error', {
packageName: '@cratis/components', // barrel to forbid
allow: [], // exact specifiers to permit
}],
'@cratis/components/no-primereact-dialog': ['error', {
source: 'primereact/dialog', // module to forbid
}],
```
41 changes: 41 additions & 0 deletions ESLint/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createRequire } from 'node:module';
import { noPrimereactDialog } from './lib/noPrimereactDialog.js';
import { noRootBarrelImport } from './lib/noRootBarrelImport.js';

const { version } = createRequire(import.meta.url)('./package.json');

// A single flat-config plugin object — meta + rules + self-referencing configs — per the
// ESLint flat-config plugin convention. The default export IS the plugin, so consumers
// get `components.meta`, `components.rules`, and `components.configs` directly. Composes
// on top of @cratis/eslint-config.
const plugin = {
meta: { name: '@cratis/eslint-plugin-components', version },
rules: {
'no-primereact-dialog': noPrimereactDialog,
'no-root-barrel-import': noRootBarrelImport,
},
configs: {},
};

// configs reference the plugin itself, so they are assigned after it exists.
//
// import cratis from '@cratis/eslint-config';
// import components from '@cratis/eslint-plugin-components';
// export default [...cratis.configs.consumer, ...components.configs.recommended];
Object.assign(plugin.configs, {
recommended: [
{
name: '@cratis/components/recommended',
files: ['**/*.ts', '**/*.tsx'],
plugins: { '@cratis/components': plugin },
rules: {
'@cratis/components/no-primereact-dialog': 'error',
'@cratis/components/no-root-barrel-import': 'error',
},
},
],
});

export default plugin;
export const { configs, rules, meta } = plugin;
export { noPrimereactDialog, noRootBarrelImport };
45 changes: 45 additions & 0 deletions ESLint/lib/noPrimereactDialog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const DEFAULTS = { source: 'primereact/dialog' };

// Disallow importing PrimeReact's Dialog directly. Cratis Components wraps it with
// behavior (Arc command binding, overlay/focus fixes, theming) via CommandDialog and the
// Dialogs surface; reaching past those to `primereact/dialog` bypasses all of it. Covers
// `import` and re-`export … from` forms.
export const noPrimereactDialog = {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow importing Dialog from primereact/dialog; use the Cratis Components dialog wrappers.',
recommended: true,
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
},
schema: [{
type: 'object',
properties: {
source: { type: 'string' },
},
additionalProperties: false,
}],
messages: {
useWrapper: "Do not import from '{{source}}'. Use CommandDialog from '@cratis/components/CommandDialog', or Dialog from '@cratis/components/Dialogs'.",
},
},
create(context) {
const options = { ...DEFAULTS, ...(context.options[0] ?? {}) };

const check = node => {
const source = node.source;
if (!source || typeof source.value !== 'string') return;
if (source.value === options.source) {
context.report({ node: source, messageId: 'useWrapper', data: { source: options.source } });
}
};

return {
ImportDeclaration: check,
ExportNamedDeclaration: check,
ExportAllDeclaration: check,
};
},
};

export default noPrimereactDialog;
48 changes: 48 additions & 0 deletions ESLint/lib/noRootBarrelImport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const DEFAULTS = { packageName: '@cratis/components', allow: [] };

// Disallow importing from the Cratis Components root barrel. The package exposes
// purpose-built subpath exports (`@cratis/components/CommandDialog`,
// `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …); importing the root
// pulls the whole optional-peer-heavy surface and hides intent. Covers `import` and
// re-`export … from` forms.
export const noRootBarrelImport = {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow importing from the @cratis/components root barrel; use a subpath export.',
recommended: true,
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
},
schema: [{
type: 'object',
properties: {
packageName: { type: 'string' },
allow: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
}],
messages: {
useSubpath: "Import from a '{{packageName}}' subpath (e.g. '{{packageName}}/CommandDialog'), not the package root barrel.",
},
},
create(context) {
const options = { ...DEFAULTS, ...(context.options[0] ?? {}) };
const allow = new Set(options.allow);

const check = node => {
const source = node.source;
if (!source || typeof source.value !== 'string') return;
if (source.value === options.packageName && !allow.has(source.value)) {
context.report({ node: source, messageId: 'useSubpath', data: { packageName: options.packageName } });
}
};

return {
ImportDeclaration: check,
ExportNamedDeclaration: check,
ExportAllDeclaration: check,
};
},
};

export default noRootBarrelImport;
38 changes: 38 additions & 0 deletions ESLint/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@cratis/eslint-plugin-components",
"version": "0.0.0",
"description": "Cratis Components ESLint rules: import from subpaths, not the root barrel, and use the Cratis dialog wrappers instead of primereact/dialog. Compose on top of @cratis/eslint-config.",
"author": "Cratis",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/Cratis/Components"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"eslint",
"eslintplugin",
"eslint-plugin",
"cratis",
"components"
],
"files": [
"index.js",
"lib",
"README.md"
],
"exports": {
"./package.json": "./package.json",
".": "./index.js"
},
"scripts": {
"test": "yarn g:test",
"ci": "yarn g:test"
},
"peerDependencies": {
"eslint": ">=9"
}
}
66 changes: 66 additions & 0 deletions ESLint/test/rules.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { RuleTester } from 'eslint';
import { afterAll, describe, it } from 'vitest';
import { noPrimereactDialog } from '../lib/noPrimereactDialog.js';
import { noRootBarrelImport } from '../lib/noRootBarrelImport.js';

RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;

const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
});

ruleTester.run('no-root-barrel-import', noRootBarrelImport, {
valid: [
"import { CommandDialog } from '@cratis/components/CommandDialog';",
"import { DataPage } from '@cratis/components/DataPage';",
"import { useState } from 'react';",
"export { Toolbar } from '@cratis/components/Toolbar';",
// Not the same package — a longer name that merely starts the same.
"import x from '@cratis/components-extra';",
],
invalid: [
{
code: "import { Button } from '@cratis/components';",
errors: [{ messageId: 'useSubpath', data: { packageName: '@cratis/components' } }],
},
{
code: "export { Button } from '@cratis/components';",
errors: [{ messageId: 'useSubpath' }],
},
{
code: "export * from '@cratis/components';",
errors: [{ messageId: 'useSubpath' }],
},
{
// Configurable package name.
code: "import x from '@acme/ui';",
options: [{ packageName: '@acme/ui' }],
errors: [{ messageId: 'useSubpath', data: { packageName: '@acme/ui' } }],
},
],
});

ruleTester.run('no-primereact-dialog', noPrimereactDialog, {
valid: [
"import { CommandDialog } from '@cratis/components/CommandDialog';",
"import { Dialog } from '@cratis/components/Dialogs';",
"import { Button } from 'primereact/button';",
],
invalid: [
{
code: "import { Dialog } from 'primereact/dialog';",
errors: [{ messageId: 'useWrapper', data: { source: 'primereact/dialog' } }],
},
{
code: "import Dialog from 'primereact/dialog';",
errors: [{ messageId: 'useWrapper' }],
},
{
code: "export { Dialog } from 'primereact/dialog';",
errors: [{ messageId: 'useWrapper' }],
},
],
});
6 changes: 6 additions & 0 deletions ESLint/vitest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
test: {
include: ['test/**/*.test.js'],
environment: 'node',
},
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"private": true,
"workspaces": [
"Source"
"Source",
"ESLint"
],
"engines": {
"node": ">=23.0.0"
Expand Down
15 changes: 12 additions & 3 deletions run-task-on-workspaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function updateDependencyVersionsFromLocalWorkspaces(file, packageJson, version)
}
}

const publishFailures = [];

for (const workspaceName in workspaces) {
const workspaceRelativeLocation = workspaces[workspaceName];
const workspaceAbsoluteLocation = path.join(process.cwd(), workspaceRelativeLocation);
Expand Down Expand Up @@ -108,9 +110,11 @@ for (const workspaceName in workspaces) {
console.log(result.stdout.toString());
console.log(result.stderr.toString());
if (result.status !== 0) {
console.log(`Error publishing workspace '${workspaceName}'`);
process.exit(1);
return;
// Don't abort the release: a single workspace failing (e.g. a brand-new
// package whose npm trusted publisher isn't configured yet) must not
// strand the other packages. Collect and fail at the end instead.
console.log(`Error publishing workspace '${workspaceName}' - continuing with remaining workspaces`);
publishFailures.push(workspaceName);
}
}
} else {
Expand All @@ -133,3 +137,8 @@ for (const workspaceName in workspaces) {
}
}
}

if (publishFailures.length > 0) {
console.log(`\n${publishFailures.length} workspace(s) failed to publish: ${publishFailures.join(', ')}`);
process.exit(1);
}
Loading