Repository: HiDeoo/starlight-links-validator (ts)

Issue:
Ability to evaluate "external" links that are really just internal links
### Is your feature request related to a problem?

Limitation when people specify a link externally (`https://developers.cloudflare.com/workers/`) that should really be evaluated (and flagged) as if they're internal links (`/workers/`).

This situation skips evaluation, as demonstrated in this [test PR](https://github.com/cloudflare/cloudflare-docs/pull/19799/files#diff-0e116de756c88231bc3c18adc9465a833e6d9210b0039c12fcaeabd01adc2238R20) || [CI run](https://github.com/cloudflare/cloudflare-docs/actions/runs/13183588128/job/36800222268?pr=19799#step:9:11367)



### Describe the solution you'd like

I'd love it if you were able to specify a `host` or at least a pattern to ignore so that these links would be evaluated as internal.

That way, we could accept the links and know that they were valid and then fix in the background gradually.

### Describe alternatives you've considered

We have linting around this in PRs, but it does add friction for contributors.
https://github.com/cloudflare/cloudflare-docs/pull/19799/files#r1945066002

As long we can evaluate these links successfully, we'd love to just not even worry about this in reviews and fix these on a cadence.

### Additional Context

_No response_

Pull request description:
Add `sameSitePolicy` and `components` options
Close #90 

Patch:
diff --git a/.changeset/short-crabs-promise.md b/.changeset/short-crabs-promise.md
new file mode 100644
index 0000000..3d0bcb0
--- /dev/null
+++ b/.changeset/short-crabs-promise.md
@@ -0,0 +1,7 @@
+---
+'starlight-links-validator': minor
+---
+
+Adds a new [`sameSitePolicy` option](https://starlight-links-validator.vercel.app/configuration#samesitepolicy) to configure how external links pointing to the same origin as the one configured in the [Astro `site` option](https://docs.astro.build/en/reference/configuration-reference/#site) should be handled.
+
+The current default behavior to ignore all external links remains unchanged. This new option allows to error on such links so they can be rewritten without the origin or to validate them as if they were internal links.
diff --git a/.changeset/witty-deers-explode.md b/.changeset/witty-deers-explode.md
new file mode 100644
index 0000000..05504fb
--- /dev/null
+++ b/.changeset/witty-deers-explode.md
@@ -0,0 +1,5 @@
+---
+'starlight-links-validator': minor
+---
+
+Adds a new [`components`](https://starlight-links-validator.vercel.app/configuration#components) option to define additional components and their props to validate as links on top of the built-in `<LinkButton>` and `<LinkCard>` Starlight components.
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index 142824d..b9eb2fa 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -28,6 +28,36 @@ export default defineConfig({
 
 You can pass the following options to the Starlight Links Validator plugin.
 
+### `components`
+
+**Type:** `[component: string, prop: string][]`  
+**Default:** `[]`
+
+Defines a list of additional components and their props that should be validated as links.
+
+By default, the Starlight Links Validator plugin will validate links defined in the `href` prop of the [`<LinkButton>`](https://starlight.astro.build/components/link-buttons/) and [`<LinkCard>`](https://starlight.astro.build/components/link-cards/) built-in Starlight components.
+Adding custom components to this list will allow the plugin to validate links in those components as well.
+
+```js {6}
+export default defineConfig({
+  integrations: [
+    starlight({
+      plugins: [
+        starlightLinksValidator({
+          components: [['CustomLink', 'url']],
+        }),
+      ],
+    }),
+  ],
+})
+```
+
+The example above will validate links in Starlight built-in components and also in the `url` prop of the `<CustomLink>` component.
+
+```mdx title="src/content/docs/example.mdx"
+<CustomLink url="/test/" />
+```
+
 ### `errorOnFallbackPages`
 
 **Type:** `boolean`  
@@ -175,3 +205,52 @@ export default defineConfig({
 You can use this [webpage](https://www.digitalocean.com/community/tools/glob) to generate and test glob patterns.
 
 :::
+
+### `sameSitePolicy`
+
+**Type:** `'error' | 'ignore' | 'validate'`  
+**Default:** `'ignore'`
+
+By default, the Starlight Links Validator plugin will ignore all external links, e.g. links starting with `http://` or `https://`, and will not validate them.
+
+The `sameSitePolicy` option provide a more granular control over how external links are handled when they point to the same [origin](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin) as the one configured in the [Astro `site` configuration option](https://docs.astro.build/en/reference/configuration-reference/#site).
+
+- `ignore`: Ignore all external links and do not validate them.
+- `error`: Error on external links with an origin matching the Astro `site` configuration option and hint that the link can be rewritten without the origin.
+- `validate`: Validate external links with an origin matching the Astro `site` configuration option as if they were internal links.
+
+Other external links having a different origin will be ignored.
+
+The following configuration will error on the `https://example.com/test/` external link and hint that it can be rewritten as `/test/`:
+
+```js {6,11}
+export default defineConfig({
+  integrations: [
+    starlight({
+      plugins: [
+        starlightLinksValidator({
+          sameSitePolicy: 'error',
+        }),
+      ],
+    }),
+  ],
+  site: 'https://example.com',
+})
+```
+
+The following configuration will validate the `https://example.com/test/` external link as if it was written as `/test/`:
+
+```js {6,11}
+export default defineConfig({
+  integrations: [
+    starlight({
+      plugins: [
+        starlightLinksValidator({
+          sameSitePolicy: 'validate',
+        }),
+      ],
+    }),
+  ],
+  site: 'https://example.com',
+})
+```
diff --git a/packages/starlight-links-validator/index.ts b/packages/starlight-links-validator/index.ts
index 27e1ef6..f0c8b3b 100644
--- a/packages/starlight-links-validator/index.ts
+++ b/packages/starlight-links-validator/index.ts
@@ -4,12 +4,22 @@ import { AstroError } from 'astro/errors'
 import { z } from 'astro/zod'
 
 import { clearContentLayerCache } from './libs/astro'
-import { pathnameToSlug } from './libs/path'
-import { remarkStarlightLinksValidator } from './libs/remark'
+import { pathnameToSlug, stripTrailingSlash } from './libs/path'
+import { remarkStarlightLinksValidator, type RemarkStarlightLinksValidatorConfig } from './libs/remark'
 import { logErrors, validateLinks } from './libs/validation'
 
 const starlightLinksValidatorOptionsSchema = z
   .object({
+    /**
+     * Defines a list of additional components and their props that should be validated as links.
+     *
+     * By default, the plugin will only validate links defined in the `href` prop of the `<LinkButton>` and `<LinkCard>`
+     * built-in Starlight components.
+     * Adding custom components to this list will allow the plugin to validate links in those components as well.
+     *
+     * @default []
+     */
+    components: z.tuple([z.string(), z.string()]).array().default([]),
     /**
      * Defines whether the plugin should error on fallback pages.
      *
@@ -58,6 +68,20 @@ const starlightLinksValidatorOptionsSchema = z
      * @default []
      */
     exclude: z.array(z.string()).default([]),
+    /**
+     * Defines the policy for external links with an origin matching the Astro `site` option.
+     *
+     * By default, all external links are ignored and not validated by the plugin.
+     * Setting this option to `error` will make the plugin error on external links with an origin matching the Astro
+     * `site` option and hint that the link can be rewritten without the origin.
+     * Setting this option to `validate` will make the plugin validate external links with an origin matching the Astro
+     * `site` option as if they were internal links.
+     *
+     * @default 'ignore'
+     * @see https://docs.astro.build/en/reference/configuration-reference/#site
+     * @see https://developer.mozilla.org/en-US/docs/Web/API/URL/origin
+     */
+    sameSitePolicy: z.enum(['error', 'ignore', 'validate']).default('ignore'),
   })
   .default({})
 
@@ -75,6 +99,7 @@ export default function starlightLinksValidatorPlugin(
     hooks: {
       'config:setup'({ addIntegration, astroConfig, config: starlightConfig, logger }) {
         let routes: IntegrationResolvedRoute[] = []
+        const site = astroConfig.site ? stripTrailingSlash(astroConfig.site) : undefined
 
         addIntegration({
           name: 'starlight-links-validator-integration',
@@ -89,7 +114,15 @@ export default function starlightLinksValidatorPlugin(
               updateConfig({
                 markdown: {
                   remarkPlugins: [
-                    [remarkStarlightLinksValidator, { base: astroConfig.base, srcDir: astroConfig.srcDir }],
+                    [
+                      remarkStarlightLinksValidator,
+                      {
+                        base: astroConfig.base,
+                        options: options.data,
+                        site,
+                        srcDir: astroConfig.srcDir,
+                      } satisfies RemarkStarlightLinksValidatorConfig,
+                    ],
                   ],
                 },
               })
@@ -111,7 +144,7 @@ export default function starlightLinksValidatorPlugin(
 
               const errors = validateLinks(pages, customPages, dir, astroConfig, starlightConfig, options.data)
 
-              const hasInvalidLinkToCustomPage = logErrors(logger, errors)
+              const hasInvalidLinkToCustomPage = logErrors(logger, errors, site)
 
               if (errors.size > 0) {
                 throwPluginError(
diff --git a/packages/starlight-links-validator/libs/remark.ts b/packages/starlight-links-validator/libs/remark.ts
index 2c85f7b..e028868 100644
--- a/packages/starlight-links-validator/libs/remark.ts
+++ b/packages/starlight-links-validator/libs/remark.ts
@@ -3,6 +3,7 @@ import 'mdast-util-mdx-jsx'
 import nodePath from 'node:path'
 import { fileURLToPath } from 'node:url'
 
+import type { AstroConfig } from 'astro'
 import GitHubSlugger, { slug } from 'github-slugger'
 import type { Nodes } from 'hast'
 import { fromHtml } from 'hast-util-from-html'
@@ -14,17 +15,28 @@ import { toString } from 'mdast-util-to-string'
 import type { Plugin } from 'unified'
 import { visit } from 'unist-util-visit'
 
+import type { StarlightLinksValidatorOptions } from '..'
+
 import { ensureTrailingSlash, stripLeadingSlash } from './path'
+import { ValidationErrorType } from './validation'
+
+const builtInComponents: StarlightLinksValidatorOptions['components'] = [
+  ['LinkButton', 'href'],
+  ['LinkCard', 'href'],
+]
 
 // All the headings keyed by file path.
 const headings: Headings = new Map()
 // All the internal links keyed by file path.
 const links: Links = new Map()
 
-export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL }], Root> = function ({
-  base,
-  srcDir,
-}) {
+export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidatorConfig], Root> = function (config) {
+  const { base, options, srcDir } = config
+
+  const linkComponents: Record<string, string> = Object.fromEntries(
+    [...builtInComponents, ...options.components].map(([name, attribute]) => [name, attribute]),
+  )
+
   return (tree, file) => {
     if (file.data.astro?.frontmatter?.['draft']) return
 
@@ -34,7 +46,7 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
       typeof file.data.astro?.frontmatter?.['slug'] === 'string' ? file.data.astro.frontmatter['slug'] : undefined
 
     const fileHeadings: string[] = []
-    const fileLinks: string[] = []
+    const fileLinks: Link[] = []
     const fileDefinitions = new Map<string, string>()
 
     visit(tree, 'definition', (node) => {
@@ -64,18 +76,17 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
           break
         }
         case 'link': {
-          if (shouldValidateLink(node.url)) {
-            fileLinks.push(node.url)
-          }
+          const link = getLinkToValidate(node.url, config)
+          if (link) fileLinks.push(link)
 
           break
         }
         case 'linkReference': {
           const definition = fileDefinitions.get(node.identifier)
+          if (!definition) break
 
-          if (definition && shouldValidateLink(definition)) {
-            fileLinks.push(definition)
-          }
+          const link = getLinkToValidate(definition, config)
+          if (link) fileLinks.push(link)
 
           break
         }
@@ -86,22 +97,27 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
             }
           }
 
-          if (node.name !== 'a' && node.name !== 'LinkCard' && node.name !== 'LinkButton') {
+          if (!node.name) {
+            break
+          }
+
+          const componentProp = linkComponents[node.name]
+
+          if (node.name !== 'a' && !componentProp) {
             break
           }
 
           for (const attribute of node.attributes) {
             if (
               attribute.type !== 'mdxJsxAttribute' ||
-              attribute.name !== 'href' ||
+              attribute.name !== (componentProp ?? 'href') ||
               typeof attribute.value !== 'string'
             ) {
               continue
             }
 
-            if (shouldValidateLink(attribute.value)) {
-              fileLinks.push(attribute.value)
-            }
+            const link = getLinkToValidate(attribute.value, config)
+            if (link) fileLinks.push(link)
           }
 
           break
@@ -127,10 +143,10 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
               htmlNode.type === 'element' &&
               htmlNode.tagName === 'a' &&
               hasProperty(htmlNode, 'href') &&
-              typeof htmlNode.properties.href === 'string' &&
-              shouldValidateLink(htmlNode.properties.href)
+              typeof htmlNode.properties.href === 'string'
             ) {
-              fileLinks.push(htmlNode.properties.href)
+              const link = getLinkToValidate(htmlNode.properties.href, config)
+              if (link) fileLinks.push(link)
             }
           })
 
@@ -148,17 +164,31 @@ export function getValidationData() {
   return { headings, links }
 }
 
-function shouldValidateLink(link: string) {
+function getLinkToValidate(link: string, { options, site }: RemarkStarlightLinksValidatorConfig): Link | undefined {
+  const linkTovalidate = { raw: link }
+
   if (!isAbsoluteUrl(link)) {
-    return true
+    return linkTovalidate
   }
 
   try {
     const url = new URL(link)
 
+    if (options.sameSitePolicy !== 'ignore' && url.origin === site) {
+      if (options.sameSitePolicy === 'error') {
+        return { ...linkTovalidate, error: ValidationErrorType.SameSite }
+      } else {
+        let transformed = link.replace(url.origin, '')
+        if (!transformed) transformed = '/'
+        return { ...linkTovalidate, transformed }
+      }
+    }
+
     return url.hostname === 'localhost' || url.hostname === '127.0.0.1'
+      ? { ...linkTovalidate, error: ValidationErrorType.LocalLink }
+      : undefined
   } catch {
-    return false
+    return undefined
   }
 }
 
@@ -195,8 +225,21 @@ function isMdxIdAttribute(attribute: MdxJsxAttribute | MdxJsxExpressionAttribute
   return attribute.type === 'mdxJsxAttribute' && attribute.name === 'id' && typeof attribute.value === 'string'
 }
 
+export interface RemarkStarlightLinksValidatorConfig {
+  base: string
+  options: StarlightLinksValidatorOptions
+  site: AstroConfig['site']
+  srcDir: URL
+}
+
 export type Headings = Map<string, string[]>
-export type Links = Map<string, string[]>
+export type Links = Map<string, Link[]>
+
+export interface Link {
+  error?: ValidationErrorType
+  raw: string
+  transformed?: string
+}
 
 interface MdxIdAttribute {
   name: 'id'
diff --git a/packages/starlight-links-validator/libs/validation.ts b/packages/starlight-links-validator/libs/validation.ts
index 030e585..513e4ef 100644
--- a/packages/starlight-links-validator/libs/validation.ts
+++ b/packages/starlight-links-validator/libs/validation.ts
@@ -11,7 +11,7 @@ import type { StarlightLinksValidatorOptions } from '..'
 
 import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n'
 import { ensureTrailingSlash, stripLeadingSlash, stripTrailingSlash } from './path'
-import { getValidationData, type Headings } from './remark'
+import { getValidationData, type Headings, type Link } from './remark'
 
 export const ValidationErrorType = {
   InconsistentLocale: 'inconsistent locale',
@@ -20,6 +20,7 @@ export const ValidationErrorType = {
   InvalidLinkToCustomPage: 'invalid link to custom page',
   LocalLink: 'local link',
   RelativeLink: 'relative link',
+  SameSite: '{{site}} can be omitted',
   TrailingSlashMissing: 'missing trailing slash',
   TrailingSlashForbidden: 'forbidden trailing slash',
 } as const
@@ -63,7 +64,7 @@ export function validateLinks(
         pages: allPages,
       }
 
-      if (link.startsWith('#') || link.startsWith('?')) {
+      if (link.raw.startsWith('#') || link.raw.startsWith('?')) {
         if (options.errorOnInvalidHashes) {
           validateSelfHash(validationContext)
         }
@@ -76,7 +77,7 @@ export function validateLinks(
   return errors
 }
 
-export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors) {
+export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors, site: AstroConfig['site']) {
   const logger = pluginLogger.fork('')
 
   if (errors.size === 0) {
@@ -103,7 +104,7 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
     for (const [index, validationError] of validationErrors.entries()) {
       logger.info(
         `  ${blue(`${index < validationErrors.length - 1 ? '├' : '└'}─`)} ${validationError.link}${dim(
-          ` - ${validationError.type}`,
+          ` - ${formatValidationError(validationError, site)}`,
         )}`,
       )
       hasInvalidLinkToCustomPage = validationError.type === ValidationErrorType.InvalidLinkToCustomPage
@@ -125,15 +126,13 @@ function validateLink(context: ValidationContext) {
     return
   }
 
-  if (/^https?:\/\//.test(link)) {
-    if (options.errorOnLocalLinks) {
-      addError(errors, filePath, link, ValidationErrorType.LocalLink)
-    }
-
+  if (link.error) {
+    addError(errors, filePath, link, link.error)
     return
   }
 
-  const sanitizedLink = link.replace(/^\//, '')
+  const linkToValidate = link.transformed ?? link.raw
+  const sanitizedLink = linkToValidate.replace(/^\//, '')
   const segments = sanitizedLink.split('#')
 
   const path = segments[0]
@@ -143,7 +142,7 @@ function validateLink(context: ValidationContext) {
     throw new Error('Failed to validate a link with no path.')
   }
 
-  if (path.startsWith('.') || (!link.startsWith('/') && !link.startsWith('?'))) {
+  if (path.startsWith('.') || (!linkToValidate.startsWith('/') && !linkToValidate.startsWith('?'))) {
     if (options.errorOnRelativeLinks) {
       addError(errors, filePath, link, ValidationErrorType.RelativeLink)
     }
@@ -172,7 +171,7 @@ function validateLink(context: ValidationContext) {
     return
   }
 
-  if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link, localeConfig)) {
+  if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link.raw, localeConfig)) {
     addError(errors, filePath, link, ValidationErrorType.InconsistentLocale)
     return
   }
@@ -209,7 +208,7 @@ function getFileHeadings(path: string, { astroConfig, headings, localeConfig, op
  * Validate a link to an hash in the same page.
  */
 function validateSelfHash({ errors, link, filePath, headings }: ValidationContext) {
-  const hash = link.split('#')[1] ?? link
+  const hash = link.raw.split('#')[1] ?? link.raw
   const sanitizedHash = hash.replace(/^#/, '')
   const fileHeadings = headings.get(filePath)
 
@@ -249,17 +248,17 @@ function isValidAsset(path: string, context: ValidationContext) {
 /**
  * Check if a link is excluded from validation by the user.
  */
-function isExcludedLink(link: string, context: ValidationContext) {
-  return picomatch(context.options.exclude)(link)
+function isExcludedLink(link: Link, context: ValidationContext) {
+  return picomatch(context.options.exclude)(link.raw)
 }
 
 function stripQueryString(path: string): string {
   return path.split('?')[0] ?? path
 }
 
-function addError(errors: ValidationErrors, filePath: string, link: string, type: ValidationErrorType) {
+function addError(errors: ValidationErrors, filePath: string, link: Link, type: ValidationErrorType) {
   const fileErrors = errors.get(filePath) ?? []
-  fileErrors.push({ link, type })
+  fileErrors.push({ link: link.raw, type })
 
   errors.set(filePath, fileErrors)
 }
@@ -268,6 +267,12 @@ function pluralize(count: number, singular: string) {
   return count === 1 ? singular : `${singular}s`
 }
 
+function formatValidationError(error: ValidationError, site: AstroConfig['site']) {
+  if (error.type !== ValidationErrorType.SameSite || !site) return error.type
+
+  return error.type.replace('{{site}}', site)
+}
+
 // The validation errors keyed by file path.
 type ValidationErrors = Map<string, ValidationError[]>
 
@@ -290,7 +295,7 @@ interface ValidationContext {
   errors: ValidationErrors
   filePath: string
   headings: Headings
-  link: string
+  link: Link
   localeConfig: LocaleConfig | undefined
   options: StarlightLinksValidatorOptions
   outputDir: URL
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e9bfb23..7ff42da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -310,6 +310,30 @@ importers:
         specifier: workspace:*
         version: link:../../..
 
+  packages/starlight-links-validator/tests/fixtures/same-site-error:
+    dependencies:
+      '@astrojs/starlight':
+        specifier: ^0.32.0
+        version: 0.32.0(astro@5.3.0)
+      astro:
+        specifier: ^5.3.0
+        version: 5.3.0(@types/node@18.19.68)(typescript@5.7.2)
+      starlight-links-validator:
+        specifier: workspace:*
+        version: link:../../..
+
+  packages/starlight-links-validator/tests/fixtures/same-site-validate:
+    dependencies:
+      '@astrojs/starlight':
+        specifier: ^0.32.0
+        version: 0.32.0(astro@5.3.0)
+      astro:
+        specifier: ^5.3.0
+        version: 5.3.0(@types/node@18.19.68)(typescript@5.7.2)
+      starlight-links-validator:
+        specifier: workspace:*
+        version: link:../../..
+
   packages/starlight-links-validator/tests/fixtures/src-dir:
     dependencies:
       '@astrojs/starlight':


Test patch:
diff --git a/packages/starlight-links-validator/tests/base-path.test.ts b/packages/starlight-links-validator/tests/base-path.test.ts
index 6d00e49..a145e65 100644
--- a/packages/starlight-links-validator/tests/base-path.test.ts
+++ b/packages/starlight-links-validator/tests/base-path.test.ts
@@ -9,7 +9,7 @@ test('validates links when the `base` Astro option is set', async () => {
 
   expect(status).toBe('error')
 
-  expectValidationErrorCount(output, 20, 2)
+  expectValidationErrorCount(output, 28, 2)
 
   expectValidationErrors(output, 'test/test/', [
     ['/guides/example', ValidationErrorType.InvalidLink],
@@ -28,6 +28,14 @@ test('validates links when the `base` Astro option is set', async () => {
     ['/release/@pkg/v0.1.0/#some-content', ValidationErrorType.InvalidLink],
     ['/guides/page-with-custom-slug', ValidationErrorType.InvalidLink],
     ['/guides/page-with-custom-slug/', ValidationErrorType.InvalidLink],
+    ['https://example.com/guides/example', ValidationErrorType.InvalidLink],
+    ['https://example.com/guides/example/', ValidationErrorType.InvalidLink],
+    ['https://example.com/guides/example#description', ValidationErrorType.InvalidLink],
+    ['https://example.com/guides/example/#description', ValidationErrorType.InvalidLink],
+    ['https://example.com/unknown', ValidationErrorType.InvalidLink],
+    ['https://example.com/unknown/', ValidationErrorType.InvalidLink],
+    ['https://example.com/test/guides/example#unknown', ValidationErrorType.InvalidHash],
+    ['https://example.com/test/guides/example/#unknown', ValidationErrorType.InvalidHash],
   ])
 
   expectValidationErrors(output, 'test/fr/test/', [
diff --git a/packages/starlight-links-validator/tests/basics.test.ts b/packages/starlight-links-validator/tests/basics.test.ts
index 20dd4e2..31720dd 100644
--- a/packages/starlight-links-validator/tests/basics.test.ts
+++ b/packages/starlight-links-validator/tests/basics.test.ts
@@ -21,7 +21,7 @@ test('does not build with invalid links', async () => {
 
   expect(status).toBe('error')
 
-  expectValidationErrorCount(output, 69, 4)
+  expectValidationErrorCount(output, 73, 4)
 
   expectValidationErrors(output, 'test/', [
     ['/https://starlight.astro.build/', ValidationErrorType.InvalidLink],
@@ -82,6 +82,10 @@ test('does not build with invalid links', async () => {
     ['/guidelines/ui.pdf?query=string', ValidationErrorType.InvalidLink],
     ['/linkcard/?query=string', ValidationErrorType.InvalidLink],
     ['/linkbutton/?query=string', ValidationErrorType.InvalidLink],
+    ['/customlink/', ValidationErrorType.InvalidLink],
+    ['/customlink/#links', ValidationErrorType.InvalidLink],
+    ['#customlink', ValidationErrorType.InvalidHash],
+    ['/customlink/?query=string', ValidationErrorType.InvalidLink],
   ])
 
   expectValidationErrors(output, 'guides/namespacetest/', [
diff --git a/packages/starlight-links-validator/tests/fixtures/base-path/astro.config.ts b/packages/starlight-links-validator/tests/fixtures/base-path/astro.config.ts
index fe15bda..541533d 100644
--- a/packages/starlight-links-validator/tests/fixtures/base-path/astro.config.ts
+++ b/packages/starlight-links-validator/tests/fixtures/base-path/astro.config.ts
@@ -11,8 +11,9 @@ export default defineConfig({
         fr: { label: 'Français', lang: 'fr' },
       },
       pagefind: false,
-      plugins: [starlightLinksValidator({ errorOnFallbackPages: false })],
+      plugins: [starlightLinksValidator({ errorOnFallbackPages: false, sameSitePolicy: 'validate' })],
       title: 'Starlight Links Validator Tests - base path',
     }),
   ],
+  site: 'https://example.com',
 })
diff --git a/packages/starlight-links-validator/tests/fixtures/base-path/src/content/docs/test.md b/packages/starlight-links-validator/tests/fixtures/base-path/src/content/docs/test.md
index 2c63e28..c9dee24 100644
--- a/packages/starlight-links-validator/tests/fixtures/base-path/src/content/docs/test.md
+++ b/packages/starlight-links-validator/tests/fixtures/base-path/src/content/docs/test.md
@@ -44,3 +44,23 @@ title: Test
 
 - [Example page with custom slug and old link](/guides/page-with-custom-slug)
 - [Example page with custom slug and old link](/guides/page-with-custom-slug/)
+
+## Same site URL
+
+- [Example page](https://example.com/test/guides/example)
+- [Example page](https://example.com/test/guides/example/)
+
+- [Example page with hash](https://example.com/test/guides/example#description)
+- [Example page with hash](https://example.com/test/guides/example/#description)
+
+- [Example page with missing base](https://example.com/guides/example)
+- [Example page with missing base](https://example.com/guides/example/)
+
+- [Example page with missing base and hash](https://example.com/guides/example#description)
+- [Example page with missing base and hash](https://example.com/guides/example/#description)
+
+- [Unknown page](https://example.com/unknown)
+- [Unknown page](https://example.com/unknown/)
+
+- [Example page with unknown hash](https://example.com/test/guides/example#unknown)
+- [Example page with unknown hash](https://example.com/test/guides/example/#unknown)
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/astro.config.ts b/packages/starlight-links-validator/tests/fixtures/invalid-links/astro.config.ts
index 3eaec93..dad6a55 100644
--- a/packages/starlight-links-validator/tests/fixtures/invalid-links/astro.config.ts
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/astro.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
   integrations: [
     starlight({
       pagefind: false,
-      plugins: [starlightLinksValidator()],
+      plugins: [starlightLinksValidator({ components: [['CustomLink', 'link']], sameSitePolicy: 'error' })],
       title: 'Starlight Links Validator Tests - invalid links',
     }),
   ],
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/components/CustomLink.astro b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/components/CustomLink.astro
new file mode 100644
index 0000000..8d3f1f8
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/components/CustomLink.astro
@@ -0,0 +1,7 @@
+---
+interface Props {
+  link: string;
+}
+---
+
+<a href={Astro.props.link}><slot /></a>
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/guides/example.mdx b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/guides/example.mdx
index 66dacab..88c5c9c 100644
--- a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/guides/example.mdx
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/guides/example.mdx
@@ -60,3 +60,12 @@ some content
 </CardGrid>
 
 <LinkButton href="/linkbutton/?query=string">LinkButton: unknown page with query string</LinkButton>
+
+## Custom link component
+
+import CustomLink from '../../../components/CustomLink.astro'
+
+<CustomLink link="/customlink/">CustomLink: unknown page</CustomLink>
+<CustomLink link="/customlink/#links">CustomLink: unknown page and hash</CustomLink>
+<CustomLink link="#customlink">CustomLink: unknown hash</CustomLink>
+<CustomLink link="/customlink/?query=string">CustomLink: unknown page with query string</CustomLink>
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/test.md b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/test.md
index e677840..318057e 100644
--- a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/test.md
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/test.md
@@ -91,3 +91,11 @@ some content
 - [Link to custom dynamic page with known param](/custom-dynamic-page/foo/)
 - [Link to custom dynamic page with known param including a dot](/custom-dynamic-page/foo.bar/)
 - [Link to custom dynamic page with unknown param](/custom-dynamic-page/bar/)
+
+## Same site URL
+
+- [Unknown page](https://example.com/unknown)
+- [Unknown page](https://example.com/unknown/)
+
+- [Unknown page with hash](https://example.com/unknown#title)
+- [Unknown page with hash](https://example.com/unknown/#title)
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-error/astro.config.ts b/packages/starlight-links-validator/tests/fixtures/same-site-error/astro.config.ts
new file mode 100644
index 0000000..0a4a19b
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-error/astro.config.ts
@@ -0,0 +1,14 @@
+import starlight from '@astrojs/starlight'
+import { defineConfig } from 'astro/config'
+import starlightLinksValidator from 'starlight-links-validator'
+
+export default defineConfig({
+  integrations: [
+    starlight({
+      pagefind: false,
+      plugins: [starlightLinksValidator({ sameSitePolicy: 'error' })],
+      title: 'Starlight Links Validator Tests - sameSitePolicy error',
+    }),
+  ],
+  site: 'https://example.com',
+})
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-error/package.json b/packages/starlight-links-validator/tests/fixtures/same-site-error/package.json
new file mode 100644
index 0000000..f25085e
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-error/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "@starlight-links-validator-tests/same-site-error",
+  "version": "0.0.0",
+  "dependencies": {
+    "@astrojs/starlight": "^0.32.0",
+    "astro": "^5.3.0",
+    "starlight-links-validator": "workspace:*"
+  },
+  "private": true
+}
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content.config.ts b/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content.config.ts
new file mode 100644
index 0000000..0ae6ac4
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content.config.ts
@@ -0,0 +1,7 @@
+import { docsLoader } from '@astrojs/starlight/loaders'
+import { docsSchema } from '@astrojs/starlight/schema'
+import { defineCollection } from 'astro:content'
+
+export const collections = {
+  docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+}
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content/docs/guides/example.md b/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content/docs/guides/example.md
new file mode 100644
index 0000000..abaa28c
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content/docs/guides/example.md
@@ -0,0 +1,7 @@
+---
+title: Example
+---
+
+## Content
+
+Some content.
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content/docs/test.md b/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content/docs/test.md
new file mode 100644
index 0000000..bdaea04
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-error/src/content/docs/test.md
@@ -0,0 +1,18 @@
+---
+title: Test
+---
+
+# Some links
+
+- [Other page](/guides/example/)
+
+- [External link with different origin](https://starlight.astro.build/)
+
+- [External link with same origin](https://example.com)
+- [External link with same origin](https://example.com/)
+
+- [External link with same origin and valid pathname](https://example.com/guides/example/)
+- [External link with same origin and unknown pathname](https://example.com/unknown/)
+
+- [External link with same originm, valid pathname and valid hash](https://example.com/guides/example/#content)
+- [External link with same originm, valid pathname and unknown hash](https://example.com/guides/example/#unknown)
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-validate/astro.config.ts b/packages/starlight-links-validator/tests/fixtures/same-site-validate/astro.config.ts
new file mode 100644
index 0000000..25036e7
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-validate/astro.config.ts
@@ -0,0 +1,14 @@
+import starlight from '@astrojs/starlight'
+import { defineConfig } from 'astro/config'
+import starlightLinksValidator from 'starlight-links-validator'
+
+export default defineConfig({
+  integrations: [
+    starlight({
+      pagefind: false,
+      plugins: [starlightLinksValidator({ sameSitePolicy: 'validate' })],
+      title: 'Starlight Links Validator Tests - sameSitePolicy validate',
+    }),
+  ],
+  site: 'https://example.com',
+})
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-validate/package.json b/packages/starlight-links-validator/tests/fixtures/same-site-validate/package.json
new file mode 100644
index 0000000..1256349
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-validate/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "@starlight-links-validator-tests/same-site-validate",
+  "version": "0.0.0",
+  "dependencies": {
+    "@astrojs/starlight": "^0.32.0",
+    "astro": "^5.3.0",
+    "starlight-links-validator": "workspace:*"
+  },
+  "private": true
+}
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content.config.ts b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content.config.ts
new file mode 100644
index 0000000..0ae6ac4
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content.config.ts
@@ -0,0 +1,7 @@
+import { docsLoader } from '@astrojs/starlight/loaders'
+import { docsSchema } from '@astrojs/starlight/schema'
+import { defineCollection } from 'astro:content'
+
+export const collections = {
+  docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+}
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/guides/example.md b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/guides/example.md
new file mode 100644
index 0000000..abaa28c
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/guides/example.md
@@ -0,0 +1,7 @@
+---
+title: Example
+---
+
+## Content
+
+Some content.
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/index.md b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/index.md
new file mode 100644
index 0000000..c09aceb
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/index.md
@@ -0,0 +1,7 @@
+---
+title: Home
+---
+
+## Content
+
+Some content.
diff --git a/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/test.md b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/test.md
new file mode 100644
index 0000000..bdaea04
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/same-site-validate/src/content/docs/test.md
@@ -0,0 +1,18 @@
+---
+title: Test
+---
+
+# Some links
+
+- [Other page](/guides/example/)
+
+- [External link with different origin](https://starlight.astro.build/)
+
+- [External link with same origin](https://example.com)
+- [External link with same origin](https://example.com/)
+
+- [External link with same origin and valid pathname](https://example.com/guides/example/)
+- [External link with same origin and unknown pathname](https://example.com/unknown/)
+
+- [External link with same originm, valid pathname and valid hash](https://example.com/guides/example/#content)
+- [External link with same originm, valid pathname and unknown hash](https://example.com/guides/example/#unknown)
diff --git a/packages/starlight-links-validator/tests/fixtures/valid-links/astro.config.ts b/packages/starlight-links-validator/tests/fixtures/valid-links/astro.config.ts
index 5ba66df..38195a1 100644
--- a/packages/starlight-links-validator/tests/fixtures/valid-links/astro.config.ts
+++ b/packages/starlight-links-validator/tests/fixtures/valid-links/astro.config.ts
@@ -6,8 +6,9 @@ export default defineConfig({
   integrations: [
     starlight({
       pagefind: false,
-      plugins: [starlightLinksValidator()],
+      plugins: [starlightLinksValidator({ components: [['CustomLinkFoo', 'link']] })],
       title: 'Starlight Links Validator Tests - valid links',
     }),
   ],
+  site: 'https://example.com',
 })
diff --git a/packages/starlight-links-validator/tests/fixtures/valid-links/src/components/CustomLinkBar.astro b/packages/starlight-links-validator/tests/fixtures/valid-links/src/components/CustomLinkBar.astro
new file mode 100644
index 0000000..dae314c
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/valid-links/src/components/CustomLinkBar.astro
@@ -0,0 +1,7 @@
+---
+interface Props {
+  href: string;
+}
+---
+
+<a href={Astro.props.href}><slot /></a>
diff --git a/packages/starlight-links-validator/tests/fixtures/valid-links/src/components/CustomLinkFoo.astro b/packages/starlight-links-validator/tests/fixtures/valid-links/src/components/CustomLinkFoo.astro
new file mode 100644
index 0000000..8d3f1f8
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/valid-links/src/components/CustomLinkFoo.astro
@@ -0,0 +1,7 @@
+---
+interface Props {
+  link: string;
+}
+---
+
+<a href={Astro.props.link}><slot /></a>
diff --git a/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/guides/example.mdx b/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/guides/example.mdx
index 5bb83db..287b7a9 100644
--- a/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/guides/example.mdx
+++ b/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/guides/example.mdx
@@ -44,9 +44,9 @@ some content
   <LinkCard title="LinkCard: hash" href="#steps" />
 </CardGrid>
 
-<LinkButton href="/test/">LinkCard: unknown page</LinkButton>
-<LinkButton href="/test/#title">LinkCard: unknown page and hash</LinkButton>
-<LinkButton href="#steps">LinkCard: unknown hash</LinkButton>
+<LinkButton href="/test/">LinkCard: page</LinkButton>
+<LinkButton href="/test/#title">LinkCard: page and hash</LinkButton>
+<LinkButton href="#steps">LinkCard: hash</LinkButton>
 
 import { Badge } from '@astrojs/starlight/components'
 
@@ -61,3 +61,15 @@ import { Badge } from '@astrojs/starlight/components'
 ## <Badge text="x"/> <Badge text="x">Foo</Badge> Heading with <Badge text="x"/> <Badge text="x">Foo</Badge> complex markup <Badge text="x">Foo</Badge> <Badge text="x"/> <Badge text="y"/>
 
 - [Link to heading with complex markup](#-foo-heading-with--foo-complex-markup-foo-)
+
+## Custom link component
+
+import CustomLinkFoo from '../../../components/CustomLinkFoo.astro'
+
+<CustomLinkFoo link="/test/">CustomLinkFoo: page</CustomLinkFoo>
+<CustomLinkFoo link="/test/#title">CustomLinkFoo: page and hash</CustomLinkFoo>
+<CustomLinkFoo link="#steps">CustomLinkFoo: hash</CustomLinkFoo>
+
+import CustomLinkBar from '../../../components/CustomLinkBar.astro'
+
+<CustomLinkBar href="/unknown/">CustomLinkBar: unknown page</CustomLinkBar>
diff --git a/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/index.md b/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/index.md
index 7e51eb0..04fb27f 100644
--- a/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/index.md
+++ b/packages/starlight-links-validator/tests/fixtures/valid-links/src/content/docs/index.md
@@ -76,3 +76,11 @@ title: Index
 [ref-with-query-string]: /test?query=string
 [ref-with-query-string-and-hash-internal]: ?query=string#some-links
 [ref-with-query-string-and-hash-external]: /test?query=string#title
+
+## Same site URL
+
+- [Test page](https://example.com/test)
+- [Test page](https://example.com/test/)
+
+- [Unknown page](https://example.com/unknown)
+- [Unknown page](https://example.com/unknown/)
diff --git a/packages/starlight-links-validator/tests/same-site.test.ts b/packages/starlight-links-validator/tests/same-site.test.ts
new file mode 100644
index 0000000..27dd943
--- /dev/null
+++ b/packages/starlight-links-validator/tests/same-site.test.ts
@@ -0,0 +1,36 @@
+import { expect, test } from 'vitest'
+
+import { ValidationErrorType } from '../libs/validation'
+
+import config from './fixtures/same-site-error/astro.config'
+import { buildFixture, expectValidationErrorCount, expectValidationErrors } from './utils'
+
+test('does not build with same site links with the option set to `error`', async () => {
+  const { output, status } = await buildFixture('same-site-error')
+
+  expect(status).toBe('error')
+
+  expectValidationErrorCount(output, 6, 1)
+
+  expectValidationErrors(output, 'test/', [
+    ['https://example.com', ValidationErrorType.SameSite, config.site],
+    ['https://example.com/', ValidationErrorType.SameSite, config.site],
+    ['https://example.com/guides/example/', ValidationErrorType.SameSite, config.site],
+    ['https://example.com/unknown/', ValidationErrorType.SameSite, config.site],
+    ['https://example.com/guides/example/#content', ValidationErrorType.SameSite, config.site],
+    ['https://example.com/guides/example/#unknown', ValidationErrorType.SameSite, config.site],
+  ])
+})
+
+test('does not build with invalid same site links with the option set to `validate`', async () => {
+  const { output, status } = await buildFixture('same-site-validate')
+
+  expect(status).toBe('error')
+
+  expectValidationErrorCount(output, 2, 1)
+
+  expectValidationErrors(output, 'test/', [
+    ['https://example.com/unknown/', ValidationErrorType.InvalidLink],
+    ['https://example.com/guides/example/#unknown', ValidationErrorType.InvalidHash],
+  ])
+})
diff --git a/packages/starlight-links-validator/tests/utils.ts b/packages/starlight-links-validator/tests/utils.ts
index 34d340b..fdce97b 100644
--- a/packages/starlight-links-validator/tests/utils.ts
+++ b/packages/starlight-links-validator/tests/utils.ts
@@ -45,14 +45,14 @@ export function expectValidationErrorCount(output: string, count: number, filesC
 export function expectValidationErrors(
   output: string,
   path: string,
-  validationErrors: [link: string, type: ValidationErrorType][],
+  validationErrors: [link: string, type: ValidationErrorType, site?: string | undefined][],
 ) {
   expect(output).toMatch(
     new RegExp(`▶ ${path}
 ${validationErrors
   .map(
-    ([link, type], index) =>
-      `.* ${index < validationErrors.length - 1 ? '├' : '└'}─ ${link.replaceAll('?', String.raw`\?`)} - ${type}`,
+    ([link, type, site], index) =>
+      `.* ${index < validationErrors.length - 1 ? '├' : '└'}─ ${link.replaceAll('?', String.raw`\?`)} - ${site ? type.replace('{{site}}', site) : type}`,
   )
   .join('\n')}`),
   )