@bison-lab/payload-core 0.1.0
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/README.md +128 -0
- package/dist/index.d.mts +106 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +81 -0
- package/dist/index.mjs.map +1 -0
- package/dist/metadata.d.mts +70 -0
- package/dist/metadata.d.mts.map +1 -0
- package/dist/metadata.mjs +61 -0
- package/dist/metadata.mjs.map +1 -0
- package/dist/share-image-C4ILz4p2.mjs +37 -0
- package/dist/share-image-C4ILz4p2.mjs.map +1 -0
- package/dist/title-Wut0nzJQ.d.mts +55 -0
- package/dist/title-Wut0nzJQ.d.mts.map +1 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# `@bison-lab/payload-core`
|
|
2
|
+
|
|
3
|
+
Site-agnostic Payload CMS configuration for Bison Lab sites. Today: the SEO
|
|
4
|
+
tab on a collection and the metadata reader for the pages it describes. The
|
|
5
|
+
rest of the shared CMS layer (access matrix, collection factories, slug field)
|
|
6
|
+
joins it here as it is extracted.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @bison-lab/payload-core @payloadcms/plugin-seo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Peers: `payload` and `@payloadcms/plugin-seo`. Pin the plugin to the same
|
|
13
|
+
version as `payload`; Payload releases them in lockstep.
|
|
14
|
+
|
|
15
|
+
## Two entry points, and why
|
|
16
|
+
|
|
17
|
+
| Import | Contents | Runs where |
|
|
18
|
+
| ---------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- |
|
|
19
|
+
| `@bison-lab/payload-core` | `seoPlugin`, `noIndexField`, `SHARE_IMAGE_SIZE`, title and text helpers, types | Node. What `payload.config.ts` imports; it loads the plugin. |
|
|
20
|
+
| `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. |
|
|
21
|
+
|
|
22
|
+
## What editors get
|
|
23
|
+
|
|
24
|
+
An **SEO** tab beside the page's **Content** tab, never below the block
|
|
25
|
+
editor: an overview with character counts, meta title and description with
|
|
26
|
+
Generate buttons, a share image from the media collection with its own
|
|
27
|
+
Generate button when the site can name an image already on the page, a
|
|
28
|
+
search-result preview, and a **Hide this page from search engines** switch,
|
|
29
|
+
off by default.
|
|
30
|
+
|
|
31
|
+
## Wiring a site
|
|
32
|
+
|
|
33
|
+
**1. Configure the plugin.** Every string it generates comes from here, so
|
|
34
|
+
the site spells its name and its URL scheme once.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// payload.config.ts
|
|
38
|
+
import { firstImageIn, seoPlugin } from "@bison-lab/payload-core";
|
|
39
|
+
import type { Page } from "@/payload-types";
|
|
40
|
+
|
|
41
|
+
export default buildConfig({
|
|
42
|
+
plugins: [
|
|
43
|
+
seoPlugin<Page>({
|
|
44
|
+
siteName: "Acme Clinics",
|
|
45
|
+
urlFor: (doc) => (doc.slug ? absoluteUrl(pagePath(doc.slug)) : undefined),
|
|
46
|
+
describeFrom: (doc) => doc.hero?.[0]?.body,
|
|
47
|
+
imageFor: (doc) =>
|
|
48
|
+
firstImageIn([...(doc.hero ?? []), ...(doc.layout ?? [])]),
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`collections` defaults to `['pages']` and `uploadsCollection` to `'media'`.
|
|
55
|
+
`urlFor` answering `undefined` leaves the preview empty rather than pointing
|
|
56
|
+
it at the home page while the slug is still blank. `describeFrom` and
|
|
57
|
+
`imageFor` are optional; without them those Generate buttons fill in nothing
|
|
58
|
+
(the image button does not appear at all).
|
|
59
|
+
|
|
60
|
+
**2. Cut the share rendition.** Add `SHARE_IMAGE_SIZE` to the upload
|
|
61
|
+
collection so every image gets a 1200x630 card:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { SHARE_IMAGE_SIZE } from "@bison-lab/payload-core";
|
|
65
|
+
|
|
66
|
+
export const Media: CollectionConfig = {
|
|
67
|
+
slug: "media",
|
|
68
|
+
upload: { imageSizes: [SHARE_IMAGE_SIZE] },
|
|
69
|
+
fields: [{ name: "alt", type: "text", required: true }],
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then `payload generate:types`, `payload generate:importmap` (the plugin's
|
|
74
|
+
admin components resolve by string key from the site's import map) and
|
|
75
|
+
`payload migrate:create`.
|
|
76
|
+
|
|
77
|
+
**3. Build the site's title template from the same helper.** Next applies
|
|
78
|
+
`title.template` to `<title>` alone, and `pageMetadata` needs the completed
|
|
79
|
+
title for Open Graph, so both read one spelling:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// app/layout.tsx
|
|
83
|
+
import { titleTemplate } from "@bison-lab/payload-core/metadata";
|
|
84
|
+
|
|
85
|
+
export const metadata: Metadata = {
|
|
86
|
+
title: { default: SITE_NAME, template: titleTemplate(SITE_NAME) },
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**4. Read the tab in the page route.**
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// app/[...slug]/page.tsx
|
|
94
|
+
import { pageMetadata } from "@bison-lab/payload-core/metadata";
|
|
95
|
+
|
|
96
|
+
export async function generateMetadata({ params }): Promise<Metadata> {
|
|
97
|
+
const page = await getPage(params);
|
|
98
|
+
if (!page) return {};
|
|
99
|
+
return pageMetadata(page, {
|
|
100
|
+
siteName: SITE_NAME,
|
|
101
|
+
canonical: absoluteUrl(pagePath(page.slug)),
|
|
102
|
+
absoluteUrl,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`pageMetadata` returns `<title>`, description, canonical, Open Graph and
|
|
108
|
+
Twitter with the share image (falling back to the original file when the
|
|
109
|
+
rendition is missing), and `noindex, nofollow` when the page is hidden. A
|
|
110
|
+
written meta title is `absolute`, so the layout's template does not append
|
|
111
|
+
the site name twice. Fields the editor left empty are omitted rather than
|
|
112
|
+
set to `undefined`, so the layout's own description and default share image
|
|
113
|
+
carry through Next's metadata merge.
|
|
114
|
+
|
|
115
|
+
The sitemap is the site's: filter `meta.noIndex` out of it.
|
|
116
|
+
|
|
117
|
+
## No generated types
|
|
118
|
+
|
|
119
|
+
The package cannot import a site's `payload-types`, so the shapes it reads
|
|
120
|
+
(`SeoMeta`, `SeoImageDoc`, `SeoPage`) are hand-written and structural. A
|
|
121
|
+
generated `Page` and `Media` are assignable to them; nothing carries an index
|
|
122
|
+
signature, since an interface will not assign to a type that has one.
|
|
123
|
+
|
|
124
|
+
## Changing a field
|
|
125
|
+
|
|
126
|
+
`noIndexField` and the plugin's own fields are columns in every consuming
|
|
127
|
+
site. A change to them is a schema change: say "run `payload migrate:create`"
|
|
128
|
+
in the changeset.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { a as SeoImageSize, i as SeoImageDoc, n as titleTemplate, o as SeoImageValue, r as MediaId, s as SeoMeta, t as documentTitle } from "./title-Wut0nzJQ.mjs";
|
|
2
|
+
import { CheckboxField, CollectionSlug, Plugin, UploadCollectionSlug } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/seo/plugin.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* What the plugin reads off a document: its title, for the generated meta
|
|
7
|
+
* title. The site's generated `Page` is assignable to this; the `urlFor`,
|
|
8
|
+
* `describeFrom` and `imageFor` callbacks read the rest, typed as the site
|
|
9
|
+
* chooses through `TDoc`.
|
|
10
|
+
*/
|
|
11
|
+
interface SeoDoc {
|
|
12
|
+
title?: string | null;
|
|
13
|
+
}
|
|
14
|
+
interface SeoPluginOptions<TDoc extends SeoDoc = SeoDoc> {
|
|
15
|
+
/** Appended to every generated title: `<page title> | <siteName>`. */
|
|
16
|
+
siteName: string;
|
|
17
|
+
/**
|
|
18
|
+
* The absolute public URL of a document from the form's data, for the
|
|
19
|
+
* search-result preview and the Generate button beside it. `undefined`
|
|
20
|
+
* when the document has no address yet (a slug not typed), which leaves
|
|
21
|
+
* the preview empty rather than pointing at the home page.
|
|
22
|
+
*/
|
|
23
|
+
urlFor: (doc: TDoc) => string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Prose to cut a generated description from, the first plain-text field a
|
|
26
|
+
* page always has near the top (a hero's body, say). Cut at a word near
|
|
27
|
+
* 155 characters. Without it the Generate button fills in nothing.
|
|
28
|
+
*/
|
|
29
|
+
describeFrom?: (doc: TDoc) => string | null | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* An image already on the page, for the Meta Image Generate button, so an
|
|
32
|
+
* editor need not upload a second copy of the hero picture. `firstImageIn`
|
|
33
|
+
* walks block rows for one. The upload chooser stays, so any other media
|
|
34
|
+
* row can still be picked.
|
|
35
|
+
*/
|
|
36
|
+
imageFor?: (doc: TDoc) => MediaId | null | undefined;
|
|
37
|
+
/** Collections that get the SEO tab. Default `['pages']`. */
|
|
38
|
+
collections?: CollectionSlug[];
|
|
39
|
+
/** The upload collection the meta image comes from. Default `'media'`. */
|
|
40
|
+
uploadsCollection?: UploadCollectionSlug;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `@payloadcms/plugin-seo` configured the Bison Lab way: an SEO tab beside a
|
|
44
|
+
* Content tab (never below the block editor), holding the overview with its
|
|
45
|
+
* character counts, meta title, description and image with Generate buttons,
|
|
46
|
+
* the search-result preview, and the `noIndex` switch.
|
|
47
|
+
*
|
|
48
|
+
* Every string the tab generates comes from the options, so a site spells
|
|
49
|
+
* its name and its URL scheme once. Pin `@payloadcms/plugin-seo` to the same
|
|
50
|
+
* version as `payload` in the site; the plugin's admin components are
|
|
51
|
+
* resolved from the site's import map, so run `payload generate:importmap`
|
|
52
|
+
* after adding it.
|
|
53
|
+
*/
|
|
54
|
+
declare function seoPlugin<TDoc extends SeoDoc = SeoDoc>({
|
|
55
|
+
siteName,
|
|
56
|
+
urlFor,
|
|
57
|
+
describeFrom,
|
|
58
|
+
imageFor,
|
|
59
|
+
collections,
|
|
60
|
+
uploadsCollection
|
|
61
|
+
}: SeoPluginOptions<TDoc>): Plugin;
|
|
62
|
+
/**
|
|
63
|
+
* The first image among some block rows, as a media id, for `imageFor`. Each
|
|
64
|
+
* row is checked for `field` holding either a bare id or a populated upload
|
|
65
|
+
* document; rows without one are skipped. Pass the page's hero and layout
|
|
66
|
+
* together (`[...doc.hero, ...doc.layout]`) to search in reading order.
|
|
67
|
+
*/
|
|
68
|
+
declare function firstImageIn(blocks: unknown, field?: string): MediaId | undefined;
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/seo/fields.d.ts
|
|
71
|
+
/**
|
|
72
|
+
* The index switch, last in the SEO tab. Off by default: a page is public
|
|
73
|
+
* unless an editor says otherwise. `pageMetadata` turns it into
|
|
74
|
+
* `noindex, nofollow`; a site's sitemap should filter on it too.
|
|
75
|
+
*/
|
|
76
|
+
declare const noIndexField: CheckboxField;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/seo/share-image.d.ts
|
|
79
|
+
/**
|
|
80
|
+
* The rendition a media collection cuts for social share cards: 1.91:1 at the
|
|
81
|
+
* size Open Graph consumers ask for, cropped to shape rather than letterboxed
|
|
82
|
+
* so a portrait upload still fills the card. Add it to the upload
|
|
83
|
+
* collection's `imageSizes`; `pageMetadata` reads `sizes.share` off a page's
|
|
84
|
+
* meta image and falls back to the original for a file uploaded before the
|
|
85
|
+
* size existed, or one too small to cut (Payload does not upscale).
|
|
86
|
+
*/
|
|
87
|
+
declare const SHARE_IMAGE_SIZE: {
|
|
88
|
+
name: string;
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
position: string;
|
|
92
|
+
};
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/seo/text.d.ts
|
|
95
|
+
/** The length a search result shows of a description before it is cut. */
|
|
96
|
+
declare const DESCRIPTION_LENGTH = 155;
|
|
97
|
+
/**
|
|
98
|
+
* Prose cut to `max` characters at a word, with an ellipsis, so a generated
|
|
99
|
+
* description does not end mid-syllable. Text that fits is returned trimmed
|
|
100
|
+
* and whole. A first word longer than `max` is cut mid-word, since there is
|
|
101
|
+
* no boundary to cut at.
|
|
102
|
+
*/
|
|
103
|
+
declare function truncateAtWord(text: string, max?: number): string;
|
|
104
|
+
//#endregion
|
|
105
|
+
export { DESCRIPTION_LENGTH, type MediaId, SHARE_IMAGE_SIZE, type SeoDoc, type SeoImageDoc, type SeoImageSize, type SeoImageValue, type SeoMeta, type SeoPluginOptions, documentTitle, firstImageIn, noIndexField, seoPlugin, titleTemplate, truncateAtWord };
|
|
106
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/seo/plugin.ts","../src/seo/fields.ts","../src/seo/share-image.ts","../src/seo/text.ts"],"mappings":";;;;;;AAoBA;;;;UAAiB,MAAA;EACf,KAAA;AAAA;AAAA,UAGe,gBAAA,cAA8B,MAAA,GAAS,MAAA;EAAT;EAE7C,QAAA;EAOc;;;;;;EAAd,MAAA,GAAS,GAAA,EAAK,IAAA;EAiB0B;;;;;EAXxC,YAAA,IAAgB,GAAA,EAAK,IAAA;EANP;;;;;;EAad,QAAA,IAAY,GAAA,EAAK,IAAA,KAAS,OAAA;EAAd;EAEZ,WAAA,GAAc,cAAA;EAAd;EAEA,iBAAA,GAAoB,oBAAA;AAAA;;;;AAetB;;;;;;;;;iBAAgB,SAAA,cAAuB,MAAA,GAAS,MAAA,CAAA,CAAA;EAC9C,QAAA;EACA,MAAA;EACA,YAAA;EACA,QAAA;EACA,WAAA;EACA;AAAA,GACC,gBAAA,CAAiB,IAAA,IAAQ,MAAA;;;;;;;iBA6BZ,YAAA,CACd,MAAA,WACA,KAAA,YACC,OAAA;;;;;;AApFH;;cCba,YAAA,EAAc,aAAA;;;;;;;ADa3B;;;;cEVa,gBAAA;;;;;;;;;cCTA,kBAAA;;;AHmBb;;;;iBGXgB,cAAA,CAAe,IAAA,UAAc,GAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { n as documentTitle, r as titleTemplate, t as SHARE_IMAGE_SIZE } from "./share-image-C4ILz4p2.mjs";
|
|
2
|
+
import { seoPlugin as seoPlugin$1 } from "@payloadcms/plugin-seo";
|
|
3
|
+
//#region src/seo/fields.ts
|
|
4
|
+
/**
|
|
5
|
+
* The index switch, last in the SEO tab. Off by default: a page is public
|
|
6
|
+
* unless an editor says otherwise. `pageMetadata` turns it into
|
|
7
|
+
* `noindex, nofollow`; a site's sitemap should filter on it too.
|
|
8
|
+
*/
|
|
9
|
+
const noIndexField = {
|
|
10
|
+
name: "noIndex",
|
|
11
|
+
type: "checkbox",
|
|
12
|
+
label: "Hide this page from search engines",
|
|
13
|
+
defaultValue: false,
|
|
14
|
+
admin: { description: "Search engines will not list this page and the sitemap will leave it out. Anyone with the link can still open it." }
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/seo/text.ts
|
|
18
|
+
/** The length a search result shows of a description before it is cut. */
|
|
19
|
+
const DESCRIPTION_LENGTH = 155;
|
|
20
|
+
/**
|
|
21
|
+
* Prose cut to `max` characters at a word, with an ellipsis, so a generated
|
|
22
|
+
* description does not end mid-syllable. Text that fits is returned trimmed
|
|
23
|
+
* and whole. A first word longer than `max` is cut mid-word, since there is
|
|
24
|
+
* no boundary to cut at.
|
|
25
|
+
*/
|
|
26
|
+
function truncateAtWord(text, max = 155) {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (trimmed.length <= max) return trimmed;
|
|
29
|
+
const cut = trimmed.slice(0, max);
|
|
30
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
31
|
+
return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/seo/plugin.ts
|
|
35
|
+
/**
|
|
36
|
+
* `@payloadcms/plugin-seo` configured the Bison Lab way: an SEO tab beside a
|
|
37
|
+
* Content tab (never below the block editor), holding the overview with its
|
|
38
|
+
* character counts, meta title, description and image with Generate buttons,
|
|
39
|
+
* the search-result preview, and the `noIndex` switch.
|
|
40
|
+
*
|
|
41
|
+
* Every string the tab generates comes from the options, so a site spells
|
|
42
|
+
* its name and its URL scheme once. Pin `@payloadcms/plugin-seo` to the same
|
|
43
|
+
* version as `payload` in the site; the plugin's admin components are
|
|
44
|
+
* resolved from the site's import map, so run `payload generate:importmap`
|
|
45
|
+
* after adding it.
|
|
46
|
+
*/
|
|
47
|
+
function seoPlugin({ siteName, urlFor, describeFrom, imageFor, collections = ["pages"], uploadsCollection = "media" }) {
|
|
48
|
+
const generateTitle = ({ doc }) => doc?.title ? documentTitle(siteName, doc.title) : "";
|
|
49
|
+
const generateDescription = ({ doc }) => truncateAtWord(describeFrom?.(doc) ?? "");
|
|
50
|
+
const generateURL = ({ doc }) => urlFor(doc) ?? "";
|
|
51
|
+
const generateImage = imageFor ? ({ doc }) => imageFor(doc) ?? "" : void 0;
|
|
52
|
+
return seoPlugin$1({
|
|
53
|
+
collections,
|
|
54
|
+
uploadsCollection,
|
|
55
|
+
tabbedUI: true,
|
|
56
|
+
generateTitle,
|
|
57
|
+
generateDescription,
|
|
58
|
+
generateURL,
|
|
59
|
+
...generateImage ? { generateImage } : {},
|
|
60
|
+
fields: ({ defaultFields }) => [...defaultFields, noIndexField]
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The first image among some block rows, as a media id, for `imageFor`. Each
|
|
65
|
+
* row is checked for `field` holding either a bare id or a populated upload
|
|
66
|
+
* document; rows without one are skipped. Pass the page's hero and layout
|
|
67
|
+
* together (`[...doc.hero, ...doc.layout]`) to search in reading order.
|
|
68
|
+
*/
|
|
69
|
+
function firstImageIn(blocks, field = "image") {
|
|
70
|
+
if (!Array.isArray(blocks)) return void 0;
|
|
71
|
+
for (const block of blocks) {
|
|
72
|
+
if (typeof block !== "object" || block === null) continue;
|
|
73
|
+
const value = block[field];
|
|
74
|
+
const id = typeof value === "object" && value !== null && "id" in value ? value.id : value;
|
|
75
|
+
if (typeof id === "number" || typeof id === "string" && id !== "") return id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { DESCRIPTION_LENGTH, SHARE_IMAGE_SIZE, documentTitle, firstImageIn, noIndexField, seoPlugin, titleTemplate, truncateAtWord };
|
|
80
|
+
|
|
81
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["payloadSeoPlugin"],"sources":["../src/seo/fields.ts","../src/seo/text.ts","../src/seo/plugin.ts"],"sourcesContent":["import type { CheckboxField } from \"payload\";\n\n/**\n * The index switch, last in the SEO tab. Off by default: a page is public\n * unless an editor says otherwise. `pageMetadata` turns it into\n * `noindex, nofollow`; a site's sitemap should filter on it too.\n */\nexport const noIndexField: CheckboxField = {\n name: \"noIndex\",\n type: \"checkbox\",\n label: \"Hide this page from search engines\",\n defaultValue: false,\n admin: {\n description:\n \"Search engines will not list this page and the sitemap will leave it out. Anyone with the link can still open it.\",\n },\n};\n","/** The length a search result shows of a description before it is cut. */\nexport const DESCRIPTION_LENGTH = 155;\n\n/**\n * Prose cut to `max` characters at a word, with an ellipsis, so a generated\n * description does not end mid-syllable. Text that fits is returned trimmed\n * and whole. A first word longer than `max` is cut mid-word, since there is\n * no boundary to cut at.\n */\nexport function truncateAtWord(text: string, max = DESCRIPTION_LENGTH): string {\n const trimmed = text.trim();\n if (trimmed.length <= max) return trimmed;\n const cut = trimmed.slice(0, max);\n const lastSpace = cut.lastIndexOf(\" \");\n return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;\n}\n","import { seoPlugin as payloadSeoPlugin } from \"@payloadcms/plugin-seo\";\nimport type {\n GenerateDescription,\n GenerateImage,\n GenerateTitle,\n GenerateURL,\n} from \"@payloadcms/plugin-seo/types\";\nimport type { CollectionSlug, Plugin, UploadCollectionSlug } from \"payload\";\n\nimport { noIndexField } from \"./fields\";\nimport { truncateAtWord } from \"./text\";\nimport { documentTitle } from \"./title\";\nimport type { MediaId } from \"./types\";\n\n/**\n * What the plugin reads off a document: its title, for the generated meta\n * title. The site's generated `Page` is assignable to this; the `urlFor`,\n * `describeFrom` and `imageFor` callbacks read the rest, typed as the site\n * chooses through `TDoc`.\n */\nexport interface SeoDoc {\n title?: string | null;\n}\n\nexport interface SeoPluginOptions<TDoc extends SeoDoc = SeoDoc> {\n /** Appended to every generated title: `<page title> | <siteName>`. */\n siteName: string;\n /**\n * The absolute public URL of a document from the form's data, for the\n * search-result preview and the Generate button beside it. `undefined`\n * when the document has no address yet (a slug not typed), which leaves\n * the preview empty rather than pointing at the home page.\n */\n urlFor: (doc: TDoc) => string | undefined;\n /**\n * Prose to cut a generated description from, the first plain-text field a\n * page always has near the top (a hero's body, say). Cut at a word near\n * 155 characters. Without it the Generate button fills in nothing.\n */\n describeFrom?: (doc: TDoc) => string | null | undefined;\n /**\n * An image already on the page, for the Meta Image Generate button, so an\n * editor need not upload a second copy of the hero picture. `firstImageIn`\n * walks block rows for one. The upload chooser stays, so any other media\n * row can still be picked.\n */\n imageFor?: (doc: TDoc) => MediaId | null | undefined;\n /** Collections that get the SEO tab. Default `['pages']`. */\n collections?: CollectionSlug[];\n /** The upload collection the meta image comes from. Default `'media'`. */\n uploadsCollection?: UploadCollectionSlug;\n}\n\n/**\n * `@payloadcms/plugin-seo` configured the Bison Lab way: an SEO tab beside a\n * Content tab (never below the block editor), holding the overview with its\n * character counts, meta title, description and image with Generate buttons,\n * the search-result preview, and the `noIndex` switch.\n *\n * Every string the tab generates comes from the options, so a site spells\n * its name and its URL scheme once. Pin `@payloadcms/plugin-seo` to the same\n * version as `payload` in the site; the plugin's admin components are\n * resolved from the site's import map, so run `payload generate:importmap`\n * after adding it.\n */\nexport function seoPlugin<TDoc extends SeoDoc = SeoDoc>({\n siteName,\n urlFor,\n describeFrom,\n imageFor,\n collections = [\"pages\"],\n uploadsCollection = \"media\",\n}: SeoPluginOptions<TDoc>): Plugin {\n const generateTitle: GenerateTitle<TDoc> = ({ doc }) =>\n doc?.title ? documentTitle(siteName, doc.title) : \"\";\n const generateDescription: GenerateDescription<TDoc> = ({ doc }) =>\n truncateAtWord(describeFrom?.(doc) ?? \"\");\n const generateURL: GenerateURL<TDoc> = ({ doc }) => urlFor(doc) ?? \"\";\n // Only when the site can name one: the button appears with the function.\n const generateImage: GenerateImage<TDoc> | undefined = imageFor\n ? ({ doc }) => imageFor(doc) ?? \"\"\n : undefined;\n\n return payloadSeoPlugin({\n collections,\n uploadsCollection,\n tabbedUI: true,\n generateTitle,\n generateDescription,\n generateURL,\n ...(generateImage ? { generateImage } : {}),\n fields: ({ defaultFields }) => [...defaultFields, noIndexField],\n });\n}\n\n/**\n * The first image among some block rows, as a media id, for `imageFor`. Each\n * row is checked for `field` holding either a bare id or a populated upload\n * document; rows without one are skipped. Pass the page's hero and layout\n * together (`[...doc.hero, ...doc.layout]`) to search in reading order.\n */\nexport function firstImageIn(\n blocks: unknown,\n field = \"image\",\n): MediaId | undefined {\n if (!Array.isArray(blocks)) return undefined;\n for (const block of blocks) {\n if (typeof block !== \"object\" || block === null) continue;\n const value = (block as Record<string, unknown>)[field];\n const id =\n typeof value === \"object\" && value !== null && \"id\" in value\n ? (value as { id: unknown }).id\n : value;\n if (typeof id === \"number\" || (typeof id === \"string\" && id !== \"\"))\n return id;\n }\n return undefined;\n}\n"],"mappings":";;;;;;;;AAOA,MAAa,eAA8B;CACzC,MAAM;CACN,MAAM;CACN,OAAO;CACP,cAAc;CACd,OAAO,EACL,aACE,qHACH;CACF;;;;ACfD,MAAa,qBAAqB;;;;;;;AAQlC,SAAgB,eAAe,MAAc,MAAA,KAAkC;CAC7E,MAAM,UAAU,KAAK,MAAM;AAC3B,KAAI,QAAQ,UAAU,IAAK,QAAO;CAClC,MAAM,MAAM,QAAQ,MAAM,GAAG,IAAI;CACjC,MAAM,YAAY,IAAI,YAAY,IAAI;AACtC,QAAO,GAAG,YAAY,IAAI,IAAI,MAAM,GAAG,UAAU,GAAG,IAAI;;;;;;;;;;;;;;;;ACmD1D,SAAgB,UAAwC,EACtD,UACA,QACA,cACA,UACA,cAAc,CAAC,QAAQ,EACvB,oBAAoB,WACa;CACjC,MAAM,iBAAsC,EAAE,UAC5C,KAAK,QAAQ,cAAc,UAAU,IAAI,MAAM,GAAG;CACpD,MAAM,uBAAkD,EAAE,UACxD,eAAe,eAAe,IAAI,IAAI,GAAG;CAC3C,MAAM,eAAkC,EAAE,UAAU,OAAO,IAAI,IAAI;CAEnE,MAAM,gBAAiD,YAClD,EAAE,UAAU,SAAS,IAAI,IAAI,KAC9B,KAAA;AAEJ,QAAOA,YAAiB;EACtB;EACA;EACA,UAAU;EACV;EACA;EACA;EACA,GAAI,gBAAgB,EAAE,eAAe,GAAG,EAAE;EAC1C,SAAS,EAAE,oBAAoB,CAAC,GAAG,eAAe,aAAa;EAChE,CAAC;;;;;;;;AASJ,SAAgB,aACd,QACA,QAAQ,SACa;AACrB,KAAI,CAAC,MAAM,QAAQ,OAAO,CAAE,QAAO,KAAA;AACnC,MAAK,MAAM,SAAS,QAAQ;AAC1B,MAAI,OAAO,UAAU,YAAY,UAAU,KAAM;EACjD,MAAM,QAAS,MAAkC;EACjD,MAAM,KACJ,OAAO,UAAU,YAAY,UAAU,QAAQ,QAAQ,QAClD,MAA0B,KAC3B;AACN,MAAI,OAAO,OAAO,YAAa,OAAO,OAAO,YAAY,OAAO,GAC9D,QAAO"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { a as SeoImageSize, i as SeoImageDoc, n as titleTemplate, o as SeoImageValue, r as MediaId, s as SeoMeta, t as documentTitle } from "./title-Wut0nzJQ.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/seo/metadata.d.ts
|
|
4
|
+
/** A page as the reader needs it; a site's generated `Page` is assignable. */
|
|
5
|
+
interface SeoPage {
|
|
6
|
+
title: string;
|
|
7
|
+
meta?: SeoMeta | null;
|
|
8
|
+
}
|
|
9
|
+
interface PageMetadataOptions {
|
|
10
|
+
/** The suffix a page without a written meta title gets. */
|
|
11
|
+
siteName: string;
|
|
12
|
+
/** This page's absolute URL at the site's own origin. */
|
|
13
|
+
canonical: string;
|
|
14
|
+
/** Turns a media file path into an absolute URL; crawlers take no other kind. */
|
|
15
|
+
absoluteUrl: (path: string) => string;
|
|
16
|
+
/** The rendition to read off the meta image. Default `SHARE_IMAGE_SIZE.name`. */
|
|
17
|
+
shareSize?: string;
|
|
18
|
+
}
|
|
19
|
+
interface PageMetadataImage {
|
|
20
|
+
url: string;
|
|
21
|
+
width?: number;
|
|
22
|
+
height?: number;
|
|
23
|
+
alt?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The shape `pageMetadata` returns, assignable to Next's `Metadata` without
|
|
27
|
+
* this package depending on `next`. A written meta title is `absolute` so
|
|
28
|
+
* the layout's title template does not append the site name a second time;
|
|
29
|
+
* an unwritten one is the bare page title, which the template completes.
|
|
30
|
+
* Optional keys are absent rather than `undefined`: Next's merge lets an
|
|
31
|
+
* `undefined` key override the layout's value instead of inheriting it.
|
|
32
|
+
*/
|
|
33
|
+
interface PageMetadata {
|
|
34
|
+
title: string | {
|
|
35
|
+
absolute: string;
|
|
36
|
+
};
|
|
37
|
+
description?: string;
|
|
38
|
+
alternates: {
|
|
39
|
+
canonical: string;
|
|
40
|
+
};
|
|
41
|
+
openGraph: {
|
|
42
|
+
title: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
url: string;
|
|
45
|
+
images?: PageMetadataImage[];
|
|
46
|
+
};
|
|
47
|
+
twitter: {
|
|
48
|
+
card: "summary_large_image";
|
|
49
|
+
title: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
images?: PageMetadataImage[];
|
|
52
|
+
};
|
|
53
|
+
robots?: {
|
|
54
|
+
index: false;
|
|
55
|
+
follow: false;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Next metadata for a page with the SEO tab: `<title>`, description,
|
|
60
|
+
* canonical, Open Graph and Twitter with the share image, and
|
|
61
|
+
* `noindex, nofollow` when the page is hidden. Fields the editor left empty
|
|
62
|
+
* are left out, so the layout's defaults (a site-wide description, a default
|
|
63
|
+
* share image) carry through Next's merge. The canonical is always this
|
|
64
|
+
* site's origin; a page that must canonicalise elsewhere is a redirect, not
|
|
65
|
+
* a field.
|
|
66
|
+
*/
|
|
67
|
+
declare function pageMetadata(page: SeoPage, options: PageMetadataOptions): PageMetadata;
|
|
68
|
+
//#endregion
|
|
69
|
+
export { type MediaId, type PageMetadata, type PageMetadataImage, type PageMetadataOptions, type SeoImageDoc, type SeoImageSize, type SeoImageValue, type SeoMeta, type SeoPage, documentTitle, pageMetadata, titleTemplate };
|
|
70
|
+
//# sourceMappingURL=metadata.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.d.mts","names":[],"sources":["../src/seo/metadata.ts"],"mappings":";;;;UAKiB,OAAA;EACf,KAAA;EACA,IAAA,GAAO,OAAA;AAAA;AAAA,UAGQ,mBAAA;EAJf;EAMA,QAAA;EALO;EAOP,SAAA;EAPc;EASd,WAAA,GAAc,IAAA;EANoB;EAQlC,SAAA;AAAA;AAAA,UAGe,iBAAA;EACf,GAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;AAAA;;AAJF;;;;;;;UAeiB,YAAA;EACf,KAAA;IAAkB,QAAA;EAAA;EAClB,WAAA;EACA,UAAA;IAAc,SAAA;EAAA;EACd,SAAA;IACE,KAAA;IACA,WAAA;IACA,GAAA;IACA,MAAA,GAAS,iBAAA;EAAA;EAEX,OAAA;IACE,IAAA;IACA,KAAA;IACA,WAAA;IACA,MAAA,GAAS,iBAAA;EAAA;EAEX,MAAA;IAAW,KAAA;IAAc,MAAA;EAAA;AAAA;;;;;AAkC3B;;;;;iBAAgB,YAAA,CACd,IAAA,EAAM,OAAA,EACN,OAAA,EAAS,mBAAA,GACR,YAAA"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { n as documentTitle, r as titleTemplate, t as SHARE_IMAGE_SIZE } from "./share-image-C4ILz4p2.mjs";
|
|
2
|
+
//#region src/seo/metadata.ts
|
|
3
|
+
/**
|
|
4
|
+
* The share image as a crawler wants it, or `undefined` when the page has
|
|
5
|
+
* none or the relation came back unpopulated (`depth: 0` upstream). The
|
|
6
|
+
* named rendition is preferred; a file uploaded before that size existed, or
|
|
7
|
+
* too small to cut, has only its original, which is used as is.
|
|
8
|
+
*/
|
|
9
|
+
function shareImage(image, { absoluteUrl, shareSize = SHARE_IMAGE_SIZE.name }) {
|
|
10
|
+
if (!image || typeof image !== "object") return void 0;
|
|
11
|
+
const rendition = image.sizes?.[shareSize];
|
|
12
|
+
const file = rendition?.url ? rendition : image;
|
|
13
|
+
if (!file.url) return void 0;
|
|
14
|
+
return {
|
|
15
|
+
url: absoluteUrl(file.url),
|
|
16
|
+
...file.width ? { width: file.width } : {},
|
|
17
|
+
...file.height ? { height: file.height } : {},
|
|
18
|
+
...image.alt ? { alt: image.alt } : {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Next metadata for a page with the SEO tab: `<title>`, description,
|
|
23
|
+
* canonical, Open Graph and Twitter with the share image, and
|
|
24
|
+
* `noindex, nofollow` when the page is hidden. Fields the editor left empty
|
|
25
|
+
* are left out, so the layout's defaults (a site-wide description, a default
|
|
26
|
+
* share image) carry through Next's merge. The canonical is always this
|
|
27
|
+
* site's origin; a page that must canonicalise elsewhere is a redirect, not
|
|
28
|
+
* a field.
|
|
29
|
+
*/
|
|
30
|
+
function pageMetadata(page, options) {
|
|
31
|
+
const meta = page.meta ?? {};
|
|
32
|
+
const title = meta.title || documentTitle(options.siteName, page.title);
|
|
33
|
+
const description = meta.description ? { description: meta.description } : {};
|
|
34
|
+
const image = shareImage(meta.image, options);
|
|
35
|
+
const images = image ? { images: [image] } : {};
|
|
36
|
+
return {
|
|
37
|
+
title: meta.title ? { absolute: meta.title } : page.title,
|
|
38
|
+
...description,
|
|
39
|
+
alternates: { canonical: options.canonical },
|
|
40
|
+
openGraph: {
|
|
41
|
+
title,
|
|
42
|
+
...description,
|
|
43
|
+
url: options.canonical,
|
|
44
|
+
...images
|
|
45
|
+
},
|
|
46
|
+
twitter: {
|
|
47
|
+
card: "summary_large_image",
|
|
48
|
+
title,
|
|
49
|
+
...description,
|
|
50
|
+
...images
|
|
51
|
+
},
|
|
52
|
+
...meta.noIndex ? { robots: {
|
|
53
|
+
index: false,
|
|
54
|
+
follow: false
|
|
55
|
+
} } : {}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { documentTitle, pageMetadata, titleTemplate };
|
|
60
|
+
|
|
61
|
+
//# sourceMappingURL=metadata.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.mjs","names":[],"sources":["../src/seo/metadata.ts"],"sourcesContent":["import { SHARE_IMAGE_SIZE } from \"./share-image\";\nimport { documentTitle } from \"./title\";\nimport type { SeoImageValue, SeoMeta } from \"./types\";\n\n/** A page as the reader needs it; a site's generated `Page` is assignable. */\nexport interface SeoPage {\n title: string;\n meta?: SeoMeta | null;\n}\n\nexport interface PageMetadataOptions {\n /** The suffix a page without a written meta title gets. */\n siteName: string;\n /** This page's absolute URL at the site's own origin. */\n canonical: string;\n /** Turns a media file path into an absolute URL; crawlers take no other kind. */\n absoluteUrl: (path: string) => string;\n /** The rendition to read off the meta image. Default `SHARE_IMAGE_SIZE.name`. */\n shareSize?: string;\n}\n\nexport interface PageMetadataImage {\n url: string;\n width?: number;\n height?: number;\n alt?: string;\n}\n\n/**\n * The shape `pageMetadata` returns, assignable to Next's `Metadata` without\n * this package depending on `next`. A written meta title is `absolute` so\n * the layout's title template does not append the site name a second time;\n * an unwritten one is the bare page title, which the template completes.\n * Optional keys are absent rather than `undefined`: Next's merge lets an\n * `undefined` key override the layout's value instead of inheriting it.\n */\nexport interface PageMetadata {\n title: string | { absolute: string };\n description?: string;\n alternates: { canonical: string };\n openGraph: {\n title: string;\n description?: string;\n url: string;\n images?: PageMetadataImage[];\n };\n twitter: {\n card: \"summary_large_image\";\n title: string;\n description?: string;\n images?: PageMetadataImage[];\n };\n robots?: { index: false; follow: false };\n}\n\n/**\n * The share image as a crawler wants it, or `undefined` when the page has\n * none or the relation came back unpopulated (`depth: 0` upstream). The\n * named rendition is preferred; a file uploaded before that size existed, or\n * too small to cut, has only its original, which is used as is.\n */\nfunction shareImage(\n image: SeoImageValue,\n { absoluteUrl, shareSize = SHARE_IMAGE_SIZE.name }: PageMetadataOptions,\n): PageMetadataImage | undefined {\n if (!image || typeof image !== \"object\") return undefined;\n const rendition = image.sizes?.[shareSize];\n const file = rendition?.url ? rendition : image;\n if (!file.url) return undefined;\n return {\n url: absoluteUrl(file.url),\n ...(file.width ? { width: file.width } : {}),\n ...(file.height ? { height: file.height } : {}),\n ...(image.alt ? { alt: image.alt } : {}),\n };\n}\n\n/**\n * Next metadata for a page with the SEO tab: `<title>`, description,\n * canonical, Open Graph and Twitter with the share image, and\n * `noindex, nofollow` when the page is hidden. Fields the editor left empty\n * are left out, so the layout's defaults (a site-wide description, a default\n * share image) carry through Next's merge. The canonical is always this\n * site's origin; a page that must canonicalise elsewhere is a redirect, not\n * a field.\n */\nexport function pageMetadata(\n page: SeoPage,\n options: PageMetadataOptions,\n): PageMetadata {\n const meta = page.meta ?? {};\n const title = meta.title || documentTitle(options.siteName, page.title);\n const description = meta.description ? { description: meta.description } : {};\n const image = shareImage(meta.image, options);\n const images = image ? { images: [image] } : {};\n\n return {\n title: meta.title ? { absolute: meta.title } : page.title,\n ...description,\n alternates: { canonical: options.canonical },\n openGraph: { title, ...description, url: options.canonical, ...images },\n twitter: { card: \"summary_large_image\", title, ...description, ...images },\n // Both off: a page hidden from search should not lend its links weight either.\n ...(meta.noIndex ? { robots: { index: false, follow: false } } : {}),\n };\n}\n"],"mappings":";;;;;;;;AA6DA,SAAS,WACP,OACA,EAAE,aAAa,YAAY,iBAAiB,QACb;AAC/B,KAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,KAAA;CAChD,MAAM,YAAY,MAAM,QAAQ;CAChC,MAAM,OAAO,WAAW,MAAM,YAAY;AAC1C,KAAI,CAAC,KAAK,IAAK,QAAO,KAAA;AACtB,QAAO;EACL,KAAK,YAAY,KAAK,IAAI;EAC1B,GAAI,KAAK,QAAQ,EAAE,OAAO,KAAK,OAAO,GAAG,EAAE;EAC3C,GAAI,KAAK,SAAS,EAAE,QAAQ,KAAK,QAAQ,GAAG,EAAE;EAC9C,GAAI,MAAM,MAAM,EAAE,KAAK,MAAM,KAAK,GAAG,EAAE;EACxC;;;;;;;;;;;AAYH,SAAgB,aACd,MACA,SACc;CACd,MAAM,OAAO,KAAK,QAAQ,EAAE;CAC5B,MAAM,QAAQ,KAAK,SAAS,cAAc,QAAQ,UAAU,KAAK,MAAM;CACvE,MAAM,cAAc,KAAK,cAAc,EAAE,aAAa,KAAK,aAAa,GAAG,EAAE;CAC7E,MAAM,QAAQ,WAAW,KAAK,OAAO,QAAQ;CAC7C,MAAM,SAAS,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,GAAG,EAAE;AAE/C,QAAO;EACL,OAAO,KAAK,QAAQ,EAAE,UAAU,KAAK,OAAO,GAAG,KAAK;EACpD,GAAG;EACH,YAAY,EAAE,WAAW,QAAQ,WAAW;EAC5C,WAAW;GAAE;GAAO,GAAG;GAAa,KAAK,QAAQ;GAAW,GAAG;GAAQ;EACvE,SAAS;GAAE,MAAM;GAAuB;GAAO,GAAG;GAAa,GAAG;GAAQ;EAE1E,GAAI,KAAK,UAAU,EAAE,QAAQ;GAAE,OAAO;GAAO,QAAQ;GAAO,EAAE,GAAG,EAAE;EACpE"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/seo/title.ts
|
|
2
|
+
/**
|
|
3
|
+
* A document title from a page title: the one place the `| Site Name` suffix
|
|
4
|
+
* is spelled. The frontend layout's title template, the admin's Generate
|
|
5
|
+
* button and a CMS page's Open Graph title are all built from it, since Next
|
|
6
|
+
* applies a title template to `<title>` alone.
|
|
7
|
+
*/
|
|
8
|
+
function documentTitle(siteName, pageTitle) {
|
|
9
|
+
return `${pageTitle} | ${siteName}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The same format as Next's `title.template` wants it, for the root layout:
|
|
13
|
+
* `%s` is Next's placeholder for the page's own title.
|
|
14
|
+
*/
|
|
15
|
+
function titleTemplate(siteName) {
|
|
16
|
+
return documentTitle(siteName, "%s");
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/seo/share-image.ts
|
|
20
|
+
/**
|
|
21
|
+
* The rendition a media collection cuts for social share cards: 1.91:1 at the
|
|
22
|
+
* size Open Graph consumers ask for, cropped to shape rather than letterboxed
|
|
23
|
+
* so a portrait upload still fills the card. Add it to the upload
|
|
24
|
+
* collection's `imageSizes`; `pageMetadata` reads `sizes.share` off a page's
|
|
25
|
+
* meta image and falls back to the original for a file uploaded before the
|
|
26
|
+
* size existed, or one too small to cut (Payload does not upscale).
|
|
27
|
+
*/
|
|
28
|
+
const SHARE_IMAGE_SIZE = {
|
|
29
|
+
name: "share",
|
|
30
|
+
width: 1200,
|
|
31
|
+
height: 630,
|
|
32
|
+
position: "centre"
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { documentTitle as n, titleTemplate as r, SHARE_IMAGE_SIZE as t };
|
|
36
|
+
|
|
37
|
+
//# sourceMappingURL=share-image-C4ILz4p2.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"share-image-C4ILz4p2.mjs","names":[],"sources":["../src/seo/title.ts","../src/seo/share-image.ts"],"sourcesContent":["/**\n * A document title from a page title: the one place the `| Site Name` suffix\n * is spelled. The frontend layout's title template, the admin's Generate\n * button and a CMS page's Open Graph title are all built from it, since Next\n * applies a title template to `<title>` alone.\n */\nexport function documentTitle(siteName: string, pageTitle: string): string {\n return `${pageTitle} | ${siteName}`;\n}\n\n/**\n * The same format as Next's `title.template` wants it, for the root layout:\n * `%s` is Next's placeholder for the page's own title.\n */\nexport function titleTemplate(siteName: string): string {\n return documentTitle(siteName, \"%s\");\n}\n","import type { ImageSize } from \"payload\";\n\n/**\n * The rendition a media collection cuts for social share cards: 1.91:1 at the\n * size Open Graph consumers ask for, cropped to shape rather than letterboxed\n * so a portrait upload still fills the card. Add it to the upload\n * collection's `imageSizes`; `pageMetadata` reads `sizes.share` off a page's\n * meta image and falls back to the original for a file uploaded before the\n * size existed, or one too small to cut (Payload does not upscale).\n */\nexport const SHARE_IMAGE_SIZE = {\n name: \"share\",\n width: 1200,\n height: 630,\n position: \"centre\",\n} satisfies ImageSize;\n"],"mappings":";;;;;;;AAMA,SAAgB,cAAc,UAAkB,WAA2B;AACzE,QAAO,GAAG,UAAU,KAAK;;;;;;AAO3B,SAAgB,cAAc,UAA0B;AACtD,QAAO,cAAc,UAAU,KAAK;;;;;;;;;;;;ACLtC,MAAa,mBAAmB;CAC9B,MAAM;CACN,OAAO;CACP,QAAQ;CACR,UAAU;CACX"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/seo/types.d.ts
|
|
2
|
+
/** A media row id as Payload stores it: a number on Postgres, a string on Mongo. */
|
|
3
|
+
type MediaId = number | string;
|
|
4
|
+
/** One rendition under an upload document's `sizes`. */
|
|
5
|
+
interface SeoImageSize {
|
|
6
|
+
url?: string | null;
|
|
7
|
+
width?: number | null;
|
|
8
|
+
height?: number | null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The subset of an upload document the metadata reader touches. A site's
|
|
12
|
+
* generated `Media` is assignable to this whatever else it carries. No index
|
|
13
|
+
* signature at the top level, for the reason `@bison-lab/payload-blocks`
|
|
14
|
+
* gives its `MediaDoc` none: an interface will not assign to a type that has
|
|
15
|
+
* one, and a generated `Media` is always an interface. `sizes` is generated
|
|
16
|
+
* as an anonymous object type, which does assign to an indexed one.
|
|
17
|
+
*/
|
|
18
|
+
interface SeoImageDoc {
|
|
19
|
+
url?: string | null;
|
|
20
|
+
alt?: string | null;
|
|
21
|
+
width?: number | null;
|
|
22
|
+
height?: number | null;
|
|
23
|
+
sizes?: {
|
|
24
|
+
[name: string]: SeoImageSize | undefined;
|
|
25
|
+
} | null;
|
|
26
|
+
}
|
|
27
|
+
/** What the `image` field holds before and after `depth` populates it. */
|
|
28
|
+
type SeoImageValue = MediaId | SeoImageDoc | null | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The `meta` group the plugin adds to a collection, as a site's generated
|
|
31
|
+
* `Page['meta']` reads once `payload generate:types` has run.
|
|
32
|
+
*/
|
|
33
|
+
interface SeoMeta {
|
|
34
|
+
title?: string | null;
|
|
35
|
+
description?: string | null;
|
|
36
|
+
image?: SeoImageValue;
|
|
37
|
+
noIndex?: boolean | null;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/seo/title.d.ts
|
|
41
|
+
/**
|
|
42
|
+
* A document title from a page title: the one place the `| Site Name` suffix
|
|
43
|
+
* is spelled. The frontend layout's title template, the admin's Generate
|
|
44
|
+
* button and a CMS page's Open Graph title are all built from it, since Next
|
|
45
|
+
* applies a title template to `<title>` alone.
|
|
46
|
+
*/
|
|
47
|
+
declare function documentTitle(siteName: string, pageTitle: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* The same format as Next's `title.template` wants it, for the root layout:
|
|
50
|
+
* `%s` is Next's placeholder for the page's own title.
|
|
51
|
+
*/
|
|
52
|
+
declare function titleTemplate(siteName: string): string;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { SeoImageSize as a, SeoImageDoc as i, titleTemplate as n, SeoImageValue as o, MediaId as r, SeoMeta as s, documentTitle as t };
|
|
55
|
+
//# sourceMappingURL=title-Wut0nzJQ.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"title-Wut0nzJQ.d.mts","names":[],"sources":["../src/seo/types.ts","../src/seo/title.ts"],"mappings":";;KACY,OAAA;;UAGK,YAAA;EACf,GAAA;EACA,KAAA;EACA,MAAA;AAAA;;;;;;;;AAWF;UAAiB,WAAA;EACf,GAAA;EACA,GAAA;EACA,KAAA;EACA,MAAA;EACA,KAAA;IAAA,CAAW,IAAA,WAAe,YAAA;EAAA;AAAA;;KAIhB,aAAA,GAAgB,OAAA,GAAU,WAAA;;AAAtC;;;UAMiB,OAAA;EACf,KAAA;EACA,WAAA;EACA,KAAA,GAAQ,aAAA;EACR,OAAA;AAAA;;;;AApCF;;;;;iBCKgB,aAAA,CAAc,QAAA,UAAkB,SAAA;;;;;iBAQhC,aAAA,CAAc,QAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bison-lab/payload-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab on a collection and the metadata reader for the pages it describes",
|
|
5
|
+
"homepage": "https://components.bisonlab.ai",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Bison-Lab-LLC/bisonlab-component-library.git",
|
|
9
|
+
"directory": "packages/payload-core"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Bison-Lab-LLC/bisonlab-component-library/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.mjs",
|
|
16
|
+
"types": "./dist/index.d.mts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.mts"
|
|
21
|
+
},
|
|
22
|
+
"./metadata": {
|
|
23
|
+
"import": "./dist/metadata.mjs",
|
|
24
|
+
"types": "./dist/metadata.d.mts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@payloadcms/plugin-seo": "^3.0.0",
|
|
32
|
+
"payload": "^3.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@payloadcms/plugin-seo": "^3.88.0",
|
|
36
|
+
"payload": "^3.88.0",
|
|
37
|
+
"tsdown": "^0.21.0",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^4.1.2"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsdown",
|
|
46
|
+
"dev": "tsdown --watch",
|
|
47
|
+
"clean": "rm -rf dist",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest"
|
|
51
|
+
}
|
|
52
|
+
}
|