@abinnovision/payloadcms-wayfinder 1.0.0-beta.2 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +22 -19
  2. package/dist/config/load-mappings.d.mts +17 -1
  3. package/dist/config/load-mappings.mjs +16 -4
  4. package/dist/config/mapping-global.d.mts +14 -5
  5. package/dist/config/mapping-global.mjs +2 -1
  6. package/dist/config/plugin.d.mts +6 -3
  7. package/dist/config/plugin.mjs +5 -2
  8. package/dist/index.d.mts +10 -14
  9. package/dist/index.mjs +4 -13
  10. package/dist/internal.d.mts +11 -0
  11. package/dist/internal.mjs +11 -0
  12. package/dist/lexical/index.d.mts +9 -23
  13. package/dist/lexical/index.mjs +11 -25
  14. package/dist/montage/index.d.mts +33 -11
  15. package/dist/montage/index.mjs +25 -11
  16. package/dist/pattern/define-links.d.mts +19 -7
  17. package/dist/pattern/define-mappings.d.mts +5 -1
  18. package/dist/pattern/define-mappings.mjs +3 -1
  19. package/dist/pattern/derive-link-label.mjs +2 -1
  20. package/dist/pattern/link-node.d.mts +21 -0
  21. package/dist/pattern/link-node.mjs +34 -0
  22. package/dist/pattern/param-query-path.d.mts +7 -5
  23. package/dist/pattern/param-query-path.mjs +6 -7
  24. package/dist/pattern/resolver.d.mts +3 -1
  25. package/dist/pattern/resolver.mjs +5 -2
  26. package/dist/pattern/types.d.mts +31 -4
  27. package/dist/pattern/types.mjs +2 -1
  28. package/dist/runtime/build-href.d.mts +0 -6
  29. package/dist/runtime/build-href.mjs +1 -2
  30. package/dist/runtime/build-path.mjs +9 -0
  31. package/dist/runtime/create-router.d.mts +72 -0
  32. package/dist/runtime/create-router.mjs +64 -0
  33. package/dist/runtime/resolve-link.d.mts +15 -10
  34. package/dist/runtime/resolve-link.mjs +10 -10
  35. package/dist/runtime/resolve-path.d.mts +22 -7
  36. package/dist/runtime/resolve-path.mjs +2 -4
  37. package/dist/runtime/resolve-relationship-slug.d.mts +0 -1
  38. package/dist/runtime/resolve-relationship-slug.mjs +16 -8
  39. package/package.json +6 -2
  40. package/dist/pattern/index.d.mts +0 -8
  41. package/dist/pattern/index.mjs +0 -8
  42. package/dist/runtime/index.d.mts +0 -7
  43. package/dist/runtime/index.mjs +0 -6
package/README.md CHANGED
@@ -6,10 +6,11 @@ Editor-authored URL routing for [Payload CMS](https://payloadcms.com/).
6
6
 
7
7
  Wayfinder owns one thing: the map from collections to the URL patterns their documents are served
8
8
  at. That map is authored in the CMS, so adding a page type is an editorial act rather than a code
9
- change. Everything else in the package falls out of it. `buildHref` turns a document into an href,
10
- `buildPath` builds a path from parameter values alone for sitemaps and feeds,
11
- `resolvePathToDocument` turns a request path back into a document for a catch-all route, and
12
- `resolveLink` plus `linkField` give editors a link that follows its target when that collection's
9
+ change. Everything else in the package falls out of it. `createRouter` binds the mapping, the
10
+ locale and the href formatter once per request and hands back a router: `router.href` turns a
11
+ document into an href, `router.path` builds a path from parameter values alone for sitemaps and
12
+ feeds, `router.resolve` turns a request path back into a document for a catch-all route, and
13
+ `router.link` plus `linkField` give editors a link that follows its target when that collection's
13
14
  pattern changes. [`docs/concepts.md`](./docs/concepts.md) describes the model;
14
15
  [`docs/limitations.md`](./docs/limitations.md) states what is out of scope and why.
15
16
 
@@ -42,7 +43,7 @@ import { buildConfig } from "payload";
42
43
 
43
44
  export default buildConfig({
44
45
  // ...
45
- plugins: [wayfinderPlugin({ linkableCollections: ["pages", "articles"] })],
46
+ plugins: [wayfinderPlugin({ checkDefaultPopulateOn: ["pages", "articles"] })],
46
47
  });
47
48
  ```
48
49
 
@@ -63,7 +64,7 @@ Read the mapping and hand it to a catch-all route:
63
64
 
64
65
  ```tsx
65
66
  // app/[[...path]]/page.tsx
66
- import { resolvePathToDocument } from "@abinnovision/payloadcms-wayfinder";
67
+ import { createRouter } from "@abinnovision/payloadcms-wayfinder";
67
68
  import { loadMappings } from "@abinnovision/payloadcms-wayfinder/config";
68
69
  import { notFound } from "next/navigation";
69
70
  import { getPayload } from "payload";
@@ -75,11 +76,10 @@ const Page = async ({ params }: { params: Promise<{ path?: string[] }> }) => {
75
76
  const mappings = await loadMappings({ payload });
76
77
  const { path } = await params;
77
78
 
78
- const resolved = await resolvePathToDocument({
79
+ const wayfinder = createRouter({ mappings, locale: "en" });
80
+
81
+ const resolved = await wayfinder.resolve(`/${(path ?? []).join("/")}`, {
79
82
  payload,
80
- mappings,
81
- path: `/${(path ?? []).join("/")}`,
82
- locale: "en",
83
83
  });
84
84
 
85
85
  if (!resolved) {
@@ -98,17 +98,20 @@ export default Page;
98
98
  ## Entrypoints
99
99
 
100
100
  ```
101
- "." buildHref, buildPath, resolvePathToDocument, resolveLink, defineMappings, defineLinks, types
102
- "./config" wayfinderPlugin, createMappingGlobal, loadMappings, linkField
103
- "./lexical" wayfinderLinkFeature, linkLabelFeature, resolveLinkNode
104
- "./admin" LinkLabelFeatureClient, mounted by linkLabelFeature through the import map
105
- "./montage" initWayfinder, getMappings, wayfinderExtension
101
+ "." createRouter, defineMappings, defineLinks, resolveRelationshipSlug, deriveLinkLabel, types
102
+ "./internal" the unbound functions the router is built out of, plus the pattern internals
103
+ "./config" wayfinderPlugin, createMappingGlobal, loadMappings, linkField
104
+ "./lexical" wayfinderLinkFeature, linkLabelFeature, resolveLinkNode
105
+ "./admin" LinkLabelFeatureClient, mounted by linkLabelFeature through the import map
106
+ "./montage" initWayfinder, wayfinderFrom, wayfinderExtension
106
107
  ```
107
108
 
108
109
  `.` is the runtime half. It takes mappings as plain data and never reads the CMS, so it runs in a
109
- route handler, a sitemap, a script or a test alike. `./config` is loaded by the CLI, by migrations
110
- and by `payload generate:types`, so it must stay React-free. `./lexical`, `./admin` and
111
- `./montage` each pull in an optional peer and are separate for that reason.
110
+ route handler, a sitemap, a script or a test alike. `./internal` is the escape hatch behind it,
111
+ for a caller that holds no request or wants a different set of arguments per call; nothing there
112
+ carries a compatibility guarantee. `./config` is loaded by the CLI, by migrations and by
113
+ `payload generate:types`, so it must stay React-free. `./lexical`, `./admin` and `./montage` each
114
+ pull in an optional peer and are separate for that reason.
112
115
  [`docs/layers.md`](./docs/layers.md) explains the split.
113
116
 
114
117
  ## Documentation
@@ -119,7 +122,7 @@ and by `payload generate:types`, so it must stay React-free. `./lexical`, `./adm
119
122
  works without.
120
123
  - [`docs/integration.md`](./docs/integration.md): plugin setup, caching, catch-all routes,
121
124
  sitemaps and `defaultPopulate`.
122
- - [`docs/linking.md`](./docs/linking.md): `linkField`, `defineLinks`, `resolveLink` and the Lexical
125
+ - [`docs/linking.md`](./docs/linking.md): `linkField`, `defineLinks`, `router.link` and the Lexical
123
126
  feature.
124
127
  - [`docs/code-defined-mappings.md`](./docs/code-defined-mappings.md): `defineMappings`, with no CMS
125
128
  global at all.
@@ -17,8 +17,24 @@ interface MappingCache {
17
17
  interface LoadMappingsArgs {
18
18
  payload: Payload;
19
19
  globalSlug?: string;
20
- /** Must match what {@link createMappingGlobal} was given. */
20
+ /**
21
+ * Whether patterns are per-locale.
22
+ *
23
+ * Derived from the instance's own `localization` config, which is the same
24
+ * authority `wayfinderPlugin` derives it from, so the two sides cannot
25
+ * disagree. Set it only to override that, and then on both sides.
26
+ */
21
27
  localized?: boolean;
28
+ /**
29
+ * The field a relationship parameter falls back to when the target
30
+ * collection's own pattern cannot name one.
31
+ *
32
+ * Read off the mapping global's own config when the plugin was given it,
33
+ * so a project states it once, where the global is declared, and both the
34
+ * save-time validation and the compiled mappings get the same answer. Set
35
+ * it here only to override that.
36
+ */
37
+ fallbackIdentifierField?: string;
22
38
  /** Reuses compiled patterns across reads. @see MappingCache */
23
39
  cache?: MappingCache;
24
40
  }
@@ -10,6 +10,16 @@ import "./mapping-global.mjs";
10
10
  };
11
11
  const defaultCache = createMemoryCache();
12
12
  /**
13
+ * Reads what {@link createMappingGlobal} was told, off the global's own config.
14
+ *
15
+ * The write side validates patterns against this and the read side compiles
16
+ * mappings with it, so the two have to agree. Carrying it on the config means
17
+ * a project states it where it declares the global and nowhere else.
18
+ */ const declaredIdentifierField = (payload, slug) => {
19
+ const declared = (payload.config.globals.find((it) => it.slug === slug)?.custom)?.wayfinder?.fallbackIdentifierField;
20
+ return typeof declared === "string" ? declared : void 0;
21
+ };
22
+ /**
13
23
  * Reads the mapping global and compiles it.
14
24
  *
15
25
  * Returns an empty list rather than throwing when the global has never been
@@ -20,10 +30,12 @@ const defaultCache = createMemoryCache();
20
30
  *
21
31
  * @param args The Payload instance and mapping-global settings.
22
32
  */ const loadMappings = async (args) => {
23
- const localized = args.localized ?? true;
33
+ const slug = args.globalSlug ?? "collections-mapping";
34
+ const localized = args.localized ?? Boolean(args.payload.config.localization);
24
35
  const cache = args.cache ?? defaultCache;
36
+ const fallbackIdentifierField = args.fallbackIdentifierField ?? declaredIdentifierField(args.payload, slug);
25
37
  const global = await args.payload.findGlobal({
26
- slug: args.globalSlug ?? "collections-mapping",
38
+ slug,
27
39
  depth: 0,
28
40
  overrideAccess: true,
29
41
  ...localized ? { locale: "all" } : {}
@@ -43,12 +55,12 @@ const defaultCache = createMemoryCache();
43
55
  path: patterns
44
56
  }] : [];
45
57
  });
46
- const key = JSON.stringify(usable);
58
+ const key = JSON.stringify([usable, fallbackIdentifierField]);
47
59
  const cached = cache.get(key);
48
60
  if (cached) return cached;
49
61
  const compiled = usable.flatMap((mapping) => {
50
62
  try {
51
- return [resolveCollectionMapping(mapping)];
63
+ return [resolveCollectionMapping(mapping, fallbackIdentifierField)];
52
64
  } catch {
53
65
  return [];
54
66
  }
@@ -6,13 +6,22 @@ interface CreateMappingGlobalArgs {
6
6
  /** Defaults to {@link DEFAULT_MAPPING_GLOBAL_SLUG}. */
7
7
  globalSlug?: string;
8
8
  /**
9
- * Whether path patterns differ per locale. Turn it off for a project with
10
- * no `localization` block — Payload would otherwise return a scalar where
11
- * a per-locale record is expected.
9
+ * Whether path patterns differ per locale.
10
+ *
11
+ * Derived from the config's `localization` block by `wayfinderPlugin`, and
12
+ * from the running instance by `loadMappings`, so a project normally never
13
+ * sets it. Pass it only to override that — and then to both sides, because
14
+ * Payload returns a scalar for an unlocalized field and a per-locale record
15
+ * for a localized one, and the read has to expect the shape the write
16
+ * produced.
12
17
  */
13
18
  localized?: boolean;
14
- /** Fallback identifier field for relationship parameters. */
15
- identifierField?: string;
19
+ /**
20
+ * The field a relationship parameter falls back to when the target
21
+ * collection's pattern cannot name one. Used here to validate a pattern at
22
+ * save time, before any mapping has been compiled to carry it.
23
+ */
24
+ fallbackIdentifierField?: string;
16
25
  label?: GlobalConfig["label"];
17
26
  adminGroup?: string;
18
27
  access?: GlobalConfig["access"];
@@ -41,7 +41,7 @@ import { text } from "payload/shared";
41
41
  config: collection.config,
42
42
  param: key.name,
43
43
  collections: opts.req.payload.collections,
44
- ...args.identifierField ? { identifierField: args.identifierField } : {}
44
+ ...args.fallbackIdentifierField ? { fallbackIdentifierField: args.fallbackIdentifierField } : {}
45
45
  });
46
46
  if ("error" in resolved) return resolved.error;
47
47
  }
@@ -51,6 +51,7 @@ import { text } from "payload/shared";
51
51
  return {
52
52
  slug: args.globalSlug ?? "collections-mapping",
53
53
  label: args.label ?? "Collections Mapping",
54
+ ...args.fallbackIdentifierField ? { custom: { wayfinder: { fallbackIdentifierField: args.fallbackIdentifierField } } } : {},
54
55
  ...args.access ? { access: args.access } : {},
55
56
  admin: {
56
57
  group: args.adminGroup ?? "Settings",
@@ -3,10 +3,13 @@ import { Plugin } from "payload";
3
3
  //#region src/config/plugin.d.ts
4
4
  interface WayfinderPluginArgs extends CreateMappingGlobalArgs {
5
5
  /**
6
- * Collections that can be linked to. Only used to warn about missing
7
- * `defaultPopulate`; linking itself is governed by the link field.
6
+ * Collections to check for `defaultPopulate` at boot.
7
+ *
8
+ * Named for what it does. It grants nothing: which collections can be
9
+ * linked to is decided by the link field's own `relationTo`, and an
10
+ * earlier name implied this list governed that.
8
11
  */
9
- linkableCollections?: string[];
12
+ checkDefaultPopulateOn?: string[];
10
13
  /**
11
14
  * Set when the project resolves references through its own index rather
12
15
  * than a populated document. Suppresses the `defaultPopulate` warning,
@@ -13,7 +13,10 @@ import { createMappingGlobal } from "./mapping-global.mjs";
13
13
  */ const wayfinderPlugin = (args = {}) => (incoming) => {
14
14
  const config = {
15
15
  ...incoming,
16
- globals: [...incoming.globals ?? [], createMappingGlobal(args)],
16
+ globals: [...incoming.globals ?? [], createMappingGlobal({
17
+ localized: Boolean(incoming.localization),
18
+ ...args
19
+ })],
17
20
  i18n: {
18
21
  ...incoming.i18n,
19
22
  translations: {
@@ -24,7 +27,7 @@ import { createMappingGlobal } from "./mapping-global.mjs";
24
27
  };
25
28
  if (args.quiet) return config;
26
29
  if (!args.resolvesReferencesExternally) {
27
- const missing = (args.linkableCollections ?? []).filter((slug) => {
30
+ const missing = (args.checkDefaultPopulateOn ?? []).filter((slug) => {
28
31
  const collection = config.collections?.find((it) => it.slug === slug);
29
32
  return collection && !collection.defaultPopulate;
30
33
  });
package/dist/index.d.mts CHANGED
@@ -1,16 +1,12 @@
1
- import { BaseResolvedLink, BuiltinLinkVariant, Contributed, DEFAULT_LOCALE_KEY, DeclaredLinkVariant, FormatHref, LabelLike, LinkFieldData, PayloadCollectionMapping, PayloadCollectionMappingMatch, PayloadCollectionMappingResolved, PayloadCollectionMappingResolvers, PayloadCollectionMappingSpecificity, ResolvedLink } from "./pattern/types.mjs";
2
- import { AnyLinkVariantDefinition, DataOfFields, LinkDataOf, LinkDeclaration, LinkVariantDefinition, LinkVariantSource, LinkVariantSpec, ResolvedLinkOf, VariantBuilder, defineLinks, variantsOf } from "./pattern/define-links.mjs";
3
- import { defineMappings } from "./pattern/define-mappings.mjs";
4
- import { deriveLinkLabel } from "./pattern/derive-link-label.mjs";
5
- import { PayloadCollectionMatch, matchCollectionMappings } from "./pattern/matcher.mjs";
6
- import { DEFAULT_IDENTIFIER_FIELD, RegisteredCollections, ResolveParamQueryPathArgs, resolveParamQueryPath } from "./pattern/param-query-path.mjs";
7
- import { isRootWildcard, resolveCollectionMapping, resolversFor } from "./pattern/resolver.mjs";
8
- import "./pattern/index.mjs";
1
+ import { BaseResolvedLink, BuiltinLinkVariant, DocumentId, FormatHref, LabelLike, LinkFieldData, PayloadCollectionMapping, PayloadCollectionMappingResolved } from "./pattern/types.mjs";
2
+ import { LinkContextOf, LinkDataOf, LinkDeclaration, ResolvedLinkOf, defineLinks } from "./pattern/define-links.mjs";
9
3
  import { BuildDiagnosticReason, Diagnostic, DiagnosticReason, OnDiagnostic, ResolveLinkDiagnosticReason, ResolvePathDiagnosticReason } from "./runtime/diagnostics.mjs";
10
- import { BuildHrefArgs, LinkableDocument, buildHref, identityFormatHref } from "./runtime/build-href.mjs";
11
- import { BuildPathArgs, buildPath } from "./runtime/build-path.mjs";
12
- import { ResolveLinkArgs, ResolveReference, isAvailableLink, resolveLink } from "./runtime/resolve-link.mjs";
13
- import { PayloadDocument, ResolvePathToDocumentArgs, ResolvePathWhere, ResolvedPath, resolvePathToDocument } from "./runtime/resolve-path.mjs";
4
+ import { LinkableDocument } from "./runtime/build-href.mjs";
5
+ import { ResolveReference } from "./runtime/resolve-link.mjs";
6
+ import { PayloadCollectionMatch } from "./pattern/matcher.mjs";
7
+ import { PayloadDocument, ResolvePathWhere, ResolvedPath } from "./runtime/resolve-path.mjs";
8
+ import { CreateRouterArgs, Router, createRouter } from "./runtime/create-router.mjs";
14
9
  import { ResolveRelationshipSlugArgs, resolveRelationshipSlug } from "./runtime/resolve-relationship-slug.mjs";
15
- import "./runtime/index.mjs";
16
- export { type AnyLinkVariantDefinition, type BaseResolvedLink, type BuildDiagnosticReason, type BuildHrefArgs, type BuildPathArgs, type BuiltinLinkVariant, type Contributed, DEFAULT_IDENTIFIER_FIELD, DEFAULT_LOCALE_KEY, type DataOfFields, type DeclaredLinkVariant, type Diagnostic, type DiagnosticReason, type FormatHref, type LabelLike, type LinkDataOf, type LinkDeclaration, type LinkFieldData, type LinkVariantDefinition, type LinkVariantSource, type LinkVariantSpec, type LinkableDocument, type OnDiagnostic, type PayloadCollectionMapping, type PayloadCollectionMappingMatch, type PayloadCollectionMappingResolved, type PayloadCollectionMappingResolvers, type PayloadCollectionMappingSpecificity, type PayloadCollectionMatch, type PayloadDocument, type RegisteredCollections, type ResolveLinkArgs, type ResolveLinkDiagnosticReason, type ResolveParamQueryPathArgs, type ResolvePathDiagnosticReason, type ResolvePathToDocumentArgs, type ResolvePathWhere, type ResolveReference, type ResolveRelationshipSlugArgs, type ResolvedLink, type ResolvedLinkOf, type ResolvedPath, type VariantBuilder, buildHref, buildPath, defineLinks, defineMappings, deriveLinkLabel, identityFormatHref, isAvailableLink, isRootWildcard, matchCollectionMappings, resolveCollectionMapping, resolveLink, resolveParamQueryPath, resolvePathToDocument, resolveRelationshipSlug, resolversFor, variantsOf };
10
+ import { defineMappings } from "./pattern/define-mappings.mjs";
11
+ import { deriveLinkLabel } from "./pattern/derive-link-label.mjs";
12
+ export { type BaseResolvedLink, type BuildDiagnosticReason, type BuiltinLinkVariant, type CreateRouterArgs, type Diagnostic, type DiagnosticReason, type DocumentId, type FormatHref, type LabelLike, type LinkContextOf, type LinkDataOf, type LinkDeclaration, type LinkFieldData, type LinkableDocument, type OnDiagnostic, type PayloadCollectionMapping, type PayloadCollectionMappingResolved, type PayloadCollectionMatch, type PayloadDocument, type ResolveLinkDiagnosticReason, type ResolvePathDiagnosticReason, type ResolvePathWhere, type ResolveReference, type ResolveRelationshipSlugArgs, type ResolvedLinkOf, type ResolvedPath, type Router, createRouter, defineLinks, defineMappings, deriveLinkLabel, resolveRelationshipSlug };
package/dist/index.mjs CHANGED
@@ -1,15 +1,6 @@
1
- import { defineLinks, variantsOf } from "./pattern/define-links.mjs";
2
- import { DEFAULT_LOCALE_KEY } from "./pattern/types.mjs";
3
- import { isRootWildcard, resolveCollectionMapping, resolversFor } from "./pattern/resolver.mjs";
1
+ import { defineLinks } from "./pattern/define-links.mjs";
2
+ import { createRouter } from "./runtime/create-router.mjs";
3
+ import { resolveRelationshipSlug } from "./runtime/resolve-relationship-slug.mjs";
4
4
  import { defineMappings } from "./pattern/define-mappings.mjs";
5
5
  import { deriveLinkLabel } from "./pattern/derive-link-label.mjs";
6
- import { matchCollectionMappings } from "./pattern/matcher.mjs";
7
- import { DEFAULT_IDENTIFIER_FIELD, resolveParamQueryPath } from "./pattern/param-query-path.mjs";
8
- import "./pattern/index.mjs";
9
- import { buildHref, identityFormatHref } from "./runtime/build-href.mjs";
10
- import { buildPath } from "./runtime/build-path.mjs";
11
- import { isAvailableLink, resolveLink } from "./runtime/resolve-link.mjs";
12
- import { resolvePathToDocument } from "./runtime/resolve-path.mjs";
13
- import { resolveRelationshipSlug } from "./runtime/resolve-relationship-slug.mjs";
14
- import "./runtime/index.mjs";
15
- export { DEFAULT_IDENTIFIER_FIELD, DEFAULT_LOCALE_KEY, buildHref, buildPath, defineLinks, defineMappings, deriveLinkLabel, identityFormatHref, isAvailableLink, isRootWildcard, matchCollectionMappings, resolveCollectionMapping, resolveLink, resolveParamQueryPath, resolvePathToDocument, resolveRelationshipSlug, resolversFor, variantsOf };
6
+ export { createRouter, defineLinks, defineMappings, deriveLinkLabel, resolveRelationshipSlug };
@@ -0,0 +1,11 @@
1
+ import { Contributed, DEFAULT_IDENTIFIER_FIELD, DEFAULT_LOCALE_KEY, DeclaredLinkVariant, PayloadCollectionMappingMatch, PayloadCollectionMappingResolvers, PayloadCollectionMappingSpecificity, ResolvedLink } from "./pattern/types.mjs";
2
+ import { AnyLinkVariantDefinition, DataOfFields, LinkVariantDefinition, LinkVariantSource, LinkVariantSpec, VariantBuilder, variantsOf } from "./pattern/define-links.mjs";
3
+ import { BuildHrefArgs, buildHref, identityFormatHref } from "./runtime/build-href.mjs";
4
+ import { ResolveLinkArgs, isAvailableLink, resolveLink } from "./runtime/resolve-link.mjs";
5
+ import { matchCollectionMappings } from "./pattern/matcher.mjs";
6
+ import { ResolvePathToDocumentArgs, resolvePathToDocument } from "./runtime/resolve-path.mjs";
7
+ import { BuildPathArgs, buildPath } from "./runtime/build-path.mjs";
8
+ import { normaliseLinkNodeFields } from "./pattern/link-node.mjs";
9
+ import { RegisteredCollections, ResolveParamQueryPathArgs, resolveParamQueryPath } from "./pattern/param-query-path.mjs";
10
+ import { isRootWildcard, resolveCollectionMapping, resolversFor } from "./pattern/resolver.mjs";
11
+ export { type AnyLinkVariantDefinition, type BuildHrefArgs, type BuildPathArgs, type Contributed, DEFAULT_IDENTIFIER_FIELD, DEFAULT_LOCALE_KEY, type DataOfFields, type DeclaredLinkVariant, type LinkVariantDefinition, type LinkVariantSource, type LinkVariantSpec, type PayloadCollectionMappingMatch, type PayloadCollectionMappingResolvers, type PayloadCollectionMappingSpecificity, type RegisteredCollections, type ResolveLinkArgs, type ResolveParamQueryPathArgs, type ResolvePathToDocumentArgs, type ResolvedLink, type VariantBuilder, buildHref, buildPath, identityFormatHref, isAvailableLink, isRootWildcard, matchCollectionMappings, normaliseLinkNodeFields, resolveCollectionMapping, resolveLink, resolveParamQueryPath, resolvePathToDocument, resolversFor, variantsOf };
@@ -0,0 +1,11 @@
1
+ import { DEFAULT_IDENTIFIER_FIELD, DEFAULT_LOCALE_KEY } from "./pattern/types.mjs";
2
+ import { isRootWildcard, resolveCollectionMapping, resolversFor } from "./pattern/resolver.mjs";
3
+ import { buildHref, identityFormatHref } from "./runtime/build-href.mjs";
4
+ import { buildPath } from "./runtime/build-path.mjs";
5
+ import { variantsOf } from "./pattern/define-links.mjs";
6
+ import { isAvailableLink, resolveLink } from "./runtime/resolve-link.mjs";
7
+ import { matchCollectionMappings } from "./pattern/matcher.mjs";
8
+ import { resolveParamQueryPath } from "./pattern/param-query-path.mjs";
9
+ import { resolvePathToDocument } from "./runtime/resolve-path.mjs";
10
+ import { normaliseLinkNodeFields } from "./pattern/link-node.mjs";
11
+ export { DEFAULT_IDENTIFIER_FIELD, DEFAULT_LOCALE_KEY, buildHref, buildPath, identityFormatHref, isAvailableLink, isRootWildcard, matchCollectionMappings, normaliseLinkNodeFields, resolveCollectionMapping, resolveLink, resolveParamQueryPath, resolvePathToDocument, resolversFor, variantsOf };
@@ -1,4 +1,3 @@
1
- import { BaseResolvedLink, LinkFieldData, ResolvedLink } from "../pattern/types.mjs";
2
1
  import { LinkDeclaration, ResolvedLinkOf } from "../pattern/define-links.mjs";
3
2
  import { LinkFieldArgs } from "../config/link-field.mjs";
4
3
  import { ResolveLinkArgs } from "../runtime/resolve-link.mjs";
@@ -18,35 +17,22 @@ declare const linkLabelFeature: import("@payloadcms/richtext-lexical").FeaturePr
18
17
  * @param args The same arguments as the standalone link field.
19
18
  */
20
19
  declare const wayfinderLinkFeature: <TDeclaration extends LinkDeclaration = LinkDeclaration>(args: LinkFieldArgs<TDeclaration>) => ReturnType<typeof LinkFeature>;
21
- /**
22
- * The two shapes a link node's fields arrive in.
23
- *
24
- * A node written by {@link wayfinderLinkFeature} nests the group under `link`.
25
- * A node written by Lexical's stock link feature stores `linkType` and `doc`
26
- * at the top level, which existing content will still hold.
27
- */
28
- interface SerializedLinkFields {
29
- link?: LinkFieldData;
30
- linkType?: "custom" | "internal";
31
- url?: string | null;
32
- newTab?: boolean | null;
33
- doc?: {
34
- relationTo: string;
35
- value: string | {
36
- id: string;
37
- };
38
- } | null;
39
- }
40
20
  /**
41
21
  * Resolves a rich-text link node to an href.
42
22
  *
23
+ * Takes the node's `fields` as `unknown`, because Lexical types them as an
24
+ * open record and a narrower parameter would make every converter cast. The
25
+ * two shapes a node can hold are unwrapped by
26
+ * {@link normaliseLinkNodeFields}, so a link written in rich text and a link
27
+ * authored in a block resolve through exactly the same call.
28
+ *
43
29
  * Returns null when the node points nowhere resolvable, so a converter can
44
30
  * render the text without an anchor rather than emitting a dead one.
45
31
  *
46
32
  * @param args The node's fields plus the usual link-resolution arguments.
47
33
  */
48
- declare const resolveLinkNode: <TExtra = object, TDeclaration extends LinkDeclaration = LinkDeclaration>(args: Omit<ResolveLinkArgs<TExtra, TDeclaration>, "link"> & {
49
- fields: SerializedLinkFields | undefined;
50
- }) => BaseResolvedLink | ResolvedLink<object> | ResolvedLinkOf<TDeclaration> | null;
34
+ declare const resolveLinkNode: <TDeclaration extends LinkDeclaration = LinkDeclaration>(args: Omit<ResolveLinkArgs<TDeclaration>, "link"> & {
35
+ fields: unknown;
36
+ }) => ResolvedLinkOf<TDeclaration> | null;
51
37
  //#endregion
52
38
  export { linkLabelFeature, resolveLinkNode, wayfinderLinkFeature };
@@ -1,4 +1,5 @@
1
1
  import { resolveLink } from "../runtime/resolve-link.mjs";
2
+ import { normaliseLinkNodeFields } from "../pattern/link-node.mjs";
2
3
  import { linkField } from "../config/link-field.mjs";
3
4
  import { LinkFeature, createServerFeature } from "@payloadcms/richtext-lexical";
4
5
  //#region src/lexical/index.ts
@@ -22,36 +23,21 @@ import { LinkFeature, createServerFeature } from "@payloadcms/richtext-lexical";
22
23
  * @param args The same arguments as the standalone link field.
23
24
  */ const wayfinderLinkFeature = (args) => LinkFeature({ fields: () => [linkField(args)] });
24
25
  /**
25
- * Normalises a link node's fields into the link field's own shape.
26
- *
27
- * @param fields The node's `fields` object.
28
- */ const normaliseNodeFields = (fields) => {
29
- if (!fields) return;
30
- if (fields.link) return fields.link;
31
- if (fields.linkType === "internal" && fields.doc) return {
32
- type: "reference",
33
- reference: fields.doc,
34
- newTab: fields.newTab ?? null
35
- };
36
- if (fields.url) return {
37
- type: "custom",
38
- url: fields.url,
39
- newTab: fields.newTab ?? null
40
- };
41
- };
42
- /**
43
26
  * Resolves a rich-text link node to an href.
44
27
  *
28
+ * Takes the node's `fields` as `unknown`, because Lexical types them as an
29
+ * open record and a narrower parameter would make every converter cast. The
30
+ * two shapes a node can hold are unwrapped by
31
+ * {@link normaliseLinkNodeFields}, so a link written in rich text and a link
32
+ * authored in a block resolve through exactly the same call.
33
+ *
45
34
  * Returns null when the node points nowhere resolvable, so a converter can
46
35
  * render the text without an anchor rather than emitting a dead one.
47
36
  *
48
37
  * @param args The node's fields plus the usual link-resolution arguments.
49
- */ const resolveLinkNode = (args) => {
50
- const link = normaliseNodeFields(args.fields);
51
- return resolveLink({
52
- ...args,
53
- link
54
- });
55
- };
38
+ */ const resolveLinkNode = (args) => resolveLink({
39
+ ...args,
40
+ link: normaliseLinkNodeFields(args.fields)
41
+ });
56
42
  //#endregion
57
43
  export { linkLabelFeature, resolveLinkNode, wayfinderLinkFeature };
@@ -1,32 +1,54 @@
1
1
  import { PayloadCollectionMappingResolved } from "../pattern/types.mjs";
2
+ import { AnyLinkVariantDefinition, LinkDeclaration } from "../pattern/define-links.mjs";
2
3
  import { LoadMappingsArgs } from "../config/load-mappings.mjs";
4
+ import { CreateRouterArgs, Router } from "../runtime/create-router.mjs";
3
5
  import { MontageSlots } from "@abinnovision/payloadcms-montage";
4
6
  //#region src/montage/index.d.ts
5
7
  /**
6
- * The slot the compiled mappings ride in.
8
+ * The slot the bound router rides in.
7
9
  *
8
10
  * Prefixed with the package name because extension names share one namespace
9
11
  * across every library using montage.
10
12
  */
11
- declare const wayfinderExtension: import("@abinnovision/payloadcms-montage").ContextExtension<PayloadCollectionMappingResolved[]>;
13
+ declare const wayfinderExtension: import("@abinnovision/payloadcms-montage").ContextExtension<Router<LinkDeclaration<Record<string, AnyLinkVariantDefinition>>>>;
12
14
  /**
13
- * Loads the mappings once and parks them on the render context.
15
+ * What `initWayfinder` needs: a router's settings, plus where the mappings
16
+ * come from.
17
+ *
18
+ * Either hand over mappings already in hand, or the instance to read them
19
+ * from. A route that resolved a path has them already, and reading the global
20
+ * a second time to render the same page is a query for nothing.
21
+ */
22
+ type InitWayfinderArgs<TDeclaration extends LinkDeclaration = LinkDeclaration> = Omit<CreateRouterArgs<TDeclaration>, "mappings"> & ({
23
+ mappings: PayloadCollectionMappingResolved[];
24
+ } | {
25
+ load: Omit<LoadMappingsArgs, "localized"> & {
26
+ localized?: boolean;
27
+ };
28
+ });
29
+ /**
30
+ * Builds the router once and parks it on the render context.
14
31
  *
15
32
  * Blocks resolve links individually and there may be dozens on a page, so the
16
- * read happens once per request rather than once per link.
33
+ * mappings are read once per request rather than once per link — and the
34
+ * locale and href formatter travel with them, which is what stops a block
35
+ * resolving a link into the wrong locale or out of preview.
17
36
  *
18
37
  * @param ctx The render context.
19
- * @param args The Payload instance and mapping-global settings.
38
+ * @param args The router's settings, and the mappings or where to read them.
20
39
  */
21
- declare const initWayfinder: (ctx: MontageSlots, args: LoadMappingsArgs) => Promise<void>;
40
+ declare const initWayfinder: <TDeclaration extends LinkDeclaration = LinkDeclaration>(ctx: MontageSlots, args: InitWayfinderArgs<TDeclaration>) => Promise<void>;
22
41
  /**
23
- * Reads the mappings off the render context.
42
+ * Reads the router off the render context.
24
43
  *
25
- * Returns an empty list when {@link initWayfinder} has not run, so a block
26
- * rendered outside a request renders without links rather than throwing.
44
+ * Named for what it does — the router is already built, and this is a slot
45
+ * read rather than a load. Throws when {@link initWayfinder} has not run,
46
+ * because a block rendering links without a locale would silently produce the
47
+ * wrong ones, and a blank page is easier to explain than a page of quietly
48
+ * mislocalised links.
27
49
  *
28
50
  * @param ctx The render context.
29
51
  */
30
- declare const getMappings: (ctx: MontageSlots) => PayloadCollectionMappingResolved[];
52
+ declare const wayfinderFrom: <TDeclaration extends LinkDeclaration = LinkDeclaration>(ctx: MontageSlots) => Router<TDeclaration>;
31
53
  //#endregion
32
- export { getMappings, initWayfinder, wayfinderExtension };
54
+ export { InitWayfinderArgs, initWayfinder, wayfinderExtension, wayfinderFrom };
@@ -1,30 +1,44 @@
1
+ import { createRouter } from "../runtime/create-router.mjs";
1
2
  import { loadMappings } from "../config/load-mappings.mjs";
2
3
  import { createContextExtension } from "@abinnovision/payloadcms-montage";
3
4
  //#region src/montage/index.ts
4
5
  /**
5
- * The slot the compiled mappings ride in.
6
+ * The slot the bound router rides in.
6
7
  *
7
8
  * Prefixed with the package name because extension names share one namespace
8
9
  * across every library using montage.
9
- */ const wayfinderExtension = createContextExtension("wayfinder:mappings");
10
+ */ const wayfinderExtension = createContextExtension("wayfinder:router");
10
11
  /**
11
- * Loads the mappings once and parks them on the render context.
12
+ * Builds the router once and parks it on the render context.
12
13
  *
13
14
  * Blocks resolve links individually and there may be dozens on a page, so the
14
- * read happens once per request rather than once per link.
15
+ * mappings are read once per request rather than once per link — and the
16
+ * locale and href formatter travel with them, which is what stops a block
17
+ * resolving a link into the wrong locale or out of preview.
15
18
  *
16
19
  * @param ctx The render context.
17
- * @param args The Payload instance and mapping-global settings.
20
+ * @param args The router's settings, and the mappings or where to read them.
18
21
  */ const initWayfinder = async (ctx, args) => {
19
- wayfinderExtension.set(ctx, await loadMappings(args));
22
+ const mappings = "mappings" in args ? args.mappings : await loadMappings(args.load);
23
+ wayfinderExtension.set(ctx, createRouter({
24
+ ...args,
25
+ mappings
26
+ }));
20
27
  };
21
28
  /**
22
- * Reads the mappings off the render context.
29
+ * Reads the router off the render context.
23
30
  *
24
- * Returns an empty list when {@link initWayfinder} has not run, so a block
25
- * rendered outside a request renders without links rather than throwing.
31
+ * Named for what it does — the router is already built, and this is a slot
32
+ * read rather than a load. Throws when {@link initWayfinder} has not run,
33
+ * because a block rendering links without a locale would silently produce the
34
+ * wrong ones, and a blank page is easier to explain than a page of quietly
35
+ * mislocalised links.
26
36
  *
27
37
  * @param ctx The render context.
28
- */ const getMappings = (ctx) => wayfinderExtension.get(ctx) ?? [];
38
+ */ const wayfinderFrom = (ctx) => {
39
+ const router = wayfinderExtension.get(ctx);
40
+ if (!router) throw new Error("[wayfinder] No router on the render context. Call `initWayfinder(ctx, ...)` in the route before rendering blocks.");
41
+ return router;
42
+ };
29
43
  //#endregion
30
- export { getMappings, initWayfinder, wayfinderExtension };
44
+ export { initWayfinder, wayfinderExtension, wayfinderFrom };
@@ -49,8 +49,16 @@ type FieldData<F1> = F1 extends {
49
49
  } ? { [K in N]?: number | null; } : F1 extends {
50
50
  type: "checkbox";
51
51
  } ? { [K in N]?: boolean | null; } : { [K in N]?: unknown; } : object;
52
- /** Everything a variant's own fields contribute, as one object type. */
53
- type DataOfFields<TFields extends readonly unknown[]> = UnionToIntersection<FieldData<TFields[number]>>;
52
+ /**
53
+ * Everything a variant's own fields contribute, as one object type.
54
+ *
55
+ * A variant declared without `fields`, or one whose fields were not captured
56
+ * as a literal tuple, arrives here as the bare `readonly Field[]`. Deriving
57
+ * from the whole `Field` union would intersect every field type into index
58
+ * signatures that reject ordinary link data, so such a variant contributes
59
+ * nothing rather than something wrong.
60
+ */
61
+ type DataOfFields<TFields extends readonly unknown[]> = number extends TFields["length"] ? object : UnionToIntersection<FieldData<TFields[number]>>;
54
62
  /** A variant that has had no resolver attached. */
55
63
  interface LinkVariantSpec<TFields extends readonly Field[]> {
56
64
  label: LabelLike;
@@ -93,17 +101,21 @@ interface LinkDeclaration<TVariants1 extends Record<string, AnyLinkVariantDefini
93
101
  variants: TVariants1;
94
102
  }
95
103
  /** The stored shape of a link field built from a declaration. */
104
+ type FieldsData<V> = V extends {
105
+ fields?: infer F extends readonly unknown[];
106
+ } ? DataOfFields<F> : object;
96
107
  /**
97
108
  * What one variant contributes.
98
109
  *
99
- * Read off `__data` rather than re-derived from `fields`, so a variant that
100
- * named its own shape with `.data<T>()` keeps it.
110
+ * `__data` is read first, so a variant that named its own shape with
111
+ * `.data<T>()` keeps it. Every object type structurally satisfies an optional
112
+ * property, so its absence shows up as `unknown` and falls through to the
113
+ * fields — which is what lets a variant with no resolver be written as a plain
114
+ * object literal and still be typed.
101
115
  */
102
116
  type DataOfVariant<V> = V extends {
103
117
  __data?: infer D;
104
- } ? unknown extends D ? object : D : V extends {
105
- fields?: infer F extends readonly unknown[];
106
- } ? DataOfFields<F> : object;
118
+ } ? unknown extends D ? FieldsData<V> : D : FieldsData<V>;
107
119
  type LinkDataOf<T> = T extends LinkDeclaration<infer TVariants> ? LinkFieldData<Extract<keyof TVariants, string>, UnionToIntersection<{ [K in keyof TVariants]: DataOfVariant<TVariants[K]>; }[keyof TVariants]>> : never;
108
120
  /** What resolving a link built from a declaration can produce. */
109
121
  type ResolvedLinkOf<T> = T extends LinkDeclaration<infer TVariants> ? BaseResolvedLink & Contributed<UnionToIntersection<{ [K in keyof TVariants]: TVariants[K] extends {
@@ -9,7 +9,11 @@ import { PayloadCollectionMapping, PayloadCollectionMappingResolved } from "./ty
9
9
  * tests — none of which should have to stand up a global first.
10
10
  *
11
11
  * @param mappings The collection-to-pattern map.
12
+ * @param options Where a relationship parameter falls back to when the target
13
+ * collection's own pattern cannot name an identifier.
12
14
  */
13
- declare const defineMappings: (mappings: PayloadCollectionMapping[]) => PayloadCollectionMappingResolved[];
15
+ declare const defineMappings: (mappings: PayloadCollectionMapping[], options?: {
16
+ fallbackIdentifierField?: string;
17
+ }) => PayloadCollectionMappingResolved[];
14
18
  //#endregion
15
19
  export { defineMappings };
@@ -9,6 +9,8 @@ import { resolveCollectionMapping } from "./resolver.mjs";
9
9
  * tests — none of which should have to stand up a global first.
10
10
  *
11
11
  * @param mappings The collection-to-pattern map.
12
- */ const defineMappings = (mappings) => mappings.map((mapping) => resolveCollectionMapping(mapping));
12
+ * @param options Where a relationship parameter falls back to when the target
13
+ * collection's own pattern cannot name an identifier.
14
+ */ const defineMappings = (mappings, options = {}) => mappings.map((mapping) => resolveCollectionMapping(mapping, options.fallbackIdentifierField));
13
15
  //#endregion
14
16
  export { defineMappings };