@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/api/routes/seo/handlers/analysis.d.ts +4 -0
  3. package/dist/api/routes/seo/handlers/analysis.d.ts.map +1 -0
  4. package/dist/api/routes/seo/handlers/analysis.js +131 -0
  5. package/dist/api/routes/seo/handlers/analysis.js.map +1 -0
  6. package/dist/api/routes/seo/handlers/audits.d.ts +5 -0
  7. package/dist/api/routes/seo/handlers/audits.d.ts.map +1 -0
  8. package/dist/api/routes/seo/handlers/audits.js +121 -0
  9. package/dist/api/routes/seo/handlers/audits.js.map +1 -0
  10. package/dist/api/routes/seo/handlers/content.d.ts +4 -0
  11. package/dist/api/routes/seo/handlers/content.d.ts.map +1 -0
  12. package/dist/api/routes/seo/handlers/content.js +330 -0
  13. package/dist/api/routes/seo/handlers/content.js.map +1 -0
  14. package/dist/api/routes/seo/handlers/issue-helpers.d.ts +37 -0
  15. package/dist/api/routes/seo/handlers/issue-helpers.d.ts.map +1 -0
  16. package/dist/api/routes/seo/handlers/issue-helpers.js +72 -0
  17. package/dist/api/routes/seo/handlers/issue-helpers.js.map +1 -0
  18. package/dist/api/routes/seo/handlers/issues-briefs.d.ts +7 -0
  19. package/dist/api/routes/seo/handlers/issues-briefs.d.ts.map +1 -0
  20. package/dist/api/routes/seo/handlers/issues-briefs.js +341 -0
  21. package/dist/api/routes/seo/handlers/issues-briefs.js.map +1 -0
  22. package/dist/api/routes/seo/handlers/issues-fixes.d.ts +4 -0
  23. package/dist/api/routes/seo/handlers/issues-fixes.d.ts.map +1 -0
  24. package/dist/api/routes/seo/handlers/issues-fixes.js +233 -0
  25. package/dist/api/routes/seo/handlers/issues-fixes.js.map +1 -0
  26. package/dist/api/routes/seo/handlers/issues-list.d.ts +7 -0
  27. package/dist/api/routes/seo/handlers/issues-list.d.ts.map +1 -0
  28. package/dist/api/routes/seo/handlers/issues-list.js +191 -0
  29. package/dist/api/routes/seo/handlers/issues-list.js.map +1 -0
  30. package/dist/api/routes/seo/handlers/links.d.ts +6 -0
  31. package/dist/api/routes/seo/handlers/links.d.ts.map +1 -0
  32. package/dist/api/routes/seo/handlers/links.js +383 -0
  33. package/dist/api/routes/seo/handlers/links.js.map +1 -0
  34. package/dist/api/routes/seo/handlers/overview.d.ts +4 -0
  35. package/dist/api/routes/seo/handlers/overview.d.ts.map +1 -0
  36. package/dist/api/routes/seo/handlers/overview.js +348 -0
  37. package/dist/api/routes/seo/handlers/overview.js.map +1 -0
  38. package/dist/api/routes/seo/handlers/pages.d.ts +2 -0
  39. package/dist/api/routes/seo/handlers/pages.d.ts.map +1 -0
  40. package/dist/api/routes/seo/handlers/pages.js +38 -0
  41. package/dist/api/routes/seo/handlers/pages.js.map +1 -0
  42. package/dist/api/routes/seo/handlers/proposals.d.ts +4 -0
  43. package/dist/api/routes/seo/handlers/proposals.d.ts.map +1 -0
  44. package/dist/api/routes/seo/handlers/proposals.js +93 -0
  45. package/dist/api/routes/seo/handlers/proposals.js.map +1 -0
  46. package/dist/api/routes/seo/handlers/public-files.d.ts +6 -0
  47. package/dist/api/routes/seo/handlers/public-files.d.ts.map +1 -0
  48. package/dist/api/routes/seo/handlers/public-files.js +328 -0
  49. package/dist/api/routes/seo/handlers/public-files.js.map +1 -0
  50. package/dist/api/routes/seo/handlers/shared.d.ts +24 -0
  51. package/dist/api/routes/seo/handlers/shared.d.ts.map +1 -0
  52. package/dist/api/routes/seo/handlers/shared.js +67 -0
  53. package/dist/api/routes/seo/handlers/shared.js.map +1 -0
  54. package/dist/api/routes/seo.d.ts +5 -5
  55. package/dist/api/routes/seo.d.ts.map +1 -1
  56. package/dist/api/routes/seo.js +56 -2610
  57. package/dist/api/routes/seo.js.map +1 -1
  58. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @actuate-media/cms-core
2
2
 
3
+ ## 7.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3343dac: Split SEO route registration into per-area handlers. HTTP behavior is unchanged.
8
+
3
9
  ## 7.7.0
4
10
 
5
11
  ### Minor Changes
@@ -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