diff --git a/Cargo.lock b/Cargo.lock index f8bc78976..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", @@ -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/.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/Cargo.toml b/api/Cargo.toml index af5fbb923..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" @@ -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/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/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/api/package.rs b/api/src/api/package.rs index 68b4dae92..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) @@ -4219,7 +4243,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 +4258,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!(), } @@ -5148,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", @@ -5162,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/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/docs.rs b/api/src/docs.rs index f35d373ed..69768e22f 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)); @@ -281,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( @@ -316,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", [ @@ -660,6 +670,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 @@ -739,7 +814,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 +846,8 @@ pub fn get_generate_ctx( runtime_compat, scope, package, + version, + version_is_latest, }) as Arc }), rewrite_map: Some(rewrite_map), @@ -824,21 +901,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 @@ -1434,6 +1515,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 +1531,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,11 +1576,17 @@ 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()))), ); } - 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(), @@ -1531,6 +1636,54 @@ 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(""#), "{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/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 }} 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(" 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", 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/package/source.tsx b/frontend/routes/package/source.tsx index 76e24bdc1..e28461843 100644 --- a/frontend/routes/package/source.tsx +++ b/frontend/routes/package/source.tsx @@ -21,6 +21,13 @@ export default define.page(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 ? (
(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}`} /> )} diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index 994288f78..d04b84e49 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -196,6 +196,7 @@ export interface SourceFile { kind: "file"; size: number; view: string | null; + rendered: string | null; } export interface PackageVersionSource {