@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 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cms-lab/core",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "type": "module",
5
5
  "description": "Core config, scan, diagnostics, and checks for cms-lab.",
6
6
  "license": "MIT",