diff --git a/ESLint/README.md b/ESLint/README.md new file mode 100644 index 0000000..cd0ba89 --- /dev/null +++ b/ESLint/README.md @@ -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 +}], +``` diff --git a/ESLint/index.js b/ESLint/index.js new file mode 100644 index 0000000..5498ac0 --- /dev/null +++ b/ESLint/index.js @@ -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 }; diff --git a/ESLint/lib/noPrimereactDialog.js b/ESLint/lib/noPrimereactDialog.js new file mode 100644 index 0000000..bb20d5e --- /dev/null +++ b/ESLint/lib/noPrimereactDialog.js @@ -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; diff --git a/ESLint/lib/noRootBarrelImport.js b/ESLint/lib/noRootBarrelImport.js new file mode 100644 index 0000000..03ae4a9 --- /dev/null +++ b/ESLint/lib/noRootBarrelImport.js @@ -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; diff --git a/ESLint/package.json b/ESLint/package.json new file mode 100644 index 0000000..212a77a --- /dev/null +++ b/ESLint/package.json @@ -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" + } +} diff --git a/ESLint/test/rules.test.js b/ESLint/test/rules.test.js new file mode 100644 index 0000000..74a339f --- /dev/null +++ b/ESLint/test/rules.test.js @@ -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' }], + }, + ], +}); diff --git a/ESLint/vitest.config.js b/ESLint/vitest.config.js new file mode 100644 index 0000000..a0990f5 --- /dev/null +++ b/ESLint/vitest.config.js @@ -0,0 +1,6 @@ +export default { + test: { + include: ['test/**/*.test.js'], + environment: 'node', + }, +}; diff --git a/package.json b/package.json index f83da01..0236d16 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "private": true, "workspaces": [ - "Source" + "Source", + "ESLint" ], "engines": { "node": ">=23.0.0" diff --git a/run-task-on-workspaces.js b/run-task-on-workspaces.js index a108d71..069c0a5 100755 --- a/run-task-on-workspaces.js +++ b/run-task-on-workspaces.js @@ -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); @@ -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 { @@ -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); +}