Skip to content

Commit 5651270

Browse files
committed
feat(nx-dev): add conformance rule to verify blog post cover images
1 parent 6610f3d commit 5651270

File tree

3 files changed

+170
-0
lines changed

3 files changed

+170
-0
lines changed

nx.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,13 @@
263263
"mdGlobPattern": "{blog,shared}/**/!(sitemap).md"
264264
}
265265
},
266+
{
267+
"rule": "@nx/workspace-plugin/conformance-rules/blog-cover-image",
268+
"projects": ["docs"],
269+
"options": {
270+
"mdGlobPattern": "blog/**/!(sitemap).md"
271+
}
272+
},
266273
{
267274
"rule": "@nx/workspace-plugin/conformance-rules/project-package-json",
268275
"projects": [
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { readFileSync, existsSync } from 'node:fs';
2+
import { join, dirname, basename, extname } from 'node:path';
3+
import { load as yamlLoad } from 'js-yaml';
4+
import { workspaceRoot } from '@nx/devkit';
5+
import { sync as globSync } from 'glob';
6+
import {
7+
createConformanceRule,
8+
type ProjectFilesViolation,
9+
} from '@nx/powerpack-conformance';
10+
11+
export default createConformanceRule<{ mdGlobPattern: string }>({
12+
name: 'blog-cover-image',
13+
category: 'consistency',
14+
description:
15+
'Ensures that blog posts have a cover_image defined in avif or jpg format with appropriate fallbacks',
16+
reporter: 'project-files-reporter',
17+
implementation: async ({ projectGraph, ruleOptions }) => {
18+
const violations: ProjectFilesViolation[] = [];
19+
const webinarWarnings: ProjectFilesViolation[] = [];
20+
const { mdGlobPattern } = ruleOptions;
21+
22+
// Look for the docs project
23+
const docsProject = Object.values(projectGraph.nodes).find(
24+
(project) => project.name === 'docs'
25+
);
26+
27+
if (!docsProject) {
28+
return {
29+
severity: 'low',
30+
details: {
31+
violations: [],
32+
},
33+
};
34+
}
35+
36+
const blogPattern = join(
37+
workspaceRoot,
38+
docsProject.data.root,
39+
mdGlobPattern
40+
);
41+
42+
// find markdown files
43+
const files = globSync(blogPattern);
44+
45+
for (const file of files) {
46+
const content = readFileSync(file, 'utf-8');
47+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
48+
49+
if (!frontmatterMatch) {
50+
//ignore missing frontmatter for now
51+
continue;
52+
}
53+
54+
try {
55+
const frontmatter = yamlLoad(frontmatterMatch[1]) as Record<
56+
string,
57+
unknown
58+
>;
59+
60+
// Check for webinar tag as we ignore webinars for now (they're pulled in via the Notion API)
61+
const isWebinar =
62+
Array.isArray(frontmatter.tags) &&
63+
frontmatter.tags.includes('webinar');
64+
65+
const coverImagePath = frontmatter.cover_image as string;
66+
const fileExtension = extname(coverImagePath).toLowerCase();
67+
68+
if (fileExtension !== '.avif' && fileExtension !== '.jpg') {
69+
const message = 'Blog post cover_image must be in avif or jpg format';
70+
if (isWebinar) {
71+
webinarWarnings.push({
72+
message: `[Webinar] ${message}`,
73+
sourceProject: docsProject.name,
74+
file: file,
75+
});
76+
} else {
77+
violations.push({
78+
message,
79+
sourceProject: docsProject.name,
80+
file: file,
81+
});
82+
}
83+
continue;
84+
}
85+
86+
// Adjust the image path for proper resolution
87+
// For paths starting with /blog/, we need to look in docs/blog/images/
88+
let absoluteImagePath: string;
89+
if (coverImagePath.startsWith('/blog/')) {
90+
const adjustedPath = coverImagePath.replace(/^\/blog\//, '/');
91+
absoluteImagePath = join(
92+
workspaceRoot,
93+
docsProject.data.root,
94+
'blog',
95+
adjustedPath
96+
);
97+
} else {
98+
// For any other paths, use the as-is path
99+
absoluteImagePath = join(workspaceRoot, coverImagePath);
100+
}
101+
102+
// Check if the image file exists
103+
if (!existsSync(absoluteImagePath)) {
104+
const message = `Cover image file does not exist: ${coverImagePath} (resolved to ${absoluteImagePath})`;
105+
if (isWebinar) {
106+
webinarWarnings.push({
107+
message: `[Webinar] ${message}`,
108+
sourceProject: docsProject.name,
109+
file: file,
110+
});
111+
} else {
112+
violations.push({
113+
message,
114+
sourceProject: docsProject.name,
115+
file: file,
116+
});
117+
}
118+
continue;
119+
}
120+
121+
// If it's an AVIF image, check if there's a JPG equivalent
122+
if (fileExtension === '.avif' && !isWebinar) {
123+
if (
124+
!existsSync(absoluteImagePath.replace('.avif', '.jpg')) &&
125+
!existsSync(absoluteImagePath.replace('.avif', '.png')) &&
126+
!existsSync(absoluteImagePath.replace('.avif', '.webp'))
127+
) {
128+
violations.push({
129+
message: `AVIF cover image must have a JPG equivalent to be accepted as a valid OG image: ${coverImagePath.replace(
130+
'.avif',
131+
'.jpg'
132+
)}`,
133+
sourceProject: docsProject.name,
134+
file: file,
135+
});
136+
}
137+
}
138+
} catch (e) {
139+
// If YAML parsing fails, we skip the file
140+
continue;
141+
}
142+
}
143+
144+
// Return violations with appropriate severity level
145+
return {
146+
severity: violations.length > 0 ? 'high' : 'low',
147+
details: {
148+
violations: [...violations, ...webinarWarnings],
149+
},
150+
};
151+
},
152+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"properties": {
5+
"mdGlobPattern": {
6+
"type": "string",
7+
"description": "The glob pattern to use to find the markdown files to analyze"
8+
}
9+
},
10+
"additionalProperties": false
11+
}

0 commit comments

Comments
 (0)