From a363d1c2dffc356df0661831af1da20ec22900fa Mon Sep 17 00:00:00 2001 From: arhimede Date: Mon, 7 Sep 2026 16:03:55 +0300 Subject: [PATCH] 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 --- .githooks/pre-commit | 20 +++ .githooks/prepare-commit-msg | 34 +++++ README.md | 35 +++++ composer.json | 6 +- tools/one-sentence-per-line.php | 220 ++++++++++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/prepare-commit-msg create mode 100755 tools/one-sentence-per-line.php diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..6da447fa --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Rejects a commit whose staged Markdown puts more than one sentence on a line. +# +# Enable it once per clone: +# +# git config core.hooksPath .githooks +# +# Bypass for a single commit with --no-verify. + +if ! command -v php >/dev/null 2>&1; then + echo "pre-commit: php not found, skipping the one-sentence-per-line check." >&2 + exit 0 +fi + +repository_root=$(git rev-parse --show-toplevel) + +# The checker resolves the staged Markdown itself, which keeps file names with +# spaces intact and avoids xargs portability differences between GNU and BSD. +exec php "$repository_root/tools/one-sentence-per-line.php" --staged diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100755 index 00000000..9ea8ca27 --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,34 @@ +#!/bin/sh +# +# Append a DCO Signed-off-by trailer using the configured git identity, +# unless one is already present. Equivalent to always passing `git commit -s`. +# +# $1 = path to the commit message file +# $2 = message source (message|template|merge|squash|commit) + +set -e + +MSG_FILE="$1" +MSG_SOURCE="$2" + +# Skip merge and squash messages; git generates those itself. +case "$MSG_SOURCE" in + merge|squash) exit 0 ;; +esac + +NAME=$(git config user.name) +EMAIL=$(git config user.email) + +# Without an identity there is nothing to sign off with. +if [ -z "$NAME" ] || [ -z "$EMAIL" ]; then + exit 0 +fi + +SIGNOFF="Signed-off-by: $NAME <$EMAIL>" + +# Do not duplicate an existing identical trailer. +if grep -qsF "$SIGNOFF" "$MSG_FILE"; then + exit 0 +fi + +git interpret-trailers --in-place --trailer "$SIGNOFF" "$MSG_FILE" diff --git a/README.md b/README.md index 52a19463..e4f59270 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # Dotkernel API 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. + +This repository holds the Markdown sources for the documentation published at [docs.dotkernel.org](https://docs.dotkernel.org/api-documentation/). + +## Writing style + +Documentation prose uses **one sentence per line**, relying on the renderer to wrap it. +A sentence stays on one line however long it gets, rather than being hard-wrapped to a column width. + +The reason is the diff: a one-sentence-per-line source shows reviewers the sentence that changed, instead of a whole reflowed paragraph. + +Code blocks and table rows are exempt, since neither can be broken across lines. + +## Checking it + +`markdownlint` has no rule for this, so the CI documentation linting cannot catch it. +This repository ships its own checker instead: + +```shell +php tools/one-sentence-per-line.php docs/book/v7/some-page.md +php tools/one-sentence-per-line.php --all +``` + +To have it run automatically, enable the bundled hooks once per clone: + +```shell +git config core.hooksPath .githooks +``` + +That directory holds two hooks: + +- `pre-commit` runs the check above against the Markdown you staged, so a pre-existing violation elsewhere never blocks an unrelated commit. +- `prepare-commit-msg` appends the `Signed-off-by` trailer this repository's DCO check requires, using your configured git identity. + +Setting `core.hooksPath` makes git ignore `.git/hooks`, so move any hook you keep there into `.githooks` as well. +Use `git commit --no-verify` to bypass the checks for a single commit. diff --git a/composer.json b/composer.json index feeb67f9..a56b12a5 100644 --- a/composer.json +++ b/composer.json @@ -9,5 +9,9 @@ "email": "team@dotkernel.com" } ], - "require": {} + "require": {}, + "scripts": { + "check-sentences": "php tools/one-sentence-per-line.php --all", + "check-sentences-staged": "php tools/one-sentence-per-line.php --staged" + } } diff --git a/tools/one-sentence-per-line.php b/tools/one-sentence-per-line.php new file mode 100755 index 00000000..c871f260 --- /dev/null +++ b/tools/one-sentence-per-line.php @@ -0,0 +1,220 @@ + + */ +function collectMarkdownFiles(string $directory): array +{ + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'md') { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; +} + +/** + * Markdown files staged for commit, added or modified. + * + * @return list + */ +function collectStagedFiles(): array +{ + $command = 'git diff --cached --name-only --diff-filter=ACM -z -- "*.md"'; + $output = shell_exec($command); + + if (! is_string($output) || $output === '') { + return []; + } + + return array_values(array_filter(explode("\0", $output), static fn (string $p): bool => $p !== '')); +} + +/** + * The staged content of a file, which is what the commit will contain and may + * differ from the working tree when only some hunks were added. + * + * @return list + */ +function readStagedLines(string $path): array +{ + $output = shell_exec('git show :' . escapeshellarg($path) . ' 2>/dev/null'); + + if (! is_string($output)) { + return []; + } + + return explode("\n", rtrim($output, "\n")); +} + +/** + * True when the offset sits inside an inline code span, i.e. an odd number of + * backticks precedes it on the line. + */ +function insideCodeSpan(string $line, int $offset): bool +{ + return substr_count(substr($line, 0, $offset), '`') % 2 === 1; +} + +/** + * True when the sentence-ending dot at $offset closes a known abbreviation. + */ +function endsAbbreviation(string $line, int $offset): bool +{ + $before = substr($line, 0, $offset + 1); + + foreach (ABBREVIATIONS as $abbreviation) { + if (str_ends_with($before, $abbreviation)) { + return true; + } + } + + return false; +} + +/** + * @param list $lines + * @return list + */ +function findViolations(array $lines): array +{ + $violations = []; + $inFence = false; + + foreach ($lines as $index => $line) { + $trimmed = ltrim($line); + + // Fenced code blocks, opened and closed by ``` or ~~~. + if (str_starts_with($trimmed, '```') || str_starts_with($trimmed, '~~~')) { + $inFence = ! $inFence; + continue; + } + + if ($inFence) { + continue; + } + + // Table rows: cells are prose but wrapping them is not possible. + if (str_starts_with($trimmed, '|')) { + continue; + } + + // Ordered list markers ("1. Do the thing") are not sentence ends. + $line = (string) preg_replace('/^(\s*)\d+\.(\s)/', '$1 $2', $line); + + // A sentence end: . ! or ? after a word character, then space, then + // the start of something that looks like a new sentence. + $pattern = '/(?<=[a-z0-9\)\]"])[.!?]\s+(?=["A-Z])/'; + + if (preg_match_all($pattern, $line, $matches, PREG_OFFSET_CAPTURE) === 0) { + continue; + } + + foreach ($matches[0] as [$match, $offset]) { + if (insideCodeSpan($line, $offset) || endsAbbreviation($line, $offset)) { + continue; + } + + $start = max(0, $offset - 40); + $snippet = trim(substr($line, $start, 90)); + + $violations[] = [ + 'line' => $index + 1, + 'snippet' => ($start > 0 ? '...' : '') . $snippet . '...', + ]; + } + } + + return $violations; +} + +$arguments = array_slice($argv, 1); + +if ($arguments === []) { + fwrite(STDERR, "Usage: php tools/one-sentence-per-line.php [...] | --all | --staged\n"); + exit(1); +} + +$staged = $arguments === ['--staged']; + +$paths = match (true) { + $staged => collectStagedFiles(), + $arguments === ['--all'] => collectMarkdownFiles(__DIR__ . '/../docs/book'), + default => $arguments, +}; + +if ($paths === []) { + exit(0); +} + +$total = 0; + +foreach ($paths as $path) { + if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'md') { + continue; + } + + $lines = $staged ? readStagedLines($path) : (is_file($path) ? file($path, FILE_IGNORE_NEW_LINES) : []); + + if ($lines === []) { + continue; + } + + foreach (findViolations($lines) as $violation) { + printf("%s:%d: more than one sentence on this line\n", $path, $violation['line']); + printf(" %s\n", $violation['snippet']); + $total++; + } +} + +if ($total > 0) { + printf("\n%d line(s) hold more than one sentence.\n", $total); + print("Dotkernel docs use one sentence per line, so a diff shows the edited sentence\n"); + print("rather than a reflowed paragraph. Put each sentence on its own line.\n"); + exit(1); +} + +print("One sentence per line: OK\n"); +exit(0);