@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 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.
@@ -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
+ }