@cms-lab/core 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Afaq Rashid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @cms-lab/core
2
+
3
+ Core config, types, diagnostics, route resolution, and scan orchestration for cms-lab.
4
+
5
+ ```ts
6
+ import { defineConfig, scanDocuments } from "@cms-lab/core";
7
+ ```
8
+
9
+ Most users should install `cms-lab` and use the CLI. This package is public for
10
+ typed config files, tests, and adapter/report integrations.
11
+
12
+ `scanDocuments` understands the shared `CMSDocument` contract plus provider
13
+ field shapes from the bundled adapters. SEO checks recognize common Prismic,
14
+ Strapi, Directus, and WordPress SEO fields, while image checks recognize native
15
+ alt fields such as Strapi `alternativeText`, Directus file `description`, and
16
+ WordPress `alt_text`.
17
+
18
+ ## Open Source
19
+
20
+ MIT licensed. See the repository [license](https://github.com/i-afaqrashid/cms-lab/blob/main/LICENSE), [contributing guide](https://github.com/i-afaqrashid/cms-lab/blob/main/CONTRIBUTING.md), and [support guide](https://github.com/i-afaqrashid/cms-lab/blob/main/SUPPORT.md).
@@ -0,0 +1,164 @@
1
+ type CMSDocumentStatus = "published" | "draft";
2
+ type CMSDocument = {
3
+ id: string;
4
+ type: string;
5
+ uid?: string;
6
+ url?: string;
7
+ status: CMSDocumentStatus;
8
+ data: unknown;
9
+ };
10
+ type DiagnosticSeverity = "error" | "warning" | "info";
11
+ type Diagnostic = {
12
+ severity: DiagnosticSeverity;
13
+ code: string;
14
+ message: string;
15
+ path?: string;
16
+ source?: string;
17
+ };
18
+ type DiagnosticExplanation = {
19
+ code: string;
20
+ severity: DiagnosticSeverity;
21
+ title: string;
22
+ meaning: string;
23
+ fix: string;
24
+ };
25
+ type ProjectInfo = {
26
+ framework: "next";
27
+ router: "app" | "pages";
28
+ rootDir: string;
29
+ appDir?: string;
30
+ pagesDir?: string;
31
+ };
32
+ type RouteDefinition = {
33
+ type: string;
34
+ pattern: string;
35
+ getPath: (document: CMSDocument) => string;
36
+ };
37
+ type RequiredFieldRule = {
38
+ type: string;
39
+ path: string;
40
+ severity?: "error" | "warning";
41
+ };
42
+ type PrismicCmsProviderConfig = {
43
+ provider: "prismic";
44
+ repositoryName: string;
45
+ accessToken?: string;
46
+ endpoint?: string;
47
+ };
48
+ type StrapiCollectionConfig = {
49
+ type: string;
50
+ endpoint: string;
51
+ };
52
+ type StrapiCmsProviderConfig = {
53
+ provider: "strapi";
54
+ url: string;
55
+ token?: string;
56
+ collections: StrapiCollectionConfig[];
57
+ };
58
+ type DirectusCollectionConfig = {
59
+ type: string;
60
+ collection: string;
61
+ };
62
+ type DirectusCmsProviderConfig = {
63
+ provider: "directus";
64
+ url: string;
65
+ token?: string;
66
+ collections: DirectusCollectionConfig[];
67
+ };
68
+ type WordPressContentTypeConfig = {
69
+ type: string;
70
+ endpoint: string;
71
+ };
72
+ type WordPressCmsProviderConfig = {
73
+ provider: "wordpress";
74
+ url: string;
75
+ contentTypes?: WordPressContentTypeConfig[];
76
+ };
77
+ type CmsProviderConfig = PrismicCmsProviderConfig | StrapiCmsProviderConfig | DirectusCmsProviderConfig | WordPressCmsProviderConfig;
78
+ type CmsLabConfig = {
79
+ site: {
80
+ url: string;
81
+ };
82
+ framework: {
83
+ type: "next";
84
+ router: "app" | "pages";
85
+ };
86
+ cms: CmsProviderConfig;
87
+ routes: RouteDefinition[];
88
+ checks?: {
89
+ routes?: boolean;
90
+ seo?: boolean | {
91
+ metaTitle?: boolean;
92
+ metaDescription?: boolean;
93
+ };
94
+ images?: boolean;
95
+ a11y?: boolean | {
96
+ imgAlt?: boolean;
97
+ };
98
+ fields?: boolean | {
99
+ required?: RequiredFieldRule[];
100
+ };
101
+ };
102
+ };
103
+ type ScanSummary = {
104
+ errors: number;
105
+ warnings: number;
106
+ info: number;
107
+ };
108
+ type ScanResult = {
109
+ project: ProjectInfo;
110
+ documents: CMSDocument[];
111
+ diagnostics: Diagnostic[];
112
+ summary: ScanSummary;
113
+ };
114
+ type FetchLike = typeof fetch;
115
+ type CheckGroup = "routes" | "seo" | "a11y" | "images" | "fields";
116
+ type ScanFilters = {
117
+ types?: string[];
118
+ only?: CheckGroup[];
119
+ skip?: CheckGroup[];
120
+ };
121
+
122
+ type LoadedCmsLabConfig = {
123
+ config: CmsLabConfig;
124
+ configFile?: string;
125
+ };
126
+ declare function defineConfig(config: CmsLabConfig): CmsLabConfig;
127
+ declare function validateConfig(input: unknown): CmsLabConfig;
128
+ declare function loadCmsLabConfig(options: {
129
+ cwd: string;
130
+ configPath?: string;
131
+ }): Promise<LoadedCmsLabConfig>;
132
+
133
+ declare function createDiagnostic(input: Diagnostic): Diagnostic;
134
+ declare function summarizeDiagnostics(diagnostics: Diagnostic[]): ScanSummary;
135
+ declare function explainDiagnostic(code: string): DiagnosticExplanation | undefined;
136
+ declare function listDiagnosticExplanations(): DiagnosticExplanation[];
137
+
138
+ declare class CmsLabError extends Error {
139
+ readonly code: string;
140
+ constructor(message: string, code: string);
141
+ }
142
+ declare class ConfigLoadError extends CmsLabError {
143
+ constructor(message: string);
144
+ }
145
+ declare class CmsFetchError extends CmsLabError {
146
+ constructor(message: string);
147
+ }
148
+ declare class SiteUnreachableError extends CmsLabError {
149
+ constructor(message: string);
150
+ }
151
+
152
+ type ScanDocumentsOptions = {
153
+ config: CmsLabConfig;
154
+ project: ProjectInfo;
155
+ documents: CMSDocument[];
156
+ fetch?: FetchLike;
157
+ timeoutMs?: number;
158
+ concurrency?: number;
159
+ retries?: number;
160
+ filters?: ScanFilters;
161
+ };
162
+ declare function scanDocuments(options: ScanDocumentsOptions): Promise<ScanResult>;
163
+
164
+ export { type CMSDocument, type CMSDocumentStatus, type CheckGroup, CmsFetchError, type CmsLabConfig, CmsLabError, type CmsProviderConfig, ConfigLoadError, type Diagnostic, type DiagnosticExplanation, type DiagnosticSeverity, type DirectusCmsProviderConfig, type DirectusCollectionConfig, type FetchLike, type LoadedCmsLabConfig, type PrismicCmsProviderConfig, type ProjectInfo, type RequiredFieldRule, type RouteDefinition, type ScanDocumentsOptions, type ScanFilters, type ScanResult, type ScanSummary, SiteUnreachableError, type StrapiCmsProviderConfig, type StrapiCollectionConfig, type WordPressCmsProviderConfig, type WordPressContentTypeConfig, createDiagnostic, defineConfig, explainDiagnostic, listDiagnosticExplanations, loadCmsLabConfig, scanDocuments, summarizeDiagnostics, validateConfig };
package/dist/index.js ADDED
@@ -0,0 +1,844 @@
1
+ // src/config.ts
2
+ import { loadConfig } from "c12";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { z } from "zod";
6
+
7
+ // src/errors.ts
8
+ var CmsLabError = class extends Error {
9
+ constructor(message, code) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "CmsLabError";
13
+ }
14
+ code;
15
+ };
16
+ var ConfigLoadError = class extends CmsLabError {
17
+ constructor(message) {
18
+ super(message, "CONFIG_ERROR");
19
+ this.name = "ConfigLoadError";
20
+ }
21
+ };
22
+ var CmsFetchError = class extends CmsLabError {
23
+ constructor(message) {
24
+ super(message, "CMS_UNREACHABLE");
25
+ this.name = "CmsFetchError";
26
+ }
27
+ };
28
+ var SiteUnreachableError = class extends CmsLabError {
29
+ constructor(message) {
30
+ super(message, "SITE_UNREACHABLE");
31
+ this.name = "SiteUnreachableError";
32
+ }
33
+ };
34
+
35
+ // src/config.ts
36
+ var routeSchema = z.object({
37
+ type: z.string().min(1),
38
+ pattern: z.string().min(1),
39
+ getPath: z.custom(
40
+ (value) => typeof value === "function",
41
+ {
42
+ message: "routes[].getPath must be a function"
43
+ }
44
+ )
45
+ }).strict();
46
+ var prismicConfigSchema = z.object({
47
+ provider: z.literal("prismic"),
48
+ repositoryName: z.string().min(1),
49
+ accessToken: z.string().optional(),
50
+ endpoint: z.string().url().optional()
51
+ }).strict();
52
+ var strapiConfigSchema = z.object({
53
+ provider: z.literal("strapi"),
54
+ url: z.string().url(),
55
+ token: z.string().optional(),
56
+ collections: z.array(
57
+ z.object({
58
+ type: z.string().min(1),
59
+ endpoint: z.string().min(1)
60
+ }).strict()
61
+ ).min(1)
62
+ }).strict();
63
+ var directusConfigSchema = z.object({
64
+ provider: z.literal("directus"),
65
+ url: z.string().url(),
66
+ token: z.string().optional(),
67
+ collections: z.array(
68
+ z.object({
69
+ type: z.string().min(1),
70
+ collection: z.string().min(1)
71
+ }).strict()
72
+ ).min(1)
73
+ }).strict();
74
+ var wordpressConfigSchema = z.object({
75
+ provider: z.literal("wordpress"),
76
+ url: z.string().url(),
77
+ contentTypes: z.array(
78
+ z.object({
79
+ type: z.string().min(1),
80
+ endpoint: z.string().min(1)
81
+ }).strict()
82
+ ).optional()
83
+ }).strict();
84
+ var cmsConfigSchema = z.discriminatedUnion("provider", [
85
+ prismicConfigSchema,
86
+ strapiConfigSchema,
87
+ directusConfigSchema,
88
+ wordpressConfigSchema
89
+ ]);
90
+ var requiredFieldRuleSchema = z.object({
91
+ type: z.string().min(1),
92
+ path: z.string().min(1),
93
+ severity: z.enum(["error", "warning"]).optional()
94
+ });
95
+ var checksSchema = z.object({
96
+ routes: z.boolean().optional(),
97
+ seo: z.union([
98
+ z.boolean(),
99
+ z.object({
100
+ metaTitle: z.boolean().optional(),
101
+ metaDescription: z.boolean().optional()
102
+ }).strict()
103
+ ]).optional(),
104
+ images: z.boolean().optional(),
105
+ a11y: z.union([
106
+ z.boolean(),
107
+ z.object({ imgAlt: z.boolean().optional() }).strict()
108
+ ]).optional(),
109
+ fields: z.union([
110
+ z.boolean(),
111
+ z.object({
112
+ required: z.array(requiredFieldRuleSchema).optional()
113
+ })
114
+ ]).optional()
115
+ }).strict().optional();
116
+ var configSchema = z.object({
117
+ site: z.object({
118
+ url: z.string().url()
119
+ }).strict(),
120
+ framework: z.object({
121
+ type: z.literal("next"),
122
+ router: z.enum(["app", "pages"])
123
+ }).strict(),
124
+ cms: cmsConfigSchema,
125
+ routes: z.array(routeSchema).min(1),
126
+ checks: checksSchema
127
+ }).strict();
128
+ function defineConfig(config) {
129
+ return config;
130
+ }
131
+ function validateConfig(input) {
132
+ const result = configSchema.safeParse(input);
133
+ if (!result.success) {
134
+ throw new ConfigLoadError(z.prettifyError(result.error));
135
+ }
136
+ return result.data;
137
+ }
138
+ async function loadCmsLabConfig(options) {
139
+ try {
140
+ const result = await loadConfig({
141
+ name: "cms-lab",
142
+ cwd: options.cwd,
143
+ configFile: options.configPath,
144
+ configFileRequired: true,
145
+ dotenv: true,
146
+ jitiOptions: {
147
+ alias: {
148
+ "@cms-lab/core": selfEntryPath()
149
+ }
150
+ }
151
+ });
152
+ return {
153
+ config: validateConfig(result.config),
154
+ configFile: result.configFile
155
+ };
156
+ } catch (error) {
157
+ if (error instanceof ConfigLoadError) {
158
+ throw error;
159
+ }
160
+ throw new ConfigLoadError(
161
+ error instanceof Error ? error.message : "Failed to load cms-lab config"
162
+ );
163
+ }
164
+ }
165
+ function selfEntryPath() {
166
+ const currentFile = fileURLToPath(import.meta.url);
167
+ const extension = currentFile.endsWith(".ts") ? "ts" : "js";
168
+ return join(dirname(currentFile), `index.${extension}`);
169
+ }
170
+
171
+ // src/diagnostics.ts
172
+ function createDiagnostic(input) {
173
+ return input;
174
+ }
175
+ function summarizeDiagnostics(diagnostics) {
176
+ return {
177
+ errors: countSeverity(diagnostics, "error"),
178
+ warnings: countSeverity(diagnostics, "warning"),
179
+ info: countSeverity(diagnostics, "info")
180
+ };
181
+ }
182
+ function countSeverity(diagnostics, severity) {
183
+ return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
184
+ }
185
+ var explanations = [
186
+ {
187
+ code: "CMS-UID-MISSING",
188
+ severity: "error",
189
+ title: "CMS document is missing a UID",
190
+ meaning: "A route pattern requires :uid, but the CMS document does not have a usable uid value.",
191
+ fix: "Publish the document with a UID, or change the cms-lab route mapping so this content type does not depend on :uid."
192
+ },
193
+ {
194
+ code: "CMS-ROUTE-404",
195
+ severity: "error",
196
+ title: "CMS route returned 404",
197
+ meaning: "The route mapping resolved to a URL, but the running site returned a not-found response for that CMS document.",
198
+ fix: "Check generateStaticParams, dynamicParams, CMS fetch logic, and the getPath mapping for this content type."
199
+ },
200
+ {
201
+ code: "CMS-ROUTE-500",
202
+ severity: "error",
203
+ title: "CMS route returned a server error",
204
+ meaning: "The mapped page crashed or returned a 5xx response when cms-lab probed it.",
205
+ fix: "Open the path locally or in staging, inspect the server logs, and fix the page data fetching or rendering error."
206
+ },
207
+ {
208
+ code: "CMS-ROUTE-ERROR",
209
+ severity: "error",
210
+ title: "CMS route probe failed",
211
+ meaning: "cms-lab could not complete the HTTP probe for a mapped CMS route, or the route returned an unexpected 4xx response.",
212
+ fix: "Verify the site is reachable, the route is public, auth middleware allows the probe, and the URL mapping is correct."
213
+ },
214
+ {
215
+ code: "CMS-ROUTE-INVALID",
216
+ severity: "error",
217
+ title: "Route mapping returned an invalid path",
218
+ meaning: "A route getPath function returned an empty value, a value that does not start with /, or a protocol-relative path that could leave the configured site origin.",
219
+ fix: "Update the getPath function to return a same-origin site path such as /about or /blog/my-post."
220
+ },
221
+ {
222
+ code: "CMS-ROUTE-UNMAPPED",
223
+ severity: "info",
224
+ title: "CMS document has no route mapping",
225
+ meaning: "A fetched CMS document type is not listed in the cms-lab routes config, so cms-lab skipped route probing for that document.",
226
+ fix: "Add a route mapping if this content type should render a public page, or leave it unmapped for settings/navigation documents."
227
+ },
228
+ {
229
+ code: "CMS-ROUTE-RESOLVE",
230
+ severity: "error",
231
+ title: "Route mapping threw an exception",
232
+ meaning: "A route getPath function failed while resolving a CMS document.",
233
+ fix: "Make the getPath function tolerate optional fields and return a clear absolute path for every routable document."
234
+ },
235
+ {
236
+ code: "SEO-META-MISSING",
237
+ severity: "warning",
238
+ title: "SEO metadata is missing",
239
+ meaning: "A document is missing one or more configured SEO fields. cms-lab checks common provider fields such as Prismic meta fields, Strapi/Directus seo objects, and WordPress SEO plugin JSON.",
240
+ fix: "Fill the missing CMS fields or disable the SEO check if this content type intentionally does not use metadata."
241
+ },
242
+ {
243
+ code: "A11Y-IMG-ALT",
244
+ severity: "warning",
245
+ title: "Image alt text is missing",
246
+ meaning: "An image-like CMS field has no useful alt text, or uses a placeholder such as image, photo, or picture. Native fields such as Prismic alt, Strapi alternativeText, Directus file description, and WordPress alt_text are checked.",
247
+ fix: "Add meaningful alt text in the CMS, or leave decorative images out of content fields that require editorial alt text."
248
+ },
249
+ {
250
+ code: "CMS-FIELD-MISSING",
251
+ severity: "error",
252
+ title: "Required CMS field is missing",
253
+ meaning: "A document is missing a required field declared in cms-lab config.",
254
+ fix: "Fill the required field in the CMS, or update checks.fields.required if the field is no longer required."
255
+ }
256
+ ];
257
+ function explainDiagnostic(code) {
258
+ return explanations.find(
259
+ (explanation) => explanation.code === code.trim().toUpperCase()
260
+ );
261
+ }
262
+ function listDiagnosticExplanations() {
263
+ return [...explanations];
264
+ }
265
+
266
+ // src/scan.ts
267
+ async function scanDocuments(options) {
268
+ const fetchImpl = options.fetch ?? fetch;
269
+ const timeoutMs = options.timeoutMs ?? 5e3;
270
+ const concurrency = normalizeConcurrency(options.concurrency);
271
+ const retries = normalizeRetries(options.retries);
272
+ const documents = filterDocuments(options.documents, options.filters);
273
+ const diagnostics = [];
274
+ await assertSiteReachable(
275
+ options.config.site.url,
276
+ fetchImpl,
277
+ timeoutMs,
278
+ retries
279
+ );
280
+ const shouldRunRoutes = shouldRunCheck(
281
+ "routes",
282
+ options.config,
283
+ options.filters
284
+ );
285
+ const routeCandidates = shouldRunRoutes ? resolveRouteCandidates(options.config, documents, diagnostics) : [];
286
+ if (shouldRunRoutes) {
287
+ diagnostics.push(
288
+ ...await checkRouteReachability(
289
+ options.config,
290
+ routeCandidates,
291
+ fetchImpl,
292
+ timeoutMs,
293
+ concurrency,
294
+ retries
295
+ )
296
+ );
297
+ }
298
+ if (shouldRunCheck("seo", options.config, options.filters)) {
299
+ diagnostics.push(...checkSeoFields(options.config, documents));
300
+ }
301
+ if (shouldRunImageAltTextCheck(options.config, options.filters)) {
302
+ diagnostics.push(...checkImageAltText(options.config, documents));
303
+ }
304
+ if (shouldRunCheck("fields", options.config, options.filters)) {
305
+ diagnostics.push(...checkRequiredFields(options.config, documents));
306
+ }
307
+ return {
308
+ project: options.project,
309
+ documents,
310
+ diagnostics,
311
+ summary: summarizeDiagnostics(diagnostics)
312
+ };
313
+ }
314
+ function resolveRouteCandidates(config, documents, diagnostics) {
315
+ const candidates = [];
316
+ for (const document of documents) {
317
+ const route = config.routes.find(
318
+ (candidate) => candidate.type === document.type
319
+ );
320
+ if (!route) {
321
+ diagnostics.push(
322
+ createDiagnostic({
323
+ severity: "info",
324
+ code: "CMS-ROUTE-UNMAPPED",
325
+ message: `Document ${document.id} of type ${document.type} has no configured route mapping`,
326
+ source: sourceFor(config, document)
327
+ })
328
+ );
329
+ continue;
330
+ }
331
+ if (route.pattern.includes(":uid") && !document.uid) {
332
+ diagnostics.push(
333
+ createDiagnostic({
334
+ severity: "error",
335
+ code: "CMS-UID-MISSING",
336
+ message: `Document ${document.id} of type ${document.type} is missing uid`,
337
+ source: sourceFor(config, document)
338
+ })
339
+ );
340
+ continue;
341
+ }
342
+ try {
343
+ const path = route.getPath(document);
344
+ if (!path || !isSiteRelativePath(path)) {
345
+ diagnostics.push(
346
+ createDiagnostic({
347
+ severity: "error",
348
+ code: "CMS-ROUTE-INVALID",
349
+ message: `Route for document ${document.id} must resolve to a same-origin path starting with a single /`,
350
+ source: sourceFor(config, document)
351
+ })
352
+ );
353
+ continue;
354
+ }
355
+ candidates.push({ document, route, path });
356
+ } catch (error) {
357
+ diagnostics.push(
358
+ createDiagnostic({
359
+ severity: "error",
360
+ code: "CMS-ROUTE-RESOLVE",
361
+ message: messageFrom(
362
+ error instanceof Error ? error : `Failed to resolve route for document ${document.id}`
363
+ ),
364
+ source: sourceFor(config, document)
365
+ })
366
+ );
367
+ }
368
+ }
369
+ return candidates;
370
+ }
371
+ async function checkRouteReachability(config, candidates, fetchImpl, timeoutMs, concurrency, retries) {
372
+ const results = await mapLimit(candidates, concurrency, async (candidate) => {
373
+ const diagnostics = [];
374
+ const siteUrl = new URL(config.site.url);
375
+ const url = new URL(candidate.path, siteUrl);
376
+ const diagnosticPath = pathForDiagnostic(candidate.path);
377
+ let response;
378
+ if (url.origin !== siteUrl.origin) {
379
+ diagnostics.push(
380
+ createDiagnostic({
381
+ severity: "error",
382
+ code: "CMS-ROUTE-INVALID",
383
+ message: `Route ${diagnosticPath} resolved outside configured site origin`,
384
+ path: diagnosticPath,
385
+ source: sourceFor(config, candidate.document)
386
+ })
387
+ );
388
+ return diagnostics;
389
+ }
390
+ try {
391
+ response = await fetchWithRetries(fetchImpl, url, timeoutMs, retries);
392
+ } catch (error) {
393
+ diagnostics.push(
394
+ createDiagnostic({
395
+ severity: "error",
396
+ code: "CMS-ROUTE-ERROR",
397
+ message: `Route ${diagnosticPath} could not be fetched: ${messageFrom(error)}`,
398
+ path: diagnosticPath,
399
+ source: sourceFor(config, candidate.document)
400
+ })
401
+ );
402
+ return diagnostics;
403
+ }
404
+ const status = response.status;
405
+ if (status === 404) {
406
+ diagnostics.push(
407
+ createDiagnostic({
408
+ severity: "error",
409
+ code: "CMS-ROUTE-404",
410
+ message: `Route ${diagnosticPath} returned 404`,
411
+ path: diagnosticPath,
412
+ source: sourceFor(config, candidate.document)
413
+ })
414
+ );
415
+ return diagnostics;
416
+ }
417
+ if (status >= 500) {
418
+ diagnostics.push(
419
+ createDiagnostic({
420
+ severity: "error",
421
+ code: "CMS-ROUTE-500",
422
+ message: `Route ${diagnosticPath} returned ${status}`,
423
+ path: diagnosticPath,
424
+ source: sourceFor(config, candidate.document)
425
+ })
426
+ );
427
+ return diagnostics;
428
+ }
429
+ if (status >= 400) {
430
+ diagnostics.push(
431
+ createDiagnostic({
432
+ severity: "error",
433
+ code: "CMS-ROUTE-ERROR",
434
+ message: `Route ${diagnosticPath} returned ${status}`,
435
+ path: diagnosticPath,
436
+ source: sourceFor(config, candidate.document)
437
+ })
438
+ );
439
+ }
440
+ return diagnostics;
441
+ });
442
+ return results.flat();
443
+ }
444
+ async function assertSiteReachable(siteUrl, fetchImpl, timeoutMs, retries) {
445
+ try {
446
+ const response = await fetchWithRetries(
447
+ fetchImpl,
448
+ new URL(siteUrl),
449
+ timeoutMs,
450
+ retries
451
+ );
452
+ if (!response.ok) {
453
+ throw new SiteUnreachableError(
454
+ `Site ${siteUrlForDiagnostic(siteUrl)} returned HTTP ${response.status}`
455
+ );
456
+ }
457
+ } catch (error) {
458
+ if (error instanceof SiteUnreachableError) {
459
+ throw error;
460
+ }
461
+ throw new SiteUnreachableError(
462
+ error instanceof Error ? messageFrom(error) : `Site ${siteUrlForDiagnostic(siteUrl)} is unreachable`
463
+ );
464
+ }
465
+ }
466
+ async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
467
+ const controller = new AbortController();
468
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
469
+ try {
470
+ return await fetchImpl(url, { method: "GET", signal: controller.signal });
471
+ } finally {
472
+ clearTimeout(timeout);
473
+ }
474
+ }
475
+ async function fetchWithRetries(fetchImpl, url, timeoutMs, retries) {
476
+ let lastError;
477
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
478
+ try {
479
+ const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
480
+ if (attempt < retries && isRetryableStatus(response.status)) {
481
+ continue;
482
+ }
483
+ return response;
484
+ } catch (error) {
485
+ lastError = error;
486
+ if (attempt >= retries) {
487
+ break;
488
+ }
489
+ }
490
+ }
491
+ throw lastError;
492
+ }
493
+ function checkSeoFields(config, documents) {
494
+ const diagnostics = [];
495
+ const seo = config.checks?.seo;
496
+ const checkMetaTitle = typeof seo === "object" ? seo.metaTitle !== false : true;
497
+ const checkMetaDescription = typeof seo === "object" ? seo.metaDescription !== false : true;
498
+ for (const document of documents) {
499
+ const data = asRecord(document.data);
500
+ if (!data) {
501
+ continue;
502
+ }
503
+ const missing = [];
504
+ if (checkMetaTitle && !hasSeoValue(config, data, "title")) {
505
+ missing.push("meta_title");
506
+ }
507
+ if (checkMetaDescription && !hasSeoValue(config, data, "description")) {
508
+ missing.push("meta_description");
509
+ }
510
+ if (missing.length > 0) {
511
+ diagnostics.push(
512
+ createDiagnostic({
513
+ severity: "warning",
514
+ code: "SEO-META-MISSING",
515
+ message: `Document ${document.id} is missing ${missing.join(", ")}`,
516
+ source: sourceFor(config, document)
517
+ })
518
+ );
519
+ }
520
+ }
521
+ return diagnostics;
522
+ }
523
+ function checkImageAltText(config, documents) {
524
+ const diagnostics = [];
525
+ for (const document of documents) {
526
+ const missingPaths = collectImagesMissingAlt(
527
+ document.data,
528
+ config.cms.provider
529
+ );
530
+ for (const path of missingPaths) {
531
+ diagnostics.push(
532
+ createDiagnostic({
533
+ severity: "warning",
534
+ code: "A11Y-IMG-ALT",
535
+ message: `Image field ${path} is missing useful alt text`,
536
+ source: sourceFor(config, document)
537
+ })
538
+ );
539
+ }
540
+ }
541
+ return diagnostics;
542
+ }
543
+ function checkRequiredFields(config, documents) {
544
+ const rules = requiredFieldRules(config);
545
+ if (rules.length === 0) {
546
+ return [];
547
+ }
548
+ const diagnostics = [];
549
+ for (const document of documents) {
550
+ for (const rule of rules) {
551
+ if (rule.type !== document.type) {
552
+ continue;
553
+ }
554
+ const fullPath = `data.${rule.path}`;
555
+ if (!isMissingFieldValue(readDataPath(document.data, rule.path))) {
556
+ continue;
557
+ }
558
+ diagnostics.push(
559
+ createDiagnostic({
560
+ severity: rule.severity ?? "error",
561
+ code: "CMS-FIELD-MISSING",
562
+ message: `Document ${document.id} is missing required field ${fullPath}`,
563
+ path: fullPath,
564
+ source: sourceFor(config, document)
565
+ })
566
+ );
567
+ }
568
+ }
569
+ return diagnostics;
570
+ }
571
+ function requiredFieldRules(config) {
572
+ const fields = config.checks?.fields;
573
+ if (!fields || typeof fields === "boolean") {
574
+ return [];
575
+ }
576
+ return fields.required ?? [];
577
+ }
578
+ function hasSeoValue(config, data, kind) {
579
+ return seoFieldPaths(config.cms.provider, kind).some(
580
+ (path) => !isBlank(readDataPath(data, path))
581
+ );
582
+ }
583
+ function seoFieldPaths(provider, kind) {
584
+ const common = kind === "title" ? [
585
+ "meta_title",
586
+ "meta.title",
587
+ "seo.title",
588
+ "seo.metaTitle",
589
+ "seo.meta_title",
590
+ "seoTitle",
591
+ "seo_title"
592
+ ] : [
593
+ "meta_description",
594
+ "meta.description",
595
+ "seo.description",
596
+ "seo.metaDescription",
597
+ "seo.meta_description",
598
+ "seoDescription",
599
+ "seo_description"
600
+ ];
601
+ if (provider === "wordpress") {
602
+ return kind === "title" ? [
603
+ ...common,
604
+ "yoast_head_json.title",
605
+ "yoast_head_json.og_title",
606
+ "rank_math_title",
607
+ "_yoast_wpseo_title"
608
+ ] : [
609
+ ...common,
610
+ "yoast_head_json.description",
611
+ "yoast_head_json.og_description",
612
+ "rank_math_description",
613
+ "_yoast_wpseo_metadesc"
614
+ ];
615
+ }
616
+ if (provider === "prismic") {
617
+ return kind === "title" ? [...common, "metaTitle"] : [...common, "metaDescription"];
618
+ }
619
+ return common;
620
+ }
621
+ function collectImagesMissingAlt(value, provider, path = "data") {
622
+ if (Array.isArray(value)) {
623
+ return value.flatMap(
624
+ (item, index) => collectImagesMissingAlt(item, provider, `${path}[${index}]`)
625
+ );
626
+ }
627
+ const record = asRecord(value);
628
+ if (!record) {
629
+ return [];
630
+ }
631
+ const imageAlt = imageAltCandidate(provider, record);
632
+ if (imageAlt.isImage && isBlankOrPlaceholderAlt(imageAlt.value)) {
633
+ return [path];
634
+ }
635
+ return Object.entries(record).flatMap(
636
+ ([key, nested]) => collectImagesMissingAlt(nested, provider, `${path}.${key}`)
637
+ );
638
+ }
639
+ function imageAltCandidate(provider, record) {
640
+ if (typeof record.url === "string" && ("alt" in record || "dimensions" in record)) {
641
+ return { isImage: true, value: record.alt };
642
+ }
643
+ if (provider === "strapi" && typeof record.url === "string" && ("alternativeText" in record || "mime" in record || "formats" in record)) {
644
+ return { isImage: true, value: record.alternativeText };
645
+ }
646
+ if (provider === "wordpress" && typeof record.source_url === "string" && ("alt_text" in record || "media_type" in record || "mime_type" in record)) {
647
+ return { isImage: true, value: record.alt_text };
648
+ }
649
+ if (provider === "directus" && isDirectusImageRecord(record)) {
650
+ return { isImage: true, value: record.description };
651
+ }
652
+ return { isImage: false, value: void 0 };
653
+ }
654
+ function isDirectusImageRecord(record) {
655
+ return typeof record.type === "string" && record.type.startsWith("image/") || hasImageExtension(record.filename_download) || hasImageExtension(record.filename_disk) || hasImageExtension(record.filename);
656
+ }
657
+ function hasImageExtension(value) {
658
+ return typeof value === "string" && /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(value);
659
+ }
660
+ function isCheckEnabled(value, defaultValue) {
661
+ if (typeof value === "boolean") {
662
+ return value;
663
+ }
664
+ return defaultValue;
665
+ }
666
+ function shouldRunCheck(group, config, filters) {
667
+ const only = normalizeFilterList(filters?.only);
668
+ const skip = normalizeFilterList(filters?.skip);
669
+ if (only.length > 0 && !only.includes(group)) {
670
+ return false;
671
+ }
672
+ if (skip.includes(group)) {
673
+ return false;
674
+ }
675
+ if (group === "routes") {
676
+ return isCheckEnabled(config.checks?.routes, true);
677
+ }
678
+ if (group === "seo") {
679
+ return isCheckEnabled(config.checks?.seo, true);
680
+ }
681
+ if (group === "a11y") {
682
+ return isCheckEnabled(config.checks?.a11y, true);
683
+ }
684
+ if (group === "images") {
685
+ return isCheckEnabled(config.checks?.images, true);
686
+ }
687
+ return isCheckEnabled(config.checks?.fields, true);
688
+ }
689
+ function shouldRunImageAltTextCheck(config, filters) {
690
+ if (!isImageAltCheckEnabled(config)) {
691
+ return false;
692
+ }
693
+ const only = normalizeFilterList(filters?.only);
694
+ const skip = normalizeFilterList(filters?.skip);
695
+ if (skip.includes("a11y") || skip.includes("images")) {
696
+ return false;
697
+ }
698
+ if (only.length > 0 && !only.includes("a11y") && !only.includes("images")) {
699
+ return false;
700
+ }
701
+ return true;
702
+ }
703
+ function isImageAltCheckEnabled(config) {
704
+ if (config.checks?.images === false) {
705
+ return false;
706
+ }
707
+ const a11y = config.checks?.a11y;
708
+ if (typeof a11y === "boolean") {
709
+ return a11y;
710
+ }
711
+ if (typeof a11y === "object") {
712
+ return a11y.imgAlt !== false;
713
+ }
714
+ return true;
715
+ }
716
+ function filterDocuments(documents, filters) {
717
+ const types = normalizeFilterList(filters?.types);
718
+ if (types.length === 0) {
719
+ return documents;
720
+ }
721
+ return documents.filter((document) => types.includes(document.type));
722
+ }
723
+ function normalizeFilterList(values) {
724
+ return [
725
+ ...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))
726
+ ];
727
+ }
728
+ function normalizeConcurrency(value) {
729
+ if (typeof value !== "number" || !Number.isFinite(value)) {
730
+ return 8;
731
+ }
732
+ return Math.max(1, Math.floor(value));
733
+ }
734
+ function normalizeRetries(value) {
735
+ if (typeof value !== "number" || !Number.isFinite(value)) {
736
+ return 1;
737
+ }
738
+ return Math.max(0, Math.floor(value));
739
+ }
740
+ function isRetryableStatus(status) {
741
+ return status === 408 || status === 425 || status === 429 || status >= 500;
742
+ }
743
+ function isSiteRelativePath(path) {
744
+ return path.startsWith("/") && !path.startsWith("//");
745
+ }
746
+ function pathForDiagnostic(path) {
747
+ try {
748
+ const url = new URL(path, "https://cms-lab.local");
749
+ return `${url.pathname}${url.search ? "?[redacted]" : ""}`;
750
+ } catch {
751
+ return path;
752
+ }
753
+ }
754
+ function siteUrlForDiagnostic(value) {
755
+ try {
756
+ const url = new URL(value);
757
+ const auth = url.username || url.password ? "[redacted]@" : "";
758
+ const hash = url.hash ? "#[redacted]" : "";
759
+ return `${url.protocol}//${auth}${url.host}${url.pathname}${url.search ? "?[redacted]" : ""}${hash}`;
760
+ } catch {
761
+ return redactSensitive(value);
762
+ }
763
+ }
764
+ async function mapLimit(values, limit, mapper) {
765
+ const results = new Array(values.length);
766
+ let nextIndex = 0;
767
+ async function worker() {
768
+ while (nextIndex < values.length) {
769
+ const index = nextIndex;
770
+ nextIndex += 1;
771
+ results[index] = await mapper(values[index], index);
772
+ }
773
+ }
774
+ const workerCount = Math.min(limit, values.length);
775
+ await Promise.all(
776
+ Array.from({ length: workerCount }, async () => {
777
+ await worker();
778
+ })
779
+ );
780
+ return results;
781
+ }
782
+ function isBlank(value) {
783
+ return typeof value !== "string" || value.trim().length === 0;
784
+ }
785
+ function isMissingFieldValue(value) {
786
+ if (value === void 0 || value === null) {
787
+ return true;
788
+ }
789
+ if (typeof value === "string") {
790
+ return value.trim().length === 0;
791
+ }
792
+ if (Array.isArray(value)) {
793
+ return value.length === 0;
794
+ }
795
+ return false;
796
+ }
797
+ function readDataPath(data, path) {
798
+ let current = data;
799
+ for (const segment of path.split(".")) {
800
+ if (!segment) {
801
+ return void 0;
802
+ }
803
+ const record = asRecord(current);
804
+ if (!record || !(segment in record)) {
805
+ return void 0;
806
+ }
807
+ current = record[segment];
808
+ }
809
+ return current;
810
+ }
811
+ function isBlankOrPlaceholderAlt(value) {
812
+ return isBlank(value) || typeof value === "string" && ["image", "photo", "picture"].includes(value.trim().toLowerCase());
813
+ }
814
+ function asRecord(value) {
815
+ if (value && typeof value === "object" && !Array.isArray(value)) {
816
+ return value;
817
+ }
818
+ return void 0;
819
+ }
820
+ function sourceFor(config, document) {
821
+ return `${config.cms.provider}:${document.type}#${document.id}`;
822
+ }
823
+ function messageFrom(error) {
824
+ return redactSensitive(
825
+ error instanceof Error ? error.message : String(error)
826
+ );
827
+ }
828
+ function redactSensitive(value) {
829
+ return value.replaceAll(/(access_token=)[^&\s]+/gi, "$1[redacted]").replaceAll(/([?&](?:token|password|secret)=)[^&\s]+/gi, "$1[redacted]").replaceAll(/\bBearer\s+[-._~+/=a-z0-9]+/gi, "Bearer [redacted]").replaceAll(/(https?:\/\/)([^:\s/@]+):([^@\s/]+)@/gi, "$1[redacted]@");
830
+ }
831
+ export {
832
+ CmsFetchError,
833
+ CmsLabError,
834
+ ConfigLoadError,
835
+ SiteUnreachableError,
836
+ createDiagnostic,
837
+ defineConfig,
838
+ explainDiagnostic,
839
+ listDiagnosticExplanations,
840
+ loadCmsLabConfig,
841
+ scanDocuments,
842
+ summarizeDiagnostics,
843
+ validateConfig
844
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@cms-lab/core",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Core config, scan, diagnostics, and checks for cms-lab.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/i-afaqrashid/cms-lab.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "homepage": "https://cms-lab.dev",
13
+ "bugs": {
14
+ "url": "https://github.com/i-afaqrashid/cms-lab/issues"
15
+ },
16
+ "keywords": [
17
+ "cms",
18
+ "nextjs",
19
+ "prismic",
20
+ "testing"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20.10"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "c12": "^3.3.0",
40
+ "zod": "^4.1.13"
41
+ },
42
+ "author": "Afaq Rashid",
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm --dts --clean --tsconfig ../../tsconfig.base.json"
45
+ }
46
+ }