@actuate-media/cms-core 0.25.1 → 0.26.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.
Files changed (33) hide show
  1. package/dist/__tests__/api/stats-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/stats-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/stats-routes.test.js +251 -0
  4. package/dist/__tests__/api/stats-routes.test.js.map +1 -0
  5. package/dist/__tests__/content-health/scanner.test.d.ts +2 -0
  6. package/dist/__tests__/content-health/scanner.test.d.ts.map +1 -0
  7. package/dist/__tests__/content-health/scanner.test.js +151 -0
  8. package/dist/__tests__/content-health/scanner.test.js.map +1 -0
  9. package/dist/__tests__/diagnostics/api-metrics.test.d.ts +2 -0
  10. package/dist/__tests__/diagnostics/api-metrics.test.d.ts.map +1 -0
  11. package/dist/__tests__/diagnostics/api-metrics.test.js +153 -0
  12. package/dist/__tests__/diagnostics/api-metrics.test.js.map +1 -0
  13. package/dist/api/handler-factory.d.ts.map +1 -1
  14. package/dist/api/handler-factory.js +31 -1
  15. package/dist/api/handler-factory.js.map +1 -1
  16. package/dist/api/handlers.d.ts.map +1 -1
  17. package/dist/api/handlers.js +263 -0
  18. package/dist/api/handlers.js.map +1 -1
  19. package/dist/content-health/cron.d.ts +67 -0
  20. package/dist/content-health/cron.d.ts.map +1 -0
  21. package/dist/content-health/cron.js +167 -0
  22. package/dist/content-health/cron.js.map +1 -0
  23. package/dist/content-health/scanner.d.ts +118 -0
  24. package/dist/content-health/scanner.d.ts.map +1 -0
  25. package/dist/content-health/scanner.js +263 -0
  26. package/dist/content-health/scanner.js.map +1 -0
  27. package/dist/diagnostics/api-metrics.d.ts +135 -0
  28. package/dist/diagnostics/api-metrics.d.ts.map +1 -0
  29. package/dist/diagnostics/api-metrics.js +374 -0
  30. package/dist/diagnostics/api-metrics.js.map +1 -0
  31. package/package.json +1 -1
  32. package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
  33. package/prisma/schema.prisma +180 -73
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Content health cron — runs `scanDocument` across published
3
+ * documents in chunks, upserts findings into
4
+ * `actuate_content_issues`, and deletes rows whose issue resolved
5
+ * since the previous run.
6
+ *
7
+ * ## Why chunked iteration
8
+ *
9
+ * A single full scan over a 10k-doc corpus does ~10k HTML parses
10
+ * plus up to ~50k internal-link probes (HEAD requests with 5s
11
+ * timeouts). On Vercel that quickly approaches the function
12
+ * wall-time cap. We batch in `BATCH_SIZE` pages and yield to the
13
+ * event loop between batches so a 10-minute cron can make
14
+ * incremental progress even when the corpus is large.
15
+ *
16
+ * ## Why upsert + delete-stale
17
+ *
18
+ * The scanner is the source of truth. After a run, any (docId, type)
19
+ * pair NOT emitted by the scan no longer applies — the editor
20
+ * fixed the issue, the document was unpublished, etc. We track
21
+ * pairs we've seen and DELETE the unseen ones at the end.
22
+ *
23
+ * ## Why we never delete on partial failure
24
+ *
25
+ * If the scan throws mid-run, we abandon the delete step. Better
26
+ * to over-report (a previously-recorded issue may already be
27
+ * resolved) than to silently drop a real issue because the scan
28
+ * crashed before reaching that document.
29
+ */
30
+ import { type ContentIssueKind } from './scanner.js';
31
+ type PrismaDB = any;
32
+ export interface ContentHealthScanOptions {
33
+ /** Cap the number of documents processed per run. Default 500. */
34
+ maxDocuments?: number;
35
+ /** Documents per DB query / processing chunk. Default 50. */
36
+ batchSize?: number;
37
+ /** Origin to resolve internal links against. Required for link checking. */
38
+ siteOrigin?: string | null;
39
+ /**
40
+ * Per-link probe timeout in milliseconds. Default 5000. Set
41
+ * shorter on CI to keep test runtimes bounded.
42
+ */
43
+ linkProbeTimeoutMs?: number;
44
+ /**
45
+ * Test seam — inject a synchronous link checker so unit tests
46
+ * don't fan out real HEAD requests. Defaults to
47
+ * `checkInternalLinkReachable`.
48
+ */
49
+ checkLink?: ((url: string) => Promise<boolean>) | null;
50
+ }
51
+ export interface ContentHealthScanResult {
52
+ /** Number of documents scanned. */
53
+ scanned: number;
54
+ /** New issue rows inserted (didn't exist before). */
55
+ inserted: number;
56
+ /** Existing issue rows updated (count or details changed). */
57
+ updated: number;
58
+ /** Issue rows deleted (no longer apply). */
59
+ resolved: number;
60
+ /** Map of `type` → total count across all docs at end of scan. */
61
+ totals: Record<ContentIssueKind, number>;
62
+ /** True if the scan stopped early because maxDocuments was reached. */
63
+ truncated: boolean;
64
+ }
65
+ export declare function processContentHealthScan(db: PrismaDB, options?: ContentHealthScanOptions): Promise<ContentHealthScanResult>;
66
+ export {};
67
+ //# sourceMappingURL=cron.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron.d.ts","sourceRoot":"","sources":["../../src/content-health/cron.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAA;AAErB,KAAK,QAAQ,GAAG,GAAG,CAAA;AAEnB,MAAM,WAAW,wBAAwB;IACvC,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,IAAI,CAAA;CACvD;AAED,MAAM,WAAW,uBAAuB;IACtC,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAA;IACf,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAA;IAChB,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAA;IACxC,uEAAuE;IACvE,SAAS,EAAE,OAAO,CAAA;CACnB;AAKD,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,QAAQ,EACZ,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,uBAAuB,CAAC,CA4FlC"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Content health cron — runs `scanDocument` across published
3
+ * documents in chunks, upserts findings into
4
+ * `actuate_content_issues`, and deletes rows whose issue resolved
5
+ * since the previous run.
6
+ *
7
+ * ## Why chunked iteration
8
+ *
9
+ * A single full scan over a 10k-doc corpus does ~10k HTML parses
10
+ * plus up to ~50k internal-link probes (HEAD requests with 5s
11
+ * timeouts). On Vercel that quickly approaches the function
12
+ * wall-time cap. We batch in `BATCH_SIZE` pages and yield to the
13
+ * event loop between batches so a 10-minute cron can make
14
+ * incremental progress even when the corpus is large.
15
+ *
16
+ * ## Why upsert + delete-stale
17
+ *
18
+ * The scanner is the source of truth. After a run, any (docId, type)
19
+ * pair NOT emitted by the scan no longer applies — the editor
20
+ * fixed the issue, the document was unpublished, etc. We track
21
+ * pairs we've seen and DELETE the unseen ones at the end.
22
+ *
23
+ * ## Why we never delete on partial failure
24
+ *
25
+ * If the scan throws mid-run, we abandon the delete step. Better
26
+ * to over-report (a previously-recorded issue may already be
27
+ * resolved) than to silently drop a real issue because the scan
28
+ * crashed before reaching that document.
29
+ */
30
+ import { scanDocument, checkInternalLinkReachable, } from './scanner.js';
31
+ const DEFAULT_BATCH_SIZE = 50;
32
+ const DEFAULT_MAX_DOCUMENTS = 500;
33
+ export async function processContentHealthScan(db, options = {}) {
34
+ const maxDocuments = options.maxDocuments ?? DEFAULT_MAX_DOCUMENTS;
35
+ const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
36
+ const linkProbeTimeoutMs = options.linkProbeTimeoutMs ?? 5000;
37
+ // If neither a checker nor a siteOrigin is supplied, the link
38
+ // check is disabled — the scanner still runs the other three
39
+ // checks. Passing `checkLink: null` explicitly is the supported
40
+ // way to opt out (useful in CI / tests).
41
+ const checkLink = options.checkLink === undefined
42
+ ? (url) => checkInternalLinkReachable(url, linkProbeTimeoutMs)
43
+ : options.checkLink;
44
+ const result = {
45
+ scanned: 0,
46
+ inserted: 0,
47
+ updated: 0,
48
+ resolved: 0,
49
+ totals: {
50
+ MISSING_META_DESCRIPTION: 0,
51
+ MISSING_ALT_TEXT: 0,
52
+ BROKEN_INTERNAL_LINK: 0,
53
+ OUTDATED_CONTENT: 0,
54
+ },
55
+ truncated: false,
56
+ };
57
+ if (!db?.document || !db?.contentIssue)
58
+ return result;
59
+ // Track the (docId, type) pairs we've seen this run. After the
60
+ // scan completes, any row in the table NOT in this set is
61
+ // resolved and gets deleted.
62
+ const seen = new Set();
63
+ let cursor;
64
+ while (result.scanned < maxDocuments) {
65
+ const remaining = maxDocuments - result.scanned;
66
+ const take = Math.min(batchSize, remaining);
67
+ const batch = await db.document.findMany({
68
+ where: { deletedAt: null, status: 'PUBLISHED' },
69
+ orderBy: { id: 'asc' },
70
+ take,
71
+ ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
72
+ select: { id: true, data: true, updatedAt: true, publishedAt: true },
73
+ });
74
+ if (batch.length === 0)
75
+ break;
76
+ for (const doc of batch) {
77
+ const issues = await scanDocument({
78
+ id: doc.id,
79
+ data: (doc.data ?? {}),
80
+ updatedAt: doc.updatedAt,
81
+ publishedAt: doc.publishedAt,
82
+ }, {
83
+ checkLink,
84
+ siteOrigin: options.siteOrigin ?? null,
85
+ });
86
+ for (const issue of issues) {
87
+ seen.add(`${doc.id}|${issue.type}`);
88
+ const persisted = await upsertIssue(db, doc.id, issue);
89
+ if (persisted === 'inserted')
90
+ result.inserted++;
91
+ else if (persisted === 'updated')
92
+ result.updated++;
93
+ result.totals[issue.type] += issue.count;
94
+ }
95
+ result.scanned++;
96
+ }
97
+ cursor = batch[batch.length - 1]?.id;
98
+ if (batch.length < take)
99
+ break;
100
+ if (result.scanned >= maxDocuments) {
101
+ result.truncated = true;
102
+ break;
103
+ }
104
+ }
105
+ // Resolve no-longer-applicable rows. Done in one DELETE per
106
+ // type so the table stays consistent even on a corpus with
107
+ // 10k+ resolved issues — `IN` clauses on large id lists are
108
+ // far slower than `NOT IN` with a (small) set of currently-seen
109
+ // pairs per type.
110
+ result.resolved = await pruneResolved(db, seen);
111
+ return result;
112
+ }
113
+ async function upsertIssue(db, documentId, issue) {
114
+ // We can't tell `upsert` to report whether it inserted or
115
+ // updated from a single call, so we attempt to `update` first
116
+ // and fall back to `create` on P2025 (record not found).
117
+ try {
118
+ await db.contentIssue.update({
119
+ where: { documentId_type: { documentId, type: issue.type } },
120
+ data: {
121
+ count: issue.count,
122
+ details: issue.details ?? null,
123
+ lastSeenAt: new Date(),
124
+ },
125
+ });
126
+ return 'updated';
127
+ }
128
+ catch (err) {
129
+ if (err?.code === 'P2025') {
130
+ await db.contentIssue.create({
131
+ data: {
132
+ documentId,
133
+ type: issue.type,
134
+ count: issue.count,
135
+ details: issue.details ?? undefined,
136
+ },
137
+ });
138
+ return 'inserted';
139
+ }
140
+ throw err;
141
+ }
142
+ }
143
+ /**
144
+ * Delete rows for (documentId, type) pairs NOT present in `seen`.
145
+ * Implementation: load all current rows in (id, documentId, type)
146
+ * tuples, filter to the unseen ones, and bulk-delete. For tables
147
+ * up to ~100k rows this is faster than building a giant SQL
148
+ * `WHERE NOT (... OR ... OR ...)` clause.
149
+ */
150
+ async function pruneResolved(db, seen) {
151
+ const allRows = await db.contentIssue.findMany({
152
+ select: { id: true, documentId: true, type: true },
153
+ });
154
+ const stale = allRows.filter((r) => !seen.has(`${r.documentId}|${r.type}`)).map((r) => r.id);
155
+ if (stale.length === 0)
156
+ return 0;
157
+ // Chunked deleteMany — Postgres caps IN-list size around 32k
158
+ // parameters; 1000 is well under and easy to reason about.
159
+ let deleted = 0;
160
+ for (let i = 0; i < stale.length; i += 1000) {
161
+ const slice = stale.slice(i, i + 1000);
162
+ const r = await db.contentIssue.deleteMany({ where: { id: { in: slice } } });
163
+ deleted += r.count;
164
+ }
165
+ return deleted;
166
+ }
167
+ //# sourceMappingURL=cron.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron.js","sourceRoot":"","sources":["../../src/content-health/cron.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Content health scanner — produces the issue rows that power the
3
+ * dashboard "Content Health" card.
4
+ *
5
+ * ## Why a separate module from the cron / endpoint
6
+ *
7
+ * The scanner is pure logic — give it a document, get back an array
8
+ * of issues. The cron drives it across the corpus and persists the
9
+ * results; the endpoint reads the persisted results. Keeping the
10
+ * scanner free of I/O dependencies makes it trivially testable
11
+ * (~10 µs per call, no DB) and lets future callers (CI checks, a
12
+ * pre-publish hook, etc.) reuse it without dragging in cron
13
+ * infrastructure.
14
+ *
15
+ * ## Why HEAD probes for the link checker
16
+ *
17
+ * Internal links 404 in two flavors: a hard 404 from the framework
18
+ * (page genuinely doesn't exist) and a soft 404 where the framework
19
+ * returns 200 with a "not found" template. We only catch the first.
20
+ * That's fine — the dashboard's job is to surface the high-confidence
21
+ * issues, not chase ambiguous soft-404 heuristics. The scanner can
22
+ * be extended with response-body matching later if the editor
23
+ * feedback warrants it.
24
+ *
25
+ * ## Why we count alt-less images per document, not per image
26
+ *
27
+ * The dashboard surfaces total issue counts. Storing one row per
28
+ * image would blow up the issue table for pages with 20+
29
+ * unannotated photos and provide no extra value (the editor still
30
+ * gets a single "12 images need alt text" row in the UI). We
31
+ * collapse to one row per (document, type) with `count` carrying
32
+ * the per-document magnitude.
33
+ */
34
+ export type ContentIssueKind = 'MISSING_META_DESCRIPTION' | 'MISSING_ALT_TEXT' | 'BROKEN_INTERNAL_LINK' | 'OUTDATED_CONTENT';
35
+ export interface ScannerInput {
36
+ id: string;
37
+ /**
38
+ * Document's `data` JSON. Fields we read:
39
+ * - `metaDescription`, `seoDescription` (optional)
40
+ * - `body`, `content`, `richText`, `html` (any string field used
41
+ * as the primary HTML payload — we try each, longest wins)
42
+ */
43
+ data: Record<string, unknown>;
44
+ updatedAt: Date;
45
+ publishedAt?: Date | null;
46
+ }
47
+ export interface ScannerIssue {
48
+ type: ContentIssueKind;
49
+ count: number;
50
+ details?: Record<string, unknown>;
51
+ }
52
+ export interface ScannerOptions {
53
+ /**
54
+ * Cut-off (in days) for the OUTDATED_CONTENT check. A document
55
+ * whose `updatedAt` is older than this is flagged. Default 90.
56
+ */
57
+ outdatedDays?: number;
58
+ /**
59
+ * Link-checker callback. Resolves to `true` if the link is
60
+ * reachable, `false` if broken. Pass `null` to disable the
61
+ * link-check (useful in tests). The default cron supplies
62
+ * `checkInternalLinkReachable` from this module.
63
+ */
64
+ checkLink?: ((url: string) => Promise<boolean>) | null;
65
+ /**
66
+ * Origin used to resolve relative URLs and identify
67
+ * "internal" links. Required for the link checker — passing
68
+ * `null` here is the same as passing `checkLink: null` (no
69
+ * link probing).
70
+ */
71
+ siteOrigin?: string | null;
72
+ }
73
+ /**
74
+ * Count <img> tags in the HTML that don't carry an `alt` attribute.
75
+ * Matches the existing a11y rule in `a11y/index.ts` so the scanner
76
+ * agrees with the per-document audit panel.
77
+ */
78
+ export declare function countMissingAltImages(html: string): number;
79
+ /**
80
+ * Extract candidate URLs from <a href="..."> attributes. Returns
81
+ * the raw href values; classification (internal vs external,
82
+ * absolute vs relative) happens in the caller.
83
+ */
84
+ export declare function extractLinks(html: string): string[];
85
+ /**
86
+ * Decide whether a URL is "internal" relative to `siteOrigin`.
87
+ * Internal links include:
88
+ * - relative paths: `/about`, `./page`, `../foo`
89
+ * - same-origin absolute URLs: `https://example.com/page`
90
+ *
91
+ * Excluded:
92
+ * - external URLs (different origin)
93
+ * - hash-only anchors (`#section`)
94
+ * - non-http schemes (`mailto:`, `tel:`, `javascript:`, etc.)
95
+ * - protocol-relative URLs to a different host (`//other.com/x`)
96
+ */
97
+ export declare function classifyLink(url: string, siteOrigin: string): {
98
+ isInternal: boolean;
99
+ resolved: string | null;
100
+ };
101
+ /**
102
+ * Single-link reachability probe. Default implementation used by
103
+ * the cron — issues a HEAD with a 5s timeout, falls back to GET if
104
+ * HEAD returns 405/501 (some frameworks reject HEAD).
105
+ */
106
+ export declare function checkInternalLinkReachable(url: string, timeoutMs?: number): Promise<boolean>;
107
+ /**
108
+ * Run all checks on a single document. The link checker is
109
+ * concurrency-bounded to `LINK_CHECK_CONCURRENCY` per document so a
110
+ * page with 200 links doesn't open 200 sockets at once.
111
+ *
112
+ * Each issue type appears at most once in the returned array. The
113
+ * shape `{ type, count, details }` lets the caller upsert by
114
+ * `(documentId, type)` and rely on `count` for the magnitude
115
+ * surfaced in the dashboard.
116
+ */
117
+ export declare function scanDocument(doc: ScannerInput, opts?: ScannerOptions): Promise<ScannerIssue[]>;
118
+ //# sourceMappingURL=scanner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanner.d.ts","sourceRoot":"","sources":["../../src/content-health/scanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAIH,MAAM,MAAM,gBAAgB,GACxB,0BAA0B,GAC1B,kBAAkB,GAClB,sBAAsB,GACtB,kBAAkB,CAAA;AAEtB,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7B,SAAS,EAAE,IAAI,CAAA;IACf,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,gBAAgB,CAAA;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClC;AAED,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,IAAI,CAAA;IACtD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAsCD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1D;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAUnD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GACjB;IAAE,UAAU,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CA8BlD;AAED;;;;GAIG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAwBhG;AAID;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,YAAY,EACjB,IAAI,GAAE,cAAmB,GACxB,OAAO,CAAC,YAAY,EAAE,CAAC,CA4DzB"}
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Content health scanner — produces the issue rows that power the
3
+ * dashboard "Content Health" card.
4
+ *
5
+ * ## Why a separate module from the cron / endpoint
6
+ *
7
+ * The scanner is pure logic — give it a document, get back an array
8
+ * of issues. The cron drives it across the corpus and persists the
9
+ * results; the endpoint reads the persisted results. Keeping the
10
+ * scanner free of I/O dependencies makes it trivially testable
11
+ * (~10 µs per call, no DB) and lets future callers (CI checks, a
12
+ * pre-publish hook, etc.) reuse it without dragging in cron
13
+ * infrastructure.
14
+ *
15
+ * ## Why HEAD probes for the link checker
16
+ *
17
+ * Internal links 404 in two flavors: a hard 404 from the framework
18
+ * (page genuinely doesn't exist) and a soft 404 where the framework
19
+ * returns 200 with a "not found" template. We only catch the first.
20
+ * That's fine — the dashboard's job is to surface the high-confidence
21
+ * issues, not chase ambiguous soft-404 heuristics. The scanner can
22
+ * be extended with response-body matching later if the editor
23
+ * feedback warrants it.
24
+ *
25
+ * ## Why we count alt-less images per document, not per image
26
+ *
27
+ * The dashboard surfaces total issue counts. Storing one row per
28
+ * image would blow up the issue table for pages with 20+
29
+ * unannotated photos and provide no extra value (the editor still
30
+ * gets a single "12 images need alt text" row in the UI). We
31
+ * collapse to one row per (document, type) with `count` carrying
32
+ * the per-document magnitude.
33
+ */
34
+ // ─── Helpers ───────────────────────────────────────────────────────────────
35
+ /**
36
+ * Return the longest plausible HTML payload from a document. CMS
37
+ * collections use different field names (`body`, `content`,
38
+ * `richText`, `html`) so we try each and pick the longest — that
39
+ * resolves the common case where a document has both a plain
40
+ * `excerpt` and a rich `body`, and we want to scan the body.
41
+ */
42
+ function extractHtml(data) {
43
+ const candidates = ['body', 'content', 'richText', 'richtext', 'html', 'description'];
44
+ let best = '';
45
+ for (const key of candidates) {
46
+ const v = data[key];
47
+ if (typeof v === 'string' && v.length > best.length)
48
+ best = v;
49
+ }
50
+ return best;
51
+ }
52
+ const META_DESCRIPTION_KEYS = ['metaDescription', 'seoDescription', 'meta_description'];
53
+ function getMetaDescription(data) {
54
+ for (const key of META_DESCRIPTION_KEYS) {
55
+ const v = data[key];
56
+ if (typeof v === 'string' && v.trim().length > 0)
57
+ return v.trim();
58
+ }
59
+ // Nested SEO objects (e.g. data.seo.description).
60
+ const seo = data['seo'];
61
+ if (seo && typeof seo === 'object') {
62
+ const obj = seo;
63
+ const v = obj['description'] ?? obj['metaDescription'];
64
+ if (typeof v === 'string' && v.trim().length > 0)
65
+ return v.trim();
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Count <img> tags in the HTML that don't carry an `alt` attribute.
71
+ * Matches the existing a11y rule in `a11y/index.ts` so the scanner
72
+ * agrees with the per-document audit panel.
73
+ */
74
+ export function countMissingAltImages(html) {
75
+ if (!html)
76
+ return 0;
77
+ const re = /<img(?![^>]*\salt\s*=)[^>]*>/gi;
78
+ let count = 0;
79
+ // We don't iterate matches into an array — just count, so memory
80
+ // stays bounded even on huge pages.
81
+ while (re.exec(html) !== null)
82
+ count++;
83
+ return count;
84
+ }
85
+ /**
86
+ * Extract candidate URLs from <a href="..."> attributes. Returns
87
+ * the raw href values; classification (internal vs external,
88
+ * absolute vs relative) happens in the caller.
89
+ */
90
+ export function extractLinks(html) {
91
+ if (!html)
92
+ return [];
93
+ const out = [];
94
+ const re = /<a\b[^>]*\shref\s*=\s*("([^"]*)"|'([^']*)')/gi;
95
+ let match;
96
+ while ((match = re.exec(html)) !== null) {
97
+ const url = (match[2] ?? match[3] ?? '').trim();
98
+ if (url.length > 0)
99
+ out.push(url);
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Decide whether a URL is "internal" relative to `siteOrigin`.
105
+ * Internal links include:
106
+ * - relative paths: `/about`, `./page`, `../foo`
107
+ * - same-origin absolute URLs: `https://example.com/page`
108
+ *
109
+ * Excluded:
110
+ * - external URLs (different origin)
111
+ * - hash-only anchors (`#section`)
112
+ * - non-http schemes (`mailto:`, `tel:`, `javascript:`, etc.)
113
+ * - protocol-relative URLs to a different host (`//other.com/x`)
114
+ */
115
+ export function classifyLink(url, siteOrigin) {
116
+ const trimmed = url.trim();
117
+ if (trimmed.length === 0)
118
+ return { isInternal: false, resolved: null };
119
+ if (trimmed.startsWith('#'))
120
+ return { isInternal: false, resolved: null };
121
+ // Reject non-http schemes outright.
122
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
123
+ if (schemeMatch) {
124
+ const scheme = schemeMatch[1].toLowerCase();
125
+ if (scheme !== 'http' && scheme !== 'https')
126
+ return { isInternal: false, resolved: null };
127
+ }
128
+ let parsedOrigin;
129
+ try {
130
+ parsedOrigin = new URL(siteOrigin);
131
+ }
132
+ catch {
133
+ return { isInternal: false, resolved: null };
134
+ }
135
+ try {
136
+ const resolved = new URL(trimmed, parsedOrigin);
137
+ if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
138
+ return { isInternal: false, resolved: null };
139
+ }
140
+ return {
141
+ isInternal: resolved.origin === parsedOrigin.origin,
142
+ resolved: resolved.toString(),
143
+ };
144
+ }
145
+ catch {
146
+ return { isInternal: false, resolved: null };
147
+ }
148
+ }
149
+ /**
150
+ * Single-link reachability probe. Default implementation used by
151
+ * the cron — issues a HEAD with a 5s timeout, falls back to GET if
152
+ * HEAD returns 405/501 (some frameworks reject HEAD).
153
+ */
154
+ export async function checkInternalLinkReachable(url, timeoutMs = 5000) {
155
+ const controller = new AbortController();
156
+ const t = setTimeout(() => controller.abort(), timeoutMs);
157
+ try {
158
+ const head = await fetch(url, {
159
+ method: 'HEAD',
160
+ redirect: 'follow',
161
+ signal: controller.signal,
162
+ });
163
+ if (head.status === 405 || head.status === 501) {
164
+ // Method not allowed / not implemented → retry with GET.
165
+ const get = await fetch(url, {
166
+ method: 'GET',
167
+ redirect: 'follow',
168
+ signal: controller.signal,
169
+ });
170
+ return get.status < 400;
171
+ }
172
+ return head.status < 400;
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ finally {
178
+ clearTimeout(t);
179
+ }
180
+ }
181
+ // ─── Scanner ───────────────────────────────────────────────────────────────
182
+ /**
183
+ * Run all checks on a single document. The link checker is
184
+ * concurrency-bounded to `LINK_CHECK_CONCURRENCY` per document so a
185
+ * page with 200 links doesn't open 200 sockets at once.
186
+ *
187
+ * Each issue type appears at most once in the returned array. The
188
+ * shape `{ type, count, details }` lets the caller upsert by
189
+ * `(documentId, type)` and rely on `count` for the magnitude
190
+ * surfaced in the dashboard.
191
+ */
192
+ export async function scanDocument(doc, opts = {}) {
193
+ const outdatedDays = opts.outdatedDays ?? 90;
194
+ const issues = [];
195
+ // 1. Missing meta description --------------------------------------------
196
+ if (!getMetaDescription(doc.data)) {
197
+ issues.push({ type: 'MISSING_META_DESCRIPTION', count: 1 });
198
+ }
199
+ // 2. Missing alt text ----------------------------------------------------
200
+ const html = extractHtml(doc.data);
201
+ const altLessImages = countMissingAltImages(html);
202
+ if (altLessImages > 0) {
203
+ issues.push({ type: 'MISSING_ALT_TEXT', count: altLessImages });
204
+ }
205
+ // 3. Outdated content ----------------------------------------------------
206
+ const referenceTime = doc.publishedAt ?? doc.updatedAt;
207
+ const ageMs = Date.now() - referenceTime.getTime();
208
+ if (ageMs > outdatedDays * 24 * 60 * 60 * 1000) {
209
+ issues.push({
210
+ type: 'OUTDATED_CONTENT',
211
+ count: 1,
212
+ details: {
213
+ ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
214
+ threshold: outdatedDays,
215
+ },
216
+ });
217
+ }
218
+ // 4. Broken internal links ----------------------------------------------
219
+ if (opts.checkLink && opts.siteOrigin) {
220
+ const links = extractLinks(html);
221
+ const internalUrls = new Set();
222
+ for (const raw of links) {
223
+ const { isInternal, resolved } = classifyLink(raw, opts.siteOrigin);
224
+ if (isInternal && resolved)
225
+ internalUrls.add(resolved);
226
+ }
227
+ const broken = [];
228
+ if (internalUrls.size > 0) {
229
+ const checker = opts.checkLink;
230
+ await Promise.all(chunk([...internalUrls], LINK_CHECK_CONCURRENCY).map(async (group) => {
231
+ const results = await Promise.all(group.map((u) => checker(u).catch(() => false)));
232
+ group.forEach((u, i) => {
233
+ if (!results[i])
234
+ broken.push(u);
235
+ });
236
+ }));
237
+ }
238
+ if (broken.length > 0) {
239
+ issues.push({
240
+ type: 'BROKEN_INTERNAL_LINK',
241
+ count: broken.length,
242
+ details: { urls: broken.slice(0, 10) }, // cap stored details
243
+ });
244
+ }
245
+ }
246
+ return issues;
247
+ }
248
+ /**
249
+ * Concurrency cap for the per-document link checker. Vercel
250
+ * functions are wall-time bounded; opening 5 concurrent sockets
251
+ * keeps us under the typical 60s soft cap even for pages with
252
+ * 100+ internal links.
253
+ */
254
+ const LINK_CHECK_CONCURRENCY = 5;
255
+ function chunk(arr, size) {
256
+ if (size <= 0)
257
+ return [arr];
258
+ const out = [];
259
+ for (let i = 0; i < arr.length; i += size)
260
+ out.push(arr.slice(i, i + size));
261
+ return out;
262
+ }
263
+ //# sourceMappingURL=scanner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanner.js","sourceRoot":"","sources":["../../src/content-health/scanner.ts"],"names":[],"mappings":"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"}