@abinnovision/payloadcms-wayfinder 1.0.0-beta.2

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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +132 -0
  3. package/dist/admin/LinkLabelFeature.client.d.mts +5 -0
  4. package/dist/admin/LinkLabelFeature.client.mjs +35 -0
  5. package/dist/admin/index.d.mts +2 -0
  6. package/dist/admin/index.mjs +3 -0
  7. package/dist/config/collection-string-field.d.mts +20 -0
  8. package/dist/config/collection-string-field.mjs +33 -0
  9. package/dist/config/has-duplicates.d.mts +15 -0
  10. package/dist/config/has-duplicates.mjs +14 -0
  11. package/dist/config/index.d.mts +8 -0
  12. package/dist/config/index.mjs +8 -0
  13. package/dist/config/link-field.d.mts +35 -0
  14. package/dist/config/link-field.mjs +150 -0
  15. package/dist/config/load-mappings.d.mts +38 -0
  16. package/dist/config/load-mappings.mjs +60 -0
  17. package/dist/config/mapping-global.d.mts +34 -0
  18. package/dist/config/mapping-global.mjs +86 -0
  19. package/dist/config/plugin.d.mts +31 -0
  20. package/dist/config/plugin.mjs +36 -0
  21. package/dist/config/translations.d.mts +34 -0
  22. package/dist/config/translations.mjs +45 -0
  23. package/dist/index.d.mts +16 -0
  24. package/dist/index.mjs +15 -0
  25. package/dist/lexical/index.d.mts +52 -0
  26. package/dist/lexical/index.mjs +57 -0
  27. package/dist/montage/index.d.mts +32 -0
  28. package/dist/montage/index.mjs +30 -0
  29. package/dist/pattern/define-links.d.mts +187 -0
  30. package/dist/pattern/define-links.mjs +41 -0
  31. package/dist/pattern/define-mappings.d.mts +15 -0
  32. package/dist/pattern/define-mappings.mjs +14 -0
  33. package/dist/pattern/derive-link-label.d.mts +23 -0
  34. package/dist/pattern/derive-link-label.mjs +35 -0
  35. package/dist/pattern/index.d.mts +8 -0
  36. package/dist/pattern/index.mjs +8 -0
  37. package/dist/pattern/matcher.d.mts +26 -0
  38. package/dist/pattern/matcher.mjs +74 -0
  39. package/dist/pattern/param-query-path.d.mts +42 -0
  40. package/dist/pattern/param-query-path.mjs +53 -0
  41. package/dist/pattern/resolver.d.mts +30 -0
  42. package/dist/pattern/resolver.mjs +104 -0
  43. package/dist/pattern/types.d.mts +169 -0
  44. package/dist/pattern/types.mjs +11 -0
  45. package/dist/runtime/build-href.d.mts +39 -0
  46. package/dist/runtime/build-href.mjs +73 -0
  47. package/dist/runtime/build-path.d.mts +32 -0
  48. package/dist/runtime/build-path.mjs +49 -0
  49. package/dist/runtime/diagnostics.d.mts +35 -0
  50. package/dist/runtime/index.d.mts +7 -0
  51. package/dist/runtime/index.mjs +6 -0
  52. package/dist/runtime/resolve-link.d.mts +63 -0
  53. package/dist/runtime/resolve-link.mjs +89 -0
  54. package/dist/runtime/resolve-path.d.mts +54 -0
  55. package/dist/runtime/resolve-path.mjs +108 -0
  56. package/dist/runtime/resolve-relationship-slug.d.mts +33 -0
  57. package/dist/runtime/resolve-relationship-slug.mjs +51 -0
  58. package/package.json +133 -0
@@ -0,0 +1,89 @@
1
+ import { variantsOf } from "../pattern/define-links.mjs";
2
+ import { buildHref } from "./build-href.mjs";
3
+ //#region src/runtime/resolve-link.ts
4
+ /** Types the package resolves without a declaration. */ const BUILTIN_VARIANTS = /* @__PURE__ */ new Set([
5
+ "none",
6
+ "reference",
7
+ "custom",
8
+ "same-page"
9
+ ]);
10
+ const newTabProps = (newTab) => newTab ? {
11
+ target: "_blank",
12
+ rel: "noopener noreferrer"
13
+ } : {};
14
+ /**
15
+ * Turns a link field's value into an href.
16
+ *
17
+ * Internal references route through the collection mapping rather than any
18
+ * hardcoded prefix, so changing a collection's URL pattern in the CMS updates
19
+ * every link to it.
20
+ *
21
+ * Returns null rather than degrading to the site root. A link that silently
22
+ * points somewhere plausible is harder to find than one that renders nothing.
23
+ *
24
+ * @param args The link value, mappings, locale and any app-declared variants.
25
+ */ const resolveLink = (args) => {
26
+ const link = args.link;
27
+ if (!link?.type || link.type === "none") return null;
28
+ const variant = variantsOf(args).find((it) => it.value === link.type);
29
+ if (variant?.resolve) return variant.resolve({
30
+ link,
31
+ context: args.context
32
+ });
33
+ if (link.type === "custom" && link.url) return {
34
+ href: link.url,
35
+ ...newTabProps(link.newTab)
36
+ };
37
+ if (link.type === "same-page" && link.samePageIdentifier) return { href: `#${link.samePageIdentifier}` };
38
+ if (link.type === "reference" && link.reference) {
39
+ const { relationTo, value } = link.reference;
40
+ if (args.resolveReference) {
41
+ const href = args.resolveReference({
42
+ relationTo,
43
+ value,
44
+ locale: args.locale
45
+ });
46
+ return href ? {
47
+ href,
48
+ ...newTabProps(link.newTab)
49
+ } : null;
50
+ }
51
+ if (typeof value === "string") {
52
+ args.onDiagnostic?.({
53
+ reason: "unpopulated-reference",
54
+ collection: relationTo
55
+ });
56
+ return null;
57
+ }
58
+ const href = buildHref({
59
+ mappings: args.mappings,
60
+ collection: relationTo,
61
+ document: value,
62
+ locale: args.locale,
63
+ ...args.formatHref ? { formatHref: args.formatHref } : {},
64
+ ...args.identifierField ? { identifierField: args.identifierField } : {},
65
+ ...args.onDiagnostic ? { onDiagnostic: args.onDiagnostic } : {}
66
+ });
67
+ return href ? {
68
+ href,
69
+ ...newTabProps(link.newTab)
70
+ } : null;
71
+ }
72
+ if (!BUILTIN_VARIANTS.has(link.type) && !variant) args.onDiagnostic?.({
73
+ reason: "unknown-variant",
74
+ variant: link.type
75
+ });
76
+ return null;
77
+ };
78
+ /**
79
+ * Whether a link would resolve to something navigable. Use it to decide
80
+ * whether to render a link at all, rather than emitting a dead anchor.
81
+ *
82
+ * @param args The same arguments as {@link resolveLink}, plus whether a label
83
+ * is required for the link to be worth rendering.
84
+ */ const isAvailableLink = (args) => {
85
+ if (args.withLabel && !args.link?.label) return false;
86
+ return resolveLink(args) !== null;
87
+ };
88
+ //#endregion
89
+ export { isAvailableLink, resolveLink };
@@ -0,0 +1,54 @@
1
+ import { PayloadCollectionMappingResolved } from "../pattern/types.mjs";
2
+ import { PayloadCollectionMatch } from "../pattern/matcher.mjs";
3
+ import { OnDiagnostic, ResolvePathDiagnosticReason } from "./diagnostics.mjs";
4
+ import { Payload, Where } from "payload";
5
+ //#region src/runtime/resolve-path.d.ts
6
+ /**
7
+ * A fetched document. The concrete shape varies per collection and callers
8
+ * read it structurally, so it is an open record rather than the union of every
9
+ * generated collection type.
10
+ */
11
+ type PayloadDocument = {
12
+ id: string | number;
13
+ } & Record<string, unknown>;
14
+ interface ResolvedPath {
15
+ match: PayloadCollectionMatch;
16
+ collection: string;
17
+ document: PayloadDocument;
18
+ }
19
+ /** Extra conditions to AND into the lookup. */
20
+ type ResolvePathWhere = Where | ((args: {
21
+ collection: string;
22
+ match: PayloadCollectionMatch;
23
+ draft: boolean;
24
+ }) => Where | undefined);
25
+ interface ResolvePathToDocumentArgs {
26
+ payload: Payload;
27
+ mappings: PayloadCollectionMappingResolved[];
28
+ path: string;
29
+ locale: string;
30
+ /** Read drafts rather than only published documents. */
31
+ draft?: boolean;
32
+ depth?: number;
33
+ identifierField?: string;
34
+ /**
35
+ * Access rules, tenancy and language visibility all narrow which document
36
+ * a path may resolve to, and none of them belong to the package. Without
37
+ * this seam a project has to query twice or fork the function.
38
+ */
39
+ where?: ResolvePathWhere;
40
+ onDiagnostic?: OnDiagnostic<ResolvePathDiagnosticReason>;
41
+ }
42
+ /**
43
+ * Finds the document a path resolves to, or null.
44
+ *
45
+ * Candidates are tried in specificity order and the first one that actually
46
+ * has a document wins. Without the fallback a nested page path would be
47
+ * claimed by a more specific pattern and 404 even though the page exists —
48
+ * `/legal/imprint` also fits `/:section/:slug`.
49
+ *
50
+ * @param args The Payload instance, mappings, path and locale.
51
+ */
52
+ declare const resolvePathToDocument: (args: ResolvePathToDocumentArgs) => Promise<ResolvedPath | null>;
53
+ //#endregion
54
+ export { PayloadDocument, ResolvePathToDocumentArgs, ResolvePathWhere, ResolvedPath, resolvePathToDocument };
@@ -0,0 +1,108 @@
1
+ import { matchCollectionMappings } from "../pattern/matcher.mjs";
2
+ import { resolveParamQueryPath } from "../pattern/param-query-path.mjs";
3
+ //#region src/runtime/resolve-path.ts
4
+ /**
5
+ * Blocks nest arbitrarily deep and read their relationships directly, so the
6
+ * default is generous. Lower it when a project knows its own shape.
7
+ */ const DEFAULT_DOCUMENT_DEPTH = 10;
8
+ /**
9
+ * Turns the matched scope parameters into query conditions.
10
+ *
11
+ * Reuses the same parameter resolution the mapping validator applies at save
12
+ * time, so a pattern that validated will query the field it promised.
13
+ */ const buildScopeConditions = (args) => {
14
+ const config = args.payload.collections[args.collection]?.config;
15
+ if (!config) return [];
16
+ return Object.entries(args.scope).flatMap(([param, value]) => {
17
+ const resolved = resolveParamQueryPath({
18
+ config,
19
+ param,
20
+ collections: args.payload.collections,
21
+ mappings: args.mappings,
22
+ locale: args.locale,
23
+ ...args.identifierField ? { identifierField: args.identifierField } : {}
24
+ });
25
+ return "error" in resolved ? [] : [{ [resolved.queryPath]: { equals: value } }];
26
+ });
27
+ };
28
+ /**
29
+ * Whether a collection keeps drafts.
30
+ *
31
+ * Payload only adds `_status` to a collection that does, so both asking for a
32
+ * draft read and filtering on published status have to be conditional: on a
33
+ * collection without versions the column does not exist and the query fails
34
+ * outright rather than coming back empty.
35
+ */ const supportsDrafts = (payload, collection) => Boolean(payload.collections[collection]?.config.versions?.drafts);
36
+ /**
37
+ * Restricts a public read to published documents.
38
+ *
39
+ * `payload.find` with `draft: false` still returns the newest version of a
40
+ * document, which for a drafts-enabled collection includes one that has never
41
+ * been published. Without this a URL becomes routable the moment someone saves
42
+ * a draft at it, which is the opposite of what turning drafts on is for.
43
+ */ const publishedOnly = (payload, collection, draft) => !draft && supportsDrafts(payload, collection) ? [{ _status: { equals: "published" } }] : [];
44
+ /**
45
+ * Finds the document a path resolves to, or null.
46
+ *
47
+ * Candidates are tried in specificity order and the first one that actually
48
+ * has a document wins. Without the fallback a nested page path would be
49
+ * claimed by a more specific pattern and 404 even though the page exists —
50
+ * `/legal/imprint` also fits `/:section/:slug`.
51
+ *
52
+ * @param args The Payload instance, mappings, path and locale.
53
+ */ const resolvePathToDocument = async (args) => {
54
+ const draft = args.draft ?? false;
55
+ const matches = matchCollectionMappings({
56
+ path: args.path,
57
+ locale: args.locale,
58
+ mappings: args.mappings
59
+ });
60
+ if (matches.length === 0) {
61
+ args.onDiagnostic?.({
62
+ reason: "no-mapping",
63
+ path: args.path
64
+ });
65
+ return null;
66
+ }
67
+ for (const match of matches) {
68
+ const collection = match.mapping.collection;
69
+ const extra = typeof args.where === "function" ? args.where({
70
+ collection,
71
+ match,
72
+ draft
73
+ }) : args.where;
74
+ const document = (await args.payload.find({
75
+ collection,
76
+ locale: args.locale,
77
+ draft: draft && supportsDrafts(args.payload, collection),
78
+ overrideAccess: true,
79
+ limit: 1,
80
+ depth: args.depth ?? DEFAULT_DOCUMENT_DEPTH,
81
+ where: { and: [
82
+ { [match.identifier.field]: { equals: match.identifier.value } },
83
+ ...buildScopeConditions({
84
+ payload: args.payload,
85
+ collection,
86
+ scope: match.scope,
87
+ mappings: args.mappings,
88
+ locale: args.locale,
89
+ ...args.identifierField ? { identifierField: args.identifierField } : {}
90
+ }),
91
+ ...publishedOnly(args.payload, collection, draft),
92
+ ...extra ? [extra] : []
93
+ ] }
94
+ })).docs[0];
95
+ if (document) return {
96
+ match,
97
+ collection,
98
+ document
99
+ };
100
+ }
101
+ args.onDiagnostic?.({
102
+ reason: "no-document",
103
+ path: args.path
104
+ });
105
+ return null;
106
+ };
107
+ //#endregion
108
+ export { resolvePathToDocument };
@@ -0,0 +1,33 @@
1
+ import { PayloadCollectionMappingResolved } from "../pattern/types.mjs";
2
+ import { Payload, SanitizedCollectionConfig } from "payload";
3
+ //#region src/runtime/resolve-relationship-slug.d.ts
4
+ interface ResolveRelationshipSlugArgs {
5
+ payload: Payload;
6
+ /** The collection the pattern parameter belongs to. */
7
+ config: SanitizedCollectionConfig;
8
+ /** The pattern parameter naming the relationship. */
9
+ param: string;
10
+ /** The raw value held by the document being edited. */
11
+ value: unknown;
12
+ mappings?: PayloadCollectionMappingResolved[];
13
+ locale?: string;
14
+ identifierField?: string;
15
+ }
16
+ /**
17
+ * Reads the identifier value behind a relationship parameter.
18
+ *
19
+ * The inverse of `resolveParamQueryPath`: that turns a parameter into the
20
+ * field a path lookup queries, this turns the same parameter into the value
21
+ * that field holds. The admin panel is where the two have to meet — a preview
22
+ * URL is built from form state, where a relationship is still a bare id and
23
+ * there is no populated document to read a slug off, so a URL built without
24
+ * this would never match back.
25
+ *
26
+ * Returns null when the value names nothing resolvable, which callers should
27
+ * treat as "no preview URL" rather than guessing.
28
+ *
29
+ * @param args The Payload instance, the parameter and its raw value.
30
+ */
31
+ declare const resolveRelationshipSlug: (args: ResolveRelationshipSlugArgs) => Promise<string | null>;
32
+ //#endregion
33
+ export { ResolveRelationshipSlugArgs, resolveRelationshipSlug };
@@ -0,0 +1,51 @@
1
+ import { resolversFor } from "../pattern/resolver.mjs";
2
+ //#region src/runtime/resolve-relationship-slug.ts
3
+ const identifierFor = (target, args) => {
4
+ const fallback = args.identifierField ?? "slug";
5
+ if (!args.mappings || args.locale === void 0) return fallback;
6
+ const mapping = args.mappings.find((it) => it.collection === target);
7
+ const resolvers = mapping ? resolversFor(mapping, args.locale) : void 0;
8
+ if (!resolvers || resolvers.specificity.hasWildcard) return fallback;
9
+ return resolvers.paramNames.at(-1) ?? fallback;
10
+ };
11
+ /**
12
+ * Reads the identifier value behind a relationship parameter.
13
+ *
14
+ * The inverse of `resolveParamQueryPath`: that turns a parameter into the
15
+ * field a path lookup queries, this turns the same parameter into the value
16
+ * that field holds. The admin panel is where the two have to meet — a preview
17
+ * URL is built from form state, where a relationship is still a bare id and
18
+ * there is no populated document to read a slug off, so a URL built without
19
+ * this would never match back.
20
+ *
21
+ * Returns null when the value names nothing resolvable, which callers should
22
+ * treat as "no preview URL" rather than guessing.
23
+ *
24
+ * @param args The Payload instance, the parameter and its raw value.
25
+ */ const resolveRelationshipSlug = async (args) => {
26
+ const field = args.config.flattenedFields.find((it) => it.name === args.param);
27
+ if (field?.type !== "relationship") return typeof args.value === "string" ? args.value : null;
28
+ const targets = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo];
29
+ if (args.value && typeof args.value === "object") {
30
+ const identifier = identifierFor(targets[0], args);
31
+ const held = args.value[identifier];
32
+ return typeof held === "string" ? held : null;
33
+ }
34
+ if (typeof args.value !== "string") return null;
35
+ for (const target of targets) {
36
+ const identifier = identifierFor(target, args);
37
+ try {
38
+ const held = (await args.payload.findByID({
39
+ collection: target,
40
+ id: args.value,
41
+ depth: 0,
42
+ select: { [identifier]: true },
43
+ overrideAccess: true
44
+ }))[identifier];
45
+ if (typeof held === "string") return held;
46
+ } catch {}
47
+ }
48
+ return null;
49
+ };
50
+ //#endregion
51
+ export { resolveRelationshipSlug };
package/package.json ADDED
@@ -0,0 +1,133 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@abinnovision/payloadcms-wayfinder",
4
+ "version": "1.0.0-beta.2",
5
+ "description": "Editor-authored URL routing for Payload: collection-to-path patterns, document-to-href building, path-to-document resolution and a routing-aware link field.",
6
+ "keywords": [
7
+ "payload",
8
+ "payloadcms",
9
+ "routing",
10
+ "permalinks",
11
+ "urls",
12
+ "link-field"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/abinnovision/payloadcms-commons.git",
17
+ "directory": "packages/wayfinder"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "abi group GmbH",
22
+ "email": "info@abigroup.io",
23
+ "url": "https://abigroup.io"
24
+ },
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.mts",
29
+ "default": "./dist/index.mjs"
30
+ },
31
+ "./config": {
32
+ "types": "./dist/config/index.d.mts",
33
+ "default": "./dist/config/index.mjs"
34
+ },
35
+ "./lexical": {
36
+ "types": "./dist/lexical/index.d.mts",
37
+ "default": "./dist/lexical/index.mjs"
38
+ },
39
+ "./admin": {
40
+ "types": "./dist/admin/index.d.mts",
41
+ "default": "./dist/admin/index.mjs"
42
+ },
43
+ "./montage": {
44
+ "types": "./dist/montage/index.d.mts",
45
+ "default": "./dist/montage/index.mjs"
46
+ }
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "LICENSE",
51
+ "README.md"
52
+ ],
53
+ "scripts": {
54
+ "build": "tsdown",
55
+ "format:check": "prettier --check 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
56
+ "format:fix": "prettier --write 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
57
+ "lint:check": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts'",
58
+ "lint:fix": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts' --fix",
59
+ "test-integration": "vitest --run --config test/integration/vitest.config.mts",
60
+ "test-unit": "vitest --run --coverage --config vitest.config.mts",
61
+ "test-unit:watch": "vitest --config vitest.config.mts",
62
+ "typecheck": "tsc --noEmit"
63
+ },
64
+ "lint-staged": {
65
+ "src/**/*.{ts,tsx}": [
66
+ "eslint --fix",
67
+ "prettier --write"
68
+ ],
69
+ "*.{json{,5},md,y{,a}ml}": "prettier --write"
70
+ },
71
+ "prettier": "@abinnovision/prettier-config",
72
+ "dependencies": {
73
+ "path-to-regexp": "^8.4.2"
74
+ },
75
+ "devDependencies": {
76
+ "@abinnovision/eslint-config-base": "^3.4.2",
77
+ "@abinnovision/payloadcms-montage": "0.0.0",
78
+ "@abinnovision/prettier-config": "^2.2.0",
79
+ "@arethetypeswrong/core": "^0.18.5",
80
+ "@lexical/react": "0.41.0",
81
+ "@payloadcms/db-sqlite": "3.88.0",
82
+ "@payloadcms/richtext-lexical": "3.88.0",
83
+ "@payloadcms/ui": "3.88.0",
84
+ "@swc/core": "^1.16.1",
85
+ "@types/node": "^26.3.0",
86
+ "@types/react": "^19.2.18",
87
+ "@vitest/coverage-v8": "^4.1.10",
88
+ "eslint": "^10.9.1",
89
+ "lexical": "0.41.0",
90
+ "payload": "3.88.0",
91
+ "prettier": "^3.9.5",
92
+ "publint": "^0.3.24",
93
+ "react": "^19.2.8",
94
+ "tsdown": "^0.22.14",
95
+ "typescript": "^6.0.3",
96
+ "unplugin-swc": "^1.5.11",
97
+ "vitest": "^4.1.10"
98
+ },
99
+ "peerDependencies": {
100
+ "@abinnovision/payloadcms-montage": ">=1.0.0-beta.1",
101
+ "@lexical/react": ">=0.35 <1",
102
+ "@payloadcms/richtext-lexical": ">=3.88.0 <4",
103
+ "@payloadcms/ui": ">=3.88.0 <4",
104
+ "lexical": ">=0.35 <1",
105
+ "payload": ">=3.88.0 <4",
106
+ "react": "^19"
107
+ },
108
+ "peerDependenciesMeta": {
109
+ "@abinnovision/payloadcms-montage": {
110
+ "optional": true
111
+ },
112
+ "@lexical/react": {
113
+ "optional": true
114
+ },
115
+ "@payloadcms/richtext-lexical": {
116
+ "optional": true
117
+ },
118
+ "@payloadcms/ui": {
119
+ "optional": true
120
+ },
121
+ "lexical": {
122
+ "optional": true
123
+ },
124
+ "react": {
125
+ "optional": true
126
+ }
127
+ },
128
+ "publishConfig": {
129
+ "ghpr": true,
130
+ "npm": true,
131
+ "npmAccess": "public"
132
+ }
133
+ }