@cms-lab/core 1.2.4 → 1.2.6
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/README.md +4 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +183 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,10 @@ routes: [
|
|
|
40
40
|
probe hit a known-good page or endpoint while normal route probes still use
|
|
41
41
|
`site.url`.
|
|
42
42
|
|
|
43
|
+
`checks.relationships` supports narrow equality joins across normalized
|
|
44
|
+
documents, for example requiring each `menu_item` to have at least one matching
|
|
45
|
+
`pricing` row by `id -> menu_item_id`.
|
|
46
|
+
|
|
43
47
|
## Release History
|
|
44
48
|
|
|
45
49
|
See the repository [changelog](https://github.com/i-afaqrashid/cms-lab/blob/main/CHANGELOG.md)
|
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,16 @@ type RequiredFieldRule = {
|
|
|
41
41
|
path: string;
|
|
42
42
|
severity?: "error" | "warning";
|
|
43
43
|
};
|
|
44
|
+
type RelationshipRule = {
|
|
45
|
+
from: string;
|
|
46
|
+
to: string;
|
|
47
|
+
where: {
|
|
48
|
+
fromField: string;
|
|
49
|
+
toField: string;
|
|
50
|
+
};
|
|
51
|
+
min?: number;
|
|
52
|
+
severity?: DiagnosticSeverity;
|
|
53
|
+
};
|
|
44
54
|
type PrismicCmsProviderConfig = {
|
|
45
55
|
provider: "prismic";
|
|
46
56
|
repositoryName: string;
|
|
@@ -142,6 +152,7 @@ type CmsLabConfig = {
|
|
|
142
152
|
fields?: boolean | {
|
|
143
153
|
required?: RequiredFieldRule[];
|
|
144
154
|
};
|
|
155
|
+
relationships?: RelationshipRule[];
|
|
145
156
|
};
|
|
146
157
|
};
|
|
147
158
|
type ScanSummary = {
|
|
@@ -149,14 +160,25 @@ type ScanSummary = {
|
|
|
149
160
|
warnings: number;
|
|
150
161
|
info: number;
|
|
151
162
|
};
|
|
163
|
+
type DiagnosticGroupSummary = {
|
|
164
|
+
key: string;
|
|
165
|
+
severity: DiagnosticSeverity;
|
|
166
|
+
code: string;
|
|
167
|
+
count: number;
|
|
168
|
+
label: string;
|
|
169
|
+
type?: string;
|
|
170
|
+
routePattern?: string;
|
|
171
|
+
examples: string[];
|
|
172
|
+
};
|
|
152
173
|
type ScanResult = {
|
|
153
174
|
project: ProjectInfo;
|
|
154
175
|
documents: CMSDocument[];
|
|
155
176
|
diagnostics: Diagnostic[];
|
|
177
|
+
diagnosticGroups?: DiagnosticGroupSummary[];
|
|
156
178
|
summary: ScanSummary;
|
|
157
179
|
};
|
|
158
180
|
type FetchLike = typeof fetch;
|
|
159
|
-
type CheckGroup = "routes" | "seo" | "a11y" | "images" | "fields";
|
|
181
|
+
type CheckGroup = "routes" | "seo" | "a11y" | "images" | "fields" | "relationships";
|
|
160
182
|
type ScanFilters = {
|
|
161
183
|
types?: string[];
|
|
162
184
|
only?: CheckGroup[];
|
|
@@ -211,4 +233,4 @@ type ScanDocumentsOptions = {
|
|
|
211
233
|
declare function scanDocuments(options: ScanDocumentsOptions): Promise<ScanResult>;
|
|
212
234
|
declare function resolveSiteHealthUrl(site: CmsLabConfig["site"]): URL;
|
|
213
235
|
|
|
214
|
-
export { type CMSDocument, type CMSDocumentStatus, type CheckGroup, CmsFetchError, type CmsFieldMappingConfig, type CmsLabConfig, CmsLabError, type CmsProviderConfig, ConfigLoadError, type ContentfulCmsProviderConfig, type ContentfulContentTypeConfig, type Diagnostic, type DiagnosticExplanation, type DiagnosticSeverity, type DirectusCmsProviderConfig, type DirectusCollectionConfig, type FetchLike, type LoadedCmsLabConfig, type PrismicCmsProviderConfig, type ProjectInfo, type RequiredFieldRule, type RouteDefinition, type SanityCmsProviderConfig, type SanityContentTypeConfig, type ScanDocumentsOptions, type ScanFilters, type ScanResult, type ScanSummary, SiteUnreachableError, type StrapiCmsProviderConfig, type StrapiCollectionConfig, type StrapiLocaleConfig, type StrapiSingleTypeConfig, type WordPressCmsProviderConfig, type WordPressContentTypeConfig, createDiagnostic, defineConfig, explainDiagnostic, listDiagnosticExplanations, loadCmsLabConfig, readCmsDataPath, resolveSiteHealthUrl, scanDocuments, strapiRelationSlug, strapiRelationValue, summarizeDiagnostics, validateConfig };
|
|
236
|
+
export { type CMSDocument, type CMSDocumentStatus, type CheckGroup, CmsFetchError, type CmsFieldMappingConfig, type CmsLabConfig, CmsLabError, type CmsProviderConfig, ConfigLoadError, type ContentfulCmsProviderConfig, type ContentfulContentTypeConfig, type Diagnostic, type DiagnosticExplanation, type DiagnosticGroupSummary, type DiagnosticSeverity, type DirectusCmsProviderConfig, type DirectusCollectionConfig, type FetchLike, type LoadedCmsLabConfig, type PrismicCmsProviderConfig, type ProjectInfo, type RelationshipRule, type RequiredFieldRule, type RouteDefinition, type SanityCmsProviderConfig, type SanityContentTypeConfig, type ScanDocumentsOptions, type ScanFilters, type ScanResult, type ScanSummary, SiteUnreachableError, type StrapiCmsProviderConfig, type StrapiCollectionConfig, type StrapiLocaleConfig, type StrapiSingleTypeConfig, type WordPressCmsProviderConfig, type WordPressContentTypeConfig, createDiagnostic, defineConfig, explainDiagnostic, listDiagnosticExplanations, loadCmsLabConfig, readCmsDataPath, resolveSiteHealthUrl, scanDocuments, strapiRelationSlug, strapiRelationValue, summarizeDiagnostics, validateConfig };
|
package/dist/index.js
CHANGED
|
@@ -143,6 +143,16 @@ var requiredFieldRuleSchema = z.object({
|
|
|
143
143
|
path: z.string().min(1),
|
|
144
144
|
severity: z.enum(["error", "warning"]).optional()
|
|
145
145
|
});
|
|
146
|
+
var relationshipRuleSchema = z.object({
|
|
147
|
+
from: z.string().min(1),
|
|
148
|
+
to: z.string().min(1),
|
|
149
|
+
where: z.object({
|
|
150
|
+
fromField: z.string().min(1),
|
|
151
|
+
toField: z.string().min(1)
|
|
152
|
+
}).strict(),
|
|
153
|
+
min: z.number().int().min(0).optional(),
|
|
154
|
+
severity: z.enum(["error", "warning", "info"]).optional()
|
|
155
|
+
}).strict();
|
|
146
156
|
var checksSchema = z.object({
|
|
147
157
|
routes: z.boolean().optional(),
|
|
148
158
|
seo: z.union([
|
|
@@ -162,7 +172,8 @@ var checksSchema = z.object({
|
|
|
162
172
|
z.object({
|
|
163
173
|
required: z.array(requiredFieldRuleSchema).optional()
|
|
164
174
|
})
|
|
165
|
-
]).optional()
|
|
175
|
+
]).optional(),
|
|
176
|
+
relationships: z.array(relationshipRuleSchema).optional()
|
|
166
177
|
}).strict().optional();
|
|
167
178
|
var configSchema = z.object({
|
|
168
179
|
site: z.object({
|
|
@@ -330,6 +341,13 @@ var explanations = [
|
|
|
330
341
|
title: "Required CMS field is missing",
|
|
331
342
|
meaning: "A document is missing a required field declared in cms-lab config.",
|
|
332
343
|
fix: "Fill the required field in the CMS, or update checks.fields.required if the field is no longer required."
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
code: "CMS-RELATIONSHIP-MISSING",
|
|
347
|
+
severity: "warning",
|
|
348
|
+
title: "CMS relationship rule did not match enough records",
|
|
349
|
+
meaning: "A relationship rule expected a document to have one or more matching related records, but cms-lab found fewer than the configured minimum.",
|
|
350
|
+
fix: "Check the related CMS collection, the join fields in checks.relationships, and whether the related content should be published or active."
|
|
333
351
|
}
|
|
334
352
|
];
|
|
335
353
|
function explainDiagnostic(code) {
|
|
@@ -418,10 +436,18 @@ async function scanDocuments(options) {
|
|
|
418
436
|
if (shouldRunCheck("fields", options.config, options.filters)) {
|
|
419
437
|
diagnostics.push(...checkRequiredFields(options.config, documents));
|
|
420
438
|
}
|
|
439
|
+
if (shouldRunCheck("relationships", options.config, options.filters)) {
|
|
440
|
+
diagnostics.push(...checkRelationships(options.config, documents));
|
|
441
|
+
}
|
|
421
442
|
return {
|
|
422
443
|
project: options.project,
|
|
423
444
|
documents,
|
|
424
445
|
diagnostics,
|
|
446
|
+
diagnosticGroups: summarizeDiagnosticGroups(
|
|
447
|
+
diagnostics,
|
|
448
|
+
options.config,
|
|
449
|
+
routeCandidates
|
|
450
|
+
),
|
|
425
451
|
summary: summarizeDiagnostics(diagnostics)
|
|
426
452
|
};
|
|
427
453
|
}
|
|
@@ -709,6 +735,159 @@ function requiredFieldRules(config) {
|
|
|
709
735
|
}
|
|
710
736
|
return fields.required ?? [];
|
|
711
737
|
}
|
|
738
|
+
function checkRelationships(config, documents) {
|
|
739
|
+
const rules = relationshipRules(config);
|
|
740
|
+
if (rules.length === 0) {
|
|
741
|
+
return [];
|
|
742
|
+
}
|
|
743
|
+
const documentsByType = /* @__PURE__ */ new Map();
|
|
744
|
+
for (const document of documents) {
|
|
745
|
+
documentsByType.set(document.type, [
|
|
746
|
+
...documentsByType.get(document.type) ?? [],
|
|
747
|
+
document
|
|
748
|
+
]);
|
|
749
|
+
}
|
|
750
|
+
const diagnostics = [];
|
|
751
|
+
for (const rule of rules) {
|
|
752
|
+
const min = rule.min ?? 1;
|
|
753
|
+
const targets = documentsByType.get(rule.to) ?? [];
|
|
754
|
+
for (const document of documentsByType.get(rule.from) ?? []) {
|
|
755
|
+
const fromValues = relationshipValues(document, rule.where.fromField);
|
|
756
|
+
const matchCount = targets.filter(
|
|
757
|
+
(target) => hasRelationshipMatch(
|
|
758
|
+
fromValues,
|
|
759
|
+
relationshipValues(target, rule.where.toField)
|
|
760
|
+
)
|
|
761
|
+
).length;
|
|
762
|
+
if (matchCount >= min) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
diagnostics.push(
|
|
766
|
+
createDiagnostic({
|
|
767
|
+
severity: rule.severity ?? "warning",
|
|
768
|
+
code: "CMS-RELATIONSHIP-MISSING",
|
|
769
|
+
message: `Document ${document.id} of type ${document.type} has ${matchCount} ${rule.to} records matching ${rule.where.fromField} -> ${rule.where.toField}; expected at least ${min}`,
|
|
770
|
+
path: `relationships.${rule.from}.${rule.to}`,
|
|
771
|
+
source: sourceFor(config, document)
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return diagnostics;
|
|
777
|
+
}
|
|
778
|
+
function relationshipRules(config) {
|
|
779
|
+
return config.checks?.relationships ?? [];
|
|
780
|
+
}
|
|
781
|
+
function summarizeDiagnosticGroups(diagnostics, config, routeCandidates) {
|
|
782
|
+
const routePatternBySource = new Map(
|
|
783
|
+
routeCandidates.map((candidate) => [
|
|
784
|
+
sourceFor(config, candidate.document),
|
|
785
|
+
candidate.route.pattern
|
|
786
|
+
])
|
|
787
|
+
);
|
|
788
|
+
const groups = /* @__PURE__ */ new Map();
|
|
789
|
+
for (const diagnostic of diagnostics) {
|
|
790
|
+
const type = typeFromSource(diagnostic.source);
|
|
791
|
+
const routePattern = routePatternForDiagnostic(
|
|
792
|
+
diagnostic,
|
|
793
|
+
type,
|
|
794
|
+
config,
|
|
795
|
+
routePatternBySource
|
|
796
|
+
);
|
|
797
|
+
const key = diagnosticGroupKey(diagnostic, type, routePattern);
|
|
798
|
+
const existing = groups.get(key);
|
|
799
|
+
const example = diagnosticExample(diagnostic);
|
|
800
|
+
if (existing) {
|
|
801
|
+
existing.count += 1;
|
|
802
|
+
if (example && !existing.examples.includes(example)) {
|
|
803
|
+
existing.examples.push(example);
|
|
804
|
+
}
|
|
805
|
+
existing.examples = existing.examples.slice(0, 3);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
groups.set(key, {
|
|
809
|
+
key,
|
|
810
|
+
severity: diagnostic.severity,
|
|
811
|
+
code: diagnostic.code,
|
|
812
|
+
count: 1,
|
|
813
|
+
...type ? { type } : {},
|
|
814
|
+
...routePattern ? { routePattern } : {},
|
|
815
|
+
label: diagnosticGroupLabel(type, routePattern),
|
|
816
|
+
examples: example ? [example] : []
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
return [...groups.values()];
|
|
820
|
+
}
|
|
821
|
+
function routePatternForDiagnostic(diagnostic, type, config, routePatternBySource) {
|
|
822
|
+
if (!isRouteDiagnostic(diagnostic.code) && diagnostic.code !== "CMS-UID-MISSING") {
|
|
823
|
+
return void 0;
|
|
824
|
+
}
|
|
825
|
+
if (diagnostic.source) {
|
|
826
|
+
const routePattern = routePatternBySource.get(diagnostic.source);
|
|
827
|
+
if (routePattern) {
|
|
828
|
+
return routePattern;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return type ? config.routes.find((route) => route.type === type)?.pattern : void 0;
|
|
832
|
+
}
|
|
833
|
+
function isRouteDiagnostic(code) {
|
|
834
|
+
return code.startsWith("CMS-ROUTE");
|
|
835
|
+
}
|
|
836
|
+
function diagnosticGroupKey(diagnostic, type, routePattern) {
|
|
837
|
+
return [diagnostic.severity, diagnostic.code, type, routePattern].filter(Boolean).join(":");
|
|
838
|
+
}
|
|
839
|
+
function diagnosticGroupLabel(type, routePattern) {
|
|
840
|
+
if (type && routePattern) {
|
|
841
|
+
return `${type} ${routePattern}`;
|
|
842
|
+
}
|
|
843
|
+
return type ?? "project";
|
|
844
|
+
}
|
|
845
|
+
function diagnosticExample(diagnostic) {
|
|
846
|
+
return diagnostic.path ?? diagnostic.source ?? diagnostic.message;
|
|
847
|
+
}
|
|
848
|
+
function typeFromSource(source) {
|
|
849
|
+
if (!source) {
|
|
850
|
+
return void 0;
|
|
851
|
+
}
|
|
852
|
+
const match = /^[^:]+:([^#]+)#/.exec(source);
|
|
853
|
+
return match?.[1];
|
|
854
|
+
}
|
|
855
|
+
function hasRelationshipMatch(fromValues, toValues) {
|
|
856
|
+
if (fromValues.length === 0 || toValues.length === 0) {
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
const targetValues = new Set(toValues);
|
|
860
|
+
return fromValues.some((value) => targetValues.has(value));
|
|
861
|
+
}
|
|
862
|
+
function relationshipValues(document, path) {
|
|
863
|
+
const dataValue = readCmsDataPath(document.data, path);
|
|
864
|
+
if (dataValue !== void 0) {
|
|
865
|
+
return normalizeRelationshipValues(dataValue);
|
|
866
|
+
}
|
|
867
|
+
return normalizeRelationshipValues(
|
|
868
|
+
document[path]
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
function normalizeRelationshipValues(value) {
|
|
872
|
+
if (value === void 0 || value === null) {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
if (Array.isArray(value)) {
|
|
876
|
+
return value.flatMap((item) => normalizeRelationshipValues(item));
|
|
877
|
+
}
|
|
878
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
879
|
+
return [String(value)];
|
|
880
|
+
}
|
|
881
|
+
const record = asRecord3(value);
|
|
882
|
+
if (!record) {
|
|
883
|
+
return [];
|
|
884
|
+
}
|
|
885
|
+
return [
|
|
886
|
+
...normalizeRelationshipValues(record.id),
|
|
887
|
+
...normalizeRelationshipValues(record.uid),
|
|
888
|
+
...normalizeRelationshipValues(record.slug)
|
|
889
|
+
];
|
|
890
|
+
}
|
|
712
891
|
function hasSeoValue(config, data, kind) {
|
|
713
892
|
return seoFieldPaths(config.cms.provider, kind).some(
|
|
714
893
|
(path) => !isBlank(readCmsDataPath(data, path))
|
|
@@ -836,6 +1015,9 @@ function shouldRunCheck(group, config, filters) {
|
|
|
836
1015
|
if (group === "images") {
|
|
837
1016
|
return isCheckEnabled(config.checks?.images, true);
|
|
838
1017
|
}
|
|
1018
|
+
if (group === "relationships") {
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
839
1021
|
return isCheckEnabled(config.checks?.fields, true);
|
|
840
1022
|
}
|
|
841
1023
|
function shouldRunImageAltTextCheck(config, filters) {
|