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

Issue:
Links with query parameters are considered invalid
### Is your feature request related to a problem?

I have a site with Astro output set to `static` but I am consuming query parameters with JS on the client. For this reason I'm adding query params to the links in the markdown body, however the build is failing because links such as `/docs/some-doc?p=value` are considered invalid.

### Describe the solution you'd like

I'd like a way to successfully validate such links. Ideally a configuration option where I can allow query parameters for specific link patterns.

### Describe alternatives you've considered

Alternatively, the ability to add a custom parser to validate links matching specific patterns.

### Additional Context

Currently I can use an [`exclude`](https://starlight-links-validator.vercel.app/configuration/#exclude) pattern to fix the build but I'd like to be able to still validate the part of the link without the query string.

Pull request description:
Fix validation issues with links containing query strings
Close #63 

Patch:
diff --git a/packages/starlight-links-validator/libs/validation.ts b/packages/starlight-links-validator/libs/validation.ts
index cbc08bf..dbbc970 100644
--- a/packages/starlight-links-validator/libs/validation.ts
+++ b/packages/starlight-links-validator/libs/validation.ts
@@ -58,7 +58,7 @@ export function validateLinks(
         pages: allPages,
       }
 
-      if (link.startsWith('#')) {
+      if (link.startsWith('#') || link.startsWith('?')) {
         if (options.errorOnInvalidHashes) {
           validateSelfHash(validationContext)
         }
@@ -125,7 +125,7 @@ function validateLink(context: ValidationContext) {
     throw new Error('Failed to validate a link with no path.')
   }
 
-  if (path.startsWith('.') || !link.startsWith('/')) {
+  if (path.startsWith('.') || (!link.startsWith('/') && !link.startsWith('?'))) {
     if (options.errorOnRelativeLinks) {
       addError(errors, filePath, link, ValidationErrorType.RelativeLink)
     }
@@ -137,7 +137,7 @@ function validateLink(context: ValidationContext) {
     return
   }
 
-  const sanitizedPath = ensureTrailingSlash(path)
+  const sanitizedPath = ensureTrailingSlash(stripQueryString(path))
 
   const isValidPage = pages.has(sanitizedPath)
   const fileHeadings = getFileHeadings(sanitizedPath, context)
@@ -183,7 +183,8 @@ function getFileHeadings(path: string, { headings, localeConfig, options }: Vali
  * Validate a link to an hash in the same page.
  */
 function validateSelfHash({ errors, link, filePath, headings }: ValidationContext) {
-  const sanitizedHash = link.replace(/^#/, '')
+  const hash = link.split('#')[1] ?? link
+  const sanitizedHash = hash.replace(/^#/, '')
   const fileHeadings = headings.get(filePath)
 
   if (!fileHeadings) {
@@ -226,6 +227,10 @@ function isExcludedLink(link: string, context: ValidationContext) {
   return picomatch(context.options.exclude)(link)
 }
 
+function stripQueryString(path: string): string {
+  return path.split('?')[0] ?? path
+}
+
 function addError(errors: ValidationErrors, filePath: string, link: string, type: ValidationErrorType) {
   const fileErrors = errors.get(filePath) ?? []
   fileErrors.push({ link, type })


Test patch:
diff --git a/packages/starlight-links-validator/tests/basics.test.ts b/packages/starlight-links-validator/tests/basics.test.ts
index c63bbe3..4021023 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, 35, 4)
+  expectValidationErrorCount(output, 59, 4)
 
   expectValidationErrors(output, 'test/', [
     ['/https://starlight.astro.build/', ValidationErrorType.InvalidLink],
@@ -39,6 +39,17 @@ test('does not build with invalid links', async () => {
     ['#anotherDiv', ValidationErrorType.InvalidHash],
     ['/guides/page-with-custom-slug', ValidationErrorType.InvalidLink],
     ['/release/@pkg/v0.2.0', ValidationErrorType.InvalidLink],
+    ['/?query=string', ValidationErrorType.InvalidLink],
+    ['/unknown?query=string', ValidationErrorType.InvalidLink],
+    ['/unknown/?query=string', ValidationErrorType.InvalidLink],
+    ['/unknown?query=string#title', ValidationErrorType.InvalidLink],
+    ['/unknown/?query=string#title', ValidationErrorType.InvalidLink],
+    ['?query=string#links', ValidationErrorType.InvalidHash],
+    ['/guides/example/?query=string#links', ValidationErrorType.InvalidHash],
+    ['/icon.svg?query=string', ValidationErrorType.InvalidLink],
+    ['/guidelines/ui.pdf?query=string', ValidationErrorType.InvalidLink],
+    ['/unknown-ref?query=string', ValidationErrorType.InvalidLink],
+    ['?query=string#unknown-ref', ValidationErrorType.InvalidHash],
   ])
 
   expectValidationErrors(output, 'guides/example/', [
@@ -54,6 +65,13 @@ test('does not build with invalid links', async () => {
     ['/linkbutton/', ValidationErrorType.InvalidLink],
     ['/linkbutton/#links', ValidationErrorType.InvalidLink],
     ['#linkbutton', ValidationErrorType.InvalidHash],
+    ['?query=string#links', ValidationErrorType.InvalidHash],
+    ['/unknown/?query=string#links', ValidationErrorType.InvalidLink],
+    ['/unknown?query=string', ValidationErrorType.InvalidLink],
+    ['/icon.svg?query=string', ValidationErrorType.InvalidLink],
+    ['/guidelines/ui.pdf?query=string', ValidationErrorType.InvalidLink],
+    ['/linkcard/?query=string', ValidationErrorType.InvalidLink],
+    ['/linkbutton/?query=string', ValidationErrorType.InvalidLink],
   ])
 
   expectValidationErrors(output, 'guides/namespacetest/', [
@@ -68,5 +86,11 @@ test('does not build with invalid links', async () => {
     ['./guides/example', ValidationErrorType.RelativeLink],
     ['../test', ValidationErrorType.RelativeLink],
     ['test', ValidationErrorType.RelativeLink],
+    ['.?query=string', ValidationErrorType.RelativeLink],
+    ['./relative?query=string', ValidationErrorType.RelativeLink],
+    ['./test?query=string', ValidationErrorType.RelativeLink],
+    ['./guides/example?query=string', ValidationErrorType.RelativeLink],
+    ['../test?query=string', ValidationErrorType.RelativeLink],
+    ['test?query=string', ValidationErrorType.RelativeLink],
   ])
 })
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 972f2b1..66dacab 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
@@ -44,3 +44,19 @@ some content
 <LinkButton href="/linkbutton/">LinkButton: unknown page</LinkButton>
 <LinkButton href="/linkbutton/#links">LinkButton: unknown page and hash</LinkButton>
 <LinkButton href="#linkbutton">LinkButton: unknown hash</LinkButton>
+
+## Query strings
+
+- [Link with query string to invalid hash in the same page](?query=string#links)
+- [Link with query string to invalid hash in another page](/unknown/?query=string#links)
+
+<a href="/unknown?query=string">HTML link with query string to unknown page</a>
+
+<a href="/icon.svg?query=string">Link with query string to invalid asset</a>
+<a href="/guidelines/ui.pdf?query=string">Link with query string to another invalid asset</a>
+
+<CardGrid>
+  <LinkCard title="LinkCard: unknown page with query string" href="/linkcard/?query=string" />
+</CardGrid>
+
+<LinkButton href="/linkbutton/?query=string">LinkButton: unknown page with query string</LinkButton>
diff --git a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/relative.md b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/relative.md
index f03f0c3..de78a65 100644
--- a/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/relative.md
+++ b/packages/starlight-links-validator/tests/fixtures/invalid-links/src/content/docs/relative.md
@@ -8,3 +8,12 @@ title: Relative
 - [Link to another page in another directory](./guides/example)
 - [Link to another page in a parent directory](../test)
 - [Another link to another page in the same directory](test)
+
+## Query strings
+
+- [Link with query string to the same page](.?query=string)
+- [Link with query string to the same page with its name](./relative?query=string)
+- [Link with query string to another page in the same directory](./test?query=string)
+- [Link with query string to another page in another directory](./guides/example?query=string)
+- [Link with query string to another page in a parent directory](../test?query=string)
+- [Another link with query string to another page in the same directory](test?query=string)
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 5150ea5..235b987 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
@@ -51,3 +51,26 @@ some content
 
 - [Link to page not using its custom slug](/guides/page-with-custom-slug)
 - [Link to page using an invalid custom slug](/release/@pkg/v0.2.0)
+
+## Query strings
+
+- [Home page with query string](/?query=string)
+
+- [Unknown page with query string](/unknown?query=string)
+- [Unknown page with query string](/unknown/?query=string)
+
+- [Unknown page with query string and hash](/unknown?query=string#title)
+- [Unknown page with query string and hash](/unknown/?query=string#title)
+
+- [Link with query string to valid hash in this page](?query=string#some-links)
+- [Link with query string to invalid hash in this page](?query=string#links)
+- [Link with query string to valid hash in another MDX page](/guides/example/?query=string#some-links)
+- [Link with query string to invalid hash in another MDX page](/guides/example/?query=string#links)
+- [Link with query string to invalid asset](/icon.svg?query=string)
+- [Link with query string to another invalid asset](/guidelines/ui.pdf?query=string)
+
+- [Link reference with query string to unknwon page][ref-with-query-string-unknown-page]
+- [Link reference with query string to invalid hash][ref-with-query-string-invalid-hash]
+
+[ref-with-query-string-unknown-page]: /unknown-ref?query=string
+[ref-with-query-string-invalid-hash]: ?query=string#unknown-ref
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 06af854..7ad6ba6 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
@@ -44,3 +44,27 @@ title: Index
 ## Link to page with custom slug
 
 - [A page with custom slug](/release/@pkg/v0.1.0)
+
+## Query strings
+
+- [Home page with query string](/?query=string)
+
+- [Test page with query string](/test?query=string)
+- [Test page with query string](/test/?query=string)
+
+- [Test page with query string and hash](/test?query=string#title)
+- [Test page with query string and hash](/test/?query=string#title)
+
+- [Link to hash in this page with query string](?query=string#some-links)
+
+- [Link to an asset with query string](/favicon.svg?query=string)
+- [Link to another asset with query string](/guidelines/dummy.pdf?query=string)
+
+- [ref-with-query-string]
+- [Link reference with query string][ref-with-query-string]
+- [Link reference with query string and hash in this page][ref-with-query-string-and-hash-internal]
+- [Link reference with query string and hash in another page][ref-with-query-string-and-hash-external]
+
+[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
diff --git a/packages/starlight-links-validator/tests/utils.ts b/packages/starlight-links-validator/tests/utils.ts
index 269555f..e8b65b2 100644
--- a/packages/starlight-links-validator/tests/utils.ts
+++ b/packages/starlight-links-validator/tests/utils.ts
@@ -50,7 +50,10 @@ export function expectValidationErrors(
   expect(output).toMatch(
     new RegExp(`▶ ${path}
 ${validationErrors
-  .map(([link, type], index) => `.* ${index < validationErrors.length - 1 ? '├' : '└'}─ ${link} - ${type}`)
+  .map(
+    ([link, type], index) =>
+      `.* ${index < validationErrors.length - 1 ? '├' : '└'}─ ${link.replaceAll('?', '\\?')} - ${type}`,
+  )
   .join('\n')}`),
   )
 }