Skip to content

feat: changelog - formatted links, more git providers & copy markdown#2983

Open
WilcoSp wants to merge 291 commits into
npmx-dev:mainfrom
WilcoSp:changelog/format-link+git-providers+copy-markdown
Open

feat: changelog - formatted links, more git providers & copy markdown#2983
WilcoSp wants to merge 291 commits into
npmx-dev:mainfrom
WilcoSp:changelog/format-link+git-providers+copy-markdown

Conversation

@WilcoSp

@WilcoSp WilcoSp commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

preview

🔗 Linked issue

🧭 Context

  • Adding support for more git providers
  • Adding formatting for issues, pr/mr, commits, accounts and compare from plaintext to formatted link and unformatted link to a formatted link
  • adds button to copy the markdown from

📚 Description

  • Before only github was supported, in this pr also other git providers will be added and are included with the other 2 additions
    added providers:
    • Codeberg & Forgejo
    • Gitlab
    • tangled (does current have both issue & pr as #, will in the future change due to tangled not having shorthands yet)
      (the following aren't required for this pr, just nice to have and are here if review takes long)
    • bitbucket
    • Gitea
    • Radicle
    • Sourcehut
    • Gitee

Adding support to format links to the git providers (same repo only):

  • convert unformatted links for issues, pr/mr, commit & compare to a formatted link
  • convert plaintext issues, pr/mr, account & commit to formatted links to the git providers

Added button to allow copying the raw markdown

  • copy from release
  • copy from changelog.md

I did only use AI for some of the regular expressions

this pr continued from #2717 which continued from the pr before, that's why there are so many commits

WilcoSp and others added 30 commits February 25, 2026 10:10
changed the view_on_npm to view_on with {site}
added cache keys to the markdown & releases endpoints
added scroll margin classes
ensuring to navigate to hash if present
@ghostdevv

ghostdevv commented Jul 7, 2026

Copy link
Copy Markdown
Member

it's more here in case review takes long.

review time and PR size are very much linked 😄 naturally some PRs will be big, but if you can split up changes it's better for reviewing as well as the changelog - the main exception is if there is a change that requires consensus from multiple maintainers, even a small PR might take time then (but you should still split em as it's still overall faster)

WilcoSp added 2 commits July 9, 2026 09:21
…ithub.com:WilcoSp/npmx.dev into changelog/format-link+git-providers+copy-markdown
copy markdown button will wait 300ms before prefetching when hovering

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (3)
server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts (1)

116-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Set User-Agent header for Forgejo and GitLab API calls.

getMarkdownFromGithub sets User-Agent: 'npmx.dev', but getMarkdownFromForgejo and getMarkdownFromGitlab don't. The detection functions in detectChangelog.ts do set this header for the same APIs. Some Forgejo/GitLab instances may reject requests without a User-Agent.

♻️ Proposed fix
 async function getMarkdownFromForgejo(
   owner: string,
   repo: string,
   tag: string,
   host: string = 'codeberg.org',
 ) {
-  const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases/tags/${tag}`)
+  const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, {
+    headers: {
+      'User-Agent': 'npmx.dev',
+      'accept': 'application/json',
+    },
+  })
   const release = v.parse(ForgejoReleaseSchama, data)
   return release.body
 }
 
 async function getMarkdownFromGitlab(
   owner: string,
   repo: string,
   tag: string,
   host: string = 'gitlab.com',
 ) {
   owner = decodeURIComponent(owner)
   const repoPath = encodeURIComponent(`${owner}/${repo}`)
-  const data = await $fetch(`https://${host}/api/v4/projects/${repoPath}/releases/${tag}`)
+  const data = await $fetch(`https://${host}/api/v4/projects/${repoPath}/releases/${encodeURIComponent(tag)}`, {
+    headers: {
+      'User-Agent': 'npmx.dev',
+      'accept': 'application/json',
+    },
+  })
   const release = v.parse(GitlabReleaseSchame, data)
   return release.description
 }

Also applies to: 129-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/changelog/releases/`[provider]/[owner]/[repo]/raw/[tag].get.ts
around lines 116 - 127, Update getMarkdownFromForgejo and getMarkdownFromGitlab
so their $fetch API requests include the same User-Agent header as
getMarkdownFromGithub, using the value npmx.dev. Preserve the existing request
URLs and response parsing.
server/utils/changelog/detectChangelog.ts (1)

106-114: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to the fetch call in checkFiles.

The global fetch has no timeout, so a slow or unresponsive provider will hang the request indefinitely. While .catch(() => false) handles errors, a hung connection never triggers the catch. Consider using AbortController or switching to $fetch with a timeout option.

♻️ Proposed fix using AbortSignal.timeout
     const exists = await fetch(resolveURL(baseUrl.raw, dir ?? '', fileName), {
       headers: {
         // GitHub API requires User-Agent
         'User-Agent': 'npmx.dev',
       },
       method: ref.provider != 'tangled' ? 'HEAD' : 'GET', // we just need to know if it exists or not, tangled doesn't support HEAD
+      signal: AbortSignal.timeout(5000),
     })
       .then(r => r.ok)
       .catch(() => false)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/changelog/detectChangelog.ts` around lines 106 - 114, Update the
fetch call in checkFiles to enforce a finite timeout, using AbortSignal.timeout
or an equivalent AbortController signal, while preserving the existing URL,
headers, method selection, and false-on-error behavior.
app/components/Changelog/Markdown.vue (1)

24-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Non-obvious guard could use a brief comment.

typeof data.value == 'string' isn't self-explanatory — presumably guarding against the union response type this endpoint can return (object vs raw string) depending on the raw query used elsewhere in this file. A short comment would help future readers.

As per coding guidelines, "Add comments only to explain complex logic or non-obvious implementations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/Changelog/Markdown.vue` around lines 24 - 26, Add a brief
explanatory comment immediately above the typeof data.value == 'string' guard in
the watchEffect callback, clarifying that the endpoint may return either a
parsed object or raw string depending on the raw query. Keep the existing guard
and behavior unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/components/Button/CopyMd.vue`:
- Around line 15-36: Update prefetchMarkdown and the fetch condition inside
copyMarkdown so status === 'error' retries fetchMarkdown before copying,
preventing stale or empty markdown after transient failures. Preserve the
existing idle/pending behavior and ensure copying proceeds only with the
refreshed result or surfaces the fetch failure.

In `@app/components/Changelog/Releases.vue`:
- Around line 11-21: Pass the existing host value from Releases.vue into each
ChangelogCard, then update ChangelogCard’s raw release useLazyFetch query to
include host alongside the existing parameters. Ensure the raw endpoint receives
the self-hosted host instead of falling back to its default.

In `@server/api/changelog/md/`[provider]/[owner]/[repo]/[...path].get.ts:
- Around line 19-20: The changelog fetch path must not pass arbitrary host
values from the host query parameter into server-side requests. Update the
validation and fetch flow around the parsed host, including the logic at lines
32-46, to reuse a shared outbound-host validator that validates URL structure,
resolved addresses, and every redirect, or enforce the established host
allowlist before fetching and returning raw content.
- Around line 93-101: Add a `tangled` case to the `getRepoInfo()` provider
switch and dispatch to the existing `createTangledInfo` function with the
appropriate repository parameters, so Tangled repositories return their info
instead of falling through as undefined. Preserve the existing dispatch behavior
for GitHub, Forgejo/Codeberg, and GitLab.

In `@server/api/changelog/releases/`[provider]/[owner]/[repo].get.ts:
- Around line 37-41: Add a `tangled` branch to the provider switch alongside the
existing Forgejo and GitLab cases, routing through a dedicated
`getReleasesFromTangled` fetcher with the appropriate owner, repository, and
host values. Ensure the endpoint’s provider-specific release retrieval and
rendering path covers Tangled requests consistently with the advertised support.
- Around line 23-24: Validate the optional host from the query before it reaches
the Forgejo and GitLab $fetch URL construction, allowing only approved public
destinations or an explicit host allowlist. Apply the same protection to every
provider request and validate each redirect target to block private, loopback,
link-local, reserved, and DNS-rebinding addresses; reject invalid hosts before
fetching.

In `@server/api/changelog/releases/`[provider]/[owner]/[repo]/raw/[tag].get.ts:
- Line 75: URL-encode the decoded tag route parameter in the upstream API URL
construction for all three provider functions. Update each URL template using
tag to apply encodeURIComponent(tag), while leaving owner, repo, and the
surrounding request behavior unchanged.
- Around line 17-18: Validate the user-controlled host from rawQuery before
constructing any upstream URL. Add or reuse a validateHost(provider, host) guard
that permits only approved Git hosting providers (or otherwise enforces valid
public hostnames and rejects private, loopback, and link-local ranges), then
invoke it in the raw release flow before the provider-specific API calls; apply
the same protection to the corresponding releases endpoint and every listed host
usage.

In `@server/utils/changelog/detectChangelog.ts`:
- Around line 243-248: Update ROOT_ONLY_REGEX and both directory-filter
conditions in checkLatestForgejoRelease and checkLatestGitlabRelease so
extracted root-level paths such as changelog.md match without a leading slash,
while nested paths still require the configured directory prefix.

In `@server/utils/changelog/markdown.ts`:
- Line 239: Update accountRegex and the corresponding matching logic at the
referenced usage to exclude @ segments from email addresses and package-version
suffixes such as package@latest, while preserving valid standalone account
mentions. Ensure both account-matching paths use the same corrected boundary
behavior.
- Line 100: Update the textFilter configuration to prevent
createResolveGitTextToLinks from emitting unsafe raw anchor markup from
request-derived host, owner, or repo values; build links before sanitization or
HTML-escape both generated URLs and labels before insertion. Add a regression
test covering quote-containing repository components and verify the resulting
href and text cannot inject markup.

In `@shared/utils/constants.ts`:
- Line 48: Update the ERROR_CHANGELOG_NOT_FOUND constant’s user-facing message
to use “was found” instead of “had been found,” preserving the rest of the
wording.

---

Nitpick comments:
In `@app/components/Changelog/Markdown.vue`:
- Around line 24-26: Add a brief explanatory comment immediately above the
typeof data.value == 'string' guard in the watchEffect callback, clarifying that
the endpoint may return either a parsed object or raw string depending on the
raw query. Keep the existing guard and behavior unchanged.

In `@server/api/changelog/releases/`[provider]/[owner]/[repo]/raw/[tag].get.ts:
- Around line 116-127: Update getMarkdownFromForgejo and getMarkdownFromGitlab
so their $fetch API requests include the same User-Agent header as
getMarkdownFromGithub, using the value npmx.dev. Preserve the existing request
URLs and response parsing.

In `@server/utils/changelog/detectChangelog.ts`:
- Around line 106-114: Update the fetch call in checkFiles to enforce a finite
timeout, using AbortSignal.timeout or an equivalent AbortController signal,
while preserving the existing URL, headers, method selection, and false-on-error
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3e14fb36-dfc4-4096-947f-8c8738e998bf

📥 Commits

Reviewing files that changed from the base of the PR and between 6a5befd and 4d504b1.

📒 Files selected for processing (24)
  • app/components/Button/CopyMd.vue
  • app/components/Changelog/Card.vue
  • app/components/Changelog/Markdown.vue
  • app/components/Changelog/Releases.vue
  • app/pages/package-changelog/[[org]]/[name].vue
  • app/pages/package/[[org]]/[name].vue
  • i18n/locales/en.json
  • i18n/locales/nl.json
  • i18n/schema.json
  • nuxt.config.ts
  • server/api/changelog/info/[name]/v/[version].get.ts
  • server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts
  • server/api/changelog/releases/[provider]/[owner]/[repo].get.ts
  • server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts
  • server/utils/changelog/baseFileUrl.ts
  • server/utils/changelog/detectChangelog.ts
  • server/utils/changelog/markdown.ts
  • server/utils/changelog/mdRepoInfo.ts
  • server/utils/mdKit.ts
  • shared/schemas/changelog/release.ts
  • shared/types/changelog.ts
  • shared/utils/constants.ts
  • test/nuxt/a11y.spec.ts
  • test/unit/server/utils/changelog/markdown.spec.ts

Comment on lines +15 to +36
function prefetchMarkdown() {
if (status === 'idle') {
fetchMarkdown()
}
}

const {
copied: copiedReadme,
copy,
copyPending: copyReadmePending,
} = useClipboard({
copiedDuring: 2000,
})

function copyMarkdown() {
copy(async () => {
if (status === 'idle' || status === 'pending') {
await fetchMarkdown()
}
return markdown ?? ''
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## File outline\n'
ast-grep outline app/components/Button/CopyMd.vue --view expanded || true

printf '\n## Relevant file section\n'
wc -l app/components/Button/CopyMd.vue
cat -n app/components/Button/CopyMd.vue | sed -n '1,220p'

printf '\n## Find clipboard helper usage and dependency version\n'
rg -n "useClipboard|copyPending|copiedDuring|`@vueuse/core`" -S . --glob '!**/node_modules/**' || true

printf '\n## Package manifests\n'
git ls-files 'package.json' 'pnpm-lock.yaml' 'package-lock.json' 'yarn.lock' 'npm-shrinkwrap.json' | xargs -r -I{} sh -c 'echo "--- {}"; sed -n "1,220p" "{}"'

Repository: npmx-dev/npmx.dev

Length of output: 22845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Find CopyMd.vue usage\n'
rg -n "CopyMd" app docs -S || true

printf '\n## Inspect surrounding usage if present\n'
for f in $(rg -l "CopyMd" app docs -S || true); do
  echo "--- $f"
  cat -n "$f" | sed -n '1,220p'
done

printf '\n## Search for fetchMarkdown/markdown/status props passed to CopyMd\n'
rg -n "fetchMarkdown|markdown:|status:" app docs -S --glob '!**/node_modules/**' || true

Repository: npmx-dev/npmx.dev

Length of output: 21935


🌐 Web query:

VueUse useClipboard copy signature v14.3.0 source string async callback docs

💡 Result:

In VueUse version 14.3.0, the useClipboard function provides a flexible copy mechanism that supports both static strings and asynchronous callbacks [1][2]. The copy function signature is: copy(value?: ClipboardValue) => Promise The ClipboardValue type allows the source to be either a string or an async function that returns a string [2]. Key details regarding the source and copy behavior in v14.3.0 include: 1. Handling of Sources: When calling copy, if no argument is provided, the function defaults to the source defined in the useClipboard options (which supports MaybeRefOrGetter) [3][1][2]. 2. Asynchronous Support: The implementation was updated in v14.3.0 (via PR #5369) to better handle asynchronous operations, specifically addressing issues in Safari [4][5][2]. When providing an async function as the source, the copy method will await the resolved value before performing the clipboard write [2]. 3. Legacy Fallback: If the Clipboard API is unavailable or permission is denied, and legacy mode is enabled (legacy: true), the function handles both string values and asynchronous callbacks by resolving the async function, updating the text ref, and then executing a fallback copy (using document.execCommand) [1][2]. The return object of useClipboard includes the copy function, which is typed to return a Promise that resolves after the operation completes [1][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## useClipboard source from installed package if present\n'
for f in $(git ls-files | grep -E '(^|/)package.json$|(^|/)pnpm-lock.yaml$' | head -n 2); do :; done

rg -n "function useClipboard|export function useClipboard|type ClipboardValue|copy\\(" node_modules .pnpm . -S --glob '**/useClipboard/**' --glob '!**/dist/**' --glob '!**/node_modules/**' || true

printf '\n## Search for error handling / retry patterns around lazy fetches\n'
rg -n "status === 'error'|status === \\\"error\\\"|error'\\]\\.includes\\(.*status|fetchMarkdown\\(\\)" app -S --glob '!**/node_modules/**' || true

Repository: npmx-dev/npmx.dev

Length of output: 509


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Search for error-state handling near lazy fetch/clipboard flows\n'
rg -n "status === 'error'|status === \"error\"|\\bcopyMarkdown\\b|\\bprefetchMarkdown\\b|useLazyFetch<.*Markdown" app docs test -S --glob '!**/node_modules/**' || true

printf '\n## Inspect related clipboard buttons for comparison\n'
for f in app/components/Terminal/Install.vue app/components/Code/Header.vue app/composables/useInstallCommand.ts; do
  if [ -f "$f" ]; then
    echo "--- $f"
    cat -n "$f" | sed -n '1,220p'
  fi
done

Repository: npmx-dev/npmx.dev

Length of output: 22933


Retry on fetch errors before copying markdown. prefetchMarkdown() and copyMarkdown() only fetch when status is idle/pending, so a transient failure leaves the button unable to recover and it can keep copying stale/empty content. Allow status === 'error' to retry, or surface the failure instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/Button/CopyMd.vue` around lines 15 - 36, Update
prefetchMarkdown and the fetch condition inside copyMarkdown so status ===
'error' retries fetchMarkdown before copying, preventing stale or empty markdown
after transient failures. Preserve the existing idle/pending behavior and ensure
copying proceeds only with the refreshed result or surfaces the fetch failure.

Comment on lines +11 to +21
const url = computed(() => `/api/changelog/releases/${info.provider}/${info.repo}` as const)

const {
data: releases,
error,
pending,
} = useLazyFetch<ReleaseData[]>(() => `/api/changelog/releases/${info.provider}/${info.repo}`)
} = useLazyFetch<ReleaseData[]>(url, {
query: {
host: computed(() => info.host),
},
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the releases raw markdown endpoint handler accepts and uses host
ast-grep outline server/api/changelog/releases --items all --type function

# Check ChangelogCard.vue for any inject or other host access mechanism
rg -n -C3 'host' app/components/Changelog/Card.vue

Repository: npmx-dev/npmx.dev

Length of output: 450


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant components and endpoint implementations.
ast-grep outline app/components/Changelog/Releases.vue --items all
ast-grep outline app/components/Changelog/Card.vue --items all
ast-grep outline app/components/Changelog/Markdown.vue --items all
ast-grep outline server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts --items all

# Read the small relevant slices with line numbers.
printf '\n--- Releases.vue ---\n'
cat -n app/components/Changelog/Releases.vue | sed -n '1,220p'

printf '\n--- Card.vue ---\n'
cat -n app/components/Changelog/Card.vue | sed -n '1,220p'

printf '\n--- Markdown.vue ---\n'
cat -n app/components/Changelog/Markdown.vue | sed -n '1,220p'

printf '\n--- raw/[tag].get.ts ---\n'
cat -n server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts | sed -n '1,260p'

Repository: npmx-dev/npmx.dev

Length of output: 15982


Propagate host to the release card raw fetch
ChangelogCard fetches ${baseUrl}/raw/${release.tag} without host, so self-hosted Forgejo/GitLab instances fall back to the default host in server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts and can return the wrong markdown or a 404. Pass host through from Releases.vue and include it in the card’s useLazyFetch query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/Changelog/Releases.vue` around lines 11 - 21, Pass the
existing host value from Releases.vue into each ChangelogCard, then update
ChangelogCard’s raw release useLazyFetch query to include host alongside the
existing parameters. Ensure the raw endpoint receives the self-hosted host
instead of falling back to its default.

Comment on lines +19 to +20
const { host, raw } = v.parse(
v.object({ host: v.optional(v.string()), raw: v.optional(v.string()) }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Block SSRF through the host query parameter.

v.string() permits arbitrary hosts, which are passed directly into the server-side Markdown fetch. A caller can target private, loopback, link-local, or otherwise internal HTTPS services; raw mode can return the fetched body.

Use a shared outbound-host validator that checks URL structure, resolved addresses, and every redirect—or restrict hosts to an allowlist.

Also applies to: 32-46

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/changelog/md/`[provider]/[owner]/[repo]/[...path].get.ts around
lines 19 - 20, The changelog fetch path must not pass arbitrary host values from
the host query parameter into server-side requests. Update the validation and
fetch flow around the parsed host, including the logic at lines 32-46, to reuse
a shared outbound-host validator that validates URL structure, resolved
addresses, and every redirect, or enforce the established host allowlist before
fetching and returning raw content.

Comment on lines +93 to +101
switch (provider) {
case 'github':
return createGithubRepoInfo(owner, repo, path)
case 'codeberg':
case 'forgejo':
return createForgejoRepoInfo(host ?? 'codeberg.org', owner, repo, path)
case 'gitlab':
return createGitLabRepoInfo(host ?? 'gitlab.com', owner, repo, path)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add the missing Tangled repository dispatch.

createTangledInfo exists, but getRepoInfo() returns undefined for tangled, causing the handler's not-found branch even when the Markdown base URL is available.

Proposed fix
+    case 'tangled':
+      return createTangledInfo(owner, repo, path)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
switch (provider) {
case 'github':
return createGithubRepoInfo(owner, repo, path)
case 'codeberg':
case 'forgejo':
return createForgejoRepoInfo(host ?? 'codeberg.org', owner, repo, path)
case 'gitlab':
return createGitLabRepoInfo(host ?? 'gitlab.com', owner, repo, path)
}
switch (provider) {
case 'github':
return createGithubRepoInfo(owner, repo, path)
case 'codeberg':
case 'forgejo':
return createForgejoRepoInfo(host ?? 'codeberg.org', owner, repo, path)
case 'gitlab':
return createGitLabRepoInfo(host ?? 'gitlab.com', owner, repo, path)
case 'tangled':
return createTangledInfo(owner, repo, path)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/changelog/md/`[provider]/[owner]/[repo]/[...path].get.ts around
lines 93 - 101, Add a `tangled` case to the `getRepoInfo()` provider switch and
dispatch to the existing `createTangledInfo` function with the appropriate
repository parameters, so Tangled repositories return their info instead of
falling through as undefined. Preserve the existing dispatch behavior for
GitHub, Forgejo/Codeberg, and GitLab.

Comment on lines +23 to +24
const rawQuery = getQuery(event)
const { host } = v.parse(v.object({ host: v.optional(v.string()) }), rawQuery)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Block SSRF through custom provider hosts.

The unvalidated host value is interpolated directly into Forgejo and GitLab $fetch URLs. Reject private, loopback, link-local, reserved, and DNS-rebinding destinations, including redirect targets, or use an approved-host allowlist.

Also applies to: 105-106, 127-132

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/changelog/releases/`[provider]/[owner]/[repo].get.ts around lines
23 - 24, Validate the optional host from the query before it reaches the Forgejo
and GitLab $fetch URL construction, allowing only approved public destinations
or an explicit host allowlist. Apply the same protection to every provider
request and validate each redirect target to block private, loopback,
link-local, reserved, and DNS-rebinding addresses; reject invalid hosts before
fetching.

// https://github.com/unjs/ungh/pull/162

const responseGithub = await $fetch(
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

URL-encode tag in upstream API URLs.

The tag route param is URL-decoded by Nuxt and used directly in API URLs. Git tags can contain / (e.g., release/1.0.0), which would produce malformed URLs. Apply encodeURIComponent(tag) in all three provider functions.

🐛 Proposed fix
-      `https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`,
+      `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
-  const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases/tags/${tag}`)
+  const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`)
-  const data = await $fetch(`https://${host}/api/v4/projects/${repoPath}/releases/${tag}`)
+  const data = await $fetch(`https://${host}/api/v4/projects/${repoPath}/releases/${encodeURIComponent(tag)}`)

Also applies to: 122-122, 138-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/changelog/releases/`[provider]/[owner]/[repo]/raw/[tag].get.ts at
line 75, URL-encode the decoded tag route parameter in the upstream API URL
construction for all three provider functions. Update each URL template using
tag to apply encodeURIComponent(tag), while leaving owner, repo, and the
surrounding request behavior unchanged.

Comment on lines +243 to +248
if (
directory &&
!(
path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) ||
ROOT_ONLY_REGEX.test(path)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

ROOT_ONLY_REGEX never matches extracted paths — root-level changelogs are incorrectly rejected when a directory is specified.

ROOT_ONLY_REGEX is defined as /^\/[^/]+$/ (requires a leading /), but paths extracted from release bodies via matchedChangelog.replace(/^.*\/src\/branch\/[^/]+\//i, '') never have a leading / (e.g., changelog.md, not /changelog.md). This means ROOT_ONLY_REGEX.test(path) always returns false, so the directory constraint condition !(path.startsWith(directory) || ROOT_ONLY_REGEX.test(path)) always evaluates to true for root-level files when a directory is set, causing them to be rejected with [false, null].

This affects both checkLatestForgejoRelease (lines 243-248) and checkLatestGitlabRelease (lines 309-317).

🐛 Proposed fix
-const ROOT_ONLY_REGEX = /^\/[^/]+$/
+const ROOT_ONLY_REGEX = /^[^/]+$/

Also applies to: 309-317

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/changelog/detectChangelog.ts` around lines 243 - 248, Update
ROOT_ONLY_REGEX and both directory-filter conditions in
checkLatestForgejoRelease and checkLatestGitlabRelease so extracted root-level
paths such as changelog.md match without a leading slash, while nested paths
still require the configured directory prefix.

processLink,
toUserContentId,
lastSemanticLevel,
textFilter: createResolveGitTextToLinks(mdRepoInfo),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
curl -fsSL https://unpkg.com/sanitize-html@2.17.5/index.js \
  | rg -n -C3 'options\.textFilter\(escaped'

Repository: npmx-dev/npmx.dev

Length of output: 705


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant file and inspect the surrounding implementation.
git ls-files 'server/utils/changelog/markdown.ts' 'server/utils/changelog/*' | sed -n '1,120p'
echo '--- markdown.ts (relevant lines) ---'
sed -n '1,220p' server/utils/changelog/markdown.ts

Repository: npmx-dev/npmx.dev

Length of output: 7154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- createResolveGitTextToLinks and related helpers ---'
rg -n -C 5 'createResolveGitTextToLinks|resolveGitLinkText|sanitizeRawHTML|textFilter:' server/utils/changelog/markdown.ts server/utils/mdKit* server/utils/mdKit/** 2>/dev/null || true

echo '--- markdown.ts tail ---'
sed -n '220,420p' server/utils/changelog/markdown.ts

echo '--- mdKit sanitizeRawHTML references ---'
rg -n -C 4 'function sanitizeRawHTML|sanitizeRawHTML\(' server/utils/mdKit* server/utils/mdKit/** 2>/dev/null || true

Repository: npmx-dev/npmx.dev

Length of output: 7602


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- sanitizeRawHTML implementation ---'
sed -n '436,560p' server/utils/mdKit.ts

echo '--- mdRepoInfo construction sites ---'
rg -n -C 4 'hostBaseUrl|rawBaseUrl|blobBaseUrl|commitBaseUrl|issueBaseUrl|prBaseUrl|compareBaseUrl' server/utils/changelog server/utils -g '!server/utils/changelog/markdown.ts'

Repository: npmx-dev/npmx.dev

Length of output: 17331


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- create*RepoInfo callers ---'
rg -n -C 4 'createGithubRepoInfo\(|createForgejoRepoInfo\(|createGitLabRepoInfo\(|createTangledInfo\(' server . -g '!**/node_modules/**'

echo '--- joinURL semantics from dependency source if vendored or lock references ---'
node - <<'JS'
const { joinURL } = require('ufo')
for (const c of [
  ['https://github.com', 'owner/repo/issues', '12'],
  ['https://github.com" onclick="x', '12'],
  ['https://github.com', 'owner/repo"', '12'],
]) {
  try {
    console.log(c.join(' | '), '=>', joinURL(c[0], c[1], c[2]))
  } catch (e) {
    console.log(c.join(' | '), '=> ERR:', e.message)
  }
}
JS

Repository: npmx-dev/npmx.dev

Length of output: 17276


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- callers and repo URL parsing ---'
rg -n -C 4 'createGithubRepoInfo\(|createForgejoRepoInfo\(|createGitLabRepoInfo\(|createTangledInfo\(|owner\s*[:=]|repo\s*[:=]' server

echo '--- repository info construction helpers ---'
sed -n '1,220p' server/utils/changelog/mdRepoInfo.ts

Repository: npmx-dev/npmx.dev

Length of output: 36214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- callers and repo URL parsing ---'
rg -n -C 4 'createGithubRepoInfo\(|createForgejoRepoInfo\(|createGitLabRepoInfo\(|createTangledInfo\(' server

echo '--- repository info construction helpers ---'
sed -n '1,220p' server/utils/changelog/mdRepoInfo.ts

Repository: npmx-dev/npmx.dev

Length of output: 8962


Do not emit raw <a> tags from textFilter here. sanitize-html appends the filter output verbatim, so quote characters in request-derived host/owner/repo values can break the generated href and inject markup. Build these links before sanitising, or HTML-escape the generated URL and label first. Add a regression test with quote-containing repo components.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/changelog/markdown.ts` at line 100, Update the textFilter
configuration to prevent createResolveGitTextToLinks from emitting unsafe raw
anchor markup from request-derived host, owner, or repo values; build links
before sanitization or HTML-escape both generated URLs and labels before
insertion. Add a regression test covering quote-containing repository components
and verify the resulting href and text cannot inject markup.

'!': /\B!\d+\b/g,
} as const

const accountRegex = /\B@(?![\d.]+\b)(?![\w.-]*\/)[\w\-.]+\b/g

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude emails and package-version suffixes from account matching.

\B@ also matches name@example.com and package@latest, incorrectly linking @example.com or @latest.

Proposed fix
-const accountRegex = /\B@(?![\d.]+\b)(?![\w.-]*\/)[\w\-.]+\b/g
+const accountRegex = /(?<![\w.+-])@(?![\d.]+\b)(?![\w.-]*\/)[\w\-.]+\b/g

Also applies to: 263-267

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/utils/changelog/markdown.ts` at line 239, Update accountRegex and the
corresponding matching logic at the referenced usage to exclude @ segments from
email addresses and package-version suffixes such as package@latest, while
preserving valid standalone account mentions. Ensure both account-matching paths
use the same corrected boundary behavior.

Comment thread shared/utils/constants.ts

export const ERROR_CHANGELOG_NOT_FOUND =
'No releases or changelogs have been found for this package'
export const ERROR_CHANGELOG_NOT_FOUND = 'No release or changelog had been found for this package'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the user-facing grammar.

Use “was found” rather than “had been found”.

Proposed fix
-export const ERROR_CHANGELOG_NOT_FOUND = 'No release or changelog had been found for this package'
+export const ERROR_CHANGELOG_NOT_FOUND = 'No release or changelog was found for this package'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const ERROR_CHANGELOG_NOT_FOUND = 'No release or changelog had been found for this package'
export const ERROR_CHANGELOG_NOT_FOUND = 'No release or changelog was found for this package'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/utils/constants.ts` at line 48, Update the ERROR_CHANGELOG_NOT_FOUND
constant’s user-facing message to use “was found” instead of “had been found,”
preserving the rest of the wording.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants