prose-preflight 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,25 @@
1
+ """Checker registry. Adding a checker is adding a module and one line here."""
2
+
3
+ from prose_preflight.checks import (
4
+ acronym,
5
+ claim,
6
+ readability,
7
+ sentence_length,
8
+ structure,
9
+ terminology,
10
+ units,
11
+ vale,
12
+ )
13
+
14
+ CHECKERS = {
15
+ "acronym": acronym.check,
16
+ "claim": claim.check,
17
+ "readability": readability.check,
18
+ "sentence_length": sentence_length.check,
19
+ "structure": structure.check,
20
+ "terminology": terminology.check,
21
+ "units": units.check,
22
+ "vale": vale.check,
23
+ }
24
+
25
+ __all__ = ["CHECKERS"]
@@ -0,0 +1,60 @@
1
+ """Shared paragraph and sentence helpers. Not a checker, not registered."""
2
+
3
+ import re
4
+
5
+ from prose_preflight.extract import Document
6
+
7
+ # ponytail: regex sentence split -- misses "e.g. Foo" and "Fig. 3 shows". No spaCy for
8
+ # this; upgrade the lookbehind when a fixture shows a real miss.
9
+ SENTENCE_END = re.compile(r"(?<=[.!?])[\"')\]]*\s+(?=[\"'(\[]*[A-Z0-9])")
10
+ WORD = re.compile(r"\S+")
11
+
12
+
13
+ def alternation(words, ignore_case: bool = True):
14
+ """Compile `words` into one word-bounded alternation. Longest first, so a variant
15
+ that starts another one still matches in full."""
16
+ return re.compile(
17
+ r"\b(?:{})\b".format(
18
+ "|".join(re.escape(w) for w in sorted(words, key=len, reverse=True))
19
+ ),
20
+ re.IGNORECASE if ignore_case else 0,
21
+ )
22
+
23
+
24
+ def paragraphs(doc: Document):
25
+ """Yield (text, positions) per paragraph: masked text joined with single spaces,
26
+ plus a (line, col) per character, so a match offset maps back to the source."""
27
+ for first, last in doc.paragraphs:
28
+ chunks, positions = [], []
29
+ for n in range(first, last + 1):
30
+ line = doc.masked[n - 1]
31
+ if positions:
32
+ chunks.append(" ")
33
+ positions.append(positions[-1])
34
+ chunks.append(line)
35
+ positions += [(n, col) for col in range(1, len(line) + 1)]
36
+ yield "".join(chunks), positions
37
+
38
+
39
+ def sentences(text: str):
40
+ """Yield (offset, sentence) over `text`."""
41
+ start = 0
42
+ for split in SENTENCE_END.finditer(text):
43
+ yield start, text[start : split.start()]
44
+ start = split.end()
45
+ if text[start:].strip():
46
+ yield start, text[start:]
47
+
48
+
49
+ def truncate(text: str, limit: int) -> str:
50
+ """First `limit` characters, with an ellipsis when something was cut."""
51
+ return text[:limit].strip() + ("\u2026" if len(text) > limit else "")
52
+
53
+
54
+ def excerpt(line: str, start: int, end: int, pad: int = 30) -> str:
55
+ left, right = max(0, start - pad), min(len(line), end + pad)
56
+ return (
57
+ ("…" if left else "")
58
+ + line[left:right].strip()
59
+ + ("…" if right < len(line) else "")
60
+ )
@@ -0,0 +1,46 @@
1
+ """First-use expansion tracking. An acronym is defined by a parenthesized pairing."""
2
+
3
+ import re
4
+
5
+ from prose_preflight.checks._text import excerpt
6
+ from prose_preflight.extract import Document
7
+ from prose_preflight.finding import Finding
8
+
9
+ ACRONYM = re.compile(r"\b([A-Z]{2,})s?\b")
10
+ # "Deep Neural Network (DNN)" -- the words before the parenthesis define it.
11
+ DEFINED_AFTER = re.compile(r"(?:\b[\w-]+\s+){1,6}\(([A-Z]{2,})s?\)")
12
+ # "DNN (deep neural network)" -- the parenthesis expands it.
13
+ DEFINED_BEFORE = re.compile(r"\b([A-Z]{2,})s?\s*\((?=[^)]*[a-z])[^)]{4,}\)")
14
+
15
+
16
+ def check(doc: Document, rule: dict) -> list[Finding]:
17
+ allow = {form.upper() for form in rule.get("allowlist", [])}
18
+ minimum = rule.get("min_uses", 1)
19
+ severity = rule.get("severity", "warning")
20
+ uses, defined = {}, set()
21
+ for n, line in doc.prose():
22
+ defined |= {m.group(1) for m in DEFINED_AFTER.finditer(line)}
23
+ defined |= {m.group(1) for m in DEFINED_BEFORE.finditer(line)}
24
+ for m in ACRONYM.finditer(line):
25
+ form = m.group(1)
26
+ if form in allow or form in defined:
27
+ continue
28
+ uses.setdefault(form, []).append((n, m))
29
+ findings = []
30
+ for form, hits in uses.items():
31
+ if len(hits) < minimum:
32
+ continue
33
+ n, m = hits[0]
34
+ findings.append(
35
+ Finding(
36
+ check="acronym.undefined",
37
+ category="acronym",
38
+ severity=severity,
39
+ line=n,
40
+ col=m.start() + 1,
41
+ excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
42
+ message=f"{form!r} is used {len(hits)}× but never expanded; spell it out on first use.",
43
+ section=doc.section_at(n),
44
+ )
45
+ )
46
+ return findings
@@ -0,0 +1,34 @@
1
+ """Hedges and boosters. Emits `review` only -- never auto-applied, by design."""
2
+
3
+ from prose_preflight.checks._text import alternation, excerpt
4
+ from prose_preflight.extract import Document
5
+ from prose_preflight.finding import Finding
6
+
7
+ KINDS = {
8
+ "hedge": "Hedge -- check the claim is not weaker than the evidence.",
9
+ "booster": "Booster -- check the evidence supports a claim this strong.",
10
+ }
11
+
12
+
13
+ def check(doc: Document, rule: dict) -> list[Finding]:
14
+ findings = []
15
+ for kind, message in KINDS.items():
16
+ words = rule.get(f"{kind}s", [])
17
+ if not words:
18
+ continue
19
+ pattern = alternation(words)
20
+ for n, line in doc.prose():
21
+ for m in pattern.finditer(line):
22
+ findings.append(
23
+ Finding(
24
+ check=f"claim.{kind}",
25
+ category="claim",
26
+ severity="review", # load-bearing: never auto-applied
27
+ line=n,
28
+ col=m.start() + 1,
29
+ excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
30
+ message=f"{message} ({m.group()!r})",
31
+ section=doc.section_at(n),
32
+ )
33
+ )
34
+ return findings
@@ -0,0 +1,60 @@
1
+ """Per-section readability scores from textstat, against YAML thresholds."""
2
+
3
+ from bisect import bisect_right
4
+
5
+ import textstat
6
+
7
+ from prose_preflight.extract import Document
8
+ from prose_preflight.finding import Finding
9
+
10
+ METRICS = { # rule suffix: (textstat function, comparison, threshold key)
11
+ "flesch": (textstat.flesch_reading_ease, "min", "min_flesch"),
12
+ "fog": (textstat.gunning_fog, "max", "max_fog"),
13
+ }
14
+
15
+
16
+ def check(doc: Document, rule: dict) -> list[Finding]:
17
+ severity = rule.get("severity", "warning")
18
+ min_words = rule.get("min_words", 50)
19
+ findings = []
20
+ for line, title, text, words in _sections(doc):
21
+ if words < min_words: # short sections score as noise
22
+ continue
23
+ for name, (score_of, direction, key) in METRICS.items():
24
+ limit = rule.get(key)
25
+ score = round(score_of(text), 1)
26
+ if limit is None or (
27
+ score >= limit if direction == "min" else score <= limit
28
+ ):
29
+ continue
30
+ findings.append(
31
+ Finding(
32
+ check=f"readability.{name}",
33
+ category="readability",
34
+ severity=severity,
35
+ line=line,
36
+ col=1,
37
+ excerpt=title or str(doc.path.name),
38
+ message=(
39
+ f"{name} score {score} is "
40
+ f"{'below' if direction == 'min' else 'above'} the {limit} "
41
+ f"threshold ({words} words); shorten sentences or simplify wording."
42
+ ),
43
+ section=title,
44
+ )
45
+ )
46
+ return findings
47
+
48
+
49
+ def _sections(doc: Document):
50
+ """Yield (line, title, text, word count) per section; the whole file if unheaded."""
51
+ bounds = [(line, title) for line, _, title in doc.headings] or [(1, None)]
52
+ starts = [line for line, _ in bounds]
53
+ chunks = [[] for _ in bounds]
54
+ for first, last in doc.paragraphs:
55
+ owner = bisect_right(starts, first) - 1
56
+ if owner >= 0: # paragraphs above the first heading belong to no section
57
+ chunks[owner] += [doc.masked[n - 1].strip() for n in range(first, last + 1)]
58
+ for (start, title), parts in zip(bounds, chunks):
59
+ text = " ".join(parts)
60
+ yield start, title, text, len(text.split())
@@ -0,0 +1,30 @@
1
+ """Sentence-length outliers. Regex segmentation, word count against a YAML maximum."""
2
+
3
+ from prose_preflight.checks._text import WORD, paragraphs, sentences, truncate
4
+ from prose_preflight.extract import Document
5
+ from prose_preflight.finding import Finding
6
+
7
+
8
+ def check(doc: Document, rule: dict) -> list[Finding]:
9
+ maximum = rule.get("max_words", 40)
10
+ severity = rule.get("severity", "warning")
11
+ findings = []
12
+ for text, positions in paragraphs(doc):
13
+ for offset, sentence in sentences(text):
14
+ words = len(WORD.findall(sentence))
15
+ if words <= maximum:
16
+ continue
17
+ line, col = positions[offset]
18
+ findings.append(
19
+ Finding(
20
+ check="sentence_length.long",
21
+ category="readability",
22
+ severity=severity,
23
+ line=line,
24
+ col=col,
25
+ excerpt=truncate(sentence, 40),
26
+ message=f"Sentence runs {words} words (limit {maximum}); split it.",
27
+ section=doc.section_at(line),
28
+ )
29
+ )
30
+ return findings
@@ -0,0 +1,46 @@
1
+ """Required sections and their order, from the YAML list. Titles match case-insensitively."""
2
+
3
+ from itertools import pairwise
4
+
5
+ from prose_preflight.extract import Document
6
+ from prose_preflight.finding import Finding
7
+
8
+
9
+ def check(doc: Document, rule: dict) -> list[Finding]:
10
+ required = rule.get("required_sections", [])
11
+ if not required:
12
+ return []
13
+ found = {} # required title (as configured) -> source line of its first match
14
+ for line, _, title in doc.headings:
15
+ for want in required:
16
+ if title.strip().lower() == want.lower() and want not in found:
17
+ found[want] = line
18
+ findings = [
19
+ Finding(
20
+ check="structure.missing_section",
21
+ category="structure",
22
+ severity=rule.get("severity", "error"),
23
+ line=1,
24
+ col=1,
25
+ excerpt=doc.path.name,
26
+ message=f"Required section {want!r} is missing.",
27
+ )
28
+ for want in required
29
+ if want not in found
30
+ ]
31
+ present = [want for want in required if want in found]
32
+ for earlier, later in pairwise(present):
33
+ if found[later] < found[earlier]:
34
+ findings.append(
35
+ Finding(
36
+ check="structure.section_order",
37
+ category="structure",
38
+ severity=rule.get("order_severity", "warning"),
39
+ line=found[later],
40
+ col=1,
41
+ excerpt=later,
42
+ message=f"Section {later!r} appears before {earlier!r}; expected order is {' → '.join(required)}.",
43
+ section=later,
44
+ )
45
+ )
46
+ return findings
@@ -0,0 +1,38 @@
1
+ """Preferred-form enforcement. The table is data; this module is the matcher."""
2
+
3
+ from prose_preflight.checks._text import alternation, excerpt
4
+ from prose_preflight.extract import Document
5
+ from prose_preflight.finding import Finding
6
+
7
+
8
+ def check(doc: Document, rule: dict) -> list[Finding]:
9
+ severity = rule.get("severity", "warning")
10
+ findings = []
11
+ for preferred, variants in rule.get("terms", {}).items():
12
+ pattern = alternation(variants, rule.get("ignore_case", True))
13
+ for n, line in doc.prose():
14
+ for m in pattern.finditer(line):
15
+ if m.group() == preferred:
16
+ continue
17
+ findings.append(
18
+ Finding(
19
+ check="terminology.variant",
20
+ category="terminology",
21
+ severity=severity,
22
+ line=n,
23
+ col=m.start() + 1,
24
+ excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
25
+ message=f"Use {preferred!r}, not {m.group()!r}.",
26
+ section=doc.section_at(n),
27
+ suggestion=_match_case(m.group(), preferred),
28
+ )
29
+ )
30
+ return findings
31
+
32
+
33
+ def _match_case(found: str, preferred: str) -> str:
34
+ """Carry the variant's case over to the suggestion. A single capital letter is
35
+ sentence case, not an acronym, so only multi-character ALL CAPS becomes ALL CAPS."""
36
+ if len(found) > 1 and found.isupper():
37
+ return preferred.upper()
38
+ return preferred[:1].upper() + preferred[1:] if found[:1].isupper() else preferred
@@ -0,0 +1,66 @@
1
+ """Number and unit *formatting* only -- spacing and dashes. Regex, not Pint."""
2
+
3
+ import re
4
+
5
+ from prose_preflight.checks._text import excerpt
6
+ from prose_preflight.extract import Document
7
+ from prose_preflight.finding import Finding
8
+
9
+ NBSP = " "
10
+ RULES = { # rule suffix: (pattern template, replacement, message)
11
+ "space_before_unit": (
12
+ r"(?<![\w.])(\d+(?:[.,]\d+)?)[ ]({units})\b",
13
+ lambda m: f"{m.group(1)}{NBSP}{m.group(2)}",
14
+ "Use a non-breaking space between value and unit.",
15
+ ),
16
+ "percent_spacing": (
17
+ r"(?<![\w.])(\d+(?:[.,]\d+)?)\s+%",
18
+ lambda m: f"{m.group(1)}%",
19
+ "Write the percent sign attached to the number.",
20
+ ),
21
+ "range_dash": (
22
+ r"(?<![\w.-])(\d+(?:[.,]\d+)?)-(\d+(?:[.,]\d+)?)(?![\w-])",
23
+ lambda m: f"{m.group(1)}–{m.group(2)}",
24
+ "Use an en dash for numeric ranges.",
25
+ ),
26
+ "p_value_spacing": (
27
+ r"\bp\s*([<>=≤≥])\s*(\.\d+|0\.\d+)",
28
+ lambda m: (
29
+ f"p {m.group(1)} {m.group(2) if m.group(2)[0] == '0' else '0' + m.group(2)}"
30
+ ),
31
+ "Write p-values as 'p < 0.05', with spaces and a leading zero.",
32
+ ),
33
+ }
34
+
35
+
36
+ def check(doc: Document, rule: dict) -> list[Finding]:
37
+ severity = rule.get("severity", "warning")
38
+ units = "|".join(
39
+ re.escape(u) for u in sorted(rule.get("units", []), key=len, reverse=True)
40
+ )
41
+ findings = []
42
+ for name, (template, replace, message) in RULES.items():
43
+ if name in rule.get("disabled_rules", []) or (
44
+ "{units}" in template and not units
45
+ ):
46
+ continue
47
+ pattern = re.compile(template.format(units=units))
48
+ for n, line in doc.prose():
49
+ for m in pattern.finditer(line):
50
+ suggestion = replace(m)
51
+ if suggestion == m.group():
52
+ continue
53
+ findings.append(
54
+ Finding(
55
+ check=f"units.{name}",
56
+ category="units",
57
+ severity=severity,
58
+ line=n,
59
+ col=m.start() + 1,
60
+ excerpt=excerpt(doc.lines[n - 1], m.start(), m.end()),
61
+ message=message,
62
+ section=doc.section_at(n),
63
+ suggestion=suggestion,
64
+ )
65
+ )
66
+ return findings
@@ -0,0 +1,92 @@
1
+ """Vale, behind the checker interface. An external binary that must never abort the run."""
2
+
3
+ import json
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from prose_preflight.extract import Document
9
+ from prose_preflight.finding import Finding
10
+
11
+ # The `vale` dependency ships the binary, so reaching this message means the
12
+ # environment blocked it: no network on first run, or a binary quarantined by the OS.
13
+ MISSING = (
14
+ "The Vale binary could not be found, so grammar and style were not checked. "
15
+ "It is normally installed with this package; a blocked first-run download is the "
16
+ "usual cause. Install it manually from https://vale.sh/docs/install and it will be "
17
+ "picked up from PATH."
18
+ )
19
+
20
+
21
+ def check(doc: Document, rule: dict) -> list[Finding]:
22
+ try:
23
+ alerts = _run(doc, rule)
24
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
25
+ return [
26
+ Finding(
27
+ check="vale.unavailable",
28
+ category="style",
29
+ severity="warning",
30
+ line=1,
31
+ col=1,
32
+ excerpt="",
33
+ message=MISSING
34
+ if isinstance(exc, FileNotFoundError)
35
+ else f"Vale failed: {exc}",
36
+ section=None,
37
+ suggestion=None,
38
+ )
39
+ ]
40
+ return [
41
+ Finding(
42
+ # ponytail: every Vale rule lands in `style`. Split into grammar/spelling
43
+ # by rule prefix once a config needs to weight them differently.
44
+ check=f"vale.{alert['Check']}",
45
+ category="style",
46
+ severity="error" if alert["Severity"].lower() == "error" else "warning",
47
+ line=alert["Line"],
48
+ col=alert["Span"][0],
49
+ excerpt=alert["Match"],
50
+ message=alert["Message"],
51
+ section=doc.section_at(alert["Line"]),
52
+ suggestion=next(iter(alert.get("Action", {}).get("Params") or []), None),
53
+ )
54
+ for alert in alerts
55
+ ]
56
+
57
+
58
+ def _run(doc: Document, rule: dict) -> list[dict]:
59
+ """Invoke Vale on the document. Vale exits nonzero when it finds alerts, so only
60
+ the JSON on stdout decides success."""
61
+ with tempfile.TemporaryDirectory() as tmp:
62
+ ini = rule.get("config_file") or _write_ini(Path(tmp), rule)
63
+ path = _readable(doc, Path(tmp))
64
+ result = subprocess.run(
65
+ ["vale", "--output=JSON", f"--config={ini}", str(path)],
66
+ capture_output=True,
67
+ check=False, # nonzero just means "alerts found"
68
+ text=True,
69
+ timeout=rule.get("timeout", 60),
70
+ )
71
+ if not result.stdout.strip():
72
+ raise subprocess.SubprocessError(result.stderr.strip() or "no output")
73
+ report = json.loads(result.stdout)
74
+ return [alert for alerts in report.values() for alert in alerts]
75
+
76
+
77
+ def _readable(doc: Document, tmp: Path) -> Path:
78
+ """Vale parses Markdown and plain text; it has no LaTeX reader, and pointed at a
79
+ .tex file it would flag the preamble and every command. `doc.masked` is already
80
+ the prose alone, line for line, so any other format goes to Vale as masked text."""
81
+ if doc.path.suffix.lower() in {".md", ".markdown", ".txt"}:
82
+ return doc.path
83
+ masked = tmp / f"{doc.path.stem}.txt"
84
+ masked.write_text("\n".join(doc.masked))
85
+ return masked
86
+
87
+
88
+ def _write_ini(tmp: Path, rule: dict) -> Path:
89
+ """Vale's own config is an INI string in `default.yaml`, so it stays data."""
90
+ ini = tmp / ".vale.ini"
91
+ ini.write_text(rule.get("ini", ""))
92
+ return ini
@@ -0,0 +1,161 @@
1
+ # Bundled config. A user file passed with --config is deep-merged over this one.
2
+ # Rule keys land here alongside their checkers; a new document convention is a
3
+ # YAML edit, never a code change.
4
+ checks:
5
+ terminology:
6
+ enabled: true
7
+ severity: warning
8
+ ignore_case: true
9
+ # Preferred form: variants that should become it. Empty by default -- the right
10
+ # spellings are your journal's, not ours. Uncomment and edit:
11
+ terms: {}
12
+ # dataset: [data set, data-set]
13
+ # machine learning: [machine-learning]
14
+ # ground truth: [ground-truth, groundtruth]
15
+ readability:
16
+ enabled: true
17
+ severity: warning
18
+ min_words: 50 # sections shorter than this score as noise
19
+ min_flesch: 30.0 # below: hard to read
20
+ max_fog: 18.0 # above: too many long words per sentence
21
+ sentence_length:
22
+ enabled: true
23
+ severity: warning
24
+ max_words: 40
25
+ acronym:
26
+ enabled: true
27
+ severity: warning
28
+ min_uses: 1 # raise to ignore one-off mentions
29
+ # never expanded: too common, or not really acronyms
30
+ allowlist:
31
+ [
32
+ API,
33
+ CPU,
34
+ GPU,
35
+ RAM,
36
+ PDF,
37
+ HTML,
38
+ JSON,
39
+ YAML,
40
+ XML,
41
+ CSV,
42
+ URL,
43
+ USA,
44
+ EU,
45
+ UK,
46
+ ID,
47
+ OK,
48
+ TODO,
49
+ FAQ,
50
+ PhD,
51
+ DOI,
52
+ ISBN,
53
+ GDP,
54
+ AM,
55
+ PM,
56
+ UTC,
57
+ ISO,
58
+ IEEE,
59
+ ACM,
60
+ ]
61
+ structure:
62
+ enabled: true
63
+ severity: error # a missing section
64
+ order_severity: warning # a section out of order
65
+ # Empty by default: most documents are fragments, not whole papers. Set the list to
66
+ # opt in, e.g. [Abstract, Introduction, Methods, Results, Discussion, References].
67
+ required_sections: []
68
+ units:
69
+ enabled: true
70
+ severity: warning
71
+ disabled_rules: [] # any of: space_before_unit, percent_spacing, range_dash, p_value_spacing
72
+ units:
73
+ [
74
+ mm,
75
+ cm,
76
+ m,
77
+ km,
78
+ mg,
79
+ g,
80
+ kg,
81
+ ms,
82
+ s,
83
+ min,
84
+ h,
85
+ Hz,
86
+ kHz,
87
+ MHz,
88
+ GHz,
89
+ kB,
90
+ MB,
91
+ GB,
92
+ TB,
93
+ W,
94
+ kW,
95
+ V,
96
+ A,
97
+ K,
98
+ °C,
99
+ °F,
100
+ px,
101
+ dpi,
102
+ bp,
103
+ mol,
104
+ N,
105
+ Pa,
106
+ J,
107
+ kJ,
108
+ ]
109
+ claim:
110
+ enabled: true # severity is always `review`; never auto-applied
111
+ hedges:
112
+ [
113
+ may,
114
+ might,
115
+ could,
116
+ possibly,
117
+ perhaps,
118
+ arguably,
119
+ seems to,
120
+ appears to,
121
+ somewhat,
122
+ relatively,
123
+ fairly,
124
+ rather,
125
+ quite,
126
+ suggests that,
127
+ tends to,
128
+ ]
129
+ boosters:
130
+ [
131
+ clearly,
132
+ obviously,
133
+ undoubtedly,
134
+ certainly,
135
+ definitely,
136
+ significantly,
137
+ dramatically,
138
+ drastically,
139
+ vastly,
140
+ extremely,
141
+ profoundly,
142
+ unprecedented,
143
+ groundbreaking,
144
+ revolutionary,
145
+ novel,
146
+ optimal,
147
+ perfect,
148
+ proves,
149
+ proven,
150
+ ]
151
+ vale:
152
+ enabled: true
153
+ timeout: 60
154
+ # Vale's own config, kept as data. Built-in `Vale.*` rules only: no `vale sync`,
155
+ # no downloaded styles, so runs stay offline and deterministic. Point
156
+ # `config_file` at your own .vale.ini to use styles you have synced.
157
+ config_file: null
158
+ ini: |
159
+ MinAlertLevel = suggestion
160
+ [*.{md,markdown,txt}]
161
+ BasedOnStyles = Vale
@@ -0,0 +1,17 @@
1
+ """Extraction: pick a reader by suffix, return one normalized `Document`."""
2
+
3
+ from pathlib import Path
4
+
5
+ from prose_preflight.extract import document, latex, markdown
6
+ from prose_preflight.extract.document import Document
7
+
8
+ __all__ = ["Document", "read"]
9
+
10
+ READERS = {".tex": latex.read, ".md": markdown.read, ".markdown": markdown.read}
11
+
12
+
13
+ def read(path: str | Path) -> Document:
14
+ """Anything without a known suffix is read as plain text."""
15
+ path = Path(path)
16
+ reader = READERS.get(path.suffix.lower(), document.read)
17
+ return reader(path, path.read_text(encoding="utf-8"))