@prenta/core 7.7.0 → 7.7.1
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 +6 -0
- package/dist/api/routes/seo/handlers/analysis.d.ts +4 -0
- package/dist/api/routes/seo/handlers/analysis.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/analysis.js +131 -0
- package/dist/api/routes/seo/handlers/analysis.js.map +1 -0
- package/dist/api/routes/seo/handlers/audits.d.ts +5 -0
- package/dist/api/routes/seo/handlers/audits.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/audits.js +121 -0
- package/dist/api/routes/seo/handlers/audits.js.map +1 -0
- package/dist/api/routes/seo/handlers/content.d.ts +4 -0
- package/dist/api/routes/seo/handlers/content.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/content.js +330 -0
- package/dist/api/routes/seo/handlers/content.js.map +1 -0
- package/dist/api/routes/seo/handlers/issue-helpers.d.ts +37 -0
- package/dist/api/routes/seo/handlers/issue-helpers.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issue-helpers.js +72 -0
- package/dist/api/routes/seo/handlers/issue-helpers.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-briefs.d.ts +7 -0
- package/dist/api/routes/seo/handlers/issues-briefs.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-briefs.js +341 -0
- package/dist/api/routes/seo/handlers/issues-briefs.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-fixes.d.ts +4 -0
- package/dist/api/routes/seo/handlers/issues-fixes.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-fixes.js +233 -0
- package/dist/api/routes/seo/handlers/issues-fixes.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-list.d.ts +7 -0
- package/dist/api/routes/seo/handlers/issues-list.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-list.js +191 -0
- package/dist/api/routes/seo/handlers/issues-list.js.map +1 -0
- package/dist/api/routes/seo/handlers/links.d.ts +6 -0
- package/dist/api/routes/seo/handlers/links.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/links.js +383 -0
- package/dist/api/routes/seo/handlers/links.js.map +1 -0
- package/dist/api/routes/seo/handlers/overview.d.ts +4 -0
- package/dist/api/routes/seo/handlers/overview.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/overview.js +348 -0
- package/dist/api/routes/seo/handlers/overview.js.map +1 -0
- package/dist/api/routes/seo/handlers/pages.d.ts +2 -0
- package/dist/api/routes/seo/handlers/pages.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/pages.js +38 -0
- package/dist/api/routes/seo/handlers/pages.js.map +1 -0
- package/dist/api/routes/seo/handlers/proposals.d.ts +4 -0
- package/dist/api/routes/seo/handlers/proposals.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/proposals.js +93 -0
- package/dist/api/routes/seo/handlers/proposals.js.map +1 -0
- package/dist/api/routes/seo/handlers/public-files.d.ts +6 -0
- package/dist/api/routes/seo/handlers/public-files.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/public-files.js +328 -0
- package/dist/api/routes/seo/handlers/public-files.js.map +1 -0
- package/dist/api/routes/seo/handlers/shared.d.ts +24 -0
- package/dist/api/routes/seo/handlers/shared.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/shared.js +67 -0
- package/dist/api/routes/seo/handlers/shared.js.map +1 -0
- package/dist/api/routes/seo.d.ts +5 -5
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +56 -2610
- package/dist/api/routes/seo.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function getSeoAnalysis(request: Request, params: Record<string, string>): Promise<Response>;
|
|
2
|
+
export declare function getSeoReadability(request: Request, params: Record<string, string>): Promise<Response>;
|
|
3
|
+
export declare function getSeoInternalLinks(request: Request, params: Record<string, string>): Promise<Response>;
|
|
4
|
+
//# sourceMappingURL=analysis.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analysis.d.ts","sourceRoot":"","sources":["../../../../../src/api/routes/seo/handlers/analysis.ts"],"names":[],"mappings":"AAaA,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAkDnB;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAuBnB;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,OAAO,CAAC,QAAQ,CAAC,CA8CnB"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { getPrentaConfig } from '../../../../config/runtime.js';
|
|
2
|
+
import { extraImagesForSeoAnalysis, firstSectionHeading } from '../../../../seo/document-content.js';
|
|
3
|
+
import { errorResponse, internalError, requireAuth, enforceDocumentReadAccess, } from '../../../route-helpers.js';
|
|
4
|
+
import { db } from './shared.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// SEO analysis, readability, internal links, schema, meta
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export async function getSeoAnalysis(request, params) {
|
|
9
|
+
try {
|
|
10
|
+
const auth = await requireAuth(request);
|
|
11
|
+
if (auth.error)
|
|
12
|
+
return auth.error;
|
|
13
|
+
const doc = await db().document.findFirst({
|
|
14
|
+
where: { id: params.documentId, deletedAt: null },
|
|
15
|
+
});
|
|
16
|
+
if (!doc)
|
|
17
|
+
return errorResponse('Not found', 404);
|
|
18
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
19
|
+
if (accessErr)
|
|
20
|
+
return accessErr;
|
|
21
|
+
const { analyzeContent } = await import('../../../../seo/analysis.js');
|
|
22
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../../../seo/audit-runner.js');
|
|
23
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../../../seo/config-store.js');
|
|
24
|
+
const data = doc.data || {};
|
|
25
|
+
// Same content assembly as readability + audit entity (`documentContentForSeoAnalysis`)
|
|
26
|
+
// so MCP/admin scores agree and section-built pages still get plainText/sections.
|
|
27
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
28
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
29
|
+
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
30
|
+
const entity = documentToAuditEntity(doc, collectionTypes, siteSeo);
|
|
31
|
+
const result = analyzeContent({
|
|
32
|
+
title: entity.title,
|
|
33
|
+
slug: doc.slug || data.slug || '',
|
|
34
|
+
content: entity.content ?? '',
|
|
35
|
+
// Rendered title (site template applied) — the same value the page
|
|
36
|
+
// score measures, so both agree instead of one reading the page portion.
|
|
37
|
+
metaTitle: entity.metaTitle ?? undefined,
|
|
38
|
+
metaDescription: entity.metaDescription ?? undefined,
|
|
39
|
+
focusKeyphrase: entity.focusKeyword ?? undefined,
|
|
40
|
+
canonical: entity.canonicalUrl ?? undefined,
|
|
41
|
+
ogTitle: entity.ogTitle ?? undefined,
|
|
42
|
+
ogDescription: entity.ogDescription ?? undefined,
|
|
43
|
+
ogImage: entity.ogImage ?? undefined,
|
|
44
|
+
extraImages: extraImagesForSeoAnalysis(data),
|
|
45
|
+
entityType: entity.entityType,
|
|
46
|
+
sectionH1: firstSectionHeading(data) ?? undefined,
|
|
47
|
+
isCornerstone: data.isCornerstone,
|
|
48
|
+
});
|
|
49
|
+
return new Response(JSON.stringify(result), {
|
|
50
|
+
status: 200,
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return internalError(err, 'seo/analysis');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function getSeoReadability(request, params) {
|
|
59
|
+
try {
|
|
60
|
+
const auth = await requireAuth(request);
|
|
61
|
+
if (auth.error)
|
|
62
|
+
return auth.error;
|
|
63
|
+
const doc = await db().document.findFirst({
|
|
64
|
+
where: { id: params.documentId, deletedAt: null },
|
|
65
|
+
});
|
|
66
|
+
if (!doc)
|
|
67
|
+
return errorResponse('Not found', 404);
|
|
68
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
69
|
+
if (accessErr)
|
|
70
|
+
return accessErr;
|
|
71
|
+
const { calculateReadability, stripHtmlTags } = await import('../../../../seo/analysis.js');
|
|
72
|
+
const { documentContentForSeoAnalysis } = await import('../../../../seo/document-content.js');
|
|
73
|
+
const text = stripHtmlTags(documentContentForSeoAnalysis(doc));
|
|
74
|
+
const result = calculateReadability(text);
|
|
75
|
+
return new Response(JSON.stringify(result), {
|
|
76
|
+
status: 200,
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return internalError(err, 'seo/readability');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function getSeoInternalLinks(request, params) {
|
|
85
|
+
try {
|
|
86
|
+
const auth = await requireAuth(request);
|
|
87
|
+
if (auth.error)
|
|
88
|
+
return auth.error;
|
|
89
|
+
const doc = await db().document.findFirst({
|
|
90
|
+
where: { id: params.documentId, deletedAt: null },
|
|
91
|
+
});
|
|
92
|
+
if (!doc)
|
|
93
|
+
return errorResponse('Not found', 404);
|
|
94
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
95
|
+
if (accessErr)
|
|
96
|
+
return accessErr;
|
|
97
|
+
const data = doc.data || {};
|
|
98
|
+
const title = doc.title || data.title || '';
|
|
99
|
+
const keywords = title
|
|
100
|
+
.split(/\s+/)
|
|
101
|
+
.filter((w) => w.length > 3)
|
|
102
|
+
.slice(0, 5);
|
|
103
|
+
if (keywords.length === 0) {
|
|
104
|
+
return new Response(JSON.stringify({ suggestions: [] }), {
|
|
105
|
+
status: 200,
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const suggestions = await db().document.findMany({
|
|
110
|
+
where: {
|
|
111
|
+
id: { not: params.documentId },
|
|
112
|
+
status: 'PUBLISHED',
|
|
113
|
+
deletedAt: null,
|
|
114
|
+
OR: keywords.map((kw) => ({
|
|
115
|
+
title: { contains: kw, mode: 'insensitive' },
|
|
116
|
+
})),
|
|
117
|
+
},
|
|
118
|
+
select: { id: true, title: true, slug: true, collection: true },
|
|
119
|
+
take: 10,
|
|
120
|
+
orderBy: { updatedAt: 'desc' },
|
|
121
|
+
});
|
|
122
|
+
return new Response(JSON.stringify({ suggestions }), {
|
|
123
|
+
status: 200,
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return internalError(err, 'seo/internal-links');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=analysis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analysis.js","sourceRoot":"","sources":["../../../../../src/api/routes/seo/handlers/analysis.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function postSeoScan(request: Request): Promise<Response>;
|
|
2
|
+
export declare function postSeoAudit(request: Request): Promise<Response>;
|
|
3
|
+
export declare function getSeoAudits(request: Request): Promise<Response>;
|
|
4
|
+
export declare function getSeoAudit(request: Request, params: Record<string, string>): Promise<Response>;
|
|
5
|
+
//# sourceMappingURL=audits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audits.d.ts","sourceRoot":"","sources":["../../../../../src/api/routes/seo/handlers/audits.ts"],"names":[],"mappings":"AAmBA,wBAAsB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4BrE;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqDtE;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAatE;AAED,wBAAsB,WAAW,CAC/B,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAWnB"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { logEvent } from '../../../../security/audit.js';
|
|
2
|
+
import { processSeoScan } from '../../../../cron/index.js';
|
|
3
|
+
import { isResolvedIp } from '../../../../security/client-ip.js';
|
|
4
|
+
import { getPrentaConfig } from '../../../../config/runtime.js';
|
|
5
|
+
import { json, errorResponse, internalError, hasModel, modelNotAvailable, requireAuth, WRITE_ROLES, requireRole, seoAuditLimiter, checkRateLimitAsync, clientIp, } from '../../../route-helpers.js';
|
|
6
|
+
import { db } from './shared.js';
|
|
7
|
+
export async function postSeoScan(request) {
|
|
8
|
+
try {
|
|
9
|
+
const auth = await requireAuth(request);
|
|
10
|
+
if (auth.error)
|
|
11
|
+
return auth.error;
|
|
12
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
13
|
+
if (roleErr)
|
|
14
|
+
return roleErr;
|
|
15
|
+
// Expensive: loads + regex-scans every published doc. Rate-limit it (same
|
|
16
|
+
// budget as the full audit) so it can't be hammered into an OOM/CPU spike.
|
|
17
|
+
const scanIp = clientIp(request);
|
|
18
|
+
const scanKey = isResolvedIp(scanIp)
|
|
19
|
+
? `seo-scan:${scanIp}`
|
|
20
|
+
: `seo-scan-user:${auth.session.userId}`;
|
|
21
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
|
|
22
|
+
return errorResponse('Too many scans. Please try again later.', 429);
|
|
23
|
+
}
|
|
24
|
+
// Scope the scan to configured front-end content collections so structural
|
|
25
|
+
// (navigation/footer) and benchmark documents are never reported. Shares
|
|
26
|
+
// the exact scan logic with the cron path via processSeoScan.
|
|
27
|
+
const { frontEndContentCollectionTypes } = await import('../../../../seo/audit-runner.js');
|
|
28
|
+
const allowedCollections = Object.keys(frontEndContentCollectionTypes(getPrentaConfig()));
|
|
29
|
+
const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
|
|
30
|
+
return json({ data: result });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return internalError(err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function postSeoAudit(request) {
|
|
37
|
+
try {
|
|
38
|
+
const auth = await requireAuth(request);
|
|
39
|
+
if (auth.error)
|
|
40
|
+
return auth.error;
|
|
41
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
42
|
+
if (roleErr)
|
|
43
|
+
return roleErr;
|
|
44
|
+
if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
|
|
45
|
+
return modelNotAvailable('SeoAuditRun');
|
|
46
|
+
}
|
|
47
|
+
const ip = clientIp(request);
|
|
48
|
+
const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
|
|
49
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
|
|
50
|
+
return errorResponse('Too many audits. Please try again later.', 429);
|
|
51
|
+
}
|
|
52
|
+
const body = (await request.json().catch(() => ({})));
|
|
53
|
+
const scope = body?.scope ?? 'full';
|
|
54
|
+
const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../../../../seo/audit-runner.js');
|
|
55
|
+
const run = await runAndPersistAudit(db(), {
|
|
56
|
+
scope,
|
|
57
|
+
startedById: auth.session.userId,
|
|
58
|
+
collectionTypes: frontEndContentCollectionTypes(getPrentaConfig()),
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
await logEvent({
|
|
62
|
+
event: 'settings_changed',
|
|
63
|
+
userId: auth.session.userId,
|
|
64
|
+
details: { action: 'seo_audit', auditRunId: run.id, scope },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* audit-log failures never block */
|
|
69
|
+
}
|
|
70
|
+
return json({
|
|
71
|
+
data: {
|
|
72
|
+
id: run.id,
|
|
73
|
+
status: run.status,
|
|
74
|
+
siteScoreAfter: run.siteScoreAfter,
|
|
75
|
+
issuesFound: run.issuesFound,
|
|
76
|
+
criticalCount: run.criticalCount,
|
|
77
|
+
warningCount: run.warningCount,
|
|
78
|
+
infoCount: run.infoCount,
|
|
79
|
+
pagesScanned: run.pagesScanned,
|
|
80
|
+
renderedAudit: run.renderedAudit,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return internalError(err, 'seo/audit');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function getSeoAudits(request) {
|
|
89
|
+
try {
|
|
90
|
+
const auth = await requireAuth(request);
|
|
91
|
+
if (auth.error)
|
|
92
|
+
return auth.error;
|
|
93
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
94
|
+
return json({ data: { runs: [] } });
|
|
95
|
+
const runs = await db().seoAuditRun.findMany({
|
|
96
|
+
orderBy: { startedAt: 'desc' },
|
|
97
|
+
take: 50,
|
|
98
|
+
});
|
|
99
|
+
return json({ data: { runs } });
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return internalError(err, 'seo/audits');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function getSeoAudit(request, params) {
|
|
106
|
+
try {
|
|
107
|
+
const auth = await requireAuth(request);
|
|
108
|
+
if (auth.error)
|
|
109
|
+
return auth.error;
|
|
110
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
111
|
+
return modelNotAvailable('SeoAuditRun');
|
|
112
|
+
const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
|
|
113
|
+
if (!run)
|
|
114
|
+
return errorResponse('Audit run not found', 404);
|
|
115
|
+
return json({ data: run });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return internalError(err, 'seo/audits/:id');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=audits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audits.js","sourceRoot":"","sources":["../../../../../src/api/routes/seo/handlers/audits.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function getSeoContent(request: Request): Promise<Response>;
|
|
2
|
+
export declare function getSeoContentRecord(request: Request, params: Record<string, string>): Promise<Response>;
|
|
3
|
+
export declare function putSeoContentRecord(request: Request, params: Record<string, string>): Promise<Response>;
|
|
4
|
+
//# sourceMappingURL=content.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../../../src/api/routes/seo/handlers/content.ts"],"names":[],"mappings":"AAgIA,wBAAsB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4BvE;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAqDnB;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,OAAO,CAAC,QAAQ,CAAC,CAmInB"}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { updateDocumentSeoFields } from '../../../../actions.js';
|
|
2
|
+
import { logEvent } from '../../../../security/audit.js';
|
|
3
|
+
import { DEFAULT_DIRECTIVES, coerceMaxImagePreview } from '../../../../seo/robots-directives.js';
|
|
4
|
+
import { getPrentaConfig } from '../../../../config/runtime.js';
|
|
5
|
+
import { syncSchemaTypeFields } from '../../../../seo/schema-sync.js';
|
|
6
|
+
import { json, errorResponse, internalError, asRecord, requireAuth, buildActionContext, WRITE_ROLES, requireRole, enforceDocumentReadAccess, clampPage, clampPageSize, } from '../../../route-helpers.js';
|
|
7
|
+
import { db } from './shared.js';
|
|
8
|
+
/**
|
|
9
|
+
* Compute the set of front-end content collections the caller may read.
|
|
10
|
+
* Mirrors the per-document `enforceDocumentReadAccess` check the sibling
|
|
11
|
+
* `/seo/analysis|readability|…` endpoints run, but resolved once for the
|
|
12
|
+
* collection list so the aggregate `/seo/content` view can never surface SEO
|
|
13
|
+
* fields for a collection the caller's scope/role forbids.
|
|
14
|
+
*/
|
|
15
|
+
async function readableSeoCollections(session) {
|
|
16
|
+
const { frontEndContentCollectionTypes } = await import('../../../../seo/audit-runner.js');
|
|
17
|
+
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
18
|
+
const allowed = new Set();
|
|
19
|
+
for (const slug of Object.keys(collectionTypes)) {
|
|
20
|
+
const err = await enforceDocumentReadAccess(session, slug);
|
|
21
|
+
if (!err)
|
|
22
|
+
allowed.add(slug);
|
|
23
|
+
}
|
|
24
|
+
return allowed;
|
|
25
|
+
}
|
|
26
|
+
async function buildSeoContentRecords(allowedCollections,
|
|
27
|
+
/**
|
|
28
|
+
* Restrict to specific documents, regardless of publication status. Used by
|
|
29
|
+
* the per-document GET, which must answer for a saved draft — the list
|
|
30
|
+
* endpoints deliberately stay published-only.
|
|
31
|
+
*/
|
|
32
|
+
documentIds, page) {
|
|
33
|
+
const { gatherAuditEntities, countAuditEntities, frontEndContentCollectionTypes } = await import('../../../../seo/audit-runner.js');
|
|
34
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../../../seo/config-store.js');
|
|
35
|
+
const { calculatePageSeoScore } = await import('../../../../seo/score.js');
|
|
36
|
+
const allTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
37
|
+
// When a readable-collection allow-list is supplied, scope the gather to it
|
|
38
|
+
// so records for forbidden collections are never even built.
|
|
39
|
+
const collectionTypes = allowedCollections
|
|
40
|
+
? Object.fromEntries(Object.entries(allTypes).filter(([slug]) => allowedCollections.has(slug)))
|
|
41
|
+
: allTypes;
|
|
42
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
43
|
+
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
44
|
+
const [entities, total] = await Promise.all([
|
|
45
|
+
gatherAuditEntities(db(), {
|
|
46
|
+
collectionTypes,
|
|
47
|
+
documentIds,
|
|
48
|
+
skip: page?.skip,
|
|
49
|
+
take: page?.take,
|
|
50
|
+
}),
|
|
51
|
+
documentIds
|
|
52
|
+
? Promise.resolve(documentIds.length)
|
|
53
|
+
: countAuditEntities(db(), { collectionTypes }),
|
|
54
|
+
]);
|
|
55
|
+
const records = entities.map((e) => {
|
|
56
|
+
const score = calculatePageSeoScore(e);
|
|
57
|
+
return {
|
|
58
|
+
id: `${e.entityType}:${e.entityId}`,
|
|
59
|
+
entityType: e.entityType,
|
|
60
|
+
entityId: e.entityId,
|
|
61
|
+
title: e.title,
|
|
62
|
+
url: e.url,
|
|
63
|
+
canonicalUrl: e.canonicalUrl ?? null,
|
|
64
|
+
/** Rendered public title (after site title template). */
|
|
65
|
+
metaTitle: e.metaTitle ?? null,
|
|
66
|
+
/** Stored page-specific portion (`%page%` token). */
|
|
67
|
+
metaTitlePage: e.metaTitlePage ?? null,
|
|
68
|
+
siteName: siteSeo?.siteName ?? null,
|
|
69
|
+
titleTemplate: siteSeo?.titleTemplate ?? null,
|
|
70
|
+
metaDescription: e.metaDescription ?? null,
|
|
71
|
+
focusKeyword: e.focusKeyword ?? null,
|
|
72
|
+
keyTakeaway: e.keyTakeaway ?? null,
|
|
73
|
+
ogTitle: e.ogTitle ?? null,
|
|
74
|
+
ogDescription: e.ogDescription ?? null,
|
|
75
|
+
ogImageId: null,
|
|
76
|
+
ogImage: e.ogImage ?? null,
|
|
77
|
+
twitterTitle: null,
|
|
78
|
+
twitterDescription: null,
|
|
79
|
+
twitterImageId: null,
|
|
80
|
+
noindex: e.noindex ?? false,
|
|
81
|
+
nofollow: false,
|
|
82
|
+
// Permissive defaults; the single-record GET overlays what is stored.
|
|
83
|
+
nosnippet: false,
|
|
84
|
+
noarchive: false,
|
|
85
|
+
maxImagePreview: DEFAULT_DIRECTIVES.maxImagePreview,
|
|
86
|
+
structuredDataType: e.structuredDataType ?? null,
|
|
87
|
+
structuredData: null,
|
|
88
|
+
// llms.txt curation defaults; the single-record GET overlays the real
|
|
89
|
+
// stored values (the list endpoint doesn't surface these).
|
|
90
|
+
llmsInclude: 'AUTO',
|
|
91
|
+
llmsDescription: null,
|
|
92
|
+
llmsSection: null,
|
|
93
|
+
llmsOptional: false,
|
|
94
|
+
seoScore: score.score,
|
|
95
|
+
seoGrade: score.grade,
|
|
96
|
+
updatedAt: e.updatedAt instanceof Date
|
|
97
|
+
? e.updatedAt.toISOString()
|
|
98
|
+
: typeof e.updatedAt === 'string'
|
|
99
|
+
? e.updatedAt
|
|
100
|
+
: null,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
records,
|
|
105
|
+
total,
|
|
106
|
+
truncated: !documentIds && total > records.length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export async function getSeoContent(request) {
|
|
110
|
+
try {
|
|
111
|
+
const auth = await requireAuth(request);
|
|
112
|
+
if (auth.error)
|
|
113
|
+
return auth.error;
|
|
114
|
+
// Scope the list to the collections this caller may read — otherwise a
|
|
115
|
+
// collection-scoped API key (or a collection-restricted role) could pull
|
|
116
|
+
// metaTitle/description/canonical/OG for every collection, the exact
|
|
117
|
+
// bypass the sibling document-level SEO endpoints already prevent.
|
|
118
|
+
const allowed = await readableSeoCollections(auth.session);
|
|
119
|
+
const url = new URL(request.url);
|
|
120
|
+
const page = clampPage(url.searchParams.get('page'));
|
|
121
|
+
const pageSize = clampPageSize(url.searchParams.get('pageSize'), 100, 50);
|
|
122
|
+
const result = await buildSeoContentRecords(allowed, undefined, {
|
|
123
|
+
skip: (page - 1) * pageSize,
|
|
124
|
+
take: pageSize,
|
|
125
|
+
});
|
|
126
|
+
return json({
|
|
127
|
+
data: {
|
|
128
|
+
records: result.records,
|
|
129
|
+
total: result.total,
|
|
130
|
+
page,
|
|
131
|
+
pageSize,
|
|
132
|
+
truncated: result.truncated,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return internalError(err, 'seo/content');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function getSeoContentRecord(request, params) {
|
|
141
|
+
try {
|
|
142
|
+
const auth = await requireAuth(request);
|
|
143
|
+
if (auth.error)
|
|
144
|
+
return auth.error;
|
|
145
|
+
// Load the target first so we can enforce the collection's read-access
|
|
146
|
+
// rule (requireCollectionScope + assertCollectionAccess) before returning
|
|
147
|
+
// any SEO fields — parity with /seo/analysis and friends.
|
|
148
|
+
const doc = await db().document.findFirst({
|
|
149
|
+
where: { id: params.entityId, deletedAt: null },
|
|
150
|
+
select: { data: true, collection: true },
|
|
151
|
+
});
|
|
152
|
+
if (!doc)
|
|
153
|
+
return errorResponse('Content record not found', 404);
|
|
154
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
155
|
+
if (accessErr)
|
|
156
|
+
return accessErr;
|
|
157
|
+
// Scoped to THIS document and not filtered by publication status: a saved
|
|
158
|
+
// draft has SEO worth editing, and the matching PUT accepts one. Resolving
|
|
159
|
+
// through the published-only gather made drafts 404 here while remaining
|
|
160
|
+
// writable — so an editor could save SEO and never read it back.
|
|
161
|
+
const { records } = await buildSeoContentRecords(null, [params.entityId]);
|
|
162
|
+
const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
|
|
163
|
+
if (!record)
|
|
164
|
+
return errorResponse('Content record not found', 404);
|
|
165
|
+
// Overlay the real llms.txt curation values from the stored document
|
|
166
|
+
// (the audit-entity pipeline that builds the base record doesn't carry
|
|
167
|
+
// these convention keys).
|
|
168
|
+
const data = asRecord(doc.data);
|
|
169
|
+
const includeRaw = String(data.llmsInclude ?? '').toUpperCase();
|
|
170
|
+
const merged = {
|
|
171
|
+
...record,
|
|
172
|
+
llmsInclude: includeRaw === 'ALWAYS' || includeRaw === 'NEVER'
|
|
173
|
+
? includeRaw
|
|
174
|
+
: 'AUTO',
|
|
175
|
+
llmsDescription: typeof data.llmsDescription === 'string' ? data.llmsDescription : null,
|
|
176
|
+
llmsSection: typeof data.llmsSection === 'string' ? data.llmsSection : null,
|
|
177
|
+
llmsOptional: data.llmsOptional === true,
|
|
178
|
+
// Robots directives live in the same content JSON. Overlaid here for
|
|
179
|
+
// the same reason as the llms.txt keys: the audit-entity pipeline that
|
|
180
|
+
// builds the base record does not carry them, so without this the
|
|
181
|
+
// editor would read every page back as fully permissive and silently
|
|
182
|
+
// overwrite a real restriction on the next save.
|
|
183
|
+
nosnippet: data.nosnippet === true,
|
|
184
|
+
noarchive: data.noarchive === true,
|
|
185
|
+
maxImagePreview: coerceMaxImagePreview(data.maxImagePreview),
|
|
186
|
+
};
|
|
187
|
+
return json({ data: merged });
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return internalError(err, 'seo/content/:id');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function putSeoContentRecord(request, params) {
|
|
194
|
+
try {
|
|
195
|
+
const auth = await requireAuth(request);
|
|
196
|
+
if (auth.error)
|
|
197
|
+
return auth.error;
|
|
198
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
199
|
+
if (roleErr)
|
|
200
|
+
return roleErr;
|
|
201
|
+
const body = (await request.json().catch(() => null));
|
|
202
|
+
if (!body || typeof body !== 'object') {
|
|
203
|
+
return errorResponse('Request body must be an object', 400);
|
|
204
|
+
}
|
|
205
|
+
const doc = await db().document.findFirst({
|
|
206
|
+
where: { id: params.entityId, deletedAt: null },
|
|
207
|
+
select: { id: true, collection: true },
|
|
208
|
+
});
|
|
209
|
+
if (!doc)
|
|
210
|
+
return errorResponse('Document not found', 404);
|
|
211
|
+
// String SEO fields: payload key -> stored data key, each with a max
|
|
212
|
+
// length so we never persist unbounded/megabyte values into the content
|
|
213
|
+
// JSON. Canonical is stored as `canonical` to match normalizeSeoPage.
|
|
214
|
+
const TEXT_FIELDS = {
|
|
215
|
+
metaTitle: { target: 'metaTitle', max: 300 },
|
|
216
|
+
metaDescription: { target: 'metaDescription', max: 1000 },
|
|
217
|
+
canonicalUrl: { target: 'canonical', max: 2000 },
|
|
218
|
+
focusKeyword: { target: 'focusKeyword', max: 200 },
|
|
219
|
+
keyTakeaway: { target: 'keyTakeaway', max: 500 },
|
|
220
|
+
ogTitle: { target: 'ogTitle', max: 300 },
|
|
221
|
+
ogDescription: { target: 'ogDescription', max: 1000 },
|
|
222
|
+
ogImage: { target: 'ogImage', max: 2000 },
|
|
223
|
+
twitterTitle: { target: 'twitterTitle', max: 300 },
|
|
224
|
+
twitterDescription: { target: 'twitterDescription', max: 1000 },
|
|
225
|
+
twitterImage: { target: 'twitterImage', max: 2000 },
|
|
226
|
+
structuredDataType: { target: 'structuredDataType', max: 100 },
|
|
227
|
+
schemaType: { target: 'schemaType', max: 100 },
|
|
228
|
+
llmsDescription: { target: 'llmsDescription', max: 500 },
|
|
229
|
+
llmsSection: { target: 'llmsSection', max: 100 },
|
|
230
|
+
};
|
|
231
|
+
const patch = {};
|
|
232
|
+
for (const [key, { target, max }] of Object.entries(TEXT_FIELDS)) {
|
|
233
|
+
if (!(key in body))
|
|
234
|
+
continue;
|
|
235
|
+
const value = body[key];
|
|
236
|
+
if (value === null || value === '') {
|
|
237
|
+
patch[target] = '';
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (typeof value !== 'string') {
|
|
241
|
+
return errorResponse(`Field "${key}" must be a string`, 400);
|
|
242
|
+
}
|
|
243
|
+
if (value.length > max) {
|
|
244
|
+
return errorResponse(`Field "${key}" exceeds the ${max}-character limit`, 400);
|
|
245
|
+
}
|
|
246
|
+
patch[target] = value;
|
|
247
|
+
}
|
|
248
|
+
if ('noindex' in body)
|
|
249
|
+
patch.noindex = body.noindex === true;
|
|
250
|
+
if ('nofollow' in body)
|
|
251
|
+
patch.nofollow = body.nofollow === true;
|
|
252
|
+
if ('nosnippet' in body)
|
|
253
|
+
patch.nosnippet = body.nosnippet === true;
|
|
254
|
+
if ('noarchive' in body)
|
|
255
|
+
patch.noarchive = body.noarchive === true;
|
|
256
|
+
if ('maxImagePreview' in body) {
|
|
257
|
+
// Coerced rather than rejected: the field is an enum with a permissive
|
|
258
|
+
// default, and refusing the write would block an otherwise valid patch
|
|
259
|
+
// over a value the composer already knows how to fall back from.
|
|
260
|
+
patch.maxImagePreview = coerceMaxImagePreview(body.maxImagePreview);
|
|
261
|
+
}
|
|
262
|
+
if ('llmsOptional' in body)
|
|
263
|
+
patch.llmsOptional = body.llmsOptional === true;
|
|
264
|
+
if ('llmsInclude' in body) {
|
|
265
|
+
const v = String(body.llmsInclude ?? '').toUpperCase();
|
|
266
|
+
if (v !== 'AUTO' && v !== 'ALWAYS' && v !== 'NEVER') {
|
|
267
|
+
return errorResponse('llmsInclude must be one of AUTO, ALWAYS, NEVER', 400);
|
|
268
|
+
}
|
|
269
|
+
// AUTO is the default — store empty so the field falls back cleanly.
|
|
270
|
+
patch.llmsInclude = v === 'AUTO' ? '' : v;
|
|
271
|
+
}
|
|
272
|
+
if ('llmsPriority' in body) {
|
|
273
|
+
const n = body.llmsPriority;
|
|
274
|
+
if (n !== null && typeof n !== 'number') {
|
|
275
|
+
return errorResponse('llmsPriority must be a number', 400);
|
|
276
|
+
}
|
|
277
|
+
patch.llmsPriority = n ?? 0;
|
|
278
|
+
}
|
|
279
|
+
if ('structuredData' in body) {
|
|
280
|
+
const sd = body.structuredData;
|
|
281
|
+
if (sd !== null && (typeof sd !== 'object' || Array.isArray(sd))) {
|
|
282
|
+
return errorResponse('structuredData must be a JSON object', 400);
|
|
283
|
+
}
|
|
284
|
+
if (sd && JSON.stringify(sd).length > 50_000) {
|
|
285
|
+
return errorResponse('structuredData payload is too large', 400);
|
|
286
|
+
}
|
|
287
|
+
patch.structuredData = sd;
|
|
288
|
+
}
|
|
289
|
+
if (Object.keys(patch).length === 0) {
|
|
290
|
+
return errorResponse('No recognized SEO fields in request body', 400);
|
|
291
|
+
}
|
|
292
|
+
if ('structuredDataType' in patch || 'schemaType' in patch) {
|
|
293
|
+
Object.assign(patch, syncSchemaTypeFields(patch));
|
|
294
|
+
}
|
|
295
|
+
// Route through the data layer so the edit gets per-collection access
|
|
296
|
+
// control, a Version snapshot, search re-indexing and the afterUpdate
|
|
297
|
+
// hook — never write document.data directly. (updateDocumentSeoFields
|
|
298
|
+
// preserves the undeclared SEO convention keys that the generic
|
|
299
|
+
// updateDocument field-access layer would strip.)
|
|
300
|
+
const ctx = buildActionContext(auth.session, db());
|
|
301
|
+
try {
|
|
302
|
+
await updateDocumentSeoFields(doc.collection, doc.id, patch, ctx);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
const msg = err instanceof Error ? err.message : '';
|
|
306
|
+
if (msg.includes('Access denied'))
|
|
307
|
+
return errorResponse('Insufficient permissions', 403);
|
|
308
|
+
if (msg.includes('not found'))
|
|
309
|
+
return errorResponse('Document not found', 404);
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await logEvent({
|
|
314
|
+
event: 'document_updated',
|
|
315
|
+
userId: auth.session.userId,
|
|
316
|
+
details: { action: 'seo_fields_update', documentId: doc.id },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
/* noop */
|
|
321
|
+
}
|
|
322
|
+
const { records } = await buildSeoContentRecords(null, [doc.id]);
|
|
323
|
+
const record = records.find((r) => r.entityId === doc.id);
|
|
324
|
+
return json({ data: record ?? { ok: true } });
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
return internalError(err, 'seo/content PUT');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
//# sourceMappingURL=content.js.map
|