@actuate-media/cms-core 0.65.0 → 0.67.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.
Files changed (155) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/dist/__tests__/api/ai-quality.test.js +73 -0
  3. package/dist/__tests__/api/ai-quality.test.js.map +1 -1
  4. package/dist/__tests__/api/llms-txt.test.js +59 -0
  5. package/dist/__tests__/api/llms-txt.test.js.map +1 -1
  6. package/dist/__tests__/api/public-seo.test.js +402 -4
  7. package/dist/__tests__/api/public-seo.test.js.map +1 -1
  8. package/dist/__tests__/api/route-contracts.test.js +7 -0
  9. package/dist/__tests__/api/route-contracts.test.js.map +1 -1
  10. package/dist/__tests__/content/slug.test.d.ts +2 -0
  11. package/dist/__tests__/content/slug.test.d.ts.map +1 -0
  12. package/dist/__tests__/content/slug.test.js +68 -0
  13. package/dist/__tests__/content/slug.test.js.map +1 -0
  14. package/dist/__tests__/middleware.test.js +47 -0
  15. package/dist/__tests__/middleware.test.js.map +1 -1
  16. package/dist/__tests__/redirects/auto-301.test.d.ts +2 -0
  17. package/dist/__tests__/redirects/auto-301.test.d.ts.map +1 -0
  18. package/dist/__tests__/redirects/auto-301.test.js +172 -0
  19. package/dist/__tests__/redirects/auto-301.test.js.map +1 -0
  20. package/dist/__tests__/seo/ai-bots.test.d.ts +2 -0
  21. package/dist/__tests__/seo/ai-bots.test.d.ts.map +1 -0
  22. package/dist/__tests__/seo/ai-bots.test.js +53 -0
  23. package/dist/__tests__/seo/ai-bots.test.js.map +1 -0
  24. package/dist/__tests__/seo/alt-text.test.d.ts +2 -0
  25. package/dist/__tests__/seo/alt-text.test.d.ts.map +1 -0
  26. package/dist/__tests__/seo/alt-text.test.js +47 -0
  27. package/dist/__tests__/seo/alt-text.test.js.map +1 -0
  28. package/dist/__tests__/seo/feeds.test.d.ts +2 -0
  29. package/dist/__tests__/seo/feeds.test.d.ts.map +1 -0
  30. package/dist/__tests__/seo/feeds.test.js +61 -0
  31. package/dist/__tests__/seo/feeds.test.js.map +1 -0
  32. package/dist/__tests__/seo/freshness.test.d.ts +2 -0
  33. package/dist/__tests__/seo/freshness.test.d.ts.map +1 -0
  34. package/dist/__tests__/seo/freshness.test.js +89 -0
  35. package/dist/__tests__/seo/freshness.test.js.map +1 -0
  36. package/dist/__tests__/seo/meta-tags-alternates.test.d.ts +2 -0
  37. package/dist/__tests__/seo/meta-tags-alternates.test.d.ts.map +1 -0
  38. package/dist/__tests__/seo/meta-tags-alternates.test.js +52 -0
  39. package/dist/__tests__/seo/meta-tags-alternates.test.js.map +1 -0
  40. package/dist/__tests__/seo/page-meta.test.js +230 -14
  41. package/dist/__tests__/seo/page-meta.test.js.map +1 -1
  42. package/dist/__tests__/seo/sitemap-notify.test.js +77 -1
  43. package/dist/__tests__/seo/sitemap-notify.test.js.map +1 -1
  44. package/dist/__tests__/validation/publish.test.d.ts +2 -0
  45. package/dist/__tests__/validation/publish.test.d.ts.map +1 -0
  46. package/dist/__tests__/validation/publish.test.js +164 -0
  47. package/dist/__tests__/validation/publish.test.js.map +1 -0
  48. package/dist/actions.d.ts.map +1 -1
  49. package/dist/actions.js +172 -8
  50. package/dist/actions.js.map +1 -1
  51. package/dist/api/route-helpers.d.ts +8 -0
  52. package/dist/api/route-helpers.d.ts.map +1 -1
  53. package/dist/api/route-helpers.js +20 -0
  54. package/dist/api/route-helpers.js.map +1 -1
  55. package/dist/api/routes/ai-seo.d.ts.map +1 -1
  56. package/dist/api/routes/ai-seo.js +32 -1
  57. package/dist/api/routes/ai-seo.js.map +1 -1
  58. package/dist/api/routes/auth.d.ts.map +1 -1
  59. package/dist/api/routes/auth.js +2 -1
  60. package/dist/api/routes/auth.js.map +1 -1
  61. package/dist/api/routes/documents.d.ts.map +1 -1
  62. package/dist/api/routes/documents.js +3 -3
  63. package/dist/api/routes/documents.js.map +1 -1
  64. package/dist/api/routes/seo-public.d.ts.map +1 -1
  65. package/dist/api/routes/seo-public.js +371 -62
  66. package/dist/api/routes/seo-public.js.map +1 -1
  67. package/dist/api/routes/seo.d.ts.map +1 -1
  68. package/dist/api/routes/seo.js +21 -10
  69. package/dist/api/routes/seo.js.map +1 -1
  70. package/dist/api/routes/url-resolution.d.ts.map +1 -1
  71. package/dist/api/routes/url-resolution.js +40 -1
  72. package/dist/api/routes/url-resolution.js.map +1 -1
  73. package/dist/api/routes/users.d.ts.map +1 -1
  74. package/dist/api/routes/users.js +6 -3
  75. package/dist/api/routes/users.js.map +1 -1
  76. package/dist/client.d.ts +1 -0
  77. package/dist/client.d.ts.map +1 -1
  78. package/dist/client.js +3 -0
  79. package/dist/client.js.map +1 -1
  80. package/dist/config/types.d.ts +35 -1
  81. package/dist/config/types.d.ts.map +1 -1
  82. package/dist/content/index.d.ts +1 -0
  83. package/dist/content/index.d.ts.map +1 -1
  84. package/dist/content/index.js +1 -0
  85. package/dist/content/index.js.map +1 -1
  86. package/dist/content/slug.d.ts +40 -0
  87. package/dist/content/slug.d.ts.map +1 -0
  88. package/dist/content/slug.js +78 -0
  89. package/dist/content/slug.js.map +1 -0
  90. package/dist/content/structured-data.d.ts +6 -0
  91. package/dist/content/structured-data.d.ts.map +1 -1
  92. package/dist/content/structured-data.js +6 -0
  93. package/dist/content/structured-data.js.map +1 -1
  94. package/dist/content-health/scanner.d.ts +4 -3
  95. package/dist/content-health/scanner.d.ts.map +1 -1
  96. package/dist/content-health/scanner.js +6 -12
  97. package/dist/content-health/scanner.js.map +1 -1
  98. package/dist/middleware.d.ts +1 -0
  99. package/dist/middleware.d.ts.map +1 -1
  100. package/dist/middleware.js +11 -2
  101. package/dist/middleware.js.map +1 -1
  102. package/dist/redirects/auto.d.ts +45 -0
  103. package/dist/redirects/auto.d.ts.map +1 -0
  104. package/dist/redirects/auto.js +55 -0
  105. package/dist/redirects/auto.js.map +1 -0
  106. package/dist/redirects/index.d.ts +2 -0
  107. package/dist/redirects/index.d.ts.map +1 -1
  108. package/dist/redirects/index.js +1 -0
  109. package/dist/redirects/index.js.map +1 -1
  110. package/dist/seo/ai-bots.d.ts +42 -0
  111. package/dist/seo/ai-bots.d.ts.map +1 -0
  112. package/dist/seo/ai-bots.js +104 -0
  113. package/dist/seo/ai-bots.js.map +1 -0
  114. package/dist/seo/alt-text.d.ts +39 -0
  115. package/dist/seo/alt-text.d.ts.map +1 -0
  116. package/dist/seo/alt-text.js +67 -0
  117. package/dist/seo/alt-text.js.map +1 -0
  118. package/dist/seo/analysis.d.ts.map +1 -1
  119. package/dist/seo/analysis.js +7 -11
  120. package/dist/seo/analysis.js.map +1 -1
  121. package/dist/seo/audit-engine.d.ts +1 -0
  122. package/dist/seo/audit-engine.d.ts.map +1 -1
  123. package/dist/seo/audit-engine.js +4 -4
  124. package/dist/seo/audit-engine.js.map +1 -1
  125. package/dist/seo/audit-runner.d.ts.map +1 -1
  126. package/dist/seo/audit-runner.js +1 -0
  127. package/dist/seo/audit-runner.js.map +1 -1
  128. package/dist/seo/feeds.d.ts +36 -0
  129. package/dist/seo/feeds.d.ts.map +1 -0
  130. package/dist/seo/feeds.js +71 -0
  131. package/dist/seo/feeds.js.map +1 -0
  132. package/dist/seo/index.d.ts +9 -3
  133. package/dist/seo/index.d.ts.map +1 -1
  134. package/dist/seo/index.js +5 -2
  135. package/dist/seo/index.js.map +1 -1
  136. package/dist/seo/llms-txt.d.ts.map +1 -1
  137. package/dist/seo/llms-txt.js +13 -2
  138. package/dist/seo/llms-txt.js.map +1 -1
  139. package/dist/seo/meta-tags.d.ts +14 -0
  140. package/dist/seo/meta-tags.d.ts.map +1 -1
  141. package/dist/seo/meta-tags.js +21 -2
  142. package/dist/seo/meta-tags.js.map +1 -1
  143. package/dist/seo/page-meta.d.ts +47 -0
  144. package/dist/seo/page-meta.d.ts.map +1 -1
  145. package/dist/seo/page-meta.js +280 -70
  146. package/dist/seo/page-meta.js.map +1 -1
  147. package/dist/seo/sitemap-notify.d.ts +30 -0
  148. package/dist/seo/sitemap-notify.d.ts.map +1 -1
  149. package/dist/seo/sitemap-notify.js +87 -0
  150. package/dist/seo/sitemap-notify.js.map +1 -1
  151. package/dist/validation/publish.d.ts +53 -0
  152. package/dist/validation/publish.d.ts.map +1 -0
  153. package/dist/validation/publish.js +75 -0
  154. package/dist/validation/publish.js.map +1 -0
  155. 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 cols = sitemapEligibleCollections(cfg);
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>${now}</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
- const prefix = (collection.urlPrefix ?? '').replace(/^\/|\/$/g, '');
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>${new Date().toISOString()}</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
- if (!slug)
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
- const loc = prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
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
- '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
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
- // Admin-edited custom robots.txt body (Technical tab) wins when set
222
- // the editor is the single source of truth for operators who want full
223
- // control over directives.
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
- const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
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 through to generated defaults on any override-read failure.
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
- const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
245
- const lines = [
246
- 'User-agent: *',
247
- 'Allow: /',
248
- 'Disallow: /admin',
249
- 'Disallow: /api/',
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
- if (seo?.robots?.blockAIBots) {
261
- const bots = [
262
- 'GPTBot',
263
- 'ChatGPT-User',
264
- 'ClaudeBot',
265
- 'Claude-Web',
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 sitemapUrl = cfg?.seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
693
- const lines = ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', ''];
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}/api/cms/sitemap.xml`,
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}/api/cms/sitemap.xml`,
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) {