diff --git a/packages/eslint-plugin-boxel/index.js b/packages/eslint-plugin-boxel/index.js index ba8c2700ca2..f1f9756a431 100644 --- a/packages/eslint-plugin-boxel/index.js +++ b/packages/eslint-plugin-boxel/index.js @@ -5,6 +5,7 @@ module.exports = { 'missing-card-api-import': require('./lib/rules/missing-card-api-import'), 'no-duplicate-imports': require('./lib/rules/no-duplicate-imports'), 'no-percy-direct-import': require('./lib/rules/no-percy-direct-import'), + 'no-literal-realm-urls': require('./lib/rules/no-literal-realm-urls'), // Add other rules here }, diff --git a/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js b/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js new file mode 100644 index 00000000000..3831072e7f0 --- /dev/null +++ b/packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js @@ -0,0 +1,181 @@ +'use strict'; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +// Maps each registered prefix to the set of environment-specific base URLs +// that should be replaced by it, plus optional regex patterns for dynamic +// URLs (e.g. environment-mode *.localhost subdomains). +const DEFAULT_REALM_MAPPINGS = [ + { + prefix: '@cardstack/catalog/', + urls: [ + 'http://localhost:4201/catalog/', + 'https://realms-staging.stack.cards/catalog/', + 'https://app.boxel.ai/catalog/', + ], + // Catches environment-mode URLs like http://realm-server.linty.localhost/catalog/ + patterns: ['https?://realm-server\\.[^.]+\\.localhost[^/]*/catalog/'], + }, + // Future entries: + // { + // prefix: '@cardstack/skills/', + // urls: [ + // 'http://localhost:4201/skills/', + // 'https://realms-staging.stack.cards/skills/', + // 'https://app.boxel.ai/skills/', + // ], + // patterns: ['https?://[^/]+\\.localhost[^/]*/skills/'], + // }, + // { + // prefix: '@cardstack/base/', + // urls: [ + // 'https://cardstack.com/base/', + // ], + // }, +]; + +/** + * Build a flat list of { url, prefix } pairs from the realm mappings, + * sorted longest-URL-first so that replacements are unambiguous. + */ +function buildExactReplacements(mappings) { + let pairs = []; + for (let mapping of mappings) { + for (let url of mapping.urls) { + pairs.push({ url, prefix: mapping.prefix }); + } + } + // Sort longest first so we don't accidentally match a shorter prefix + pairs.sort((a, b) => b.url.length - a.url.length); + return pairs; +} + +/** + * Build a list of { regex, prefix } from pattern-based mappings. + * Each pattern should match the full base URL up to and including + * the realm path segment (e.g. the catalog/ portion). + */ +function buildPatternReplacements(mappings) { + let result = []; + for (let mapping of mappings) { + if (!mapping.patterns) { + continue; + } + for (let pattern of mapping.patterns) { + result.push({ + regex: new RegExp(pattern), + prefix: mapping.prefix, + }); + } + } + return result; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow environment-specific realm URLs in code; use portable prefixes like @cardstack/catalog/ instead', + category: 'Best Practices', + recommended: true, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + realmMappings: { + type: 'array', + items: { + type: 'object', + properties: { + prefix: { type: 'string' }, + urls: { + type: 'array', + items: { type: 'string' }, + }, + patterns: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['prefix', 'urls'], + }, + }, + }, + additionalProperties: false, + }, + ], + messages: { + noLiteralRealmUrl: + 'Use "{{prefix}}" instead of the environment-specific URL "{{url}}".', + }, + }, + + create(context) { + let options = context.options[0] || {}; + let mappings = options.realmMappings || DEFAULT_REALM_MAPPINGS; + let exactReplacements = buildExactReplacements(mappings); + let patternReplacements = buildPatternReplacements(mappings); + + /** + * Try exact URL matches first, then regex patterns. + * Returns { matchedUrl, prefix } or null. + */ + function findMatch(value) { + // Exact matches take priority + for (let { url, prefix } of exactReplacements) { + if (value.includes(url)) { + return { matchedUrl: url, prefix }; + } + } + // Then try regex patterns + for (let { regex, prefix } of patternReplacements) { + let match = regex.exec(value); + if (match) { + return { matchedUrl: match[0], prefix }; + } + } + return null; + } + + /** + * Check a string value for environment-specific realm URLs and report/fix. + */ + function checkAndReport(node, value) { + let result = findMatch(value); + if (!result) { + return; + } + let { matchedUrl, prefix } = result; + context.report({ + node, + messageId: 'noLiteralRealmUrl', + data: { prefix, url: matchedUrl }, + fix(fixer) { + let raw = context.sourceCode.getText(node); + let fixed = raw.split(matchedUrl).join(prefix); + return fixer.replaceText(node, fixed); + }, + }); + } + + return { + Literal(node) { + if (typeof node.value === 'string') { + checkAndReport(node, node.value); + } + }, + TemplateLiteral(node) { + // For template literals, check the full source text so we catch + // URLs that span a single quasi segment + let fullText = context.sourceCode.getText(node); + checkAndReport(node, fullText); + }, + }; + }, +}; diff --git a/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js b/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js new file mode 100644 index 00000000000..08be65e79bf --- /dev/null +++ b/packages/eslint-plugin-boxel/tests/lib/rules/no-literal-realm-urls-test.js @@ -0,0 +1,141 @@ +'use strict'; + +const rule = require('../../../lib/rules/no-literal-realm-urls'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +ruleTester.run('no-literal-realm-urls', rule, { + valid: [ + // Already using prefix form + { + code: `let id = '@cardstack/catalog/PersonCard';`, + }, + // Unrelated URLs + { + code: `let url = 'https://example.com/something';`, + }, + // Partial match that isn't a realm URL + { + code: `let url = 'http://localhost:4201/other-realm/foo';`, + }, + // Template literal with prefix form + { + code: 'let id = `@cardstack/catalog/${name}`;', + }, + // .localhost but not a catalog path + { + code: `let url = 'http://realm-server.linty.localhost/other/foo';`, + }, + // .localhost with catalog path but not realm-server host + { + code: `let url = 'http://boxel.linty.localhost/catalog/foo';`, + }, + // localhost without subdomain doesn't match the pattern (matched by exact URL instead, but not /other/) + { + code: `let url = 'http://localhost:4201/other/foo';`, + }, + ], + + invalid: [ + // --- Exact URL matches --- + + // localhost catalog URL in a string literal + { + code: `let id = 'http://localhost:4201/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // staging catalog URL + { + code: `let id = 'https://realms-staging.stack.cards/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // production catalog URL + { + code: `let id = 'https://app.boxel.ai/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // URL with deeper path + { + code: `let ref = 'http://localhost:4201/catalog/fields/SkillCard.json';`, + output: `let ref = '@cardstack/catalog/fields/SkillCard.json';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Import statement with catalog URL + { + code: `import { PersonCard } from 'http://localhost:4201/catalog/person-card';`, + output: `import { PersonCard } from '@cardstack/catalog/person-card';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Template literal containing a catalog URL + { + code: 'let url = `http://localhost:4201/catalog/cards/${id}`;', + output: 'let url = `@cardstack/catalog/cards/${id}`;', + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Double-quoted string + { + code: `let id = "https://app.boxel.ai/catalog/FancyCard";`, + output: `let id = "@cardstack/catalog/FancyCard";`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Just the base URL (no trailing path beyond the realm) + { + code: `let base = 'http://localhost:4201/catalog/';`, + output: `let base = '@cardstack/catalog/';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + + // --- Pattern-based matches (environment-mode *.localhost) --- + + // Environment mode with subdomain + { + code: `let id = 'http://realm-server.linty.localhost/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Environment mode with port + { + code: `let id = 'http://realm-server.linty.localhost:4201/catalog/PersonCard';`, + output: `let id = '@cardstack/catalog/PersonCard';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Environment mode with https + { + code: `let id = 'https://realm-server.foo.localhost/catalog/deep/path/Card';`, + output: `let id = '@cardstack/catalog/deep/path/Card';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + // Environment mode — just the base URL + { + code: `let base = 'http://realm-server.linty.localhost/catalog/';`, + output: `let base = '@cardstack/catalog/';`, + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + + // --- Custom realm mappings via options --- + { + code: `let id = 'https://cardstack.com/base/card-api';`, + output: `let id = '@cardstack/base/card-api';`, + options: [ + { + realmMappings: [ + { + prefix: '@cardstack/base/', + urls: ['https://cardstack.com/base/'], + }, + ], + }, + ], + errors: [{ messageId: 'noLiteralRealmUrl' }], + }, + ], +}); diff --git a/packages/runtime-common/tasks/lint.ts b/packages/runtime-common/tasks/lint.ts index 4fe6a184ee4..2e40e23f0cf 100644 --- a/packages/runtime-common/tasks/lint.ts +++ b/packages/runtime-common/tasks/lint.ts @@ -80,6 +80,7 @@ async function lintFix({ '@cardstack/boxel/no-duplicate-imports': 'error', '@cardstack/boxel/no-css-position-fixed': 'warn', '@cardstack/boxel/no-forbidden-head-tags': 'warn', + '@cardstack/boxel/no-literal-realm-urls': 'error', }; const eslintJsModule = await import(/* webpackIgnore: true */ '@eslint/js');