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.

diff --git a/frontend/routes/ticket.tsx b/frontend/routes/ticket.tsx index f3d7a29ed..1cba40108 100644 --- a/frontend/routes/ticket.tsx +++ b/frontend/routes/ticket.tsx @@ -25,6 +25,7 @@ import type { export default define.page(function Ticket({ data, state, + url, }) { const ticket = data.ticket; // The reporter of an unclaimed ticket reaches this page with the token from @@ -80,6 +81,10 @@ export default define.page(function Ticket({ ticketId={ticket.id} claimToken={data.claimToken} signedIn={state.user !== null} + // Carries the claim token through the login round trip, so the + // reporter lands back here able to claim rather than on the home page + // with the link they followed spent. + returnTo={`${url.pathname}${url.search}`} /> )} From 871ff492ce797ec5eea273b7497dff61314d2834 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 25 Aug 2026 13:00:48 +0000 Subject: [PATCH 2/8] fix(api): let staff reply to a ticket nobody has claimed (#1515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Replying to a ticket that arrived by email fails with a 500 and the reply is discarded.** That is the ordinary way support mail gets answered, so answering inbound email does not currently work at all. Found while building something else; the new test was simply the first thing to exercise this path. ## Cause `ticket_add_message` works out whether the author is the person who opened the ticket, to decide which way the message is going and who is owed a response. It asked the database: ```sql SELECT creator = $1 as "is_creator!" FROM tickets WHERE id = $2 ``` A ticket opened by email has no creator until somebody claims it, and `NULL = $1` is NULL rather than false — so the query returned NULL into a column the caller had asserted was non-null. Every such reply failed to decode and 500'd. It went unnoticed because the flow it breaks is the one combination the tests did not cover: replies were only ever exercised on tickets opened through the web UI, which always have a creator. ## Fix `COALESCE(creator = $1, false)` — a ticket with no creator has no author who is its creator. The regression test replies to an unclaimed ticket as staff and checks the reply lands and moves the ticket to `waiting_on_user`. I confirmed it fails against the old query before fixing it, rather than assuming. ## Also here Removed the em dashes from the ticket emails, which read badly in a plain-text mail client. Split into two sentences rather than swapped for a hyphen, which would only be a worse version of the same construction. Affects all three ticket email templates, text and HTML. ## Testing 237 API tests pass. Clippy `-D warnings`, `cargo fmt`, `sqlx prepare --check` clean. --- ...140ef880c3be8ed33191c40e45e8ae6bc0ab.json} | 4 +-- api/src/api/hooks.rs | 29 +++++++++++++++++++ api/src/db/database.rs | 5 +++- .../support_ticket_auto_reply.html.hbs | 2 +- .../support_ticket_auto_reply.txt.hbs | 2 +- .../templates/support_ticket_created.html.hbs | 2 +- .../templates/support_ticket_created.txt.hbs | 2 +- .../templates/support_ticket_message.html.hbs | 2 +- .../templates/support_ticket_message.txt.hbs | 2 +- 9 files changed, 41 insertions(+), 9 deletions(-) rename api/.sqlx/{query-7aec305f8d3cf75fe4b6b47f67386e30f16668093448b612cb77a9e4b7e640fd.json => query-9285cef0b61fce21343e353c512e140ef880c3be8ed33191c40e45e8ae6bc0ab.json} (62%) diff --git a/api/.sqlx/query-7aec305f8d3cf75fe4b6b47f67386e30f16668093448b612cb77a9e4b7e640fd.json b/api/.sqlx/query-9285cef0b61fce21343e353c512e140ef880c3be8ed33191c40e45e8ae6bc0ab.json similarity index 62% rename from api/.sqlx/query-7aec305f8d3cf75fe4b6b47f67386e30f16668093448b612cb77a9e4b7e640fd.json rename to api/.sqlx/query-9285cef0b61fce21343e353c512e140ef880c3be8ed33191c40e45e8ae6bc0ab.json index a2cf036f6..d75a564b9 100644 --- a/api/.sqlx/query-7aec305f8d3cf75fe4b6b47f67386e30f16668093448b612cb77a9e4b7e640fd.json +++ b/api/.sqlx/query-9285cef0b61fce21343e353c512e140ef880c3be8ed33191c40e45e8ae6bc0ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT creator = $2 as \"is_creator!\" FROM tickets WHERE id = $1", + "query": "SELECT COALESCE(creator = $2, false) as \"is_creator!\" FROM tickets WHERE id = $1", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "7aec305f8d3cf75fe4b6b47f67386e30f16668093448b612cb77a9e4b7e640fd" + "hash": "9285cef0b61fce21343e353c512e140ef880c3be8ed33191c40e45e8ae6bc0ab" } diff --git a/api/src/api/hooks.rs b/api/src/api/hooks.rs index 77adad66c..8babc0af8 100644 --- a/api/src/api/hooks.rs +++ b/api/src/api/hooks.rs @@ -1174,6 +1174,35 @@ mod integration { ); } + #[tokio::test] + async fn staff_can_reply_to_a_ticket_nobody_has_claimed() { + let mut t = TestSetup::new().await; + + deliver(&mut t, inbound("", "Help", json!([]))).await; + let ticket = all_tickets(&mut t).await.remove(0); + assert_eq!(ticket.status, TicketStatus::Open); + + // The ticket has no creator until it is claimed, so anything comparing the + // author against it has to cope with a NULL on the other side. This is the + // ordinary path for answering support mail, and it used to 500. + let staff_token = t.staff_user.token.clone(); + let mut resp = t + .http() + .post(format!("/api/tickets/{}", ticket.id)) + .token(Some(&staff_token)) + .body_json(json!({ "message": "Have you tried publishing again?" })) + .call() + .await + .unwrap(); + let reply: crate::api::ApiTicketMessage = resp.expect_ok().await; + assert_eq!(reply.message, "Have you tried publishing again?"); + assert_eq!(reply.direction, crate::db::TicketMessageDirection::Outbound); + + // Staff spoke last, so the reporter is the one now owed a response. + let after = all_tickets(&mut t).await.remove(0); + assert_eq!(after.status, TicketStatus::WaitingOnUser); + } + #[tokio::test] async fn wrong_webhook_password_is_rejected() { let mut t = TestSetup::new().await; diff --git a/api/src/db/database.rs b/api/src/db/database.rs index f15cbb5aa..8c69a8672 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -4765,8 +4765,11 @@ gitlab_id: r.user_gitlab_id, ) -> Result<(TicketMessage, UserPublic)> { let mut tx = self.pool.begin().await?; + // COALESCE, because a ticket opened by email has no creator until somebody + // claims it, and `NULL = $2` is NULL rather than false. Without it, every + // staff reply to an unclaimed ticket fails to decode and 500s. let author_is_creator = sqlx::query_scalar!( - r#"SELECT creator = $2 as "is_creator!" FROM tickets WHERE id = $1"#, + r#"SELECT COALESCE(creator = $2, false) as "is_creator!" FROM tickets WHERE id = $1"#, id as _, author as _, ) diff --git a/api/src/emails/templates/support_ticket_auto_reply.html.hbs b/api/src/emails/templates/support_ticket_auto_reply.html.hbs index d4f9ac40f..e20b08db3 100644 --- a/api/src/emails/templates/support_ticket_auto_reply.html.hbs +++ b/api/src/emails/templates/support_ticket_auto_reply.html.hbs @@ -6,7 +6,7 @@ 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: 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): before: import from jsr:@luca/flag without a version After — latest version page (caret constraint, unversioned add command) and pinned version page (exact version everywhere): | Latest | Pinned | |--|--| | after: jsr specifier with caret range | after: exact version in add command and jsr specifier | --- 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 | |--|--| | ![before: README only, examples dropped](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/895-before.png) | ![after: README plus Examples section](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/895-after.png) | (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 | |--|--| | ![before: no usage instructions at all](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/799-before.png) | ![after: pnpm/Yarn/vlt/npm tabs shown](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/799-after.png) | --- 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>, DocNodeCacheError> { - let key = - format!("@{scope}/{package}/{version}/{version_is_latest}/{has_readme}"); + // runtime_compat is part of the key because the usage instructions baked + // into the GenerateCtx depend on it, and it is editable in the package + // settings. + let key = format!( + "@{scope}/{package}/{version}/{version_is_latest}/{has_readme}/{:?}", + runtime_compat + ); if let Some(cached) = self.cache.get(&key).await { return Ok(Some(cached)); @@ -1505,7 +1511,13 @@ impl deno_doc::html::UsageComposer for DocUsageComposer { ); } - if !self.runtime_compat.node.is_some_and(|compat| !compat) { + // Packages that only target browsers or Cloudflare Workers are still + // installed through npm-ecosystem package managers (via a bundler), so an + // explicit browser/workerd "yes" shows these tabs even when node is "no". + if !self.runtime_compat.node.is_some_and(|compat| !compat) + || self.runtime_compat.browser.is_some_and(|compat| compat) + || self.runtime_compat.workerd.is_some_and(|compat| compat) + { map.insert( UsageComposerEntry { name: "pnpm".to_string(), From ccd90c7939cee5377c1f01639fd7842905af5ef5 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Tue, 25 Aug 2026 13:37:29 +0000 Subject: [PATCH 6/8] feat: syntax highlighting for TOML, YAML, C, diff, INI, batch and Dockerfile (#1519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tree-sitter grammars for TOML (`toml`), YAML (`yaml`/`yml`) and C (`c`/`h`) to markdown code blocks and the file view, plus fence aliases for `mjs`/`cjs`/`mts`/`cts` (the file view already mapped those extensions, but markdown fences missed them). Grammar crates chosen for compatibility with the pinned tree-sitter 0.22: `tree-sitter-toml-ng@0.6.0`, `tree-sitter-yaml@0.6.1`, `tree-sitter-c@0.21.4`. The rest of the languages requested in #593 (diff, ini/editorconfig, batch, dockerfile) need a tree-sitter 0.25 upgrade first — their current grammar crates only expose `LanguageFn` — so that's left for a follow-up; `gitignore`/`git` grammars aren't published on crates.io at all. Towards #593 (the remaining languages land in a follow-up PR right after this merges — the merge queue locks this branch). ## Screenshots A README with `toml`/`yaml`/`diff`/`ini`/`dockerfile` fences (local dev registry): | Before | After | |--|--| | ![before: all fences plain](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/895-before.png) | ![after: toml and yaml highlighted](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/895-after.png) | (diff/ini/dockerfile remain plain — they're in the follow-up tranche that needs the tree-sitter upgrade. The after shot also shows the Examples section from #1518 — same local stack.) --- Cargo.lock | 33 +++++++++++++++ api/Cargo.toml | 3 ++ api/src/tree_sitter.rs | 91 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8bc78976..a29ce39f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,6 +3528,7 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "tree-sitter-bash", + "tree-sitter-c", "tree-sitter-css", "tree-sitter-highlight", "tree-sitter-html", @@ -3536,8 +3537,10 @@ dependencies = [ "tree-sitter-md", "tree-sitter-regex", "tree-sitter-rust", + "tree-sitter-toml-ng", "tree-sitter-typescript", "tree-sitter-xml", + "tree-sitter-yaml", "url", "urlencoding", "uuid", @@ -5452,6 +5455,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-c" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f956d5351d62652864a4ff3ae861747e7a1940dc96c9998ae400ac0d3ce30427" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-css" version = "0.21.1" @@ -5534,6 +5547,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-toml-ng" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695d20cd83acf16c02c773f03e76d7b43b19883d4e2ce3652a8f06b5e0da7455" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-typescript" version = "0.21.2" @@ -5554,6 +5577,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-yaml" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aad27ec46ad343d8b514f64dd3fdffb478c592ece561b6c935d90ef55589c6b6" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "triomphe" version = "0.1.13" diff --git a/api/Cargo.toml b/api/Cargo.toml index af5fbb923..39e89bd13 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -129,6 +129,9 @@ tree-sitter-rust = "0.21.2" tree-sitter-html = "0.20.3" tree-sitter-bash = "0.21.0" tree-sitter-xml = "0.6.4" +tree-sitter-toml-ng = "0.6.0" +tree-sitter-yaml = "0.6.1" +tree-sitter-c = "0.21.4" lazy_static = "1.5.0" tikv-jemallocator = { version = "0.6", features = ["profiling"] } tikv-jemalloc-ctl = { version = "0.6", features = ["stats"] } diff --git a/api/src/tree_sitter.rs b/api/src/tree_sitter.rs index f3dedef2e..4910aee94 100644 --- a/api/src/tree_sitter.rs +++ b/api/src/tree_sitter.rs @@ -140,9 +140,9 @@ pub fn tree_sitter_language_cb( ) -> Option<&'static HighlightConfiguration> { for lang in lang.split(',') { let cfg = match lang.trim() { - "js" | "javascript" => tree_sitter_language_javascript(), + "js" | "javascript" | "mjs" | "cjs" => tree_sitter_language_javascript(), "jsx" => tree_sitter_language_jsx(), - "ts" | "typescript" => tree_sitter_language_typescript(), + "ts" | "typescript" | "mts" | "cts" => tree_sitter_language_typescript(), "tsx" => tree_sitter_language_tsx(), "json" | "jsonc" => tree_sitter_language_json(), "css" => tree_sitter_language_css(), @@ -153,6 +153,9 @@ pub fn tree_sitter_language_cb( "rs" | "rust" => tree_sitter_language_rust(), "html" => tree_sitter_language_html(), "sh" | "bash" => tree_sitter_language_bash(), + "toml" => tree_sitter_language_toml(), + "yaml" | "yml" => tree_sitter_language_yaml(), + "c" | "h" => tree_sitter_language_c(), _ => continue, }; return Some(cfg); @@ -393,3 +396,87 @@ fn tree_sitter_language_bash() -> &'static HighlightConfiguration { config }) } + +fn tree_sitter_language_toml() -> &'static HighlightConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_toml_ng::language(), + "toml", + tree_sitter_toml_ng::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("failed to initialize tree_sitter_toml highlighter"); + config.configure(CAPTURE_NAMES); + config + }) +} + +fn tree_sitter_language_yaml() -> &'static HighlightConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_yaml::language(), + "yaml", + tree_sitter_yaml::HIGHLIGHTS_QUERY, + "", + "", + ) + .expect("failed to initialize tree_sitter_yaml highlighter"); + config.configure(CAPTURE_NAMES); + config + }) +} + +fn tree_sitter_language_c() -> &'static HighlightConfiguration { + static CONFIG: OnceLock = OnceLock::new(); + CONFIG.get_or_init(|| { + let mut config = HighlightConfiguration::new( + tree_sitter_c::language(), + "c", + tree_sitter_c::HIGHLIGHT_QUERY, + "", + "", + ) + .expect("failed to initialize tree_sitter_c highlighter"); + config.configure(CAPTURE_NAMES); + config + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use comrak::adapters::SyntaxHighlighterAdapter; + + fn highlight(lang: &str, code: &str) -> String { + let adapter = ComrakAdapter { + show_line_numbers: false, + }; + let mut out = Vec::new(); + adapter + .write_highlighted(&mut out, Some(lang), code) + .unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn highlights_added_languages() { + for (lang, code) in [ + ("toml", "[package]\nname = \"demo\"\n"), + ("yaml", "key: value\nlist:\n - a\n"), + ("yml", "key: value\n"), + ("c", "int main(void) { return 0; }\n"), + ("mjs", "export const x = 1;\n"), + ("mts", "export const x: number = 1;\n"), + ] { + assert!( + tree_sitter_language_cb(lang).is_some(), + "no highlighter for {lang}" + ); + let html = highlight(lang, code); + assert!(html.contains(" Date: Tue, 25 Aug 2026 13:40:28 +0000 Subject: [PATCH 7/8] feat: render markdown files in the source view (#1523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown files in the Files view now render like on GitHub, with a **Preview / Code** toggle in the file navigation bar (preview is the default; `?code` shows the highlighted source). The API returns rendered HTML alongside the highlighted source for `.md`/`.markdown` files, using the same comrak pipeline, ammonia sanitizer and relative-link resolution as README rendering — links resolve against the file's directory, and packages with a linked GitHub repository keep their existing link rewriting. Fixes #709. ## Screenshots Production today (markdown files show raw source only): ![before: raw markdown source on jsr.io](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/709-before-prod.png) After (local dev registry) — preview by default, with a toggle: | Preview | Code | |--|--| | ![after: rendered markdown with Preview/Code toggle](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/709-after-preview.png) | ![after: highlighted source via the Code toggle](https://raw.githubusercontent.com/crowlKats/jsr/docs-gen-sweep-assets/docs-gen-sweep/709-after-code.png) | --- api/src/api.yml | 4 ++ api/src/api/package.rs | 44 +++++++++++++-- api/src/api/types.rs | 11 +++- api/src/docs.rs | 88 ++++++++++++++++++++++++++++++ frontend/routes/package/source.tsx | 44 ++++++++++++++- frontend/utils/api_types.ts | 1 + 6 files changed, 185 insertions(+), 7 deletions(-) diff --git a/api/src/api.yml b/api/src/api.yml index 63a4daf0c..6df968203 100644 --- a/api/src/api.yml +++ b/api/src/api.yml @@ -3556,6 +3556,10 @@ components: type: string nullable: true description: HTML-rendered source code view. Null for binary files. + rendered: + type: string + nullable: true + description: Rendered HTML, present for markdown files. required: - kind - size diff --git a/api/src/api/package.rs b/api/src/api/package.rs index 0b2dc80e7..d42b7941b 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -1738,7 +1738,7 @@ pub async fn get_source_handler( let db = req.data::().unwrap(); let buckets = req.data::().unwrap(); - let _ = db + let (_, repo, _) = db .get_package(&scope, &package) .await? .ok_or(ApiError::PackageNotFound)?; @@ -1822,7 +1822,31 @@ pub async fn get_source_handler( None }; - ApiSource::File { size, view } + // Markdown files additionally get a rendered form so the frontend can + // offer a GitHub-style preview next to the source. + let rendered = if path_buf + .extension() + .is_some_and(|ext| matches!(&*ext.to_string_lossy(), "md" | "markdown")) + { + std::str::from_utf8(&file).ok().and_then(|md| { + crate::docs::render_markdown_file( + md, + &scope, + &package, + &version.version, + repo, + path_buf.to_string_lossy().as_ref(), + ) + }) + } else { + None + }; + + ApiSource::File { + size, + view, + rendered, + } } else { let files = db .list_package_files(&scope, &package, &version.version) @@ -5176,12 +5200,18 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let mut resp = t.http().get(url).call().await.unwrap(); let body = resp.expect_ok::().await; - let ApiSource::File { size, view } = body.source else { + let ApiSource::File { + size, + view, + rendered, + } = body.source + else { panic!(); }; assert_eq!(size, 124); assert!(view.is_some()); + assert!(rendered.is_none()); let url = format!( "/api/scopes/{}/packages/{}/versions/{}/source?path=/bin.bin", @@ -5190,12 +5220,18 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let mut resp = t.http().get(url).call().await.unwrap(); let body = resp.expect_ok::().await; - let ApiSource::File { size, view } = body.source else { + let ApiSource::File { + size, + view, + rendered, + } = body.source + else { panic!(); }; assert_eq!(size, 1000); assert!(view.is_none()); + assert!(rendered.is_none()); } #[tokio::test] diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 88da77447..c8469d31b 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -726,8 +726,15 @@ pub struct ApiSourceDirEntry { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum ApiSource { - Dir { entries: Vec }, - File { size: usize, view: Option }, + Dir { + entries: Vec, + }, + File { + size: usize, + view: Option, + /// Rendered HTML, present for markdown files. + rendered: Option, + }, } #[derive(Debug, Serialize, Deserialize)] diff --git a/api/src/docs.rs b/api/src/docs.rs index 2801d78d2..e6eb6193e 100644 --- a/api/src/docs.rs +++ b/api/src/docs.rs @@ -666,6 +666,71 @@ fn get_url_rewriter( }) } +/// Renders a standalone markdown file from a package (e.g. a non-README `.md` +/// opened in the source view) to sanitized HTML, without building a full +/// `GenerateCtx`. `path` is the package-root-relative path of the file (with +/// a leading slash); relative links resolve against the file's directory, the +/// same way README links do. +pub fn render_markdown_file( + md: &str, + scope: &ScopeName, + package: &PackageName, + version: &Version, + github_repository: Option, + path: &str, +) -> Option { + let renderer = deno_doc::html::comrak::create_renderer( + Some(Arc::new(super::tree_sitter::ComrakAdapter { + show_line_numbers: false, + })), + Some(Box::new(match_node_value)), + Some(Box::new(|html| AMMONIA.clean(&html).to_string())), + ); + + let url_rewriter = get_url_rewriter( + format!("/@{scope}/{package}/{version}"), + github_repository, + false, + ); + + let specifier = ModuleSpecifier::parse(&format!("file://{path}")).ok()?; + let short_path = ShortPath::new(specifier, None, None, None); + + let anchorizer: deno_doc::html::jsdoc::Anchorizer = { + let seen = std::sync::Mutex::new(std::collections::HashMap::new()); + Arc::new(move |content: String, level: u8| { + let mut slug = String::new(); + for ch in content.chars() { + if ch.is_alphanumeric() { + slug.extend(ch.to_lowercase()); + } else if (ch.is_whitespace() || ch == '-') && !slug.ends_with('-') { + slug.push('-'); + } + } + let mut seen = seen.lock().unwrap(); + let count = seen + .entry((slug.clone(), level)) + .and_modify(|count| *count += 1) + .or_insert(0); + if *count > 0 { + format!("{slug}-{count}") + } else { + slug + } + }) + }; + + CURRENT_FILE.set(Some(Some(short_path))); + URL_REWRITER.set(Some(url_rewriter)); + + let rendered = renderer(md, false, None, anchorizer); + + CURRENT_FILE.set(None); + URL_REWRITER.set(None); + + rendered +} + /// Maximum number of symbol rows rendered in module symbol listings (the /// per-entrypoint overview and the "all symbols" page). Namespace-heavy /// packages (e.g. zod, which re-exports its whole API under several @@ -1567,6 +1632,29 @@ mod tests { use super::*; use deno_doc::html::ShortPath; + #[test] + fn render_markdown_file_test() { + let html = render_markdown_file( + "# Hi\n\nSee the [guide](./other.md) and ![img](image.png)\n\n```ts\nconst x: number = 1;\n```\n", + &ScopeName::new("scope".to_string()).unwrap(), + &PackageName::new("pkg".to_string()).unwrap(), + &Version::new("1.0.0").unwrap(), + None, + "/docs/guide.md", + ) + .unwrap(); + assert!( + html.contains(r#"href="/@scope/pkg/1.0.0/docs/./other.md""#), + "{html}" + ); + assert!( + html.contains(r#"src="/@scope/pkg/1.0.0/docs/image.png""#), + "{html}" + ); + // code fences go through the tree-sitter highlighter + assert!(html.contains("(function PackagePage( const sourceRoot = `/@${params.scope}/${params.package}/${data.selectedVersion.version}`; + const rendered = + (data.source?.source.kind === "file" && data.source.source.rendered) || + null; + const showPreview = rendered !== null && !data.showCode; + const selfPath = sourceRoot + + (data.sourcePath === "/" ? "" : data.sourcePath); + return (
{data.source && ( @@ -100,6 +107,30 @@ export default define.page(function PackagePage( ); })}
+ {rendered !== null && ( + + )} {data.source @@ -118,7 +149,17 @@ export default define.page(function PackagePage( ) : ( - data.source.source.view + showPreview + ? ( +
+
+
+ ) + : data.source.source.view ? (
Date: Tue, 25 Aug 2026 16:27:57 +0000 Subject: [PATCH 8/8] fix(api): render markdown footnotes (#1524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsr half of #291. deno_doc 0.205.0 (denoland/deno_doc#849) turns comrak's `footnotes` extension on, so this bumps to it and widens the ammonia allowlist to let the markup it emits through — the allowlist stripped the `
` wrapper and the `footnote-ref`/`footnote-backref` classes, which would have mangled every rendered footnote. The `#fn-*`/`#fnref-*` anchors already survive: `id` is an allowed generic attribute and fragment hrefs pass the URL rewriter untouched. - `api`: `=0.204.0` → `=0.205.0`, plus `section` + the three footnote classes on the sanitizer. Everything that renders markdown through `AMMONIA` gets footnotes — JSDoc, READMEs, and the markdown source view from #1523. - Test renders `claim[^1]` through `render_markdown_file` (the real render path, sanitizer included) rather than a hand-written fixture, so it tracks whatever markup the deno_doc of the day emits, and asserts both the classes and the anchor pair that makes the jump there and back work. - `frontend`: the pin moves with the api's (they're kept in sync), which also lets `ModuleDoc` drop its `symbols_trimmed` cast — 0.205.0's html-types declare the field (denoland/deno_doc#845). Footnote styling ships in deno_doc's `comrak.gen.css`, which the frontend already serves as `comrakCss`, so no css change here. Fixes #291. --- Cargo.lock | 4 ++-- api/Cargo.toml | 2 +- api/src/docs.rs | 31 ++++++++++++++++++++++++++- frontend/components/doc/ModuleDoc.tsx | 5 +---- frontend/deno.json | 2 +- frontend/deno.lock | 10 ++++----- 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a29ce39f3..a40ee4719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,9 +1056,9 @@ dependencies = [ [[package]] name = "deno_doc" -version = "0.204.0" +version = "0.205.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c121a45c4001f3b89cc7af2e3cab71514db6807ded0c9a0317b118d3d3fe87" +checksum = "4d80bf35d7150d36768fc1f4e6bb71444f39924263b71a343e1a7b4efa64b9d8" dependencies = [ "anyhow", "cfg-if", diff --git a/api/Cargo.toml b/api/Cargo.toml index 39e89bd13..b68ef389f 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -98,7 +98,7 @@ async-compression = { version = "0.4", features = ["futures-io", "gzip"] } deno_graph = "=0.110.2" deno_ast = { version = "0.53.0", features = ["view"] } # sync with frontend/deno.json -deno_doc = { version = "=0.204.0", features = ["comrak"] } +deno_doc = { version = "=0.205.0", features = ["comrak"] } deno_error = "0.7.0" comrak = { version = "0.29.0", default-features = false } ammonia = "4.0.0" diff --git a/api/src/docs.rs b/api/src/docs.rs index e6eb6193e..69768e22f 100644 --- a/api/src/docs.rs +++ b/api/src/docs.rs @@ -287,7 +287,7 @@ lazy_static::lazy_static! { let mut ammonia_builder = ammonia::Builder::default(); ammonia_builder - .add_tags(["video", "button", "svg", "path", "rect"]) + .add_tags(["video", "button", "svg", "path", "rect", "section"]) .add_generic_attributes(["id", "align"]) .add_tag_attributes("button", ["data-copy"]) .add_tag_attributes( @@ -322,6 +322,10 @@ lazy_static::lazy_static! { .add_tag_attributes("video", ["src", "controls"]) .add_allowed_classes("pre", ["highlight"]) .add_allowed_classes("button", ["copyButton"]) + // comrak footnote output + .add_allowed_classes("section", ["footnotes"]) + .add_allowed_classes("sup", ["footnote-ref"]) + .add_allowed_classes("a", ["footnote-backref"]) .add_allowed_classes( "div", [ @@ -1655,6 +1659,31 @@ mod tests { assert!(html.contains(""#), "{html}"); + assert!(html.contains(r#""#), "{html}"); + assert!(html.contains(r#"class="footnote-backref""#), "{html}"); + // and so do the anchors that make the jump there and back work + assert!(html.contains(r##"href="#fn-1""##), "{html}"); + assert!(html.contains(r#"id="fn-1""#), "{html}"); + assert!(html.contains(r##"href="#fnref-1""##), "{html}"); + assert!(html.contains(r#"id="fnref-1""#), "{html}"); + } + #[test] fn url_resolver_test() { let resolver = DocResolver { diff --git a/frontend/components/doc/ModuleDoc.tsx b/frontend/components/doc/ModuleDoc.tsx index 70f7174ff..b71522d01 100644 --- a/frontend/components/doc/ModuleDoc.tsx +++ b/frontend/components/doc/ModuleDoc.tsx @@ -4,10 +4,7 @@ import { Deprecated } from "./Deprecated.tsx"; import { SymbolContent } from "./SymbolContent.tsx"; export function ModuleDoc({ content }: { content: ModuleDocCtx }) { - // `symbols_trimmed` is missing from @deno/doc@0.204.0's html-types; drop - // this extension once the published types include it. - const symbolsTrimmed = - (content as ModuleDocCtx & { symbols_trimmed?: boolean }).symbols_trimmed; + const symbolsTrimmed = content.symbols_trimmed; return (
diff --git a/frontend/deno.json b/frontend/deno.json index 31d440497..6c3d6e552 100644 --- a/frontend/deno.json +++ b/frontend/deno.json @@ -14,7 +14,7 @@ } }, "imports": { - "@deno/doc": "jsr:@deno/doc@^0.204.0", + "@deno/doc": "jsr:@deno/doc@^0.205.0", "@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^1.0.0", "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.1.2", "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.3.0", diff --git a/frontend/deno.lock b/frontend/deno.lock index c88c5e730..00dddeac6 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -2,7 +2,7 @@ "version": "5", "specifiers": { "jsr:@deno/cache-dir@0.25": "0.25.0", - "jsr:@deno/doc@0.204": "0.204.0", + "jsr:@deno/doc@0.205": "0.205.0", "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", "jsr:@deno/gfm@0.11": "0.11.0", "jsr:@deno/graph@0.100": "0.100.1", @@ -29,7 +29,6 @@ "jsr:@std/fmt@^1.0.7": "1.0.10", "jsr:@std/fmt@^1.0.8": "1.0.10", "jsr:@std/front-matter@1": "1.0.9", - "jsr:@std/fs@1": "1.0.24", "jsr:@std/fs@^1.0.19": "1.0.24", "jsr:@std/fs@^1.0.24": "1.0.24", "jsr:@std/fs@^1.0.6": "1.0.24", @@ -113,8 +112,8 @@ "jsr:@std/path@^1.0.8" ] }, - "@deno/doc@0.204.0": { - "integrity": "2098f4924af3b193984fd0a195321bc12a996a90a428fb93d27746bb4efb5009", + "@deno/doc@0.205.0": { + "integrity": "86e01dd7f2bc8b5d9059b3458cfce10a9e8a9f06245be612d82e09f21373c36e", "dependencies": [ "jsr:@deno/cache-dir", "jsr:@deno/graph@0.100" @@ -260,7 +259,6 @@ "@std/fs@1.0.24": { "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ - "jsr:@std/internal", "jsr:@std/path@^1.1.5" ] }, @@ -3021,7 +3019,7 @@ }, "workspace": { "dependencies": [ - "jsr:@deno/doc@0.204", + "jsr:@deno/doc@0.205", "jsr:@deno/gfm@0.11", "jsr:@fresh/core@^2.3.3", "jsr:@fresh/plugin-tailwind@1",