Skip to content

Commit 42ac466

Browse files
authored
[experimental] Add Strapi 12 Rules automated documentation validation (#2556)
* feat: Add Strapi 12 Rules automated documentation validation - Implement automated style checking based on Strapi's 12 Rules of Technical Writing - Critical rules block PRs: procedures in numbered lists, no easy/difficult, professional tone - GitHub Action comments PRs with specific violations and suggestions - Support for all content, structure, and formatting rules * Remove workflow file * Delete docusaurus/docs/test-file.md * Delete docusaurus/style-check-results.json
1 parent 254aa5c commit 42ac466

File tree

5 files changed

+1027
-0
lines changed

5 files changed

+1027
-0
lines changed

docusaurus/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
"embla-carousel-react": "^7.1.0",
4141
"embla-carousel-wheel-gestures": "^3.0.0",
4242
"fs-extra": "^11.3.0",
43+
"glob": "^11.0.3",
4344
"gray-matter": "^4.0.3",
45+
"js-yaml": "^4.1.0",
4446
"prism-react-renderer": "^2.1.0",
4547
"qs": "^6.11.1",
4648
"react": "^18.2.0",
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const yaml = require('js-yaml');
6+
7+
/**
8+
* Simplified Rule Parser for Strapi's 12 Rules of Technical Writing
9+
* Focused on the most critical validations for quick implementation
10+
*/
11+
class Strapi12RulesParser {
12+
constructor(configPath) {
13+
this.config = this.loadConfig(configPath);
14+
this.parsedRules = [];
15+
this.parseRules();
16+
}
17+
18+
loadConfig(configPath) {
19+
try {
20+
const fileContents = fs.readFileSync(configPath, 'utf8');
21+
return yaml.load(fileContents);
22+
} catch (error) {
23+
throw new Error(`Failed to load configuration: ${error.message}`);
24+
}
25+
}
26+
27+
parseRules() {
28+
// Parse the most critical rules first
29+
this.parseCriticalRules();
30+
this.parseContentRules();
31+
this.parseStructureRules();
32+
}
33+
34+
parseCriticalRules() {
35+
const criticalRules = this.config.critical_violations;
36+
if (!criticalRules) return;
37+
38+
Object.entries(criticalRules).forEach(([ruleKey, ruleConfig]) => {
39+
if (!ruleConfig.enabled) return;
40+
41+
const rule = this.createCriticalRule(ruleKey, ruleConfig);
42+
if (rule) this.parsedRules.push(rule);
43+
});
44+
}
45+
46+
createCriticalRule(ruleKey, config) {
47+
switch (ruleKey) {
48+
case 'procedures_must_be_numbered':
49+
return {
50+
id: ruleKey,
51+
category: 'critical',
52+
description: config.rule,
53+
severity: 'error',
54+
validator: (content, filePath) => {
55+
const errors = [];
56+
57+
// Detect procedure indicators
58+
const procedurePatterns = [
59+
/follow these steps/gi,
60+
/to do this/gi,
61+
/procedure/gi,
62+
/instructions/gi,
63+
/how to.*:/gi,
64+
/steps to/gi,
65+
/first.*then.*next/gi,
66+
/1\..*2\..*3\./g // Already has numbers - this is good!
67+
];
68+
69+
const hasProceduralContent = procedurePatterns.some(pattern =>
70+
pattern.test(content) && !/1\..*2\..*3\./.test(content)
71+
);
72+
73+
if (hasProceduralContent) {
74+
// Check if content has numbered lists
75+
const hasNumberedLists = /^\d+\.\s+/gm.test(content);
76+
77+
if (!hasNumberedLists) {
78+
const lineNumber = this.findLineWithPattern(content, procedurePatterns);
79+
errors.push({
80+
file: filePath,
81+
line: lineNumber,
82+
message: 'CRITICAL: Step-by-step instructions must use numbered lists (Rule 7)',
83+
severity: 'error',
84+
rule: 'procedures_must_be_numbered',
85+
suggestion: 'Convert instructions to numbered list format:\n1. First action\n2. Second action\n3. Third action'
86+
});
87+
}
88+
}
89+
90+
return errors;
91+
}
92+
};
93+
94+
case 'easy_difficult_words':
95+
return {
96+
id: ruleKey,
97+
category: 'critical',
98+
description: config.rule,
99+
severity: 'error',
100+
validator: (content, filePath) => {
101+
const errors = [];
102+
const forbiddenWords = config.words;
103+
const lines = content.split('\n');
104+
105+
forbiddenWords.forEach(word => {
106+
lines.forEach((line, index) => {
107+
const regex = new RegExp(`\\b${word}\\b`, 'gi');
108+
if (regex.test(line)) {
109+
errors.push({
110+
file: filePath,
111+
line: index + 1,
112+
message: `CRITICAL: Never use "${word}" - it can discourage readers (Rule 6)`,
113+
severity: 'error',
114+
rule: 'easy_difficult_words',
115+
suggestion: 'Remove subjective difficulty assessment and provide clear instructions instead'
116+
});
117+
}
118+
});
119+
});
120+
121+
return errors;
122+
}
123+
};
124+
125+
case 'jokes_and_casual_tone':
126+
return {
127+
id: ruleKey,
128+
category: 'critical',
129+
description: config.rule,
130+
severity: 'error',
131+
validator: (content, filePath) => {
132+
const errors = [];
133+
const casualPatterns = config.patterns;
134+
135+
casualPatterns.forEach(pattern => {
136+
const regex = new RegExp(pattern, 'gi');
137+
let match;
138+
139+
while ((match = regex.exec(content)) !== null) {
140+
const lineNumber = content.substring(0, match.index).split('\n').length;
141+
errors.push({
142+
file: filePath,
143+
line: lineNumber,
144+
message: 'CRITICAL: Maintain professional tone - avoid casual language (Rule 3)',
145+
severity: 'error',
146+
rule: 'jokes_and_casual_tone',
147+
suggestion: 'Use professional, neutral language in technical documentation'
148+
});
149+
}
150+
});
151+
152+
return errors;
153+
}
154+
};
155+
156+
default:
157+
return null;
158+
}
159+
}
160+
161+
parseContentRules() {
162+
const contentRules = this.config.content_rules;
163+
if (!contentRules) return;
164+
165+
Object.entries(contentRules).forEach(([ruleKey, ruleConfig]) => {
166+
if (!ruleConfig.enabled) return;
167+
168+
const rule = this.createContentRule(ruleKey, ruleConfig);
169+
if (rule) this.parsedRules.push(rule);
170+
});
171+
}
172+
173+
createContentRule(ruleKey, config) {
174+
switch (ruleKey) {
175+
case 'minimize_pronouns':
176+
return {
177+
id: ruleKey,
178+
category: 'content',
179+
description: config.rule,
180+
severity: config.severity,
181+
validator: (content, filePath) => {
182+
const errors = [];
183+
const pronouns = config.discouraged_pronouns;
184+
const lines = content.split('\n');
185+
186+
lines.forEach((line, index) => {
187+
let pronounCount = 0;
188+
pronouns.forEach(pronoun => {
189+
const regex = new RegExp(`\\b${pronoun}\\b`, 'gi');
190+
const matches = line.match(regex);
191+
if (matches) pronounCount += matches.length;
192+
});
193+
194+
if (pronounCount > (config.max_pronouns_per_paragraph || 3)) {
195+
errors.push({
196+
file: filePath,
197+
line: index + 1,
198+
message: `Too many pronouns (${pronounCount}) - avoid "you/we" in technical docs (Rule 11)`,
199+
severity: config.severity,
200+
rule: ruleKey,
201+
suggestion: 'Focus on actions and explanations rather than addressing the reader directly'
202+
});
203+
}
204+
});
205+
206+
return errors;
207+
}
208+
};
209+
210+
case 'simple_english_vocabulary':
211+
return {
212+
id: ruleKey,
213+
category: 'content',
214+
description: config.rule,
215+
severity: config.severity,
216+
validator: (content, filePath) => {
217+
const errors = [];
218+
const complexWords = config.complex_words || [];
219+
const replacements = config.replacement_suggestions || {};
220+
221+
complexWords.forEach(word => {
222+
const regex = new RegExp(`\\b${word}\\b`, 'gi');
223+
let match;
224+
225+
while ((match = regex.exec(content)) !== null) {
226+
const lineNumber = content.substring(0, match.index).split('\n').length;
227+
const suggestion = replacements[word] ?
228+
`Use "${replacements[word]}" instead of "${word}"` :
229+
`Use simpler language instead of "${word}"`;
230+
231+
errors.push({
232+
file: filePath,
233+
line: lineNumber,
234+
message: `Complex word detected: "${word}" - stick to simple English (Rule 4)`,
235+
severity: config.severity,
236+
rule: ruleKey,
237+
suggestion: suggestion
238+
});
239+
}
240+
});
241+
242+
return errors;
243+
}
244+
};
245+
246+
default:
247+
return null;
248+
}
249+
}
250+
251+
parseStructureRules() {
252+
const structureRules = this.config.structure_rules;
253+
if (!structureRules) return;
254+
255+
Object.entries(structureRules).forEach(([ruleKey, ruleConfig]) => {
256+
if (!ruleConfig.enabled) return;
257+
258+
const rule = this.createStructureRule(ruleKey, ruleConfig);
259+
if (rule) this.parsedRules.push(rule);
260+
});
261+
}
262+
263+
createStructureRule(ruleKey, config) {
264+
switch (ruleKey) {
265+
case 'use_bullet_lists':
266+
return {
267+
id: ruleKey,
268+
category: 'structure',
269+
description: config.rule,
270+
severity: config.severity,
271+
validator: (content, filePath) => {
272+
const errors = [];
273+
274+
// Detect inline enumerations like "features include A, B, C, and D"
275+
const enumerationPattern = /(\w+\s+(include|are|consists?\s+of))?\s*([A-Za-z]+,\s*[A-Za-z]+,\s*(and\s+)?[A-Za-z]+)/gi;
276+
let match;
277+
278+
while ((match = enumerationPattern.exec(content)) !== null) {
279+
const lineNumber = content.substring(0, match.index).split('\n').length;
280+
281+
// Count items in enumeration
282+
const items = match[3].split(',').length;
283+
284+
if (items >= (config.max_inline_list_items || 3)) {
285+
errors.push({
286+
file: filePath,
287+
line: lineNumber,
288+
message: `Long enumeration detected (${items} items) - use bullet list instead (Rule 8)`,
289+
severity: config.severity,
290+
rule: ruleKey,
291+
suggestion: 'Convert to bullet list format:\n- Item 1\n- Item 2\n- Item 3'
292+
});
293+
}
294+
}
295+
296+
return errors;
297+
}
298+
};
299+
300+
default:
301+
return null;
302+
}
303+
}
304+
305+
// Helper method to find line number for patterns
306+
findLineWithPattern(content, patterns) {
307+
const lines = content.split('\n');
308+
309+
for (let i = 0; i < lines.length; i++) {
310+
const line = lines[i];
311+
for (const pattern of patterns) {
312+
if (pattern.test(line)) {
313+
return i + 1;
314+
}
315+
}
316+
}
317+
318+
return 1; // Default to first line if not found
319+
}
320+
321+
// Get all parsed rules
322+
getAllRules() {
323+
return this.parsedRules;
324+
}
325+
326+
// Get rules by category
327+
getRulesByCategory(category) {
328+
return this.parsedRules.filter(rule => rule.category === category);
329+
}
330+
331+
// Get critical rules only
332+
getCriticalRules() {
333+
return this.getRulesByCategory('critical');
334+
}
335+
}
336+
337+
module.exports = Strapi12RulesParser;

0 commit comments

Comments
 (0)