@actuate-media/cms-core 0.88.0 → 0.90.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 +27 -0
- package/dist/api/route-helpers.d.ts.map +1 -1
- package/dist/api/route-helpers.js +1 -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 +65 -16
- package/dist/api/routes/ai-seo.js.map +1 -1
- package/dist/api/routes/media.d.ts.map +1 -1
- package/dist/api/routes/media.js +3 -0
- package/dist/api/routes/media.js.map +1 -1
- package/dist/api/routes/redirects.d.ts.map +1 -1
- package/dist/api/routes/redirects.js +185 -106
- package/dist/api/routes/redirects.js.map +1 -1
- package/dist/api/routes/saved-sections.d.ts.map +1 -1
- package/dist/api/routes/saved-sections.js +35 -3
- package/dist/api/routes/saved-sections.js.map +1 -1
- package/dist/api/routes/seo-public.d.ts.map +1 -1
- package/dist/api/routes/seo-public.js +42 -56
- 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 +325 -30
- 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 +1 -0
- 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 +1 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -0
- package/dist/media/index.js.map +1 -1
- package/dist/media/optimize.d.ts +24 -2
- package/dist/media/optimize.d.ts.map +1 -1
- package/dist/media/optimize.js +55 -36
- package/dist/media/optimize.js.map +1 -1
- package/dist/redirects/fix-link.d.ts +15 -0
- package/dist/redirects/fix-link.d.ts.map +1 -0
- package/dist/redirects/fix-link.js +66 -0
- package/dist/redirects/fix-link.js.map +1 -0
- package/dist/redirects/index.d.ts +10 -0
- package/dist/redirects/index.d.ts.map +1 -1
- package/dist/redirects/index.js +6 -0
- package/dist/redirects/index.js.map +1 -1
- package/dist/redirects/legitimacy.d.ts +23 -0
- package/dist/redirects/legitimacy.d.ts.map +1 -0
- package/dist/redirects/legitimacy.js +64 -0
- package/dist/redirects/legitimacy.js.map +1 -0
- package/dist/redirects/recovery.d.ts +85 -0
- package/dist/redirects/recovery.d.ts.map +1 -0
- package/dist/redirects/recovery.js +176 -0
- package/dist/redirects/recovery.js.map +1 -0
- package/dist/redirects/rerank.d.ts +24 -0
- package/dist/redirects/rerank.d.ts.map +1 -0
- package/dist/redirects/rerank.js +82 -0
- package/dist/redirects/rerank.js.map +1 -0
- package/dist/redirects/suggest.d.ts +2 -0
- package/dist/redirects/suggest.d.ts.map +1 -1
- package/dist/redirects/suggest.js +25 -0
- package/dist/redirects/suggest.js.map +1 -1
- package/dist/redirects/typo.d.ts +10 -0
- package/dist/redirects/typo.d.ts.map +1 -0
- package/dist/redirects/typo.js +51 -0
- package/dist/redirects/typo.js.map +1 -0
- package/dist/redirects/validate-destination.d.ts +13 -0
- package/dist/redirects/validate-destination.d.ts.map +1 -0
- package/dist/redirects/validate-destination.js +51 -0
- package/dist/redirects/validate-destination.js.map +1 -0
- package/dist/script-tags/resolve.d.ts +7 -6
- package/dist/script-tags/resolve.d.ts.map +1 -1
- package/dist/script-tags/resolve.js +20 -8
- package/dist/script-tags/resolve.js.map +1 -1
- package/dist/sections/validate.d.ts +2 -0
- package/dist/sections/validate.d.ts.map +1 -1
- package/dist/sections/validate.js +23 -0
- package/dist/sections/validate.js.map +1 -1
- package/dist/seo/audit-engine.d.ts +4 -0
- package/dist/seo/audit-engine.d.ts.map +1 -1
- package/dist/seo/audit-engine.js +28 -3
- 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 +29 -2
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/config-store.d.ts +10 -0
- package/dist/seo/config-store.d.ts.map +1 -1
- package/dist/seo/config-store.js +14 -0
- package/dist/seo/config-store.js.map +1 -1
- package/dist/seo/index.d.ts +1 -1
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +1 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/issue-fix-ai.d.ts.map +1 -1
- package/dist/seo/issue-fix-ai.js +22 -13
- package/dist/seo/issue-fix-ai.js.map +1 -1
- package/dist/seo/issue-fix-redirect.d.ts +18 -0
- package/dist/seo/issue-fix-redirect.d.ts.map +1 -0
- package/dist/seo/issue-fix-redirect.js +152 -0
- package/dist/seo/issue-fix-redirect.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +4 -0
- package/dist/seo/issue-fix.d.ts.map +1 -1
- package/dist/seo/issue-fix.js +51 -29
- package/dist/seo/issue-fix.js.map +1 -1
- package/dist/seo/link-graph.d.ts +46 -0
- package/dist/seo/link-graph.d.ts.map +1 -0
- package/dist/seo/link-graph.js +141 -0
- package/dist/seo/link-graph.js.map +1 -0
- package/dist/seo/migrate-meta.js +3 -2
- package/dist/seo/migrate-meta.js.map +1 -1
- package/dist/seo/page-meta.d.ts +5 -0
- package/dist/seo/page-meta.d.ts.map +1 -1
- package/dist/seo/page-meta.js +58 -66
- package/dist/seo/page-meta.js.map +1 -1
- package/dist/seo/robots.d.ts +22 -0
- package/dist/seo/robots.d.ts.map +1 -1
- package/dist/seo/robots.js +35 -0
- package/dist/seo/robots.js.map +1 -1
- package/dist/seo/title-templates.d.ts +28 -0
- package/dist/seo/title-templates.d.ts.map +1 -1
- package/dist/seo/title-templates.js +62 -2
- package/dist/seo/title-templates.js.map +1 -1
- package/dist/site.d.ts +7 -0
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +20 -0
- package/dist/site.js.map +1 -1
- package/package.json +1 -1
package/dist/api/routes/seo.js
CHANGED
|
@@ -9,7 +9,7 @@ import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
|
|
|
9
9
|
import { guardedDb } from '../guarded-db.js';
|
|
10
10
|
import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
|
|
11
11
|
import { detectAiBot } from '../../seo/ai-bots.js';
|
|
12
|
-
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, } from '../route-helpers.js';
|
|
12
|
+
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, importAIPlugin, } from '../route-helpers.js';
|
|
13
13
|
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
|
|
14
14
|
// alphabetically (critical, info, warning) which wrongly ranks `info` above
|
|
15
15
|
// `warning`. Unknown severities sort last.
|
|
@@ -91,37 +91,49 @@ export function registerSeoRoutes(router) {
|
|
|
91
91
|
}
|
|
92
92
|
const MAX_LINKS_PER_PAGE = 50;
|
|
93
93
|
const MAX_TOTAL_LINKS = 500;
|
|
94
|
+
const DOC_BATCH_SIZE = 100;
|
|
94
95
|
const PER_LINK_TIMEOUT_MS = 4000;
|
|
95
96
|
const CONCURRENCY = 8;
|
|
96
|
-
const docs = await db().document.findMany({
|
|
97
|
-
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
98
|
-
select: { id: true, title: true, data: true, collection: true },
|
|
99
|
-
});
|
|
100
97
|
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
|
|
101
98
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
|
|
102
99
|
const queue = [];
|
|
103
100
|
const seenGlobal = new Set();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
101
|
+
let docsScanned = 0;
|
|
102
|
+
let skip = 0;
|
|
103
|
+
scanLoop: while (queue.length < MAX_TOTAL_LINKS) {
|
|
104
|
+
const batch = await db().document.findMany({
|
|
105
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
106
|
+
select: { id: true, title: true, data: true, collection: true },
|
|
107
|
+
orderBy: { updatedAt: 'desc' },
|
|
108
|
+
skip,
|
|
109
|
+
take: DOC_BATCH_SIZE,
|
|
110
|
+
});
|
|
111
|
+
if (batch.length === 0)
|
|
112
|
+
break;
|
|
113
|
+
docsScanned += batch.length;
|
|
114
|
+
skip += batch.length;
|
|
115
|
+
for (const doc of batch) {
|
|
116
|
+
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
117
|
+
const content = JSON.stringify(doc.data ?? {});
|
|
118
|
+
const urls = content.match(urlRegex) ?? [];
|
|
119
|
+
const seenInPage = new Set();
|
|
120
|
+
let countInPage = 0;
|
|
121
|
+
for (const url of urls) {
|
|
122
|
+
const clean = url.replace(/[",;)}\]]+$/, '');
|
|
123
|
+
if (seenInPage.has(clean))
|
|
124
|
+
continue;
|
|
125
|
+
seenInPage.add(clean);
|
|
126
|
+
if (seenGlobal.has(clean))
|
|
127
|
+
continue;
|
|
128
|
+
seenGlobal.add(clean);
|
|
129
|
+
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
130
|
+
break;
|
|
131
|
+
if (queue.length >= MAX_TOTAL_LINKS)
|
|
132
|
+
break scanLoop;
|
|
133
|
+
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
134
|
+
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
135
|
+
countInPage++;
|
|
136
|
+
}
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
const linkResults = [];
|
|
@@ -165,6 +177,7 @@ export function registerSeoRoutes(router) {
|
|
|
165
177
|
return json({
|
|
166
178
|
data: {
|
|
167
179
|
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
180
|
+
docsScanned,
|
|
168
181
|
checked: queue.length,
|
|
169
182
|
issues: linkResults,
|
|
170
183
|
},
|
|
@@ -174,6 +187,197 @@ export function registerSeoRoutes(router) {
|
|
|
174
187
|
return internalError(err);
|
|
175
188
|
}
|
|
176
189
|
});
|
|
190
|
+
router.get('/seo/links/snapshot', async (request) => {
|
|
191
|
+
try {
|
|
192
|
+
const auth = await requireAuth(request);
|
|
193
|
+
if (auth.error)
|
|
194
|
+
return auth.error;
|
|
195
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
196
|
+
if (roleErr)
|
|
197
|
+
return roleErr;
|
|
198
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
199
|
+
const { buildLinkGraphSnapshot } = await import('../../seo/link-graph.js');
|
|
200
|
+
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
201
|
+
const cfg = getActuateConfig();
|
|
202
|
+
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
203
|
+
const siteUrl = cfg?.seo?.siteUrl?.replace(/\/+$/, '') ?? '';
|
|
204
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
205
|
+
const docRows = await db()
|
|
206
|
+
.document.findMany({
|
|
207
|
+
where: {
|
|
208
|
+
deletedAt: null,
|
|
209
|
+
status: 'PUBLISHED',
|
|
210
|
+
collection: { in: Object.keys(collectionTypes) },
|
|
211
|
+
},
|
|
212
|
+
select: { id: true, collection: true },
|
|
213
|
+
})
|
|
214
|
+
.catch(() => []);
|
|
215
|
+
const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
|
|
216
|
+
let brokenLinkCount = 0;
|
|
217
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
218
|
+
brokenLinkCount = await db()
|
|
219
|
+
.seoIssue.count({
|
|
220
|
+
where: {
|
|
221
|
+
status: { in: ['open', 'recurring'] },
|
|
222
|
+
type: 'broken-internal-link',
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
.catch(() => 0);
|
|
226
|
+
}
|
|
227
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
228
|
+
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
229
|
+
const snapshot = buildLinkGraphSnapshot({
|
|
230
|
+
entities,
|
|
231
|
+
siteUrl,
|
|
232
|
+
collections,
|
|
233
|
+
brokenLinkCount,
|
|
234
|
+
aiIndexLastRunAt: moduleSettings.linkAiIndexLastRunAt,
|
|
235
|
+
});
|
|
236
|
+
return json({ data: snapshot });
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
return internalError(err, 'seo/links/snapshot');
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
router.post('/seo/links/ai-index', async (request) => {
|
|
243
|
+
try {
|
|
244
|
+
const auth = await requireAuth(request);
|
|
245
|
+
if (auth.error)
|
|
246
|
+
return auth.error;
|
|
247
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
248
|
+
if (roleErr)
|
|
249
|
+
return roleErr;
|
|
250
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-ai-index:${auth.session.userId}`))) {
|
|
251
|
+
return errorResponse('AI link index rate limit reached. Try again later.', 429);
|
|
252
|
+
}
|
|
253
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
254
|
+
const { patchSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
255
|
+
const cfg = getActuateConfig();
|
|
256
|
+
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
257
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
258
|
+
let aiAvailable = false;
|
|
259
|
+
try {
|
|
260
|
+
await importAIPlugin();
|
|
261
|
+
aiAvailable = true;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
aiAvailable = false;
|
|
265
|
+
}
|
|
266
|
+
const now = new Date().toISOString();
|
|
267
|
+
await patchSeoModuleSettings(db(), { linkAiIndexLastRunAt: now }, auth.session.userId);
|
|
268
|
+
await logEvent({
|
|
269
|
+
event: 'seo_link_ai_index',
|
|
270
|
+
userId: auth.session.userId,
|
|
271
|
+
details: { entitiesIndexed: entities.length, aiAvailable },
|
|
272
|
+
});
|
|
273
|
+
return json({
|
|
274
|
+
data: {
|
|
275
|
+
indexedAt: now,
|
|
276
|
+
entitiesIndexed: entities.length,
|
|
277
|
+
aiAvailable,
|
|
278
|
+
message: aiAvailable
|
|
279
|
+
? 'Content indexed for AI link suggestions. Run suggestions on orphaned or under-linked pages.'
|
|
280
|
+
: 'Link graph refreshed. Install @actuate-media/plugin-ai for semantic AI suggestions.',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
return internalError(err, 'seo/links/ai-index');
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
router.post('/seo/links/suggest', async (request) => {
|
|
289
|
+
try {
|
|
290
|
+
const auth = await requireAuth(request);
|
|
291
|
+
if (auth.error)
|
|
292
|
+
return auth.error;
|
|
293
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
294
|
+
if (roleErr)
|
|
295
|
+
return roleErr;
|
|
296
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-suggest:${auth.session.userId}`))) {
|
|
297
|
+
return errorResponse('Link suggestion rate limit reached. Try again later.', 429);
|
|
298
|
+
}
|
|
299
|
+
const body = (await request.json());
|
|
300
|
+
if (!body.targetEntityId || !body.targetEntityType) {
|
|
301
|
+
return errorResponse('targetEntityId and targetEntityType are required', 400);
|
|
302
|
+
}
|
|
303
|
+
const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
|
|
304
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
305
|
+
const cfg = getActuateConfig();
|
|
306
|
+
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
307
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
308
|
+
const target = entities.find((e) => e.entityId === body.targetEntityId);
|
|
309
|
+
if (!target)
|
|
310
|
+
return errorResponse('Target document not found in link graph', 404);
|
|
311
|
+
const sources = entities.filter((e) => e.entityId !== target.entityId);
|
|
312
|
+
const query = [target.title, target.focusKeyword, target.metaDescription]
|
|
313
|
+
.filter(Boolean)
|
|
314
|
+
.join(' — ');
|
|
315
|
+
const docRows = await db()
|
|
316
|
+
.document.findMany({
|
|
317
|
+
where: { id: { in: sources.map((s) => s.entityId) } },
|
|
318
|
+
select: { id: true, collection: true },
|
|
319
|
+
})
|
|
320
|
+
.catch(() => []);
|
|
321
|
+
const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
|
|
322
|
+
let suggestions = [];
|
|
323
|
+
try {
|
|
324
|
+
const aiModule = await importAIPlugin();
|
|
325
|
+
if (typeof aiModule?.semanticSearch === 'function') {
|
|
326
|
+
const corpus = sources.map((s) => ({
|
|
327
|
+
id: s.entityId,
|
|
328
|
+
collectionSlug: collections[s.entityId] ?? 'pages',
|
|
329
|
+
title: s.title,
|
|
330
|
+
content: (s.content ?? '').slice(0, 8000),
|
|
331
|
+
}));
|
|
332
|
+
const results = await aiModule.semanticSearch(query, { limit, threshold: 0.35, corpus });
|
|
333
|
+
suggestions = results.map((r) => ({
|
|
334
|
+
sourceEntityId: r.id,
|
|
335
|
+
sourceTitle: r.title,
|
|
336
|
+
sourceUrl: sources.find((s) => s.entityId === r.id)?.url ?? '',
|
|
337
|
+
anchorText: target.title,
|
|
338
|
+
confidence: Math.round(r.score * 100) / 100,
|
|
339
|
+
excerpt: r.excerpt,
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
else if (typeof aiModule?.suggestInternalLinks === 'function') {
|
|
343
|
+
for (const source of sources.slice(0, 40)) {
|
|
344
|
+
const found = await aiModule.suggestInternalLinks(source.content ?? '', [
|
|
345
|
+
{
|
|
346
|
+
id: target.entityId,
|
|
347
|
+
title: target.title,
|
|
348
|
+
url: target.url,
|
|
349
|
+
keywords: target.focusKeyword ? [target.focusKeyword] : [],
|
|
350
|
+
},
|
|
351
|
+
]);
|
|
352
|
+
for (const f of found.slice(0, 2)) {
|
|
353
|
+
suggestions.push({
|
|
354
|
+
sourceEntityId: source.entityId,
|
|
355
|
+
sourceTitle: source.title,
|
|
356
|
+
sourceUrl: source.url,
|
|
357
|
+
anchorText: f.anchorText,
|
|
358
|
+
confidence: f.confidence,
|
|
359
|
+
excerpt: (source.content ?? '').slice(f.position.start, f.position.end + 40),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
364
|
+
suggestions = suggestions.slice(0, limit);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
suggestions = [];
|
|
369
|
+
}
|
|
370
|
+
return json({
|
|
371
|
+
data: {
|
|
372
|
+
target: { id: target.entityId, title: target.title, url: target.url },
|
|
373
|
+
suggestions,
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
return internalError(err, 'seo/links/suggest');
|
|
379
|
+
}
|
|
380
|
+
});
|
|
177
381
|
router.post('/seo/scan', async (request) => {
|
|
178
382
|
try {
|
|
179
383
|
const auth = await requireAuth(request);
|
|
@@ -233,8 +437,11 @@ export function registerSeoRoutes(router) {
|
|
|
233
437
|
}
|
|
234
438
|
async function buildSeoContentRecords() {
|
|
235
439
|
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
440
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
236
441
|
const { calculatePageSeoScore } = await import('../../seo/score.js');
|
|
237
442
|
const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
|
|
443
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
444
|
+
const siteSeo = applySeoOverrides(getActuateConfig(), overrides)?.seo;
|
|
238
445
|
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
239
446
|
return entities.map((e) => {
|
|
240
447
|
const score = calculatePageSeoScore(e);
|
|
@@ -245,7 +452,12 @@ export function registerSeoRoutes(router) {
|
|
|
245
452
|
title: e.title,
|
|
246
453
|
url: e.url,
|
|
247
454
|
canonicalUrl: e.canonicalUrl ?? null,
|
|
455
|
+
/** Rendered public title (after site title template). */
|
|
248
456
|
metaTitle: e.metaTitle ?? null,
|
|
457
|
+
/** Stored page-specific portion (`%page%` token). */
|
|
458
|
+
metaTitlePage: e.metaTitlePage ?? null,
|
|
459
|
+
siteName: siteSeo?.siteName ?? null,
|
|
460
|
+
titleTemplate: siteSeo?.titleTemplate ?? null,
|
|
249
461
|
metaDescription: e.metaDescription ?? null,
|
|
250
462
|
focusKeyword: e.focusKeyword ?? null,
|
|
251
463
|
keyTakeaway: e.keyTakeaway ?? null,
|
|
@@ -983,18 +1195,29 @@ export function registerSeoRoutes(router) {
|
|
|
983
1195
|
entity: loaded.entity,
|
|
984
1196
|
siteSeo: loaded.siteSeo,
|
|
985
1197
|
updatedAt: loaded.updatedAt,
|
|
1198
|
+
metadata: asRecord(issue.metadata),
|
|
986
1199
|
};
|
|
987
1200
|
let aiDeps;
|
|
988
|
-
const
|
|
989
|
-
if (
|
|
990
|
-
hasPlanFeature('seo.inlineApply') &&
|
|
991
|
-
(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
|
|
1201
|
+
const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
|
|
1202
|
+
if (REDIRECT_FIX_TYPES.has(issue.type)) {
|
|
992
1203
|
aiDeps = {
|
|
993
1204
|
db: db(),
|
|
994
1205
|
userId: auth.session.userId,
|
|
995
1206
|
collection: loaded.collection,
|
|
996
1207
|
};
|
|
997
1208
|
}
|
|
1209
|
+
else {
|
|
1210
|
+
const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1211
|
+
if (!adminErr &&
|
|
1212
|
+
hasPlanFeature('seo.inlineApply') &&
|
|
1213
|
+
(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
|
|
1214
|
+
aiDeps = {
|
|
1215
|
+
db: db(),
|
|
1216
|
+
userId: auth.session.userId,
|
|
1217
|
+
collection: loaded.collection,
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
998
1221
|
const { suggestIssueFix } = await import('../../seo/issue-fix.js');
|
|
999
1222
|
const { pendingFixToMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1000
1223
|
const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
|
|
@@ -1043,7 +1266,79 @@ export function registerSeoRoutes(router) {
|
|
|
1043
1266
|
entity: loaded.entity,
|
|
1044
1267
|
siteSeo: loaded.siteSeo,
|
|
1045
1268
|
updatedAt: loaded.updatedAt,
|
|
1269
|
+
metadata: asRecord(issue.metadata),
|
|
1046
1270
|
};
|
|
1271
|
+
const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
|
|
1272
|
+
if (REDIRECT_FIX_TYPES.has(issue.type)) {
|
|
1273
|
+
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1274
|
+
const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1275
|
+
const cached = pendingFixFromMetadata(issue.metadata);
|
|
1276
|
+
const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
|
|
1277
|
+
if (!cached?.autoFixable) {
|
|
1278
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1279
|
+
}
|
|
1280
|
+
if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
|
|
1281
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1282
|
+
}
|
|
1283
|
+
if (cached.fingerprint !== liveFingerprint) {
|
|
1284
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1285
|
+
}
|
|
1286
|
+
const fixStrategy = cached.patch.fixStrategy === 'fix-link' ? 'fix-link' : 'redirect';
|
|
1287
|
+
try {
|
|
1288
|
+
if (fixStrategy === 'fix-link') {
|
|
1289
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1290
|
+
await applyBrokenLinkContentFix(loaded.collection, loaded.entity.entityId, cached.patch, ctx);
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1293
|
+
await applyBrokenLinkRedirectFix(db(), cached.patch, auth.session.userId);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
catch (err) {
|
|
1297
|
+
const msg = err instanceof Error ? err.message : 'Fix application failed';
|
|
1298
|
+
return errorResponse(msg, 400);
|
|
1299
|
+
}
|
|
1300
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1301
|
+
const resolved = await db().seoIssue.update({
|
|
1302
|
+
where: { id: issue.id },
|
|
1303
|
+
data: {
|
|
1304
|
+
status: 'resolved',
|
|
1305
|
+
resolvedAt: new Date(),
|
|
1306
|
+
resolvedById: auth.session.userId,
|
|
1307
|
+
metadata: { ...existingMeta, pendingFix: null },
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
try {
|
|
1311
|
+
await logEvent({
|
|
1312
|
+
event: 'document_updated',
|
|
1313
|
+
userId: auth.session.userId,
|
|
1314
|
+
details: {
|
|
1315
|
+
action: fixStrategy === 'fix-link'
|
|
1316
|
+
? 'seo_issue_link_fix_applied'
|
|
1317
|
+
: 'seo_issue_redirect_applied',
|
|
1318
|
+
issueId: issue.id,
|
|
1319
|
+
issueType: issue.type,
|
|
1320
|
+
...(fixStrategy === 'fix-link'
|
|
1321
|
+
? {
|
|
1322
|
+
fixLinkFrom: cached.patch.fixLinkFrom,
|
|
1323
|
+
fixLinkTo: cached.patch.fixLinkTo,
|
|
1324
|
+
}
|
|
1325
|
+
: {
|
|
1326
|
+
redirectFrom: cached.patch.redirectFrom,
|
|
1327
|
+
redirectTo: cached.patch.redirectTo,
|
|
1328
|
+
}),
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
catch {
|
|
1333
|
+
/* noop */
|
|
1334
|
+
}
|
|
1335
|
+
return json({
|
|
1336
|
+
data: {
|
|
1337
|
+
issue: seoIssueToApi(resolved),
|
|
1338
|
+
applied: cached.changes,
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1047
1342
|
const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1048
1343
|
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1049
1344
|
const deterministic = suggestDeterministicFix(issueInput, fixCtx);
|