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

Issue:
Custom pages with periods in the slug are reported as invalid
### Describe the bug

I believe this is effectively the same as https://github.com/HiDeoo/starlight-links-validator/issues/31, but the fix that relies on `file.data.astro` does not apply to custom pages.

### To Reproduce

[StackBlitz](https://stackblitz.com/edit/github-dkq88g9p-bnepl16d?file=src%2Fcontent%2Fdocs%2Findex.mdx)

1. Create `src/pages/foo.bar.astro`.
2. Create `src/content/docs/foo.mdx`.
  a. Add `title: foo` and `slug: foo.baz` to the frontmatter.
3. Link to `[foo](/foo.bar/)` and `[foo](/foo.baz/)` in a Markdown file.
4. Run `npx astro build`, and observe that `/foo.bar/index.html` and `/foo.baz/index.html` are generated.

```
12:05:27 ▶ src/pages/foo.bar.astro
12:05:27   └─ /foo.bar/index.html (+16ms) 
12:05:27 ▶ @astrojs/starlight/routes/static/index.astro
12:05:27   ├─ /foo.baz/index.html (+67ms) 
```

5. Only `/foo.bar/`, the custom page, will be reported as an invalid link.

```
12:05:35 [ERROR] ✗ Found 1 invalid link in 1 file.
12:05:35 ▶ /
12:05:35   └─ /foo.bar/ - invalid link
```

### Expected behavior

`/foo.bar/` is reported as a valid link.

### How often does this bug happen?

Every time

### System Info

_No response_

### Additional Context

_No response_

Pull request:
Improve error message for invalid links to custom pages
Close #84 

Patch:
diff --git a/.changeset/new-pumas-mate.md b/.changeset/new-pumas-mate.md
new file mode 100644
index 0000000..ecfecbd
--- /dev/null
+++ b/.changeset/new-pumas-mate.md
@@ -0,0 +1,5 @@
+---
+'starlight-links-validator': patch
+---
+
+Improves error message for invalid links to custom pages.
diff --git a/packages/starlight-links-validator/index.ts b/packages/starlight-links-validator/index.ts
index d6deaed..08e3c3c 100644
--- a/packages/starlight-links-validator/index.ts
+++ b/packages/starlight-links-validator/index.ts
@@ -1,8 +1,10 @@
 import type { StarlightPlugin } from '@astrojs/starlight/types'
+import type { IntegrationResolvedRoute } from 'astro'
 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 { logErrors, validateLinks } from './libs/validation'
 
@@ -72,6 +74,8 @@ export default function starlightLinksValidatorPlugin(
     name: 'starlight-links-validator-plugin',
     hooks: {
       setup({ addIntegration, astroConfig, config: starlightConfig, logger }) {
+        let routes: IntegrationResolvedRoute[] = []
+
         addIntegration({
           name: 'starlight-links-validator-integration',
           hooks: {
@@ -90,13 +94,32 @@ export default function starlightLinksValidatorPlugin(
                 },
               })
             },
-            'astro:build:done': ({ dir, pages }) => {
-              const errors = validateLinks(pages, dir, astroConfig, starlightConfig, options.data)
+            'astro:routes:resolved': (params) => {
+              routes = params.routes
+            },
+            'astro:build:done': ({ dir, pages, assets }) => {
+              const customPages = new Set<string>()
 
-              logErrors(logger, errors)
+              for (const [pattern, urls] of assets) {
+                const route = routes.find((route) => route.pattern === pattern)
+                if (!route || route.origin !== 'project') continue
+
+                for (const url of urls) {
+                  customPages.add(pathnameToSlug(url.pathname.replace(astroConfig.outDir.pathname, '')))
+                }
+              }
+
+              const errors = validateLinks(pages, customPages, dir, astroConfig, starlightConfig, options.data)
+
+              const hasInvalidLinkToCustomPage = logErrors(logger, errors)
 
               if (errors.size > 0) {
-                throwPluginError('Links validation failed.')
+                throwPluginError(
+                  'Links validation failed.',
+                  hasInvalidLinkToCustomPage
+                    ? 'Some invalid links point to custom pages which cannot be validated, see the `exclude` option for more informations at https://starlight-links-validator.vercel.app/configuration#exclude'
+                    : undefined,
+                )
               }
             },
           },
@@ -106,11 +129,13 @@ export default function starlightLinksValidatorPlugin(
   }
 }
 
-function throwPluginError(message: string): never {
-  throw new AstroError(
-    message,
-    `See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-links-validator/issues/new/choose`,
-  )
+function throwPluginError(message: string, additionalHint?: string): never {
+  let hint = 'See the error report above for more informations.\n\n'
+  if (additionalHint) hint += `${additionalHint}\n\n`
+  hint +=
+    'If you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-links-validator/issues/new/choose'
+
+  throw new AstroError(message, hint)
 }
 
 type StarlightLinksValidatorUserOptions = z.input<typeof starlightLinksValidatorOptionsSchema>
diff --git a/packages/starlight-links-validator/libs/path.ts b/packages/starlight-links-validator/libs/path.ts
index 071427d..ab4e385 100644
--- a/packages/starlight-links-validator/libs/path.ts
+++ b/packages/starlight-links-validator/libs/path.ts
@@ -1,3 +1,5 @@
+const htmlExtension = '.html'
+
 export function ensureLeadingSlash(path: string): string {
   return path.startsWith('/') ? path : `/${path}`
 }
@@ -9,3 +11,28 @@ export function ensureTrailingSlash(path: string): string {
 export function stripLeadingSlash(path: string) {
   return path.replace(/^\//, '')
 }
+
+export function stripTrailingSlash(path: string) {
+  return path.replace(/\/$/, '')
+}
+
+export function pathnameToSlug(pathname: string): string {
+  const base = stripTrailingSlash(import.meta.env.BASE_URL)
+
+  if (pathname.startsWith(base)) {
+    pathname = pathname.replace(base, '')
+  }
+
+  const segments = pathname.split('/')
+
+  if (segments.at(-1) === 'index.html') {
+    segments.pop()
+  } else if (segments.at(-1)?.endsWith(htmlExtension)) {
+    const last = segments.pop()
+    if (last) {
+      segments.push(last.slice(0, -1 * htmlExtension.length))
+    }
+  }
+
+  return segments.filter(Boolean).join('/')
+}
diff --git a/packages/starlight-links-validator/libs/validation.ts b/packages/starlight-links-validator/libs/validation.ts
index 13908a5..cf8c1f4 100644
--- a/packages/starlight-links-validator/libs/validation.ts
+++ b/packages/starlight-links-validator/libs/validation.ts
@@ -10,13 +10,14 @@ import picomatch from 'picomatch'
 import type { StarlightLinksValidatorOptions } from '..'
 
 import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n'
-import { ensureTrailingSlash, stripLeadingSlash } from './path'
+import { ensureTrailingSlash, stripLeadingSlash, stripTrailingSlash } from './path'
 import { getValidationData, type Headings } from './remark'
 
 export const ValidationErrorType = {
   InconsistentLocale: 'inconsistent locale',
   InvalidHash: 'invalid hash',
   InvalidLink: 'invalid link',
+  InvalidLinkToCustomPage: 'invalid link to custom page',
   LocalLink: 'local link',
   RelativeLink: 'relative link',
   TrailingSlashMissing: 'missing trailing slash',
@@ -25,6 +26,7 @@ export const ValidationErrorType = {
 
 export function validateLinks(
   pages: PageData[],
+  customPages: Set<string>,
   outputDir: URL,
   astroConfig: AstroConfig,
   starlightConfig: StarlightUserConfig,
@@ -50,6 +52,7 @@ export function validateLinks(
     for (const link of fileLinks) {
       const validationContext: ValidationContext = {
         astroConfig,
+        customPages,
         errors,
         filePath,
         headings,
@@ -92,6 +95,8 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
     ),
   )
 
+  let hasInvalidLinkToCustomPage = false
+
   for (const [file, validationErrors] of errors) {
     logger.info(`${red('▶')} ${blue(file)}`)
 
@@ -101,17 +106,20 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
           ` - ${validationError.type}`,
         )}`,
       )
+      hasInvalidLinkToCustomPage = validationError.type === ValidationErrorType.InvalidLinkToCustomPage
     }
   }
 
   process.stdout.write('\n')
+
+  return hasInvalidLinkToCustomPage
 }
 
 /**
  * Validate a link to another internal page that may or may not have a hash.
  */
 function validateLink(context: ValidationContext) {
-  const { astroConfig, errors, filePath, link, localeConfig, options, pages } = context
+  const { astroConfig, customPages, errors, filePath, link, localeConfig, options, pages } = context
 
   if (isExcludedLink(link, context)) {
     return
@@ -153,7 +161,14 @@ function validateLink(context: ValidationContext) {
   const fileHeadings = getFileHeadings(sanitizedPath, context)
 
   if (!isValidPage || !fileHeadings) {
-    addError(errors, filePath, link, ValidationErrorType.InvalidLink)
+    addError(
+      errors,
+      filePath,
+      link,
+      customPages.has(stripTrailingSlash(sanitizedPath))
+        ? ValidationErrorType.InvalidLinkToCustomPage
+        : ValidationErrorType.InvalidLink,
+    )
     return
   }
 
@@ -271,6 +286,7 @@ type Pages = Set<PageData['pathname']>
 
 interface ValidationContext {
   astroConfig: AstroConfig
+  customPages: Set<string>
   errors: ValidationErrors
   filePath: string
   headings: Headings


Test patch:
diff --git a/packages/starlight-links-validator/tests/basics.test.ts b/packages/starlight-links-validator/tests/basics.test.ts
index 3d9aa61..20dd4e2 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, 64, 4)
+  expectValidationErrorCount(output, 69, 4)
 
   expectValidationErrors(output, 'test/', [
     ['/https://starlight.astro.build/', ValidationErrorType.InvalidLink],
@@ -55,6 +55,11 @@ test('does not build with invalid links', async () => {
     ['http://localhost', ValidationErrorType.LocalLink],
     ['http://localhost:4321/', ValidationErrorType.LocalLink],
     ['https://127.0.0.1:4321/getting-started', ValidationErrorType.LocalLink],
+    ['/unknown-custom-page/', ValidationErrorType.InvalidLink],
+    ['/custom-page/', ValidationErrorType.InvalidLinkToCustomPage],
+    ['/custom-dynamic-page/foo/', ValidationErrorType.InvalidLinkToCustomPage],
+    ['/custom-dynamic-page/foo.bar/', ValidationErrorType.InvalidLinkToCustomPage],
+    ['/custom-dynamic-page/bar/', ValidationErrorType.InvalidLink],
   ])
 
   expectValidationErrors(output, 'guides/example/', [
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 69e1513..e677840 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
@@ -83,3 +83,11 @@ some content
 - [Local link](http://localhost)
 - [Local link with port](http://localhost:4321/)
 - [Local link using HTTPS](https://127.0.0.1:4321/getting-started)
+
+## Links to custom pages
+
+- [Link to unknown custom page](/unknown-custom-page/)
+- [Link to custom page](/custom-page/)
+- [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/)
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/pages/custom-dynamic-page/[...param].astro b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/pages/custom-dynamic-page/[...param].astro
new file mode 100644
index 0000000..d0970d9
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/pages/custom-dynamic-page/[...param].astro
@@ -0,0 +1,12 @@
+---
+export function getStaticPaths() {
+  return [
+    {params: { param: 'foo' }},
+    {params: { param: 'foo.bar' }},
+  ];
+}
+
+const { param } = Astro.params;
+---
+
+<p>Some content about {param}</p>
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/pages/custom-page.astro b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/pages/custom-page.astro
new file mode 100644
index 0000000..aa40cce
--- /dev/null
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/pages/custom-page.astro
@@ -0,0 +1,1 @@
+<p>Some content</p>