Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/docs/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
basicRoutingDemoRoute,
blogRoutingDemoRoute,
dataGridDemoRoute,
complexRoutingDemoRoute,
asyncLookupDemoRoute,
} from "./router/routes";
import Home from "./pages/Home";
Expand All @@ -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";

Expand Down Expand Up @@ -67,6 +69,9 @@ export const App = () => (
<Route on={dataGridDemoRoute}>
<DataGridDemo />
</Route>
<Route on={complexRoutingDemoRoute}>
<ComplexRoutingDemo />
</Route>
<Route on={asyncLookupDemoRoute}>
<AsyncLookupDemo />
</Route>
Expand Down
174 changes: 174 additions & 0 deletions packages/docs/src/demos/ComplexRoutingApp.tsx
Original file line number Diff line number Diff line change
@@ -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 = <Parent extends DefaultParams>(options?: RouteOptions<Parent>) =>
routeAtom<DateSegment, Parent>(
(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 = <Parent extends DefaultParams>(options?: RouteOptions<Parent>) =>
routeAtom<FilenameSegment, Parent>(
(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<DefaultParams>) => {
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<typeof createComplexRoutes>;

const FILE_KIND: Record<string, string> = {
pdf: "document",
txt: "text file",
zip: "archive",
};

const ComplexNav = ({ routes }: { routes: ComplexRoutes }) => (
<nav>
<Link route={routes.root} to={{}} exact>
Overview
</Link>
</nav>
);

const dateFromSegment = (segment: string): DateSegment => {
const [year, month, day] = segment.split("-").map(Number);
return { year, month, day };
};

const ComplexIndex = ({ routes }: { routes: ComplexRoutes }) => (
<div>
<h3>Custom path segments</h3>
<p>Dates as one `yyyy-mm-dd` segment, validated against the real calendar:</p>
<ul>
{sampleDates.map((date) => (
<li key={date}>
<Link route={routes.archiveDate} to={dateFromSegment(date)}>
/archive/{date}
</Link>
</li>
))}
</ul>
<p>Filenames as one `name.ext` segment:</p>
<ul>
{sampleFiles.map(({ name, ext, label }) => (
<li key={`${name}.${ext}`}>
<Link route={routes.file} to={{ name, ext }}>
/files/{name}.{ext}
</Link>{" "}
&mdash; {label}
</li>
))}
</ul>
</div>
);

const ComplexNotFound = ({ routes }: { routes: ComplexRoutes }) => (
<div>
<h3>Not found</h3>
<p>
No custom segment here matched: an out-of-range date, an invalid calendar date, or a filename with no extension.
</p>
<p>
<Link route={routes.root} to={{}}>
Back to overview
</Link>
</p>
</div>
);

const ArchivePage = ({ year, month, day }: DateSegment) => (
<div>
<h3>Archive for {`${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`}</h3>
<p>
Parsed from a single path segment into <code>{`{ year: ${year}, month: ${month}, day: ${day} }`}</code>.
</p>
</div>
);

const FilePage = ({ name, ext }: FilenameSegment) => (
<div>
<h3>
{name}.{ext}
</h3>
<p>
Parsed into <code>{`{ name: "${name}", ext: "${ext}" }`}</code> &mdash; treated here as a{" "}
{FILE_KIND[ext] ?? "file of unknown type"}.
</p>
</div>
);

/**
* 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<DefaultParams> }) => {
const routes = useMemo(() => createComplexRoutes(rootAtom), [rootAtom]);
return (
<>
<ComplexNav routes={routes} />
<Switch fallback={<ComplexNotFound routes={routes} />}>
<Route on={routes.root} exact>
<ComplexIndex routes={routes} />
</Route>
<Route on={routes.archiveDate} exact>
{(values) => <ArchivePage {...values} />}
</Route>
<Route on={routes.file} exact>
{(values) => <FilePage {...values} />}
</Route>
</Switch>
</>
);
};

export default ComplexRoutingApp;
16 changes: 16 additions & 0 deletions packages/docs/src/demos/complexRoutingSamples.ts
Original file line number Diff line number Diff line change
@@ -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}`),
];
16 changes: 16 additions & 0 deletions packages/docs/src/pages/ComplexRoutingDemo.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<DemoPage
title="Live demo: complex routing (custom path segments)"
sourcePath="packages/docs/src/demos/ComplexRoutingApp.tsx"
source={demoSource}
>
<ComplexRoutingApp rootAtom={complexRoutingDemoRoute} />
</DemoPage>
);

export default ComplexRoutingDemo;
15 changes: 14 additions & 1 deletion packages/docs/src/pages/DemosIndex.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<>
Expand Down Expand Up @@ -34,6 +40,13 @@ export const DemosIndex = () => (
&mdash; a table whose filter text and sort column live in <code>queryParamAtom</code>s chained off the mount
route, so the grid&apos;s state is shareable and moves with back/forward navigation.
</li>
<li>
<Link route={complexRoutingDemoRoute} to={{}}>
Complex routing
</Link>{" "}
&mdash; custom single-segment path atoms built on <code>routeAtom</code> directly: <code>yyyy-mm-dd</code> gated
on the real calendar with <code>validateAtom</code>, and <code>name.ext</code> filenames.
</li>
<li>
<Link route={asyncLookupDemoRoute} to={{}}>
Async lookup
Expand Down
23 changes: 18 additions & 5 deletions packages/docs/src/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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:
Expand All @@ -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";
Expand Down Expand Up @@ -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}`),
];
Loading