@actuate-media/cms-core 0.66.0 → 0.68.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/CHANGELOG.md +151 -0
- package/dist/__tests__/api/ai-quality.test.js +73 -0
- package/dist/__tests__/api/ai-quality.test.js.map +1 -1
- package/dist/__tests__/api/llms-txt.test.js +284 -0
- package/dist/__tests__/api/llms-txt.test.js.map +1 -1
- package/dist/__tests__/api/public-seo.test.js +402 -4
- package/dist/__tests__/api/public-seo.test.js.map +1 -1
- package/dist/__tests__/api/route-contracts.test.js +7 -0
- package/dist/__tests__/api/route-contracts.test.js.map +1 -1
- package/dist/__tests__/content/slug.test.d.ts +2 -0
- package/dist/__tests__/content/slug.test.d.ts.map +1 -0
- package/dist/__tests__/content/slug.test.js +68 -0
- package/dist/__tests__/content/slug.test.js.map +1 -0
- package/dist/__tests__/redirects/auto-301.test.d.ts +2 -0
- package/dist/__tests__/redirects/auto-301.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/auto-301.test.js +172 -0
- package/dist/__tests__/redirects/auto-301.test.js.map +1 -0
- package/dist/__tests__/seo/ai-bots.test.d.ts +2 -0
- package/dist/__tests__/seo/ai-bots.test.d.ts.map +1 -0
- package/dist/__tests__/seo/ai-bots.test.js +53 -0
- package/dist/__tests__/seo/ai-bots.test.js.map +1 -0
- package/dist/__tests__/seo/alt-text.test.d.ts +2 -0
- package/dist/__tests__/seo/alt-text.test.d.ts.map +1 -0
- package/dist/__tests__/seo/alt-text.test.js +47 -0
- package/dist/__tests__/seo/alt-text.test.js.map +1 -0
- package/dist/__tests__/seo/feeds.test.d.ts +2 -0
- package/dist/__tests__/seo/feeds.test.d.ts.map +1 -0
- package/dist/__tests__/seo/feeds.test.js +61 -0
- package/dist/__tests__/seo/feeds.test.js.map +1 -0
- package/dist/__tests__/seo/freshness.test.d.ts +2 -0
- package/dist/__tests__/seo/freshness.test.d.ts.map +1 -0
- package/dist/__tests__/seo/freshness.test.js +89 -0
- package/dist/__tests__/seo/freshness.test.js.map +1 -0
- package/dist/__tests__/seo/llms-sanitize.test.d.ts +2 -0
- package/dist/__tests__/seo/llms-sanitize.test.d.ts.map +1 -0
- package/dist/__tests__/seo/llms-sanitize.test.js +74 -0
- package/dist/__tests__/seo/llms-sanitize.test.js.map +1 -0
- package/dist/__tests__/seo/meta-tags-alternates.test.d.ts +2 -0
- package/dist/__tests__/seo/meta-tags-alternates.test.d.ts.map +1 -0
- package/dist/__tests__/seo/meta-tags-alternates.test.js +52 -0
- package/dist/__tests__/seo/meta-tags-alternates.test.js.map +1 -0
- package/dist/__tests__/seo/page-meta.test.js +230 -14
- package/dist/__tests__/seo/page-meta.test.js.map +1 -1
- package/dist/__tests__/seo/sitemap-notify.test.js +77 -1
- package/dist/__tests__/seo/sitemap-notify.test.js.map +1 -1
- package/dist/__tests__/validation/publish.test.d.ts +2 -0
- package/dist/__tests__/validation/publish.test.d.ts.map +1 -0
- package/dist/__tests__/validation/publish.test.js +164 -0
- package/dist/__tests__/validation/publish.test.js.map +1 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +172 -8
- package/dist/actions.js.map +1 -1
- package/dist/api/route-helpers.d.ts +8 -0
- package/dist/api/route-helpers.d.ts.map +1 -1
- package/dist/api/route-helpers.js +20 -0
- package/dist/api/route-helpers.js.map +1 -1
- package/dist/api/routes/ai-seo.d.ts.map +1 -1
- package/dist/api/routes/ai-seo.js +32 -1
- package/dist/api/routes/ai-seo.js.map +1 -1
- package/dist/api/routes/documents.d.ts.map +1 -1
- package/dist/api/routes/documents.js +3 -3
- package/dist/api/routes/documents.js.map +1 -1
- package/dist/api/routes/seo-public.d.ts.map +1 -1
- package/dist/api/routes/seo-public.js +371 -62
- package/dist/api/routes/seo-public.js.map +1 -1
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +124 -16
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/api/routes/url-resolution.d.ts.map +1 -1
- package/dist/api/routes/url-resolution.js +40 -1
- package/dist/api/routes/url-resolution.js.map +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -1
- package/dist/config/types.d.ts +51 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/content/index.d.ts +1 -0
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +1 -0
- package/dist/content/index.js.map +1 -1
- package/dist/content/slug.d.ts +40 -0
- package/dist/content/slug.d.ts.map +1 -0
- package/dist/content/slug.js +78 -0
- package/dist/content/slug.js.map +1 -0
- package/dist/content/structured-data.d.ts +6 -0
- package/dist/content/structured-data.d.ts.map +1 -1
- package/dist/content/structured-data.js +6 -0
- package/dist/content/structured-data.js.map +1 -1
- package/dist/content-health/scanner.d.ts +4 -3
- package/dist/content-health/scanner.d.ts.map +1 -1
- package/dist/content-health/scanner.js +6 -12
- package/dist/content-health/scanner.js.map +1 -1
- package/dist/redirects/auto.d.ts +45 -0
- package/dist/redirects/auto.d.ts.map +1 -0
- package/dist/redirects/auto.js +55 -0
- package/dist/redirects/auto.js.map +1 -0
- package/dist/redirects/index.d.ts +2 -0
- package/dist/redirects/index.d.ts.map +1 -1
- package/dist/redirects/index.js +1 -0
- package/dist/redirects/index.js.map +1 -1
- package/dist/seo/ai-bots.d.ts +42 -0
- package/dist/seo/ai-bots.d.ts.map +1 -0
- package/dist/seo/ai-bots.js +104 -0
- package/dist/seo/ai-bots.js.map +1 -0
- package/dist/seo/alt-text.d.ts +39 -0
- package/dist/seo/alt-text.d.ts.map +1 -0
- package/dist/seo/alt-text.js +67 -0
- package/dist/seo/alt-text.js.map +1 -0
- package/dist/seo/analysis.d.ts.map +1 -1
- package/dist/seo/analysis.js +7 -11
- package/dist/seo/analysis.js.map +1 -1
- package/dist/seo/audit-engine.d.ts +1 -0
- package/dist/seo/audit-engine.d.ts.map +1 -1
- package/dist/seo/audit-engine.js +4 -4
- package/dist/seo/audit-engine.js.map +1 -1
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js +1 -0
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/feeds.d.ts +36 -0
- package/dist/seo/feeds.d.ts.map +1 -0
- package/dist/seo/feeds.js +71 -0
- package/dist/seo/feeds.js.map +1 -0
- package/dist/seo/index.d.ts +10 -4
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +6 -3
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/llms-txt.d.ts +24 -0
- package/dist/seo/llms-txt.d.ts.map +1 -1
- package/dist/seo/llms-txt.js +109 -47
- package/dist/seo/llms-txt.js.map +1 -1
- package/dist/seo/meta-tags.d.ts +14 -0
- package/dist/seo/meta-tags.d.ts.map +1 -1
- package/dist/seo/meta-tags.js +21 -2
- package/dist/seo/meta-tags.js.map +1 -1
- package/dist/seo/page-meta.d.ts +47 -0
- package/dist/seo/page-meta.d.ts.map +1 -1
- package/dist/seo/page-meta.js +280 -70
- package/dist/seo/page-meta.js.map +1 -1
- package/dist/seo/sitemap-notify.d.ts +30 -0
- package/dist/seo/sitemap-notify.d.ts.map +1 -1
- package/dist/seo/sitemap-notify.js +87 -0
- package/dist/seo/sitemap-notify.js.map +1 -1
- package/dist/validation/publish.d.ts +53 -0
- package/dist/validation/publish.d.ts.map +1 -0
- package/dist/validation/publish.js +75 -0
- package/dist/validation/publish.js.map +1 -0
- package/package.json +1 -1
|
@@ -3,6 +3,10 @@ import { safeFetch, SsrfBlockedError } from '../../security/safe-fetch.js';
|
|
|
3
3
|
import { getActuateConfig } from '../../config/runtime.js';
|
|
4
4
|
import { guardedDb } from '../guarded-db.js';
|
|
5
5
|
import { computeLightSeoScore } from './dashboard.js';
|
|
6
|
+
import { resolveDocumentUrl } from '../../seo/page-meta.js';
|
|
7
|
+
import { resolveRobotsDirectives, resolveSeoEnvironment } from '../../seo/robots.js';
|
|
8
|
+
import { buildAiBotRobotsLines } from '../../seo/ai-bots.js';
|
|
9
|
+
import { getSeoOverrides, resolveSeoModuleSettings, } from '../../seo/config-store.js';
|
|
6
10
|
import { json, errorResponse, internalError, escapeXml, SITEMAP_PAGE_SIZE, renderOgSvg, safeFindMany, requireAuth, WRITE_ROLES, ADMIN_ROLES, requireRole, enforceDocumentReadAccess, } from '../route-helpers.js';
|
|
7
11
|
/**
|
|
8
12
|
* Load the runtime config with DB-stored SEO overrides applied. Used by every
|
|
@@ -25,6 +29,34 @@ export async function loadEffectiveConfig() {
|
|
|
25
29
|
return base;
|
|
26
30
|
}
|
|
27
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether a published document resolves to `noindex` and therefore must be kept
|
|
34
|
+
* OUT of the sitemap. Listing a noindexed URL in the sitemap ("index me") while
|
|
35
|
+
* its page emits a `noindex` meta tag ("don't") sends crawlers conflicting
|
|
36
|
+
* signals and wastes crawl budget — Google explicitly flags this. Mirrors the
|
|
37
|
+
* robots inputs used by `composePageMeta` so the sitemap and the rendered meta
|
|
38
|
+
* tag always agree.
|
|
39
|
+
*/
|
|
40
|
+
function isDocumentNoIndex(data, collectionSeo, siteSeo, environment) {
|
|
41
|
+
const pageSettings = (data.pageSettings ?? {});
|
|
42
|
+
const robots = resolveRobotsDirectives({
|
|
43
|
+
pageData: {
|
|
44
|
+
robotsPolicy: pageSettings.robotsPolicy ?? data.robotsPolicy,
|
|
45
|
+
noIndex: pageSettings.noIndex ??
|
|
46
|
+
data.noIndex ??
|
|
47
|
+
// Legacy lowercase field still written by some editors / counted by the
|
|
48
|
+
// sitemap status endpoint.
|
|
49
|
+
data.noindex ??
|
|
50
|
+
collectionSeo?.defaultRobots?.noIndex,
|
|
51
|
+
},
|
|
52
|
+
settings: {
|
|
53
|
+
defaultNoIndex: siteSeo?.robots?.defaultNoIndex,
|
|
54
|
+
noIndexNonProduction: siteSeo?.robots?.noIndexNonProduction,
|
|
55
|
+
},
|
|
56
|
+
environment,
|
|
57
|
+
});
|
|
58
|
+
return robots.noIndex;
|
|
59
|
+
}
|
|
28
60
|
export function siteUrlFromRequest(request, cfg) {
|
|
29
61
|
const seo = (cfg ?? getActuateConfig())?.seo;
|
|
30
62
|
if (seo?.siteUrl)
|
|
@@ -46,10 +78,11 @@ export function registerSeoPublicRoutes(router) {
|
|
|
46
78
|
// and the dynamic /og.png OG-image endpoint. These are unauthenticated by
|
|
47
79
|
// design — search engines and social crawlers will fetch them.
|
|
48
80
|
// ---------------------------------------------------------------------------
|
|
49
|
-
function sitemapEligibleCollections(cfg) {
|
|
81
|
+
function sitemapEligibleCollections(cfg, opts) {
|
|
50
82
|
const resolved = cfg ?? getActuateConfig();
|
|
51
83
|
if (!resolved)
|
|
52
84
|
return [];
|
|
85
|
+
const includePosts = opts?.includePosts ?? true;
|
|
53
86
|
const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
|
|
54
87
|
const out = [];
|
|
55
88
|
for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
|
|
@@ -58,6 +91,10 @@ export function registerSeoPublicRoutes(router) {
|
|
|
58
91
|
// no `type` and must never produce indexable sitemap URLs.
|
|
59
92
|
if (col.type !== 'page' && col.type !== 'post')
|
|
60
93
|
continue;
|
|
94
|
+
// Honor the "Include posts in sitemap" crawl setting (Settings → SEO →
|
|
95
|
+
// Technical). When off, post-type collections are dropped entirely.
|
|
96
|
+
if (col.type === 'post' && !includePosts)
|
|
97
|
+
continue;
|
|
61
98
|
if (col.admin?.hidden)
|
|
62
99
|
continue;
|
|
63
100
|
if (excluded.has(slug))
|
|
@@ -74,7 +111,27 @@ export function registerSeoPublicRoutes(router) {
|
|
|
74
111
|
if (cfg?.seo?.sitemap?.disabled)
|
|
75
112
|
return errorResponse('Sitemap disabled', 404);
|
|
76
113
|
const base = siteUrlFromRequest(request, cfg);
|
|
77
|
-
const
|
|
114
|
+
const moduleSettings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
115
|
+
// Master switch: when the whole public site is opted out of indexing, the
|
|
116
|
+
// sitemap (a positive "please index these" signal) must be empty so it
|
|
117
|
+
// doesn't contradict the `Disallow: /` we emit in robots.txt.
|
|
118
|
+
if (moduleSettings.indexEntireSite === false) {
|
|
119
|
+
const emptyIndex = [
|
|
120
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
121
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
122
|
+
'</sitemapindex>',
|
|
123
|
+
].join('\n');
|
|
124
|
+
return new Response(emptyIndex, {
|
|
125
|
+
status: 200,
|
|
126
|
+
headers: {
|
|
127
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
128
|
+
'Cache-Control': 'public, max-age=300, s-maxage=600',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const cols = sitemapEligibleCollections(cfg, {
|
|
133
|
+
includePosts: moduleSettings.includePostsInSitemap,
|
|
134
|
+
});
|
|
78
135
|
// Sitemap index points at /sitemaps/:slug.xml for each collection. A
|
|
79
136
|
// collection with more than SITEMAP_PAGE_SIZE published docs is split
|
|
80
137
|
// across `?page=N` files so we never silently truncate (the old code
|
|
@@ -82,10 +139,22 @@ export function registerSeoPublicRoutes(router) {
|
|
|
82
139
|
const now = new Date().toISOString();
|
|
83
140
|
const perCollection = await Promise.all(cols.map(async (c) => {
|
|
84
141
|
let count = 0;
|
|
142
|
+
// Report the newest published change in the collection as the child
|
|
143
|
+
// sitemap's <lastmod> instead of "now". A lastmod that changes on
|
|
144
|
+
// every fetch trains crawlers to ignore it; an accurate one is the
|
|
145
|
+
// single most useful signal we can give them.
|
|
146
|
+
let lastmod = now;
|
|
85
147
|
try {
|
|
86
148
|
count = await db().document.count({
|
|
87
149
|
where: { collection: c.slug, deletedAt: null, status: 'PUBLISHED' },
|
|
88
150
|
});
|
|
151
|
+
const newest = await db().document.findFirst({
|
|
152
|
+
where: { collection: c.slug, deletedAt: null, status: 'PUBLISHED' },
|
|
153
|
+
select: { updatedAt: true },
|
|
154
|
+
orderBy: { updatedAt: 'desc' },
|
|
155
|
+
});
|
|
156
|
+
if (newest?.updatedAt)
|
|
157
|
+
lastmod = newest.updatedAt.toISOString();
|
|
89
158
|
}
|
|
90
159
|
catch {
|
|
91
160
|
count = 0;
|
|
@@ -99,7 +168,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
99
168
|
entries.push([
|
|
100
169
|
' <sitemap>',
|
|
101
170
|
` <loc>${escapeXml(loc)}</loc>`,
|
|
102
|
-
` <lastmod>${
|
|
171
|
+
` <lastmod>${lastmod}</lastmod>`,
|
|
103
172
|
' </sitemap>',
|
|
104
173
|
].join('\n'));
|
|
105
174
|
}
|
|
@@ -148,32 +217,115 @@ export function registerSeoPublicRoutes(router) {
|
|
|
148
217
|
return errorResponse('Collection excluded from sitemap', 404);
|
|
149
218
|
if (collection.seo?.excludeFromSitemap)
|
|
150
219
|
return errorResponse('Collection excluded from sitemap', 404);
|
|
220
|
+
const moduleSettings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
221
|
+
// Master switch: whole public site opted out of indexing → empty urlset
|
|
222
|
+
// (mirrors the empty sitemap index + `Disallow: /` in robots.txt).
|
|
223
|
+
if (moduleSettings.indexEntireSite === false) {
|
|
224
|
+
const emptyUrlset = [
|
|
225
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
226
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
227
|
+
'</urlset>',
|
|
228
|
+
].join('\n');
|
|
229
|
+
return new Response(emptyUrlset, {
|
|
230
|
+
status: 200,
|
|
231
|
+
headers: {
|
|
232
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
233
|
+
'Cache-Control': 'public, max-age=300, s-maxage=600',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// Honor the "Include posts in sitemap" crawl setting consistently with the
|
|
238
|
+
// sitemap index, so a post-type sitemap can't be reached when posts are
|
|
239
|
+
// excluded there.
|
|
240
|
+
if (collection.type === 'post' && !moduleSettings.includePostsInSitemap)
|
|
241
|
+
return errorResponse('Collection excluded from sitemap', 404);
|
|
151
242
|
const base = siteUrlFromRequest(request, cfg);
|
|
243
|
+
const environment = resolveSeoEnvironment();
|
|
152
244
|
const pageParam = Number(new URL(request.url).searchParams.get('page') ?? '0');
|
|
153
245
|
const page = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 0;
|
|
154
246
|
const docs = await db().document.findMany({
|
|
155
247
|
where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
|
|
156
|
-
select: { slug: true, updatedAt: true, data: true },
|
|
248
|
+
select: { slug: true, updatedAt: true, data: true, locale: true, localeGroupId: true },
|
|
157
249
|
orderBy: { updatedAt: 'desc' },
|
|
158
250
|
skip: page * SITEMAP_PAGE_SIZE,
|
|
159
251
|
take: SITEMAP_PAGE_SIZE,
|
|
160
252
|
});
|
|
161
|
-
|
|
253
|
+
// hreflang: when the site runs i18n with more than one locale, emit
|
|
254
|
+
// `<xhtml:link rel="alternate">` annotations so search engines serve the
|
|
255
|
+
// right language variant. Each translation shares a `localeGroupId`; its
|
|
256
|
+
// own `data.path`/slug yields the localized URL via resolveDocumentUrl, so
|
|
257
|
+
// this works regardless of the locale routing strategy. Skipped entirely
|
|
258
|
+
// (zero extra queries) for single-locale sites.
|
|
259
|
+
const i18nLocales = cfg?.i18n?.locales ?? [];
|
|
260
|
+
const i18nEnabled = i18nLocales.length > 1;
|
|
261
|
+
const defaultLocale = cfg?.i18n?.defaultLocale;
|
|
262
|
+
const alternatesByGroup = new Map();
|
|
263
|
+
if (i18nEnabled) {
|
|
264
|
+
const groupIds = [
|
|
265
|
+
...new Set(docs.map((d) => d.localeGroupId).filter((g) => !!g)),
|
|
266
|
+
];
|
|
267
|
+
if (groupIds.length > 0) {
|
|
268
|
+
const siblings = await db().document.findMany({
|
|
269
|
+
where: {
|
|
270
|
+
collection: rawSlug,
|
|
271
|
+
deletedAt: null,
|
|
272
|
+
status: 'PUBLISHED',
|
|
273
|
+
localeGroupId: { in: groupIds },
|
|
274
|
+
},
|
|
275
|
+
select: { slug: true, data: true, locale: true, localeGroupId: true },
|
|
276
|
+
});
|
|
277
|
+
for (const s of siblings) {
|
|
278
|
+
if (!s.localeGroupId)
|
|
279
|
+
continue;
|
|
280
|
+
const sData = s.data || {};
|
|
281
|
+
const loc = resolveDocumentUrl({
|
|
282
|
+
siteUrl: base,
|
|
283
|
+
urlPrefix: collection.urlPrefix,
|
|
284
|
+
slug: s.slug,
|
|
285
|
+
data: sData,
|
|
286
|
+
});
|
|
287
|
+
const list = alternatesByGroup.get(s.localeGroupId) ?? [];
|
|
288
|
+
list.push({ locale: s.locale, loc });
|
|
289
|
+
alternatesByGroup.set(s.localeGroupId, list);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
162
293
|
const defaultPriority = collection.seo?.sitemapPriority ??
|
|
163
294
|
cfg?.seo?.sitemap?.defaultPriority ??
|
|
164
295
|
(collection.type === 'page' ? 0.8 : 0.6);
|
|
165
296
|
const changefreq = collection.seo?.sitemapChangeFreq ?? cfg?.seo?.sitemap?.defaultChangeFreq ?? 'weekly';
|
|
166
297
|
const urls = [];
|
|
298
|
+
let hreflangUsed = false;
|
|
299
|
+
const renderAlternates = (groupId) => {
|
|
300
|
+
if (!i18nEnabled || !groupId)
|
|
301
|
+
return [];
|
|
302
|
+
const alts = alternatesByGroup.get(groupId);
|
|
303
|
+
if (!alts || alts.length < 2)
|
|
304
|
+
return [];
|
|
305
|
+
hreflangUsed = true;
|
|
306
|
+
const lines = alts.map((a) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.locale ?? defaultLocale ?? 'und')}" href="${escapeXml(a.loc)}" />`);
|
|
307
|
+
const fallback = alts.find((a) => a.locale === defaultLocale) ?? alts[0];
|
|
308
|
+
if (fallback) {
|
|
309
|
+
lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(fallback.loc)}" />`);
|
|
310
|
+
}
|
|
311
|
+
return lines;
|
|
312
|
+
};
|
|
167
313
|
// Archive page first (e.g. /blog) for post-type collections — only on
|
|
168
314
|
// the first page so it isn't duplicated across paginated sitemap files.
|
|
169
315
|
if (page === 0 && collection.seo?.archivePath && collection.type === 'post') {
|
|
170
316
|
const archive = collection.seo.archivePath.startsWith('http')
|
|
171
317
|
? collection.seo.archivePath
|
|
172
318
|
: `${base}${collection.seo.archivePath}`;
|
|
319
|
+
// The archive reflects its newest post, so derive lastmod from the most
|
|
320
|
+
// recently updated published doc rather than the current time.
|
|
321
|
+
const archiveLastmod = docs.reduce((newest, d) => {
|
|
322
|
+
const u = d.updatedAt instanceof Date ? d.updatedAt : null;
|
|
323
|
+
return u && (!newest || u > newest) ? u : newest;
|
|
324
|
+
}, null) ?? new Date();
|
|
173
325
|
urls.push([
|
|
174
326
|
' <url>',
|
|
175
327
|
` <loc>${escapeXml(archive)}</loc>`,
|
|
176
|
-
` <lastmod>${
|
|
328
|
+
` <lastmod>${archiveLastmod.toISOString()}</lastmod>`,
|
|
177
329
|
` <changefreq>${changefreq}</changefreq>`,
|
|
178
330
|
' <priority>0.6</priority>',
|
|
179
331
|
' </url>',
|
|
@@ -182,21 +334,37 @@ export function registerSeoPublicRoutes(router) {
|
|
|
182
334
|
for (const d of docs) {
|
|
183
335
|
const data = d.data || {};
|
|
184
336
|
const slug = d.slug ?? data.slug;
|
|
185
|
-
|
|
337
|
+
// An explicit `data.path` can stand on its own (e.g. the homepage at
|
|
338
|
+
// "/"), so only require a slug when there's no canonical path set.
|
|
339
|
+
const hasExplicitPath = typeof data.path === 'string' && data.path.trim() !== '';
|
|
340
|
+
if (!slug && !hasExplicitPath)
|
|
186
341
|
continue;
|
|
187
|
-
|
|
342
|
+
// Never list a noindexed URL — it conflicts with the page's own robots
|
|
343
|
+
// meta tag and wastes crawl budget.
|
|
344
|
+
if (isDocumentNoIndex(data, collection.seo, cfg?.seo, environment))
|
|
345
|
+
continue;
|
|
346
|
+
const loc = resolveDocumentUrl({
|
|
347
|
+
siteUrl: base,
|
|
348
|
+
urlPrefix: collection.urlPrefix,
|
|
349
|
+
slug,
|
|
350
|
+
data,
|
|
351
|
+
});
|
|
188
352
|
urls.push([
|
|
189
353
|
' <url>',
|
|
190
354
|
` <loc>${escapeXml(loc)}</loc>`,
|
|
191
355
|
` <lastmod>${d.updatedAt?.toISOString() ?? new Date().toISOString()}</lastmod>`,
|
|
192
356
|
` <changefreq>${changefreq}</changefreq>`,
|
|
193
357
|
` <priority>${defaultPriority.toFixed(1)}</priority>`,
|
|
358
|
+
...renderAlternates(d.localeGroupId),
|
|
194
359
|
' </url>',
|
|
195
360
|
].join('\n'));
|
|
196
361
|
}
|
|
362
|
+
const urlsetOpen = hreflangUsed
|
|
363
|
+
? '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'
|
|
364
|
+
: '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
|
|
197
365
|
const xml = [
|
|
198
366
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
199
|
-
|
|
367
|
+
urlsetOpen,
|
|
200
368
|
urls.join('\n'),
|
|
201
369
|
'</urlset>',
|
|
202
370
|
].join('\n');
|
|
@@ -212,43 +380,165 @@ export function registerSeoPublicRoutes(router) {
|
|
|
212
380
|
return internalError(err, 'sitemaps/:slug.xml');
|
|
213
381
|
}
|
|
214
382
|
});
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Content feeds: RSS 2.0 (/feed.xml) + JSON Feed 1.1 (/feed.json).
|
|
385
|
+
//
|
|
386
|
+
// Newest published items across post-type collections (falling back to pages
|
|
387
|
+
// when a site has no post collections), excluding per-document noindex. Unlike
|
|
388
|
+
// the sitemap these are a subscription/distribution surface, not a crawl
|
|
389
|
+
// directive, so they are not gated by `indexEntireSite`.
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
const FEED_LIMIT = 50;
|
|
392
|
+
async function gatherFeedItems(cfg, base) {
|
|
393
|
+
const { resolveDocumentUrl } = await import('../../seo/page-meta.js');
|
|
394
|
+
const environment = resolveSeoEnvironment();
|
|
395
|
+
const siteSeo = cfg?.seo;
|
|
396
|
+
const all = sitemapEligibleCollections(cfg);
|
|
397
|
+
const posts = all.filter((c) => c.type === 'post');
|
|
398
|
+
const cols = posts.length > 0 ? posts : all;
|
|
399
|
+
const collected = [];
|
|
400
|
+
for (const c of cols) {
|
|
401
|
+
let docs = [];
|
|
402
|
+
try {
|
|
403
|
+
docs = await db().document.findMany({
|
|
404
|
+
where: { collection: c.slug, deletedAt: null, status: 'PUBLISHED' },
|
|
405
|
+
select: { slug: true, title: true, data: true, publishedAt: true, updatedAt: true },
|
|
406
|
+
orderBy: { publishedAt: 'desc' },
|
|
407
|
+
take: FEED_LIMIT,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
docs = [];
|
|
412
|
+
}
|
|
413
|
+
for (const d of docs) {
|
|
414
|
+
const data = d.data || {};
|
|
415
|
+
if (isDocumentNoIndex(data, c.seo, siteSeo, environment))
|
|
416
|
+
continue;
|
|
417
|
+
const url = resolveDocumentUrl({
|
|
418
|
+
siteUrl: base,
|
|
419
|
+
urlPrefix: c.urlPrefix,
|
|
420
|
+
slug: d.slug,
|
|
421
|
+
data,
|
|
422
|
+
});
|
|
423
|
+
const title = (typeof data.metaTitle === 'string' && data.metaTitle) ||
|
|
424
|
+
d.title ||
|
|
425
|
+
(typeof data.title === 'string' ? data.title : '') ||
|
|
426
|
+
'Untitled';
|
|
427
|
+
const description = (typeof data.keyTakeaway === 'string' && data.keyTakeaway) ||
|
|
428
|
+
(typeof data.metaDescription === 'string' && data.metaDescription) ||
|
|
429
|
+
(typeof data.excerpt === 'string' ? data.excerpt : '') ||
|
|
430
|
+
undefined;
|
|
431
|
+
const date = (d.publishedAt ?? d.updatedAt)?.toISOString();
|
|
432
|
+
const modified = d.updatedAt?.toISOString();
|
|
433
|
+
// Only advertise a modification time when it is genuinely newer than
|
|
434
|
+
// publication, so unchanged posts don't look perpetually "updated".
|
|
435
|
+
const dateModified = modified && date && modified > date ? modified : undefined;
|
|
436
|
+
collected.push({ url, title, description, date, dateModified });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Merge collections and keep the globally newest items.
|
|
440
|
+
collected.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
|
|
441
|
+
return collected.slice(0, FEED_LIMIT);
|
|
442
|
+
}
|
|
443
|
+
router.get('/feed.xml', async (request) => {
|
|
444
|
+
try {
|
|
445
|
+
const cfg = await loadEffectiveConfig();
|
|
446
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
447
|
+
const items = await gatherFeedItems(cfg, base);
|
|
448
|
+
const { buildRssFeed } = await import('../../seo/feeds.js');
|
|
449
|
+
const xml = buildRssFeed({
|
|
450
|
+
title: cfg?.seo?.siteName || 'Site',
|
|
451
|
+
homeUrl: base || '/',
|
|
452
|
+
feedUrl: `${base}/feed.xml`,
|
|
453
|
+
description: cfg?.seo?.defaultMetaDescription,
|
|
454
|
+
updated: items[0]?.date,
|
|
455
|
+
language: cfg?.i18n?.defaultLocale,
|
|
456
|
+
}, items);
|
|
457
|
+
return new Response(xml, {
|
|
458
|
+
status: 200,
|
|
459
|
+
headers: {
|
|
460
|
+
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
461
|
+
'Cache-Control': 'public, max-age=300, s-maxage=600',
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
return internalError(err, 'feed.xml');
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
router.get('/feed.json', async (request) => {
|
|
470
|
+
try {
|
|
471
|
+
const cfg = await loadEffectiveConfig();
|
|
472
|
+
const base = siteUrlFromRequest(request, cfg);
|
|
473
|
+
const items = await gatherFeedItems(cfg, base);
|
|
474
|
+
const { buildJsonFeed } = await import('../../seo/feeds.js');
|
|
475
|
+
const body = buildJsonFeed({
|
|
476
|
+
title: cfg?.seo?.siteName || 'Site',
|
|
477
|
+
homeUrl: base || '/',
|
|
478
|
+
feedUrl: `${base}/feed.json`,
|
|
479
|
+
description: cfg?.seo?.defaultMetaDescription,
|
|
480
|
+
updated: items[0]?.date,
|
|
481
|
+
language: cfg?.i18n?.defaultLocale,
|
|
482
|
+
}, items);
|
|
483
|
+
return new Response(body, {
|
|
484
|
+
status: 200,
|
|
485
|
+
headers: {
|
|
486
|
+
'Content-Type': 'application/feed+json; charset=utf-8',
|
|
487
|
+
'Cache-Control': 'public, max-age=300, s-maxage=600',
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
return internalError(err, 'feed.json');
|
|
493
|
+
}
|
|
494
|
+
});
|
|
215
495
|
router.get('/robots.txt', async (request) => {
|
|
216
496
|
try {
|
|
217
497
|
const cfg = await loadEffectiveConfig();
|
|
218
498
|
const seo = cfg?.seo;
|
|
219
499
|
if (seo?.robots?.disabled)
|
|
220
500
|
return errorResponse('robots.txt disabled', 404);
|
|
221
|
-
//
|
|
222
|
-
// the
|
|
223
|
-
|
|
501
|
+
// Read SEO module settings up-front: they drive the custom-body override,
|
|
502
|
+
// the `robotsEnabled` kill-switch, and the `indexEntireSite` master toggle.
|
|
503
|
+
let settings;
|
|
224
504
|
try {
|
|
225
|
-
|
|
226
|
-
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
227
|
-
if (settings.robotsEnabled === false)
|
|
228
|
-
return errorResponse('robots.txt disabled', 404);
|
|
229
|
-
const custom = settings.robotsTxt?.trim();
|
|
230
|
-
if (custom) {
|
|
231
|
-
return new Response(settings.robotsTxt, {
|
|
232
|
-
status: 200,
|
|
233
|
-
headers: {
|
|
234
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
235
|
-
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
}
|
|
505
|
+
settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
239
506
|
}
|
|
240
507
|
catch {
|
|
241
|
-
// Fall
|
|
508
|
+
// Fall back to defaults (indexable) on any override-read failure.
|
|
509
|
+
settings = resolveSeoModuleSettings(null);
|
|
510
|
+
}
|
|
511
|
+
if (settings.robotsEnabled === false)
|
|
512
|
+
return errorResponse('robots.txt disabled', 404);
|
|
513
|
+
// Admin-edited custom robots.txt body (Technical tab) wins when set —
|
|
514
|
+
// the editor is the single source of truth for operators who want full
|
|
515
|
+
// control over directives.
|
|
516
|
+
const custom = settings.robotsTxt?.trim();
|
|
517
|
+
if (custom) {
|
|
518
|
+
return new Response(settings.robotsTxt, {
|
|
519
|
+
status: 200,
|
|
520
|
+
headers: {
|
|
521
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
522
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
523
|
+
},
|
|
524
|
+
});
|
|
242
525
|
}
|
|
243
526
|
const base = siteUrlFromRequest(request, cfg);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
527
|
+
// `indexEntireSite` is the public-site master switch. When OFF we emit a
|
|
528
|
+
// blanket `Disallow: /` for all crawlers (which already covers /admin and
|
|
529
|
+
// /api) and stop advertising the sitemap — the operator has opted the
|
|
530
|
+
// whole public site out of indexing. This only ever tightens crawling; it
|
|
531
|
+
// never affects CMS admin/API protection, which is enforced by auth + the
|
|
532
|
+
// admin's own `noindex` meta tag regardless of this file.
|
|
533
|
+
const indexable = settings.indexEntireSite !== false;
|
|
534
|
+
// Advertise the canonical public sitemap URL (`/sitemap.xml`), not the
|
|
535
|
+
// internal `/api/cms/sitemap.xml` mount. Consumer sites proxy the root
|
|
536
|
+
// path to the CMS route; the rest of the SEO subsystem (search-engine
|
|
537
|
+
// pings, index status, llms.txt) already standardizes on `/sitemap.xml`.
|
|
538
|
+
const sitemapUrl = !indexable || seo?.sitemap?.disabled ? undefined : `${base}/sitemap.xml`;
|
|
539
|
+
const lines = indexable
|
|
540
|
+
? ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', '']
|
|
541
|
+
: ['User-agent: *', 'Disallow: /', ''];
|
|
252
542
|
for (const rule of seo?.robots?.additionalRules ?? []) {
|
|
253
543
|
lines.push(`User-agent: ${rule.userAgent}`);
|
|
254
544
|
for (const allow of rule.allow ?? [])
|
|
@@ -257,22 +547,12 @@ export function registerSeoPublicRoutes(router) {
|
|
|
257
547
|
lines.push(`Disallow: ${dis}`);
|
|
258
548
|
lines.push('');
|
|
259
549
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
'anthropic-ai',
|
|
267
|
-
'Bytespider',
|
|
268
|
-
'CCBot',
|
|
269
|
-
'Google-Extended',
|
|
270
|
-
];
|
|
271
|
-
for (const bot of bots) {
|
|
272
|
-
lines.push(`User-agent: ${bot}`);
|
|
273
|
-
lines.push('Disallow: /');
|
|
274
|
-
lines.push('');
|
|
275
|
-
}
|
|
550
|
+
// Nuanced AI-crawler policy (training vs retrieval), falling back to the
|
|
551
|
+
// legacy `blockAIBots` boolean. Only emitted when the site is indexable —
|
|
552
|
+
// when the master switch is off, the blanket `Disallow: /` above already
|
|
553
|
+
// covers every crawler.
|
|
554
|
+
if (indexable) {
|
|
555
|
+
lines.push(...buildAiBotRobotsLines(seo?.robots?.aiPolicy, seo?.robots?.blockAIBots));
|
|
276
556
|
}
|
|
277
557
|
if (sitemapUrl)
|
|
278
558
|
lines.push(`Sitemap: ${sitemapUrl}`, '');
|
|
@@ -288,6 +568,29 @@ export function registerSeoPublicRoutes(router) {
|
|
|
288
568
|
return internalError(err, 'robots.txt');
|
|
289
569
|
}
|
|
290
570
|
});
|
|
571
|
+
// IndexNow key verification file. IndexNow (Bing, Yandex, Seznam, Naver, …)
|
|
572
|
+
// requires the submitting key to be retrievable from the same host; we serve
|
|
573
|
+
// it here and advertise this path as `keyLocation` when submitting. The key
|
|
574
|
+
// comes from the `INDEXNOW_KEY` env var — when unset, IndexNow is disabled and
|
|
575
|
+
// this returns 404. The key is a public verification token by design (it's
|
|
576
|
+
// embedded in every submission), so serving it is not a secret leak.
|
|
577
|
+
router.get('/indexnow-key.txt', async () => {
|
|
578
|
+
try {
|
|
579
|
+
const key = process.env.INDEXNOW_KEY?.trim();
|
|
580
|
+
if (!key)
|
|
581
|
+
return errorResponse('IndexNow not configured', 404);
|
|
582
|
+
return new Response(key, {
|
|
583
|
+
status: 200,
|
|
584
|
+
headers: {
|
|
585
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
586
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
return internalError(err, 'indexnow-key.txt');
|
|
592
|
+
}
|
|
593
|
+
});
|
|
291
594
|
router.get('/og.png', async (request) => {
|
|
292
595
|
try {
|
|
293
596
|
const cfg = await loadEffectiveConfig();
|
|
@@ -341,7 +644,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
341
644
|
url: `/${doc.collection}/${doc.slug || ''}`,
|
|
342
645
|
title: doc.title || data.title || '',
|
|
343
646
|
description: data.metaDescription || data.seoDescription || '',
|
|
344
|
-
datePublished: doc.createdAt?.toISOString(),
|
|
647
|
+
datePublished: (doc.publishedAt ?? doc.createdAt)?.toISOString(),
|
|
345
648
|
dateModified: doc.updatedAt?.toISOString(),
|
|
346
649
|
type: doc.collection === 'posts' ? 'post' : 'page',
|
|
347
650
|
keywords: data.tags || data.keywords,
|
|
@@ -398,11 +701,11 @@ export function registerSeoPublicRoutes(router) {
|
|
|
398
701
|
twitterTitle: data.twitterTitle,
|
|
399
702
|
twitterDescription: data.twitterDescription,
|
|
400
703
|
twitterImage: data.twitterImage,
|
|
401
|
-
publishedTime: doc.createdAt?.toISOString(),
|
|
704
|
+
publishedTime: (doc.publishedAt ?? doc.createdAt)?.toISOString(),
|
|
402
705
|
modifiedTime: doc.updatedAt?.toISOString(),
|
|
403
706
|
noIndex: robots.noIndex,
|
|
404
707
|
noFollow: robots.noFollow,
|
|
405
|
-
canonical: data.canonical,
|
|
708
|
+
canonical: data.canonical || data.canonicalUrl,
|
|
406
709
|
});
|
|
407
710
|
return new Response(JSON.stringify(tags), {
|
|
408
711
|
status: 200,
|
|
@@ -687,10 +990,16 @@ export function registerSeoPublicRoutes(router) {
|
|
|
687
990
|
}, userId);
|
|
688
991
|
}
|
|
689
992
|
/** Build the default robots.txt body (mirrors the public /robots.txt route). */
|
|
690
|
-
function buildDefaultRobots(request, cfg) {
|
|
993
|
+
function buildDefaultRobots(request, cfg, settings) {
|
|
691
994
|
const base = siteUrlFromRequest(request, cfg);
|
|
692
|
-
const
|
|
693
|
-
const
|
|
995
|
+
const indexable = settings.indexEntireSite !== false;
|
|
996
|
+
const sitemapUrl = !indexable || cfg?.seo?.sitemap?.disabled ? undefined : `${base}/sitemap.xml`;
|
|
997
|
+
const lines = indexable
|
|
998
|
+
? ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', '']
|
|
999
|
+
: ['User-agent: *', 'Disallow: /', ''];
|
|
1000
|
+
if (indexable) {
|
|
1001
|
+
lines.push(...buildAiBotRobotsLines(cfg?.seo?.robots?.aiPolicy, cfg?.seo?.robots?.blockAIBots));
|
|
1002
|
+
}
|
|
694
1003
|
if (sitemapUrl)
|
|
695
1004
|
lines.push(`Sitemap: ${sitemapUrl}`, '');
|
|
696
1005
|
return lines.join('\n');
|
|
@@ -707,7 +1016,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
707
1016
|
const cfg = await loadEffectiveConfig();
|
|
708
1017
|
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
709
1018
|
const base = siteUrlFromRequest(request, cfg);
|
|
710
|
-
const cols = sitemapEligibleCollections(cfg);
|
|
1019
|
+
const cols = sitemapEligibleCollections(cfg, { includePosts: settings.includePostsInSitemap });
|
|
711
1020
|
let pagesIncluded = 0;
|
|
712
1021
|
let pagesExcluded = 0;
|
|
713
1022
|
for (const col of cols) {
|
|
@@ -736,7 +1045,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
736
1045
|
warnings.push('No published pages are eligible for the sitemap.');
|
|
737
1046
|
return json({
|
|
738
1047
|
data: {
|
|
739
|
-
sitemapUrl: `${base}/
|
|
1048
|
+
sitemapUrl: `${base}/sitemap.xml`,
|
|
740
1049
|
lastGeneratedAt: settings.sitemapLastGeneratedAt,
|
|
741
1050
|
pagesIncluded,
|
|
742
1051
|
pagesExcluded,
|
|
@@ -764,7 +1073,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
764
1073
|
const cfg = await loadEffectiveConfig();
|
|
765
1074
|
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
766
1075
|
const base = siteUrlFromRequest(request, cfg);
|
|
767
|
-
const cols = sitemapEligibleCollections(cfg);
|
|
1076
|
+
const cols = sitemapEligibleCollections(cfg, { includePosts: settings.includePostsInSitemap });
|
|
768
1077
|
let pagesIncluded = 0;
|
|
769
1078
|
for (const col of cols) {
|
|
770
1079
|
pagesIncluded += await db().document.count({
|
|
@@ -773,7 +1082,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
773
1082
|
}
|
|
774
1083
|
return json({
|
|
775
1084
|
data: {
|
|
776
|
-
sitemapUrl: `${base}/
|
|
1085
|
+
sitemapUrl: `${base}/sitemap.xml`,
|
|
777
1086
|
lastGeneratedAt: now,
|
|
778
1087
|
pagesIncluded,
|
|
779
1088
|
pagesExcluded: 0,
|
|
@@ -887,7 +1196,7 @@ export function registerSeoPublicRoutes(router) {
|
|
|
887
1196
|
const cfg = await loadEffectiveConfig();
|
|
888
1197
|
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
889
1198
|
const custom = settings.robotsTxt?.trim();
|
|
890
|
-
const body = custom ? settings.robotsTxt : buildDefaultRobots(request, cfg);
|
|
1199
|
+
const body = custom ? settings.robotsTxt : buildDefaultRobots(request, cfg, settings);
|
|
891
1200
|
return json({ data: { body, generated: !custom } });
|
|
892
1201
|
}
|
|
893
1202
|
catch (err) {
|