Skip to content

fix: SiteOS audit fixes, www canonical host, and header Integrations link - #198

Merged
vannyle merged 34 commits into
mainfrom
fix/siteos-seo-audit
Oct 29, 2026
Merged

vannyle merged 34 commits into
mainfrom
fix/siteos-seo-audit

Conversation

@vannyle

@vannyle vannyle commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the repo-side findings from the SiteOS site audit (technical audit of 2026-10-05), aligns the code with www.novu.co as the canonical host, and makes the header "Integrations" item clickable.

Changes

  • Contributor profiles (fixes 404s): the contributors API matches GitHub handles case-sensitively, but profile URLs and the sitemap use lowercase handles, so all 837 non-teammate profiles with mixed-case handles returned 404. Profiles now look up the handle's stored casing in the contributors list first, so each profile makes one API request. Unknown handles still return 404. If the list is unavailable, profiles still request the lowercase handle: a handle stored that way renders, and a miss counts as unavailable instead of a 404, so the page errors and ISR keeps the last good version. After a failed list load, profiles skip the list for 30 seconds instead of each waiting on it again. When the API lists one person under several casings, a community entry wins over a teammate one, then the entry with the most pull requests.
  • Links without redirect hops:
    • Blog pagination hrefs get their trailing slash (ReactPaginate renders raw anchors that skip Next's trailingSlash handling).
    • /integrations/ and /integrations/azure/ redirect in one hop, and the footer links straight to /integrations/channels/.
    • Absolute https://novu.co/... contact CTAs are now relative paths, with utm_campaign values 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 listing meta descriptions: category and pagination descriptions no longer run together (they read "How to Stay up to date…" before). /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.
  • Copy fixes: ACI page (and its markdown alternate) and the building-in-house, MagicBell and SuprSend comparison pages, including the data residency sentence ("and a self-hosted option").
  • Canonical host www.novu.co: the default site URL is now https://www.novu.co. Canonicals, og:url, JSON-LD page URLs and breadcrumbs are built with getSiteUrl() or absoluteUrl(), with trailing slashes (blog post, static, careers and integration pages use absoluteUrl(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 at https://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 through getConfiguredSiteUrl(). The copy-paste agent prompt ("…using instructions from https://www.novu.co/agents.md"), the Connect footer's agents.md link, llms.txt, auth.md and the .well-known catalog files now use https://www.novu.co too, so agents skip the apex redirect. The analytics data-domain stays novu.co, since it's the analytics site ID.
  • Header dropdowns: Integrations was a dropdown toggle with no destination. It is now a link to /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 has aria-current (page on its own page, true elsewhere 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.
  • Cleanup: contact CTAs, including the comparison and pricing "Talk To Sales" buttons, use ROUTE.contactUs, and the unused ROUTE.bookMeeting is removed. Blog listing and category descriptions are built in shared helpers. src/app/AGENTS.md now requires the @/lib/site-url helpers for absolute URLs, and getConfiguredSiteUrl() for links stored outside the site.
  • Merged main: resolved conflicts with the use-cases redesign (feat(usecases): redesign multi-channel notifications page #199) in favor of main'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.co is already set in Vercel production. NEXT_PUBLIC_* values are inlined at build time, so canonicals, sitemap and robots.txt switch to www with the next production build.
  • Not in this PR:
    • The Sanity content fixes (copy, broken links, apex links rewritten to relative paths) are 68 drafts awaiting review in Studio. The full list of changes, with before and after values and a Studio link for each draft, is here: Sanity audit drafts.
    • robots.txt still lists /sitemap-index.xml, which returns 404.
    • Contributor profiles still read the cached contributors list to find each handle's stored casing, because the contributors API (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 typecheck passes, pnpm lint has 0 errors, and pnpm test passes 282 of 282, including new tests for the handle-casing lookup, its behavior when the list is unavailable, the retry pause, and getConfiguredSiteUrl().
  • pnpm build compiles, then stops at /careers/[slug] locally because .env.local has no Notion careers settings.
  • Local dev server:
    • The four previously broken contributor profiles return 200 with lowercase canonicals. /contributors/j-rayx/ shows its pull request, and unknown handles, constructor and tostring return 404.
    • /integrations/ resolves in one hop.
    • The affected pages render relative contact links.
    • With NEXT_PUBLIC_DEFAULT_SITE_URL=https://www.novu.co, canonical, og:url, JSON-LD and robots.txt all use www.
  • Headless Chromium header checks, 19 of 19 passing:
    • Focus opens no menu, and Tab skips a closed dropdown.
    • Enter, Escape and Enter again work on the menu button.
    • Hover then click keeps Product, Build with AI, Integrations and Resources open.
    • The Integrations link navigates, and refocusing it keeps the menu closed.
    • With touch at 1024px, a tap on the chevron opens the menu.
    • The mobile accordion expands.
  • Navigation critical flows pass on desktop and mobile Chromium and WebKit (16 passed, 16 skipped), including aria-current checks 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.
  • In Chromium and WebKit, a Product link click works with focus left on the Integrations link, its chevron or after a cmd-click, and in a menu opened from the keyboard. Each was lost before. Clicking elsewhere or tabbing out still closes a keyboard-opened menu.
  • Full pnpm test:critical locally: 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.
  • Share links, checked against the production Sanity project: every share button, the RSS feed and the contributor dialogs give 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.
  • Agent-facing links: every new www URL in llms.txt, auth.md and the .well-known files returns 200 on production without a redirect, except the openapi.json alias, which redirects to api.novu.co as 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.
  • Rendered head tags and JSON-LD on the blog listing, pagination and category pages, a blog post, a static page and an integration page match the previous commit, except the /blog/ description's period.
  • With the contributors list failing (a local mock API that returns 500 for the list and passes detail requests through), /contributors/mayorjay/ (stored in lowercase) renders, while /contributors/mahendrahegde/ (stored as mahendraHegde) 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

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.
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
website-new Ready Ready Preview Oct 29, 2026 10:15am UTC

Request Review

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.
@vannyle
vannyle merged commit 7a5f7f8 into main Oct 29, 2026
5 checks passed

This branch was successfully deployed

1 active deployment
Preview — 1e481b08 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants