Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Continuous Integration

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
# Latest stable Python only; pyproject.toml sets the floor.
python-version: "3.x"
- run: pip install -e ".[dev]"
- run: pytest --cov=src/md_lines --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: true

# pre-commit.ci runs the other hooks on every pull request. It cannot run this repository's
# own md-lines hook (`language: system`, skipped in .pre-commit-config.yaml), and it does not
# test installing the hook the way a consumer does, so this job covers those two things.
hook:
name: md-lines hook and try-repo
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.x"
- run: pip install -e ".[dev]"
- name: This repository's own md-lines hook on its Markdown
run: pre-commit run md-lines --all-files --show-diff-on-failure
- name: Install the hook the way a consumer would
run: pre-commit try-repo . md-lines --files README.md --verbose
Comment thread
CodyCBakerPhD marked this conversation as resolved.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.pytest_cache/
.venv/
58 changes: 58 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Based on the pre-commit setup of CodyCBakerPhD/historia.
ci:
autoupdate_schedule: quarterly
# pre-commit.ci cannot run `language: system` hooks; the GitHub Actions workflow runs this one.
skip: [md-lines]

default_install_hook_types:
- pre-commit

# src/md_lines/models.py is generated from the LinkML schema; the hooks leave it alone.
exclude: ^src/md_lines/models\.py$

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
args: ["--markdown-linebreak-ext=md"]
- id: check-json
- id: check-toml
- id: check-added-large-files
- id: check-case-conflict
- id: check-merge-conflict

- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.38.2
hooks:
- id: check-github-workflows
args: ["--verbose"]

- repo: https://github.com/psf/black-pre-commit-mirror
rev: 26.10.0
hooks:
- id: black

# ruff lints and sorts imports (its isort rules are selected in pyproject.toml); black formats.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.10
hooks:
- id: ruff
args: [ --fix ]

- repo: https://github.com/codespell-project/codespell
rev: v2.4.3
hooks:
- id: codespell
additional_dependencies:
- tomli

- repo: local
hooks:
- id: md-lines
name: md-lines (this repository's own hook)
entry: md-lines
language: system
types: [markdown]
10 changes: 10 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- id: md-lines
name: md-lines (one sentence per line in Markdown)
description: >-
Reflow Markdown prose so that each sentence sits on its own line. Code, tables,
headings, HTML and front matter are left alone. Files that needed changes are
rewritten and the hook fails once so the changes can be reviewed and staged.
entry: md-lines
language: python
types: [markdown]
minimum_pre_commit_version: "2.9.0"
49 changes: 49 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Agent instructions

## Commits and PRs

- Always run `pre-commit run --all-files` before committing and pushing changes
- Always link PRs to issues when possible
- PR titles should be human-readable and in the past tense. They should NOT use conventional commit style.
- Every commit must include a `Co-Authored-By` trailer identifying your tool name and version and your underlying model and version. Format (replace all `<…>` placeholders with actual values): `Co-Authored-By: <Tool> <tool-version> / <Model> <model-version> <noreply@vendor-domain>`

## Versioning and changelog

- Bump the version in `pyproject.toml` once per PR when any file under `src/` or `pyproject.toml` itself changes. That is the only place to edit it; `md_lines.__version__` reads it from the installed package metadata. Do NOT bump for changes that are purely CI/workflow, documentation, or configuration (e.g., GitHub Actions workflows, `AGENTS.md`, `README.md` badges).
- The changelog follows the version. If the PR does not bump `pyproject.toml`, it does not need a `CHANGELOG.md` entry either. A documentation-only change needs neither.
- Otherwise, add a short entry to the `## Upcoming` section of `CHANGELOG.md` under the appropriate subsection (`### 🚀 Enhancement`, `### 🐛 Bug Fix`, `### 📝 Documentation`, `### 🔩 Dependency Updates`, `### 🏠 Internal`). Include the PR link at the end of each entry in the format `([#N](https://github.com/CodyCBakerPhD/md-lines-pre-commit/pull/N))`. Create the `## Upcoming` section or subsection if it does not yet exist.
- Releases are git tags of the form `vX.Y.Z`, which is what consumers pin as `rev:` in their pre-commit configuration.

## Code style

- Require keyword-only arguments `(*, ...)` for multi-input functions. For any function with exactly one caller-supplied parameter (excluding `self` and `cls`), require positional-only usage with the `/` designator.
- Always add new imports at the top of the file. The only exception is when a local import is needed to avoid a circular dependency.
- For external dependencies, use the full module import style (e.g., `import xyz; xyz.abc`) rather than `from xyz import abc`.
- For internal imports, always use relative style (e.g., `from .foo import bar`).
- Prefer assigning return values to named locals before `return` when this improves readability and debugger breakpoint placement.
- Avoid excessive em-dashes, colons, and semicolons in written text such as documentation. Prefer breaking into separate, shorter sentences instead.
- Write documentation, comments and messages in American English. codespell enforces it.
- In Markdown, put each sentence on its own line. This repository's own hook enforces it.
- Favor defining one-word names for CLI flags, then map those onto longer, more explicit keyword arguments at the API level.
- Keep inline comments sparse. Only explain non-obvious "why", not "what" the code does. Prefer self-documenting code and clear names over narration; do not annotate routine logic.

## Data model

- The data model (`Options`, `Problem`, `Result`, `Paragraph`) is defined in the LinkML schema `src/md_lines/schema/md_lines.yaml`. Change the schema, never the generated code.
- `src/md_lines/models.py` is generated from the schema with `gen-python --no-metadata src/md_lines/schema/md_lines.yaml > src/md_lines/models.py`. Regenerate it after every schema change; a test fails when the two drift apart.
- Module-level constants (the abbreviation list, the parser, the regular expressions, `DEFAULT_OPTIONS`) live in `src/md_lines/globals.py`, not next to the models.

## Tests

- To the best of your ability, ensure tests are passing before pushing.
- Follow assertion style: actual on left, expected on right.
- Always mark AI-generated tests with the `ai_generated` pytest marker.
- Use `pytest.mark.parametrize` wherever appropriate to reduce duplication in test cases.
- Avoid importing private API (names with a leading underscore) in tests. Always import from what is publicly exposed through `__init__.py` files.
- When monkeypatching internal imports in tests, target the importing module's binding (e.g., `foo.baz`), not the original definition module (e.g., `foo._bar.baz`).

## Module and API conventions

- Never expose private names (those with a leading underscore) in any module's `__all__`.
- Never include code other than imports, `__all__`, simple import errors, or magic `__dir__` overrides in any `__init__.py` file.
- Do not add compatibility aliases when renaming functions. Update all call sites to the canonical name instead.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

## Upcoming

### 🚀 Enhancement

- Initial implementation of the `md-lines` hook, which puts each Markdown sentence on its own line. ([#1](https://github.com/CodyCBakerPhD/md-lines-pre-commit/pull/1))
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,64 @@
# md-lines-pre-commit
A pre-commit hook for ensuring distinct breaks within markdown files.

A [pre-commit](https://pre-commit.com) hook that puts each sentence of a Markdown file on its own line.

Wrapped sentences are joined back onto one line, and lines that hold several sentences are split, so diffs and reviews work sentence by sentence.

Everything that is not prose stays exactly as it is.

## Usage

Add the hook to your `.pre-commit-config.yaml`:

```yaml
repos:
- repo: https://github.com/CodyCBakerPhD/md-lines-pre-commit
rev: v0.1.0
hooks:
- id: md-lines
```

Or directly from the command line (after installing with `pip install git+https://github.com/CodyCBakerPhD/md-lines-pre-commit`):

```sh
md-lines README.md docs/*.md # rewrite in place, exit 1 if anything changed
md-lines --check --diff README.md # only report, with a diff, do not write
```

## What it changes

This hook splits the breaks within paragraphs across lines, primarily sentences but also including list items and block quotes.
Within a paragraph:

- A line that does not end with `.`, `!` or `?` (closing quotes, parentheses or emphasis markers may follow) is joined with the next line, because the sentence was wrapped.
- A line holding several sentences is split at each sentence end, when what follows starts with an uppercase letter, a digit, an opening quote, bracket or emphasis marker, or inline code.
- List items are joined but not split, so an item may hold several sentences on its line; `--split-lists` splits them too, indented to the item's text.
- Block quote markers (`>`) and list indentation are reproduced on the new lines.
- Hard line breaks (two trailing spaces or a trailing backslash) are kept.

What is never touched: headings, code blocks and fences, tables, HTML blocks and comments, link reference definitions, thematic breaks, YAML or TOML front matter.

Block structure comes from a CommonMark parser ([markdown-it-py](https://github.com/executablebooks/markdown-it-py)), not from guessing at line prefixes, so a wrapped line that happens to start with a link or an emphasis marker is still recognized as the continuation of its sentence.

Sentence ends are not detected inside inline code, link destinations, autolinks, inline HTML, bare URLs or `$math$`, nor after common abbreviations (`e.g.`, `etc.`, `Fig.`, `Dr.`, months and so on), initials (`J. Smith`), dotted abbreviations (`U.S.`) or an enumerator at the start of a line (`1.`).
A bold lead-in such as `**Note.**` stays attached to the sentence it introduces, and no split happens inside unbalanced bold markers, brackets or parentheses.

## Options

| Option | Default | Meaning |
| --- | --- | --- |
| `--check` | off | Report what would change and exit 1 instead of rewriting files. |
| `--diff` | off | Print a unified diff of the changes. |
| `--split-lists` | off | Split sentences inside list items as well. |
| `--keep-breaks-after CHARS` | `.!?` | Keep an existing line break after any of these characters. Add `:;` to keep breaks after colons and semicolons; sentences are never split there. |
| `--abbreviations LIST` | none | Comma-separated words ending in a period that do not end a sentence, added to the built-in list. |
| `--abbreviations-file PATH` | none | File with one such word per line; blank lines and `#` comments are ignored. |
| `--no-default-abbreviations` | off | Start from an empty abbreviation list. |
| `-q`, `--quiet` | off | Do not list the problems found. |

Pass options through pre-commit with `args`:

```yaml
- id: md-lines
args: ["--split-lists", "--abbreviations", "chap.,sect."]
```
36 changes: 36 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Development

```sh
pip install -e ".[dev]"
pytest
pre-commit run --all-files
```

The `dev` extra brings pytest, pre-commit and [LinkML](https://linkml.io).
Conventions for contributors and coding agents are in [AGENTS.md](../AGENTS.md).
The hook is used on this repository's own Markdown through `.pre-commit-config.yaml`.

## Layout

- `src/md_lines/schema/md_lines.yaml` defines the data model (`Options`, `Problem`, `Result`, `Paragraph`) as a LinkML schema.
- `src/md_lines/models.py` is generated from the schema and is not edited by hand; the formatters skip it, and a test fails when it and the schema drift apart.
- `src/md_lines/globals.py` holds the module-level constants: the abbreviation list, the parser, the regular expressions and `DEFAULT_OPTIONS`.
- `src/md_lines/reflow.py` holds the reflow functions only, and `src/md_lines/cli.py` the command.
- `__version__` is read from the installed package metadata, so `pyproject.toml` is the only place the version is written.

After changing the schema, regenerate the model:

```sh
gen-python --no-metadata src/md_lines/schema/md_lines.yaml > src/md_lines/models.py
```

## Repository checks

The pre-commit configuration follows [historia](https://github.com/CodyCBakerPhD/historia): pre-commit-hooks, check-github-workflows, black, ruff (lint plus isort rules, with `--fix`), codespell with the `en-GB_to_en-US` dictionary so the text stays in American English, and this repository's own `md-lines` hook.
pre-commit.ci runs the hooks on every pull request, except this repository's own `md-lines` hook, which it cannot run.
The Actions workflow runs the tests on the newest Python release, runs that one hook, and installs the hook the way a consumer does with `pre-commit try-repo`.
The tests job uploads coverage to [Codecov](https://codecov.io/gh/CodyCBakerPhD/md-lines-pre-commit) and fails when the upload does, so the repository needs a `CODECOV_TOKEN` secret (Settings, Secrets and variables, Actions).

## Exit status

`md-lines` exits 0 when nothing needed changing, 1 when files were (or in `--check` mode would be) rewritten, and 2 when a file could not be read.
65 changes: 65 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "md-lines-pre-commit"
version = "0.1.0"
description = "pre-commit hook that puts each Markdown sentence on its own line"
readme = "README.md"
requires-python = ">=3.13"
dependencies = ["markdown-it-py>=3.0", "linkml-runtime>=1.9"]
keywords = ["markdown", "pre-commit", "semantic line breaks", "sentence per line"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Text Processing :: Markup :: Markdown",
]

[project.optional-dependencies]
test = ["pytest>=7"]
# Regenerating src/md_lines/models.py from the LinkML schema, and the test that checks it.
dev = ["pytest>=7", "pytest-cov", "linkml>=1.9", "pre-commit"]

[project.scripts]
md-lines = "md_lines.cli:main"

[project.urls]
Repository = "https://github.com/CodyCBakerPhD/md-lines-pre-commit"

[tool.hatch.build.targets.wheel]
packages = ["src/md_lines"]

[tool.coverage.run]
# Generated from the LinkML schema; its many unused branches are not ours to cover.
omit = ["src/md_lines/models.py"]

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"ai_generated: mark test as AI-generated",
]

[tool.black]
line-length = 100
target-version = ["py313"]
# Generated from src/md_lines/schema/md_lines.yaml; regenerate instead of editing.
extend-exclude = "src/md_lines/models\\.py"

[tool.ruff]
line-length = 100
target-version = "py313"
# Generated from src/md_lines/schema/md_lines.yaml; regenerate instead of editing.
extend-exclude = ["src/md_lines/models.py"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.ruff.lint.isort]
known-first-party = ["md_lines"]

[tool.codespell]
skip = ".git,*.lock"
# Also flag British spellings: the project is written in American English.
builtin = "clear,rare,en-GB_to_en-US"
17 changes: 17 additions & 0 deletions src/md_lines/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Put each Markdown sentence on its own line."""

from ._version import __version__ as __version__
from .globals import DEFAULT_ABBREVIATIONS, DEFAULT_OPTIONS
from .models import Options, Paragraph, Problem, Result
from .reflow import reflow, split_sentences

__all__ = [
"DEFAULT_ABBREVIATIONS",
"DEFAULT_OPTIONS",
"Options",
"Paragraph",
"Problem",
"Result",
"reflow",
"split_sentences",
]
11 changes: 11 additions & 0 deletions src/md_lines/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""The installed version.

It is read from the package metadata, so pyproject.toml stays the only place the version is written.
"""

import importlib.metadata

try:
__version__ = importlib.metadata.version("md-lines-pre-commit")
except importlib.metadata.PackageNotFoundError: # a checkout that was not pip-installed
__version__ = "0+unknown"
Loading
Loading