{
  "schemaVersion": 1,
  "title": "Calvin Kennedy — Solutions",
  "scope": "Curated technical fixes. Source changes and reduced reproductions; no universal compatibility claim.",
  "entries": [
    {
      "slug": "astro-redirect-data-needs-a-route",
      "title": "Astro redirect data does nothing until a route serves it",
      "summary": "Turn a redirect table into an HTTP response, preserve the query string, and keep unknown paths at 404.",
      "tags": [
        "Astro",
        "Routing"
      ],
      "environment": "Astro 4, server output, Vercel serverless adapter. The example uses standard Request and Response APIs available in Node.js 20.",
      "symptom": "A legacy URL returns 404 even though a redirect table maps it to the replacement page.",
      "cause": "The application had moved /journal and /journals into a JSON redirect table and removed their page files. The table was parsed, but no request handler used it. Configuration alone did not create an HTTP route.",
      "fix": "Add a catch-all endpoint at src/pages/[...path].ts. Look up the exact pathname, return the configured redirect, and return 404 when there is no match. Keep this endpoint server-rendered so it can handle arbitrary incoming paths.",
      "language": "typescript",
      "code": "// src/pages/[...path].ts\n// getModeledRedirect reads the application-owned redirect table.\nimport { getModeledRedirect } from '@/lib/site-data';\n\nexport const prerender = false;\n\nexport function GET({ request }: { request: Request }) {\n  const url = new URL(request.url);\n  const redirect = getModeledRedirect(url.pathname, url.search);\n  if (redirect) {\n    return Response.redirect(new URL(redirect.location, url), redirect.status);\n  }\n  return new Response('Not found', {\n    status: 404,\n    headers: { 'content-type': 'text/plain; charset=utf-8' },\n  });\n}",
      "verification": [
        "Run the standalone reproduction linked below with Node.js 20 or newer. It asserts that the disconnected version returns 404, while the connected version returns 301 and the expected Location header.",
        "With the application running, request /journal?from=bookmark without following redirects. Expect 301 and a Location ending in /writing?from=bookmark.",
        "Request a path absent from the table. Expect 404, not a redirect to the home page."
      ],
      "limits": "This is an exact-path, application-owned redirect table. It does not implement wildcard rules, trailing-slash normalization or redirects supplied by visitors. Validate destinations before accepting untrusted redirect input. Other Astro rendering modes or adapters need their own deployment check.",
      "source": {
        "revision": "1f4040a686d838c5fd0a484166212df497f67aba",
        "file": "apps/portfolio/src/pages/[...path].ts",
        "change": "The request handler was added after d104272 moved the aliases into redirect data. The excerpt above retains that handler’s behavior.",
        "access": "private",
        "reproductionScope": "The public Node example checks reduced Request/Response wiring. It does not reproduce Astro route discovery or Vercel deployment behavior."
      },
      "references": [
        {
          "title": "Astro: server endpoints",
          "url": "https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes"
        }
      ],
      "url": "https://calvinkennedy.com/solutions/astro-redirect-data-needs-a-route",
      "markdown": "https://calvinkennedy.com/solutions/astro-redirect-data-needs-a-route.md",
      "reproduction": "https://calvinkennedy.com/solutions/astro-redirect-data-needs-a-route/repro"
    },
    {
      "slug": "collapsed-menu-links-still-receive-focus",
      "title": "A collapsed menu can still contain keyboard stops",
      "summary": "Keep hidden navigation out of the tab order with inert, then restore focus when the menu opens and closes.",
      "tags": [
        "Accessibility",
        "JavaScript"
      ],
      "environment": "An HTML navigation panel collapsed with CSS, with JavaScript controlling its open state. Requires browser support for the inert attribute.",
      "symptom": "Pressing Tab moves focus into links in a menu that appears closed. The visible page and keyboard navigation disagree.",
      "cause": "Collapsing the panel visually did not make its descendants non-interactive. The site header originally changed its open class and aria-expanded value without making the closed panel inert.",
      "fix": "Start the closed panel with inert. Remove it before focusing the first link on open. Restore it on close, update aria-expanded, and return focus to the toggle when closing with Escape. aria-hidden alone is not a substitute for removing keyboard stops.",
      "language": "html",
      "code": "<button id=\"toggle\" aria-expanded=\"false\" aria-controls=\"menu\">Menu</button>\n<nav id=\"menu\" inert aria-hidden=\"true\">\n  <a href=\"#content\">Read the page</a>\n</nav>\n<main id=\"content\" tabindex=\"-1\">Page content</main>\n<script>\n  const toggle = document.querySelector('#toggle');\n  const menu = document.querySelector('#menu');\n  function setOpen(open, restoreFocus = false) {\n    menu.inert = !open;\n    menu.setAttribute('aria-hidden', String(!open));\n    menu.classList.toggle('open', open);\n    toggle.setAttribute('aria-expanded', String(open));\n    if (open) menu.querySelector('a')?.focus();\n    else if (restoreFocus) toggle.focus();\n  }\n  toggle.addEventListener('click', () => setOpen(menu.inert));\n  menu.addEventListener('keydown', event => {\n    if (event.key === 'Escape') setOpen(false, true);\n  });\n</script>",
      "verification": [
        "Open the standalone reproduction below. In the broken example, focus its Menu button and press Tab: focus enters the visually collapsed panel.",
        "In the fixed example, focus its Menu button and press Tab: focus skips the closed panel and reaches the next visible link.",
        "Open the fixed menu. Its first link receives focus. Press Escape: the menu closes, aria-expanded becomes false, and focus returns to Menu. Tab again skips the closed links."
      ],
      "limits": "This is a disclosure navigation menu, not a modal dialog or an ARIA menu widget. It does not need a modal focus trap. A full header also needs checks for navigation, viewport changes and outside clicks. The reduced example demonstrates the closed, open and Escape paths only.",
      "source": {
        "revision": "d2039ad6af4fa2c3906f889d13bda7bac564b533",
        "file": "apps/portfolio/src/components/SiteHeader.astro",
        "change": "Added inert and aria-hidden to the initially closed panel, then synchronized both in the existing open and close handlers. The example is reduced from that change.",
        "access": "private",
        "reproductionScope": "The public browser example checks closed-panel Tab navigation, opening and Escape focus return. It is not the complete site header."
      },
      "references": [
        {
          "title": "MDN: inert",
          "url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert"
        }
      ],
      "url": "https://calvinkennedy.com/solutions/collapsed-menu-links-still-receive-focus",
      "markdown": "https://calvinkennedy.com/solutions/collapsed-menu-links-still-receive-focus.md",
      "reproduction": "https://calvinkennedy.com/solutions/collapsed-menu-links-still-receive-focus/repro"
    }
  ]
}