Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/eslint-plugin-boxel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},

Expand Down
181 changes: 181 additions & 0 deletions packages/eslint-plugin-boxel/lib/rules/no-literal-realm-urls.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
},
};
Original file line number Diff line number Diff line change
@@ -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' }],
},
],
});
1 change: 1 addition & 0 deletions packages/runtime-common/tasks/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading