@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.
- package/LICENSE +201 -0
- package/README.md +132 -0
- package/dist/admin/LinkLabelFeature.client.d.mts +5 -0
- package/dist/admin/LinkLabelFeature.client.mjs +35 -0
- package/dist/admin/index.d.mts +2 -0
- package/dist/admin/index.mjs +3 -0
- package/dist/config/collection-string-field.d.mts +20 -0
- package/dist/config/collection-string-field.mjs +33 -0
- package/dist/config/has-duplicates.d.mts +15 -0
- package/dist/config/has-duplicates.mjs +14 -0
- package/dist/config/index.d.mts +8 -0
- package/dist/config/index.mjs +8 -0
- package/dist/config/link-field.d.mts +35 -0
- package/dist/config/link-field.mjs +150 -0
- package/dist/config/load-mappings.d.mts +38 -0
- package/dist/config/load-mappings.mjs +60 -0
- package/dist/config/mapping-global.d.mts +34 -0
- package/dist/config/mapping-global.mjs +86 -0
- package/dist/config/plugin.d.mts +31 -0
- package/dist/config/plugin.mjs +36 -0
- package/dist/config/translations.d.mts +34 -0
- package/dist/config/translations.mjs +45 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +15 -0
- package/dist/lexical/index.d.mts +52 -0
- package/dist/lexical/index.mjs +57 -0
- package/dist/montage/index.d.mts +32 -0
- package/dist/montage/index.mjs +30 -0
- package/dist/pattern/define-links.d.mts +187 -0
- package/dist/pattern/define-links.mjs +41 -0
- package/dist/pattern/define-mappings.d.mts +15 -0
- package/dist/pattern/define-mappings.mjs +14 -0
- package/dist/pattern/derive-link-label.d.mts +23 -0
- package/dist/pattern/derive-link-label.mjs +35 -0
- package/dist/pattern/index.d.mts +8 -0
- package/dist/pattern/index.mjs +8 -0
- package/dist/pattern/matcher.d.mts +26 -0
- package/dist/pattern/matcher.mjs +74 -0
- package/dist/pattern/param-query-path.d.mts +42 -0
- package/dist/pattern/param-query-path.mjs +53 -0
- package/dist/pattern/resolver.d.mts +30 -0
- package/dist/pattern/resolver.mjs +104 -0
- package/dist/pattern/types.d.mts +169 -0
- package/dist/pattern/types.mjs +11 -0
- package/dist/runtime/build-href.d.mts +39 -0
- package/dist/runtime/build-href.mjs +73 -0
- package/dist/runtime/build-path.d.mts +32 -0
- package/dist/runtime/build-path.mjs +49 -0
- package/dist/runtime/diagnostics.d.mts +35 -0
- package/dist/runtime/index.d.mts +7 -0
- package/dist/runtime/index.mjs +6 -0
- package/dist/runtime/resolve-link.d.mts +63 -0
- package/dist/runtime/resolve-link.mjs +89 -0
- package/dist/runtime/resolve-path.d.mts +54 -0
- package/dist/runtime/resolve-path.mjs +108 -0
- package/dist/runtime/resolve-relationship-slug.d.mts +33 -0
- package/dist/runtime/resolve-relationship-slug.mjs +51 -0
- package/package.json +133 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DEFAULT_LOCALE_KEY } from "./types.mjs";
|
|
2
|
+
import * as ptr from "path-to-regexp";
|
|
3
|
+
//#region src/pattern/resolver.ts
|
|
4
|
+
/**
|
|
5
|
+
* A wildcard stands for a whole path, so its value carries a leading slash to
|
|
6
|
+
* match how full paths are stored — a wildcard-mapped collection's identifier
|
|
7
|
+
* is `/about/team`, not `about/team`.
|
|
8
|
+
*
|
|
9
|
+
* @param value Raw parameter value from path-to-regexp.
|
|
10
|
+
* @param isWildcard Whether the parameter was declared as a wildcard.
|
|
11
|
+
*/ const normaliseValue = (value, isWildcard) => {
|
|
12
|
+
let joined = "";
|
|
13
|
+
if (Array.isArray(value)) joined = value.filter((it) => typeof it === "string").join("/");
|
|
14
|
+
else if (typeof value === "string") joined = value;
|
|
15
|
+
if (!isWildcard) return joined;
|
|
16
|
+
return joined.startsWith("/") ? joined : `/${joined}`;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Reverses {@link normaliseValue} so a stored full path can be fed back into
|
|
20
|
+
* `compile`, which requires a non-empty array for a wildcard.
|
|
21
|
+
*
|
|
22
|
+
* @param value The parameter value to convert.
|
|
23
|
+
*/ const denormaliseWildcard = (value) => (typeof value === "string" ? value : "").split("/").filter(Boolean);
|
|
24
|
+
const measureSpecificity = (pattern, keys) => {
|
|
25
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
26
|
+
return {
|
|
27
|
+
literalSegments: segments.filter((segment) => !segment.startsWith(":") && !segment.startsWith("*")).length,
|
|
28
|
+
hasWildcard: keys.some((key) => key.type === "wildcard"),
|
|
29
|
+
totalSegments: segments.length
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const createResolvers = (pattern) => {
|
|
33
|
+
const { keys } = ptr.pathToRegexp(pattern);
|
|
34
|
+
const matchFn = ptr.match(pattern);
|
|
35
|
+
const buildFn = ptr.compile(pattern);
|
|
36
|
+
const wildcardNames = new Set(keys.filter((key) => key.type === "wildcard").map((key) => key.name));
|
|
37
|
+
return {
|
|
38
|
+
specificity: measureSpecificity(pattern, keys),
|
|
39
|
+
paramNames: keys.map((key) => key.name),
|
|
40
|
+
match: (path) => {
|
|
41
|
+
const matched = matchFn(path);
|
|
42
|
+
if (!matched) return false;
|
|
43
|
+
const ordered = keys.map((key) => {
|
|
44
|
+
const name = key.name;
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
value: normaliseValue(matched.params[name], wildcardNames.has(name))
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const identifying = ordered.at(-1);
|
|
51
|
+
if (!identifying || !identifying.value) return false;
|
|
52
|
+
return {
|
|
53
|
+
identifier: {
|
|
54
|
+
field: identifying.name,
|
|
55
|
+
value: identifying.value
|
|
56
|
+
},
|
|
57
|
+
scope: Object.fromEntries(ordered.slice(0, -1).map((it) => [it.name, it.value]))
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
build: (params) => buildFn(Object.fromEntries(Object.entries(params).map(([name, value]) => [name, wildcardNames.has(name) ? denormaliseWildcard(value) : value])))
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Normalises the two shapes a mapping's `path` can take.
|
|
65
|
+
*
|
|
66
|
+
* A localized project yields one pattern per locale. A project with no
|
|
67
|
+
* `localization` block yields a plain string, which would otherwise be walked
|
|
68
|
+
* character by character by `Object.entries` and compile into one nonsense
|
|
69
|
+
* resolver per character.
|
|
70
|
+
*
|
|
71
|
+
* @param path The pattern, or per-locale patterns.
|
|
72
|
+
*/ const normalisePath = (path) => typeof path === "string" ? { [DEFAULT_LOCALE_KEY]: path } : path;
|
|
73
|
+
/**
|
|
74
|
+
* Whether a wildcard pattern was handed the site root. `compile` rejects an
|
|
75
|
+
* empty wildcard, so the home page — a document whose identifier is just `/` —
|
|
76
|
+
* has to be built as the bare root instead.
|
|
77
|
+
*
|
|
78
|
+
* @param resolvers The pattern's compiled resolvers.
|
|
79
|
+
* @param values The parameter values, in pattern order.
|
|
80
|
+
*/ const isRootWildcard = (resolvers, values) => resolvers.specificity.hasWildcard && values.every((value) => value === "" || value === "/");
|
|
81
|
+
/**
|
|
82
|
+
* Picks the resolvers for a locale, falling back to the unlocalized bucket.
|
|
83
|
+
*
|
|
84
|
+
* A project without localization has exactly one bucket under
|
|
85
|
+
* {@link DEFAULT_LOCALE_KEY}, so callers pass whatever locale they have and
|
|
86
|
+
* still get the right pattern.
|
|
87
|
+
*
|
|
88
|
+
* @param mapping The compiled mapping.
|
|
89
|
+
* @param locale The locale to resolve for.
|
|
90
|
+
*/ const resolversFor = (mapping, locale) => mapping.resolvers[locale] ?? mapping.resolvers["__default"];
|
|
91
|
+
/**
|
|
92
|
+
* Compiles a mapping's patterns into match and build functions.
|
|
93
|
+
*
|
|
94
|
+
* @param input The mapping as authored.
|
|
95
|
+
*/ const resolveCollectionMapping = (input) => {
|
|
96
|
+
const path = normalisePath(input.path);
|
|
97
|
+
return {
|
|
98
|
+
collection: input.collection,
|
|
99
|
+
path,
|
|
100
|
+
resolvers: Object.fromEntries(Object.entries(path).map(([locale, pattern]) => [locale, createResolvers(pattern)]))
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
//#endregion
|
|
104
|
+
export { isRootWildcard, resolveCollectionMapping, resolversFor };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ParamData } from "path-to-regexp";
|
|
2
|
+
import { Field } from "payload";
|
|
3
|
+
//#region src/pattern/types.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Locale bucket used when a project has no localization configured.
|
|
6
|
+
*
|
|
7
|
+
* Payload only returns a per-locale record for a localized field, and only
|
|
8
|
+
* when the config declares locales at all. Without them the mapping's `path`
|
|
9
|
+
* arrives as a plain string, which normalises into this single bucket so the
|
|
10
|
+
* rest of the package has one shape to work with.
|
|
11
|
+
*/
|
|
12
|
+
declare const DEFAULT_LOCALE_KEY = "__default";
|
|
13
|
+
/**
|
|
14
|
+
* Maps one collection onto the URL patterns its documents live at.
|
|
15
|
+
*
|
|
16
|
+
* A localized project supplies one pattern per locale; a project without
|
|
17
|
+
* localization supplies a single pattern. Authored in the mapping global, so
|
|
18
|
+
* adding a page type never means touching routing code.
|
|
19
|
+
*/
|
|
20
|
+
interface PayloadCollectionMapping {
|
|
21
|
+
collection: string;
|
|
22
|
+
/**
|
|
23
|
+
* Either one pattern per locale (`{ de: "/:format/:slug" }`) or a single
|
|
24
|
+
* pattern for a project with no locales.
|
|
25
|
+
*/
|
|
26
|
+
path: string | Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Result of matching a path against one mapping.
|
|
30
|
+
*/
|
|
31
|
+
interface PayloadCollectionMappingMatch {
|
|
32
|
+
/**
|
|
33
|
+
* The pattern's last parameter, which identifies the document within its
|
|
34
|
+
* collection.
|
|
35
|
+
*/
|
|
36
|
+
identifier: {
|
|
37
|
+
field: string;
|
|
38
|
+
value: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Any earlier parameters, keyed by their raw parameter name. These narrow
|
|
42
|
+
* the lookup without identifying the document — `format` in
|
|
43
|
+
* `/:format/:slug` is one.
|
|
44
|
+
*/
|
|
45
|
+
scope: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* How specific a pattern is, used to order candidates so that the most
|
|
49
|
+
* precise pattern wins regardless of the order rows sit in the admin UI.
|
|
50
|
+
*/
|
|
51
|
+
interface PayloadCollectionMappingSpecificity {
|
|
52
|
+
/** Segments that are literal text rather than a parameter. */
|
|
53
|
+
literalSegments: number;
|
|
54
|
+
/** Whether the pattern ends in a catch-all. */
|
|
55
|
+
hasWildcard: boolean;
|
|
56
|
+
totalSegments: number;
|
|
57
|
+
}
|
|
58
|
+
interface PayloadCollectionMappingResolvers {
|
|
59
|
+
match: (path: string) => false | PayloadCollectionMappingMatch;
|
|
60
|
+
build: (params: ParamData) => string;
|
|
61
|
+
/**
|
|
62
|
+
* Parameter names in pattern order, so callers can collect the values a
|
|
63
|
+
* `build` needs without re-parsing the pattern. The last entry identifies
|
|
64
|
+
* the document.
|
|
65
|
+
*/
|
|
66
|
+
paramNames: string[];
|
|
67
|
+
specificity: PayloadCollectionMappingSpecificity;
|
|
68
|
+
}
|
|
69
|
+
interface PayloadCollectionMappingResolved {
|
|
70
|
+
collection: string;
|
|
71
|
+
/** Always a record after normalisation, keyed by locale or by {@link DEFAULT_LOCALE_KEY}. */
|
|
72
|
+
path: Record<string, string>;
|
|
73
|
+
resolvers: Record<string, PayloadCollectionMappingResolvers>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Rewrites a built path before it is returned.
|
|
77
|
+
*
|
|
78
|
+
* Locale prefixing and preview prefixing are the same transform applied at the
|
|
79
|
+
* same point, so they share one hook rather than competing for two options:
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* formatHref: ({ path, locale }) => `/${locale}${path}`
|
|
83
|
+
* formatHref: ({ path, locale }) => `/${locale}${isPreview ? "/-preview" : ""}${path}`
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* Defaults to returning the path untouched, so a project serving unprefixed
|
|
87
|
+
* URLs needs no configuration.
|
|
88
|
+
*/
|
|
89
|
+
type FormatHref = (args: {
|
|
90
|
+
path: string;
|
|
91
|
+
locale: string;
|
|
92
|
+
}) => string;
|
|
93
|
+
/** Label accepted wherever the admin panel shows one. */
|
|
94
|
+
type LabelLike = string | Record<string, string>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional-everything, including an explicit `undefined`.
|
|
97
|
+
*
|
|
98
|
+
* `Partial<T>` is not enough under `exactOptionalPropertyTypes`: it marks a
|
|
99
|
+
* property optional without letting it hold `undefined`, so a variant that
|
|
100
|
+
* reads one of its own optional fields and passes it straight back would not
|
|
101
|
+
* typecheck against its own declared shape.
|
|
102
|
+
*/
|
|
103
|
+
type Contributed<T> = { [K in keyof T]?: T[K] | undefined; };
|
|
104
|
+
/** The link types the package understands without configuration. */
|
|
105
|
+
type BuiltinLinkVariant = "none" | "reference" | "custom" | "same-page";
|
|
106
|
+
/**
|
|
107
|
+
* Structural shape of the `link` field group.
|
|
108
|
+
*
|
|
109
|
+
* Declared here rather than imported from a project's generated types so the
|
|
110
|
+
* field definition that produces it does not depend on its own output.
|
|
111
|
+
* Nullable throughout because that is how Payload emits optional fields — a
|
|
112
|
+
* mismatch here shows up at every call site.
|
|
113
|
+
*
|
|
114
|
+
* `TVariant` carries any app-declared variant names, and `TExtra` the fields
|
|
115
|
+
* those variants contribute, so a variant's own resolver can read the data its
|
|
116
|
+
* own fields produced without a cast.
|
|
117
|
+
*/
|
|
118
|
+
type LinkFieldData<TVariant extends string = never, TExtra = object> = {
|
|
119
|
+
type?: BuiltinLinkVariant | TVariant | null;
|
|
120
|
+
label?: string | null;
|
|
121
|
+
reference?: {
|
|
122
|
+
relationTo: string;
|
|
123
|
+
value: string | {
|
|
124
|
+
id: string;
|
|
125
|
+
[field: string]: unknown;
|
|
126
|
+
};
|
|
127
|
+
} | null;
|
|
128
|
+
url?: string | null;
|
|
129
|
+
samePageIdentifier?: string | null;
|
|
130
|
+
newTab?: boolean | null;
|
|
131
|
+
} & Contributed<TExtra>;
|
|
132
|
+
/** What every link resolves to, before any variant adds to it. */
|
|
133
|
+
interface BaseResolvedLink {
|
|
134
|
+
href: string;
|
|
135
|
+
target?: string;
|
|
136
|
+
rel?: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A resolved link, widened by whatever an app-declared variant returns.
|
|
140
|
+
*
|
|
141
|
+
* `E` defaults to an empty object rather than `unknown`: an intersection with
|
|
142
|
+
* an uninstantiated type parameter stays deferred, which would force a cast at
|
|
143
|
+
* every built-in branch.
|
|
144
|
+
*
|
|
145
|
+
* Every contributed property is optional, for the same reason it is on
|
|
146
|
+
* {@link LinkFieldData}: `E` is the union of what *every* variant contributes,
|
|
147
|
+
* so requiring all of it would mean each variant had to return the other
|
|
148
|
+
* variants' properties alongside its own.
|
|
149
|
+
*/
|
|
150
|
+
type ResolvedLink<E = object> = BaseResolvedLink & Contributed<E>;
|
|
151
|
+
/**
|
|
152
|
+
* One variant, flattened out of a declaration with its key put back on it.
|
|
153
|
+
*
|
|
154
|
+
* The shape everything downstream of {@link defineLinks} works with. Not an
|
|
155
|
+
* authoring form: variants are written through the builder, which is what
|
|
156
|
+
* derives their field types.
|
|
157
|
+
*/
|
|
158
|
+
interface DeclaredLinkVariant<TCtx = unknown, TExtra = object> {
|
|
159
|
+
value: string;
|
|
160
|
+
label: LabelLike;
|
|
161
|
+
/** Readonly, because a `const` type parameter infers a readonly tuple. */
|
|
162
|
+
fields?: readonly Field[];
|
|
163
|
+
resolve?: (args: {
|
|
164
|
+
link: LinkFieldData<string, TExtra>;
|
|
165
|
+
context: TCtx;
|
|
166
|
+
}) => ResolvedLink<TExtra> | null;
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
export { BaseResolvedLink, BuiltinLinkVariant, Contributed, DEFAULT_LOCALE_KEY, DeclaredLinkVariant, FormatHref, LabelLike, LinkFieldData, PayloadCollectionMapping, PayloadCollectionMappingMatch, PayloadCollectionMappingResolved, PayloadCollectionMappingResolvers, PayloadCollectionMappingSpecificity, ResolvedLink };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/pattern/types.ts
|
|
2
|
+
/**
|
|
3
|
+
* Locale bucket used when a project has no localization configured.
|
|
4
|
+
*
|
|
5
|
+
* Payload only returns a per-locale record for a localized field, and only
|
|
6
|
+
* when the config declares locales at all. Without them the mapping's `path`
|
|
7
|
+
* arrives as a plain string, which normalises into this single bucket so the
|
|
8
|
+
* rest of the package has one shape to work with.
|
|
9
|
+
*/ const DEFAULT_LOCALE_KEY = "__default";
|
|
10
|
+
//#endregion
|
|
11
|
+
export { DEFAULT_LOCALE_KEY };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { FormatHref, PayloadCollectionMappingResolved } from "../pattern/types.mjs";
|
|
2
|
+
import { BuildDiagnosticReason, OnDiagnostic } from "./diagnostics.mjs";
|
|
3
|
+
//#region src/runtime/build-href.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A document as it arrives on a populated relationship.
|
|
6
|
+
*
|
|
7
|
+
* Typed as a plain object rather than an indexed record so generated
|
|
8
|
+
* collection interfaces assign to it; the fields the path pattern names are
|
|
9
|
+
* read dynamically below.
|
|
10
|
+
*/
|
|
11
|
+
type LinkableDocument = object;
|
|
12
|
+
/** Applied when no {@link FormatHref} is supplied: the path, untouched. */
|
|
13
|
+
declare const identityFormatHref: FormatHref;
|
|
14
|
+
interface BuildHrefArgs {
|
|
15
|
+
mappings: PayloadCollectionMappingResolved[];
|
|
16
|
+
collection: string;
|
|
17
|
+
document: LinkableDocument;
|
|
18
|
+
locale: string;
|
|
19
|
+
formatHref?: FormatHref;
|
|
20
|
+
/**
|
|
21
|
+
* The field a populated relationship is identified by, when it cannot be
|
|
22
|
+
* derived from the target's own pattern. Must match what
|
|
23
|
+
* `resolveParamQueryPath` filters on, or build and match disagree.
|
|
24
|
+
*/
|
|
25
|
+
identifierField?: string;
|
|
26
|
+
onDiagnostic?: OnDiagnostic<BuildDiagnosticReason>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Builds the href a document is served at, using its collection's pattern.
|
|
30
|
+
*
|
|
31
|
+
* Returns null when the collection has no mapping or the document is missing a
|
|
32
|
+
* value the pattern needs — a relationship left unpopulated is the usual
|
|
33
|
+
* cause, so check `defaultPopulate` before assuming the mapping is wrong.
|
|
34
|
+
*
|
|
35
|
+
* @param args The mappings, target collection, document and locale.
|
|
36
|
+
*/
|
|
37
|
+
declare const buildHref: (args: BuildHrefArgs) => string | null;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { BuildHrefArgs, LinkableDocument, buildHref, identityFormatHref };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { resolversFor } from "../pattern/resolver.mjs";
|
|
2
|
+
//#region src/runtime/build-href.ts
|
|
3
|
+
/** Applied when no {@link FormatHref} is supplied: the path, untouched. */ const identityFormatHref = ({ path }) => path;
|
|
4
|
+
/**
|
|
5
|
+
* Reads the value a path parameter needs off a document.
|
|
6
|
+
*
|
|
7
|
+
* A plain field yields its own value; a populated relationship yields the
|
|
8
|
+
* related document's identifier, which is what the query matches on.
|
|
9
|
+
*
|
|
10
|
+
* @param document The document being linked to.
|
|
11
|
+
* @param param The path parameter name.
|
|
12
|
+
* @param identifierField The related document's identifying field.
|
|
13
|
+
*/ const readParam = (document, param, identifierField) => {
|
|
14
|
+
const value = document[param];
|
|
15
|
+
if (typeof value === "string") return value;
|
|
16
|
+
if (value && typeof value === "object" && identifierField in value) {
|
|
17
|
+
const identifier = value[identifierField];
|
|
18
|
+
return typeof identifier === "string" ? identifier : void 0;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Builds the href a document is served at, using its collection's pattern.
|
|
23
|
+
*
|
|
24
|
+
* Returns null when the collection has no mapping or the document is missing a
|
|
25
|
+
* value the pattern needs — a relationship left unpopulated is the usual
|
|
26
|
+
* cause, so check `defaultPopulate` before assuming the mapping is wrong.
|
|
27
|
+
*
|
|
28
|
+
* @param args The mappings, target collection, document and locale.
|
|
29
|
+
*/ const buildHref = (args) => {
|
|
30
|
+
const format = args.formatHref ?? identityFormatHref;
|
|
31
|
+
const identifierField = args.identifierField ?? "slug";
|
|
32
|
+
const mapping = args.mappings.find((it) => it.collection === args.collection);
|
|
33
|
+
if (!mapping) {
|
|
34
|
+
args.onDiagnostic?.({
|
|
35
|
+
reason: "no-mapping",
|
|
36
|
+
collection: args.collection
|
|
37
|
+
});
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const resolvers = resolversFor(mapping, args.locale);
|
|
41
|
+
if (!resolvers) {
|
|
42
|
+
args.onDiagnostic?.({
|
|
43
|
+
reason: "no-locale-pattern",
|
|
44
|
+
collection: args.collection,
|
|
45
|
+
locale: args.locale
|
|
46
|
+
});
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const params = {};
|
|
50
|
+
for (const name of resolvers.paramNames) {
|
|
51
|
+
const value = readParam(args.document, name, identifierField);
|
|
52
|
+
if (value === void 0) {
|
|
53
|
+
args.onDiagnostic?.({
|
|
54
|
+
reason: "missing-param",
|
|
55
|
+
collection: args.collection,
|
|
56
|
+
param: name
|
|
57
|
+
});
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
params[name] = value;
|
|
61
|
+
}
|
|
62
|
+
const identifying = resolvers.paramNames.at(-1);
|
|
63
|
+
if (identifying && params[identifying] === "/") return format({
|
|
64
|
+
path: "/",
|
|
65
|
+
locale: args.locale
|
|
66
|
+
});
|
|
67
|
+
return format({
|
|
68
|
+
path: resolvers.build(params),
|
|
69
|
+
locale: args.locale
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { buildHref, identityFormatHref };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FormatHref, PayloadCollectionMappingResolved } from "../pattern/types.mjs";
|
|
2
|
+
import { BuildDiagnosticReason, OnDiagnostic } from "./diagnostics.mjs";
|
|
3
|
+
//#region src/runtime/build-path.d.ts
|
|
4
|
+
interface BuildPathArgs {
|
|
5
|
+
mappings: PayloadCollectionMappingResolved[];
|
|
6
|
+
collection: string;
|
|
7
|
+
locale: string;
|
|
8
|
+
/**
|
|
9
|
+
* Parameter values, either in pattern order or keyed by parameter name.
|
|
10
|
+
* Positional values let a caller fill a pattern without knowing what its
|
|
11
|
+
* parameters are called, so renaming one in the CMS needs no code change.
|
|
12
|
+
*/
|
|
13
|
+
values: string[] | Record<string, string>;
|
|
14
|
+
formatHref?: FormatHref;
|
|
15
|
+
onDiagnostic?: OnDiagnostic<BuildDiagnosticReason>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Builds a path from parameter values alone, with no document.
|
|
19
|
+
*
|
|
20
|
+
* Sitemaps and feeds run outside a request's rendering context and select only
|
|
21
|
+
* the fields they need, so they never hold a document shaped the way
|
|
22
|
+
* `buildHref` expects. They do hold the values, which is all a pattern needs.
|
|
23
|
+
*
|
|
24
|
+
* An unmapped collection falls back to the site root rather than returning
|
|
25
|
+
* nothing: emitting a bare root into a feed is recoverable, emitting an empty
|
|
26
|
+
* href is not.
|
|
27
|
+
*
|
|
28
|
+
* @param args The mappings, collection, locale and parameter values.
|
|
29
|
+
*/
|
|
30
|
+
declare const buildPath: (args: BuildPathArgs) => string;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { BuildPathArgs, buildPath };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isRootWildcard, resolversFor } from "../pattern/resolver.mjs";
|
|
2
|
+
import { identityFormatHref } from "./build-href.mjs";
|
|
3
|
+
//#region src/runtime/build-path.ts
|
|
4
|
+
/**
|
|
5
|
+
* Builds a path from parameter values alone, with no document.
|
|
6
|
+
*
|
|
7
|
+
* Sitemaps and feeds run outside a request's rendering context and select only
|
|
8
|
+
* the fields they need, so they never hold a document shaped the way
|
|
9
|
+
* `buildHref` expects. They do hold the values, which is all a pattern needs.
|
|
10
|
+
*
|
|
11
|
+
* An unmapped collection falls back to the site root rather than returning
|
|
12
|
+
* nothing: emitting a bare root into a feed is recoverable, emitting an empty
|
|
13
|
+
* href is not.
|
|
14
|
+
*
|
|
15
|
+
* @param args The mappings, collection, locale and parameter values.
|
|
16
|
+
*/ const buildPath = (args) => {
|
|
17
|
+
const format = args.formatHref ?? identityFormatHref;
|
|
18
|
+
const root = () => format({
|
|
19
|
+
path: "/",
|
|
20
|
+
locale: args.locale
|
|
21
|
+
});
|
|
22
|
+
const mapping = args.mappings.find((it) => it.collection === args.collection);
|
|
23
|
+
if (!mapping) {
|
|
24
|
+
args.onDiagnostic?.({
|
|
25
|
+
reason: "no-mapping",
|
|
26
|
+
collection: args.collection
|
|
27
|
+
});
|
|
28
|
+
return root();
|
|
29
|
+
}
|
|
30
|
+
const resolvers = resolversFor(mapping, args.locale);
|
|
31
|
+
if (!resolvers) {
|
|
32
|
+
args.onDiagnostic?.({
|
|
33
|
+
reason: "no-locale-pattern",
|
|
34
|
+
collection: args.collection,
|
|
35
|
+
locale: args.locale
|
|
36
|
+
});
|
|
37
|
+
return root();
|
|
38
|
+
}
|
|
39
|
+
const values = args.values;
|
|
40
|
+
const ordered = Array.isArray(values) ? resolvers.paramNames.map((_, index) => values[index] ?? "") : resolvers.paramNames.map((name) => values[name] ?? "");
|
|
41
|
+
if (isRootWildcard(resolvers, ordered)) return root();
|
|
42
|
+
const params = Object.fromEntries(resolvers.paramNames.map((name, index) => [name, ordered[index] ?? ""]));
|
|
43
|
+
return format({
|
|
44
|
+
path: resolvers.build(params),
|
|
45
|
+
locale: args.locale
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { buildPath };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/runtime/diagnostics.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Why a routing call produced nothing.
|
|
4
|
+
*
|
|
5
|
+
* Every failure here returns `null` so the happy path stays a plain value
|
|
6
|
+
* rather than a wrapper type. That leaves no way to tell "this collection has
|
|
7
|
+
* no mapping" from "this relationship was never populated", which is exactly
|
|
8
|
+
* the distinction someone staring at a missing link needs. These reasons carry
|
|
9
|
+
* it out of band.
|
|
10
|
+
*/
|
|
11
|
+
type DiagnosticReason = "no-mapping" | "no-locale-pattern" | "unpopulated-reference" | "missing-param" | "no-document" | "unknown-variant";
|
|
12
|
+
interface Diagnostic<TReason extends DiagnosticReason> {
|
|
13
|
+
reason: TReason;
|
|
14
|
+
collection?: string;
|
|
15
|
+
locale?: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
param?: string;
|
|
18
|
+
variant?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reports why a call returned nothing.
|
|
22
|
+
*
|
|
23
|
+
* Fires once per failed call, so a page whose footer holds forty links to a
|
|
24
|
+
* misconfigured collection reports forty times. Deduplicating is the caller's
|
|
25
|
+
* job — the package cannot know which of them are worth surfacing.
|
|
26
|
+
*/
|
|
27
|
+
type OnDiagnostic<TReason extends DiagnosticReason> = (diagnostic: Diagnostic<TReason>) => void;
|
|
28
|
+
/** Reasons {@link buildHref} and {@link buildPath} can report. */
|
|
29
|
+
type BuildDiagnosticReason = "no-mapping" | "no-locale-pattern" | "missing-param";
|
|
30
|
+
/** Reasons link resolution can report. */
|
|
31
|
+
type ResolveLinkDiagnosticReason = BuildDiagnosticReason | "unpopulated-reference" | "unknown-variant";
|
|
32
|
+
/** Reasons path resolution can report. */
|
|
33
|
+
type ResolvePathDiagnosticReason = "no-mapping" | "no-document";
|
|
34
|
+
//#endregion
|
|
35
|
+
export { BuildDiagnosticReason, Diagnostic, DiagnosticReason, OnDiagnostic, ResolveLinkDiagnosticReason, ResolvePathDiagnosticReason };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { BuildDiagnosticReason, Diagnostic, DiagnosticReason, OnDiagnostic, ResolveLinkDiagnosticReason, ResolvePathDiagnosticReason } from "./diagnostics.mjs";
|
|
2
|
+
import { BuildHrefArgs, LinkableDocument, buildHref, identityFormatHref } from "./build-href.mjs";
|
|
3
|
+
import { BuildPathArgs, buildPath } from "./build-path.mjs";
|
|
4
|
+
import { ResolveLinkArgs, ResolveReference, isAvailableLink, resolveLink } from "./resolve-link.mjs";
|
|
5
|
+
import { PayloadDocument, ResolvePathToDocumentArgs, ResolvePathWhere, ResolvedPath, resolvePathToDocument } from "./resolve-path.mjs";
|
|
6
|
+
import { ResolveRelationshipSlugArgs, resolveRelationshipSlug } from "./resolve-relationship-slug.mjs";
|
|
7
|
+
export { type BuildDiagnosticReason, type BuildHrefArgs, type BuildPathArgs, type Diagnostic, type DiagnosticReason, type LinkableDocument, type OnDiagnostic, type PayloadDocument, type ResolveLinkArgs, type ResolveLinkDiagnosticReason, type ResolvePathDiagnosticReason, type ResolvePathToDocumentArgs, type ResolvePathWhere, type ResolveReference, type ResolveRelationshipSlugArgs, type ResolvedPath, buildHref, buildPath, identityFormatHref, isAvailableLink, resolveLink, resolvePathToDocument, resolveRelationshipSlug };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { buildHref, identityFormatHref } from "./build-href.mjs";
|
|
2
|
+
import { buildPath } from "./build-path.mjs";
|
|
3
|
+
import { isAvailableLink, resolveLink } from "./resolve-link.mjs";
|
|
4
|
+
import { resolvePathToDocument } from "./resolve-path.mjs";
|
|
5
|
+
import { resolveRelationshipSlug } from "./resolve-relationship-slug.mjs";
|
|
6
|
+
export { buildHref, buildPath, identityFormatHref, isAvailableLink, resolveLink, resolvePathToDocument, resolveRelationshipSlug };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BaseResolvedLink, FormatHref, LinkFieldData, PayloadCollectionMappingResolved, ResolvedLink } from "../pattern/types.mjs";
|
|
2
|
+
import { LinkContextOf, LinkDeclaration, LinkVariantSource, ResolvedLinkOf } from "../pattern/define-links.mjs";
|
|
3
|
+
import { OnDiagnostic, ResolveLinkDiagnosticReason } from "./diagnostics.mjs";
|
|
4
|
+
//#region src/runtime/resolve-link.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Turns a reference into an href by some means other than the populated
|
|
7
|
+
* document — an id-to-identifier index, typically.
|
|
8
|
+
*
|
|
9
|
+
* Supplied only by projects that cap relationship depth, where a reference
|
|
10
|
+
* arrives as a bare id. One injected function rather than a second built-in
|
|
11
|
+
* strategy: the package resolves from the populated document, and this is the
|
|
12
|
+
* documented way out for setups that cannot.
|
|
13
|
+
*/
|
|
14
|
+
type ResolveReference = (args: {
|
|
15
|
+
relationTo: string;
|
|
16
|
+
value: string | {
|
|
17
|
+
id: string;
|
|
18
|
+
};
|
|
19
|
+
locale: string;
|
|
20
|
+
}) => string | null;
|
|
21
|
+
interface ResolveLinkArgs<TExtra = object, TDeclaration extends LinkDeclaration = LinkDeclaration> extends LinkVariantSource<TDeclaration> {
|
|
22
|
+
link: LinkFieldData<string, TExtra> | undefined;
|
|
23
|
+
mappings: PayloadCollectionMappingResolved[];
|
|
24
|
+
locale: string;
|
|
25
|
+
/** Passed through to a variant's resolver untouched. */
|
|
26
|
+
/**
|
|
27
|
+
* Passed through to a variant's resolver untouched.
|
|
28
|
+
*
|
|
29
|
+
* When a declaration is supplied its resolvers already say what they
|
|
30
|
+
* expect, so that shape wins over the array form's own parameter: without
|
|
31
|
+
* this, any context at all would satisfy a declaration-based call.
|
|
32
|
+
*/
|
|
33
|
+
context?: LinkContextOf<TDeclaration>;
|
|
34
|
+
formatHref?: FormatHref;
|
|
35
|
+
identifierField?: string;
|
|
36
|
+
resolveReference?: ResolveReference;
|
|
37
|
+
onDiagnostic?: OnDiagnostic<ResolveLinkDiagnosticReason>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Turns a link field's value into an href.
|
|
41
|
+
*
|
|
42
|
+
* Internal references route through the collection mapping rather than any
|
|
43
|
+
* hardcoded prefix, so changing a collection's URL pattern in the CMS updates
|
|
44
|
+
* every link to it.
|
|
45
|
+
*
|
|
46
|
+
* Returns null rather than degrading to the site root. A link that silently
|
|
47
|
+
* points somewhere plausible is harder to find than one that renders nothing.
|
|
48
|
+
*
|
|
49
|
+
* @param args The link value, mappings, locale and any app-declared variants.
|
|
50
|
+
*/
|
|
51
|
+
declare const resolveLink: <TExtra = object, TDeclaration extends LinkDeclaration = LinkDeclaration>(args: ResolveLinkArgs<TExtra, TDeclaration>) => BaseResolvedLink | ResolvedLink<TExtra> | ResolvedLinkOf<TDeclaration> | null;
|
|
52
|
+
/**
|
|
53
|
+
* Whether a link would resolve to something navigable. Use it to decide
|
|
54
|
+
* whether to render a link at all, rather than emitting a dead anchor.
|
|
55
|
+
*
|
|
56
|
+
* @param args The same arguments as {@link resolveLink}, plus whether a label
|
|
57
|
+
* is required for the link to be worth rendering.
|
|
58
|
+
*/
|
|
59
|
+
declare const isAvailableLink: <TExtra = object, TDeclaration extends LinkDeclaration = LinkDeclaration>(args: ResolveLinkArgs<TExtra, TDeclaration> & {
|
|
60
|
+
withLabel?: boolean;
|
|
61
|
+
}) => boolean;
|
|
62
|
+
//#endregion
|
|
63
|
+
export { ResolveLinkArgs, ResolveReference, isAvailableLink, resolveLink };
|