diff --git a/packages/docs/src/content/guides/PathVariables.md b/packages/docs/src/content/guides/PathVariables.md index cdf1e3d..8636e05 100644 --- a/packages/docs/src/content/guides/PathVariables.md +++ b/packages/docs/src/content/guides/PathVariables.md @@ -56,6 +56,34 @@ Linking to a dynamic route works the same way as a static one - just pass the pa ``` +## Segments from a fixed set + +A dynamic segment usually isn't dynamic in the sense of "anything at all" - it's one of a handful of +known values. `enumRouteAtom` matches exactly those and nothing else, and types the value it binds +as the union of them rather than as `string`: + +```ts +import { staticRouteAtom, paramRouteAtom, enumRouteAtom } from "jarl-atoms"; + +const productTabs = ["overview", "reviews", "specs"] as const; + +export const productsRoute = staticRouteAtom("products"); +export const productRoute = paramRouteAtom("productId", { parent: productsRoute }); +export const productTabRoute = enumRouteAtom("tab", productTabs, { parent: productRoute }); +``` + +`/products/123/reviews` matches with `{ productId: "123", tab: "reviews" }`, where `tab` is +`"overview" | "reviews" | "specs"` - so a `switch` over it is exhaustive with no default case to +write, and `` is a typo the +compiler catches rather than a dead link. `/products/123/pricing` doesn't match at all, so it falls +through to whatever the app renders for an unknown URL instead of reaching the tab page with a value +it has nothing to show for. + +`numericRouteAtom` does the same job for a segment that has to be a whole number, optionally within +a `min`/`max` range, binding it as a `number` rather than a string. For a constraint neither one +covers - a checksum, a slug in a database, a rule spanning several segments - see +`transformRouteAtom` and `validateRouteAtom` in the [API reference](/api/jarl-atoms). + ## Query parameters Dynamic _path_ segments aren't the only way to carry a value in a URL - `queryParamRouteAtom` (from diff --git a/packages/docs/src/pages/Api.tsx b/packages/docs/src/pages/Api.tsx index bf3b4dc..3d50554 100644 --- a/packages/docs/src/pages/Api.tsx +++ b/packages/docs/src/pages/Api.tsx @@ -48,34 +48,17 @@ export const ApiIndex = () => ( ); -export const ApiPage = ({ apiName }: { apiName: string }) => { - const source = content[apiName as ApiName]; - if (!source) { - return ( - <> -

Not found

-

- No API reference named “{apiName}”. Back to{" "} - - API - - . -

- - ); - } - return ( - <> - - {apiPages.map(({ apiName: name, title }) => ( - - {title} - - ))} - - - - ); -}; +export const ApiPage = ({ apiName }: { apiName: ApiName }) => ( + <> + + {apiPages.map(({ apiName: name, title }) => ( + + {title} + + ))} + + + +); export default ApiIndex; diff --git a/packages/docs/src/pages/Docs.tsx b/packages/docs/src/pages/Docs.tsx index daa4082..2ad2228 100644 --- a/packages/docs/src/pages/Docs.tsx +++ b/packages/docs/src/pages/Docs.tsx @@ -31,23 +31,6 @@ export const DocsIndex = () => ( ); -export const DocPage = ({ docName }: { docName: string }) => { - const source = guides[docName as DocName]; - if (!source) { - return ( - <> -

Not found

-

- No guide named “{docName}”. Back to{" "} - - Docs - - . -

- - ); - } - return ; -}; +export const DocPage = ({ docName }: { docName: DocName }) => ; export default DocsIndex; diff --git a/packages/docs/src/router/routes.ts b/packages/docs/src/router/routes.ts index 5aee8bc..13db345 100644 --- a/packages/docs/src/router/routes.ts +++ b/packages/docs/src/router/routes.ts @@ -6,7 +6,7 @@ * `jarl-atoms`' server-seedable `locationAtom`. */ import { atom } from "jotai"; -import { asyncRouteAtom, notAtom, rootRoute, staticRouteAtom, paramRouteAtom } from "jarl-atoms"; +import { asyncRouteAtom, enumRouteAtom, notAtom, rootRoute, staticRouteAtom, paramRouteAtom } from "jarl-atoms"; import { blogStaticPaths } from "../demos/blogPosts"; import { articleSlugs, findArticle } from "../demos/asyncArticles"; import { changelogStaticPaths } from "../pages/changelogEntries"; @@ -14,10 +14,12 @@ import { changelogStaticPaths } from "../pages/changelogEntries"; export const homeRoute = rootRoute; export const docsSectionRoute = staticRouteAtom("docs"); -export const docPageRoute = paramRouteAtom("docName", { parent: docsSectionRoute }); +const docNames = ["getting-started", "data-loading", "path-variables"] as const; +export const docPageRoute = enumRouteAtom("docName", docNames, { parent: docsSectionRoute }); export const apiSectionRoute = staticRouteAtom("api"); -export const apiPageRoute = paramRouteAtom("apiName", { parent: apiSectionRoute }); +const apiNames = ["jarl-atoms", "jarl-react"] as const; +export const apiPageRoute = enumRouteAtom("apiName", apiNames, { parent: apiSectionRoute }); // Changelog: static mount point. The per-version tree lives inside the Changelog component. export const changelogRoute = staticRouteAtom("changelog"); @@ -78,7 +80,7 @@ export const notFoundAtom = atom( (get) => get(exactRouteMissedAtom) && !get(changelogRoute).match && !get(blogRoutingDemoRoute).match, ); -export type DocName = "getting-started" | "data-loading" | "path-variables"; +export type DocName = (typeof docNames)[number]; export const docPages: { docName: DocName; title: string }[] = [ { docName: "getting-started", title: "Getting Started" }, @@ -86,7 +88,7 @@ export const docPages: { docName: DocName; title: string }[] = [ { docName: "path-variables", title: "Path Variables" }, ]; -export type ApiName = "jarl-atoms" | "jarl-react"; +export type ApiName = (typeof apiNames)[number]; export const apiPages: { apiName: ApiName; title: string }[] = [ { apiName: "jarl-atoms", title: "jarl-atoms" }, diff --git a/packages/jarl-atoms/DESIGN-NOTES.md b/packages/jarl-atoms/DESIGN-NOTES.md index 3a4d165..8b8ee9c 100644 --- a/packages/jarl-atoms/DESIGN-NOTES.md +++ b/packages/jarl-atoms/DESIGN-NOTES.md @@ -182,3 +182,28 @@ Dropping `Route` from every name instead was rejected: `routeAtom` would collide No deprecated aliases were kept for the old names. Two names per export would make the surface less consistent rather than more, which is the opposite of the point, and the alias would then need its own removal later. + +## Naming a fixed-value segment: `enumRouteAtom` + +`enum` is the ecosystem's word for a value drawn from a fixed set of strings — JSON Schema's and +OpenAPI's `enum`, and zod's `z.enum([...])`, which takes the same non-empty literal tuple and +yields the same string-literal union. It is not TypeScript's `enum` keyword, which this package +uses nowhere; what the route binds is a union of literals. It also keeps the segment constructors +named for the kind of segment they match — static, param, numeric, enum — which is what makes the +family scannable. + +Two other names were rejected. `setRouteAtom` satisfies the return-type rule above but collides +with jotai's write vocabulary, where `set` means "write to an atom": `useSetAtom(setRouteAtom(...))` +is a sentence fighting itself. `oneOfRouteAtom` reads well in isolation, but "one of" names a choice +between whole alternatives — JSON Schema's `oneOf` is exactly a union of schemas — which is what a +primitive combining several _routes_ wants, not one constraining a single segment to a value set. +The two do different jobs and neither subsumes the other, so two near-synonymous names would only +invite reaching for the wrong one. + +## A fixed-value segment needs no precedence rule + +A path segment is one string, so at most one member of the set can match it: matching is a +membership test rather than an ordered scan, and the order the values are listed in decides +nothing. What can still overlap is a route and its _siblings_ — a `staticRouteAtom("about")` and an +`enumRouteAtom` that accepts `"about"` under the same parent both match `/about` — but that is the +ordinary ambiguity of declaring two routes for one URL, which no single route atom can see. diff --git a/packages/jarl-atoms/src/__tests__/enumRouteAtom.test.ts b/packages/jarl-atoms/src/__tests__/enumRouteAtom.test.ts new file mode 100644 index 0000000..f3765c3 --- /dev/null +++ b/packages/jarl-atoms/src/__tests__/enumRouteAtom.test.ts @@ -0,0 +1,89 @@ +import { createStore } from "jotai/vanilla"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { enumRouteAtom } from "../enumRouteAtom"; +import { locationAtom } from "../locationAtom"; +import { paramRouteAtom } from "../paramRouteAtom"; +import { requireMatch } from "../requireMatch"; +import { staticRouteAtom } from "../staticRouteAtom"; + +const seed = (store: ReturnType, pathname: string) => { + store.set(locationAtom, { pathname, searchParams: new URLSearchParams() }); +}; + +const docs = staticRouteAtom("docs"); +const guideRoute = enumRouteAtom("guide", ["getting-started", "data-loading"], { parent: docs }); + +describe("enumRouteAtom", () => { + it("matches a segment in the set and binds it", () => { + const store = createStore(); + seed(store, "/docs/data-loading"); + + const route = store.get(guideRoute); + + expect(route.match).toBe(true); + expect(route.exact).toBe(true); + expect(route.values).toEqual({ guide: "data-loading" }); + }); + + it("does not match a segment outside the set", () => { + const store = createStore(); + seed(store, "/docs/nonsense"); + + const route = store.get(guideRoute); + + expect(route.match).toBe(false); + expect(route.values).toBeUndefined(); + }); + + it("does not match its parent's own path, where there is no segment to bind", () => { + const store = createStore(); + seed(store, "/docs"); + + expect(store.get(guideRoute).match).toBe(false); + }); + + it("binds the value whatever order the set is written in", () => { + const store = createStore(); + const reversed = enumRouteAtom("guide", ["data-loading", "getting-started"], { parent: docs }); + seed(store, "/docs/data-loading"); + + expect(store.get(reversed).values).toEqual({ guide: "data-loading" }); + }); + + it("types the bound value as the union of the set, not as a string", () => { + const store = createStore(); + seed(store, "/docs/getting-started"); + + const route = requireMatch(store.get(guideRoute), "guideRoute"); + + expectTypeOf(route.values.guide).toEqualTypeOf<"getting-started" | "data-loading">(); + // @ts-expect-error - only the segments the route was given can be reversed, written or linked + route.reverse({ guide: "no-such-guide" }); + }); + + it("builds hrefs through reverse()", () => { + const store = createStore(); + + expect(store.get(guideRoute).reverse({ guide: "getting-started" })).toBe("/docs/getting-started"); + }); + + it("navigates when written to", () => { + const store = createStore(); + + store.set(guideRoute, { guide: "data-loading" }); + + expect(store.get(locationAtom).pathname).toBe("/docs/data-loading"); + }); + + it("parents another route, consuming only its own segment", () => { + const store = createStore(); + const section = paramRouteAtom("section", { parent: guideRoute }); + seed(store, "/docs/data-loading/suspense"); + + const route = store.get(section); + + expect(route.match).toBe(true); + expect(route.exact).toBe(true); + expect(route.values).toEqual({ guide: "data-loading", section: "suspense" }); + }); +}); diff --git a/packages/jarl-atoms/src/enumRouteAtom.ts b/packages/jarl-atoms/src/enumRouteAtom.ts new file mode 100644 index 0000000..41f228d --- /dev/null +++ b/packages/jarl-atoms/src/enumRouteAtom.ts @@ -0,0 +1,30 @@ +import { routeAtom } from "./routeAtom"; +import { DefaultParams, RouteAtom, RouteOptions } from "./types"; + +/** The segment values an `enumRouteAtom` accepts: a non-empty tuple of string literals. */ +export type EnumValues = readonly [string, ...string[]]; + +/** + * Binds one dynamic path segment to a named value drawn from a fixed set: `enumRouteAtom("page", + * ["home", "about", "contact"], { parent: site })` matches `/:page` on those three segments and no + * others, and types `values.page` as the union of them rather than as `string` - so a `switch` over + * it is exhaustive, and a value that isn't one of them is a compile error wherever the route is + * written to, reversed or linked. Any other segment leaves the route unmatched, which is what makes + * an unknown one a genuine miss rather than a page rendering its own "not found". + * + * Pass the values as a literal array, or as an `as const` tuple to share the list with the code + * that consumes it; a plain `string[]` has no literals left to bind and won't type. + */ +export const enumRouteAtom = ( + name: Name, + allowed: Values, + options?: RouteOptions, +): RouteAtom<{ [key in Name]: Values[number] } & Parent> => { + type Bound = { [key in Name]: Values[number] }; + const accepted = new Set(allowed); + return routeAtom( + (path) => (accepted.has(path) ? ({ [name]: path } as Bound) : undefined), + (values) => values[name], + options, + ); +}; diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index bb9ac25..b00d53a 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -11,6 +11,7 @@ export * from "./rootRouteAtom"; export * from "./staticRouteAtom"; export * from "./paramRouteAtom"; export * from "./numericRouteAtom"; +export * from "./enumRouteAtom"; export * from "./transformRouteAtom"; export * from "./validateRouteAtom"; export * from "./notAtom";