Repository navigation
fix: SiteOS audit fixes, www canonical host, and header Integrations link - #198
Merged
Merged
Conversation
The contributors API matches handles case-sensitively, but profile URLs and the sitemap use lowercase handles, so 837 non-teammate profiles returned 404. Keep each handle's stored casing from the list response and retry the detail lookup with it when the lowercase lookup misses.
- Add the trailing slash to blog pagination hrefs, which ReactPaginate renders as raw anchors outside Next's trailingSlash handling. - Point the /integrations and /integrations/azure redirects at slash-terminated destinations so they resolve in one hop, and link the footer straight to /integrations/channels/. - Replace absolute https://novu.co contact and blog CTA links with relative paths, keeping their utm_campaign values. CTAs rendered by the shared Link now open in the same tab like other internal links.
Category and pagination descriptions joined the category name and page number to the blog tagline without punctuation, producing snippets like "How to Stay up to date with the latest updates from Novu". Separate the parts into sentences and keep the markdown alternate in sync.
- ACI: "notified", drop the comma before a restrictive clause, "Participants'", and "ACI is open source. Contribute what's missing". Mirror the fixes in the page's markdown alternate. - Comparison pages: "self-hosted", "you've probably been dealing", and "in a few minutes".
Production redirects novu.co to www.novu.co, and NEXT_PUBLIC_DEFAULT_SITE_URL is now set to https://www.novu.co. - Default the site URL and inline env fallbacks to https://www.novu.co. - Build JSON-LD WebSite/Organization URLs and the logo with absoluteUrl() so they follow the configured host instead of a hardcoded apex.
The Integrations trigger was a toggle button with no destination, and on desktop a click after hover closed the dropdown, so it looked inert. Header items can now have both an href and a dropdown: the trigger links to /integrations/channels/ and the dropdown opens on hover or keyboard focus. Mobile keeps the accordion for items with a dropdown.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The desktop Integrations trigger is now a link to /integrations/channels/ that opens its dropdown on hover, so TC-NAV-001 could not find the old toggle button and timed out waiting for its onClick handler. Locate the link, assert its href, and wait for its onFocus handler before hovering.
Resolve the handle's stored casing from the contributors list before the detail request, instead of retrying after a lowercase miss. Mixed-case profiles now make one request, and a failed list falls back to the lowercase handle, so unknown handles return 404 rather than 500. When the API lists one person under several casings, the entry with the most pull requests wins. Lookups ignore inherited object keys such as "constructor".
The Integrations link no longer opens its dropdown on focus. A separate chevron button toggles the menu, so keyboard and touch users can reach both the landing page and the dropdown, and returning to the tab no longer reopens the menu. A click on any dropdown trigger that hover already opened keeps the menu open instead of closing it. On mobile, only items with a menu variant render as accordions, matching desktop.
Page URLs in JSON-LD and the integrations canonicals read NEXT_PUBLIC_DEFAULT_SITE_URL directly, so they became relative without it and pointed at production on previews. They now use getSiteUrl() and absoluteUrl() with trailing slashes, as getMetadata does. Breadcrumb JSON-LD gets its origin from the server and links without redirects.
Remove the unused ROUTE.bookMeeting, which still pointed at the apex host.
Teammate profiles return 404, so when the API lists a handle under several casings, a community entry now wins over a teammate one before pull requests are compared. Otherwise a teammate casing with more pull requests would 404 a profile the sitemap still lists.
A click only keeps a hover-opened menu open when it lands within 500 ms of the hover, which is when the hover and the click come from the same gesture. Clicking or pressing Enter on a trigger later now closes the menu on the first try. Desktop and mobile share one check for whether an item has a dropdown.
Guard the fixes from review: focus alone opens no menu, Enter and Escape toggle the Integrations menu, refocusing the link keeps it closed, and a click right after hover keeps a button-only menu open.
SocialShare and the RSS feed read NEXT_PUBLIC_DEFAULT_SITE_URL directly and linked without trailing slashes, so every shared or feed link took a redirect. The ACI share buttons hardcoded https://novu.co/aci, and the contributor share and embed links dropped the trailing slash. SocialShare now takes the page's canonical URL from the server, the ACI page passes its URL to both share buttons, and the RSS feed and contributor profile URL use absoluteUrl() with toCanonicalPathname(). Rendered pages are unchanged apart from these links and the URL shown in the contributor embed snippet.
Applications submitted on a preview wrote the preview host into the Notion "Job URL" field, which recruiters open later. getConfiguredSiteUrl() returns the configured public origin without the preview override, and the careers route uses it, as it did before the site URL helpers.
The hover-then-click check passed even when the click landed outside the 500 ms window: focus was still on the Integrations link, so its blur handler closed the menu on mousedown and the click reopened it. The test now blurs the link first, freezes Date.now() so runner speed can't move a click across the window, and also checks that a click after the window closes the menu.
The comparison and pricing "Talk To Sales" buttons still hardcoded "/contact-us". They render the same href, and knock.tsx picks up Prettier's reflow of two JSX text lines.
The blog pagination page repeated the tagline and page-number suffix that the category helper builds. getBlogListingDescription() now holds it, and getBlogCategoryDescription() builds on it. Rendered descriptions are unchanged.
unstable_cache doesn't cache a failed contributors list, so while the API was down every uncached profile request waited on the list again before its own detail request. createSourceGithubResolver() now skips the list for 30 seconds after a failure and uses the lowercase handle, as it already did when the list failed. The resolver is unit-tested for the list, fallback and retry paths, and the teammate-vs-community test now covers both orders.
Each header item's blur handler closed every open menu. With focus left in Integrations (its chevron clicked, keyboard focus, or a cmd-click on the link), clicking a link in the Product menu closed that menu on mousedown, so the click never landed. An item now closes only its own menu, and keeps it open when WebKit drops focus on a click inside it. The menu also records whether hover or a click opened it, replacing the 500 ms window: a click pins a hover-opened menu however long after the hover, the next click closes it, and hovering an open menu again no longer changes that. Escape now closes the menu with focus on the Integrations link, and items without a dropdown return early. The critical-flow test no longer blurs the link to dodge the blur handler and covers each of these cases.
The Integrations link points at /integrations/channels, so the item was highlighted only on those pages. Header items can now set activeHref, and Integrations uses /integrations, which also covers the sources and single integration pages.
Without the contributors list, the resolver fell back to the lowercase handle. For handles stored with capitals the detail API then 404s, so the profile called notFound(), and ISR cached a noindex 404 for an hour over the last good page. The resolver now returns null without the list, and getContributor reports the data as unavailable, so the page errors and keeps its previous version.
The helper appended the period, so /blog/ had none while the paginated and category pages did. The period now lives in the description itself, and /blog/ and /blog.md use the same helper.
Blog post, static, careers and integration pages joined the site URL and slug by hand. They now use absoluteUrl(toCanonicalPathname(...)), like the other routes, so slugs are encoded and the trailing slash comes from one place. The rendered URLs are unchanged.
The copy-paste agent prompt, the Connect footer's agents.md link, llms.txt, auth.md, the .well-known catalog files and the agent-preview user agent still used https://novu.co, which 308-redirects every request to www. They now use https://www.novu.co, so agents and crawlers get the canonical host without the extra hop. The tests that assert the prompt text are updated to match. The analytics data-domain stays "novu.co", since it is the analytics site ID, not a link.
The nav set data-active on the current item, but nothing styles or announces that attribute, so neither sighted nor screen-reader users could tell which item was current. The active link now also gets aria-current: "page" on its own page and "true" on other pages in its section, so Integrations is announced as current on every /integrations/ page. There is still no visual active state, as before. Critical flows cover Pricing on /pricing/ and Integrations on /integrations/sources/. The dropdown test's comment now says why the clock jumps between hover and click.
When the contributors list failed, every profile reported its data as unavailable, even handles stored in lowercase that need only the detail request. Profiles now request the lowercase handle anyway. A handle stored that way renders; a miss still counts as unavailable, since a handle stored with capitals 404s like a missing profile.
The footer repeated the agents.md URL instead of using the constant that connect-prompt.ts keeps for it.
yasell
approved these changes
Oct 29, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the repo-side findings from the SiteOS site audit (technical audit of 2026-10-05), aligns the code with
www.novu.coas the canonical host, and makes the header "Integrations" item clickable.Changes
trailingSlashhandling)./integrations/and/integrations/azure/redirect in one hop, and the footer links straight to/integrations/channels/.https://novu.co/...contact CTAs are now relative paths, withutm_campaignvalues kept. Two buttons now open in the same tab, like the site's other contact links: "Book Meeting" on four use-case pages, and the bottom "Discovery Session" CTA on those pages,/oss-friends/,/contributors/and every contributor profile./blog/now ends with a period like the other listings, and every listing builds its description in one helper. The markdown alternates are updated to match.www.novu.co: the default site URL is nowhttps://www.novu.co. Canonicals,og:url, JSON-LD page URLs and breadcrumbs are built withgetSiteUrl()orabsoluteUrl(), with trailing slashes (blog post, static, careers and integration pages useabsoluteUrl(toCanonicalPathname(...))for their slug URLs), so they follow the configured host (and the preview host on previews). Share links use the same canonical URLs: the share buttons on blog, changelog, customer and static pages, the ACI "Share on X" buttons (which pointed athttps://novu.co/aci), and the contributor share and embed links. RSS feed links do too. The careers "Job URL" written to Notion keeps the public host on previews throughgetConfiguredSiteUrl(). The copy-paste agent prompt ("…using instructions from https://www.novu.co/agents.md"), the Connect footer's agents.md link,llms.txt,auth.mdand the.well-knowncatalog files now usehttps://www.novu.cotoo, so agents skip the apex redirect. The analyticsdata-domainstaysnovu.co, since it's the analytics site ID./integrations/channels/plus a separate "Integrations menu" chevron button, so keyboard and touch users can reach both. A click on a dropdown opened by hover pins it, however long after the hover, and the next click closes it. A link in one dropdown now works while focus is in another item; the item's blur used to close every menu, so the click was lost. Escape closes the menu with focus on the Integrations link. The current header link now hasaria-current(pageon its own page,trueelsewhere in its section), so screen readers announce Integrations as current on every/integrations/page. There is still no visual active state, as before. The mobile menu keeps the accordion, and desktop and mobile share one check for whether an item has a dropdown.ROUTE.contactUs, and the unusedROUTE.bookMeetingis removed. Blog listing and category descriptions are built in shared helpers.src/app/AGENTS.mdnow requires the@/lib/site-urlhelpers for absolute URLs, andgetConfiguredSiteUrl()for links stored outside the site.main: resolved conflicts with the use-cases redesign (feat(usecases): redesign multi-channel notifications page #199) in favor ofmain's new multi-channel page and "Learn more" link, then merged the Agent Inbox menu link (feat(web): add Agent Inbox link to the footer and AI menu (fixes NV-9180) #200) without conflicts.Deployment notes
NEXT_PUBLIC_DEFAULT_SITE_URL=https://www.novu.cois already set in Vercel production.NEXT_PUBLIC_*values are inlined at build time, so canonicals, sitemap androbots.txtswitch towwwwith the next production build.robots.txtstill lists/sitemap-index.xml, which returns 404.contributors.novu.co) matches handles case-sensitively. A case-insensitive lookup in that API would let each profile make one request with no list. That change belongs in the contributors API, outside this repo.Testing
pnpm typecheckpasses,pnpm linthas 0 errors, andpnpm testpasses 282 of 282, including new tests for the handle-casing lookup, its behavior when the list is unavailable, the retry pause, andgetConfiguredSiteUrl().pnpm buildcompiles, then stops at/careers/[slug]locally because.env.localhas no Notion careers settings./contributors/j-rayx/shows its pull request, and unknown handles,constructorandtostringreturn 404./integrations/resolves in one hop.NEXT_PUBLIC_DEFAULT_SITE_URL=https://www.novu.co, canonical,og:url, JSON-LD androbots.txtall usewww.aria-currentchecks for Pricing on/pricing/and Integrations on/integrations/sources/. The desktop dropdown test covers keyboard use, a click 1 s after the hover pinning the menu, hovering a keyboard-opened menu again, Escape on the Integrations link, and a Product link click with focus left on Integrations. The skipped cases are the desktop-only tests on mobile and the auth-state cases, which need a test fixture the local server didn't have.pnpm test:criticallocally: 189 passed, 15 failed, 40 skipped. The same tests also fail on the previous commit (cc90348), all from the local environment: the careers page needs Notion settings, the BetterStack key is missing, and WebKit's Tab-focus tests depend on a macOS keyboard setting.https://www.novu.co/<path>/. Screenshots at 1440, 768 and 390 px match the previous commit pixel for pixel, except the URL text in the contributor embed snippet. The comparison pages render the corrected copy.wwwURL inllms.txt,auth.mdand the.well-knownfiles returns 200 on production without a redirect, except theopenapi.jsonalias, which redirects toapi.novu.coas before. The prompt shows as text only on the Slack channel pages, where it wraps to the same number of lines at 1440, 768 and 390 px; every other page that uses it is pixel-identical. The prompt critical flows pass in Chromium./blog/description's period./contributors/mayorjay/(stored in lowercase) renders, while/contributors/mahendrahegde/(stored asmahendraHegde) and an unknown handle return the error page instead of a 404. With the real API they return 200, 200 and 404.🤖 Generated with Claude Code