@paragraphcms/seo 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 +180 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/routes.d.ts +6 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +42 -0
- package/dist/seo.d.ts +34 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +516 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# @paragraphcms/seo
|
|
2
|
+
|
|
3
|
+
SEO document generators for Paragraph CMS sites.
|
|
4
|
+
|
|
5
|
+
`@paragraphcms/seo` builds `robots.txt`, `sitemap.xml`, `rss.xml`, and `llms.txt` from a single `@paragraphcms/client` instance plus your route definitions.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @paragraphcms/client @paragraphcms/seo
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Client } from "@paragraphcms/client";
|
|
17
|
+
import {
|
|
18
|
+
SEO,
|
|
19
|
+
localizedContentRoute,
|
|
20
|
+
localizedRoute,
|
|
21
|
+
} from "@paragraphcms/seo";
|
|
22
|
+
|
|
23
|
+
const client = new Client({
|
|
24
|
+
apiKey: process.env.PARAGRAPH_API_KEY!,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const seo = new SEO({
|
|
28
|
+
client,
|
|
29
|
+
site: {
|
|
30
|
+
url: "https://example.com",
|
|
31
|
+
name: "My site",
|
|
32
|
+
description: "Latest posts from my site.",
|
|
33
|
+
defaultLocale: "en",
|
|
34
|
+
},
|
|
35
|
+
routes: {
|
|
36
|
+
home: localizedRoute(),
|
|
37
|
+
blog: localizedContentRoute("blog", {
|
|
38
|
+
params: {
|
|
39
|
+
collection: "blog",
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
features: localizedContentRoute("features", {
|
|
43
|
+
params: {
|
|
44
|
+
collection: "features",
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const robots = await seo.robotsTxt();
|
|
51
|
+
const sitemap = await seo.sitemapXml();
|
|
52
|
+
const blogRss = await seo.rssXml({
|
|
53
|
+
locale: "en",
|
|
54
|
+
route: "blog",
|
|
55
|
+
});
|
|
56
|
+
const llms = await seo.llmsTxt();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Default Locale
|
|
60
|
+
|
|
61
|
+
If you do not pass `site.defaultLocale`, the library resolves it with `client.locales.getDefaultLocale()`.
|
|
62
|
+
|
|
63
|
+
`site.defaultLocale` always wins when both are available.
|
|
64
|
+
|
|
65
|
+
`rssXml()` and `llmsTxt()` use the resolved default locale when `locale` is omitted.
|
|
66
|
+
|
|
67
|
+
## Route Params
|
|
68
|
+
|
|
69
|
+
Each content route lives directly under `routes`, and you can attach filtering params right there:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import {
|
|
73
|
+
localizedContentRoute,
|
|
74
|
+
localizedRoute,
|
|
75
|
+
} from "@paragraphcms/seo";
|
|
76
|
+
|
|
77
|
+
routes: {
|
|
78
|
+
home: localizedRoute(),
|
|
79
|
+
blog: localizedContentRoute("blog", {
|
|
80
|
+
params: {
|
|
81
|
+
collection: "Blog",
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
features: localizedContentRoute("features", {
|
|
85
|
+
params: {
|
|
86
|
+
collection: "Features",
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`params` is forwarded to `client.pages.list()` for that route, with `language` and `requiredSlug` filled in automatically by the library.
|
|
93
|
+
|
|
94
|
+
`published` defaults to `true` for every content route. If you need unpublished entries for a specific route, override it with `published: false`.
|
|
95
|
+
|
|
96
|
+
The Paragraph client also supports status filters in `pages.list()`, so you can pass `statusId` or `statusType` in `params` when needed.
|
|
97
|
+
|
|
98
|
+
If you prefer plain objects, content routes also support `basePath` directly:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
routes: {
|
|
102
|
+
home: localizedRoute(),
|
|
103
|
+
blog: {
|
|
104
|
+
basePath: "blog",
|
|
105
|
+
params: {
|
|
106
|
+
collection: "Blog",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`basePath: "blog"` expands to `/blog` for the default locale and `/:locale/blog` for other locales. Post URLs are generated automatically by appending the page slug.
|
|
113
|
+
|
|
114
|
+
## Localized Route Helpers
|
|
115
|
+
|
|
116
|
+
`localizedRoute()` returns a function, so this:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
home: localizedRoute(),
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
is equivalent to writing:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
home: ({ locale, defaultLocale }) =>
|
|
126
|
+
locale === defaultLocale ? "/" : `/${locale}`,
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Pass a path when you want a localized non-root route:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const docsRoute = localizedRoute("docs");
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## RSS
|
|
136
|
+
|
|
137
|
+
If you configure more than one content route, pass `route` to `rssXml()`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const rss = await seo.rssXml({
|
|
141
|
+
locale: "en",
|
|
142
|
+
route: "features",
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Artifact Paths
|
|
147
|
+
|
|
148
|
+
You can override the public paths used inside generated documents:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const seo = new SEO({
|
|
152
|
+
client,
|
|
153
|
+
site,
|
|
154
|
+
routes,
|
|
155
|
+
artifacts: {
|
|
156
|
+
sitemapPath: "/sitemap.xml",
|
|
157
|
+
robotsPath: "/robots.txt",
|
|
158
|
+
llmsPath: "/llms.txt",
|
|
159
|
+
rssPath: ({ locale, route, collection, defaultLocale }) =>
|
|
160
|
+
locale === defaultLocale
|
|
161
|
+
? `/${route}-${collection?.toLowerCase()}.xml`
|
|
162
|
+
: `/${locale}/${route}-${collection?.toLowerCase()}.xml`,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## API
|
|
168
|
+
|
|
169
|
+
### `new SEO(options)`
|
|
170
|
+
|
|
171
|
+
Creates an instance with four async methods:
|
|
172
|
+
|
|
173
|
+
- `robotsTxt()`
|
|
174
|
+
- `sitemapXml()`
|
|
175
|
+
- `rssXml({ locale?, route? })`
|
|
176
|
+
- `llmsTxt({ locale? })`
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EACL,qBAAqB,EACrB,cAAc,GACf,MAAM,aAAa,CAAC;AACrB,mBAAmB,YAAY,CAAC"}
|
package/dist/index.js
ADDED
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PageSummary } from "@paragraphcms/client";
|
|
2
|
+
import type { SeoLocalizedContentRouteDefinition, SeoRouteContext } from "./types.js";
|
|
3
|
+
export declare function buildLocalizedPath(context: Pick<SeoRouteContext, "locale" | "defaultLocale">, ...parts: string[]): string;
|
|
4
|
+
export declare function localizedRoute(basePath?: string): (context: Pick<SeoRouteContext, "locale" | "defaultLocale">) => string;
|
|
5
|
+
export declare function localizedContentRoute<TPage extends PageSummary = PageSummary>(basePath: string, options?: Omit<SeoLocalizedContentRouteDefinition<TPage>, "basePath">): SeoLocalizedContentRouteDefinition<TPage>;
|
|
6
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EACV,kCAAkC,EAClC,eAAe,EAChB,MAAM,YAAY,CAAC;AAyCpB,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,eAAe,CAAC,EAC1D,GAAG,KAAK,EAAE,MAAM,EAAE,UAQnB;AAED,wBAAgB,cAAc,CAAC,QAAQ,CAAC,EAAE,MAAM,IAc5C,SAAS,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,eAAe,CAAC,YAE7D;AAED,wBAAgB,qBAAqB,CACnC,KAAK,SAAS,WAAW,GAAG,WAAW,EAEvC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,IAAI,CACX,kCAAkC,CAAC,KAAK,CAAC,EACzC,UAAU,CACN,GACL,kCAAkC,CAAC,KAAK,CAAC,CAK3C"}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function isAbsoluteUrl(value) {
|
|
2
|
+
return /^https?:\/\//i.test(value);
|
|
3
|
+
}
|
|
4
|
+
function normalizePathParts(value, fieldName) {
|
|
5
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
6
|
+
throw new TypeError(`${fieldName} must be a non-empty string.`);
|
|
7
|
+
}
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (isAbsoluteUrl(trimmed)) {
|
|
10
|
+
throw new TypeError(`${fieldName} must be a relative path, not an absolute URL.`);
|
|
11
|
+
}
|
|
12
|
+
return trimmed
|
|
13
|
+
.split("/")
|
|
14
|
+
.map((part) => part.trim())
|
|
15
|
+
.filter((part) => part.length > 0);
|
|
16
|
+
}
|
|
17
|
+
function joinLocalizedPath(context, parts) {
|
|
18
|
+
const localePrefix = context.locale === context.defaultLocale
|
|
19
|
+
? []
|
|
20
|
+
: normalizePathParts(context.locale, "locale");
|
|
21
|
+
const segments = [...localePrefix, ...parts];
|
|
22
|
+
return segments.length > 0 ? `/${segments.join("/")}` : "/";
|
|
23
|
+
}
|
|
24
|
+
export function buildLocalizedPath(context, ...parts) {
|
|
25
|
+
return joinLocalizedPath(context, parts.flatMap((part, index) => normalizePathParts(part, `path part ${index + 1}`)));
|
|
26
|
+
}
|
|
27
|
+
export function localizedRoute(basePath) {
|
|
28
|
+
if (basePath !== undefined &&
|
|
29
|
+
typeof basePath !== "string") {
|
|
30
|
+
throw new TypeError("basePath must be a string.");
|
|
31
|
+
}
|
|
32
|
+
const parts = basePath === undefined
|
|
33
|
+
? []
|
|
34
|
+
: normalizePathParts(basePath, "basePath");
|
|
35
|
+
return (context) => joinLocalizedPath(context, parts);
|
|
36
|
+
}
|
|
37
|
+
export function localizedContentRoute(basePath, options = {}) {
|
|
38
|
+
return {
|
|
39
|
+
basePath: normalizePathParts(basePath, "basePath").join("/"),
|
|
40
|
+
...options,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PageSummary } from "@paragraphcms/client";
|
|
2
|
+
import type { CreateSeoOptions, SeoApi } from "./types.js";
|
|
3
|
+
export declare class SEO<TPage extends PageSummary = PageSummary> implements SeoApi<TPage> {
|
|
4
|
+
private readonly client;
|
|
5
|
+
private readonly homeRoute;
|
|
6
|
+
private readonly namedRoutes;
|
|
7
|
+
private readonly artifacts?;
|
|
8
|
+
private readonly siteName;
|
|
9
|
+
private readonly siteUrl;
|
|
10
|
+
private readonly siteDescription?;
|
|
11
|
+
private readonly siteDefaultLocale?;
|
|
12
|
+
constructor(options: CreateSeoOptions<TPage>);
|
|
13
|
+
private resolveDefaultLocale;
|
|
14
|
+
private makeResolvedSite;
|
|
15
|
+
private listLocales;
|
|
16
|
+
private makeRouteContext;
|
|
17
|
+
private makeNamedRouteContext;
|
|
18
|
+
private resolveNamedRouteIndexPath;
|
|
19
|
+
private resolveNamedRoutePostPath;
|
|
20
|
+
private resolveRssPath;
|
|
21
|
+
private resolveSitemapPath;
|
|
22
|
+
private resolveRobotsPath;
|
|
23
|
+
private resolveLlmsPath;
|
|
24
|
+
private listNamedRoutePages;
|
|
25
|
+
private listLocaleRoutePages;
|
|
26
|
+
private resolvePageTitle;
|
|
27
|
+
private resolvePageDescription;
|
|
28
|
+
private resolveRssRoute;
|
|
29
|
+
robotsTxt: () => Promise<string>;
|
|
30
|
+
sitemapXml: () => Promise<string>;
|
|
31
|
+
rssXml: SeoApi<TPage>["rssXml"];
|
|
32
|
+
llmsTxt: SeoApi<TPage>["llmsTxt"];
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=seo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../src/seo.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EACV,gBAAgB,EAEhB,MAAM,EAUP,MAAM,YAAY,CAAC;AAwOpB,qBAAa,GAAG,CAAC,KAAK,SAAS,WAAW,GAAG,WAAW,CACtD,YAAW,MAAM,CAAC,KAAK,CAAC;IAExB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA2B;IACrD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA8B;IAC1D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAqB;IAChD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAS;gBAEhC,OAAO,EAAE,gBAAgB,CAAC,KAAK,CAAC;YA8I9B,oBAAoB;IAWlC,OAAO,CAAC,gBAAgB;YAWV,WAAW;IAczB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,qBAAqB;IAY7B,OAAO,CAAC,0BAA0B;IAUlC,OAAO,CAAC,yBAAyB;IAejC,OAAO,CAAC,cAAc;IAoBtB,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,iBAAiB;IAOzB,OAAO,CAAC,eAAe;YAOT,mBAAmB;YAoBnB,oBAAoB;IASlC,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,sBAAsB;IAuB9B,OAAO,CAAC,eAAe;IA8BvB,SAAS,wBAWP;IAEF,UAAU,wBAgHR;IAEF,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CA+F7B;IAEF,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CA4E/B;CACH"}
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { buildLocalizedPath, localizedRoute, } from "./routes.js";
|
|
2
|
+
const DEFAULT_SITEMAP_PATH = "/sitemap.xml";
|
|
3
|
+
const DEFAULT_ROBOTS_PATH = "/robots.txt";
|
|
4
|
+
const DEFAULT_LLMS_PATH = "/llms.txt";
|
|
5
|
+
function assertNonEmptyString(value, fieldName) {
|
|
6
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
7
|
+
throw new TypeError(`${fieldName} must be a non-empty string.`);
|
|
8
|
+
}
|
|
9
|
+
return value.trim();
|
|
10
|
+
}
|
|
11
|
+
function normalizeSiteUrl(value) {
|
|
12
|
+
const trimmed = assertNonEmptyString(value, "site.url");
|
|
13
|
+
const normalized = trimmed.replace(/\/+$/, "");
|
|
14
|
+
try {
|
|
15
|
+
return new URL(normalized).toString().replace(/\/$/, "");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new TypeError("site.url must be a valid absolute URL.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isAbsoluteUrl(value) {
|
|
22
|
+
return /^https?:\/\//i.test(value);
|
|
23
|
+
}
|
|
24
|
+
function normalizePath(value, fallback) {
|
|
25
|
+
const candidate = value?.trim() || fallback;
|
|
26
|
+
if (isAbsoluteUrl(candidate)) {
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
if (candidate === "/") {
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
return candidate.startsWith("/") ? candidate : `/${candidate}`;
|
|
33
|
+
}
|
|
34
|
+
function appendPathSegment(basePath, segment) {
|
|
35
|
+
if (isAbsoluteUrl(basePath)) {
|
|
36
|
+
const url = new URL(basePath);
|
|
37
|
+
url.pathname =
|
|
38
|
+
url.pathname === "/"
|
|
39
|
+
? `/${segment}`
|
|
40
|
+
: `${url.pathname.replace(/\/+$/, "")}/${segment}`;
|
|
41
|
+
url.search = "";
|
|
42
|
+
url.hash = "";
|
|
43
|
+
return url.toString();
|
|
44
|
+
}
|
|
45
|
+
const normalizedBase = normalizePath(basePath, "/");
|
|
46
|
+
return normalizedBase === "/"
|
|
47
|
+
? `/${segment}`
|
|
48
|
+
: `${normalizedBase.replace(/\/+$/, "")}/${segment}`;
|
|
49
|
+
}
|
|
50
|
+
function toAbsoluteUrl(siteUrl, path) {
|
|
51
|
+
if (isAbsoluteUrl(path)) {
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
return new URL(normalizePath(path, "/"), `${siteUrl}/`).toString();
|
|
55
|
+
}
|
|
56
|
+
function escapeXml(value) {
|
|
57
|
+
return value
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, """)
|
|
62
|
+
.replace(/'/g, "'");
|
|
63
|
+
}
|
|
64
|
+
function toIsoDate(value) {
|
|
65
|
+
if (!value) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const date = new Date(value);
|
|
69
|
+
if (Number.isNaN(date.getTime())) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return date.toISOString();
|
|
73
|
+
}
|
|
74
|
+
function toRssDate(value) {
|
|
75
|
+
if (!value) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const date = new Date(value);
|
|
79
|
+
if (Number.isNaN(date.getTime())) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return date.toUTCString();
|
|
83
|
+
}
|
|
84
|
+
function dedupeStrings(values) {
|
|
85
|
+
return Array.from(new Set(values
|
|
86
|
+
.map((value) => value.trim())
|
|
87
|
+
.filter((value) => value.length > 0)));
|
|
88
|
+
}
|
|
89
|
+
function getLatestDate(values) {
|
|
90
|
+
let latest = 0;
|
|
91
|
+
for (const value of values) {
|
|
92
|
+
if (!value) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const timestamp = new Date(value).getTime();
|
|
96
|
+
if (!Number.isNaN(timestamp) && timestamp > latest) {
|
|
97
|
+
latest = timestamp;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return latest > 0 ? new Date(latest).toISOString() : undefined;
|
|
101
|
+
}
|
|
102
|
+
function getSitemapDate(page) {
|
|
103
|
+
return toIsoDate(page.updatedAt ?? page.publishedAt ?? page.createdAt);
|
|
104
|
+
}
|
|
105
|
+
function getFeedDate(page) {
|
|
106
|
+
return toRssDate(page.publishedAt ?? page.updatedAt ?? page.createdAt);
|
|
107
|
+
}
|
|
108
|
+
function comparePagesForFeed(left, right) {
|
|
109
|
+
const leftDate = new Date(left.publishedAt ?? left.updatedAt ?? left.createdAt ?? 0).getTime();
|
|
110
|
+
const rightDate = new Date(right.publishedAt ?? right.updatedAt ?? right.createdAt ?? 0).getTime();
|
|
111
|
+
if (leftDate !== rightDate) {
|
|
112
|
+
return rightDate - leftDate;
|
|
113
|
+
}
|
|
114
|
+
return left.title.localeCompare(right.title);
|
|
115
|
+
}
|
|
116
|
+
function compareRoutedPages(left, right) {
|
|
117
|
+
return comparePagesForFeed(left.page, right.page);
|
|
118
|
+
}
|
|
119
|
+
function resolveRoutePath(value, fieldName) {
|
|
120
|
+
return normalizePath(assertNonEmptyString(value, fieldName), "/");
|
|
121
|
+
}
|
|
122
|
+
function hasSlug(page) {
|
|
123
|
+
return typeof page.slug === "string" && page.slug.trim().length > 0;
|
|
124
|
+
}
|
|
125
|
+
function isPlainObject(value) {
|
|
126
|
+
return (typeof value === "object" &&
|
|
127
|
+
value !== null &&
|
|
128
|
+
!Array.isArray(value));
|
|
129
|
+
}
|
|
130
|
+
export class SEO {
|
|
131
|
+
client;
|
|
132
|
+
homeRoute;
|
|
133
|
+
namedRoutes;
|
|
134
|
+
artifacts;
|
|
135
|
+
siteName;
|
|
136
|
+
siteUrl;
|
|
137
|
+
siteDescription;
|
|
138
|
+
siteDefaultLocale;
|
|
139
|
+
constructor(options) {
|
|
140
|
+
if (!options || typeof options !== "object") {
|
|
141
|
+
throw new TypeError("SEO options are required.");
|
|
142
|
+
}
|
|
143
|
+
const { client, routes, artifacts } = options;
|
|
144
|
+
if (!client || typeof client !== "object") {
|
|
145
|
+
throw new TypeError("client is required.");
|
|
146
|
+
}
|
|
147
|
+
if (!routes || typeof routes !== "object") {
|
|
148
|
+
throw new TypeError("routes are required.");
|
|
149
|
+
}
|
|
150
|
+
if (typeof routes.home !== "function") {
|
|
151
|
+
throw new TypeError("routes.home must be a function.");
|
|
152
|
+
}
|
|
153
|
+
const routeEntries = Object.entries(routes).filter(([name]) => name !== "home");
|
|
154
|
+
if (routeEntries.length === 0) {
|
|
155
|
+
throw new TypeError("At least one content route is required.");
|
|
156
|
+
}
|
|
157
|
+
const namedRoutes = routeEntries.map(([name, route]) => {
|
|
158
|
+
const fieldName = `routes.${name}`;
|
|
159
|
+
if (!isPlainObject(route)) {
|
|
160
|
+
throw new TypeError(`${fieldName} must be an object.`);
|
|
161
|
+
}
|
|
162
|
+
const contentRoute = route;
|
|
163
|
+
if (contentRoute.params !== undefined &&
|
|
164
|
+
!isPlainObject(contentRoute.params)) {
|
|
165
|
+
throw new TypeError(`${fieldName}.params must be an object.`);
|
|
166
|
+
}
|
|
167
|
+
const hasBasePath = contentRoute.basePath !== undefined;
|
|
168
|
+
const hasIndex = typeof contentRoute.index === "function";
|
|
169
|
+
const hasPost = typeof contentRoute.post === "function";
|
|
170
|
+
let index;
|
|
171
|
+
let post;
|
|
172
|
+
if (hasBasePath) {
|
|
173
|
+
if (hasIndex || hasPost) {
|
|
174
|
+
throw new TypeError(`${fieldName}.basePath cannot be combined with ${fieldName}.index or ${fieldName}.post.`);
|
|
175
|
+
}
|
|
176
|
+
const basePath = assertNonEmptyString(contentRoute.basePath, `${fieldName}.basePath`);
|
|
177
|
+
const localizedIndex = localizedRoute(basePath);
|
|
178
|
+
index = (context) => localizedIndex(context);
|
|
179
|
+
post = (context) => buildLocalizedPath(context, basePath, context.slug);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (!hasIndex) {
|
|
183
|
+
throw new TypeError(`${fieldName}.index must be a function.`);
|
|
184
|
+
}
|
|
185
|
+
if (!hasPost) {
|
|
186
|
+
throw new TypeError(`${fieldName}.post must be a function.`);
|
|
187
|
+
}
|
|
188
|
+
index = contentRoute.index;
|
|
189
|
+
post = contentRoute.post;
|
|
190
|
+
}
|
|
191
|
+
if (contentRoute.params) {
|
|
192
|
+
if ("language" in contentRoute.params) {
|
|
193
|
+
throw new TypeError(`${fieldName}.params.language is not supported.`);
|
|
194
|
+
}
|
|
195
|
+
if ("requiredSlug" in contentRoute.params) {
|
|
196
|
+
throw new TypeError(`${fieldName}.params.requiredSlug is not supported.`);
|
|
197
|
+
}
|
|
198
|
+
if ("page" in contentRoute.params ||
|
|
199
|
+
"limit" in contentRoute.params) {
|
|
200
|
+
throw new TypeError(`${fieldName}.params.page and ${fieldName}.params.limit are not supported.`);
|
|
201
|
+
}
|
|
202
|
+
if ("collection" in contentRoute.params &&
|
|
203
|
+
contentRoute.params.collection !== undefined) {
|
|
204
|
+
assertNonEmptyString(contentRoute.params.collection, `${fieldName}.params.collection`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
fieldName,
|
|
209
|
+
name,
|
|
210
|
+
params: contentRoute.params,
|
|
211
|
+
index,
|
|
212
|
+
post,
|
|
213
|
+
title: contentRoute.title,
|
|
214
|
+
description: contentRoute.description,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
this.client = client;
|
|
218
|
+
this.homeRoute = routes.home;
|
|
219
|
+
this.namedRoutes = namedRoutes;
|
|
220
|
+
this.artifacts = artifacts;
|
|
221
|
+
this.siteName = assertNonEmptyString(options.site?.name, "site.name");
|
|
222
|
+
this.siteUrl = normalizeSiteUrl(options.site?.url ?? "");
|
|
223
|
+
this.siteDescription =
|
|
224
|
+
typeof options.site?.description === "string" &&
|
|
225
|
+
options.site.description.trim().length > 0
|
|
226
|
+
? options.site.description.trim()
|
|
227
|
+
: undefined;
|
|
228
|
+
this.siteDefaultLocale = options.site?.defaultLocale?.trim() || undefined;
|
|
229
|
+
}
|
|
230
|
+
async resolveDefaultLocale() {
|
|
231
|
+
if (this.siteDefaultLocale) {
|
|
232
|
+
return this.siteDefaultLocale;
|
|
233
|
+
}
|
|
234
|
+
return assertNonEmptyString(await this.client.locales.getDefaultLocale(), "client.locales.getDefaultLocale() result");
|
|
235
|
+
}
|
|
236
|
+
makeResolvedSite(defaultLocale) {
|
|
237
|
+
return {
|
|
238
|
+
url: this.siteUrl,
|
|
239
|
+
name: this.siteName,
|
|
240
|
+
description: this.siteDescription,
|
|
241
|
+
defaultLocale,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async listLocales(defaultLocale, extra = []) {
|
|
245
|
+
const locales = await this.client.locales.list();
|
|
246
|
+
const localeCodes = dedupeStrings([
|
|
247
|
+
...locales.map((locale) => locale.code),
|
|
248
|
+
defaultLocale,
|
|
249
|
+
...extra,
|
|
250
|
+
]);
|
|
251
|
+
return localeCodes.length > 0 ? localeCodes : [defaultLocale];
|
|
252
|
+
}
|
|
253
|
+
makeRouteContext(locale, defaultLocale, site) {
|
|
254
|
+
return {
|
|
255
|
+
locale,
|
|
256
|
+
defaultLocale,
|
|
257
|
+
site,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
makeNamedRouteContext(locale, defaultLocale, site, routeName) {
|
|
261
|
+
return {
|
|
262
|
+
...this.makeRouteContext(locale, defaultLocale, site),
|
|
263
|
+
route: routeName,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
resolveNamedRouteIndexPath(route, context) {
|
|
267
|
+
return resolveRoutePath(route.index(context), `${route.fieldName}.index`);
|
|
268
|
+
}
|
|
269
|
+
resolveNamedRoutePostPath(route, page, context) {
|
|
270
|
+
return resolveRoutePath(route.post({
|
|
271
|
+
...context,
|
|
272
|
+
page,
|
|
273
|
+
slug: page.slug,
|
|
274
|
+
}), `${route.fieldName}.post`);
|
|
275
|
+
}
|
|
276
|
+
resolveRssPath(context, route) {
|
|
277
|
+
if (typeof this.artifacts?.rssPath === "function") {
|
|
278
|
+
return resolveRoutePath(this.artifacts.rssPath(context), "artifacts.rssPath");
|
|
279
|
+
}
|
|
280
|
+
return appendPathSegment(this.resolveNamedRouteIndexPath(route, {
|
|
281
|
+
...context,
|
|
282
|
+
route: route.name,
|
|
283
|
+
}), "rss.xml");
|
|
284
|
+
}
|
|
285
|
+
resolveSitemapPath() {
|
|
286
|
+
return normalizePath(this.artifacts?.sitemapPath, DEFAULT_SITEMAP_PATH);
|
|
287
|
+
}
|
|
288
|
+
resolveRobotsPath() {
|
|
289
|
+
return normalizePath(this.artifacts?.robotsPath, DEFAULT_ROBOTS_PATH);
|
|
290
|
+
}
|
|
291
|
+
resolveLlmsPath() {
|
|
292
|
+
return normalizePath(this.artifacts?.llmsPath, DEFAULT_LLMS_PATH);
|
|
293
|
+
}
|
|
294
|
+
async listNamedRoutePages(locale, route) {
|
|
295
|
+
const { data } = await this.client.pages.list({
|
|
296
|
+
published: true,
|
|
297
|
+
...route.params,
|
|
298
|
+
language: locale,
|
|
299
|
+
requiredSlug: true,
|
|
300
|
+
});
|
|
301
|
+
return data
|
|
302
|
+
.filter(hasSlug)
|
|
303
|
+
.map((page) => ({
|
|
304
|
+
...page,
|
|
305
|
+
slug: page.slug.trim(),
|
|
306
|
+
}))
|
|
307
|
+
.sort(comparePagesForFeed);
|
|
308
|
+
}
|
|
309
|
+
async listLocaleRoutePages(locale) {
|
|
310
|
+
return Promise.all(this.namedRoutes.map(async (route) => ({
|
|
311
|
+
route,
|
|
312
|
+
pages: await this.listNamedRoutePages(locale, route),
|
|
313
|
+
})));
|
|
314
|
+
}
|
|
315
|
+
resolvePageTitle(page, route) {
|
|
316
|
+
if (route.title) {
|
|
317
|
+
return assertNonEmptyString(route.title(page), `${route.fieldName}.title() result`);
|
|
318
|
+
}
|
|
319
|
+
return page.metaName?.trim() || page.title;
|
|
320
|
+
}
|
|
321
|
+
resolvePageDescription(page, route) {
|
|
322
|
+
if (route.description) {
|
|
323
|
+
const customDescription = route.description(page);
|
|
324
|
+
if (typeof customDescription === "string") {
|
|
325
|
+
return (customDescription.trim() ||
|
|
326
|
+
page.metaDescription?.trim() ||
|
|
327
|
+
page.title);
|
|
328
|
+
}
|
|
329
|
+
if (customDescription === null) {
|
|
330
|
+
return page.title;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return page.metaDescription?.trim() || page.title;
|
|
334
|
+
}
|
|
335
|
+
resolveRssRoute(routeName) {
|
|
336
|
+
if (routeName?.trim()) {
|
|
337
|
+
const normalizedRouteName = assertNonEmptyString(routeName, "options.route");
|
|
338
|
+
const route = this.namedRoutes.find((candidate) => candidate.name === normalizedRouteName);
|
|
339
|
+
if (!route) {
|
|
340
|
+
throw new TypeError(`No route configured for "${normalizedRouteName}".`);
|
|
341
|
+
}
|
|
342
|
+
return route;
|
|
343
|
+
}
|
|
344
|
+
if (this.namedRoutes.length === 1) {
|
|
345
|
+
return this.namedRoutes[0];
|
|
346
|
+
}
|
|
347
|
+
throw new TypeError("options.route is required when multiple content routes are configured.");
|
|
348
|
+
}
|
|
349
|
+
robotsTxt = async () => {
|
|
350
|
+
const lines = [
|
|
351
|
+
"User-agent: *",
|
|
352
|
+
"Allow: /",
|
|
353
|
+
`Sitemap: ${toAbsoluteUrl(this.siteUrl, this.resolveSitemapPath())}`,
|
|
354
|
+
];
|
|
355
|
+
return `${lines.join("\n")}\n`;
|
|
356
|
+
};
|
|
357
|
+
sitemapXml = async () => {
|
|
358
|
+
const defaultLocale = await this.resolveDefaultLocale();
|
|
359
|
+
const site = this.makeResolvedSite(defaultLocale);
|
|
360
|
+
const locales = await this.listLocales(defaultLocale);
|
|
361
|
+
const entries = new Map();
|
|
362
|
+
function addEntry(url, lastModified) {
|
|
363
|
+
const current = entries.get(url);
|
|
364
|
+
if (!current) {
|
|
365
|
+
entries.set(url, { url, lastModified });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const resolvedLastModified = getLatestDate([
|
|
369
|
+
current.lastModified,
|
|
370
|
+
lastModified,
|
|
371
|
+
]);
|
|
372
|
+
entries.set(url, {
|
|
373
|
+
url,
|
|
374
|
+
lastModified: resolvedLastModified,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
await Promise.all(locales.map(async (locale) => {
|
|
378
|
+
const context = this.makeRouteContext(locale, defaultLocale, site);
|
|
379
|
+
const routePages = await this.listLocaleRoutePages(locale);
|
|
380
|
+
const latestLocaleUpdate = getLatestDate(routePages.flatMap(({ pages }) => pages.map((page) => getSitemapDate(page))));
|
|
381
|
+
addEntry(toAbsoluteUrl(site.url, resolveRoutePath(this.homeRoute(context), "routes.home")), latestLocaleUpdate);
|
|
382
|
+
for (const { route, pages } of routePages) {
|
|
383
|
+
const routeContext = this.makeNamedRouteContext(locale, defaultLocale, site, route.name);
|
|
384
|
+
const latestRouteUpdate = getLatestDate(pages.map((page) => getSitemapDate(page)));
|
|
385
|
+
addEntry(toAbsoluteUrl(site.url, this.resolveNamedRouteIndexPath(route, routeContext)), latestRouteUpdate);
|
|
386
|
+
for (const page of pages) {
|
|
387
|
+
addEntry(toAbsoluteUrl(site.url, this.resolveNamedRoutePostPath(route, page, routeContext)), getSitemapDate(page));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}));
|
|
391
|
+
const body = Array.from(entries.values())
|
|
392
|
+
.sort((left, right) => left.url.localeCompare(right.url))
|
|
393
|
+
.map((entry) => [
|
|
394
|
+
" <url>",
|
|
395
|
+
` <loc>${escapeXml(entry.url)}</loc>`,
|
|
396
|
+
entry.lastModified
|
|
397
|
+
? ` <lastmod>${escapeXml(entry.lastModified)}</lastmod>`
|
|
398
|
+
: undefined,
|
|
399
|
+
" </url>",
|
|
400
|
+
]
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.join("\n"))
|
|
403
|
+
.join("\n");
|
|
404
|
+
return [
|
|
405
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
406
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
407
|
+
body,
|
|
408
|
+
"</urlset>",
|
|
409
|
+
"",
|
|
410
|
+
].join("\n");
|
|
411
|
+
};
|
|
412
|
+
rssXml = async (input = {}) => {
|
|
413
|
+
const defaultLocale = await this.resolveDefaultLocale();
|
|
414
|
+
const locale = input.locale?.trim() || defaultLocale;
|
|
415
|
+
const site = this.makeResolvedSite(defaultLocale);
|
|
416
|
+
const route = this.resolveRssRoute(input.route);
|
|
417
|
+
const context = this.makeNamedRouteContext(locale, defaultLocale, site, route.name);
|
|
418
|
+
const collection = typeof route.params?.collection === "string"
|
|
419
|
+
? route.params.collection
|
|
420
|
+
: undefined;
|
|
421
|
+
const feedPath = this.resolveRssPath({ ...context, collection }, route);
|
|
422
|
+
const indexUrl = toAbsoluteUrl(site.url, this.resolveNamedRouteIndexPath(route, context));
|
|
423
|
+
const feedUrl = toAbsoluteUrl(site.url, feedPath);
|
|
424
|
+
const pages = await this.listNamedRoutePages(locale, route);
|
|
425
|
+
const title = this.namedRoutes.length === 1
|
|
426
|
+
? site.name
|
|
427
|
+
: `${site.name} - ${route.name}`;
|
|
428
|
+
const descriptionBase = site.description ?? `RSS feed for ${route.name} on ${site.name}.`;
|
|
429
|
+
const description = locale === defaultLocale
|
|
430
|
+
? descriptionBase
|
|
431
|
+
: `${descriptionBase} (${locale})`;
|
|
432
|
+
const lastBuildDate = getLatestDate(pages.map((page) => toIsoDate(page.publishedAt ??
|
|
433
|
+
page.updatedAt ??
|
|
434
|
+
page.createdAt)));
|
|
435
|
+
const items = pages
|
|
436
|
+
.map((page) => {
|
|
437
|
+
const url = toAbsoluteUrl(site.url, this.resolveNamedRoutePostPath(route, page, context));
|
|
438
|
+
const pubDate = getFeedDate(page);
|
|
439
|
+
return [
|
|
440
|
+
" <item>",
|
|
441
|
+
` <title>${escapeXml(this.resolvePageTitle(page, route))}</title>`,
|
|
442
|
+
` <link>${escapeXml(url)}</link>`,
|
|
443
|
+
` <guid>${escapeXml(url)}</guid>`,
|
|
444
|
+
pubDate
|
|
445
|
+
? ` <pubDate>${escapeXml(pubDate)}</pubDate>`
|
|
446
|
+
: undefined,
|
|
447
|
+
` <description>${escapeXml(this.resolvePageDescription(page, route))}</description>`,
|
|
448
|
+
...page.labels.map((label) => ` <category>${escapeXml(label.name)}</category>`),
|
|
449
|
+
" </item>",
|
|
450
|
+
]
|
|
451
|
+
.filter(Boolean)
|
|
452
|
+
.join("\n");
|
|
453
|
+
})
|
|
454
|
+
.join("\n");
|
|
455
|
+
return [
|
|
456
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
457
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
|
458
|
+
" <channel>",
|
|
459
|
+
` <title>${escapeXml(title)}</title>`,
|
|
460
|
+
` <link>${escapeXml(indexUrl)}</link>`,
|
|
461
|
+
` <description>${escapeXml(description)}</description>`,
|
|
462
|
+
` <language>${escapeXml(locale)}</language>`,
|
|
463
|
+
" <generator>@paragraphcms/seo</generator>",
|
|
464
|
+
` <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />`,
|
|
465
|
+
lastBuildDate
|
|
466
|
+
? ` <lastBuildDate>${escapeXml(toRssDate(lastBuildDate) ?? "")}</lastBuildDate>`
|
|
467
|
+
: undefined,
|
|
468
|
+
items || undefined,
|
|
469
|
+
" </channel>",
|
|
470
|
+
"</rss>",
|
|
471
|
+
"",
|
|
472
|
+
]
|
|
473
|
+
.filter(Boolean)
|
|
474
|
+
.join("\n");
|
|
475
|
+
};
|
|
476
|
+
llmsTxt = async (input = {}) => {
|
|
477
|
+
const defaultLocale = await this.resolveDefaultLocale();
|
|
478
|
+
const locale = input.locale?.trim() || defaultLocale;
|
|
479
|
+
const site = this.makeResolvedSite(defaultLocale);
|
|
480
|
+
const locales = await this.listLocales(defaultLocale, [locale]);
|
|
481
|
+
const routePages = await this.listLocaleRoutePages(locale);
|
|
482
|
+
const pages = routePages
|
|
483
|
+
.flatMap(({ route, pages }) => pages.map((page) => ({ route, page })))
|
|
484
|
+
.sort(compareRoutedPages);
|
|
485
|
+
const lines = [
|
|
486
|
+
`# ${site.name}`,
|
|
487
|
+
site.description ? `> ${site.description}` : undefined,
|
|
488
|
+
"",
|
|
489
|
+
`Canonical: ${site.url}`,
|
|
490
|
+
`Default locale: ${defaultLocale}`,
|
|
491
|
+
`Sitemap: ${toAbsoluteUrl(site.url, this.resolveSitemapPath())}`,
|
|
492
|
+
`Robots: ${toAbsoluteUrl(site.url, this.resolveRobotsPath())}`,
|
|
493
|
+
`LLMs: ${toAbsoluteUrl(site.url, this.resolveLlmsPath())}`,
|
|
494
|
+
"",
|
|
495
|
+
"## Routes",
|
|
496
|
+
...locales.flatMap((currentLocale) => this.namedRoutes.map((route) => {
|
|
497
|
+
const routeContext = this.makeNamedRouteContext(currentLocale, defaultLocale, site, route.name);
|
|
498
|
+
const collection = typeof route.params?.collection === "string"
|
|
499
|
+
? route.params.collection
|
|
500
|
+
: undefined;
|
|
501
|
+
return `- ${currentLocale} / ${route.name}: ${toAbsoluteUrl(site.url, this.resolveNamedRouteIndexPath(route, routeContext))} (RSS: ${toAbsoluteUrl(site.url, this.resolveRssPath({ ...routeContext, collection }, route))})`;
|
|
502
|
+
})),
|
|
503
|
+
"",
|
|
504
|
+
`## Posts (${locale})`,
|
|
505
|
+
...(pages.length > 0
|
|
506
|
+
? pages.map(({ route, page }) => {
|
|
507
|
+
const routeContext = this.makeNamedRouteContext(locale, defaultLocale, site, route.name);
|
|
508
|
+
return `- [${route.name}] ${this.resolvePageTitle(page, route)}: ${toAbsoluteUrl(site.url, this.resolveNamedRoutePostPath(route, page, routeContext))}`;
|
|
509
|
+
})
|
|
510
|
+
: ["- No posts found."]),
|
|
511
|
+
];
|
|
512
|
+
return `${lines
|
|
513
|
+
.filter((line) => line !== undefined)
|
|
514
|
+
.join("\n")}\n`;
|
|
515
|
+
};
|
|
516
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ListResponse, Locale, PageListQuery, PageSummary } from "@paragraphcms/client";
|
|
2
|
+
export type SeoPage<TPage extends PageSummary = PageSummary> = TPage & {
|
|
3
|
+
slug: string;
|
|
4
|
+
};
|
|
5
|
+
export interface SeoClient<TPage extends PageSummary = PageSummary> {
|
|
6
|
+
locales: {
|
|
7
|
+
list(): Promise<Locale[]>;
|
|
8
|
+
getDefaultLocale(): Promise<string>;
|
|
9
|
+
};
|
|
10
|
+
pages: {
|
|
11
|
+
list(query?: PageListQuery): Promise<ListResponse<TPage>>;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export interface SeoSiteConfig {
|
|
15
|
+
url: string;
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
defaultLocale?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ResolvedSeoSiteConfig {
|
|
21
|
+
url: string;
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
defaultLocale: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SeoRouteContext {
|
|
27
|
+
locale: string;
|
|
28
|
+
defaultLocale: string;
|
|
29
|
+
site: ResolvedSeoSiteConfig;
|
|
30
|
+
}
|
|
31
|
+
export interface SeoNamedRouteContext extends SeoRouteContext {
|
|
32
|
+
route: string;
|
|
33
|
+
}
|
|
34
|
+
export interface SeoPostRouteContext<TPage extends PageSummary = PageSummary> extends SeoNamedRouteContext {
|
|
35
|
+
page: SeoPage<TPage>;
|
|
36
|
+
slug: string;
|
|
37
|
+
}
|
|
38
|
+
export type SeoRouteParams = Omit<PageListQuery, "language" | "requiredSlug" | "page" | "limit">;
|
|
39
|
+
export interface SeoContentRouteDefinition<TPage extends PageSummary = PageSummary> {
|
|
40
|
+
/**
|
|
41
|
+
* Forwarded to `client.pages.list()`.
|
|
42
|
+
* `published` defaults to `true` unless you override it here.
|
|
43
|
+
* Supports client filters such as `statusId` and `statusType`.
|
|
44
|
+
*/
|
|
45
|
+
params?: SeoRouteParams;
|
|
46
|
+
index: (params: SeoNamedRouteContext) => string;
|
|
47
|
+
post: (params: SeoPostRouteContext<TPage>) => string;
|
|
48
|
+
title?: (page: SeoPage<TPage>) => string;
|
|
49
|
+
description?: (page: SeoPage<TPage>) => string | null | undefined;
|
|
50
|
+
}
|
|
51
|
+
export interface SeoLocalizedContentRouteDefinition<TPage extends PageSummary = PageSummary> {
|
|
52
|
+
/**
|
|
53
|
+
* Localized base path for the route, without the locale prefix
|
|
54
|
+
* and without the trailing post slug.
|
|
55
|
+
* Example: `"blog"` -> `/blog` and `/pl/blog`.
|
|
56
|
+
*/
|
|
57
|
+
basePath: string;
|
|
58
|
+
/**
|
|
59
|
+
* Forwarded to `client.pages.list()`.
|
|
60
|
+
* `published` defaults to `true` unless you override it here.
|
|
61
|
+
* Supports client filters such as `statusId` and `statusType`.
|
|
62
|
+
*/
|
|
63
|
+
params?: SeoRouteParams;
|
|
64
|
+
title?: (page: SeoPage<TPage>) => string;
|
|
65
|
+
description?: (page: SeoPage<TPage>) => string | null | undefined;
|
|
66
|
+
}
|
|
67
|
+
export type SeoNamedRouteDefinition<TPage extends PageSummary = PageSummary> = SeoContentRouteDefinition<TPage> | SeoLocalizedContentRouteDefinition<TPage>;
|
|
68
|
+
export type SeoRouteEntry<TPage extends PageSummary = PageSummary> = ((params: SeoRouteContext) => string) | SeoNamedRouteDefinition<TPage>;
|
|
69
|
+
export interface SeoRoutes<TPage extends PageSummary = PageSummary> {
|
|
70
|
+
home: (params: SeoRouteContext) => string;
|
|
71
|
+
[routeName: string]: SeoRouteEntry<TPage>;
|
|
72
|
+
}
|
|
73
|
+
export interface SeoArtifactPathContext extends SeoRouteContext {
|
|
74
|
+
route?: string;
|
|
75
|
+
collection?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface SeoArtifactsConfig {
|
|
78
|
+
sitemapPath?: string;
|
|
79
|
+
robotsPath?: string;
|
|
80
|
+
llmsPath?: string;
|
|
81
|
+
rssPath?: (params: SeoArtifactPathContext) => string;
|
|
82
|
+
}
|
|
83
|
+
export interface CreateSeoOptions<TPage extends PageSummary = PageSummary> {
|
|
84
|
+
client: SeoClient<TPage>;
|
|
85
|
+
site: SeoSiteConfig;
|
|
86
|
+
routes: SeoRoutes<TPage>;
|
|
87
|
+
artifacts?: SeoArtifactsConfig;
|
|
88
|
+
}
|
|
89
|
+
export interface SeoRssXmlOptions {
|
|
90
|
+
locale?: string;
|
|
91
|
+
route?: string;
|
|
92
|
+
}
|
|
93
|
+
export interface SeoLlmsTxtOptions {
|
|
94
|
+
locale?: string;
|
|
95
|
+
}
|
|
96
|
+
export interface SeoApi<TPage extends PageSummary = PageSummary> {
|
|
97
|
+
robotsTxt(): Promise<string>;
|
|
98
|
+
sitemapXml(): Promise<string>;
|
|
99
|
+
rssXml(options?: SeoRssXmlOptions): Promise<string>;
|
|
100
|
+
llmsTxt(options?: SeoLlmsTxtOptions): Promise<string>;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paragraphcms/seo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SEO document generators for Paragraph CMS sites.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Paragraph CMS",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"prepublishOnly": "npm run check && npm run build",
|
|
31
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"paragraphcms",
|
|
35
|
+
"seo",
|
|
36
|
+
"sitemap",
|
|
37
|
+
"rss",
|
|
38
|
+
"robots"
|
|
39
|
+
],
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@paragraphcms/client": "^2.4.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@paragraphcms/client": "^2.4.0",
|
|
45
|
+
"typescript": "^5.9.3"
|
|
46
|
+
}
|
|
47
|
+
}
|