Skip to content

Commit a363d1c

Browse files
committed
Add one-sentence-per-line check and pre-commit hook
markdownlint has no rule for this, so CI cannot catch it. Enable with: git config core.hooksPath .githooks Signed-off-by: arhimede <julian@dotkernel.com>
1 parent 275e549 commit a363d1c

5 files changed

Lines changed: 314 additions & 1 deletion

File tree

‎.githooks/pre-commit‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/sh
2+
#
3+
# Rejects a commit whose staged Markdown puts more than one sentence on a line.
4+
#
5+
# Enable it once per clone:
6+
#
7+
# git config core.hooksPath .githooks
8+
#
9+
# Bypass for a single commit with --no-verify.
10+
11+
if ! command -v php >/dev/null 2>&1; then
12+
echo "pre-commit: php not found, skipping the one-sentence-per-line check." >&2
13+
exit 0
14+
fi
15+
16+
repository_root=$(git rev-parse --show-toplevel)
17+
18+
# The checker resolves the staged Markdown itself, which keeps file names with
19+
# spaces intact and avoids xargs portability differences between GNU and BSD.
20+
exec php "$repository_root/tools/one-sentence-per-line.php" --staged

‎.githooks/prepare-commit-msg‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/bin/sh
2+
#
3+
# Append a DCO Signed-off-by trailer using the configured git identity,
4+
# unless one is already present. Equivalent to always passing `git commit -s`.
5+
#
6+
# $1 = path to the commit message file
7+
# $2 = message source (message|template|merge|squash|commit)
8+
9+
set -e
10+
11+
MSG_FILE="$1"
12+
MSG_SOURCE="$2"
13+
14+
# Skip merge and squash messages; git generates those itself.
15+
case "$MSG_SOURCE" in
16+
merge|squash) exit 0 ;;
17+
esac
18+
19+
NAME=$(git config user.name)
20+
EMAIL=$(git config user.email)
21+
22+
# Without an identity there is nothing to sign off with.
23+
if [ -z "$NAME" ] || [ -z "$EMAIL" ]; then
24+
exit 0
25+
fi
26+
27+
SIGNOFF="Signed-off-by: $NAME <$EMAIL>"
28+
29+
# Do not duplicate an existing identical trailer.
30+
if grep -qsF "$SIGNOFF" "$MSG_FILE"; then
31+
exit 0
32+
fi
33+
34+
git interpret-trailers --in-place --trailer "$SIGNOFF" "$MSG_FILE"

‎README.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,38 @@
11
# Dotkernel API
22

33
Based on Enrico Zimuel’s Zend Expressive API – Skeleton example, Dotkernel API runs on Laminas and Mezzio components and implements standards like PSR-3, PSR-4, PSR-7, PSR-11 and PSR-15.
4+
5+
This repository holds the Markdown sources for the documentation published at [docs.dotkernel.org](https://docs.dotkernel.org/api-documentation/).
6+
7+
## Writing style
8+
9+
Documentation prose uses **one sentence per line**, relying on the renderer to wrap it.
10+
A sentence stays on one line however long it gets, rather than being hard-wrapped to a column width.
11+
12+
The reason is the diff: a one-sentence-per-line source shows reviewers the sentence that changed, instead of a whole reflowed paragraph.
13+
14+
Code blocks and table rows are exempt, since neither can be broken across lines.
15+
16+
## Checking it
17+
18+
`markdownlint` has no rule for this, so the CI documentation linting cannot catch it.
19+
This repository ships its own checker instead:
20+
21+
```shell
22+
php tools/one-sentence-per-line.php docs/book/v7/some-page.md
23+
php tools/one-sentence-per-line.php --all
24+
```
25+
26+
To have it run automatically, enable the bundled hooks once per clone:
27+
28+
```shell
29+
git config core.hooksPath .githooks
30+
```
31+
32+
That directory holds two hooks:
33+
34+
- `pre-commit` runs the check above against the Markdown you staged, so a pre-existing violation elsewhere never blocks an unrelated commit.
35+
- `prepare-commit-msg` appends the `Signed-off-by` trailer this repository's DCO check requires, using your configured git identity.
36+
37+
Setting `core.hooksPath` makes git ignore `.git/hooks`, so move any hook you keep there into `.githooks` as well.
38+
Use `git commit --no-verify` to bypass the checks for a single commit.

‎composer.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@
99
"email": "team@dotkernel.com"
1010
}
1111
],
12-
"require": {}
12+
"require": {},
13+
"scripts": {
14+
"check-sentences": "php tools/one-sentence-per-line.php --all",
15+
"check-sentences-staged": "php tools/one-sentence-per-line.php --staged"
16+
}
1317
}

‎tools/one-sentence-per-line.php‎

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<?php
2+
3+
/**
4+
* Checks that Markdown prose keeps one sentence per line.
5+
*
6+
* markdownlint has no rule for this, so it cannot be caught by the CI
7+
* documentation linting. See CONTRIBUTING notes in README.md.
8+
*
9+
* Usage:
10+
* php tools/one-sentence-per-line.php docs/book/v7/some-page.md [...]
11+
* php tools/one-sentence-per-line.php --all every page under docs/book
12+
* php tools/one-sentence-per-line.php --staged staged Markdown, as the hook runs it
13+
*
14+
* Exit code 0 when clean, 1 when a line holds more than one sentence.
15+
*/
16+
17+
declare(strict_types=1);
18+
19+
/** Abbreviations whose trailing dot does not end a sentence. */
20+
const ABBREVIATIONS = [
21+
'e.g.',
22+
'i.e.',
23+
'etc.',
24+
'vs.',
25+
'cf.',
26+
'approx.',
27+
'Mr.',
28+
'Mrs.',
29+
'Ms.',
30+
'Dr.',
31+
'St.',
32+
'Inc.',
33+
'Ltd.',
34+
];
35+
36+
/**
37+
* @return list<string>
38+
*/
39+
function collectMarkdownFiles(string $directory): array
40+
{
41+
$files = [];
42+
$iterator = new RecursiveIteratorIterator(
43+
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS)
44+
);
45+
46+
/** @var SplFileInfo $file */
47+
foreach ($iterator as $file) {
48+
if ($file->isFile() && strtolower($file->getExtension()) === 'md') {
49+
$files[] = $file->getPathname();
50+
}
51+
}
52+
53+
sort($files);
54+
55+
return $files;
56+
}
57+
58+
/**
59+
* Markdown files staged for commit, added or modified.
60+
*
61+
* @return list<string>
62+
*/
63+
function collectStagedFiles(): array
64+
{
65+
$command = 'git diff --cached --name-only --diff-filter=ACM -z -- "*.md"';
66+
$output = shell_exec($command);
67+
68+
if (! is_string($output) || $output === '') {
69+
return [];
70+
}
71+
72+
return array_values(array_filter(explode("\0", $output), static fn (string $p): bool => $p !== ''));
73+
}
74+
75+
/**
76+
* The staged content of a file, which is what the commit will contain and may
77+
* differ from the working tree when only some hunks were added.
78+
*
79+
* @return list<string>
80+
*/
81+
function readStagedLines(string $path): array
82+
{
83+
$output = shell_exec('git show :' . escapeshellarg($path) . ' 2>/dev/null');
84+
85+
if (! is_string($output)) {
86+
return [];
87+
}
88+
89+
return explode("\n", rtrim($output, "\n"));
90+
}
91+
92+
/**
93+
* True when the offset sits inside an inline code span, i.e. an odd number of
94+
* backticks precedes it on the line.
95+
*/
96+
function insideCodeSpan(string $line, int $offset): bool
97+
{
98+
return substr_count(substr($line, 0, $offset), '`') % 2 === 1;
99+
}
100+
101+
/**
102+
* True when the sentence-ending dot at $offset closes a known abbreviation.
103+
*/
104+
function endsAbbreviation(string $line, int $offset): bool
105+
{
106+
$before = substr($line, 0, $offset + 1);
107+
108+
foreach (ABBREVIATIONS as $abbreviation) {
109+
if (str_ends_with($before, $abbreviation)) {
110+
return true;
111+
}
112+
}
113+
114+
return false;
115+
}
116+
117+
/**
118+
* @param list<string> $lines
119+
* @return list<array{line: int, snippet: string}>
120+
*/
121+
function findViolations(array $lines): array
122+
{
123+
$violations = [];
124+
$inFence = false;
125+
126+
foreach ($lines as $index => $line) {
127+
$trimmed = ltrim($line);
128+
129+
// Fenced code blocks, opened and closed by ``` or ~~~.
130+
if (str_starts_with($trimmed, '```') || str_starts_with($trimmed, '~~~')) {
131+
$inFence = ! $inFence;
132+
continue;
133+
}
134+
135+
if ($inFence) {
136+
continue;
137+
}
138+
139+
// Table rows: cells are prose but wrapping them is not possible.
140+
if (str_starts_with($trimmed, '|')) {
141+
continue;
142+
}
143+
144+
// Ordered list markers ("1. Do the thing") are not sentence ends.
145+
$line = (string) preg_replace('/^(\s*)\d+\.(\s)/', '$1 $2', $line);
146+
147+
// A sentence end: . ! or ? after a word character, then space, then
148+
// the start of something that looks like a new sentence.
149+
$pattern = '/(?<=[a-z0-9\)\]"])[.!?]\s+(?=["A-Z])/';
150+
151+
if (preg_match_all($pattern, $line, $matches, PREG_OFFSET_CAPTURE) === 0) {
152+
continue;
153+
}
154+
155+
foreach ($matches[0] as [$match, $offset]) {
156+
if (insideCodeSpan($line, $offset) || endsAbbreviation($line, $offset)) {
157+
continue;
158+
}
159+
160+
$start = max(0, $offset - 40);
161+
$snippet = trim(substr($line, $start, 90));
162+
163+
$violations[] = [
164+
'line' => $index + 1,
165+
'snippet' => ($start > 0 ? '...' : '') . $snippet . '...',
166+
];
167+
}
168+
}
169+
170+
return $violations;
171+
}
172+
173+
$arguments = array_slice($argv, 1);
174+
175+
if ($arguments === []) {
176+
fwrite(STDERR, "Usage: php tools/one-sentence-per-line.php <file.md> [...] | --all | --staged\n");
177+
exit(1);
178+
}
179+
180+
$staged = $arguments === ['--staged'];
181+
182+
$paths = match (true) {
183+
$staged => collectStagedFiles(),
184+
$arguments === ['--all'] => collectMarkdownFiles(__DIR__ . '/../docs/book'),
185+
default => $arguments,
186+
};
187+
188+
if ($paths === []) {
189+
exit(0);
190+
}
191+
192+
$total = 0;
193+
194+
foreach ($paths as $path) {
195+
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'md') {
196+
continue;
197+
}
198+
199+
$lines = $staged ? readStagedLines($path) : (is_file($path) ? file($path, FILE_IGNORE_NEW_LINES) : []);
200+
201+
if ($lines === []) {
202+
continue;
203+
}
204+
205+
foreach (findViolations($lines) as $violation) {
206+
printf("%s:%d: more than one sentence on this line\n", $path, $violation['line']);
207+
printf(" %s\n", $violation['snippet']);
208+
$total++;
209+
}
210+
}
211+
212+
if ($total > 0) {
213+
printf("\n%d line(s) hold more than one sentence.\n", $total);
214+
print("Dotkernel docs use one sentence per line, so a diff shows the edited sentence\n");
215+
print("rather than a reflowed paragraph. Put each sentence on its own line.\n");
216+
exit(1);
217+
}
218+
219+
print("One sentence per line: OK\n");
220+
exit(0);

0 commit comments

Comments
 (0)