Passed
Branch master (0511c6)
by Caen
06:24 queued 03:11
created

find_markdown_files()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 8
nc 4
nop 1
dl 0
loc 17
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @internal
7
 */
8
9
use Illuminate\Support\Str;
10
11
require_once __DIR__.'/../../../vendor/autoload.php';
12
13
$timeStart = microtime(true);
14
15
$filesChanged = 0;
16
17
$linesCounted = 0;
18
19
$links = [];
20
21
$warnings = [];
22
23
// Buffer headings so we can check for style
24
$headings = []; // [filename => [line => heading]]
25
$checksHeadings = false;
26
27
function find_markdown_files($dir): array
28
{
29
    $markdown_files = [];
30
31
    $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
32
    foreach ($iterator as $file) {
33
        // Skip _data directory
34
        if (str_contains($file->getPathname(), '_data')) {
35
            continue;
36
        }
37
38
        if ($file->isFile() && strtolower($file->getExtension()) == 'md') {
39
            $markdown_files[] = realpath($file->getPathname());
40
        }
41
    }
42
43
    return $markdown_files;
44
}
45
46
function handle_file(string $file): void
47
{
48
    echo 'Handling '.$file."\n";
49
50
    normalize_lines($file);
51
}
52
53
function normalize_lines($filename): void
54
{
55
    $stream = file_get_contents($filename);
56
57
    $text = $stream;
58
    $text = str_replace("\r\n", "\n", $text);
59
    $text = str_replace("\t", '    ', $text);
60
61
    if (empty(trim($text))) {
62
        // Warn
63
        global $warnings;
64
        $warnings[] = "File $filename is empty";
65
66
        return;
67
    }
68
69
    $lines = explode("\n", $text);
70
    $new_lines = [];
71
72
    $last_line = '';
73
    $was_last_line_heading = false;
74
    $is_inside_fenced_code_block = false;
75
    $is_inside_fenced_fenced_code_block = false;
76
    $firstHeadingLevel = null;
77
    foreach ($lines as $index => $line) {
78
        global $linesCounted;
79
        $linesCounted++;
80
81
        /** Normalization */
82
83
        // Remove multiple empty lines
84
        if (trim($line) == '' && trim($last_line) == '') {
85
            continue;
86
        }
87
88
        // Make sure there is a space after headings
89
        if ($was_last_line_heading && trim($line) != '') {
90
            $new_lines[] = '';
91
        }
92
93
        // Make sure there are two empty lines before level 2 headings (but not if it's the first l2 heading)
94
        if ($is_inside_fenced_code_block !== true && str_starts_with($line, '## ') && $index > $firstHeadingLevel + 3) {
95
            $new_lines[] = '';
96
        }
97
98
        if ($firstHeadingLevel === null && str_starts_with($line, '# ')) {
99
            $firstHeadingLevel = $index;
100
        }
101
102
        // Check if line is a heading
103
        if (str_starts_with($line, '##')) {
104
            $was_last_line_heading = true;
105
            global $headings;
106
            $headings[$filename][$index + 1] = $line;
107
        } else {
108
            $was_last_line_heading = false;
109
        }
110
111
        // Make sure there is a space before opening a fenced code block (search for ```language)
112
        if (str_starts_with($line, '```') && $line !== '```' && trim($last_line) != '') {
113
            if (! $is_inside_fenced_fenced_code_block) {
114
                $new_lines[] = '';
115
            }
116
        }
117
118
        // Check if line is a  fenced code block
119
        if (str_starts_with($line, '``')) {
120
            $is_inside_fenced_code_block = ! $is_inside_fenced_code_block;
121
        }
122
123
        // Check if line is an escaped fenced code block
124
        if (str_starts_with($line, '````')) {
125
            $is_inside_fenced_fenced_code_block = ! $is_inside_fenced_fenced_code_block;
126
        }
127
128
        // Remove trailing spaces
129
        $line = rtrim($line);
130
131
        $new_lines[] = $line;
132
        $last_line = $line;
133
134
        /** Linting */
135
136
        // if not inside fenced code block
137
        if (! $is_inside_fenced_code_block) {
138
            // Add any links to buffer, so we can check them later
139
            preg_match_all('/\[([^\[]+)]\((.*)\)/', $line, $matches);
140
            if (count($matches) > 0) {
141
                foreach ($matches[2] as $match) {
142
                    // If link is for an anchor, prefix the filename
143
                    if (str_starts_with($match, '#')) {
144
                        $match = 'ANCHOR_'.basename($filename).$match;
145
                    }
146
147
                    global $links;
148
                    $links[] = [
149
                        'filename' => $filename,
150
                        'line' => $index + 1,
151
                        'link' => $match,
152
                    ];
153
                }
154
            }
155
156
            // Check for un-backtick-ed inline code
157
            // If line contains $
158
            if (str_contains($line, '$') && ! str_contains($line, '[Blade]:') && ! str_contains($line, '$ php')) {
159
                // Check character before the $ is not a backtick
160
                $pos = strpos($line, '$');
161
                if ($pos > 0) {
162
                    $charBefore = substr($line, $pos - 1, 1);
163
                    if ($charBefore !== '`') {
164
                        global $warnings;
165
                        $warnings['Inline code'][] = sprintf('Unformatted inline code found in %s:%s', $filename, $index + 1);
166
                    }
167
                }
168
            }
169
            // If line contains command
170
            if (str_contains($line, 'php hyde') && ! str_contains($line, '[Blade]:') && ! str_contains($line, '$ php')) {
171
                // Check character before the php hyde is not a backtick
172
                $pos = strpos($line, 'php hyde');
173
                if ($pos > 0) {
174
                    $charBefore = substr($line, $pos - 1, 1);
175
                    if ($charBefore !== '`') {
176
                        global $warnings;
177
                        $warnings['Inline code'][] = sprintf('Unformatted inline command found in %s:%s', $filename, $index + 1);
178
                    }
179
                }
180
            }
181
            // If word ends in .php
182
            if (str_contains($line, '.php') && ! str_contains($line, '[Blade]:') && ! str_contains($line, '$ php') && ! str_contains($line, 'http') && ! str_contains(strtolower($line), 'filepath')) {
183
                // Check character after the .php is not a backtick
184
                $pos = strpos($line, '.php');
185
                if ($pos > 0) {
186
                    $charAfter = substr($line, $pos + 4, 1);
187
                    if ($charAfter !== '`') {
188
                        global $warnings;
189
                        $warnings['Inline code'][] = sprintf('Unformatted inline filename found in %s:%s', $filename, $index + 1);
190
                    }
191
                }
192
            }
193
194
            // If word ends in .json
195
            if (str_contains($line, '.json') && ! str_contains($line, '[Blade]:') && ! str_contains($line, '$ json') && ! str_contains($line, 'http') && ! str_contains(strtolower($line), 'filepath')) {
196
                // Check character after the .json is not a backtick
197
                $pos = strpos($line, '.json');
198
                if ($pos > 0) {
199
                    $charAfter = substr($line, $pos + 5, 1);
200
                    if ($charAfter !== '`') {
201
                        global $warnings;
202
                        $warnings['Inline code'][] = sprintf('Unformatted inline filename found in %s:%s', $filename, $index + 1);
203
                    }
204
                }
205
            }
206
            // if word ends with ()
207
            if (str_contains($line, '()') && ! str_contains($line, '[Blade]:')) {
208
                // Check character after the () is not a backtick
209
                $pos = strpos($line, '()');
210
                if ($pos > 0) {
211
                    $charAfter = substr($line, $pos + 2, 1);
212
                    if ($charAfter !== '`') {
213
                        global $warnings;
214
                        $warnings['Inline code'][] = sprintf('Unformatted inline function found in %s:%s', $filename, $index + 1);
215
                    }
216
                }
217
            }
218
219
            // Check for invalid command signatures
220
            if (str_contains($line, 'php hyde')) {
221
                // Extract signature from line
222
                $start = strpos($line, 'php hyde');
223
                $substr = substr($line, $start);
224
                $explode = explode(' ', $substr, 3);
225
                $signature = $explode[0].' '.$explode[1].' '.$explode[2];
226
                $end = strpos($signature, '`');
227
                if ($end === false) {
228
                    $end = strpos($signature, '<');
229
                    if ($end === false) {
230
                        $end = strlen($signature);
231
                    }
232
                }
233
                $signature = substr($signature, 0, $end);
234
                $signatures = getSignatures();
235
                if (! in_array($signature, $signatures)) {
236
                    global $warnings;
237
                    $warnings['Invalid command signatures'][] = sprintf('Invalid command signature \'%s\' found in %s:%s', $signature, $filename, $index + 1);
238
                }
239
            }
240
        }
241
242
        // Check if line is too long
243
        if (strlen($line) > 120) {
244
            global $warnings;
245
            // $warnings[] = 'Line '.$linesCounted.' in file '.$filename.' is too long';
246
        }
247
248
        // Warn if documentation contains legacy markers (experimental, beta, etc)
249
        $markers = ['experimental', 'beta', 'alpha', 'v0.'];
250
        foreach ($markers as $marker) {
251
            if (str_contains($line, $marker)) {
252
                global $warnings;
253
                $warnings['Legacy markers'][] = sprintf('Legacy marker found in %s:%s Found "%s"', $filename, $index + 1, $marker);
254
            }
255
        }
256
257
        // Warn when legacy terms are used (for example slug instead of identifier/route key)
258
        $legacyTerms = [
259
            'slug' => '"identifier" or "route key"',
260
            'slugs' => '"identifiers" or "route keys"',
261
        ];
262
263
        foreach ($legacyTerms as $legacyTerm => $newTerm) {
264
            if (str_contains(strtolower($line), $legacyTerm)) {
265
                global $warnings;
266
                $warnings['Legacy terms'][] = sprintf('Legacy term found in %s:%s Found "%s", should be %s', $filename, $index + 1, $legacyTerm, $newTerm);
267
            }
268
        }
269
    }
270
271
    $new_content = implode("\n", $new_lines);
272
    $new_content = trim($new_content)."\n";
273
    file_put_contents($filename, $new_content);
274
275
    if ($new_content !== $stream) {
276
        global $filesChanged;
277
        $filesChanged++;
278
    }
279
}
280
281
$dir = __DIR__.'/../../../docs';
282
$markdownFiles = find_markdown_files($dir);
283
284
foreach ($markdownFiles as $file) {
285
    handle_file($file);
286
}
287
288
// Just to make PhpStorm happy
289
$links[] = [
290
    'filename' => '',
291
    'line' => 1,
292
    'link' => '',
293
];
294
295
if (count($links) > 0) {
296
    $uniqueLinks = [];
297
298
    foreach ($links as $data) {
299
        $link = $data['link'];
300
        $filename = $data['filename'];
301
        $line = $data['line'];
302
303
        if (str_starts_with($link, 'http')) {
304
            // Check for outdated links
305
            // laravel.com/docs/9.x
306
            if (str_contains($link, 'laravel.com/docs/9.x')) {
307
                $warnings['Outdated links'][] = "Outdated documentation link to $link found in $filename:$line";
308
            }
309
            continue;
310
        }
311
312
        if (str_starts_with($link, '#')) {
313
            continue;
314
        }
315
316
        // Remove hash for anchors
317
        $link = explode('#', $link)[0];
318
        // Remove anything before spaces (image alt text)
319
        $link = explode(' ', $link)[0];
320
        // Trim any non-alphanumeric characters from the end of the link
321
        $link = rtrim($link, '.,;:!?)');
322
323
        if (! str_starts_with($link, 'ANCHOR_')) {
324
            // Add to new unique array
325
            $uniqueLinks[$link] = "$filename:$line";
326
        }
327
    }
328
329
    $base = __DIR__.'/../../../docs';
330
    // find all directories in the docs folder
331
    $directories = array_filter(glob($base.'/*'), 'is_dir');
332
333
    foreach ($uniqueLinks as $link => $location) {
334
        // Check uses pretty urls
335
        if (str_ends_with($link, '.html')) {
336
            $warnings['Bad links'][] = "Link to $link in $location should not use .html extension";
337
            continue;
338
        }
339
340
        // Check does not end with .md
341
        if (str_ends_with($link, '.md')) {
342
            $warnings['Bad links'][] = "Link to $link in $location must not use .md extension";
343
            continue;
344
        }
345
346
        // Check if file exists
347
        if (! file_exists($base.'/'.$link)) {
348
            $hasMatch = false;
349
            foreach ($directories as $directory) {
350
                if (file_exists($directory.'/'.$link.'.md')) {
351
                    $hasMatch = true;
352
                    break;
353
                }
354
            }
355
356
            if (! $hasMatch) {
357
                // Check that link is not for search (dynamic page)
358
                if (! str_contains($link, 'search')) {
359
                    $warnings['Broken links'][] = "Broken link to $link found in $location";
360
                }
361
            }
362
        }
363
    }
364
}
365
366
function getSignatures(): array
367
{
368
    static $signatures = null;
369
370
    if ($signatures === null) {
371
        $cache = __DIR__.'/../cache/hyde-signatures.php';
372
        if (file_exists($cache)) {
373
            $signatures = include $cache;
374
        } else {
375
            $signatures = [
376
                // Adds any hidden commands we know exist
377
                'php hyde list',
378
                'php hyde change:sourceDirectory',
379
            ];
380
            $commandRaw = shell_exec('cd ../../../ && php hyde list --raw');
381
            foreach (explode("\n", $commandRaw) as $command) {
382
                $command = Str::before($command, ' ');
383
                $signatures[] = trim('php hyde '.$command);
384
            }
385
            file_put_contents($cache, '<?php return '.var_export($signatures, true).';');
386
        }
387
    }
388
389
    return $signatures;
390
}
391
392
// Just to make PhpStorm happy
393
$headings['foo.md'][1] = '## Bar';
394
395
if ($checksHeadings && count($headings)) {
396
    foreach ($headings as $filename => $fileHeadings) {
397
        $headingLevels = [];
398
        foreach ($fileHeadings as $heading) {
399
            $headingLevel = substr_count($heading, '#');
400
            $headingLevels[] = $headingLevel;
401
402
            // Check for style: 1-2 headings should be title case, 3+ should be sentence case
403
            $headingText = trim(str_replace('#', '', $heading));
404
            $titleCase = Hyde\make_title($headingText);
405
            $alwaysUppercase = ['PHP', 'HTML', 'CLI'];
406
            $alwaysLowercase = ['to'];
407
            $titleCase = str_ireplace($alwaysUppercase, $alwaysUppercase, $titleCase);
408
            $titleCase = str_ireplace($alwaysLowercase, $alwaysLowercase, $titleCase);
409
410
            $isTitleCase = $headingText === $titleCase;
411
            $sentenceCase = Str::ucfirst($headingText);
412
            $isSentenceCase = $headingText === $sentenceCase;
413
            $something = false;
414
            if ($headingLevel < 3) {
415
                if (! $isTitleCase) {
416
                    $warnings['Headings'][] = "Heading '$headingText' should be title case in $filename (expected '$titleCase')";
417
                }
418
            } else {
419
                if (! $isSentenceCase) {
420
                    $warnings['Headings'][] = "Heading '$headingText' should be sentence case in $filename (expected '$sentenceCase')";
421
                }
422
            }
423
        }
424
    }
425
}
426
427
if (count($warnings) > 0) {
428
    echo "\n\033[31mWarnings:\033[0m \033[33m".count($warnings, COUNT_RECURSIVE) - count($warnings)." found \033[0m \n";
429
    foreach ($warnings as $type => $messages) {
430
        echo "\n\033[33m$type:\033[0m \n";
431
        foreach ($messages as $message) {
432
            echo " - $message\n";
433
        }
434
    }
435
}
436
437
$time = round((microtime(true) - $timeStart) * 1000, 2);
438
$linesTransformed = number_format($linesCounted);
439
$fileCount = count($markdownFiles);
440
441
echo "\n\n\033[32mAll done!\033[0m Formatted, normalized, and validated $linesTransformed lines of Markdown in $fileCount files in {$time}ms\n";
442
443
if ($filesChanged > 0) {
444
    echo "\n\033[32m$filesChanged files were changed.\033[0m ";
445
} else {
446
    echo "\n\033[32mNo files were changed.\033[0m ";
447
}
448
$warningCount = count($warnings, COUNT_RECURSIVE) - count($warnings);
449
if ($warningCount > 0) {
450
    echo sprintf("\033[33m%s %s found.\033[0m", $warningCount, $warningCount === 1 ? 'warning' : 'warnings');
451
    if (file_exists(__DIR__.'/../cache/last-run-warnings-count.txt')) {
452
        $lastRunWarningsCount = (int) file_get_contents(__DIR__.'/../cache/last-run-warnings-count.txt');
453
        if ($warningCount < $lastRunWarningsCount) {
454
            echo sprintf(' Good job! You fixed %d %s!', $lastRunWarningsCount - $warningCount, $lastRunWarningsCount - $warningCount === 1 ? 'warning' : 'warnings');
455
        } elseif ($warningCount > $lastRunWarningsCount) {
456
            echo sprintf(' Uh oh! You introduced %d new %s!', $warningCount - $lastRunWarningsCount, $warningCount - $lastRunWarningsCount === 1 ? 'warning' : 'warnings');
457
        }
458
    }
459
}
460
file_put_contents(__DIR__.'/../cache/last-run-warnings-count.txt', $warningCount);
461
echo "\n";
462
463
// If --git flag is passed, make a git commit
464
if (isset($argv[1]) && $argv[1] === '--git') {
465
    if ($filesChanged > 0) {
466
        echo "\n\033[33mCommitting changes to git...\033[0m\n";
467
        passthru('git commit -am "Format Markdown"');
468
    } else {
469
        echo "\n\033[33mNo changes to commit\033[0m\n";
470
    }
471
}
472