From 9b9a9f9105ac22348a0baa50382b1861d51b7d46 Mon Sep 17 00:00:00 2001
From: Leo Kettmeir
Date: Tue, 25 Aug 2026 12:03:24 +0000
Subject: [PATCH 1/8] fix(frontend): return to the ticket after signing in to
claim it (#1514)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sign-in link on an unclaimed ticket went to a bare `/login`, so
somebody following the claim link out of their auto-reply signed in and
landed on the home page. The ticket they were trying to reach was gone —
and so was the claim token that would have let them reach it again,
since it lives only in that link and in the email it came from. Getting
back meant digging the email out again.
The link now carries `?redirect=` back to this page, query string
included, the same way the header's sign-in link does
(`components/Header.tsx`). The reporter returns to the ticket already
signed in, with the claim button waiting.
Two details:
- **The return path is passed in from the route** rather than read from
`location`, which does not exist while the island is server-rendered.
- **The claim token rides along in the redirect**, which is what makes
the flow work at all. `sanitize_redirect_url` keeps the query string and
forces a same-origin relative path, so it survives intact and cannot be
pointed elsewhere. It does mean the token is briefly stored in
`oauth_states.redirect_url` — no worse than it already being in the
browser's history for that page, and those rows are cleaned up daily by
`clean_oauth_states`.
## Testing
`deno task lint:frontend` and `deno fmt` clean.
Not verified in a browser — local dev still 404s on every route for me,
which is a separate problem. The change is small and follows the
existing header pattern, but the flow itself is worth clicking through
once after deploy: open a claim link signed out, sign in, and check you
land back on the ticket with the claim button rather than on the home
page.
---
frontend/islands/ClaimTicket.tsx | 13 +++++++++++--
frontend/routes/ticket.tsx | 5 +++++
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/frontend/islands/ClaimTicket.tsx b/frontend/islands/ClaimTicket.tsx
index 693ca154e..e5b6653ae 100644
--- a/frontend/islands/ClaimTicket.tsx
+++ b/frontend/islands/ClaimTicket.tsx
@@ -6,10 +6,14 @@ import { api, path } from "../utils/api.ts";
/// rather than an account. Claiming binds it to the signed-in account, so it
/// shows up alongside their other tickets instead of only in their inbox.
export function ClaimTicket(
- { ticketId, claimToken, signedIn }: {
+ { ticketId, claimToken, signedIn, returnTo }: {
ticketId: string;
claimToken: string;
signedIn: boolean;
+ /// Where to come back to after signing in — this page, claim token and all.
+ /// Passed in rather than read from `location`, which does not exist when
+ /// this is rendered on the server.
+ returnTo: string;
},
) {
const [error, setError] = useState(null);
@@ -53,7 +57,12 @@ export function ClaimTicket(
)
: (
- Sign in{" "}
+
+ Sign in
+ {" "}
to link it to your account. You can also just reply to our email —
that works either way.
- You can keep replying to this email — your replies are added to the ticket automatically.
+ You can keep replying to this email. Your replies are added to the ticket automatically.
If you would rather follow the ticket on the web, you can link it to a {{ registry_name }} account:
diff --git a/api/src/emails/templates/support_ticket_auto_reply.txt.hbs b/api/src/emails/templates/support_ticket_auto_reply.txt.hbs
index 2f429eb8d..06ced35da 100644
--- a/api/src/emails/templates/support_ticket_auto_reply.txt.hbs
+++ b/api/src/emails/templates/support_ticket_auto_reply.txt.hbs
@@ -3,7 +3,7 @@ Hey{{#if name}} {{ name }}{{/if}},
Thanks for getting in touch. We have opened support ticket {{ ticket_number }} for your email, and someone will get back to you as soon as possible.
-You can keep replying to this email — your replies are added to the ticket automatically.
+You can keep replying to this email. Your replies are added to the ticket automatically.
If you would rather follow the ticket on the web, you can link it to a {{ registry_name }} account here:
diff --git a/api/src/emails/templates/support_ticket_created.html.hbs b/api/src/emails/templates/support_ticket_created.html.hbs
index fb1d52bc2..4cc37e6bc 100644
--- a/api/src/emails/templates/support_ticket_created.html.hbs
+++ b/api/src/emails/templates/support_ticket_created.html.hbs
@@ -26,7 +26,7 @@
You will receive emails whenever a new message is posted to the ticket.
- You can reply to the ticket via the link above, or simply reply to this email — your replies are added to the ticket automatically.
+ You can reply to the ticket via the link above, or simply reply to this email. Your replies are added to the ticket automatically.
Cheers,
diff --git a/api/src/emails/templates/support_ticket_created.txt.hbs b/api/src/emails/templates/support_ticket_created.txt.hbs
index 19359c9b0..94e2206f7 100644
--- a/api/src/emails/templates/support_ticket_created.txt.hbs
+++ b/api/src/emails/templates/support_ticket_created.txt.hbs
@@ -5,7 +5,7 @@ You have created a support ticket on {{ registry_name }}.
You will receive emails to this address whenever a new message is posted to the ticket.
-You can also view the ticket on {{ registry_url }}ticket/{{ ticket_id }}. You can reply to the ticket from there, or simply reply to this email — your replies are added to the ticket automatically.
+You can also view the ticket on {{ registry_url }}ticket/{{ ticket_id }}. You can reply to the ticket from there, or simply reply to this email. Your replies are added to the ticket automatically.
Cheers,
{{ registry_name }}
diff --git a/api/src/emails/templates/support_ticket_message.html.hbs b/api/src/emails/templates/support_ticket_message.html.hbs
index fbbf05216..5277f1488 100644
--- a/api/src/emails/templates/support_ticket_message.html.hbs
+++ b/api/src/emails/templates/support_ticket_message.html.hbs
@@ -16,7 +16,7 @@
- You can reply to the ticket via the link above, or simply reply to this email — your replies are added to the ticket automatically.
+ You can reply to the ticket via the link above, or simply reply to this email. Your replies are added to the ticket automatically.
Cheers,
diff --git a/api/src/emails/templates/support_ticket_message.txt.hbs b/api/src/emails/templates/support_ticket_message.txt.hbs
index 853af7263..07fa53789 100644
--- a/api/src/emails/templates/support_ticket_message.txt.hbs
+++ b/api/src/emails/templates/support_ticket_message.txt.hbs
@@ -7,7 +7,7 @@ You have received a reply to your support ticket on {{ registry_name }}:
{{content}}
---
-To view the ticket, you can go to {{ registry_url }}ticket/{{ ticket_id }}. You can reply to the ticket from there, or simply reply to this email — your replies are added to the ticket automatically.
+To view the ticket, you can go to {{ registry_url }}ticket/{{ ticket_id }}. You can reply to the ticket from there, or simply reply to this email. Your replies are added to the ticket automatically.
Cheers,
{{ registry_name }}
From 16f738edaae63864544ba0096964e8f720f8e25b Mon Sep 17 00:00:00 2001
From: Leo Kettmeir
Date: Tue, 25 Aug 2026 13:36:35 +0000
Subject: [PATCH 3/8] fix(api): version the usage snippets (#1516)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two related fixes to the "Use with" snippets, both in
`DocUsageComposer`:
- **#721**: when a pinned version is being viewed, the add commands
(`deno add`, `pnpm i`, `yarn add`, `vlt install`, `npx jsr add`, `bunx
jsr add`) now install exactly that version (`jsr:@scope/pkg@1.2.3`)
instead of silently installing the latest.
- **#1226**: the "Import directly with a jsr specifier" snippet now
always carries a version constraint, so copied snippets pass Deno's
`no-unversioned-import` lint rule: a caret range on the latest version
(`jsr:@scope/pkg@^1.2.3`, matching what `deno add` writes), and the
exact version when a version is pinned.
The bare "Import symbol" specifier stays unversioned — it's the
specifier that `deno add` / the package manager maps.
Added assertions to `test_package_docs` covering both the pinned and
`latest` cases.
Note: since #1473 docs are only served for the latest version, so the
pinned-version case currently only materializes on pinned-latest URLs —
the fix is correct for both and future-proof if that restriction is ever
lifted.
Fixes #721.
Fixes #1226.
## Screenshots
Production today (unversioned jsr specifier):
After — latest version page (caret constraint, unversioned add command)
and pinned version page (exact version everywhere):
| Latest | Pinned |
|--|--|
| | |
---
api/src/api/package.rs | 32 ++++++++++++++++++++++++++++++--
api/src/docs.rs | 40 ++++++++++++++++++++++++++++++----------
2 files changed, 60 insertions(+), 12 deletions(-)
diff --git a/api/src/api/package.rs b/api/src/api/package.rs
index 68b4dae92..0b2dc80e7 100644
--- a/api/src/api/package.rs
+++ b/api/src/api/package.rs
@@ -4219,7 +4219,8 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg==
let task = process_tarball_setup(&t, create_mock_tarball("ok")).await;
assert_eq!(task.status, PublishingTaskStatus::Success, "{:?}", task);
- // index page
+ // index page (pinned version): usage snippets install exactly this
+ // version, and the jsr specifier is pinned to it
let mut resp = t
.http()
.get("/api/scopes/scope/packages/foo/versions/1.2.3/docs")
@@ -4233,11 +4234,38 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg==
comrak_css: _,
script: _,
breadcrumbs,
- toc: _,
+ toc,
main: _,
} => {
assert_eq!(version.version, task.package_version);
assert!(breadcrumbs.is_none(), "{:?}", breadcrumbs);
+ let toc_json = serde_json::to_string(&toc).unwrap();
+ assert!(
+ toc_json.contains("deno add jsr:@scope/foo@1.2.3"),
+ "{toc_json}"
+ );
+ assert!(toc_json.contains("jsr:@scope/foo@1.2.3"), "{toc_json}");
+ }
+ ApiPackageVersionDocs::Redirect { .. } => panic!(),
+ }
+
+ // index page (latest): add commands are unversioned, but the direct jsr
+ // specifier carries a caret constraint on the latest version
+ let mut resp = t
+ .http()
+ .get("/api/scopes/scope/packages/foo/versions/latest/docs")
+ .call()
+ .await
+ .unwrap();
+ let docs: ApiPackageVersionDocs = resp.expect_ok().await;
+ match docs {
+ ApiPackageVersionDocs::Content { toc, .. } => {
+ let toc_json = serde_json::to_string(&toc).unwrap();
+ assert!(
+ !toc_json.contains("deno add jsr:@scope/foo@1.2.3"),
+ "{toc_json}"
+ );
+ assert!(toc_json.contains("jsr:@scope/foo@^1.2.3"), "{toc_json}");
}
ApiPackageVersionDocs::Redirect { .. } => panic!(),
}
diff --git a/api/src/docs.rs b/api/src/docs.rs
index f35d373ed..93a25cae4 100644
--- a/api/src/docs.rs
+++ b/api/src/docs.rs
@@ -739,7 +739,7 @@ pub fn get_generate_ctx(
href_resolver: Arc::new(DocResolver {
scope: scope.clone(),
package: package.clone(),
- version,
+ version: version.clone(),
version_is_latest,
registry_url,
deno_types: DENO_TYPES
@@ -771,6 +771,8 @@ pub fn get_generate_ctx(
runtime_compat,
scope,
package,
+ version,
+ version_is_latest,
}) as Arc
}),
rewrite_map: Some(rewrite_map),
@@ -1434,6 +1436,8 @@ struct DocUsageComposer {
runtime_compat: RuntimeCompat,
scope: ScopeName,
package: PackageName,
+ version: Version,
+ version_is_latest: bool,
}
impl deno_doc::html::UsageComposer for DocUsageComposer {
@@ -1448,22 +1452,38 @@ impl deno_doc::html::UsageComposer for DocUsageComposer {
usage_to_md: deno_doc::html::UsageToMd,
) -> IndexMap {
let mut map = IndexMap::new();
- let scoped_name = format!("@{}/{}", self.scope, self.package);
+ // When the user is looking at a pinned, non-latest version, the add
+ // commands install exactly that version instead of the latest one.
+ let scoped_name = if self.version_is_latest {
+ format!("@{}/{}", self.scope, self.package)
+ } else {
+ format!("@{}/{}@{}", self.scope, self.package, self.version)
+ };
let (is_main, path) = current_resolve
.get_file()
.map(|short_path| (short_path.is_main, &*short_path.path))
.unwrap_or((true, ""));
- let url = format!(
- "@{}/{}{}",
+ let subpath = if is_main {
+ String::new()
+ } else {
+ format!("/{path}")
+ };
+
+ let url = format!("@{}/{}{}", self.scope, self.package, subpath);
+
+ // A direct jsr specifier always carries a version constraint so the
+ // snippet passes Deno's no-unversioned-import lint rule: a caret range on
+ // the latest version (matching what `deno add` writes), and the exact
+ // version when a non-latest version is pinned.
+ let jsr_url = format!(
+ "@{}/{}@{}{}{}",
self.scope,
self.package,
- if is_main {
- String::new()
- } else {
- format!("/{path}")
- }
+ if self.version_is_latest { "^" } else { "" },
+ self.version,
+ subpath
);
let import = format!(
@@ -1477,7 +1497,7 @@ impl deno_doc::html::UsageComposer for DocUsageComposer {
name: "Deno".to_string(),
icon: Some("/logos/deno.svg".into()),
},
- format!("Add Package\n```\ndeno add jsr:{scoped_name}\n```{import}\n
or
\n\nImport directly with a jsr specifier\n{}\n", usage_to_md(&format!("jsr:{url}"), Some(self.package.as_str()))),
+ format!("Add Package\n```\ndeno add jsr:{scoped_name}\n```{import}\n
or
\n\nImport directly with a jsr specifier\n{}\n", usage_to_md(&format!("jsr:{jsr_url}"), Some(self.package.as_str()))),
);
}
From d547043726532cf10d07e0a68545254da3388ac3 Mon Sep 17 00:00:00 2001
From: Leo Kettmeir
Date: Tue, 25 Aug 2026 13:36:57 +0000
Subject: [PATCH 4/8] feat: show module JSDoc examples on the package overview
page (#1518)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The overview page already rendered module-level `@example` tags when the
module JSDoc was the overview's prose source, but dropped them entirely
when the README was (readme source setting, or JSDoc fallback to
README). Build the module doc context in both modes and only swap out
the prose, so a module's examples (and its `@deprecated` banner) show up
regardless of the readme source setting.
Fixes #895.
## Screenshots
A package with readme source set to README whose module JSDoc has an
`@example` (local dev registry):
| Before | After |
|--|--|
| 
| 
|
(The after shot also shows highlighted TOML/YAML fences from #1519 —
same local stack.)
---
api/src/docs.rs | 34 +++++++++++++++++++---------------
1 file changed, 19 insertions(+), 15 deletions(-)
diff --git a/api/src/docs.rs b/api/src/docs.rs
index 93a25cae4..10cc42dea 100644
--- a/api/src/docs.rs
+++ b/api/src/docs.rs
@@ -826,21 +826,25 @@ pub fn render_docs_html(
let render_ctx = RenderContext::new(ctx, doc_nodes, UrlResolveKind::Root);
- let mut index_module_doc = match readme_source {
- ReadmeSource::JSDoc => ctx
- .main_entrypoint
- .as_ref()
- .map(|entrypoint| {
- deno_doc::html::jsdoc::ModuleDocCtx::new(
- &render_ctx,
- entrypoint,
- false,
- false,
- )
- })
- .unwrap_or_default(),
- ReadmeSource::Readme => Default::default(),
- };
+ let mut index_module_doc = ctx
+ .main_entrypoint
+ .as_ref()
+ .map(|entrypoint| {
+ deno_doc::html::jsdoc::ModuleDocCtx::new(
+ &render_ctx,
+ entrypoint,
+ false,
+ false,
+ )
+ })
+ .unwrap_or_default();
+
+ // When the README is the overview's prose, drop the module JSDoc body
+ // so the README injection below kicks in — but keep the sections, which
+ // hold the module's `@example`s.
+ if matches!(readme_source, ReadmeSource::Readme) {
+ index_module_doc.sections.docs = None;
+ }
if index_module_doc.sections.docs.is_none() {
let markdown = readme
From bad5fe4e3e60124df462c128bc5de45a933a94a8 Mon Sep 17 00:00:00 2001
From: Leo Kettmeir
Date: Tue, 25 Aug 2026 13:37:16 +0000
Subject: [PATCH 5/8] fix(api): show npm-ecosystem usage instructions for
browser/workerd-only packages (#1521)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A package that explicitly marks only browsers (or Cloudflare Workers) as
supported — with `node: false` — got no pnpm/Yarn/vlt/npm tabs at all,
even though browser-only libraries are still installed through
npm-ecosystem package managers via a bundler. The reporter had to mark
Node compat as "unknown" to get install instructions to show.
The npm-family tabs now also show when `browser` or `workerd` is
explicitly `true`, regardless of `node`.
Also folds `runtime_compat` into the `GenerateCtxCache` key: the usage
instructions baked into a cached `GenerateCtx` depend on it, and it is
editable in the package settings, so editing runtime compat could serve
stale tabs until cache eviction.
Fixes #799.
## Screenshots
A browser-only package (`runtimeCompat: { browser: true, deno: false,
node: false, workerd: false, bun: false }`) on a local dev registry.
| Before | After |
|--|--|
| 
| 
|
---
api/src/docs.rs | 22 +++++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/api/src/docs.rs b/api/src/docs.rs
index 10cc42dea..2801d78d2 100644
--- a/api/src/docs.rs
+++ b/api/src/docs.rs
@@ -202,8 +202,9 @@ pub async fn download_doc_nodes(
}
/// Cache for fully-built GenerateCtx. Keyed by
-/// `scope/package/version/is_latest/has_readme` so concurrent requests
-/// for the same doc page share a single GenerateCtx without rebuilding.
+/// `scope/package/version/is_latest/has_readme/runtime_compat` so concurrent
+/// requests for the same doc page share a single GenerateCtx without
+/// rebuilding.
#[derive(Clone)]
pub struct GenerateCtxCache {
cache: moka::future::Cache>,
@@ -232,8 +233,13 @@ impl GenerateCtxCache {
registry_url: &str,
bucket: &crate::s3::Buckets,
) -> Result