@actuate-media/cms-core 0.28.1 → 0.29.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/dist/__tests__/media/seo.test.d.ts +2 -0
- package/dist/__tests__/media/seo.test.d.ts.map +1 -0
- package/dist/__tests__/media/seo.test.js +186 -0
- package/dist/__tests__/media/seo.test.js.map +1 -0
- package/dist/__tests__/redirects/graph.test.d.ts +2 -0
- package/dist/__tests__/redirects/graph.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/graph.test.js +104 -0
- package/dist/__tests__/redirects/graph.test.js.map +1 -0
- package/dist/__tests__/redirects/suggest.test.d.ts +2 -0
- package/dist/__tests__/redirects/suggest.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/suggest.test.js +28 -0
- package/dist/__tests__/redirects/suggest.test.js.map +1 -0
- package/dist/__tests__/seo/audit-engine.test.d.ts +2 -0
- package/dist/__tests__/seo/audit-engine.test.d.ts.map +1 -0
- package/dist/__tests__/seo/audit-engine.test.js +152 -0
- package/dist/__tests__/seo/audit-engine.test.js.map +1 -0
- package/dist/__tests__/seo/config-store.test.js +24 -160
- package/dist/__tests__/seo/config-store.test.js.map +1 -1
- package/dist/__tests__/seo/score.test.d.ts +2 -0
- package/dist/__tests__/seo/score.test.d.ts.map +1 -0
- package/dist/__tests__/seo/score.test.js +152 -0
- package/dist/__tests__/seo/score.test.js.map +1 -0
- package/dist/__tests__/setup/index.test.d.ts +2 -0
- package/dist/__tests__/setup/index.test.d.ts.map +1 -0
- package/dist/__tests__/setup/index.test.js +82 -0
- package/dist/__tests__/setup/index.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +2095 -228
- package/dist/api/handlers.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -0
- package/dist/media/index.js.map +1 -1
- package/dist/media/seo.d.ts +88 -0
- package/dist/media/seo.d.ts.map +1 -0
- package/dist/media/seo.js +215 -0
- package/dist/media/seo.js.map +1 -0
- package/dist/redirects/graph.d.ts +68 -0
- package/dist/redirects/graph.d.ts.map +1 -0
- package/dist/redirects/graph.js +136 -0
- package/dist/redirects/graph.js.map +1 -0
- package/dist/redirects/index.d.ts +5 -0
- package/dist/redirects/index.d.ts.map +1 -0
- package/dist/redirects/index.js +3 -0
- package/dist/redirects/index.js.map +1 -0
- package/dist/redirects/suggest.d.ts +21 -0
- package/dist/redirects/suggest.d.ts.map +1 -0
- package/dist/redirects/suggest.js +55 -0
- package/dist/redirects/suggest.js.map +1 -0
- package/dist/seo/audit-engine.d.ts +119 -0
- package/dist/seo/audit-engine.d.ts.map +1 -0
- package/dist/seo/audit-engine.js +402 -0
- package/dist/seo/audit-engine.js.map +1 -0
- package/dist/seo/audit-runner.d.ts +41 -0
- package/dist/seo/audit-runner.d.ts.map +1 -0
- package/dist/seo/audit-runner.js +223 -0
- package/dist/seo/audit-runner.js.map +1 -0
- package/dist/seo/config-store.d.ts +28 -0
- package/dist/seo/config-store.d.ts.map +1 -1
- package/dist/seo/config-store.js +22 -0
- package/dist/seo/config-store.js.map +1 -1
- package/dist/seo/index.d.ts +7 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +4 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/score.d.ts +128 -0
- package/dist/seo/score.d.ts.map +1 -0
- package/dist/seo/score.js +213 -0
- package/dist/seo/score.js.map +1 -0
- package/dist/setup/index.d.ts +16 -1
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js +52 -2
- package/dist/setup/index.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/0008_media_center/migration.sql +47 -0
- package/prisma/migrations/0009_seo/migration.sql +143 -0
- package/prisma/schema.prisma +183 -8
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph.js","sourceRoot":"","sources":["../../src/redirects/graph.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { normalizeRedirectPath, isExternal, buildEdgeMap, resolveChain, detectChainsAndLoops, wouldCreateLoop, isRedirectStale, } from './graph.js';
|
|
2
|
+
export type { RedirectRecord, ChainResolution, DetectedChain } from './graph.js';
|
|
3
|
+
export { suggestRedirectTarget } from './suggest.js';
|
|
4
|
+
export type { SuggestCandidate, RedirectSuggestionResult } from './suggest.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/redirects/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,oBAAoB,EACpB,eAAe,EACf,eAAe,GAChB,MAAM,YAAY,CAAA;AAEnB,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAEhF,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAEpD,YAAY,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/redirects/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,oBAAoB,EACpB,eAAe,EACf,eAAe,GAChB,MAAM,YAAY,CAAA;AAInB,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 404 recovery suggestions — pure heuristic matching of a dead path to the
|
|
3
|
+
* best live URL using path-segment + title token similarity. Used as the
|
|
4
|
+
* always-available baseline; the API can layer AI ranking on top when a
|
|
5
|
+
* provider is configured.
|
|
6
|
+
*/
|
|
7
|
+
export interface SuggestCandidate {
|
|
8
|
+
url: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RedirectSuggestionResult {
|
|
12
|
+
toPath: string;
|
|
13
|
+
confidence: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Suggest the best redirect target for a dead `fromPath`. Returns null when no
|
|
18
|
+
* candidate clears the minimum confidence threshold (default 30).
|
|
19
|
+
*/
|
|
20
|
+
export declare function suggestRedirectTarget(fromPath: string, candidates: SuggestCandidate[], minConfidence?: number): RedirectSuggestionResult | null;
|
|
21
|
+
//# sourceMappingURL=suggest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"suggest.d.ts","sourceRoot":"","sources":["../../src/redirects/suggest.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACf;AAmBD;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,gBAAgB,EAAE,EAC9B,aAAa,SAAK,GACjB,wBAAwB,GAAG,IAAI,CA0BjC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 404 recovery suggestions — pure heuristic matching of a dead path to the
|
|
3
|
+
* best live URL using path-segment + title token similarity. Used as the
|
|
4
|
+
* always-available baseline; the API can layer AI ranking on top when a
|
|
5
|
+
* provider is configured.
|
|
6
|
+
*/
|
|
7
|
+
function tokens(input) {
|
|
8
|
+
return input
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/^https?:\/\/[^/]+/i, '')
|
|
11
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
12
|
+
.split(/\s+/)
|
|
13
|
+
.filter((t) => t.length > 1);
|
|
14
|
+
}
|
|
15
|
+
function jaccard(a, b) {
|
|
16
|
+
if (a.size === 0 && b.size === 0)
|
|
17
|
+
return 0;
|
|
18
|
+
let inter = 0;
|
|
19
|
+
for (const x of a)
|
|
20
|
+
if (b.has(x))
|
|
21
|
+
inter++;
|
|
22
|
+
const union = a.size + b.size - inter;
|
|
23
|
+
return union === 0 ? 0 : inter / union;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Suggest the best redirect target for a dead `fromPath`. Returns null when no
|
|
27
|
+
* candidate clears the minimum confidence threshold (default 30).
|
|
28
|
+
*/
|
|
29
|
+
export function suggestRedirectTarget(fromPath, candidates, minConfidence = 30) {
|
|
30
|
+
const fromTokens = new Set(tokens(fromPath));
|
|
31
|
+
if (fromTokens.size === 0 || candidates.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const c of candidates) {
|
|
35
|
+
const pathTokens = new Set(tokens(c.url));
|
|
36
|
+
const titleTokens = new Set(tokens(c.title ?? ''));
|
|
37
|
+
const pathSim = jaccard(fromTokens, pathTokens);
|
|
38
|
+
const titleSim = jaccard(fromTokens, titleTokens);
|
|
39
|
+
// Path match weighted higher than title match.
|
|
40
|
+
const score = Math.round((pathSim * 0.7 + titleSim * 0.3) * 100);
|
|
41
|
+
if (!best || score > best.confidence) {
|
|
42
|
+
best = {
|
|
43
|
+
toPath: c.url,
|
|
44
|
+
confidence: score,
|
|
45
|
+
reason: pathSim >= titleSim
|
|
46
|
+
? `URL path similarity to ${c.url}`
|
|
47
|
+
: `Title similarity to "${c.title ?? c.url}"`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!best || best.confidence < minConfidence)
|
|
52
|
+
return null;
|
|
53
|
+
return best;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=suggest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"suggest.js","sourceRoot":"","sources":["../../src/redirects/suggest.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,SAAS,MAAM,CAAC,KAAa;IAC3B,OAAO,KAAK;SACT,WAAW,EAAE;SACb,OAAO,CAAC,oBAAoB,EAAE,EAAE,CAAC;SACjC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,KAAK,CAAC,KAAK,CAAC;SACZ,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AAChC,CAAC;AAED,SAAS,OAAO,CAAC,CAAc,EAAE,CAAc;IAC7C,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,CAAC,CAAA;IAC1C,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,KAAK,MAAM,CAAC,IAAI,CAAC;QAAE,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAAE,KAAK,EAAE,CAAA;IACxC,MAAM,KAAK,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,GAAG,KAAK,CAAA;IACrC,OAAO,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK,CAAA;AACxC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,QAAgB,EAChB,UAA8B,EAC9B,aAAa,GAAG,EAAE;IAElB,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAA;IAC5C,IAAI,UAAU,CAAC,IAAI,KAAK,CAAC,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEjE,IAAI,IAAI,GAAoC,IAAI,CAAA;IAChD,KAAK,MAAM,CAAC,IAAI,UAAU,EAAE,CAAC;QAC3B,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;QACzC,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAA;QAClD,MAAM,OAAO,GAAG,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,CAAA;QAC/C,MAAM,QAAQ,GAAG,OAAO,CAAC,UAAU,EAAE,WAAW,CAAC,CAAA;QACjD,+CAA+C;QAC/C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,GAAG,GAAG,GAAG,QAAQ,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,CAAA;QAChE,IAAI,CAAC,IAAI,IAAI,KAAK,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;YACrC,IAAI,GAAG;gBACL,MAAM,EAAE,CAAC,CAAC,GAAG;gBACb,UAAU,EAAE,KAAK;gBACjB,MAAM,EACJ,OAAO,IAAI,QAAQ;oBACjB,CAAC,CAAC,0BAA0B,CAAC,CAAC,GAAG,EAAE;oBACnC,CAAC,CAAC,wBAAwB,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,GAAG,GAAG;aAClD,CAAA;QACH,CAAC;IACH,CAAC;IAED,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,GAAG,aAAa;QAAE,OAAO,IAAI,CAAA;IACzD,OAAO,IAAI,CAAA;AACb,CAAC"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO audit engine.
|
|
3
|
+
*
|
|
4
|
+
* Pure analysis: given a set of content entities (pages/posts) plus optional
|
|
5
|
+
* cross-cutting signals (broken links, redirect chains/loops, sitemap/robots
|
|
6
|
+
* health), it produces:
|
|
7
|
+
* - per-page SEO scores (via `calculatePageSeoScore`),
|
|
8
|
+
* - a flat list of detected issues with stable `issueKey`s,
|
|
9
|
+
* - an explainable site score (via `calculateSiteSeoScore`),
|
|
10
|
+
* - issue counts by severity, and
|
|
11
|
+
* - priority-ranked quick wins.
|
|
12
|
+
*
|
|
13
|
+
* The handler layer fetches the data, calls `runSeoAuditAnalysis`, and
|
|
14
|
+
* snapshots the result into `SeoAuditRun` / `SeoIssue` rows. Keeping detection
|
|
15
|
+
* pure means it is fully unit-testable with no DB.
|
|
16
|
+
*/
|
|
17
|
+
import { gradeForSeoScore, type SeoGrade, type SiteSeoScoreResult } from './score.js';
|
|
18
|
+
export type SeoSeverity = 'critical' | 'warning' | 'info';
|
|
19
|
+
export type SeoIssueCategory = 'content' | 'technical' | 'redirects' | 'media' | 'performance' | 'schema' | 'social' | 'indexing';
|
|
20
|
+
export interface AuditEntityInput {
|
|
21
|
+
entityType: 'page' | 'post';
|
|
22
|
+
entityId: string;
|
|
23
|
+
title: string;
|
|
24
|
+
url: string;
|
|
25
|
+
content?: string;
|
|
26
|
+
metaTitle?: string | null;
|
|
27
|
+
metaDescription?: string | null;
|
|
28
|
+
focusKeyword?: string | null;
|
|
29
|
+
canonicalUrl?: string | null;
|
|
30
|
+
ogTitle?: string | null;
|
|
31
|
+
ogDescription?: string | null;
|
|
32
|
+
ogImage?: string | null;
|
|
33
|
+
structuredDataType?: string | null;
|
|
34
|
+
noindex?: boolean | null;
|
|
35
|
+
shouldBeIndexable?: boolean;
|
|
36
|
+
wordCount?: number | null;
|
|
37
|
+
}
|
|
38
|
+
export interface AuditExtraSignals {
|
|
39
|
+
/** Broken internal links discovered by the link checker. */
|
|
40
|
+
brokenLinks?: {
|
|
41
|
+
url: string;
|
|
42
|
+
fromEntityId: string;
|
|
43
|
+
fromTitle: string;
|
|
44
|
+
fromUrl: string;
|
|
45
|
+
}[];
|
|
46
|
+
/** Pages flagged as slow by an external performance source. */
|
|
47
|
+
slowPages?: {
|
|
48
|
+
entityId: string;
|
|
49
|
+
title: string;
|
|
50
|
+
url: string;
|
|
51
|
+
note?: string;
|
|
52
|
+
}[];
|
|
53
|
+
redirectChains?: number;
|
|
54
|
+
redirectLoops?: number;
|
|
55
|
+
sitemapEnabled?: boolean;
|
|
56
|
+
robotsEnabled?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface DetectedIssue {
|
|
59
|
+
issueKey: string;
|
|
60
|
+
entityType: string | null;
|
|
61
|
+
entityId: string | null;
|
|
62
|
+
entityTitle: string | null;
|
|
63
|
+
url: string | null;
|
|
64
|
+
severity: SeoSeverity;
|
|
65
|
+
category: SeoIssueCategory;
|
|
66
|
+
type: string;
|
|
67
|
+
title: string;
|
|
68
|
+
description: string;
|
|
69
|
+
impact: string;
|
|
70
|
+
recommendation: string;
|
|
71
|
+
fixable: boolean;
|
|
72
|
+
fixActionType: string | null;
|
|
73
|
+
relatedEntityIds: string[];
|
|
74
|
+
}
|
|
75
|
+
export interface AuditPageRecord {
|
|
76
|
+
entityType: 'page' | 'post';
|
|
77
|
+
entityId: string;
|
|
78
|
+
title: string;
|
|
79
|
+
url: string;
|
|
80
|
+
score: number;
|
|
81
|
+
grade: SeoGrade;
|
|
82
|
+
metaTitleSet: boolean;
|
|
83
|
+
metaDescriptionSet: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface QuickWin {
|
|
86
|
+
id: string;
|
|
87
|
+
title: string;
|
|
88
|
+
explanation: string;
|
|
89
|
+
impact: 'High' | 'Medium' | 'Low';
|
|
90
|
+
issueType: string;
|
|
91
|
+
fixActionType: string | null;
|
|
92
|
+
count: number;
|
|
93
|
+
}
|
|
94
|
+
export interface SeoAuditResult {
|
|
95
|
+
pages: AuditPageRecord[];
|
|
96
|
+
issues: DetectedIssue[];
|
|
97
|
+
siteScore: SiteSeoScoreResult;
|
|
98
|
+
counts: {
|
|
99
|
+
total: number;
|
|
100
|
+
critical: number;
|
|
101
|
+
warning: number;
|
|
102
|
+
info: number;
|
|
103
|
+
};
|
|
104
|
+
quickWins: QuickWin[];
|
|
105
|
+
}
|
|
106
|
+
/** Stable per-issue key so status carries across audit runs. */
|
|
107
|
+
export declare function issueKeyFor(type: string, identifier: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Run the full SEO audit analysis over a set of entities + signals.
|
|
110
|
+
*/
|
|
111
|
+
export declare function runSeoAuditAnalysis(entities: AuditEntityInput[], signals?: AuditExtraSignals): SeoAuditResult;
|
|
112
|
+
/**
|
|
113
|
+
* Derive priority-ranked quick wins by grouping issues by type and ranking by
|
|
114
|
+
* (impact, severity, count). Never hardcoded — purely a function of detected
|
|
115
|
+
* issues, so the Overview "Quick Wins" card always reflects reality.
|
|
116
|
+
*/
|
|
117
|
+
export declare function buildQuickWins(issues: DetectedIssue[]): QuickWin[];
|
|
118
|
+
export { gradeForSeoScore };
|
|
119
|
+
//# sourceMappingURL=audit-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-engine.d.ts","sourceRoot":"","sources":["../../src/seo/audit-engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO audit engine.
|
|
3
|
+
*
|
|
4
|
+
* Pure analysis: given a set of content entities (pages/posts) plus optional
|
|
5
|
+
* cross-cutting signals (broken links, redirect chains/loops, sitemap/robots
|
|
6
|
+
* health), it produces:
|
|
7
|
+
* - per-page SEO scores (via `calculatePageSeoScore`),
|
|
8
|
+
* - a flat list of detected issues with stable `issueKey`s,
|
|
9
|
+
* - an explainable site score (via `calculateSiteSeoScore`),
|
|
10
|
+
* - issue counts by severity, and
|
|
11
|
+
* - priority-ranked quick wins.
|
|
12
|
+
*
|
|
13
|
+
* The handler layer fetches the data, calls `runSeoAuditAnalysis`, and
|
|
14
|
+
* snapshots the result into `SeoAuditRun` / `SeoIssue` rows. Keeping detection
|
|
15
|
+
* pure means it is fully unit-testable with no DB.
|
|
16
|
+
*/
|
|
17
|
+
import { calculatePageSeoScore, calculateSiteSeoScore, gradeForSeoScore, countH1, } from './score.js';
|
|
18
|
+
import { stripHtmlTags } from './analysis.js';
|
|
19
|
+
function present(v) {
|
|
20
|
+
return typeof v === 'string' && v.trim().length > 0;
|
|
21
|
+
}
|
|
22
|
+
function normalizeTitle(t) {
|
|
23
|
+
return t.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
24
|
+
}
|
|
25
|
+
function wordsOf(content, fallback) {
|
|
26
|
+
if (typeof fallback === 'number')
|
|
27
|
+
return fallback;
|
|
28
|
+
if (!content)
|
|
29
|
+
return 0;
|
|
30
|
+
const text = stripHtmlTags(content);
|
|
31
|
+
return text ? text.split(/\s+/).filter((w) => w.length > 0).length : 0;
|
|
32
|
+
}
|
|
33
|
+
function countMissingAlt(content) {
|
|
34
|
+
if (!content)
|
|
35
|
+
return 0;
|
|
36
|
+
const imgs = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
37
|
+
return imgs.filter((img) => !/\balt\s*=\s*["'][^"']+["']/i.test(img)).length;
|
|
38
|
+
}
|
|
39
|
+
/** Stable per-issue key so status carries across audit runs. */
|
|
40
|
+
export function issueKeyFor(type, identifier) {
|
|
41
|
+
return `${type}:${identifier}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run the full SEO audit analysis over a set of entities + signals.
|
|
45
|
+
*/
|
|
46
|
+
export function runSeoAuditAnalysis(entities, signals = {}) {
|
|
47
|
+
const issues = [];
|
|
48
|
+
const pages = [];
|
|
49
|
+
// -- Per-entity content checks -------------------------------------------
|
|
50
|
+
for (const e of entities) {
|
|
51
|
+
const wordCount = wordsOf(e.content, e.wordCount);
|
|
52
|
+
const scoreResult = calculatePageSeoScore({
|
|
53
|
+
title: e.title,
|
|
54
|
+
url: e.url,
|
|
55
|
+
content: e.content,
|
|
56
|
+
metaTitle: e.metaTitle,
|
|
57
|
+
metaDescription: e.metaDescription,
|
|
58
|
+
focusKeyword: e.focusKeyword,
|
|
59
|
+
canonicalUrl: e.canonicalUrl,
|
|
60
|
+
ogTitle: e.ogTitle,
|
|
61
|
+
ogDescription: e.ogDescription,
|
|
62
|
+
ogImage: e.ogImage,
|
|
63
|
+
structuredDataType: e.structuredDataType,
|
|
64
|
+
noindex: e.noindex,
|
|
65
|
+
shouldBeIndexable: e.shouldBeIndexable,
|
|
66
|
+
wordCount,
|
|
67
|
+
});
|
|
68
|
+
pages.push({
|
|
69
|
+
entityType: e.entityType,
|
|
70
|
+
entityId: e.entityId,
|
|
71
|
+
title: e.title,
|
|
72
|
+
url: e.url,
|
|
73
|
+
score: scoreResult.score,
|
|
74
|
+
grade: scoreResult.grade,
|
|
75
|
+
metaTitleSet: present(e.metaTitle),
|
|
76
|
+
metaDescriptionSet: present(e.metaDescription),
|
|
77
|
+
});
|
|
78
|
+
const ctx = {
|
|
79
|
+
entityType: e.entityType,
|
|
80
|
+
entityId: e.entityId,
|
|
81
|
+
entityTitle: e.title,
|
|
82
|
+
url: e.url,
|
|
83
|
+
};
|
|
84
|
+
if (!present(e.metaTitle)) {
|
|
85
|
+
issues.push({
|
|
86
|
+
...ctx,
|
|
87
|
+
issueKey: issueKeyFor('missing-meta-title', e.entityId),
|
|
88
|
+
severity: 'warning',
|
|
89
|
+
category: 'content',
|
|
90
|
+
type: 'missing-meta-title',
|
|
91
|
+
title: 'Missing meta title',
|
|
92
|
+
description: 'This page has no meta title. Search engines fall back to the H1 or filename.',
|
|
93
|
+
impact: 'High',
|
|
94
|
+
recommendation: 'Add a 30–60 character meta title containing the target keyword.',
|
|
95
|
+
fixable: true,
|
|
96
|
+
fixActionType: 'edit-metadata',
|
|
97
|
+
relatedEntityIds: [],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (!present(e.metaDescription)) {
|
|
101
|
+
issues.push({
|
|
102
|
+
...ctx,
|
|
103
|
+
issueKey: issueKeyFor('missing-meta-description', e.entityId),
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
category: 'content',
|
|
106
|
+
type: 'missing-meta-description',
|
|
107
|
+
title: 'Missing meta description',
|
|
108
|
+
description: 'Add a 150–160 character description summarising the page for search engines.',
|
|
109
|
+
impact: 'High',
|
|
110
|
+
recommendation: 'Write a unique meta description that includes the focus keyword.',
|
|
111
|
+
fixable: true,
|
|
112
|
+
fixActionType: 'edit-metadata',
|
|
113
|
+
relatedEntityIds: [],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (!present(e.structuredDataType)) {
|
|
117
|
+
issues.push({
|
|
118
|
+
...ctx,
|
|
119
|
+
issueKey: issueKeyFor('missing-structured-data', e.entityId),
|
|
120
|
+
severity: 'warning',
|
|
121
|
+
category: 'schema',
|
|
122
|
+
type: 'missing-structured-data',
|
|
123
|
+
title: 'No structured data found',
|
|
124
|
+
description: 'Add Schema.org markup to improve rich-result eligibility.',
|
|
125
|
+
impact: 'Medium',
|
|
126
|
+
recommendation: 'Choose an appropriate schema type (Article, Product, FAQPage, …).',
|
|
127
|
+
fixable: true,
|
|
128
|
+
fixActionType: 'edit-schema',
|
|
129
|
+
relatedEntityIds: [],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (!present(e.canonicalUrl)) {
|
|
133
|
+
issues.push({
|
|
134
|
+
...ctx,
|
|
135
|
+
issueKey: issueKeyFor('missing-canonical', e.entityId),
|
|
136
|
+
severity: 'info',
|
|
137
|
+
category: 'technical',
|
|
138
|
+
type: 'missing-canonical',
|
|
139
|
+
title: 'Missing canonical tag',
|
|
140
|
+
description: 'Add a canonical tag to prevent duplicate-content issues across URLs.',
|
|
141
|
+
impact: 'Low',
|
|
142
|
+
recommendation: 'Set the canonical URL to this page’s preferred public URL.',
|
|
143
|
+
fixable: true,
|
|
144
|
+
fixActionType: 'edit-canonical',
|
|
145
|
+
relatedEntityIds: [],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (!present(e.ogImage)) {
|
|
149
|
+
issues.push({
|
|
150
|
+
...ctx,
|
|
151
|
+
issueKey: issueKeyFor('missing-og-image', e.entityId),
|
|
152
|
+
severity: 'info',
|
|
153
|
+
category: 'social',
|
|
154
|
+
type: 'missing-og-image',
|
|
155
|
+
title: 'No OG image set',
|
|
156
|
+
description: 'Pages without an OG image show a generic placeholder when shared.',
|
|
157
|
+
impact: 'Low',
|
|
158
|
+
recommendation: 'Pick or generate an Open Graph image in the social metadata section.',
|
|
159
|
+
fixable: true,
|
|
160
|
+
fixActionType: 'edit-og-image',
|
|
161
|
+
relatedEntityIds: [],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if ((e.content ?? '').length > 0 && countH1(e.content ?? '') === 0) {
|
|
165
|
+
issues.push({
|
|
166
|
+
...ctx,
|
|
167
|
+
issueKey: issueKeyFor('missing-h1', e.entityId),
|
|
168
|
+
severity: 'info',
|
|
169
|
+
category: 'content',
|
|
170
|
+
type: 'missing-h1',
|
|
171
|
+
title: 'Missing H1 heading',
|
|
172
|
+
description: 'Every page should have exactly one H1 that includes the target keyword.',
|
|
173
|
+
impact: 'Low',
|
|
174
|
+
recommendation: 'Add a single descriptive H1 heading to the content.',
|
|
175
|
+
fixable: false,
|
|
176
|
+
fixActionType: 'edit-content',
|
|
177
|
+
relatedEntityIds: [],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (wordCount > 0 && wordCount < 300) {
|
|
181
|
+
issues.push({
|
|
182
|
+
...ctx,
|
|
183
|
+
issueKey: issueKeyFor('thin-content', e.entityId),
|
|
184
|
+
severity: 'info',
|
|
185
|
+
category: 'content',
|
|
186
|
+
type: 'thin-content',
|
|
187
|
+
title: 'Content below 300 words',
|
|
188
|
+
description: 'Thin pages tend to rank poorly. Consider expanding the copy.',
|
|
189
|
+
impact: 'Low',
|
|
190
|
+
recommendation: 'Add more useful content or an FAQ section.',
|
|
191
|
+
fixable: false,
|
|
192
|
+
fixActionType: 'edit-content',
|
|
193
|
+
relatedEntityIds: [],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const missingAlt = countMissingAlt(e.content);
|
|
197
|
+
if (missingAlt > 0) {
|
|
198
|
+
issues.push({
|
|
199
|
+
...ctx,
|
|
200
|
+
issueKey: issueKeyFor('missing-alt-text', e.entityId),
|
|
201
|
+
severity: 'warning',
|
|
202
|
+
category: 'media',
|
|
203
|
+
type: 'missing-alt-text',
|
|
204
|
+
title: 'Images missing alt text',
|
|
205
|
+
description: `${missingAlt} image(s) are missing alt text, hurting accessibility and image search.`,
|
|
206
|
+
impact: 'Medium',
|
|
207
|
+
recommendation: 'Add descriptive alt text to every content image.',
|
|
208
|
+
fixable: true,
|
|
209
|
+
fixActionType: 'edit-content',
|
|
210
|
+
relatedEntityIds: [],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// -- Duplicate meta titles (cross-entity) --------------------------------
|
|
215
|
+
const titleGroups = new Map();
|
|
216
|
+
for (const e of entities) {
|
|
217
|
+
if (!present(e.metaTitle))
|
|
218
|
+
continue;
|
|
219
|
+
const key = normalizeTitle(e.metaTitle);
|
|
220
|
+
const arr = titleGroups.get(key) ?? [];
|
|
221
|
+
arr.push(e);
|
|
222
|
+
titleGroups.set(key, arr);
|
|
223
|
+
}
|
|
224
|
+
for (const group of titleGroups.values()) {
|
|
225
|
+
if (group.length < 2)
|
|
226
|
+
continue;
|
|
227
|
+
const ids = group.map((g) => g.entityId).sort();
|
|
228
|
+
const urls = group.map((g) => g.url).join(', ');
|
|
229
|
+
issues.push({
|
|
230
|
+
entityType: group[0].entityType,
|
|
231
|
+
entityId: group[0].entityId,
|
|
232
|
+
entityTitle: group[0].title,
|
|
233
|
+
url: urls,
|
|
234
|
+
issueKey: issueKeyFor('duplicate-meta-title', ids.join(',')),
|
|
235
|
+
severity: 'critical',
|
|
236
|
+
category: 'content',
|
|
237
|
+
type: 'duplicate-meta-title',
|
|
238
|
+
title: 'Duplicate meta title detected',
|
|
239
|
+
description: `Each page must have a unique meta title. ${urls} share the same title.`,
|
|
240
|
+
impact: 'High',
|
|
241
|
+
recommendation: 'Differentiate the meta titles so each page targets distinct intent.',
|
|
242
|
+
fixable: true,
|
|
243
|
+
fixActionType: 'edit-metadata',
|
|
244
|
+
relatedEntityIds: ids,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// -- Broken internal links -----------------------------------------------
|
|
248
|
+
for (const link of signals.brokenLinks ?? []) {
|
|
249
|
+
issues.push({
|
|
250
|
+
entityType: 'page',
|
|
251
|
+
entityId: link.fromEntityId,
|
|
252
|
+
entityTitle: link.fromTitle,
|
|
253
|
+
url: link.fromUrl,
|
|
254
|
+
issueKey: issueKeyFor('broken-internal-link', `${link.fromEntityId}:${link.url}`),
|
|
255
|
+
severity: 'critical',
|
|
256
|
+
category: 'technical',
|
|
257
|
+
type: 'broken-internal-link',
|
|
258
|
+
title: 'Internal link returns 404',
|
|
259
|
+
description: `The link to ${link.url} is broken. Remove or update it to a valid URL.`,
|
|
260
|
+
impact: 'High',
|
|
261
|
+
recommendation: 'Fix the link target or create a redirect for the old URL.',
|
|
262
|
+
fixable: true,
|
|
263
|
+
fixActionType: 'fix-link',
|
|
264
|
+
relatedEntityIds: [],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// -- Slow pages (external perf source) -----------------------------------
|
|
268
|
+
for (const slow of signals.slowPages ?? []) {
|
|
269
|
+
issues.push({
|
|
270
|
+
entityType: 'page',
|
|
271
|
+
entityId: slow.entityId,
|
|
272
|
+
entityTitle: slow.title,
|
|
273
|
+
url: slow.url,
|
|
274
|
+
issueKey: issueKeyFor('slow-page', slow.entityId),
|
|
275
|
+
severity: 'warning',
|
|
276
|
+
category: 'performance',
|
|
277
|
+
type: 'slow-page',
|
|
278
|
+
title: 'Page load time exceeds 3 seconds',
|
|
279
|
+
description: slow.note ?? 'Optimise images, enable lazy loading, and consider a CDN.',
|
|
280
|
+
impact: 'Medium',
|
|
281
|
+
recommendation: 'Reduce image sizes and JavaScript; see the technical recommendations.',
|
|
282
|
+
fixable: false,
|
|
283
|
+
fixActionType: 'technical-recommendation',
|
|
284
|
+
relatedEntityIds: [],
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// -- Site score (explainable category sub-scores) ------------------------
|
|
288
|
+
const total = entities.length;
|
|
289
|
+
const ratio = (count) => (total === 0 ? 100 : Math.round((count / total) * 100));
|
|
290
|
+
const withMetaTitle = entities.filter((e) => present(e.metaTitle)).length;
|
|
291
|
+
const withMetaDesc = entities.filter((e) => present(e.metaDescription)).length;
|
|
292
|
+
const withH1 = entities.filter((e) => countH1(e.content ?? '') === 1).length;
|
|
293
|
+
const contentScore = Math.round((ratio(withMetaTitle) + ratio(withMetaDesc) + ratio(withH1)) / 3);
|
|
294
|
+
const withCanonical = entities.filter((e) => present(e.canonicalUrl)).length;
|
|
295
|
+
const correctlyIndexed = entities.filter((e) => !(e.shouldBeIndexable !== false && e.noindex === true)).length;
|
|
296
|
+
const crawlBonus = (signals.sitemapEnabled !== false ? 50 : 0) + (signals.robotsEnabled !== false ? 50 : 0);
|
|
297
|
+
const technicalScore = Math.round((ratio(withCanonical) + ratio(correctlyIndexed) + crawlBonus) / 3);
|
|
298
|
+
const brokenCount = (signals.brokenLinks ?? []).length;
|
|
299
|
+
const chainPenalty = ((signals.redirectChains ?? 0) + (signals.redirectLoops ?? 0)) * 8;
|
|
300
|
+
const linkRedirectScore = Math.max(0, 100 - brokenCount * 15 - chainPenalty);
|
|
301
|
+
const withSchema = entities.filter((e) => present(e.structuredDataType)).length;
|
|
302
|
+
const withOgImage = entities.filter((e) => present(e.ogImage)).length;
|
|
303
|
+
const structuredSocialScore = Math.round((ratio(withSchema) + ratio(withOgImage)) / 2);
|
|
304
|
+
const performanceScore = (signals.slowPages ?? []).length > 0
|
|
305
|
+
? Math.max(0, 70 - (signals.slowPages ?? []).length * 10)
|
|
306
|
+
: 70;
|
|
307
|
+
const siteScore = calculateSiteSeoScore({
|
|
308
|
+
content: contentScore,
|
|
309
|
+
technical: technicalScore,
|
|
310
|
+
linkRedirect: linkRedirectScore,
|
|
311
|
+
structuredSocial: structuredSocialScore,
|
|
312
|
+
performance: performanceScore,
|
|
313
|
+
});
|
|
314
|
+
// -- Counts + quick wins -------------------------------------------------
|
|
315
|
+
const counts = {
|
|
316
|
+
total: issues.length,
|
|
317
|
+
critical: issues.filter((i) => i.severity === 'critical').length,
|
|
318
|
+
warning: issues.filter((i) => i.severity === 'warning').length,
|
|
319
|
+
info: issues.filter((i) => i.severity === 'info').length,
|
|
320
|
+
};
|
|
321
|
+
const quickWins = buildQuickWins(issues);
|
|
322
|
+
return { pages, issues, siteScore, counts, quickWins };
|
|
323
|
+
}
|
|
324
|
+
const IMPACT_RANK = { High: 3, Medium: 2, Low: 1 };
|
|
325
|
+
const SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
326
|
+
/**
|
|
327
|
+
* Derive priority-ranked quick wins by grouping issues by type and ranking by
|
|
328
|
+
* (impact, severity, count). Never hardcoded — purely a function of detected
|
|
329
|
+
* issues, so the Overview "Quick Wins" card always reflects reality.
|
|
330
|
+
*/
|
|
331
|
+
export function buildQuickWins(issues) {
|
|
332
|
+
const byType = new Map();
|
|
333
|
+
for (const i of issues) {
|
|
334
|
+
if (!i.fixable)
|
|
335
|
+
continue;
|
|
336
|
+
const arr = byType.get(i.type) ?? [];
|
|
337
|
+
arr.push(i);
|
|
338
|
+
byType.set(i.type, arr);
|
|
339
|
+
}
|
|
340
|
+
const wins = [];
|
|
341
|
+
for (const [type, group] of byType) {
|
|
342
|
+
const first = group[0];
|
|
343
|
+
const count = group.length;
|
|
344
|
+
wins.push({
|
|
345
|
+
id: `qw-${type}`,
|
|
346
|
+
title: quickWinTitle(type, count),
|
|
347
|
+
explanation: quickWinExplanation(type, count),
|
|
348
|
+
impact: first.impact === 'High' ? 'High' : first.impact === 'Medium' ? 'Medium' : 'Low',
|
|
349
|
+
issueType: type,
|
|
350
|
+
fixActionType: first.fixActionType,
|
|
351
|
+
count,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
wins.sort((a, b) => {
|
|
355
|
+
const ai = IMPACT_RANK[a.impact];
|
|
356
|
+
const bi = IMPACT_RANK[b.impact];
|
|
357
|
+
if (ai !== bi)
|
|
358
|
+
return bi - ai;
|
|
359
|
+
return b.count - a.count;
|
|
360
|
+
});
|
|
361
|
+
return wins.slice(0, 5);
|
|
362
|
+
}
|
|
363
|
+
function quickWinTitle(type, count) {
|
|
364
|
+
switch (type) {
|
|
365
|
+
case 'missing-meta-description':
|
|
366
|
+
return `Add meta descriptions to ${count} page${count === 1 ? '' : 's'}`;
|
|
367
|
+
case 'missing-meta-title':
|
|
368
|
+
return `Add meta titles to ${count} page${count === 1 ? '' : 's'}`;
|
|
369
|
+
case 'duplicate-meta-title':
|
|
370
|
+
return 'Fix duplicate meta title';
|
|
371
|
+
case 'missing-structured-data':
|
|
372
|
+
return `Add structured data to ${count} page${count === 1 ? '' : 's'}`;
|
|
373
|
+
case 'missing-og-image':
|
|
374
|
+
return `Add Open Graph images to ${count} page${count === 1 ? '' : 's'}`;
|
|
375
|
+
case 'missing-canonical':
|
|
376
|
+
return `Add canonical tags to ${count} page${count === 1 ? '' : 's'}`;
|
|
377
|
+
case 'broken-internal-link':
|
|
378
|
+
return `Fix ${count} broken internal link${count === 1 ? '' : 's'}`;
|
|
379
|
+
case 'missing-alt-text':
|
|
380
|
+
return `Add alt text on ${count} page${count === 1 ? '' : 's'}`;
|
|
381
|
+
default:
|
|
382
|
+
return `Resolve ${count} ${type.replace(/-/g, ' ')} issue${count === 1 ? '' : 's'}`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function quickWinExplanation(type, count) {
|
|
386
|
+
switch (type) {
|
|
387
|
+
case 'missing-meta-description':
|
|
388
|
+
return 'Missing meta descriptions reduce click-through rates by up to 30%. This is the single highest-impact fix available.';
|
|
389
|
+
case 'duplicate-meta-title':
|
|
390
|
+
return 'Pages sharing the same meta title may be penalised by Google for duplication. Differentiate them.';
|
|
391
|
+
case 'missing-structured-data':
|
|
392
|
+
return 'Schema markup enables rich results (star ratings, FAQs, breadcrumbs) and improves visibility.';
|
|
393
|
+
case 'missing-og-image':
|
|
394
|
+
return 'Open Graph images significantly improve click-through from social shares.';
|
|
395
|
+
case 'broken-internal-link':
|
|
396
|
+
return 'Broken internal links waste crawl budget and frustrate users. Fix or redirect them.';
|
|
397
|
+
default:
|
|
398
|
+
return `Resolving ${count} ${type.replace(/-/g, ' ')} issue${count === 1 ? '' : 's'} will improve your SEO health.`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export { gradeForSeoScore };
|
|
402
|
+
//# sourceMappingURL=audit-engine.js.map
|