From ad941aae93fb5cc3094265c8a5a30b908aad256a Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Wed, 19 Aug 2026 15:54:32 +0100 Subject: [PATCH] feat(docs): demo page for custom path atoms (yyyy-mm-dd, name.ext) Adds /demos/complex-routing: routeAtom-built single-segment atoms for a yyyy-mm-dd archive date (gated on the real calendar with validateAtom) and a name.ext filename. Ticket: 677 --- packages/docs/src/App.tsx | 5 + packages/docs/src/demos/ComplexRoutingApp.tsx | 174 ++++++++++++++++++ .../docs/src/demos/complexRoutingSamples.ts | 16 ++ .../docs/src/pages/ComplexRoutingDemo.tsx | 16 ++ packages/docs/src/pages/DemosIndex.tsx | 15 +- packages/docs/src/router/routes.ts | 23 ++- 6 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 packages/docs/src/demos/ComplexRoutingApp.tsx create mode 100644 packages/docs/src/demos/complexRoutingSamples.ts create mode 100644 packages/docs/src/pages/ComplexRoutingDemo.tsx diff --git a/packages/docs/src/App.tsx b/packages/docs/src/App.tsx index b31e7dc..4217f81 100644 --- a/packages/docs/src/App.tsx +++ b/packages/docs/src/App.tsx @@ -13,6 +13,7 @@ import { basicRoutingDemoRoute, blogRoutingDemoRoute, dataGridDemoRoute, + complexRoutingDemoRoute, asyncLookupDemoRoute, } from "./router/routes"; import Home from "./pages/Home"; @@ -24,6 +25,7 @@ import DemosIndex from "./pages/DemosIndex"; import BasicRoutingDemo from "./pages/BasicRoutingDemo"; import BlogRoutingDemo from "./pages/BlogRoutingDemo"; import DataGridDemo from "./pages/DataGridDemo"; +import ComplexRoutingDemo from "./pages/ComplexRoutingDemo"; import AsyncLookupDemo from "./pages/AsyncLookupDemo"; import NotFound from "./pages/NotFound"; @@ -67,6 +69,9 @@ export const App = () => ( + + + diff --git a/packages/docs/src/demos/ComplexRoutingApp.tsx b/packages/docs/src/demos/ComplexRoutingApp.tsx new file mode 100644 index 0000000..2ea2ea8 --- /dev/null +++ b/packages/docs/src/demos/ComplexRoutingApp.tsx @@ -0,0 +1,174 @@ +import { useMemo } from "react"; +import { + DefaultParams, + RouteAtom, + RouteOptions, + routeAtom, + rootAtom as defaultRootAtom, + staticRouteAtom, + validateAtom, +} from "jarl-atoms"; +import { Link, Route, Switch } from "jarl-react"; +import { isValidCalendarDate } from "./blogPosts"; +import { sampleDates, sampleFiles } from "./complexRoutingSamples"; + +type DateSegment = { year: number; month: number; day: number }; + +const DATE_SEGMENT = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * Binds one path segment shaped `yyyy-mm-dd` to its numeric parts: no separate segment per part, + * unlike the blog demo's `/:year/:month/:day` chain. `routeAtom` is the right primitive here - the + * segment syntax itself, not just the value inside it, is non-standard. + * exception: Teaching material on public docs site; docstring clarity warranted for custom atoms. + */ +const dateSegmentRouteAtom = (options?: RouteOptions) => + routeAtom( + (path) => { + const match = DATE_SEGMENT.exec(path); + if (!match) return undefined; + return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; + }, + ({ year, month, day }) => + `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + options, + ); + +type FilenameSegment = { name: string; ext: string }; + +const FILENAME_SEGMENT = /^(.+)\.([^.]+)$/; + +/** Binds one path segment shaped `name.ext` to its two parts, e.g. `report.pdf` -> `{ name: + * "report", ext: "pdf" }`. A segment with no extension doesn't match. */ +const filenameRouteAtom = (options?: RouteOptions) => + routeAtom( + (path) => { + const match = FILENAME_SEGMENT.exec(path); + return match ? { name: match[1], ext: match[2] } : undefined; + }, + (values) => `${values.name}.${values.ext}`, + options, + ); + +// The demo's whole route tree hangs off whatever root it is given, so the app +// never knows the URL it is mounted on. +const createComplexRoutes = (root: RouteAtom) => { + const archive = staticRouteAtom("archive", { parent: root }); + const archiveDate = validateAtom(dateSegmentRouteAtom({ parent: archive }), ({ year, month, day }) => + isValidCalendarDate(year, month, day), + ); + const files = staticRouteAtom("files", { parent: root }); + const file = filenameRouteAtom({ parent: files }); + return { root, archive, archiveDate, files, file }; +}; + +type ComplexRoutes = ReturnType; + +const FILE_KIND: Record = { + pdf: "document", + txt: "text file", + zip: "archive", +}; + +const ComplexNav = ({ routes }: { routes: ComplexRoutes }) => ( + +); + +const dateFromSegment = (segment: string): DateSegment => { + const [year, month, day] = segment.split("-").map(Number); + return { year, month, day }; +}; + +const ComplexIndex = ({ routes }: { routes: ComplexRoutes }) => ( +
+

Custom path segments

+

Dates as one `yyyy-mm-dd` segment, validated against the real calendar:

+
    + {sampleDates.map((date) => ( +
  • + + /archive/{date} + +
  • + ))} +
+

Filenames as one `name.ext` segment:

+
    + {sampleFiles.map(({ name, ext, label }) => ( +
  • + + /files/{name}.{ext} + {" "} + — {label} +
  • + ))} +
+
+); + +const ComplexNotFound = ({ routes }: { routes: ComplexRoutes }) => ( +
+

Not found

+

+ No custom segment here matched: an out-of-range date, an invalid calendar date, or a filename with no extension. +

+

+ + Back to overview + +

+
+); + +const ArchivePage = ({ year, month, day }: DateSegment) => ( +
+

Archive for {`${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`}

+

+ Parsed from a single path segment into {`{ year: ${year}, month: ${month}, day: ${day} }`}. +

+
+); + +const FilePage = ({ name, ext }: FilenameSegment) => ( +
+

+ {name}.{ext} +

+

+ Parsed into {`{ name: "${name}", ext: "${ext}" }`} — treated here as a{" "} + {FILE_KIND[ext] ?? "file of unknown type"}. +

+
+); + +/** + * Self-contained demo of custom single-segment path atoms, built directly on `routeAtom` rather + * than `staticRouteAtom`/`paramRouteAtom`: `yyyy-mm-dd` under `/archive`, gated on the real + * calendar via `validateAtom`, and `name.ext` under `/files`. Pass the route atom it is mounted + * on as `rootAtom` and it builds its own tree under that. + */ +export const ComplexRoutingApp = ({ rootAtom = defaultRootAtom }: { rootAtom?: RouteAtom }) => { + const routes = useMemo(() => createComplexRoutes(rootAtom), [rootAtom]); + return ( + <> + + }> + + + + + {(values) => } + + + {(values) => } + + + + ); +}; + +export default ComplexRoutingApp; diff --git a/packages/docs/src/demos/complexRoutingSamples.ts b/packages/docs/src/demos/complexRoutingSamples.ts new file mode 100644 index 0000000..60b03d9 --- /dev/null +++ b/packages/docs/src/demos/complexRoutingSamples.ts @@ -0,0 +1,16 @@ +// 2024 is a leap year, 2023 isn't - both shown in the UI to demonstrate validation, but only the valid one prerendered. +export const sampleDates = ["2024-02-29", "2023-02-29"]; + +export const sampleFiles = [ + { name: "report", ext: "pdf", label: "A PDF report" }, + { name: "notes", ext: "txt", label: "A plain-text file" }, + { name: "archive.2024", ext: "zip", label: "A filename with a dot of its own" }, +]; + +/** Every concrete path this demo's SSG build should prerender. */ +export const complexRoutingStaticPaths = (): string[] => [ + "/demos/complex-routing", + // Only prerender the valid leap-year date; the invalid one demonstrates validateAtom's rejection in the live demo. + "/demos/complex-routing/archive/2024-02-29", + ...sampleFiles.map(({ name, ext }) => `/demos/complex-routing/files/${name}.${ext}`), +]; diff --git a/packages/docs/src/pages/ComplexRoutingDemo.tsx b/packages/docs/src/pages/ComplexRoutingDemo.tsx new file mode 100644 index 0000000..1a62fce --- /dev/null +++ b/packages/docs/src/pages/ComplexRoutingDemo.tsx @@ -0,0 +1,16 @@ +import { complexRoutingDemoRoute } from "../router/routes"; +import { ComplexRoutingApp } from "../demos/ComplexRoutingApp"; +import DemoPage from "../lib/DemoPage"; +import demoSource from "../demos/ComplexRoutingApp.tsx?raw"; + +export const ComplexRoutingDemo = () => ( + + + +); + +export default ComplexRoutingDemo; diff --git a/packages/docs/src/pages/DemosIndex.tsx b/packages/docs/src/pages/DemosIndex.tsx index 9b16b80..7d52440 100644 --- a/packages/docs/src/pages/DemosIndex.tsx +++ b/packages/docs/src/pages/DemosIndex.tsx @@ -1,6 +1,12 @@ import { Link } from "jarl-react"; import LinkList from "../lib/LinkList"; -import { asyncLookupDemoRoute, basicRoutingDemoRoute, blogRoutingDemoRoute, dataGridDemoRoute } from "../router/routes"; +import { + asyncLookupDemoRoute, + basicRoutingDemoRoute, + blogRoutingDemoRoute, + complexRoutingDemoRoute, + dataGridDemoRoute, +} from "../router/routes"; export const DemosIndex = () => ( <> @@ -34,6 +40,13 @@ export const DemosIndex = () => ( — a table whose filter text and sort column live in queryParamAtoms chained off the mount route, so the grid's state is shareable and moves with back/forward navigation. +
  • + + Complex routing + {" "} + — custom single-segment path atoms built on routeAtom directly: yyyy-mm-dd gated + on the real calendar with validateAtom, and name.ext filenames. +
  • Async lookup diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts index ff232c7..8b0c945 100644 --- a/packages/docs/src/router/routes.ts +++ b/packages/docs/src/router/routes.ts @@ -8,6 +8,7 @@ import { atom } from "jotai"; import { asyncRouteAtom, notAtom, rootAtom, staticRouteAtom, paramRouteAtom } from "jarl-atoms"; import { blogStaticPaths } from "../demos/blogPosts"; +import { complexRoutingStaticPaths } from "../demos/complexRoutingSamples"; import { articleSlugs, findArticle } from "../demos/asyncArticles"; import { changelogStaticPaths } from "../pages/changelogEntries"; @@ -39,6 +40,11 @@ export const blogRoutingDemoRoute = staticRouteAtom("blog-routing", { parent: de // chained inside DataGridApp, on its own basePath-scoped root. export const dataGridDemoRoute = staticRouteAtom("data-grid", { parent: demosIndexRoute }); +// Complex routing demo: the site's own mount point. The demo's own /archive/:date and +// /files/:file trees, built on custom single-segment path atoms, live inside +// ComplexRoutingApp, on its own basePath-scoped root. +export const complexRoutingDemoRoute = staticRouteAtom("complex-routing", { parent: demosIndexRoute }); + // Async-lookup demo: /demos/async-lookup/:slug exists only if the demo's fake database has an // article at that slug, and the article it found rides along on the route's own values. Its // nested atoms stay module-level, unlike the blog demo's, because the server render needs them: @@ -63,19 +69,25 @@ const exactRouteMissedAtom = notAtom( basicRoutingDemoPageRoute, blogRoutingDemoRoute, dataGridDemoRoute, + complexRoutingDemoRoute, asyncLookupDemoRoute, asyncArticleRoute, ); /** * Whether the current location has nothing behind it, which is what makes a server render's - * *status code* right and not just its HTML. Everything under the changelog's and the blog demo's - * mounts counts as found - both route their own subtree and render their own not-found views. The - * async demo gets no such blanket, and lists `asyncArticleRoute` rather than `asyncLookupSlugRoute`: - * an unknown slug is a genuine miss, even though the demo page still renders its own not-found view. + * *status code* right and not just its HTML. Everything under the changelog's, the blog demo's + * and the complex-routing demo's mounts counts as found - all three route their own subtree and + * render their own not-found views. The async demo gets no such blanket, and lists + * `asyncArticleRoute` rather than `asyncLookupSlugRoute`: an unknown slug is a genuine miss, even + * though the demo page still renders its own not-found view. */ export const notFoundAtom = atom( - (get) => get(exactRouteMissedAtom) && !get(changelogRoute).match && !get(blogRoutingDemoRoute).match, + (get) => + get(exactRouteMissedAtom) && + !get(changelogRoute).match && + !get(blogRoutingDemoRoute).match && + !get(complexRoutingDemoRoute).match, ); export type DocName = "getting-started" | "data-loading" | "path-variables"; @@ -107,6 +119,7 @@ export const staticPaths: string[] = [ "/demos/basic-routing/about", ...blogStaticPaths(), "/demos/data-grid", + ...complexRoutingStaticPaths(), "/demos/async-lookup", ...articleSlugs().map((slug) => `/demos/async-lookup/${slug}`), ];