diff --git a/apps/www/eslint-rules/no-cross-zone-link.cjs b/apps/www/eslint-rules/no-cross-zone-link.cjs new file mode 100644 index 00000000000..857777b977f --- /dev/null +++ b/apps/www/eslint-rules/no-cross-zone-link.cjs @@ -0,0 +1,74 @@ +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const vm = require('node:vm') + +const SUPABASE_ORIGIN = 'https://supabase.com' + +// Evaluated as production: the /docs rewrite only exists there, and /docs links must be flagged in every environment. +function loadProductionRewrites() { + const rewritesModule = { exports: [] } + const source = readFileSync(join(__dirname, '../lib/rewrites.js'), 'utf8') + vm.runInNewContext(source, { + module: rewritesModule, + process: { env: { NEXT_PUBLIC_VERCEL_ENV: 'production' } }, + }) + return rewritesModule.exports +} + +const crossZonePrefixes = loadProductionRewrites() + .filter(({ destination }) => !destination.startsWith('/')) + .map(({ source }) => source.replace(/\/(:path\*)?$/, '')) + +function isCrossZonePath(href) { + const path = href.startsWith(SUPABASE_ORIGIN) ? href.slice(SUPABASE_ORIGIN.length) : href + const pathname = path.split(/[?#]/)[0] + return crossZonePrefixes.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ) +} + +function staticHrefPrefix(value) { + if (!value) return null + if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null + if (value.type !== 'JSXExpressionContainer') return null + + const { expression } = value + if (expression.type === 'Literal' && typeof expression.value === 'string') return expression.value + if (expression.type === 'TemplateLiteral') return expression.quasis[0].value.cooked + return null +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + messages: { + crossZone: + "'{{href}}' is served by another zone. Use a plain : Next.js can't prefetch or client-navigate across zones, so 404s its prefetch.", + }, + schema: [], + }, + create(context) { + const linkNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'next/link') return + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') linkNames.add(specifier.local.name) + } + }, + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || !linkNames.has(node.name.name)) return + + const hrefAttribute = node.attributes.find( + (attribute) => attribute.type === 'JSXAttribute' && attribute.name.name === 'href' + ) + const href = staticHrefPrefix(hrefAttribute?.value) + if (!href || !isCrossZonePath(href)) return + + context.report({ node: hrefAttribute, messageId: 'crossZone', data: { href } }) + }, + } + }, +} diff --git a/apps/www/eslint-rules/no-cross-zone-link.test.ts b/apps/www/eslint-rules/no-cross-zone-link.test.ts new file mode 100644 index 00000000000..7b6e70c82cb --- /dev/null +++ b/apps/www/eslint-rules/no-cross-zone-link.test.ts @@ -0,0 +1,39 @@ +import { RuleTester } from 'eslint' +import { describe, it } from 'vitest' + +import noCrossZoneLink from './no-cross-zone-link.cjs' + +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester({ + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, +}) + +ruleTester.run('no-cross-zone-link', noCrossZoneLink, { + valid: [ + 'import Link from \'next/link\'; ', + 'import Link from \'next/link\'; ', + "import Link from 'next/link'; ", + '', + 'import Link from \'somewhere-else\'; ', + ], + invalid: [ + { + code: 'import Link from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + { + code: 'import Link from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + { + code: "import Link from 'next/link'; ", + errors: [{ messageId: 'crossZone' }], + }, + { + code: 'import NextLink from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + ], +}) diff --git a/apps/www/eslint.config.cjs b/apps/www/eslint.config.cjs index 4f6ba19eb29..37ec0adc370 100644 --- a/apps/www/eslint.config.cjs +++ b/apps/www/eslint.config.cjs @@ -1,6 +1,8 @@ const { defineConfig } = require('eslint/config') const supabaseConfig = require('eslint-config-supabase/next') +const noCrossZoneLink = require('./eslint-rules/no-cross-zone-link.cjs') + module.exports = defineConfig([ supabaseConfig, { @@ -19,4 +21,9 @@ module.exports = defineConfig([ 'react/no-children-prop': 'warn', }, }, + { + files: ['**/*.{jsx,tsx}'], + plugins: { www: { rules: { 'no-cross-zone-link': noCrossZoneLink } } }, + rules: { 'www/no-cross-zone-link': 'error' }, + }, ])