feat(www): lint next/link hrefs that point at another zone

This commit is contained in:
Pamela Chia committed 2026-10-01 00:02:03 -07:00
1 parent 04666b2559
commit ab9ccf00a3
3 files changed
+120

No files matched your search

@@ -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 <a>: Next.js can't prefetch or client-navigate across zones, so <Link> 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 } })
},
}
},
}
@@ -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\'; <Link href="/pricing" />',
'import Link from \'next/link\'; <Link href="/dashboards" />',
"import Link from 'next/link'; <Link href={href} />",
'<a href="/dashboard/sign-up" />',
'import Link from \'somewhere-else\'; <Link href="/dashboard" />',
],
invalid: [
{
code: 'import Link from \'next/link\'; <Link href="/dashboard/sign-up" />',
errors: [{ messageId: 'crossZone' }],
},
{
code: 'import Link from \'next/link\'; <Link href="https://supabase.com/docs/guides/auth" />',
errors: [{ messageId: 'crossZone' }],
},
{
code: "import Link from 'next/link'; <Link href={`/docs/guides/${slug}`} />",
errors: [{ messageId: 'crossZone' }],
},
{
code: 'import NextLink from \'next/link\'; <NextLink href="/humans.txt" />',
errors: [{ messageId: 'crossZone' }],
},
],
})
+7
View File
@@ -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' },
},
])