@prenta/core 4.1.1 → 4.3.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 +12 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +4 -4
- package/dist/actions.js.map +1 -1
- package/dist/ai/brand-voice-analyze.d.ts.map +1 -1
- package/dist/ai/brand-voice-analyze.js +11 -11
- package/dist/ai/brand-voice-analyze.js.map +1 -1
- package/dist/api/routes/ai-authoring.d.ts.map +1 -1
- package/dist/api/routes/ai-authoring.js +24 -12
- package/dist/api/routes/ai-authoring.js.map +1 -1
- package/dist/api/routes/documents.d.ts.map +1 -1
- package/dist/api/routes/documents.js +2 -15
- package/dist/api/routes/documents.js.map +1 -1
- package/dist/api/routes/seo-public.d.ts +0 -8
- package/dist/api/routes/seo-public.d.ts.map +1 -1
- package/dist/api/routes/seo-public.js +67 -29
- 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 +383 -121
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/media/usage-sync.d.ts.map +1 -1
- package/dist/media/usage-sync.js +15 -11
- package/dist/media/usage-sync.js.map +1 -1
- package/dist/redirects/recovery.d.ts +8 -2
- package/dist/redirects/recovery.d.ts.map +1 -1
- package/dist/redirects/recovery.js +43 -24
- package/dist/redirects/recovery.js.map +1 -1
- package/dist/seo/audit-engine.d.ts +12 -0
- package/dist/seo/audit-engine.d.ts.map +1 -1
- package/dist/seo/audit-engine.js +24 -0
- package/dist/seo/audit-engine.js.map +1 -1
- package/dist/seo/audit-runner.d.ts +12 -0
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js +45 -10
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/index.d.ts +8 -1
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +5 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/issue-fix-link-insert.d.ts +105 -0
- package/dist/seo/issue-fix-link-insert.d.ts.map +1 -0
- package/dist/seo/issue-fix-link-insert.js +461 -0
- package/dist/seo/issue-fix-link-insert.js.map +1 -0
- package/dist/seo/issue-fix-verify.d.ts +31 -0
- package/dist/seo/issue-fix-verify.d.ts.map +1 -0
- package/dist/seo/issue-fix-verify.js +76 -0
- package/dist/seo/issue-fix-verify.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +2 -2
- package/dist/seo/issue-fix.d.ts.map +1 -1
- package/dist/seo/issue-fix.js +9 -0
- package/dist/seo/issue-fix.js.map +1 -1
- package/dist/seo/link-suggest.d.ts +49 -0
- package/dist/seo/link-suggest.d.ts.map +1 -0
- package/dist/seo/link-suggest.js +149 -0
- package/dist/seo/link-suggest.js.map +1 -0
- package/dist/seo/nav-link-targets.d.ts +9 -0
- package/dist/seo/nav-link-targets.d.ts.map +1 -0
- package/dist/seo/nav-link-targets.js +47 -0
- package/dist/seo/nav-link-targets.js.map +1 -0
- package/package.json +4 -5
package/dist/api/routes/seo.js
CHANGED
|
@@ -4,7 +4,7 @@ import { DEFAULT_DIRECTIVES, coerceMaxImagePreview } from '../../seo/robots-dire
|
|
|
4
4
|
import { processSeoScan } from '../../cron/index.js';
|
|
5
5
|
import { isResolvedIp } from '../../security/client-ip.js';
|
|
6
6
|
import { classifyLinkHealthIssue, extractOutboundUrlsFromDocumentData, probeOutboundLink, } from '../../seo/link-probe.js';
|
|
7
|
-
import { resolveTrailingSlashPolicy, trailingSlashMismatchTarget } from '../../seo/document-path.js';
|
|
7
|
+
import { documentPublicPath, resolveTrailingSlashPolicy, trailingSlashMismatchTarget, } from '../../seo/document-path.js';
|
|
8
8
|
import { getPrentaConfig } from '../../config/runtime.js';
|
|
9
9
|
import { assertPlanFeature, hasPlanFeature } from '../../plan/features.js';
|
|
10
10
|
import { extraImagesForSeoAnalysis, firstSectionHeading } from '../../seo/document-content.js';
|
|
@@ -12,7 +12,9 @@ import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
|
|
|
12
12
|
import { guardedDb } from '../guarded-db.js';
|
|
13
13
|
import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
|
|
14
14
|
import { detectAiBot } from '../../seo/ai-bots.js';
|
|
15
|
-
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';
|
|
15
|
+
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, importAIPlugin, clampPage, clampPageSize, } from '../route-helpers.js';
|
|
16
|
+
import { gatherNavigationLinkTargets } from '../../seo/nav-link-targets.js';
|
|
17
|
+
import { suggestInboundLinks } from '../../seo/link-suggest.js';
|
|
16
18
|
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
|
|
17
19
|
// alphabetically (critical, info, warning) which wrongly ranks `info` above
|
|
18
20
|
// `warning`. Unknown severities sort last.
|
|
@@ -20,43 +22,6 @@ const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
|
20
22
|
function severityRank(severity) {
|
|
21
23
|
return SEVERITY_RANK[String(severity)] ?? 99;
|
|
22
24
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Collect internal link targets from global navigation menus (the `navigations`
|
|
25
|
-
* collection) so the link-graph can treat nav-linked pages as non-orphaned.
|
|
26
|
-
* Best-effort: returns `[]` when the navigation plugin isn't installed or on any
|
|
27
|
-
* read error.
|
|
28
|
-
*/
|
|
29
|
-
async function gatherNavigationLinkTargets(db) {
|
|
30
|
-
try {
|
|
31
|
-
if (!hasModel(db, 'document'))
|
|
32
|
-
return [];
|
|
33
|
-
const { coercePublicNavItems } = await import('../../site.js');
|
|
34
|
-
const navDocs = await db.document
|
|
35
|
-
.findMany({
|
|
36
|
-
where: { collection: 'navigations', deletedAt: null, status: { not: 'ARCHIVED' } },
|
|
37
|
-
select: { data: true },
|
|
38
|
-
take: 50,
|
|
39
|
-
})
|
|
40
|
-
.catch(() => []);
|
|
41
|
-
const targets = [];
|
|
42
|
-
const walk = (items) => {
|
|
43
|
-
for (const item of items) {
|
|
44
|
-
if (typeof item.url === 'string' && item.url.trim())
|
|
45
|
-
targets.push(item.url);
|
|
46
|
-
if (item.children)
|
|
47
|
-
walk(item.children);
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
for (const doc of navDocs) {
|
|
51
|
-
const data = doc?.data && typeof doc.data === 'object' ? doc.data : {};
|
|
52
|
-
walk(coercePublicNavItems(data.items));
|
|
53
|
-
}
|
|
54
|
-
return targets;
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
25
|
function planRequiredResponse(denial) {
|
|
61
26
|
return json({
|
|
62
27
|
error: denial.message,
|
|
@@ -65,6 +30,23 @@ function planRequiredResponse(denial) {
|
|
|
65
30
|
feature: denial.feature,
|
|
66
31
|
}, 403);
|
|
67
32
|
}
|
|
33
|
+
function mapInternalLinkInsertError(err) {
|
|
34
|
+
const msg = err instanceof Error ? err.message : '';
|
|
35
|
+
switch (msg) {
|
|
36
|
+
case 'SOURCE_NOT_FOUND':
|
|
37
|
+
return errorResponse('Source document not found', 404);
|
|
38
|
+
case 'TARGET_NOT_FOUND':
|
|
39
|
+
return errorResponse('Target document not found', 404);
|
|
40
|
+
case 'NOT_WRAPPABLE':
|
|
41
|
+
return errorResponse('Anchor is not wrappable in the live source document.', 422);
|
|
42
|
+
case 'ALREADY_LINKED':
|
|
43
|
+
return errorResponse('Source already links to this target.', 422);
|
|
44
|
+
case 'TARGET_PATH_MISMATCH':
|
|
45
|
+
return errorResponse('Target path does not match the live document.', 422);
|
|
46
|
+
default:
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
68
50
|
function seoAiGenerationError(result) {
|
|
69
51
|
if (result.code === 'unavailable' || result.code === 'feature_disabled') {
|
|
70
52
|
return errorResponse(result.message, 503);
|
|
@@ -352,76 +334,25 @@ export function registerSeoRoutes(router) {
|
|
|
352
334
|
return errorResponse('targetEntityId and targetEntityType are required', 400);
|
|
353
335
|
}
|
|
354
336
|
const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
|
|
355
|
-
|
|
356
|
-
const cfg = getPrentaConfig();
|
|
357
|
-
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
358
|
-
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
359
|
-
const target = entities.find((e) => e.entityId === body.targetEntityId);
|
|
360
|
-
if (!target)
|
|
361
|
-
return errorResponse('Target document not found in link graph', 404);
|
|
362
|
-
const sources = entities.filter((e) => e.entityId !== target.entityId);
|
|
363
|
-
const query = [target.title, target.focusKeyword, target.metaDescription]
|
|
364
|
-
.filter(Boolean)
|
|
365
|
-
.join(' — ');
|
|
366
|
-
const docRows = await db()
|
|
367
|
-
.document.findMany({
|
|
368
|
-
where: { id: { in: sources.map((s) => s.entityId) } },
|
|
369
|
-
select: { id: true, collection: true },
|
|
370
|
-
})
|
|
371
|
-
.catch(() => []);
|
|
372
|
-
const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
|
|
373
|
-
let suggestions = [];
|
|
337
|
+
let result;
|
|
374
338
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
content: (s.content ?? '').slice(0, 8000),
|
|
382
|
-
}));
|
|
383
|
-
const results = await aiModule.semanticSearch(query, { limit, threshold: 0.35, corpus });
|
|
384
|
-
suggestions = results.map((r) => ({
|
|
385
|
-
sourceEntityId: r.id,
|
|
386
|
-
sourceTitle: r.title,
|
|
387
|
-
sourceUrl: sources.find((s) => s.entityId === r.id)?.url ?? '',
|
|
388
|
-
anchorText: target.title,
|
|
389
|
-
confidence: Math.round(r.score * 100) / 100,
|
|
390
|
-
excerpt: r.excerpt,
|
|
391
|
-
}));
|
|
392
|
-
}
|
|
393
|
-
else if (typeof aiModule?.suggestInternalLinks === 'function') {
|
|
394
|
-
for (const source of sources.slice(0, 40)) {
|
|
395
|
-
const found = await aiModule.suggestInternalLinks(source.content ?? '', [
|
|
396
|
-
{
|
|
397
|
-
id: target.entityId,
|
|
398
|
-
title: target.title,
|
|
399
|
-
url: target.url,
|
|
400
|
-
keywords: target.focusKeyword ? [target.focusKeyword] : [],
|
|
401
|
-
},
|
|
402
|
-
]);
|
|
403
|
-
for (const f of found.slice(0, 2)) {
|
|
404
|
-
suggestions.push({
|
|
405
|
-
sourceEntityId: source.entityId,
|
|
406
|
-
sourceTitle: source.title,
|
|
407
|
-
sourceUrl: source.url,
|
|
408
|
-
anchorText: f.anchorText,
|
|
409
|
-
confidence: f.confidence,
|
|
410
|
-
excerpt: (source.content ?? '').slice(f.position.start, f.position.end + 40),
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
415
|
-
suggestions = suggestions.slice(0, limit);
|
|
416
|
-
}
|
|
339
|
+
result = await suggestInboundLinks({
|
|
340
|
+
db: db(),
|
|
341
|
+
targetEntityId: body.targetEntityId,
|
|
342
|
+
targetEntityType: body.targetEntityType,
|
|
343
|
+
limit,
|
|
344
|
+
});
|
|
417
345
|
}
|
|
418
|
-
catch {
|
|
419
|
-
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (err instanceof Error && err.message === 'TARGET_NOT_FOUND') {
|
|
348
|
+
return errorResponse('Target document not found in link graph', 404);
|
|
349
|
+
}
|
|
350
|
+
throw err;
|
|
420
351
|
}
|
|
421
352
|
return json({
|
|
422
353
|
data: {
|
|
423
|
-
target:
|
|
424
|
-
suggestions,
|
|
354
|
+
target: result.target,
|
|
355
|
+
suggestions: result.suggestions,
|
|
425
356
|
},
|
|
426
357
|
});
|
|
427
358
|
}
|
|
@@ -429,6 +360,124 @@ export function registerSeoRoutes(router) {
|
|
|
429
360
|
return internalError(err, 'seo/links/suggest');
|
|
430
361
|
}
|
|
431
362
|
});
|
|
363
|
+
router.post('/seo/links/apply', async (request) => {
|
|
364
|
+
try {
|
|
365
|
+
const auth = await requireAuth(request);
|
|
366
|
+
if (auth.error)
|
|
367
|
+
return auth.error;
|
|
368
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
369
|
+
if (roleErr)
|
|
370
|
+
return roleErr;
|
|
371
|
+
const inlinePlanErr = assertPlanFeature('seo.inlineApply');
|
|
372
|
+
if (inlinePlanErr)
|
|
373
|
+
return planRequiredResponse(inlinePlanErr);
|
|
374
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-apply:${auth.session.userId}`))) {
|
|
375
|
+
return errorResponse('Link apply rate limit reached. Try again later.', 429);
|
|
376
|
+
}
|
|
377
|
+
const body = (await request.json().catch(() => ({})));
|
|
378
|
+
const sourceEntityId = typeof body?.sourceEntityId === 'string' ? body.sourceEntityId.trim() : '';
|
|
379
|
+
const targetEntityId = typeof body?.targetEntityId === 'string' ? body.targetEntityId.trim() : '';
|
|
380
|
+
const anchorText = typeof body?.anchorText === 'string' ? body.anchorText : '';
|
|
381
|
+
const fingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : '';
|
|
382
|
+
if (!sourceEntityId || !targetEntityId || !anchorText.trim() || !fingerprint) {
|
|
383
|
+
return errorResponse('sourceEntityId, targetEntityId, anchorText, and fingerprint are required', 400);
|
|
384
|
+
}
|
|
385
|
+
const source = await db().document.findFirst({
|
|
386
|
+
where: { id: sourceEntityId, deletedAt: null },
|
|
387
|
+
select: {
|
|
388
|
+
id: true,
|
|
389
|
+
collection: true,
|
|
390
|
+
updatedAt: true,
|
|
391
|
+
data: true,
|
|
392
|
+
slug: true,
|
|
393
|
+
status: true,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
if (!source)
|
|
397
|
+
return errorResponse('Source document not found', 404);
|
|
398
|
+
const target = await db().document.findFirst({
|
|
399
|
+
where: { id: targetEntityId, deletedAt: null },
|
|
400
|
+
select: { id: true, collection: true, data: true, slug: true, status: true },
|
|
401
|
+
});
|
|
402
|
+
if (!target)
|
|
403
|
+
return errorResponse('Target document not found', 404);
|
|
404
|
+
const targetData = asRecord(target.data);
|
|
405
|
+
const targetSlug = (typeof targetData.slug === 'string' && targetData.slug) ||
|
|
406
|
+
(typeof target.slug === 'string' && target.slug) ||
|
|
407
|
+
target.id;
|
|
408
|
+
const collectionDef = getPrentaConfig()?.collections?.[target.collection];
|
|
409
|
+
const targetPath = documentPublicPath({
|
|
410
|
+
urlPrefix: collectionDef?.urlPrefix,
|
|
411
|
+
slug: targetSlug,
|
|
412
|
+
data: targetData,
|
|
413
|
+
});
|
|
414
|
+
const { applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint } = await import('../../seo/issue-fix-link-insert.js');
|
|
415
|
+
const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
|
|
416
|
+
if (fingerprint !== liveFingerprint) {
|
|
417
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
418
|
+
}
|
|
419
|
+
const ctx = buildActionContext(auth.session, db());
|
|
420
|
+
let verified;
|
|
421
|
+
try {
|
|
422
|
+
const result = await applyAndVerifyInternalLinkInsert({
|
|
423
|
+
db: db(),
|
|
424
|
+
ctx,
|
|
425
|
+
sourceEntityId,
|
|
426
|
+
targetEntityId,
|
|
427
|
+
anchorText,
|
|
428
|
+
targetPath,
|
|
429
|
+
siteUrl: getPrentaConfig()?.seo?.siteUrl ?? '',
|
|
430
|
+
navLinks: await gatherNavigationLinkTargets(db()),
|
|
431
|
+
});
|
|
432
|
+
verified = result.verify;
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
const mapped = mapInternalLinkInsertError(err);
|
|
436
|
+
if (mapped)
|
|
437
|
+
return mapped;
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
if (!verified.ok) {
|
|
441
|
+
return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
|
|
442
|
+
}
|
|
443
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
444
|
+
await db().seoIssue.updateMany({
|
|
445
|
+
where: {
|
|
446
|
+
type: 'orphan-page',
|
|
447
|
+
entityId: targetEntityId,
|
|
448
|
+
status: { in: ['open', 'recurring'] },
|
|
449
|
+
},
|
|
450
|
+
data: {
|
|
451
|
+
status: 'resolved',
|
|
452
|
+
resolvedAt: new Date(),
|
|
453
|
+
resolvedById: auth.session.userId,
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
await logEvent({
|
|
459
|
+
event: 'document_updated',
|
|
460
|
+
userId: auth.session.userId,
|
|
461
|
+
details: {
|
|
462
|
+
action: 'seo_internal_link_applied',
|
|
463
|
+
seoFix: 'insert-link',
|
|
464
|
+
sourceEntityId,
|
|
465
|
+
targetEntityId,
|
|
466
|
+
targetPath,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
/* noop */
|
|
472
|
+
}
|
|
473
|
+
return json({
|
|
474
|
+
data: { sourceEntityId, targetEntityId, targetPath, applied: true },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
return internalError(err, 'seo/links/apply');
|
|
479
|
+
}
|
|
480
|
+
});
|
|
432
481
|
router.post('/seo/scan', async (request) => {
|
|
433
482
|
try {
|
|
434
483
|
const auth = await requireAuth(request);
|
|
@@ -510,8 +559,8 @@ export function registerSeoRoutes(router) {
|
|
|
510
559
|
* the per-document GET, which must answer for a saved draft — the list
|
|
511
560
|
* endpoints deliberately stay published-only.
|
|
512
561
|
*/
|
|
513
|
-
documentIds) {
|
|
514
|
-
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
562
|
+
documentIds, page) {
|
|
563
|
+
const { gatherAuditEntities, countAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
515
564
|
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
516
565
|
const { calculatePageSeoScore } = await import('../../seo/score.js');
|
|
517
566
|
const allTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
@@ -522,8 +571,18 @@ export function registerSeoRoutes(router) {
|
|
|
522
571
|
: allTypes;
|
|
523
572
|
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
524
573
|
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
525
|
-
const entities = await
|
|
526
|
-
|
|
574
|
+
const [entities, total] = await Promise.all([
|
|
575
|
+
gatherAuditEntities(db(), {
|
|
576
|
+
collectionTypes,
|
|
577
|
+
documentIds,
|
|
578
|
+
skip: page?.skip,
|
|
579
|
+
take: page?.take,
|
|
580
|
+
}),
|
|
581
|
+
documentIds
|
|
582
|
+
? Promise.resolve(documentIds.length)
|
|
583
|
+
: countAuditEntities(db(), { collectionTypes }),
|
|
584
|
+
]);
|
|
585
|
+
const records = entities.map((e) => {
|
|
527
586
|
const score = calculatePageSeoScore(e);
|
|
528
587
|
return {
|
|
529
588
|
id: `${e.entityType}:${e.entityId}`,
|
|
@@ -564,9 +623,18 @@ export function registerSeoRoutes(router) {
|
|
|
564
623
|
llmsOptional: false,
|
|
565
624
|
seoScore: score.score,
|
|
566
625
|
seoGrade: score.grade,
|
|
567
|
-
updatedAt:
|
|
626
|
+
updatedAt: e.updatedAt instanceof Date
|
|
627
|
+
? e.updatedAt.toISOString()
|
|
628
|
+
: typeof e.updatedAt === 'string'
|
|
629
|
+
? e.updatedAt
|
|
630
|
+
: null,
|
|
568
631
|
};
|
|
569
632
|
});
|
|
633
|
+
return {
|
|
634
|
+
records,
|
|
635
|
+
total,
|
|
636
|
+
truncated: !documentIds && total > records.length,
|
|
637
|
+
};
|
|
570
638
|
}
|
|
571
639
|
// Demo/seed search-performance rows live in a hidden collection so they can
|
|
572
640
|
// be inserted by the dev seed script and never ship to npm. In production
|
|
@@ -1295,7 +1363,8 @@ export function registerSeoRoutes(router) {
|
|
|
1295
1363
|
};
|
|
1296
1364
|
let aiDeps;
|
|
1297
1365
|
const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
|
|
1298
|
-
|
|
1366
|
+
const { INSERT_LINK_FIX_TYPES } = await import('../../seo/issue-fix-link-insert.js');
|
|
1367
|
+
if (REDIRECT_FIX_TYPES.has(issue.type) || INSERT_LINK_FIX_TYPES.has(issue.type)) {
|
|
1299
1368
|
aiDeps = {
|
|
1300
1369
|
db: db(),
|
|
1301
1370
|
userId: auth.session.userId,
|
|
@@ -1365,10 +1434,114 @@ export function registerSeoRoutes(router) {
|
|
|
1365
1434
|
metadata: asRecord(issue.metadata),
|
|
1366
1435
|
};
|
|
1367
1436
|
const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
|
|
1437
|
+
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1438
|
+
const { verifySeoIssueFix } = await import('../../seo/issue-fix-verify.js');
|
|
1439
|
+
const { INSERT_LINK_FIX_TYPES, applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint, } = await import('../../seo/issue-fix-link-insert.js');
|
|
1440
|
+
const cachedPending = pendingFixFromMetadata(issue.metadata);
|
|
1441
|
+
const isInsertLinkFix = INSERT_LINK_FIX_TYPES.has(issue.type) || cachedPending?.patch?.fixStrategy === 'insert-link';
|
|
1442
|
+
if (isInsertLinkFix) {
|
|
1443
|
+
const inlinePlanErr = assertPlanFeature('seo.inlineApply');
|
|
1444
|
+
if (inlinePlanErr)
|
|
1445
|
+
return planRequiredResponse(inlinePlanErr);
|
|
1446
|
+
if (!cachedPending?.autoFixable ||
|
|
1447
|
+
(cachedPending.patch.fixStrategy !== 'insert-link' &&
|
|
1448
|
+
!INSERT_LINK_FIX_TYPES.has(issue.type))) {
|
|
1449
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1450
|
+
}
|
|
1451
|
+
if (INSERT_LINK_FIX_TYPES.has(issue.type) &&
|
|
1452
|
+
cachedPending.patch.fixStrategy &&
|
|
1453
|
+
cachedPending.patch.fixStrategy !== 'insert-link') {
|
|
1454
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1455
|
+
}
|
|
1456
|
+
const sourceEntityId = typeof cachedPending.patch.sourceEntityId === 'string'
|
|
1457
|
+
? cachedPending.patch.sourceEntityId
|
|
1458
|
+
: '';
|
|
1459
|
+
const targetEntityId = typeof cachedPending.patch.targetEntityId === 'string'
|
|
1460
|
+
? cachedPending.patch.targetEntityId
|
|
1461
|
+
: (issue.entityId ?? '');
|
|
1462
|
+
const targetPath = typeof cachedPending.patch.targetPath === 'string'
|
|
1463
|
+
? cachedPending.patch.targetPath
|
|
1464
|
+
: loaded.entity.url;
|
|
1465
|
+
const anchorText = typeof cachedPending.patch.anchorText === 'string' ? cachedPending.patch.anchorText : '';
|
|
1466
|
+
if (!sourceEntityId || !targetEntityId || !anchorText) {
|
|
1467
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1468
|
+
}
|
|
1469
|
+
if (body?.fingerprint && body.fingerprint !== cachedPending.fingerprint) {
|
|
1470
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1471
|
+
}
|
|
1472
|
+
const source = await db().document.findFirst({
|
|
1473
|
+
where: { id: sourceEntityId, deletedAt: null },
|
|
1474
|
+
select: { id: true, updatedAt: true },
|
|
1475
|
+
});
|
|
1476
|
+
if (!source)
|
|
1477
|
+
return errorResponse('Source document not found', 404);
|
|
1478
|
+
const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
|
|
1479
|
+
if (cachedPending.fingerprint !== liveFingerprint) {
|
|
1480
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1481
|
+
}
|
|
1482
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1483
|
+
let verified;
|
|
1484
|
+
try {
|
|
1485
|
+
const result = await applyAndVerifyInternalLinkInsert({
|
|
1486
|
+
db: db(),
|
|
1487
|
+
ctx,
|
|
1488
|
+
sourceEntityId,
|
|
1489
|
+
targetEntityId,
|
|
1490
|
+
anchorText,
|
|
1491
|
+
targetPath,
|
|
1492
|
+
siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
|
|
1493
|
+
navLinks: await gatherNavigationLinkTargets(db()),
|
|
1494
|
+
});
|
|
1495
|
+
verified = result.verify;
|
|
1496
|
+
}
|
|
1497
|
+
catch (err) {
|
|
1498
|
+
const mapped = mapInternalLinkInsertError(err);
|
|
1499
|
+
if (mapped)
|
|
1500
|
+
return mapped;
|
|
1501
|
+
throw err;
|
|
1502
|
+
}
|
|
1503
|
+
if (!verified.ok) {
|
|
1504
|
+
return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
|
|
1505
|
+
}
|
|
1506
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1507
|
+
const resolved = await db().seoIssue.update({
|
|
1508
|
+
where: { id: issue.id },
|
|
1509
|
+
data: {
|
|
1510
|
+
status: 'resolved',
|
|
1511
|
+
resolvedAt: new Date(),
|
|
1512
|
+
resolvedById: auth.session.userId,
|
|
1513
|
+
relatedEntityIds: [sourceEntityId],
|
|
1514
|
+
metadata: { ...existingMeta, pendingFix: null },
|
|
1515
|
+
},
|
|
1516
|
+
});
|
|
1517
|
+
try {
|
|
1518
|
+
await logEvent({
|
|
1519
|
+
event: 'document_updated',
|
|
1520
|
+
userId: auth.session.userId,
|
|
1521
|
+
details: {
|
|
1522
|
+
action: 'seo_issue_fix_applied',
|
|
1523
|
+
seoFix: 'insert-link',
|
|
1524
|
+
issueId: issue.id,
|
|
1525
|
+
issueType: issue.type,
|
|
1526
|
+
sourceEntityId,
|
|
1527
|
+
targetEntityId,
|
|
1528
|
+
targetPath,
|
|
1529
|
+
},
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
/* noop */
|
|
1534
|
+
}
|
|
1535
|
+
return json({
|
|
1536
|
+
data: {
|
|
1537
|
+
issue: seoIssueToApi(resolved),
|
|
1538
|
+
applied: cachedPending.changes,
|
|
1539
|
+
},
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1368
1542
|
if (REDIRECT_FIX_TYPES.has(issue.type)) {
|
|
1369
|
-
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1370
1543
|
const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1371
|
-
const cached =
|
|
1544
|
+
const cached = cachedPending;
|
|
1372
1545
|
const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
|
|
1373
1546
|
if (!cached?.autoFixable) {
|
|
1374
1547
|
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
@@ -1393,6 +1566,31 @@ export function registerSeoRoutes(router) {
|
|
|
1393
1566
|
const msg = err instanceof Error ? err.message : 'Fix application failed';
|
|
1394
1567
|
return errorResponse(msg, 400);
|
|
1395
1568
|
}
|
|
1569
|
+
const afterDoc = await db().document.findFirst({
|
|
1570
|
+
where: { id: loaded.entity.entityId, deletedAt: null },
|
|
1571
|
+
select: { data: true },
|
|
1572
|
+
});
|
|
1573
|
+
const redirectSuggestion = {
|
|
1574
|
+
autoFixable: true,
|
|
1575
|
+
fingerprint: cached.fingerprint,
|
|
1576
|
+
fixStrategy: fixStrategy,
|
|
1577
|
+
patch: cached.patch,
|
|
1578
|
+
changes: cached.changes,
|
|
1579
|
+
justification: cached.justification,
|
|
1580
|
+
source: cached.source,
|
|
1581
|
+
};
|
|
1582
|
+
const verified = await verifySeoIssueFix({
|
|
1583
|
+
issueType: issue.type,
|
|
1584
|
+
suggestion: redirectSuggestion,
|
|
1585
|
+
entityId: loaded.entity.entityId,
|
|
1586
|
+
live: { data: asRecord(afterDoc?.data) },
|
|
1587
|
+
db: db(),
|
|
1588
|
+
siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
|
|
1589
|
+
navLinks: await gatherNavigationLinkTargets(db()),
|
|
1590
|
+
});
|
|
1591
|
+
if (!verified.ok) {
|
|
1592
|
+
return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
|
|
1593
|
+
}
|
|
1396
1594
|
const existingMeta = asRecord(issue.metadata);
|
|
1397
1595
|
const resolved = await db().seoIssue.update({
|
|
1398
1596
|
where: { id: issue.id },
|
|
@@ -1436,14 +1634,13 @@ export function registerSeoRoutes(router) {
|
|
|
1436
1634
|
});
|
|
1437
1635
|
}
|
|
1438
1636
|
const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1439
|
-
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1440
1637
|
const deterministic = suggestDeterministicFix(issueInput, fixCtx);
|
|
1441
1638
|
let suggestion = deterministic;
|
|
1442
1639
|
if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
|
|
1443
1640
|
const inlinePlanErr = assertPlanFeature('seo.inlineApply');
|
|
1444
1641
|
if (inlinePlanErr)
|
|
1445
1642
|
return planRequiredResponse(inlinePlanErr);
|
|
1446
|
-
const cached =
|
|
1643
|
+
const cached = cachedPending;
|
|
1447
1644
|
const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
|
|
1448
1645
|
if (!cached || !cached.autoFixable) {
|
|
1449
1646
|
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
@@ -1481,6 +1678,50 @@ export function registerSeoRoutes(router) {
|
|
|
1481
1678
|
return errorResponse('Document not found', 404);
|
|
1482
1679
|
throw err;
|
|
1483
1680
|
}
|
|
1681
|
+
const afterDoc = await db().document.findFirst({
|
|
1682
|
+
where: { id: loaded.entity.entityId, deletedAt: null },
|
|
1683
|
+
select: {
|
|
1684
|
+
id: true,
|
|
1685
|
+
collection: true,
|
|
1686
|
+
title: true,
|
|
1687
|
+
slug: true,
|
|
1688
|
+
data: true,
|
|
1689
|
+
structuredData: true,
|
|
1690
|
+
plainText: true,
|
|
1691
|
+
publishedAt: true,
|
|
1692
|
+
status: true,
|
|
1693
|
+
},
|
|
1694
|
+
});
|
|
1695
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
1696
|
+
const liveEntity = afterDoc
|
|
1697
|
+
? documentToAuditEntity(afterDoc, frontEndContentCollectionTypes(getPrentaConfig()), loaded.siteSeo ?? getPrentaConfig()?.seo)
|
|
1698
|
+
: loaded.entity;
|
|
1699
|
+
// Prefer the rendered/effective meta title so verify matches changes.after
|
|
1700
|
+
// (page-title portion in the patch ≠ rendered title when a template applies).
|
|
1701
|
+
const metaTitleLive = suggestion.changes.some((c) => c.field === 'metaTitle')
|
|
1702
|
+
? (liveEntity.metaTitle ??
|
|
1703
|
+
(typeof suggestion.patch.metaTitle === 'string' ? suggestion.patch.metaTitle : null))
|
|
1704
|
+
: (liveEntity.metaTitle ?? null);
|
|
1705
|
+
const verified = await verifySeoIssueFix({
|
|
1706
|
+
issueType: issue.type,
|
|
1707
|
+
suggestion,
|
|
1708
|
+
entityId: loaded.entity.entityId,
|
|
1709
|
+
live: {
|
|
1710
|
+
data: asRecord(afterDoc?.data),
|
|
1711
|
+
metaTitle: metaTitleLive,
|
|
1712
|
+
metaDescription: liveEntity.metaDescription ?? null,
|
|
1713
|
+
canonical: liveEntity.canonicalUrl ?? null,
|
|
1714
|
+
noindex: liveEntity.noindex ?? null,
|
|
1715
|
+
structuredDataType: liveEntity.structuredDataType ?? null,
|
|
1716
|
+
status: afterDoc ? String(afterDoc.status) : undefined,
|
|
1717
|
+
},
|
|
1718
|
+
db: db(),
|
|
1719
|
+
siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
|
|
1720
|
+
navLinks: await gatherNavigationLinkTargets(db()),
|
|
1721
|
+
});
|
|
1722
|
+
if (!verified.ok) {
|
|
1723
|
+
return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
|
|
1724
|
+
}
|
|
1484
1725
|
const existingMeta = asRecord(issue.metadata);
|
|
1485
1726
|
const resolved = await db().seoIssue.update({
|
|
1486
1727
|
where: { id: issue.id },
|
|
@@ -1545,8 +1786,14 @@ export function registerSeoRoutes(router) {
|
|
|
1545
1786
|
});
|
|
1546
1787
|
openIssues = sortIssuesBySeverity(rawIssues);
|
|
1547
1788
|
}
|
|
1548
|
-
const
|
|
1549
|
-
const
|
|
1789
|
+
const { frontEndContentCollectionTypes, countAuditEntities } = await import('../../seo/audit-runner.js');
|
|
1790
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
1791
|
+
const total = await countAuditEntities(db(), { collectionTypes }).catch(() => 0);
|
|
1792
|
+
const meta = (lastRun?.metadata ?? {});
|
|
1793
|
+
const indexable = typeof meta.indexableCount === 'number' && Number.isFinite(meta.indexableCount)
|
|
1794
|
+
? meta.indexableCount
|
|
1795
|
+
: total;
|
|
1796
|
+
const auditTruncated = meta.truncated === true;
|
|
1550
1797
|
const perf = await loadSearchPerformance(10);
|
|
1551
1798
|
const summary = {
|
|
1552
1799
|
total: openIssues.length,
|
|
@@ -1565,7 +1812,7 @@ export function registerSeoRoutes(router) {
|
|
|
1565
1812
|
{
|
|
1566
1813
|
label: 'Pages Indexed',
|
|
1567
1814
|
value: String(indexable),
|
|
1568
|
-
helper: `${
|
|
1815
|
+
helper: auditTruncated ? `${total} published (audit capped)` : `${total} published pages`,
|
|
1569
1816
|
trend: 'flat',
|
|
1570
1817
|
},
|
|
1571
1818
|
{
|
|
@@ -1626,6 +1873,7 @@ export function registerSeoRoutes(router) {
|
|
|
1626
1873
|
searchPerformance: perf,
|
|
1627
1874
|
searchConsoleConnected: moduleSettings.searchConsoleConnected,
|
|
1628
1875
|
lastAuditRunId: lastRun?.id ?? null,
|
|
1876
|
+
auditTruncated,
|
|
1629
1877
|
},
|
|
1630
1878
|
});
|
|
1631
1879
|
}
|
|
@@ -1657,8 +1905,22 @@ export function registerSeoRoutes(router) {
|
|
|
1657
1905
|
// metaTitle/description/canonical/OG for every collection, the exact
|
|
1658
1906
|
// bypass the sibling document-level SEO endpoints already prevent.
|
|
1659
1907
|
const allowed = await readableSeoCollections(auth.session);
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1908
|
+
const url = new URL(request.url);
|
|
1909
|
+
const page = clampPage(url.searchParams.get('page'));
|
|
1910
|
+
const pageSize = clampPageSize(url.searchParams.get('pageSize'), 100, 50);
|
|
1911
|
+
const result = await buildSeoContentRecords(allowed, undefined, {
|
|
1912
|
+
skip: (page - 1) * pageSize,
|
|
1913
|
+
take: pageSize,
|
|
1914
|
+
});
|
|
1915
|
+
return json({
|
|
1916
|
+
data: {
|
|
1917
|
+
records: result.records,
|
|
1918
|
+
total: result.total,
|
|
1919
|
+
page,
|
|
1920
|
+
pageSize,
|
|
1921
|
+
truncated: result.truncated,
|
|
1922
|
+
},
|
|
1923
|
+
});
|
|
1662
1924
|
}
|
|
1663
1925
|
catch (err) {
|
|
1664
1926
|
return internalError(err, 'seo/content');
|
|
@@ -1685,7 +1947,7 @@ export function registerSeoRoutes(router) {
|
|
|
1685
1947
|
// draft has SEO worth editing, and the matching PUT accepts one. Resolving
|
|
1686
1948
|
// through the published-only gather made drafts 404 here while remaining
|
|
1687
1949
|
// writable — so an editor could save SEO and never read it back.
|
|
1688
|
-
const records = await buildSeoContentRecords(null, [params.entityId]);
|
|
1950
|
+
const { records } = await buildSeoContentRecords(null, [params.entityId]);
|
|
1689
1951
|
const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
|
|
1690
1952
|
if (!record)
|
|
1691
1953
|
return errorResponse('Content record not found', 404);
|
|
@@ -1846,7 +2108,7 @@ export function registerSeoRoutes(router) {
|
|
|
1846
2108
|
catch {
|
|
1847
2109
|
/* noop */
|
|
1848
2110
|
}
|
|
1849
|
-
const records = await buildSeoContentRecords();
|
|
2111
|
+
const { records } = await buildSeoContentRecords(null, [doc.id]);
|
|
1850
2112
|
const record = records.find((r) => r.entityId === doc.id);
|
|
1851
2113
|
return json({ data: record ?? { ok: true } });
|
|
1852
2114
|
}
|