From 626779d4aa8e6f3aaac2c339c384cb5e6de42847 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 3 Jan 2026 20:29:35 +0000 Subject: [PATCH] Initial iftypeset pipeline --- .forgejo/workflows/ci.yml | 25 + .gitignore | 12 + README.md | 41 + STATUS.md | 78 + app/ARCHITECTURE.md | 93 + app/CLI_SPEC.md | 130 ++ docs/01-demo-acceptance.md | 98 + docs/02-competitor-matrix.md | 85 + docs/03-rule-ingestion-sop.md | 138 ++ docs/04-renderer-strategy.md | 126 ++ docs/05-external-evaluation-prompt.md | 236 +++ docs/06-project-overview.md | 73 + docs/07-session-resilience.md | 40 + docs/CHECKPOINTS.md | 17 + fixtures/abbreviations.md | 3 + fixtures/accessibility_alt.md | 3 + fixtures/backmatter_stub.md | 5 + fixtures/citations_author_date.md | 4 + fixtures/citations_bibliography.md | 4 + fixtures/citations_notes.md | 4 + fixtures/code_inline.md | 3 + fixtures/code_long_lines.md | 5 + fixtures/figures_placeholder.md | 3 + fixtures/frontmatter_stub.md | 5 + fixtures/headings_basic.md | 9 + fixtures/headings_deep.md | 5 + fixtures/headings_numbered.md | 7 + fixtures/hyphenation.md | 3 + fixtures/i18n_quotes.md | 3 + fixtures/layout_pagebreaks.md | 5 + fixtures/layout_spacing.md | 6 + fixtures/layout_widow.md | 5 + fixtures/links_bare_url.md | 3 + fixtures/links_long_url.md | 4 + fixtures/lists_ordered.md | 5 + fixtures/lists_unordered.md | 5 + fixtures/numbers_currency.md | 3 + fixtures/numbers_dates.md | 4 + fixtures/numbers_ranges.md | 3 + fixtures/numbers_spelling.md | 4 + fixtures/punctuation_colons.md | 4 + fixtures/punctuation_commas.md | 4 + fixtures/punctuation_dashes.md | 4 + fixtures/punctuation_quotes.md | 4 + fixtures/punctuation_semicolons.md | 4 + fixtures/sample.md | 29 + fixtures/tables_alignment.md | 5 + fixtures/tables_basic.md | 6 + fixtures/tables_wide.md | 5 + fixtures/typography_fonts.md | 3 + forgejo/README.md | 117 + pyproject.toml | 16 + requirements.txt | 2 + scripts/audit.sh | 56 + scripts/checkpoint.sh | 65 + scripts/ci.sh | 26 + spec/examples/README.md | 88 + spec/extraction_plan.md | 112 + spec/house/HOUSE_RULES.md | 59 + spec/indexes/README.md | 74 + spec/indexes/category.json | 329 +++ spec/indexes/enforcement.json | 317 +++ spec/indexes/keywords_all.json | 1883 +++++++++++++++++ spec/indexes/source_refs_all.json | 732 +++++++ spec/manifest.yaml | 142 ++ spec/profiles/dense_tech.yaml | 84 + spec/profiles/memo.yaml | 76 + spec/profiles/print_pdf.yaml | 88 + spec/profiles/slide_deck.yaml | 76 + spec/profiles/web_pdf.yaml | 96 + spec/quality_gates.yaml | 132 ++ .../accessibility/v1_accessibility_001.ndjson | 4 + spec/rules/citations/v1_citations_001.ndjson | 16 + spec/rules/citations/v1_citations_002.ndjson | 45 + spec/rules/code/v1_code_001.ndjson | 4 + spec/rules/headings/v1_headings_001.ndjson | 12 + spec/rules/headings/v1_headings_002.ndjson | 20 + spec/rules/layout/v1_layout_001.ndjson | 12 + spec/rules/layout/v1_layout_002.ndjson | 30 + spec/rules/layout/v1_layout_003.ndjson | 4 + spec/rules/links/v1_links_001.ndjson | 5 + spec/rules/numbers/v1_numbers_001.ndjson | 12 + spec/rules/numbers/v1_numbers_002.ndjson | 50 + .../punctuation/v1_punctuation_001.ndjson | 15 + .../punctuation/v1_punctuation_002.ndjson | 40 + spec/rules/tables/v1_tables_001.ndjson | 8 + spec/rules/tables/v1_tables_002.ndjson | 15 + .../rules/typography/v1_typography_001.ndjson | 8 + .../rules/typography/v1_typography_002.ndjson | 7 + spec/schema/rule.schema.json | 181 ++ src/iftypeset/__init__.py | 4 + src/iftypeset/cli.py | 431 ++++ src/iftypeset/css_gen.py | 242 +++ src/iftypeset/index_builder.py | 67 + src/iftypeset/linting.py | 343 +++ src/iftypeset/md_parser.py | 329 +++ src/iftypeset/qa.py | 344 +++ src/iftypeset/rendering.py | 384 ++++ src/iftypeset/reporting.py | 104 + src/iftypeset/rules.py | 78 + src/iftypeset/spec_loader.py | 102 + tests/test_iftypeset_smoke.py | 53 + tests/test_integration.py | 93 + tests/test_linting.py | 44 + tests/test_md_parser.py | 23 + tests/test_qa.py | 38 + tools/README.md | 14 + tools/bringhurst_locate.py | 112 + tools/chicago_ocr.py | 123 ++ .../citations_source_refs_chicago.json | 20 + .../punctuation_source_refs_chicago.json | 50 + tools/ndjson_patch.py | 121 ++ 112 files changed, 9550 insertions(+) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 STATUS.md create mode 100644 app/ARCHITECTURE.md create mode 100644 app/CLI_SPEC.md create mode 100644 docs/01-demo-acceptance.md create mode 100644 docs/02-competitor-matrix.md create mode 100644 docs/03-rule-ingestion-sop.md create mode 100644 docs/04-renderer-strategy.md create mode 100644 docs/05-external-evaluation-prompt.md create mode 100644 docs/06-project-overview.md create mode 100644 docs/07-session-resilience.md create mode 100644 docs/CHECKPOINTS.md create mode 100644 fixtures/abbreviations.md create mode 100644 fixtures/accessibility_alt.md create mode 100644 fixtures/backmatter_stub.md create mode 100644 fixtures/citations_author_date.md create mode 100644 fixtures/citations_bibliography.md create mode 100644 fixtures/citations_notes.md create mode 100644 fixtures/code_inline.md create mode 100644 fixtures/code_long_lines.md create mode 100644 fixtures/figures_placeholder.md create mode 100644 fixtures/frontmatter_stub.md create mode 100644 fixtures/headings_basic.md create mode 100644 fixtures/headings_deep.md create mode 100644 fixtures/headings_numbered.md create mode 100644 fixtures/hyphenation.md create mode 100644 fixtures/i18n_quotes.md create mode 100644 fixtures/layout_pagebreaks.md create mode 100644 fixtures/layout_spacing.md create mode 100644 fixtures/layout_widow.md create mode 100644 fixtures/links_bare_url.md create mode 100644 fixtures/links_long_url.md create mode 100644 fixtures/lists_ordered.md create mode 100644 fixtures/lists_unordered.md create mode 100644 fixtures/numbers_currency.md create mode 100644 fixtures/numbers_dates.md create mode 100644 fixtures/numbers_ranges.md create mode 100644 fixtures/numbers_spelling.md create mode 100644 fixtures/punctuation_colons.md create mode 100644 fixtures/punctuation_commas.md create mode 100644 fixtures/punctuation_dashes.md create mode 100644 fixtures/punctuation_quotes.md create mode 100644 fixtures/punctuation_semicolons.md create mode 100644 fixtures/sample.md create mode 100644 fixtures/tables_alignment.md create mode 100644 fixtures/tables_basic.md create mode 100644 fixtures/tables_wide.md create mode 100644 fixtures/typography_fonts.md create mode 100644 forgejo/README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100755 scripts/audit.sh create mode 100755 scripts/checkpoint.sh create mode 100755 scripts/ci.sh create mode 100644 spec/examples/README.md create mode 100644 spec/extraction_plan.md create mode 100644 spec/house/HOUSE_RULES.md create mode 100644 spec/indexes/README.md create mode 100644 spec/indexes/category.json create mode 100644 spec/indexes/enforcement.json create mode 100644 spec/indexes/keywords_all.json create mode 100644 spec/indexes/source_refs_all.json create mode 100644 spec/manifest.yaml create mode 100644 spec/profiles/dense_tech.yaml create mode 100644 spec/profiles/memo.yaml create mode 100644 spec/profiles/print_pdf.yaml create mode 100644 spec/profiles/slide_deck.yaml create mode 100644 spec/profiles/web_pdf.yaml create mode 100644 spec/quality_gates.yaml create mode 100644 spec/rules/accessibility/v1_accessibility_001.ndjson create mode 100644 spec/rules/citations/v1_citations_001.ndjson create mode 100644 spec/rules/citations/v1_citations_002.ndjson create mode 100644 spec/rules/code/v1_code_001.ndjson create mode 100644 spec/rules/headings/v1_headings_001.ndjson create mode 100644 spec/rules/headings/v1_headings_002.ndjson create mode 100644 spec/rules/layout/v1_layout_001.ndjson create mode 100644 spec/rules/layout/v1_layout_002.ndjson create mode 100644 spec/rules/layout/v1_layout_003.ndjson create mode 100644 spec/rules/links/v1_links_001.ndjson create mode 100644 spec/rules/numbers/v1_numbers_001.ndjson create mode 100644 spec/rules/numbers/v1_numbers_002.ndjson create mode 100644 spec/rules/punctuation/v1_punctuation_001.ndjson create mode 100644 spec/rules/punctuation/v1_punctuation_002.ndjson create mode 100644 spec/rules/tables/v1_tables_001.ndjson create mode 100644 spec/rules/tables/v1_tables_002.ndjson create mode 100644 spec/rules/typography/v1_typography_001.ndjson create mode 100644 spec/rules/typography/v1_typography_002.ndjson create mode 100644 spec/schema/rule.schema.json create mode 100644 src/iftypeset/__init__.py create mode 100644 src/iftypeset/cli.py create mode 100644 src/iftypeset/css_gen.py create mode 100644 src/iftypeset/index_builder.py create mode 100644 src/iftypeset/linting.py create mode 100644 src/iftypeset/md_parser.py create mode 100644 src/iftypeset/qa.py create mode 100644 src/iftypeset/rendering.py create mode 100644 src/iftypeset/reporting.py create mode 100644 src/iftypeset/rules.py create mode 100644 src/iftypeset/spec_loader.py create mode 100644 tests/test_iftypeset_smoke.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_linting.py create mode 100644 tests/test_md_parser.py create mode 100644 tests/test_qa.py create mode 100644 tools/README.md create mode 100644 tools/bringhurst_locate.py create mode 100644 tools/chicago_ocr.py create mode 100644 tools/mappings/citations_source_refs_chicago.json create mode 100644 tools/mappings/punctuation_source_refs_chicago.json create mode 100644 tools/ndjson_patch.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..115af0c --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: + push: + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install deps + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run CI + run: bash scripts/ci.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10bd4be --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.venv/ +.env/ +.DS_Store + +/out/ +/out-css/ +/dist/ +/build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f48158e --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# iftypeset (pubstyle) — publication-quality typesetting pipeline + +This project is a **thin, deterministic runtime** for turning Markdown into high‑quality HTML/PDF using: + +- **A machine‑readable rule registry** (Chicago / Bringhurst pointers; paraphrased rules only) +- **Typeset profiles** (`spec/profiles/*.yaml`) that map typographic intent → render tokens +- **Post‑render QA gates** (`spec/quality_gates.yaml`) that fail builds when layout degrades + +It is designed to be embedded into constrained workers (e.g. Forgejo PDF export with `--network=none`) and also run as a standalone CLI. + +**Status:** working spec + seeded rule registry. See `STATUS.md`. + +## Constraints (non-negotiable) + +- **No bulk OCR/transcription** of Chicago/Bringhurst into this repo (copyright). +- Rule records are **paraphrases only**, backed by **pointers** (e.g. `CMOS18 §X.Y pNNN (scan pMMM)`). +- Chicago OCR (when needed) must be **ephemeral** (extract just enough to locate pointers; do not store page text). + +## Quickstart (current) + +From `ai-workspace/iftypeset/`: + +- (Optional) Install deps into a venv: `python3 -m venv .venv && . .venv/bin/activate && python -m pip install -r requirements.txt` +- Validate spec + rebuild indexes: `PYTHONPATH=src python3 -m iftypeset.cli validate-spec --spec spec --build-indexes` +- Lint Markdown: `PYTHONPATH=src python3 -m iftypeset.cli lint --input fixtures/sample.md --out out --profile web_pdf` +- Render HTML + CSS: `PYTHONPATH=src python3 -m iftypeset.cli render-html --input fixtures/sample.md --out out --profile web_pdf` +- Render PDF (if an engine is installed): `PYTHONPATH=src python3 -m iftypeset.cli render-pdf --input fixtures/sample.md --out out --profile web_pdf` +- Run QA gates (HTML fallback if no PDF): `PYTHONPATH=src python3 -m iftypeset.cli qa --out out --profile web_pdf` +- Coverage report: `PYTHONPATH=src python3 -m iftypeset.cli report --spec spec --out out` +- Run self-check tests: `python3 -m unittest discover -s tests -p 'test_*.py'` + +## PDF renderers + +`render-pdf` will use the first available engine in this order: + +- `playwright` (Python module) +- `chromium` / `chromium-browser` / `google-chrome` +- `wkhtmltopdf` +- `weasyprint` (Python module) + +If none are installed, the command exits with a clear message but still leaves HTML artifacts for QA. diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..c1ed3e6 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,78 @@ +# iftypeset status (pubstyle) + +**Updated:** 2026-01-03 +**Project root:** `/root/ai-workspace/iftypeset/` + +## What exists (working today) + +- **Spec + schema:** `spec/schema/rule.schema.json`, `spec/manifest.yaml` +- **Profiles:** `spec/profiles/*.yaml` (`web_pdf`, `print_pdf`, `dense_tech`, `memo`, `slide_deck`) +- **Post-render QA gates:** `spec/quality_gates.yaml` +- **Rule registry (seeded):** `spec/rules/**.ndjson` +- **Indexes (derived):** `spec/indexes/*.json` (rebuildable) +- **CLI:** `validate-spec`, `report`, `lint`, `render-html`, `render-pdf`, `qa`, `emit-css` +- **Ephemeral extraction helpers:** `tools/` (Chicago OCR is grep-only, temp files deleted) +- **Forgejo integration note:** `forgejo/README.md` +- **Fixtures + tests:** `fixtures/` and `tests/` +- **CI script:** `scripts/ci.sh` (validate-spec, report, unit tests) + +## Rule corpus snapshot + +From `out/coverage-report.json`: + +- **Total rules:** 307 +- **By category:** citations 61, numbers 62, punctuation 55, layout 46, headings 32, tables 23, typography 15, links 5, accessibility 4, code 4 +- **By enforcement:** manual 186, typeset 62, lint 46, postrender 13 +- **By severity:** must 28, should 263, warn 16 + +## Current rule batches + +- `spec/rules/accessibility/v1_accessibility_001.ndjson` (4) +- `spec/rules/citations/v1_citations_001.ndjson` (16) +- `spec/rules/citations/v1_citations_002.ndjson` (45) +- `spec/rules/code/v1_code_001.ndjson` (4) +- `spec/rules/headings/v1_headings_001.ndjson` (12) +- `spec/rules/headings/v1_headings_002.ndjson` (20) +- `spec/rules/layout/v1_layout_001.ndjson` (12) +- `spec/rules/layout/v1_layout_002.ndjson` (30) +- `spec/rules/layout/v1_layout_003.ndjson` (4) +- `spec/rules/links/v1_links_001.ndjson` (5) +- `spec/rules/numbers/v1_numbers_001.ndjson` (12) +- `spec/rules/numbers/v1_numbers_002.ndjson` (50) +- `spec/rules/punctuation/v1_punctuation_001.ndjson` (15) +- `spec/rules/punctuation/v1_punctuation_002.ndjson` (40) +- `spec/rules/tables/v1_tables_001.ndjson` (8) +- `spec/rules/tables/v1_tables_002.ndjson` (15) +- `spec/rules/typography/v1_typography_001.ndjson` (8) +- `spec/rules/typography/v1_typography_002.ndjson` (7) + +## How to validate and inspect + +- Validate spec + rebuild indexes: + - `PYTHONPATH=src python3 -m iftypeset.cli validate-spec --spec spec --build-indexes` +- Lint: + - `PYTHONPATH=src python3 -m iftypeset.cli lint --input fixtures/sample.md --out out --profile web_pdf` +- Render HTML + CSS: + - `PYTHONPATH=src python3 -m iftypeset.cli render-html --input fixtures/sample.md --out out --profile web_pdf` +- Render PDF (if renderer installed): + - `PYTHONPATH=src python3 -m iftypeset.cli render-pdf --input fixtures/sample.md --out out --profile web_pdf` +- Run QA gates (HTML fallback if no PDF): + - `PYTHONPATH=src python3 -m iftypeset.cli qa --out out --profile web_pdf` +- Coverage report: + - `PYTHONPATH=src python3 -m iftypeset.cli report --spec spec --out out --build-indexes` +- Emit CSS for a profile: + - `PYTHONPATH=src python3 -m iftypeset.cli emit-css --spec spec --profile web_pdf --out out-css` +- Run unit tests: + - `python3 -m unittest discover -s tests -p 'test_*.py'` + +## Key constraints (don’t drift) + +- **No bulk OCR/transcription** of books into repo. Rules must be paraphrased and pointer-backed. +- `source_refs` must be **pointers**, not quotes; include `(scan pN)` only as a single page hint. +- Chicago extraction may use OCR **ephemerally** only to locate pointers; do not persist OCR output. + +## Next work (highest leverage) + +- Add new batches for: `figures`, `frontmatter`, `backmatter`, `abbreviations`, `i18n`, and expand `accessibility`. +- Grow post-render QA rule coverage (widows/orphans, heading keeps, overflow) beyond the current seed set. +- Add a real PDF-layout analyzer when a stable renderer is selected (widows/orphans, overflow). diff --git a/app/ARCHITECTURE.md b/app/ARCHITECTURE.md new file mode 100644 index 0000000..13aa669 --- /dev/null +++ b/app/ARCHITECTURE.md @@ -0,0 +1,93 @@ +# Runtime Architecture + +This is a thin, deterministic runtime that: + +A) ingests Markdown → normalizes a document AST → applies editorial lint (Chicago-derived) +B) applies typeset tokens/profile (Bringhurst-derived) +C) renders HTML and PDF deterministically +D) runs post-render QA gates (widows/orphans, heading keeps, overflow) +E) generates `layout-report.json` and fails builds when thresholds are exceeded + +Primary reference PDFs are used for pointer-based traceability only: + +* The Chicago Manual of Style (18th ed).pdf +* Robert Bringhurst – The Elements of Typographic Style.pdf + +No bulk transcription is performed; rules are paraphrases and cite sources only by pointer. + +## Components + +### 1) Registry Loader + +Inputs: + +* `spec/rules/**.ndjson` (Phase 2 output) +* `spec/schema/rule.schema.json` +* `spec/manifest.yaml` +* `spec/profiles/*.yaml` +* `spec/quality_gates.yaml` + +Responsibilities: + +* validate each rule against JSON Schema +* enforce ID uniqueness and stable sorting +* build or load indexes in `spec/indexes/*.json` +* compute coverage (implemented vs unimplemented; by enforcement) + +Output (in-memory): + +* `RuleStore` (rules + indexes + profile overrides + gate thresholds) + +### 2) Markdown Ingest + AST Normalization + +Design note: initial scaffold will ship with a minimal Markdown parser strategy and can be adapted to the existing Forgejo renderer pipeline. + +Degraded mode: + +* If parsing fails or structure is missing, switch to minimal node set and mark `structure_confidence: low`. +* Run the “degraded mode contract” from `spec/manifest.yaml`. + +### 3) Editorial Lint Engine + +* Runs `lint`-enforced rules against normalized AST. +* Emits diagnostics (`lint-report.json`, optional SARIF). +* Autofix is optional and must be deterministic. + +### 4) Typeset Profile Engine + +* Converts typographic intent into deterministic render inputs (CSS tokens + policies). +* Emits `render.css`, `typeset-report.json`. + +### 5) Deterministic Rendering + +* HTML generation must be stable (DOM order, IDs, whitespace). +* PDF generation must be deterministic given the same inputs and renderer version. + +### 6) Post-render QA Analyzer + +Detects: + +* widows/orphans +* stranded headings (keep-with-next) +* overfull lines +* table overflow/clipping +* code overflow/clipping +* link wrap incidents + +Artifacts: + +* `layout-report.json` (canonical QA report) +* `qa-report.json` (gate evaluation + failures) + +## Coverage Reporting and CI Guardrails + +Coverage is computed from: + +* total active rules +* rules with an implemented enforcement handler (lint/typeset/postrender/manual) + +CI policy (from manifest): + +* fail if MUST coverage drops +* fail if overall implemented coverage drops + diff --git a/app/CLI_SPEC.md b/app/CLI_SPEC.md new file mode 100644 index 0000000..5a4621d --- /dev/null +++ b/app/CLI_SPEC.md @@ -0,0 +1,130 @@ +# CLI Specification + +The CLI is designed for CI use: deterministic outputs, stable exit codes, and JSON artifacts for tooling. + +## Common flags (all commands) + +This is the *target* contract; v0 currently implements a subset per-command. + +* `--spec `: Spec root directory (default: `spec/`). +* `--input `: Markdown file or directory (where applicable). +* `--out `: Output directory (default: `out/`). +* `--profile `: One of: `web_pdf`, `print_pdf`, `dense_tech`, `memo`, `slide_deck` (where applicable). +* `--strict`: Use strict thresholds in `spec/quality_gates.yaml` (QA/report). +* `--format `: Lint output format. +* `--fail-on `: Lowest severity that fails `lint` (default: `must`). +* `--degraded-ok`: Allow degraded mode without failing (lint/render-html). +* `--version`: Print tool version and exit. + +## Command: `validate-spec` + +Purpose: + +* Validate YAML/JSON spec files and (when present) rule NDJSON batches. + +Outputs: + +* `out/spec-validation.json` + +Exit codes: + +* `0`: ok +* `4`: config/schema error +* `5`: internal error + +## Command: `report` + +Purpose: + +* Produce a consolidated report (coverage + indexes status). + +Outputs: + +* `out/coverage-report.json` +* `out/coverage-summary.md` + +Exit codes: + +* `0`: report built +* `2`: coverage floor violated (only when rule batches exist) +* `4`: config/schema error +* `5`: internal error + +## Command: `lint` + +Purpose: + +* Parse Markdown into a minimal block AST (headings, paragraphs, lists, code fences, tables). +* Emit deterministic diagnostics and a manual checklist derived from the registry. + +Outputs: + +* `out/lint-report.json` +* `out/manual-checklist.md` +* `out/manual-checklist.json` +* `out/degraded-mode-report.json` (only when degraded mode triggers) +* `out/lint-report.sarif` (only when `--format sarif`) +* `out/fix-suggestions.json` (only when `--fix --fix-mode suggest`) +* `out/fixed/*.md` (only when `--fix --fix-mode rewrite`) + +Exit codes: + +* `0`: ok +* `2`: lint failed (fail-on threshold exceeded, or degraded mode without `--degraded-ok`) +* `4`: config error + +## Command: `render-html` + +Purpose: + +* Render Markdown to deterministic HTML and CSS based on a profile. + +Outputs: + +* `out/render.html` +* `out/render.css` +* `out/typeset-report.json` +* `out/degraded-mode-report.json` (only when degraded mode triggers) + +Exit codes: + +* `0`: ok +* `2`: degraded mode without `--degraded-ok` +* `4`: config error + +## Command: `render-pdf` + +Purpose: + +* Render Markdown to PDF using the first available engine (playwright/chromium/wkhtmltopdf/weasyprint). + +Outputs: + +* `out/render.html` +* `out/render.css` +* `out/typeset-report.json` +* `out/render-log.json` +* `out/render.pdf` (only when rendering succeeds) + +Exit codes: + +* `0`: ok +* `3`: renderer missing or engine error (see `out/render-log.json`) +* `4`: config error + +## Command: `qa` + +Purpose: + +* Run deterministic post-render QA checks (HTML analysis v0) and enforce numeric gates from `spec/quality_gates.yaml`. + +Outputs: + +* `out/layout-report.json` +* `out/qa-report.json` + +Exit codes: + +* `0`: gates pass +* `2`: gates fail +* `4`: config error / missing `out/render.html` diff --git a/docs/01-demo-acceptance.md b/docs/01-demo-acceptance.md new file mode 100644 index 0000000..4abe0c2 --- /dev/null +++ b/docs/01-demo-acceptance.md @@ -0,0 +1,98 @@ +# iftypeset v0.1 — Demo Acceptance Pack + +This doc defines what “ship‑ready” means for `iftypeset` (pubstyle): a deterministic Markdown → HTML → PDF pipeline with enforceable quality gates and auditable artifacts. + +## Goals + +- Produce **consistently good-looking** outputs from plain Markdown without manual layout heroics. +- Provide **machine-verifiable QA gates** (layout/report JSON) suitable for CI. +- Keep the system **renderer-agnostic** via adapters (Chromium / WeasyPrint / Prince / Antenna House / Vivliostyle, etc.). +- Preserve legal constraints: **rules are paraphrases + pointers**, never book text. + +## Definition of Done (v0.1) + +“Done” means the following commands are boring and reliable: + +- `lint` emits deterministic diagnostics and a manual checklist. +- `render-html` emits deterministic HTML + CSS for a profile. +- `qa` runs on HTML (always) and on PDF (when available) and fails builds when gates exceed thresholds. +- `report` shows rule coverage and doesn’t regress. + +## Profiles to Support in v0.1 + +- `web_pdf` (screen-first): tolerant, readable, strong accessibility defaults. +- `dense_tech` (specs): tighter measure targets, code/tables common, strict numbering/citations. +- `memo` (internal): conservative, low typographic complexity. + +## Fixture Set (minimum) + +Create and maintain fixtures that represent real “pain” documents. v0.1 should ship with at least these: + +1. **Memo (short, mixed content)** — bullets + links + a couple of headings + - Stress: heading hierarchy, link wrapping, list spacing. +2. **Dense technical note** — headings, numbered sections, code blocks, small tables + - Stress: code wrapping/overflow policy, table overflow policy, numbering monotonicity. +3. **Report with many links** — long URLs/DOIs/emails, references section + - Stress: link wrap policy, footnote/reference formatting, readability. +4. **Table-heavy checklist** — 3–5 tables, some wide + - Stress: table overflow handling, header repeat policy (HTML), clipping detection (PDF when possible). +5. **Degraded input** — hard-wrapped paragraphs + inconsistent headings + - Stress: degraded-mode contract: unwrap/recover safely + emit degraded-mode report. + +## Acceptance Gates (must pass) + +### Lint gates (always on) + +- No schema/spec validation failures. +- `lint-report.json` is produced and deterministic across two runs. +- `manual-checklist.md` is produced and contains only rules tagged `manual_checklist=true`. + +### HTML render gates (always on) + +- `render.html` and `render.css` produced deterministically. +- CSS tokens reflect chosen profile (page size, margins, font stacks, line-height). +- No external fetches in HTML (self-contained mode must embed local assets). + +### QA gates (HTML fallback; PDF when available) + +Minimum v0.1 gate set: + +- `max_link_wrap_incidents` (catch “unbreakable” URLs / DOIs / emails). +- `max_table_overflow_incidents` (wide tables). +- `max_code_overflow_incidents` (wide code blocks). +- `max_stranded_headings` (keep-with-next heuristic). +- `max_heading_numbering_errors` (basic numbering monotonicity). + +PDF-only gates (enable when a PDF engine is available): + +- widows/orphans +- overfull lines (glyph boxes exceed text block) + +## Multi-renderer Compatibility (design requirement) + +v0.1 should treat the PDF engine as an adapter. Acceptance criteria: + +- `render-pdf --engine auto`: + - selects an available engine, + - writes `render-log.json` including engine name + version, + - fails clearly if no engine available (but keeps HTML artifacts). + +Renderer capability differences must be explicit in `render-log.json` and `qa-report.json` (e.g., “widow/orphan detection unavailable for HTML-only run”). + +## Demo Script (for humans) + +For a convincing “this is real” demo: + +1. Run `lint` + show `lint-report.json` and `manual-checklist.md`. +2. Run `render-html` + open `out/render.html` (show the profile look). +3. Run `qa` + show `qa-report.json` (pass/fail, counts). +4. If PDF engine exists, show the rendered PDF and the same QA gates on PDF. + +## Release Checklist (v0.1) + +- [ ] At least 30 fixtures exist and are exercised in CI. +- [ ] CI fails when QA thresholds are exceeded (no “green by vibes”). +- [ ] CI fails on spec regression (coverage floors, schema validation). +- [ ] Degraded mode emits its report artifacts and never silently “fixes” content. +- [ ] Renderer adapters are documented (how `auto` chooses, how to pin). + diff --git a/docs/02-competitor-matrix.md b/docs/02-competitor-matrix.md new file mode 100644 index 0000000..eb5d078 --- /dev/null +++ b/docs/02-competitor-matrix.md @@ -0,0 +1,85 @@ +# iftypeset — Competitor / Positioning Matrix (v0.1) + +This is a practical market map to keep us honest about what exists today and what `iftypeset` is trying to be. + +## The category we’re building + +Not “Markdown to PDF” (that’s solved), but: + +**Deterministic publishing CI**: Markdown → styled output **plus enforceable QA gates** (widows/orphans/overflow/keeps/link wrap) with machine-readable reports and coverage tracking. + +## High-level landscape + +### A) Renderers / converters (output-first) + +Great at converting formats, but usually do **not** ship “layout QA gates” as a product. + +- Pandoc (+ LaTeX) / Quarto / RMarkdown +- Typst +- LaTeX toolchains +- Markdown site tools that export PDF (MkDocs, Docusaurus, GitBook, Notion exports) + +### B) Paged-media engines (layout-first) + +Excellent at pagination + print rules, but they don’t give you a Chicago/Bringhurst rule registry or a publishing QA runtime by default. + +- PrinceXML +- Antenna House Formatter +- WeasyPrint +- Vivliostyle / Paged.js +- wkhtmltopdf (HTML → PDF, limited paged-media fidelity) + +### C) SaaS PDF rendering APIs + +Operational convenience; QA gates are typically “your responsibility”. + +- DocRaptor (Prince-powered) +- Various HTML→PDF APIs (vendor-specific) + +## Feature comparison (typical, not absolute) + +Legend: +- **✓**: first-class / native +- **~**: possible but not the default product shape +- **—**: not typical / not supported + +| Capability | iftypeset (goal) | Pandoc/Quarto | Typst | LaTeX | Prince/AH | WeasyPrint | Vivliostyle/Paged.js | +|---|---:|---:|---:|---:|---:|---:|---:| +| Markdown → HTML | ✓ | ✓ | ~ | ~ | — | — | ✓ | +| Markdown → PDF | ✓ (via adapters) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (via headless browser) | +| Deterministic artifacts (stable HTML/IDs) | ✓ | ~ | ~ | ~ | ~ | ~ | ~ | +| Profile tokens (web_pdf/print_pdf/etc.) | ✓ | ~ | ~ | ~ | ~ | ~ | ~ | +| Rule registry w/ pointers (no quotes) | ✓ | — | — | — | — | — | — | +| Lint + autofix (editorial hygiene) | ✓ | ~ | ~ | ~ | — | — | — | +| Post-render QA gates (widows/orphans/overflow) | ✓ | — | — | — | ~ | ~ | ~ | +| Coverage reporting for implemented rules | ✓ | — | — | — | — | — | — | +| Degraded-mode handling (garbage inputs) | ✓ | ~ | ~ | ~ | — | — | — | + +## What’s actually different about iftypeset + +1. **Quality is a build gate** + Not “this looks nice”, but “this fails CI if link-wrap/table overflow/stranded headings exceed thresholds”. + +2. **Rules are a registry (not hardcoded CSS)** + Chicago/Bringhurst become paraphrased, pointer-backed records you can audit, diff, and expand over time. + +3. **Renderer-agnostic** + The PDF engine is pluggable. The “meaning” is in tokens + QA + reports, not the renderer choice. + +4. **Traceability-compatible** + Machine outputs (reports/coverage) can be hashed/signed and attached to IF.TRACE receipts. + +## Who pays for this (practical) + +- Teams who must ship PDFs that survive scrutiny: + - **GRC / security** (SOC2/ISO evidence packs, policy docs) + - **research/publishing** (tech reports, standards commentary) + - **legal/professional services** (deliverables that must look “court-ready”) + - **vendor marketing with constraints** (docs that must be consistent across versions) + +## Messaging that is honest + +- “Markdown in, publication-quality out — with QA gates and receipts.” +- “It fails the build if the PDF is sloppy.” +- “Rules are paraphrased + pointer-backed; no book text shipped.” + diff --git a/docs/03-rule-ingestion-sop.md b/docs/03-rule-ingestion-sop.md new file mode 100644 index 0000000..7c59fe5 --- /dev/null +++ b/docs/03-rule-ingestion-sop.md @@ -0,0 +1,138 @@ +# Rule Ingestion SOP (Chicago / Bringhurst pointers, no quotes) + +This is the operator workflow for adding new rules to `iftypeset` without drifting into “we copied the book”. + +## Non‑negotiables (repeat them until boring) + +- **Never store book text** (OCR output, excerpts, paragraphs) in the repo. +- Rules are **paraphrases only** + **pointer refs**. +- Chicago OCR is allowed **ephemerally** to locate pointers; temp files must be deleted. +- If exact wording matters, the rule must say: `Exact wording required—refer to pointer`. + +## What you’re producing + +You add a small batch file under `spec/rules//v1__.ndjson` where each line is a JSON rule record validated by `spec/schema/rule.schema.json`. + +## Step-by-step workflow + +### 1) Pick the category + severity honestly + +- Category: one of the `category_taxonomy` buckets in `spec/manifest.yaml`. +- Severity: + - `must`: blocks release unless profile overrides lower it + - `should`: best practice; can be warn in degraded mode + - `warn`: advisory + +If it can’t be automated, tag it `manual_checklist=true` and set `enforcement: manual`. + +### 2) Locate the pointer (without storing text) + +#### Bringhurst (text-layer usable) + +- Use `tools/bringhurst_locate.py` (preferred) or `ripgrep` directly. +- Capture only: + - section identifier + - book page number (if present) + - scan page index (optional) + +#### Chicago (image scan) + +- Use `tools/chicago_ocr.py` **grep-only**. +- Do not copy OCR output into files. +- Use OCR to find: + - the relevant section (§) + - the printed page number + - the scan page index + +Pointer format examples (note: these are *pointers*, not quotes): + +- `CMOS18 §6.1 p377 (scan p10)` +- `BRING §2.3.2 p39 (scan p412)` + +Rule: `(scan pN)` is a **single 1-based PDF page index**, not a range. + +### 3) Write the rule record (paraphrase only) + +Create a new NDJSON line. Keep `rule_text` ≤ 800 chars. Prefer short, enforceable statements. + +Minimal template: + +```json +{ + "id": "CMOS.PUNCTUATION.DASHES.EM_DASH", + "title": "Use em dashes consistently", + "source_refs": ["CMOS18 §X.Y pNNN (scan pMMM)"], + "category": "punctuation", + "severity": "should", + "applies_to": "all", + "rule_text": "Paraphrase of the rule (no quotes). If wording matters, say: Exact wording required—refer to pointer.", + "rationale": "Why it matters (one line).", + "enforcement": "lint", + "autofix": "suggest", + "autofix_notes": "What we can safely fix (short).", + "tags": ["spacing", "manual_checklist=false"], + "keywords": ["em dash", "dash", "punctuation"], + "dependencies": [], + "exceptions": [], + "status": "draft" +} +``` + +Guidelines: + +- **Do not** embed long examples. If you need examples, create them under `spec/examples/` and reference `examples_ref`. +- Prefer splitting cross-layer concepts into two rules: + - `lint` rule for source cleanliness + - `postrender` rule for layout outcome +- Use `dependencies` if rule ordering matters (e.g., “normalize quotes” before “ellipsis spacing”). + +### 4) Tag manual rules so the checklist can be generated + +If a rule requires human judgment (e.g., “choose between two valid citation styles”), set: + +- `enforcement: manual` +- `tags: ["manual_checklist=true"]` +- `autofix: none` + +### 5) Validate + rebuild indexes (every batch) + +Run: + +- `PYTHONPATH=src python3 -m iftypeset.cli validate-spec --spec spec --build-indexes` +- `PYTHONPATH=src python3 -m iftypeset.cli report --spec spec --out out --build-indexes` + +Do not merge a batch if schema validation fails. + +### 6) Add fixtures / examples (so rules stay enforced) + +For each batch, add at least: + +- 1–3 `spec/examples/*` entries that trigger the rule (small, targeted). +- 1 fixture doc under `fixtures/` if the rule affects real documents. + +Rules without fixtures drift into “it exists but nothing enforces it.” + +### 7) Promote from draft → active + +Only set `status: active` when: + +- the enforcement implementation exists (lint/typeset/postrender/manual) +- at least one fixture/example covers it + +## Common traps (avoid) + +- **Copying text into rule_text** (even “short” quotes). Don’t. +- **Ranges in scan pages**: use a single `(scan pN)` hint. +- **MUST rules that are unenforceable**: tag as manual checklist or downgrade. +- **Overfitting to one document**: rules should generalize beyond a single sample. +- **“Autofix rewrite” that changes meaning**: keep fixes deterministic and reversible. + +## Review checklist (before shipping a batch) + +- [ ] No book text stored in repo (grep your changes). +- [ ] All rules have valid `source_refs` pointers. +- [ ] `rule_text` is paraphrase-only and short. +- [ ] Manual rules are tagged correctly. +- [ ] `validate-spec` + `report` pass. +- [ ] At least one fixture/example added for the batch. + diff --git a/docs/04-renderer-strategy.md b/docs/04-renderer-strategy.md new file mode 100644 index 0000000..debc49f --- /dev/null +++ b/docs/04-renderer-strategy.md @@ -0,0 +1,126 @@ +# Multi-renderer Strategy (HTML→PDF adapters) + +We should not bet the product on a single PDF engine. `iftypeset` should be **renderer-agnostic**: the “meaning” is in the rule registry + profiles + QA gates; the PDF renderer is an interchangeable adapter. + +## Principles + +- **Determinism first**: the adapter must emit `render-log.json` with engine name + version + key options. +- **No-network capable**: engines must run with `--network=none`/offline mode in CI where possible. +- **Graceful degradation**: if no PDF engine exists, HTML artifacts + HTML-based QA must still run. +- **Capability disclosure**: if a gate can’t be measured with an engine, report it explicitly (don’t silently pass). + +## Adapter interface (contract) + +All PDF engines implement the same interface: + +```python +class PdfEngine(Protocol): + name: str + + def is_available(self) -> bool: ... + def version(self) -> str: ... + def render(self, *, html_path: str, css_path: str, assets_dir: str | None, out_pdf: str, options: dict) -> dict: + """Returns a structured log: timings, warnings, engine opts, feature flags.""" +``` + +The CLI should support: + +- `--engine auto|chromium|weasyprint|prince|antenna|vivliostyle|wkhtmltopdf` +- `--engine-opts ` + +## “Majors” to target (pragmatic) + +### Tier 1 (easy to run, common) + +1) **Chromium / Headless browser** +- via Playwright or system Chromium (`chrome --headless --print-to-pdf`) +- Pros: ubiquitous, good HTML/CSS coverage, easy containerization. +- Cons: paged-media features vary; footnotes/running headers are limited unless carefully built. + +2) **WeasyPrint** +- Pros: pure Python workflow, good paged-media support, easy CI story. +- Cons: CSS compatibility differs; some complex layouts may need workarounds. + +### Tier 2 (best print fidelity; commercial) + +3) **PrinceXML** +- Pros: excellent paged media, footnotes, running headers, print-quality output. +- Cons: license cost; needs binary distribution policy. + +4) **Antenna House Formatter** +- Pros: top-tier print fidelity; standards publishing; robust PDF/A options. +- Cons: license + operational complexity. + +### Tier 3 (useful but limited) + +5) **Vivliostyle / Paged.js** +- Pros: strong paged-media model in the web ecosystem. +- Cons: heavier runtime; often “HTML+JS render” rather than simple CLI. + +6) **wkhtmltopdf** +- Pros: simple deploy story in legacy environments. +- Cons: outdated rendering model; limited CSS; not ideal for “high quality”. + +## Capability matrix (what we care about) + +We should encode an engine capability report (per run) for: + +- paged media (margins, page size, running headers) +- hyphenation support + dictionaries +- font embedding/subsetting +- link handling (wrap/break strategy) +- footnotes (if we later support them) +- PDF/A options (later) + +This capability map feeds QA: + +- if engine can’t support a gate (e.g., true widow/orphan detection on PDF), QA should: + - run the best available approximation, and + - mark the gate as `skipped` with a reason, not `passed`. + +## Determinism knobs (must record) + +For every PDF render, write `out/render-log.json` including: + +- engine name + version +- invocation args +- environment hints (OS, locale) +- “self-contained” mode on/off +- fonts resolved (what was available vs requested) +- any warnings from the engine + +If the engine is a browser: + +- fix viewport +- disable external requests +- pin print settings (margins, background graphics, scaling) + +## Security model + +- Assume untrusted Markdown input (CI context). Mitigations: + - never execute embedded JS during HTML render (or use a hardened renderer container) + - disable network + - restrict filesystem access (mount only `out/` and input) +- If using headless browsers, treat them as an attack surface; run in locked-down containers. + +## Recommended v0.1 path (fastest) + +1) Implement adapters for: + - Chromium/Playwright (auto-detect) + - WeasyPrint (if installed) +2) Keep Prince/AH as optional adapters (stub + docs) until needed. +3) Use QA gates as the real value: + - link wrap, code/table overflow, stranded headings (HTML and PDF when possible) + +This keeps delivery fast while preserving “compatible with the majors”. + +## Future: “Engine parity” testing + +Once adapters exist, add an integration job that renders the same fixtures through 2 engines (when available) and compares: + +- gate metrics (should be within thresholds) +- file size ranges +- major layout regressions (e.g., table clipping incidents) + +We don’t need pixel-perfect equivalence; we need “quality gates still pass”. + diff --git a/docs/05-external-evaluation-prompt.md b/docs/05-external-evaluation-prompt.md new file mode 100644 index 0000000..001d628 --- /dev/null +++ b/docs/05-external-evaluation-prompt.md @@ -0,0 +1,236 @@ +# External Evaluation Prompt — `iftypeset` (pubstyle) + +**Goal:** confirm there is no fundamental flaw (technical, legal, product) and identify obvious issues early. +**Audience:** humans or LLM reviewers. +**Repo root:** `ai-workspace/iftypeset/` + +## 0) Context (read this first) + +`iftypeset` is a thin, deterministic publishing runtime for **Markdown → HTML → PDF** that adds: + +- A **machine‑readable rule registry** (rules are paraphrases only) with **pointer refs** back to primary sources (Chicago / Bringhurst) instead of reproducing book text. +- **Typeset profiles** (screen-first vs print-first vs dense tech, etc.) that map typographic intent into render tokens/CSS. +- **Post‑render QA gates** that can fail builds when layout degrades (widows/orphans/keeps/overflow/link-wrap/numbering issues). + +### Non‑negotiables (legal + product) + +- Do **not** OCR/transcribe entire books into the repo (copyright). Rules must remain paraphrases with pointers only. +- Source pointers must be sufficient for someone who has the book to find the guidance, without quoting it. +- The runtime must be able to run in constrained environments (e.g. Forgejo PDF export workers) and produce deterministic artifacts. + +## 1) What to review (map of the repo) + +Start here: + +- `README.md` +- `STATUS.md` +- `app/ARCHITECTURE.md` +- `app/CLI_SPEC.md` +- `docs/01-demo-acceptance.md` +- `docs/02-competitor-matrix.md` +- `docs/03-rule-ingestion-sop.md` +- `docs/04-renderer-strategy.md` + +Spec + rules: + +- `spec/schema/rule.schema.json` +- `spec/manifest.yaml` +- `spec/profiles/*.yaml` +- `spec/quality_gates.yaml` +- `spec/rules/**.ndjson` +- `spec/indexes/*.json` (derived; rebuildable) + +Forgejo integration note: + +- `forgejo/README.md` + +## 2) Quick verification (local) + +From `ai-workspace/iftypeset/`, run: + +```bash +./scripts/ci.sh +``` + +Confirm it: + +- validates the spec +- generates a coverage report +- runs unit tests + +If it fails, include the command output in your review. + +## 3) Required reviewer metadata (so we can trust the review) + +### If you are a human reviewer + +- `reviewer_background`: 1–2 lines (e.g., “publishing/typography”, “security/GRC”, “docs tooling”). +- `tools_used`: list (e.g., Prince, Antenna House, Pandoc, Quarto, LaTeX, Typst, WeasyPrint, Paged.js, DocRaptor). +- `date_utc`: ISO 8601. + +### If you are an LLM reviewer + +- `llm_name`: provider + model string +- `probable_model`: if ambiguous +- `cutoff_date`: YYYY‑MM or `unknown` +- `response_date_utc`: ISO 8601 +- `web_access_used`: `yes|no` + +## 4) Evaluation rubric (scorecard) + +Score each category 0–5 and write 1–3 sentences of justification. + +### 4.1 Product + positioning + +1) **Problem clarity (0–5)** +Does this solve a real pain for teams shipping PDFs, beyond “another renderer”? + +2) **Differentiation (0–5)** +Is the “rule registry + QA gates + deterministic artifacts” wedge clear and credible vs: +Pandoc/Quarto/Typst/LaTeX, Prince/AntennaHouse/WeasyPrint/Vivliostyle/Paged.js, DocRaptor, etc.? + +3) **Viability (0–5)** +Is this buildable to a useful v0.1 in weeks (not months) with a small team? + +### 4.1a Content + style (docs/readability) + +11) **Docs clarity (0–5)** +Can a new contributor follow `README.md` and get a useful output quickly? + +12) **Spec readability (0–5)** +Are `spec/manifest.yaml`, `spec/profiles/*.yaml`, and `spec/quality_gates.yaml` self-explanatory enough for a reviewer? + +13) **Market-facing clarity (0–5)** +If this were shown to a buyer, does it read like a product with a clear contract, or a research project? + +### 4.2 Technical architecture + +4) **Spec design (0–5)** +Are `rule.schema.json`, `manifest.yaml`, and the profile/gate model coherent and extensible? + +5) **Enforcement model (0–5)** +Is the split between `lint` / `typeset` / `postrender` / `manual` realistic? Are “manual checklist” rules handled honestly? + +6) **Determinism strategy (0–5)** +Does the repo clearly define what “deterministic” means (inputs, renderer versions, fonts, outputs)? + +### 4.3 Rules + content quality + +7) **Rule record quality (0–5)** +Do rule records look like paraphrases with pointers (not copied text)? Are IDs/tags/keywords useful? + +8) **Coverage strategy (0–5)** +Are we prioritizing the right categories first (numbers/punctuation/citations/layout), and is coverage reporting useful? + +### 4.4 UX / operational usability + +9) **CLI ergonomics (0–5)** +Is the CLI spec clear for CI usage (exit codes, JSON artifacts, strictness flags)? + +10) **Integration story (0–5)** +Is Forgejo integration plausible and incremental (CSS first, then QA gates)? + +### 4.5 Market viability (compare to existing options) + +Rate each 0–5 based on *your experience* (no need to be exhaustive; avoid vendor hype). + +14) **Replace vs complement (0–5)** +Is `iftypeset` best positioned as a replacement for existing toolchains, or as a QA layer you plug into them? + +15) **Who pays first (0–5)** +Does the repo make it clear who would adopt/pay first (docs teams, GRC, legal, research, vendors)? + +16) **Defensible wedge (0–5)** +Is “publishing CI with hard QA gates + auditable rule registry” a defensible wedge, or easy for existing tools to add? + +## 5) “Fundamental flaw” checklist (answer explicitly) + +Mark each: `PASS` / `RISK` / `FAIL`, with a one‑line explanation. + +1) **Copyright / licensing risk** +Any sign the repo is storing book text rather than paraphrases + pointers? + +2) **Determinism risk** +Are we likely to produce different PDFs across machines/runs due to fonts/renderer drift? + +3) **QA gate feasibility** +Are the proposed post-render QA gates realistically implementable, or is this a research project? + +4) **Scope creep risk** +Does the plan keep a narrow v0.1 “definition of done”, or is it trying to boil the ocean? + +5) **Market reality** +Is there a clear “why buy/use this” vs adopting an existing doc toolchain and living with some ugliness? + +## 5a) Section-by-section ratings (required) + +Rate each **0–5** and include 1–2 lines of justification. The goal is to catch “obvious issues” early. + +- `README.md`: clarity + truthfulness (does it match current behavior?) +- `STATUS.md`: accuracy + usefulness (is it a reliable snapshot?) +- `app/ARCHITECTURE.md`: coherence + feasibility +- `app/CLI_SPEC.md`: completeness + CI friendliness +- `docs/01-demo-acceptance.md`: crisp v0.1 target or scope creep? +- `docs/02-competitor-matrix.md`: honest + actionable (no wishful marketing) +- `docs/03-rule-ingestion-sop.md`: safe + repeatable (avoids copyright drift) +- `docs/04-renderer-strategy.md`: realistic adapter plan +- `spec/manifest.yaml`: enforceable contracts + degraded mode clarity +- `spec/schema/rule.schema.json`: schema quality (strict enough, not brittle) +- `spec/profiles/*.yaml`: profiles feel sane, not arbitrary +- `spec/quality_gates.yaml`: gates are measurable + meaningful +- `spec/rules/**.ndjson`: rule quality (paraphrase + pointer discipline) + +## 6) Deliverables quality (what “good” looks like) + +Assess whether the repo is on track to produce, for a single Markdown input: + +- `render.html` + `render.css` (deterministic) +- `render.pdf` (deterministic *given pinned engine/fonts*) +- `lint-report.json` +- `layout-report.json` +- `qa-report.json` (pass/fail thresholds) +- `coverage-report.json` (rule implementation progress) +- `manual-checklist.md` (for rules that cannot be automated) + +If you think any of these deliverables are unnecessary or missing, say so. + +## 7) Patch suggestions (actionable) + +Provide 5–15 suggestions in this format: + +- `target`: file path(s) +- `problem`: 1 sentence +- `change`: concrete text/code change (copy/pasteable) +- `why`: 1 sentence +- `priority`: P0 / P1 / P2 +- `confidence`: high / medium / low + +### Preferred patch format + +If possible, include unified diffs: + +```diff +--- a/path/file.md ++++ b/path/file.md +@@ + ... +``` + +## 8) Output template (copy/paste) + +Use this structure in your response: + +1) **Summary (5–10 bullets)** +2) **Scorecard (0–5 each)** +3) **Fundamental flaw checklist (PASS/RISK/FAIL)** +4) **Top risks (P0/P1)** +5) **Patch suggestions (with diffs if possible)** +6) **Go / No‑Go recommendation for v0.1** + +## 9) Important constraint for reviewers + +Do not paste verbatim passages from Chicago/Bringhurst into your review output. Use pointers only (e.g., `BRING §2.1.8 p32`) and describe the issue in your own words. + +## 10) Quick market question (optional, but useful) + +If you had to ship “good-looking PDFs with hard QA gates” tomorrow, what would you use today, and why would you still choose `iftypeset` (or not)? diff --git a/docs/06-project-overview.md b/docs/06-project-overview.md new file mode 100644 index 0000000..2fe405e --- /dev/null +++ b/docs/06-project-overview.md @@ -0,0 +1,73 @@ +# iftypeset (pubstyle) — project overview + +This document is a narrative snapshot meant for handoffs and external reviewers. + +## Where we come from + +Most Markdown→PDF pipelines optimize for “it renders” and stop there. In practice, teams who ship PDFs for real audiences (customers, regulators, courts, boards) care about the *failure modes*: + +- links that wrap into unreadable fragments +- tables that overflow or clip +- headings stranded at the bottom of a page +- inconsistent numbering/citations +- “looks fine on my machine” drift when renderers/fonts change + +`iftypeset` starts from a simple premise: **quality must be measurable and enforceable**. + +## Where we are (today) + +We have a working foundation that is intentionally boring: + +- A **machine-readable rule registry** (`spec/rules/**.ndjson`) that stores *paraphrased* rules and **pointer refs** back to primary sources (Chicago / Bringhurst), without reproducing book text. +- A **profile system** (`spec/profiles/*.yaml`) that maps typographic intent into deterministic render tokens (page size, margins, font stacks, measure targets, hyphenation policy). +- **Post-render QA gates** (`spec/quality_gates.yaml`) that define hard numeric thresholds for layout failures. +- A working CLI surface (`iftypeset.cli`) that can validate the spec, emit coverage reports, lint Markdown, render HTML/CSS, render PDF (via available engines), and run QA. + +Current progress is tracked in `STATUS.md` and `out/coverage-summary.md`. + +## Where we are going (v0.1 → v1) + +### v0.1: “Publishing CI” for a single Markdown input + +The v0.1 goal is *not* to be the best renderer. It’s to be the most reliable pipeline: + +- deterministic HTML/CSS output for a chosen profile +- PDF generation via adapters (Chromium first, others later) +- QA reports that catch common layout failures and fail the build when thresholds are exceeded +- an honest manual checklist for rules that cannot be automated + +Definition of done lives in `docs/01-demo-acceptance.md`. + +### v0.2+: broaden rule coverage + deepen QA gates + +Once the pipeline is stable, we expand breadth and depth: + +- add more rule categories (figures, frontmatter/backmatter, abbreviations, i18n, accessibility) +- increase post-render QA coverage (widows/orphans, keep constraints, overfull lines) +- add more fixtures to harden degraded-mode handling + +### v1: “adapter-compatible” quality gates + +Longer-term, `iftypeset` should work with the majors: + +- keep the “meaning” in profiles + QA, not in a single renderer +- support swapping PDF engines without losing the ability to measure quality consistently + +Renderer strategy is documented in `docs/04-renderer-strategy.md`. + +## Traps to avoid (so we don’t drift) + +- **Copying book text into the repo:** we can use OCR to locate pointers, but we must not persist verbatim passages. +- **Pretending manual rules don’t exist:** if it can’t be enforced, it must land in `manual-checklist.md` with a pointer. +- **Overfitting to one renderer:** adapters are the point; pinning is allowed, lock-in is not. +- **Unmeasurable QA gates:** if we can’t measure it reliably, it’s a “should” or “manual”, not a “must”. + +## Why this is valuable + +The differentiator is not “Markdown to PDF”. It’s: + +**A) auditable rules** (paraphrase + pointer discipline) and +**B) enforceable layout QA** (fail the build when it’s sloppy). + +That’s what makes it compatible with governance workflows (hash/sign artifacts, attach QA reports, reproduce later) and usable in constrained CI environments (like Forgejo PDF export workers). + diff --git a/docs/07-session-resilience.md b/docs/07-session-resilience.md new file mode 100644 index 0000000..480688b --- /dev/null +++ b/docs/07-session-resilience.md @@ -0,0 +1,40 @@ +# Session Resilience (avoid “lost work” when chats reset) + +Codex/chat sessions can lose **conversation context** when a connection drops. The filesystem does not: the durable source of truth is the repo. + +This project adds a few boring mechanisms to make resuming work deterministic. + +## What to trust + +- **Repo state**: `README.md`, `STATUS.md`, `docs/` are canonical. +- **CI**: `./scripts/ci.sh` is the fastest sanity check. +- **Artifacts**: `out/` contains the latest reports from CI runs. + +## Quick resume checklist (30 seconds) + +From the repo root: + +- `./scripts/audit.sh` +- `./scripts/ci.sh` + +If both look sane, you’re back. + +## Create a checkpoint (2 minutes) + +When you finish a meaningful chunk of work (new rule batches, QA changes, renderer changes), run: + +- `./scripts/checkpoint.sh "what changed"` + +This: + +- runs CI and stores the CI JSON in `out/checkpoints/` +- creates a compressed snapshot tarball in `out/checkpoints/` +- appends a new entry to `docs/CHECKPOINTS.md` with the snapshot hash + +This gives you a **portable restore point** even if the chat transcript is gone. + +## Best practice (recommended) + +- Push to a remote early (Forgejo/GitHub). A remote is the best anti-loss mechanism. +- Treat `STATUS.md` as the “1-page truth” for what exists and what’s next. +- Don’t rely on chat logs for state; copy any critical decisions into `docs/`. diff --git a/docs/CHECKPOINTS.md b/docs/CHECKPOINTS.md new file mode 100644 index 0000000..a0a03d3 --- /dev/null +++ b/docs/CHECKPOINTS.md @@ -0,0 +1,17 @@ +# Checkpoints + +Durable restore points for this repo (useful when chat context resets). + +How to create a checkpoint: + +- `./scripts/checkpoint.sh "short note about what changed"` + +Each entry records the snapshot tarball path + sha256 and the CI JSON for that moment. + +## 2026-01-03T20:09:44Z + +- snapshot: `out/checkpoints/iftypeset_checkpoint_2026-01-03T20-09-44Z.tar.gz` +- snapshot_sha256: `95583b843415cbb39de3199b1a73453d163f858b869b1852799360b6ca35388a` +- ci_json: `out/checkpoints/ci_2026-01-03T20-09-44Z.json` +- note: first checkpoint test + diff --git a/fixtures/abbreviations.md b/fixtures/abbreviations.md new file mode 100644 index 0000000..585a4dd --- /dev/null +++ b/fixtures/abbreviations.md @@ -0,0 +1,3 @@ +# Abbreviations + +Define abbreviations on first use, for example "Minimal Viable Product (MVP)". diff --git a/fixtures/accessibility_alt.md b/fixtures/accessibility_alt.md new file mode 100644 index 0000000..28b99cb --- /dev/null +++ b/fixtures/accessibility_alt.md @@ -0,0 +1,3 @@ +# Accessibility + +![Alt text](images/example.png) diff --git a/fixtures/backmatter_stub.md b/fixtures/backmatter_stub.md new file mode 100644 index 0000000..e51dc85 --- /dev/null +++ b/fixtures/backmatter_stub.md @@ -0,0 +1,5 @@ +# Back Matter + +## Appendix A + +Supplemental material goes in appendices. diff --git a/fixtures/citations_author_date.md b/fixtures/citations_author_date.md new file mode 100644 index 0000000..eff262f --- /dev/null +++ b/fixtures/citations_author_date.md @@ -0,0 +1,4 @@ +# Author-Date + +Use author-date citations in contexts where quick lookup is needed, such as +Smith 2024, 15-18. diff --git a/fixtures/citations_bibliography.md b/fixtures/citations_bibliography.md new file mode 100644 index 0000000..74524dd --- /dev/null +++ b/fixtures/citations_bibliography.md @@ -0,0 +1,4 @@ +# Bibliography + +List sources in a consistent order, and keep formatting consistent across +entries. diff --git a/fixtures/citations_notes.md b/fixtures/citations_notes.md new file mode 100644 index 0000000..b5e4382 --- /dev/null +++ b/fixtures/citations_notes.md @@ -0,0 +1,4 @@ +# Notes + +Use notes to provide sources. Keep notes concise, and ensure they include +locators when referencing specific passages. diff --git a/fixtures/code_inline.md b/fixtures/code_inline.md new file mode 100644 index 0000000..0522d7a --- /dev/null +++ b/fixtures/code_inline.md @@ -0,0 +1,3 @@ +# Inline Code + +Use `inline_code()` for identifiers and small code tokens. diff --git a/fixtures/code_long_lines.md b/fixtures/code_long_lines.md new file mode 100644 index 0000000..ee19b00 --- /dev/null +++ b/fixtures/code_long_lines.md @@ -0,0 +1,5 @@ +# Long Code Lines + +```text +0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789 +``` diff --git a/fixtures/figures_placeholder.md b/fixtures/figures_placeholder.md new file mode 100644 index 0000000..7b83808 --- /dev/null +++ b/fixtures/figures_placeholder.md @@ -0,0 +1,3 @@ +# Figures + +Figure 1 shows a placeholder description for figure handling. diff --git a/fixtures/frontmatter_stub.md b/fixtures/frontmatter_stub.md new file mode 100644 index 0000000..945c75d --- /dev/null +++ b/fixtures/frontmatter_stub.md @@ -0,0 +1,5 @@ +# Front Matter + +## Preface + +Introductory material lives here. diff --git a/fixtures/headings_basic.md b/fixtures/headings_basic.md new file mode 100644 index 0000000..13ce03b --- /dev/null +++ b/fixtures/headings_basic.md @@ -0,0 +1,9 @@ +# Headings Overview + +## Subsection One + +Content under the first subsection. + +## Subsection Two + +Content under the second subsection. diff --git a/fixtures/headings_deep.md b/fixtures/headings_deep.md new file mode 100644 index 0000000..4c55153 --- /dev/null +++ b/fixtures/headings_deep.md @@ -0,0 +1,5 @@ +# Main Title + +### Skipped Heading Level + +This heading skips a level to test lint. diff --git a/fixtures/headings_numbered.md b/fixtures/headings_numbered.md new file mode 100644 index 0000000..7720221 --- /dev/null +++ b/fixtures/headings_numbered.md @@ -0,0 +1,7 @@ +# 1 Introduction + +## 1.1 Background + +### 1.1.1 Detail + +Text for a numbered heading sequence. diff --git a/fixtures/hyphenation.md b/fixtures/hyphenation.md new file mode 100644 index 0000000..d4670d3 --- /dev/null +++ b/fixtures/hyphenation.md @@ -0,0 +1,3 @@ +# Hyphenation + +Use hyphenation only when it improves clarity in compound modifiers. diff --git a/fixtures/i18n_quotes.md b/fixtures/i18n_quotes.md new file mode 100644 index 0000000..b669074 --- /dev/null +++ b/fixtures/i18n_quotes.md @@ -0,0 +1,3 @@ +# International Quotes + +Use locale-appropriate quotation styles when writing in non-English contexts. diff --git a/fixtures/layout_pagebreaks.md b/fixtures/layout_pagebreaks.md new file mode 100644 index 0000000..348a9d1 --- /dev/null +++ b/fixtures/layout_pagebreaks.md @@ -0,0 +1,5 @@ +# Page Breaks + +## Section One + +Content that should stay with the heading. diff --git a/fixtures/layout_spacing.md b/fixtures/layout_spacing.md new file mode 100644 index 0000000..91e3ccb --- /dev/null +++ b/fixtures/layout_spacing.md @@ -0,0 +1,6 @@ +# Layout Spacing + +This paragraph is followed by another paragraph that should preserve +consistent spacing in the rendered output. + +This is the follow-up paragraph. diff --git a/fixtures/layout_widow.md b/fixtures/layout_widow.md new file mode 100644 index 0000000..d8f1b13 --- /dev/null +++ b/fixtures/layout_widow.md @@ -0,0 +1,5 @@ +# Widow Risk + +Short line. + +Another short line. diff --git a/fixtures/links_bare_url.md b/fixtures/links_bare_url.md new file mode 100644 index 0000000..62fc178 --- /dev/null +++ b/fixtures/links_bare_url.md @@ -0,0 +1,3 @@ +# Bare URL + +Use a bare URL like http://example.com when you need the literal address. diff --git a/fixtures/links_long_url.md b/fixtures/links_long_url.md new file mode 100644 index 0000000..0e5952a --- /dev/null +++ b/fixtures/links_long_url.md @@ -0,0 +1,4 @@ +# Long URL + +A very long URL may wrap poorly in narrow measures: +https://example.com/this/is/an/extraordinarily/long/url/with/no/breaks/that/should/trigger/qa diff --git a/fixtures/lists_ordered.md b/fixtures/lists_ordered.md new file mode 100644 index 0000000..12177df --- /dev/null +++ b/fixtures/lists_ordered.md @@ -0,0 +1,5 @@ +# Ordered List + +1. First step +2. Second step +3. Third step diff --git a/fixtures/lists_unordered.md b/fixtures/lists_unordered.md new file mode 100644 index 0000000..3372d2d --- /dev/null +++ b/fixtures/lists_unordered.md @@ -0,0 +1,5 @@ +# Unordered List + +- Alpha +- Beta +- Gamma diff --git a/fixtures/numbers_currency.md b/fixtures/numbers_currency.md new file mode 100644 index 0000000..b1681d4 --- /dev/null +++ b/fixtures/numbers_currency.md @@ -0,0 +1,3 @@ +# Currency + +Use consistent currency formatting, such as USD 1,200.00 or $1,200.00. diff --git a/fixtures/numbers_dates.md b/fixtures/numbers_dates.md new file mode 100644 index 0000000..f9c8a5a --- /dev/null +++ b/fixtures/numbers_dates.md @@ -0,0 +1,4 @@ +# Dates + +Use a consistent date format. For example, 2025-01-03 in ISO form or +January 3, 2025 in US prose. diff --git a/fixtures/numbers_ranges.md b/fixtures/numbers_ranges.md new file mode 100644 index 0000000..89d82ae --- /dev/null +++ b/fixtures/numbers_ranges.md @@ -0,0 +1,3 @@ +# Inclusive Ranges + +Use en dashes for ranges like 12-24 and avoid ambiguous shortening. diff --git a/fixtures/numbers_spelling.md b/fixtures/numbers_spelling.md new file mode 100644 index 0000000..b2df44f --- /dev/null +++ b/fixtures/numbers_spelling.md @@ -0,0 +1,4 @@ +# Numbers in Prose + +Spell out numbers in running text when they are short and not attached to +units. Use numerals in technical contexts. diff --git a/fixtures/punctuation_colons.md b/fixtures/punctuation_colons.md new file mode 100644 index 0000000..2e4b2f6 --- /dev/null +++ b/fixtures/punctuation_colons.md @@ -0,0 +1,4 @@ +# Colons + +Use a colon after a complete clause when introducing a list: for example, +items, examples, and exceptions. diff --git a/fixtures/punctuation_commas.md b/fixtures/punctuation_commas.md new file mode 100644 index 0000000..a545fdd --- /dev/null +++ b/fixtures/punctuation_commas.md @@ -0,0 +1,4 @@ +# Commas in Lists + +Use commas to separate items in a series, and use a serial comma where +clarity matters. Avoid comma splices in formal prose. diff --git a/fixtures/punctuation_dashes.md b/fixtures/punctuation_dashes.md new file mode 100644 index 0000000..86bba10 --- /dev/null +++ b/fixtures/punctuation_dashes.md @@ -0,0 +1,4 @@ +# Dashes + +Use em dashes for breaks in thought and en dashes for numeric ranges such +as 2019-2024. Avoid mixing dash types within a single paragraph. diff --git a/fixtures/punctuation_quotes.md b/fixtures/punctuation_quotes.md new file mode 100644 index 0000000..8057180 --- /dev/null +++ b/fixtures/punctuation_quotes.md @@ -0,0 +1,4 @@ +# Quotation Marks + +Use double quotation marks for direct quotations in US English and single +quotation marks for quotes within quotes. diff --git a/fixtures/punctuation_semicolons.md b/fixtures/punctuation_semicolons.md new file mode 100644 index 0000000..57c0d4c --- /dev/null +++ b/fixtures/punctuation_semicolons.md @@ -0,0 +1,4 @@ +# Semicolons + +Use semicolons between related independent clauses; avoid overusing them +when a period is clearer. diff --git a/fixtures/sample.md b/fixtures/sample.md new file mode 100644 index 0000000..addc7bf --- /dev/null +++ b/fixtures/sample.md @@ -0,0 +1,29 @@ +# Sample Document + +This sample exists to exercise lint, render, and QA. It includes a long URL +and a code block that may overflow on narrow measures. + +## Links + +Visit [Example](https://example.com/docs) for context. +Here is a long URL that might wrap: https://example.com/this/is/a/very/long/path/with/no/obvious/breaks/and/it/keeps/going + +## Lists + +- First item +- Second item with two spaces to trigger lint + +1. First ordered item +2. Second ordered item + +## Table + +| Column A | Column B | Column C | Column D | Column E | Column F | +| --- | --- | --- | --- | --- | --- | +| Short | Short | Short | Short | Short | Short | + +## Code + +```python +print("This is a very long line that should trigger overflow checks in QA because it exceeds typical measures") +``` diff --git a/fixtures/tables_alignment.md b/fixtures/tables_alignment.md new file mode 100644 index 0000000..d2c8594 --- /dev/null +++ b/fixtures/tables_alignment.md @@ -0,0 +1,5 @@ +# Table Alignment + +| Left | Center | Right | +| :--- | :---: | ---: | +| L | C | R | diff --git a/fixtures/tables_basic.md b/fixtures/tables_basic.md new file mode 100644 index 0000000..eb80e88 --- /dev/null +++ b/fixtures/tables_basic.md @@ -0,0 +1,6 @@ +# Basic Table + +| Name | Value | +| --- | --- | +| Alpha | 10 | +| Beta | 20 | diff --git a/fixtures/tables_wide.md b/fixtures/tables_wide.md new file mode 100644 index 0000000..e4d3d7b --- /dev/null +++ b/fixtures/tables_wide.md @@ -0,0 +1,5 @@ +# Wide Table + +| C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| A | B | C | D | E | F | G | H | diff --git a/fixtures/typography_fonts.md b/fixtures/typography_fonts.md new file mode 100644 index 0000000..d8e92b1 --- /dev/null +++ b/fixtures/typography_fonts.md @@ -0,0 +1,3 @@ +# Font Stacks + +Choose serif and sans-serif stacks that match the target medium. diff --git a/forgejo/README.md b/forgejo/README.md new file mode 100644 index 0000000..586327e --- /dev/null +++ b/forgejo/README.md @@ -0,0 +1,117 @@ +# Forgejo PDF integration (iftypeset → forgejo-pdf worker) + +This note documents how to wire `iftypeset` into the existing Forgejo PDF worker so exported PDFs stop feeling “flat” and start behaving like a real typesetting pipeline. + +## Current state (Forgejo worker) + +The current renderer lives at: + +- `/root/ai-workspace/forgejo-pdf/worker/pdf/src/render_pdf.js` + +It currently: + +- Converts Markdown → HTML (MarkdownIt + sanitize-html). +- Renders Mermaid diagrams in-page. +- Uses Paged.js for pagination. +- Emits a PDF via Puppeteer/Chromium. +- Applies one of two static stylesheets: + - `basic.css` + - `professional.css` + +## What iftypeset adds + +`iftypeset` is a deterministic “rules + profiles + QA gates” layer. + +In Forgejo terms: + +- **Profiles** (`spec/profiles/*.yaml`) → deterministic CSS tokens (`iftypeset emit-css`). +- **Quality gates** (`spec/quality_gates.yaml`) → post-render checks (widows/orphans, overflow, stranded headings, etc.) with hard numeric thresholds. +- **Rule registry (Phase 2)** → lint + manual checklists (Chicago/Bringhurst pointers, paraphrased). + +## Minimal integration (CSS only, low risk) + +1. Generate CSS from a profile: + +```bash +cd /root/ai-workspace/iftypeset +PYTHONPATH=src python3 -m iftypeset.cli emit-css --spec spec --profile web_pdf --out /tmp/iftypeset-css +``` + +2. Copy CSS into the worker assets directory: + +```bash +cp /tmp/iftypeset-css/render.css /root/ai-workspace/forgejo-pdf/worker/pdf/assets/css/iftypeset-web_pdf.css +``` + +3. Add a new `pdf.typography` option in the worker config contract (example): + +- `basic` +- `professional` +- `iftypeset-web_pdf` (new) + +4. Update `render_pdf.js` selection logic to map that value to a CSS filename in `assets/css/`. + +This is the safest first step: no new dependencies in the worker container, no new runtime calls, just a different stylesheet. + +## Next integration (QA gates, medium risk) + +The goal is to produce: + +- `layout-report.json` (measured layout incidents) +- `qa-report.json` (gate pass/fail summary) + +at export time. + +Recommended approach: + +1. **Pre-PDF** (in-page, after Paged.js preview): + - collect page count + - collect per-page heading positions (to detect “stranded headings”) + - record overflow signals (code blocks / tables that exceed page content boxes) + +2. **Post-PDF** (optional, later): + - parse the PDF with a dedicated analyzer to detect widows/orphans more accurately + +Start with the in-page signals first because the Forgejo worker already owns the DOM and pagination lifecycle. + +## CI wiring (recommended) + +In a Forgejo job, run the pipeline after Markdown is available: + +```bash +PYTHONPATH=src python3 -m iftypeset.cli lint --input --out out --profile web_pdf +PYTHONPATH=src python3 -m iftypeset.cli render-html --input --out out --profile web_pdf +PYTHONPATH=src python3 -m iftypeset.cli render-pdf --input --out out --profile web_pdf || true +PYTHONPATH=src python3 -m iftypeset.cli qa --out out --profile web_pdf +``` + +Artifacts to publish (static hosting): + +- `out/render.html` +- `out/render.css` +- `out/render.pdf` (if available) +- `out/layout-report.json` +- `out/qa-report.json` +- `out/lint-report.json` + +Failures should be surfaced via exit codes and `qa-report.json` (gate failures list). + +## Fonts (important) + +Forgejo’s `professional.css` embeds IBM Plex via `@font-face`. + +If you switch to `iftypeset` CSS profiles as-is, you should either: + +- add the fonts used by the profile to the worker assets (preferred for consistency), or +- update the profile `fonts.*.family` stacks to prefer the fonts already bundled in the worker (`IBM Plex Sans WOFF2`, `IBM Plex Mono WOFF2`). + +## Long-term direction + +Once Phase 2 rule batches exist (`spec/rules/**.ndjson`), Forgejo can become a full “publication pipeline”: + +- `iftypeset lint` → deterministic lint report + optional autofix (no quotes from books, pointers only) +- `iftypeset emit-css` → render tokens +- Forgejo render → HTML/PDF +- `iftypeset qa` → gate failures block the PDF build in CI + +This keeps the worker simple and lets the strictness live in the spec, not ad-hoc code. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..313d3d1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "iftypeset" +version = "0.1.0" +description = "Publication-quality typesetting runtime (Markdown→HTML→PDF) with Chicago/Bringhurst-backed rule registry pointers." +requires-python = ">=3.11" +dependencies = [ + "pyyaml>=6.0", + "jsonschema>=4.19", +] + +[project.scripts] +iftypeset = "iftypeset.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3f1ecb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyYAML==6.0.2 +jsonschema==4.19.2 diff --git a/scripts/audit.sh b/scripts/audit.sh new file mode 100755 index 0000000..3509b7b --- /dev/null +++ b/scripts/audit.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +echo "iftypeset audit @ ${ts}" +echo "root: ${ROOT}" + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "git: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?') $(git rev-parse --short HEAD 2>/dev/null || echo 'no-commit')" + git status --porcelain=v1 -b || true +else + echo "git: (not a git repo)" +fi + +if [ -f out/coverage-report.json ]; then + python3 - <<'PY' +import json +from pathlib import Path + +d = json.loads(Path("out/coverage-report.json").read_text()) +counts = d.get("counts", {}) +print("coverage:") +print(f" total_rules: {counts.get('total_rules')}") +print(f" by_category: {counts.get('by_category')}") +print(f" by_enforcement: {counts.get('by_enforcement')}") +print(f" by_severity: {counts.get('by_severity')}") +print(f" generated_at_utc: {d.get('generated_at_utc')}") +PY +else + echo "coverage: out/coverage-report.json missing (run ./scripts/ci.sh)" +fi + +echo "recent files:" +ls -lt README.md STATUS.md spec/manifest.yaml 2>/dev/null | sed -n '1,5p' || true +ls -lt src/iftypeset 2>/dev/null | sed -n '1,12p' || true + +echo "checkpoints:" +if ls out/checkpoints/iftypeset_checkpoint_*.tar.gz >/dev/null 2>&1; then + latest="$(ls -1t out/checkpoints/iftypeset_checkpoint_*.tar.gz | head -n 1)" + latest_sha="$(sha256sum "$latest" | awk '{print $1}')" + echo " latest_snapshot: ${latest}" + echo " latest_snapshot_sha256: ${latest_sha}" +else + echo " latest_snapshot: (none) # run ./scripts/checkpoint.sh" +fi + +if [ -f docs/CHECKPOINTS.md ]; then + echo " docs/CHECKPOINTS.md: present" + tail -n 14 docs/CHECKPOINTS.md | sed 's/^/ | /' +else + echo " docs/CHECKPOINTS.md: missing" +fi diff --git a/scripts/checkpoint.sh b/scripts/checkpoint.sh new file mode 100755 index 0000000..ed39ce9 --- /dev/null +++ b/scripts/checkpoint.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +safe_ts="$(date -u +"%Y-%m-%dT%H-%M-%SZ")" +note="${1:-}" + +mkdir -p out/checkpoints + +echo "== running ci ==" >&2 +ci_json="$(./scripts/ci.sh)" +echo "$ci_json" > "out/checkpoints/ci_${safe_ts}.json" + +tar_path="out/checkpoints/iftypeset_checkpoint_${safe_ts}.tar.gz" + +echo "== creating snapshot ${tar_path} ==" >&2 +tar \ + --exclude=".git" \ + --exclude=".venv" \ + --exclude="out" \ + -czf "$tar_path" \ + .forgejo .gitignore README.md STATUS.md app docs fixtures forgejo pyproject.toml requirements.txt scripts spec src tests tools + +sha="$(sha256sum "$tar_path" | awk '{print $1}')" + +cat > "out/checkpoints/checkpoint_${safe_ts}.md" < docs/CHECKPOINTS.md <<'EOF' +# Checkpoints + +Durable restore points for this repo (useful when chat context resets). + +How to create a checkpoint: + +- `./scripts/checkpoint.sh "short note about what changed"` + +Each entry records the snapshot tarball path + sha256 and the CI JSON for that moment. +EOF +fi + +cat >> docs/CHECKPOINTS.md <&2 diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..00bf8c3 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +status=0 +json_entries=() + +run_step() { + local name="$1" + shift + "$@" + local rc=$? + if [ $rc -ne 0 ]; then + status=1 + fi + json_entries+=("{\"step\":\"${name}\",\"rc\":${rc}}") +} + +run_step "validate-spec" env PYTHONPATH=src python3 -m iftypeset.cli validate-spec --spec spec --build-indexes +run_step "report" env PYTHONPATH=src python3 -m iftypeset.cli report --spec spec --out out --build-indexes +run_step "tests" python3 -m unittest discover -s tests -p 'test_*.py' + +printf '{"ok":%s,"steps":[%s]}\n' "$([ $status -eq 0 ] && echo true || echo false)" "$(IFS=,; echo "${json_entries[*]}")" +exit $status diff --git a/spec/examples/README.md b/spec/examples/README.md new file mode 100644 index 0000000..caf3d56 --- /dev/null +++ b/spec/examples/README.md @@ -0,0 +1,88 @@ +# Examples + +Rules stay compact and machine-enforceable; examples live separately to avoid bloating the rule registry. + +## Goals + +* Provide **concrete fixtures** for: + * unit tests (lint, autofix, typeset transforms) + * integration tests (render + QA gates) + * documentation (human-readable “why this matters”) + +* Keep examples **small** (a few lines) and **targeted** (each example triggers a known set of rules). + +## Example ID format + +`EX...` + +* `CATEGORY` must match the category taxonomy (e.g., `PUNCTUATION`, `NUMBERS`, `CITATIONS`) +* `TOPIC` is an uppercase short slug +* `NNN` is a zero-padded integer (000–999+) + +Example: + +* `EX.PUNCTUATION.DASHES.001` + +## Suggested on-disk layout + +* `spec/examples//EX....yaml` +* `spec/examples//fixtures/.md` (optional) + +## Example YAML format (recommended) + +Fields: + +* `id` (required): example ID +* `rules` (required): list of rule IDs the example is meant to exercise +* `before` (required): inline Markdown or a reference to a fixture file +* `after` (optional): expected Markdown after autofix (if autofix exists) +* `expected` (optional): expected diagnostics/gates + * `lint_errors`: array of rule IDs expected as errors + * `lint_warnings`: array of rule IDs expected as warnings + * `qa_failures`: array of gate keys expected to fail +* `notes` (optional): short human explanation (no book quotes) + +Minimal example skeleton: + +```yaml +id: EX.PUNCTUATION.DASHES.001 +rules: + - CMOS.PUNCTUATION.DASHES.EM_DASH +before: | + ... +after: | + ... +expected: + lint_errors: + - CMOS.PUNCTUATION.DASHES.EM_DASH +``` + +## Test corpus strategy + +Maintain a small, curated corpus that triggers: + +1. Lint-only issues (AST-level) + * punctuation spacing + * numeral formatting + * heading numbering patterns + * link normalization / unsafe URLs + * citation field completeness + +2. Typeset-only issues (token/CSS decisions) + * paragraph indentation patterns + * code block wrapping rules + * table overflow strategies + +3. Post-render QA issues (PDF/HTML layout) + * widows/orphans + * stranded headings (keep-with-next) + * overfull lines (especially monospace/code) + * table/caption overflow and clipping + * link wrap incidents (URLs/DOIs split against policy) + +Recommended corpus sizing: + +* 30–80 fixtures total +* each fixture should target 3–10 rules max +* include “degraded mode” fixtures (intentionally malformed Markdown) + diff --git a/spec/extraction_plan.md b/spec/extraction_plan.md new file mode 100644 index 0000000..2d49769 --- /dev/null +++ b/spec/extraction_plan.md @@ -0,0 +1,112 @@ +# Phase 2 Extraction Plan + +This plan defines how rules will be produced in controlled batches without reproducing the books. + +## Non-negotiables (carried into Phase 2) + +* No full-book OCR/transcription. +* No long verbatim passages. +* Rules are paraphrased and capped (`rule_text` ≤ 800 chars). +* Every rule includes at least one source pointer in `source_refs`. +* If a rule depends on exact wording, the rule still paraphrases but must include: + * `rule_text`: “Exact wording required—refer to pointer” + * plus a usable pointer. + +Primary reference PDFs for pointer extraction: + +* The Chicago Manual of Style (18th ed).pdf +* Robert Bringhurst – The Elements of Typographic Style.pdf + +## Output batching format + +When you say: `EXTRACT []` + +I will output a bundle that includes: + +1. **Rules NDJSON** (150–250 rule records) + * Path: `spec/rules//.ndjson` + * One JSON object per line, validated against `spec/schema/rule.schema.json`. + +2. **Index deltas** for that category + * `spec/indexes/keywords_.json` + * `spec/indexes/source_refs_.json` + * `spec/indexes/coverage_delta_.json` + +3. **Coverage notes** report + * Short Markdown report describing enforcement split (lint/typeset/postrender/manual) + * plus any known gaps or manual-only areas + +## Batch naming + +`` format: + +* `v1__` (e.g. `v1_punctuation_001`) + +Batches are append-only: + +* If rules need revision, mark old rule `deprecated`, add a new rule ID (or new version segment) and keep both records. + +## Pointer scheme details + +Pointer strings live in `source_refs[]` and are **not** quotes. + +Preferred pointer format: + +* `CMOS18 §
p` +* `BRING §
p` +* Optional disambiguation: `(scan p)` + +Example pattern (not a quote): + +* `CMOS18 §6.1 p377 (scan p10)` + +Notes: + +* “book_page” uses the printed page number in the book when present (arabic or roman). +* “scan p” uses the PDF page index when printed page numbers are ambiguous. + +## Recommended extraction order (high-impact first) + +1. numbers +2. punctuation +3. citations +4. headings +5. tables +6. figures +7. links +8. code +9. layout (widows/orphans, keeps, overflow) +10. front/back matter +11. accessibility +12. i18n + +Rationale: + +* Numbers/punctuation/citations most directly affect correctness and consistency. +* Layout rules benefit from having structure and tokens in place. + +## Enforcement mapping guidelines (honest labeling) + +* `lint`: detectable from AST or text normalization (spacing, punctuation patterns, citation fields). +* `typeset`: enforced via CSS/tokens/paged-media decisions. +* `postrender`: requires layout inspection after rendering. +* `manual`: cannot be reliably automated; must include `tags: ["manual_checklist=true"]` and be emitted into checklist outputs. + +If a concept spans multiple enforcement layers: + +* Prefer splitting into two rules: + * one lint rule (source cleanliness) + * one postrender rule (layout outcome) +* Use `dependencies` to link them. + +## “Degraded mode” considerations during extraction + +For each category batch, include some rules that specifically target degraded inputs: + +* hard-wrap repair suggestions +* heading inference warnings +* link sanitation and encoding fixes +* Unicode normalization notes + +These rules should generally be `warn` or `should`, unless they prevent corruption (then `must`). + diff --git a/spec/house/HOUSE_RULES.md b/spec/house/HOUSE_RULES.md new file mode 100644 index 0000000..077e5f5 --- /dev/null +++ b/spec/house/HOUSE_RULES.md @@ -0,0 +1,59 @@ +# HOUSE rule pointers (iftypeset) + +This document provides pointer anchors for HOUSE rules in the `spec/rules/**.ndjson` registry. + +It intentionally contains **no** excerpts from third-party books. HOUSE rules document: + +- runtime/QA constraints we enforce in CI (overflow, keeps, deterministic output), +- integration expectations for upstream renderers (e.g., Forgejo PDF worker), +- how we map rule enforcement to lint/typeset/postrender/manual. + +Pointers use the pattern: `HOUSE §
p`. + +## §QA.OVERFLOW (p1) + +House definition of overflow incidents (overfull lines, clipped tables/code) and required handling: + +- prefer wrapping and reflow, +- allow bounded font scaling only within profile limits, +- never clip silently. + +## §QA.KEEPS (p1) + +House definition of keep constraints: + +- headings must not strand at the bottom of a page, +- keep-with-next thresholds are configured per profile. + +## §QA.LINK_WRAP (p2) + +House definition of link wrapping / line-breaking policy: + +- prefer readable labels over raw URLs in running text, +- avoid breaking URLs in ways that change meaning (scheme/host), +- if a URL must wrap, prefer breaking at safe separators and never clip silently. + +## §QA.TABLE_OVERFLOW (p2) + +House definition of table overflow handling: + +- tables must not clip off-page, +- prefer wrap → shrink (bounded) → rotate only when explicitly enabled by profile, +- repeat table headers on subsequent pages when supported. + +## §QA.CODE_OVERFLOW (p2) + +House definition of code block overflow handling: + +- code must be copy/pasteable and readable without horizontal scrolling where possible, +- prefer wrap → shrink (bounded) → optional “scroll indicator” affordance for print targets, +- never clip code. + +## §A11Y.BASICS (p2) + +House baseline accessibility rules for exported HTML/PDF: + +- heading hierarchy must be well-formed, +- images must have alt text (or an explicit empty alt when decorative), +- link text must be descriptive (avoid “click here”), +- document language must be declared where supported. diff --git a/spec/indexes/README.md b/spec/indexes/README.md new file mode 100644 index 0000000..337a1b2 --- /dev/null +++ b/spec/indexes/README.md @@ -0,0 +1,74 @@ +# Indexes + +This project builds small, fast indexes so the runtime can answer questions like: + +* “Which rules mention *en dash*?” +* “Which rules cite *CMOS18 §6.88 p412*?” +* “Which rules apply to `postrender` QA?” +* “What rules are overridden by the `print_pdf` profile?” + +Indexes are derived artifacts (rebuildable) and should not be hand-edited. + +## Indexes the app will build + +### 1) keyword → rule IDs + +**Purpose:** fast search/autocomplete and lint explanations. + +* **Path:** `spec/indexes/keywords_all.json` and per-category deltas: + * `spec/indexes/keywords_.json` +* **Format (JSON):** + * keys: normalized keyword (lowercased) + * values: array of rule IDs sorted stable (lexicographic) + +Normalization (default): + +* Unicode NFKC +* lowercase +* collapse whitespace +* strip surrounding punctuation + +### 2) source_ref → rule IDs + +**Purpose:** audit trail back to references without embedding book text. + +* **Path:** `spec/indexes/source_refs_all.json` and per-category deltas: + * `spec/indexes/source_refs_.json` +* **Format (JSON):** + * keys: exact `source_ref` pointer strings + * values: array of rule IDs + +### 3) category → rule IDs + +**Purpose:** batch reporting, extraction coverage, profile scoping. + +* **Path:** `spec/indexes/category.json` +* **Format (JSON):** + * keys: category name + * values: array of rule IDs + +### 4) enforcement → rule IDs + +**Purpose:** quickly decide which engine (lint/typeset/postrender/manual) handles which rules. + +* **Path:** `spec/indexes/enforcement.json` + +### 5) profile overrides + +**Purpose:** allow profiles to override severity or token parameters without editing rules. + +* **Path:** `spec/indexes/profile_overrides.json` +* **Format (JSON):** + * per profile: list of override objects (selector + action) + * selectors may match category, tags, applies_to, or explicit rule IDs + +## Build guarantees + +* Index builds are deterministic from: + * `spec/rules/**.ndjson` + * `spec/profiles/*.yaml` + * `spec/manifest.yaml` + +* The runtime must treat indexes as **cacheable**: + * if index missing/outdated → rebuild or fallback to scanning rule files. + diff --git a/spec/indexes/category.json b/spec/indexes/category.json new file mode 100644 index 0000000..a3a2da0 --- /dev/null +++ b/spec/indexes/category.json @@ -0,0 +1,329 @@ +{ + "accessibility": [ + "HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE", + "HOUSE.A11Y.HEADINGS.NO_SKIPS", + "HOUSE.A11Y.IMAGES.ALT_REQUIRED", + "HOUSE.A11Y.LINK_TEXT.DESCRIPTIVE" + ], + "citations": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.EXTRA_INFO", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.MULTI_SOURCES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PLACEMENT", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ALPHABETICAL", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REPEATED_NAMES", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED", + "CMOS.CITATIONS.BIBLIOGRAPHY.ALT_NAMES.CROSSREF", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.CONSISTENT_FORM", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.INITIALS_PREFERRED", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.PUBLISHED_FORM", + "CMOS.CITATIONS.BIBLIOGRAPHY.MULTI_AUTHORS.ORDER", + "CMOS.CITATIONS.BIBLIOGRAPHY.NO_AUTHOR.TITLE_LEAD", + "CMOS.CITATIONS.BIBLIOGRAPHY.PSEUDONYMS.CONSISTENT", + "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_SURNAME.DISAMBIGUATE", + "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR", + "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE", + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL", + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID", + "CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT", + "CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED", + "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES", + "CMOS.CITATIONS.NOTES.AVOID_OVERLONG", + "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK", + "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID", + "CMOS.CITATIONS.NOTES.ENDNOTES.PLACEMENT", + "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS", + "CMOS.CITATIONS.NOTES.FOOTNOTES.PAGE_BREAKS", + "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE", + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS", + "CMOS.CITATIONS.NOTES.LONG_NOTES.PARAGRAPHING", + "CMOS.CITATIONS.NOTES.MULTIPLE_CITATIONS.SINGLE_NOTE", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.HEADINGS_END", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.PLACEMENT_AFTER_PUNCT", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT", + "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR", + "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS", + "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE", + "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS", + "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE", + "CMOS.CITATIONS.NOTES.UNNUMBERED.NOT_FOR_SOURCES", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR", + "CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE", + "CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO", + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM", + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED", + "CMOS.CITATIONS.ONLINE.PERMALINKS.PREFERRED", + "CMOS.CITATIONS.ONLINE.REVISION_DATE.DISTINCT", + "CMOS.CITATIONS.ONLINE.URLS.STABLE", + "CMOS.CITATIONS.ONLINE.VERSION.CITE_RIGHT_VERSION", + "CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED", + "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY", + "CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE", + "CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT" + ], + "code": [ + "HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED", + "HOUSE.CODE.BLOCKS.NO_CLIPPING", + "HOUSE.CODE.BLOCKS.WRAP_POLICY", + "HOUSE.CODE.INLINE.MONO_BACKTICKS" + ], + "headings": [ + "BRING.HEADINGS.ALIGNMENT.CONSISTENT_LEVEL", + "BRING.HEADINGS.BLOCK_QUOTE.SPACING_AROUND", + "BRING.HEADINGS.CAPITALIZATION.CONSISTENT", + "BRING.HEADINGS.CONTRAST.CLEAR_HIERARCHY", + "BRING.HEADINGS.DEGRADED.INFER_STRUCTURE", + "BRING.HEADINGS.HIERARCHY.NO_SKIPPED_LEVELS", + "BRING.HEADINGS.MARGIN_HEADS.CLEAR_GUTTER", + "BRING.HEADINGS.PARAGRAPH_INDENT.AFTER_HEAD_NONE", + "BRING.HEADINGS.RELATED_ELEMENTS.COHERENT", + "BRING.HEADINGS.RUN_IN.STANDALONE.CONSISTENT", + "BRING.HEADINGS.SPACING.VERTICAL_RHYTHM", + "BRING.HEADINGS.STRUCTURE.MATCH_TEXT_LOGIC", + "BRING.HEADINGS.STYLE.PALETTE_LIMIT", + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY", + "BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED", + "BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS", + "BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT", + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD", + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE", + "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE", + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR", + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS", + "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT", + "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT", + "CMOS.HEADINGS.MULTIAUTHOR.AUTHOR_ATTRIBUTION_PLACEMENT", + "CMOS.HEADINGS.MULTIAUTHOR.CHAPTER_NUMBERING", + "CMOS.HEADINGS.RUNNING_HEADS.DEFINITION", + "CMOS.HEADINGS.RUNNING_HEADS.DIVISION_MATCH", + "CMOS.HEADINGS.RUNNING_HEADS.LENGTH_SHORT", + "CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE", + "HOUSE.HEADINGS.KEEPS.AVOID_STRANDED" + ], + "layout": [ + "BRING.LAYOUT.BLOCK_QUOTES.AVOID_CROWDING", + "BRING.LAYOUT.BLOCK_QUOTES.BEFORE_AFTER_SPACING", + "BRING.LAYOUT.BLOCK_QUOTES.EXTRA_LEAD", + "BRING.LAYOUT.BLOCK_QUOTES.INDENT_OR_NARROW", + "BRING.LAYOUT.COLUMNS.BALANCE_LENGTHS", + "BRING.LAYOUT.DEGRADED.HARD_WRAP_REFLOW", + "BRING.LAYOUT.ELEMENT_RELATIONSHIPS.VISIBLE", + "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE", + "BRING.LAYOUT.GRID.ALIGN_ELEMENTS", + "BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION", + "BRING.LAYOUT.HYPHENATION.STUB_END_AVOID", + "BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED", + "BRING.LAYOUT.LEADING.ADJUST_FOR_SIZE_CHANGES", + "BRING.LAYOUT.LEADING.ALIGN_BASELINE_GRID", + "BRING.LAYOUT.LEADING.AVOID_TOO_LOOSE", + "BRING.LAYOUT.LEADING.AVOID_TOO_TIGHT", + "BRING.LAYOUT.LEADING.CHOOSE_BASE", + "BRING.LAYOUT.LEADING.CONSISTENT_BODY", + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT", + "BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START", + "BRING.LAYOUT.MARGINS.FACING_PAGES.INNER_OUTER", + "BRING.LAYOUT.MEASURE.ADJUST_FOR_TYPE_SIZE", + "BRING.LAYOUT.MEASURE.AVOID_TOO_LONG", + "BRING.LAYOUT.MEASURE.AVOID_TOO_SHORT", + "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS", + "BRING.LAYOUT.MEASURE.CODE_BLOCKS.WRAP_POLICY", + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE", + "BRING.LAYOUT.MEASURE.CONSISTENT_WITHIN_SECTION", + "BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS", + "BRING.LAYOUT.MEASURE.TARGET_RANGE_CHARS", + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE", + "BRING.LAYOUT.PAGE.FRAME.TEXTBLOCK_BALANCE", + "BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES", + "BRING.LAYOUT.PAGINATION.ORPHANS_AVOID", + "BRING.LAYOUT.PAGINATION.WIDOWS_AVOID", + "BRING.LAYOUT.PARAGRAPH.BLANK_LINES.SPARING", + "BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST", + "BRING.LAYOUT.PARAGRAPH.INDENT_OR_SPACE_NOT_BOTH", + "BRING.LAYOUT.PARAGRAPH.INDENT_SIZE.CONSISTENT", + "BRING.LAYOUT.PARAGRAPH.NO_INDENT_AFTER_BLOCKS", + "BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT", + "BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT", + "BRING.LAYOUT.TEXTBLOCK.CONSISTENT_WIDTH", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES", + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT", + "HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS" + ], + "links": [ + "HOUSE.LINKS.DISALLOW.FILE_URIS", + "HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT", + "HOUSE.LINKS.TEXT.DESCRIPTIVE", + "HOUSE.LINKS.URLS.PREFER_HTTPS", + "HOUSE.LINKS.WRAP.SAFE_BREAKS" + ], + "numbers": [ + "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING", + "CMOS.NUMBERS.CENTURIES.SPELLED_OUT", + "CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID", + "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS", + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT", + "CMOS.NUMBERS.CURRENCY.HISTORICAL.YEAR_CONTEXT", + "CMOS.NUMBERS.CURRENCY.ISO_CODES", + "CMOS.NUMBERS.CURRENCY.LARGE_AMOUNTS", + "CMOS.NUMBERS.CURRENCY.NON_US.DISAMBIGUATE", + "CMOS.NUMBERS.CURRENCY.WORDS_VS_SYMBOLS", + "CMOS.NUMBERS.DATES.ABBREVIATED_YEAR", + "CMOS.NUMBERS.DATES.ALL_NUMERAL", + "CMOS.NUMBERS.DATES.CONSISTENT_FORMAT", + "CMOS.NUMBERS.DATES.ISO_8601", + "CMOS.NUMBERS.DATES.MONTH_DAY_STYLE", + "CMOS.NUMBERS.DATES.YEAR_NUMERALS", + "CMOS.NUMBERS.DECADES.CONSISTENT_FORM", + "CMOS.NUMBERS.DECIMALS.LEADING_ZERO", + "CMOS.NUMBERS.DECIMALS.NUMERALS", + "CMOS.NUMBERS.DECIMAL_MARKER.LOCALE", + "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS", + "CMOS.NUMBERS.DEGRADED.NUMERAL_NORMALIZATION", + "CMOS.NUMBERS.DENSE_CONTEXT.USE_NUMERALS", + "CMOS.NUMBERS.DIGIT_GROUPING.SI_SPACE", + "CMOS.NUMBERS.ERAS.BCE_CE", + "CMOS.NUMBERS.FRACTIONS.MATH.NUMERALS", + "CMOS.NUMBERS.FRACTIONS.MIXED.WHOLE_PLUS_FRACTION", + "CMOS.NUMBERS.FRACTIONS.SIMPLE.SPELL_OUT", + "CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR", + "CMOS.NUMBERS.INCLUSIVE.COMMAS", + "CMOS.NUMBERS.INCLUSIVE.YEARS", + "CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN", + "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS", + "CMOS.NUMBERS.LISTS.OUTLINE.NUMERAL_STYLE", + "CMOS.NUMBERS.NAMES.MONARCHS_POPES", + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS", + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT", + "CMOS.NUMBERS.PERCENTAGES.NUMERALS", + "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE", + "CMOS.NUMBERS.PLACES.BUILDINGS_APTS", + "CMOS.NUMBERS.PLACES.HIGHWAYS", + "CMOS.NUMBERS.PLACES.STREETS", + "CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE", + "CMOS.NUMBERS.PLURALS.SPELLED_OUT", + "CMOS.NUMBERS.RANGES.EN_DASH.USE", + "CMOS.NUMBERS.RATIOS.FORMAT", + "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE", + "CMOS.NUMBERS.ROMAN_NUMERALS.USE", + "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT", + "CMOS.NUMBERS.RULE_SELECTION.ALTERNATIVE_ZERO_TO_NINE", + "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE", + "CMOS.NUMBERS.SCIENTIFIC.POWERS_OF_TEN", + "CMOS.NUMBERS.SENTENCE_START.AVOID_NUMERAL", + "CMOS.NUMBERS.SI_PREFIXES.NO_HYPHEN", + "CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT", + "CMOS.NUMBERS.TELEPHONE.FORMAT", + "CMOS.NUMBERS.TIME.GENERAL_NUMERALS", + "CMOS.NUMBERS.TIME.ISO_STYLE", + "CMOS.NUMBERS.TIME.NOON_MIDNIGHT", + "CMOS.NUMBERS.TIME.TWENTY_FOUR_HOUR", + "CMOS.NUMBERS.UNITS.REPEATED.OMIT_REPEAT", + "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS" + ], + "punctuation": [ + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS", + "CMOS.PUNCTUATION.BRACKETS.NESTED_PARENS", + "CMOS.PUNCTUATION.BRACKETS.TRANSLATED_TEXT", + "CMOS.PUNCTUATION.COLONS.AS_FOLLOWS", + "CMOS.PUNCTUATION.COLONS.CAPITALIZATION", + "CMOS.PUNCTUATION.COLONS.INTRO_QUOTE_QUESTION", + "CMOS.PUNCTUATION.COLONS.SPACE_AFTER", + "CMOS.PUNCTUATION.COMMAS.ADDRESSES", + "CMOS.PUNCTUATION.COMMAS.APPOSITIVES", + "CMOS.PUNCTUATION.COMMAS.COMPOUND_PREDICATES", + "CMOS.PUNCTUATION.COMMAS.DATES", + "CMOS.PUNCTUATION.COMMAS.DEPENDENT_CLAUSE_AFTER", + "CMOS.PUNCTUATION.COMMAS.DESCRIPTIVE_PHRASES", + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY", + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_PHRASES", + "CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF", + "CMOS.PUNCTUATION.COMMAS.PARTICIPIAL_PHRASES", + "CMOS.PUNCTUATION.COMMAS.QUESTIONS", + "CMOS.PUNCTUATION.COMMAS.QUOTED_TITLES", + "CMOS.PUNCTUATION.COMMAS.REPEATED_ADJECTIVES", + "CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF", + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT", + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US", + "CMOS.PUNCTUATION.DASHES.EM_INSTEAD_OF_QUOTES", + "CMOS.PUNCTUATION.DASHES.EM_LINE_BREAKS", + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES", + "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT", + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT", + "CMOS.PUNCTUATION.EXCLAMATION.USE_SPARINGLY", + "CMOS.PUNCTUATION.EXCLAMATION.VS_QUESTION", + "CMOS.PUNCTUATION.HYPHENATION.COMPOUND_DEFINITION", + "CMOS.PUNCTUATION.HYPHENATION.GENERAL_CHOICE", + "CMOS.PUNCTUATION.HYPHENATION.READABILITY", + "CMOS.PUNCTUATION.HYPHENATION.SUSPENDED", + "CMOS.PUNCTUATION.HYPHENATION.TREND_CLOSED", + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN", + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN", + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING", + "CMOS.PUNCTUATION.PARENS.GLOSSES_TRANSLATIONS", + "CMOS.PUNCTUATION.PARENS.NESTING", + "CMOS.PUNCTUATION.PARENS.USE", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE", + "CMOS.PUNCTUATION.PERIODS.USE", + "CMOS.PUNCTUATION.PERIODS.WITH_PARENS", + "CMOS.PUNCTUATION.QUESTION_MARK.DIRECT_VS_INDIRECT", + "CMOS.PUNCTUATION.QUESTION_MARK.USE", + "CMOS.PUNCTUATION.QUESTION_MARK.WITH_PUNCT", + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US", + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US", + "CMOS.PUNCTUATION.QUOTES.SMART_QUOTES", + "CMOS.PUNCTUATION.SEMICOLONS.BEFORE_CONJUNCTION", + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE", + "CMOS.PUNCTUATION.SEMICOLONS.CONJUNCTIVE_PHRASES", + "CMOS.PUNCTUATION.SLASHES.ALTERNATIVES", + "CMOS.PUNCTUATION.SLASHES.TWO_YEAR_SPANS" + ], + "tables": [ + "BRING.TABLES.CAPTIONS.CLEAR", + "BRING.TABLES.COLUMN_ALIGNMENT.CONSISTENT", + "BRING.TABLES.DATA_TYPES.NOT_MIXED", + "BRING.TABLES.DEGRADED.REBUILD_COLUMNS", + "BRING.TABLES.EDIT_AS_TEXT.READABILITY", + "BRING.TABLES.FURNITURE.MINIMIZE", + "BRING.TABLES.GROUPING.WHITESPACE", + "BRING.TABLES.GUIDES.READING_DIRECTION", + "BRING.TABLES.HEADERS.ALIGN_WITH_COLUMNS", + "BRING.TABLES.HEADERS.CONCISE", + "BRING.TABLES.MULTI_LINE_HEADERS.AVOID", + "BRING.TABLES.NUMERIC_PRECISION.CONSISTENT", + "BRING.TABLES.ORDER.LOGICAL", + "BRING.TABLES.ROW_SPACING.READABLE", + "BRING.TABLES.SOURCES.NOTES.DISTINCT", + "BRING.TABLES.STUB_COLUMN.USE", + "BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL", + "BRING.TABLES.TITLES.CONSISTENT_PLACEMENT", + "BRING.TABLES.TYPE_SIZE.READABLE", + "BRING.TABLES.UNITS.IN_HEADERS", + "HOUSE.TABLES.ALIGNMENT.DECIMALS", + "HOUSE.TABLES.HEADERS.REPEAT_ON_PAGEBREAK", + "HOUSE.TABLES.OVERFLOW.NO_CLIPPING" + ], + "typography": [ + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_PROPER_NAMES", + "BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS", + "BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH", + "BRING.TYPOGRAPHY.HYPHENATION.MAX_CONSECUTIVE_LINES", + "BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT", + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE", + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION", + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY", + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE", + "BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE", + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE", + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS", + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID" + ] +} diff --git a/spec/indexes/enforcement.json b/spec/indexes/enforcement.json new file mode 100644 index 0000000..1be2008 --- /dev/null +++ b/spec/indexes/enforcement.json @@ -0,0 +1,317 @@ +{ + "lint": [ + "BRING.HEADINGS.CAPITALIZATION.CONSISTENT", + "BRING.HEADINGS.DEGRADED.INFER_STRUCTURE", + "BRING.HEADINGS.HIERARCHY.NO_SKIPPED_LEVELS", + "BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED", + "BRING.LAYOUT.DEGRADED.HARD_WRAP_REFLOW", + "BRING.TABLES.DEGRADED.REBUILD_COLUMNS", + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE", + "BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS", + "BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE", + "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR", + "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE", + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.HEADINGS_END", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.PLACEMENT_AFTER_PUNCT", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT", + "CMOS.NUMBERS.DECIMALS.LEADING_ZERO", + "CMOS.NUMBERS.DECIMALS.NUMERALS", + "CMOS.NUMBERS.DECIMAL_MARKER.LOCALE", + "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS", + "CMOS.NUMBERS.DEGRADED.NUMERAL_NORMALIZATION", + "CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR", + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT", + "CMOS.NUMBERS.PERCENTAGES.NUMERALS", + "CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE", + "CMOS.NUMBERS.RANGES.EN_DASH.USE", + "CMOS.NUMBERS.SI_PREFIXES.NO_HYPHEN", + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS", + "CMOS.PUNCTUATION.COLONS.SPACE_AFTER", + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT", + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US", + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES", + "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT", + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT", + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE", + "CMOS.PUNCTUATION.QUOTES.SMART_QUOTES", + "HOUSE.A11Y.HEADINGS.NO_SKIPS", + "HOUSE.A11Y.IMAGES.ALT_REQUIRED", + "HOUSE.A11Y.LINK_TEXT.DESCRIPTIVE", + "HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED", + "HOUSE.CODE.INLINE.MONO_BACKTICKS", + "HOUSE.LINKS.DISALLOW.FILE_URIS", + "HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT", + "HOUSE.LINKS.TEXT.DESCRIPTIVE", + "HOUSE.LINKS.URLS.PREFER_HTTPS" + ], + "manual": [ + "BRING.HEADINGS.CONTRAST.CLEAR_HIERARCHY", + "BRING.HEADINGS.RELATED_ELEMENTS.COHERENT", + "BRING.HEADINGS.RUN_IN.STANDALONE.CONSISTENT", + "BRING.HEADINGS.STRUCTURE.MATCH_TEXT_LOGIC", + "BRING.HEADINGS.STYLE.PALETTE_LIMIT", + "BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT", + "BRING.LAYOUT.ELEMENT_RELATIONSHIPS.VISIBLE", + "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE", + "BRING.LAYOUT.MEASURE.CODE_BLOCKS.WRAP_POLICY", + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE", + "BRING.LAYOUT.PARAGRAPH.BLANK_LINES.SPARING", + "BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT", + "BRING.TABLES.CAPTIONS.CLEAR", + "BRING.TABLES.DATA_TYPES.NOT_MIXED", + "BRING.TABLES.EDIT_AS_TEXT.READABILITY", + "BRING.TABLES.GUIDES.READING_DIRECTION", + "BRING.TABLES.HEADERS.CONCISE", + "BRING.TABLES.MULTI_LINE_HEADERS.AVOID", + "BRING.TABLES.NUMERIC_PRECISION.CONSISTENT", + "BRING.TABLES.ORDER.LOGICAL", + "BRING.TABLES.SOURCES.NOTES.DISTINCT", + "BRING.TABLES.STUB_COLUMN.USE", + "BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL", + "BRING.TABLES.TITLES.CONSISTENT_PLACEMENT", + "BRING.TABLES.UNITS.IN_HEADERS", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_PROPER_NAMES", + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE", + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY", + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.EXTRA_INFO", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.MULTI_SOURCES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PLACEMENT", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ALPHABETICAL", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REPEATED_NAMES", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED", + "CMOS.CITATIONS.BIBLIOGRAPHY.ALT_NAMES.CROSSREF", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.CONSISTENT_FORM", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.INITIALS_PREFERRED", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.PUBLISHED_FORM", + "CMOS.CITATIONS.BIBLIOGRAPHY.MULTI_AUTHORS.ORDER", + "CMOS.CITATIONS.BIBLIOGRAPHY.NO_AUTHOR.TITLE_LEAD", + "CMOS.CITATIONS.BIBLIOGRAPHY.PSEUDONYMS.CONSISTENT", + "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_SURNAME.DISAMBIGUATE", + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID", + "CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT", + "CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED", + "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES", + "CMOS.CITATIONS.NOTES.AVOID_OVERLONG", + "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK", + "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID", + "CMOS.CITATIONS.NOTES.ENDNOTES.PLACEMENT", + "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS", + "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE", + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS", + "CMOS.CITATIONS.NOTES.LONG_NOTES.PARAGRAPHING", + "CMOS.CITATIONS.NOTES.MULTIPLE_CITATIONS.SINGLE_NOTE", + "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR", + "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS", + "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE", + "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS", + "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE", + "CMOS.CITATIONS.NOTES.UNNUMBERED.NOT_FOR_SOURCES", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR", + "CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE", + "CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO", + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM", + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED", + "CMOS.CITATIONS.ONLINE.PERMALINKS.PREFERRED", + "CMOS.CITATIONS.ONLINE.REVISION_DATE.DISTINCT", + "CMOS.CITATIONS.ONLINE.URLS.STABLE", + "CMOS.CITATIONS.ONLINE.VERSION.CITE_RIGHT_VERSION", + "CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED", + "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY", + "CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE", + "CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT", + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR", + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS", + "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT", + "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT", + "CMOS.HEADINGS.MULTIAUTHOR.AUTHOR_ATTRIBUTION_PLACEMENT", + "CMOS.HEADINGS.MULTIAUTHOR.CHAPTER_NUMBERING", + "CMOS.HEADINGS.RUNNING_HEADS.DEFINITION", + "CMOS.HEADINGS.RUNNING_HEADS.DIVISION_MATCH", + "CMOS.HEADINGS.RUNNING_HEADS.LENGTH_SHORT", + "CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE", + "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING", + "CMOS.NUMBERS.CENTURIES.SPELLED_OUT", + "CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID", + "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS", + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT", + "CMOS.NUMBERS.CURRENCY.HISTORICAL.YEAR_CONTEXT", + "CMOS.NUMBERS.CURRENCY.ISO_CODES", + "CMOS.NUMBERS.CURRENCY.LARGE_AMOUNTS", + "CMOS.NUMBERS.CURRENCY.NON_US.DISAMBIGUATE", + "CMOS.NUMBERS.CURRENCY.WORDS_VS_SYMBOLS", + "CMOS.NUMBERS.DATES.ABBREVIATED_YEAR", + "CMOS.NUMBERS.DATES.ALL_NUMERAL", + "CMOS.NUMBERS.DATES.CONSISTENT_FORMAT", + "CMOS.NUMBERS.DATES.ISO_8601", + "CMOS.NUMBERS.DATES.MONTH_DAY_STYLE", + "CMOS.NUMBERS.DATES.YEAR_NUMERALS", + "CMOS.NUMBERS.DECADES.CONSISTENT_FORM", + "CMOS.NUMBERS.DENSE_CONTEXT.USE_NUMERALS", + "CMOS.NUMBERS.ERAS.BCE_CE", + "CMOS.NUMBERS.FRACTIONS.MATH.NUMERALS", + "CMOS.NUMBERS.FRACTIONS.MIXED.WHOLE_PLUS_FRACTION", + "CMOS.NUMBERS.FRACTIONS.SIMPLE.SPELL_OUT", + "CMOS.NUMBERS.INCLUSIVE.COMMAS", + "CMOS.NUMBERS.INCLUSIVE.YEARS", + "CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN", + "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS", + "CMOS.NUMBERS.LISTS.OUTLINE.NUMERAL_STYLE", + "CMOS.NUMBERS.NAMES.MONARCHS_POPES", + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS", + "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE", + "CMOS.NUMBERS.PLACES.BUILDINGS_APTS", + "CMOS.NUMBERS.PLACES.HIGHWAYS", + "CMOS.NUMBERS.PLACES.STREETS", + "CMOS.NUMBERS.PLURALS.SPELLED_OUT", + "CMOS.NUMBERS.RATIOS.FORMAT", + "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE", + "CMOS.NUMBERS.ROMAN_NUMERALS.USE", + "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT", + "CMOS.NUMBERS.RULE_SELECTION.ALTERNATIVE_ZERO_TO_NINE", + "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE", + "CMOS.NUMBERS.SCIENTIFIC.POWERS_OF_TEN", + "CMOS.NUMBERS.SENTENCE_START.AVOID_NUMERAL", + "CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT", + "CMOS.NUMBERS.TELEPHONE.FORMAT", + "CMOS.NUMBERS.TIME.GENERAL_NUMERALS", + "CMOS.NUMBERS.TIME.ISO_STYLE", + "CMOS.NUMBERS.TIME.NOON_MIDNIGHT", + "CMOS.NUMBERS.TIME.TWENTY_FOUR_HOUR", + "CMOS.NUMBERS.UNITS.REPEATED.OMIT_REPEAT", + "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS", + "CMOS.PUNCTUATION.BRACKETS.NESTED_PARENS", + "CMOS.PUNCTUATION.BRACKETS.TRANSLATED_TEXT", + "CMOS.PUNCTUATION.COLONS.AS_FOLLOWS", + "CMOS.PUNCTUATION.COLONS.CAPITALIZATION", + "CMOS.PUNCTUATION.COLONS.INTRO_QUOTE_QUESTION", + "CMOS.PUNCTUATION.COMMAS.ADDRESSES", + "CMOS.PUNCTUATION.COMMAS.APPOSITIVES", + "CMOS.PUNCTUATION.COMMAS.COMPOUND_PREDICATES", + "CMOS.PUNCTUATION.COMMAS.DATES", + "CMOS.PUNCTUATION.COMMAS.DEPENDENT_CLAUSE_AFTER", + "CMOS.PUNCTUATION.COMMAS.DESCRIPTIVE_PHRASES", + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY", + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_PHRASES", + "CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF", + "CMOS.PUNCTUATION.COMMAS.PARTICIPIAL_PHRASES", + "CMOS.PUNCTUATION.COMMAS.QUESTIONS", + "CMOS.PUNCTUATION.COMMAS.QUOTED_TITLES", + "CMOS.PUNCTUATION.COMMAS.REPEATED_ADJECTIVES", + "CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF", + "CMOS.PUNCTUATION.DASHES.EM_INSTEAD_OF_QUOTES", + "CMOS.PUNCTUATION.EXCLAMATION.USE_SPARINGLY", + "CMOS.PUNCTUATION.EXCLAMATION.VS_QUESTION", + "CMOS.PUNCTUATION.HYPHENATION.COMPOUND_DEFINITION", + "CMOS.PUNCTUATION.HYPHENATION.GENERAL_CHOICE", + "CMOS.PUNCTUATION.HYPHENATION.READABILITY", + "CMOS.PUNCTUATION.HYPHENATION.SUSPENDED", + "CMOS.PUNCTUATION.HYPHENATION.TREND_CLOSED", + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN", + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN", + "CMOS.PUNCTUATION.PARENS.GLOSSES_TRANSLATIONS", + "CMOS.PUNCTUATION.PARENS.NESTING", + "CMOS.PUNCTUATION.PARENS.USE", + "CMOS.PUNCTUATION.PERIODS.USE", + "CMOS.PUNCTUATION.PERIODS.WITH_PARENS", + "CMOS.PUNCTUATION.QUESTION_MARK.DIRECT_VS_INDIRECT", + "CMOS.PUNCTUATION.QUESTION_MARK.USE", + "CMOS.PUNCTUATION.QUESTION_MARK.WITH_PUNCT", + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US", + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US", + "CMOS.PUNCTUATION.SEMICOLONS.BEFORE_CONJUNCTION", + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE", + "CMOS.PUNCTUATION.SEMICOLONS.CONJUNCTIVE_PHRASES", + "CMOS.PUNCTUATION.SLASHES.ALTERNATIVES", + "CMOS.PUNCTUATION.SLASHES.TWO_YEAR_SPANS" + ], + "postrender": [ + "BRING.LAYOUT.COLUMNS.BALANCE_LENGTHS", + "BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION", + "BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START", + "BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES", + "BRING.LAYOUT.PAGINATION.ORPHANS_AVOID", + "BRING.LAYOUT.PAGINATION.WIDOWS_AVOID", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE", + "CMOS.CITATIONS.NOTES.FOOTNOTES.PAGE_BREAKS", + "HOUSE.CODE.BLOCKS.NO_CLIPPING", + "HOUSE.HEADINGS.KEEPS.AVOID_STRANDED", + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT", + "HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS", + "HOUSE.TABLES.OVERFLOW.NO_CLIPPING" + ], + "typeset": [ + "BRING.HEADINGS.ALIGNMENT.CONSISTENT_LEVEL", + "BRING.HEADINGS.BLOCK_QUOTE.SPACING_AROUND", + "BRING.HEADINGS.MARGIN_HEADS.CLEAR_GUTTER", + "BRING.HEADINGS.PARAGRAPH_INDENT.AFTER_HEAD_NONE", + "BRING.HEADINGS.SPACING.VERTICAL_RHYTHM", + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY", + "BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS", + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD", + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE", + "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE", + "BRING.LAYOUT.BLOCK_QUOTES.AVOID_CROWDING", + "BRING.LAYOUT.BLOCK_QUOTES.BEFORE_AFTER_SPACING", + "BRING.LAYOUT.BLOCK_QUOTES.EXTRA_LEAD", + "BRING.LAYOUT.BLOCK_QUOTES.INDENT_OR_NARROW", + "BRING.LAYOUT.GRID.ALIGN_ELEMENTS", + "BRING.LAYOUT.HYPHENATION.STUB_END_AVOID", + "BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED", + "BRING.LAYOUT.LEADING.ADJUST_FOR_SIZE_CHANGES", + "BRING.LAYOUT.LEADING.ALIGN_BASELINE_GRID", + "BRING.LAYOUT.LEADING.AVOID_TOO_LOOSE", + "BRING.LAYOUT.LEADING.AVOID_TOO_TIGHT", + "BRING.LAYOUT.LEADING.CHOOSE_BASE", + "BRING.LAYOUT.LEADING.CONSISTENT_BODY", + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT", + "BRING.LAYOUT.MARGINS.FACING_PAGES.INNER_OUTER", + "BRING.LAYOUT.MEASURE.ADJUST_FOR_TYPE_SIZE", + "BRING.LAYOUT.MEASURE.AVOID_TOO_LONG", + "BRING.LAYOUT.MEASURE.AVOID_TOO_SHORT", + "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS", + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE", + "BRING.LAYOUT.MEASURE.CONSISTENT_WITHIN_SECTION", + "BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS", + "BRING.LAYOUT.MEASURE.TARGET_RANGE_CHARS", + "BRING.LAYOUT.PAGE.FRAME.TEXTBLOCK_BALANCE", + "BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST", + "BRING.LAYOUT.PARAGRAPH.INDENT_OR_SPACE_NOT_BOTH", + "BRING.LAYOUT.PARAGRAPH.INDENT_SIZE.CONSISTENT", + "BRING.LAYOUT.PARAGRAPH.NO_INDENT_AFTER_BLOCKS", + "BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT", + "BRING.LAYOUT.TEXTBLOCK.CONSISTENT_WIDTH", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES", + "BRING.TABLES.COLUMN_ALIGNMENT.CONSISTENT", + "BRING.TABLES.FURNITURE.MINIMIZE", + "BRING.TABLES.GROUPING.WHITESPACE", + "BRING.TABLES.HEADERS.ALIGN_WITH_COLUMNS", + "BRING.TABLES.ROW_SPACING.READABLE", + "BRING.TABLES.TYPE_SIZE.READABLE", + "BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH", + "BRING.TYPOGRAPHY.HYPHENATION.MAX_CONSECUTIVE_LINES", + "BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT", + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION", + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE", + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS", + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID", + "CMOS.NUMBERS.DIGIT_GROUPING.SI_SPACE", + "CMOS.PUNCTUATION.DASHES.EM_LINE_BREAKS", + "HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE", + "HOUSE.CODE.BLOCKS.WRAP_POLICY", + "HOUSE.LINKS.WRAP.SAFE_BREAKS", + "HOUSE.TABLES.ALIGNMENT.DECIMALS", + "HOUSE.TABLES.HEADERS.REPEAT_ON_PAGEBREAK" + ] +} diff --git a/spec/indexes/keywords_all.json b/spec/indexes/keywords_all.json new file mode 100644 index 0000000..4aa6efe --- /dev/null +++ b/spec/indexes/keywords_all.json @@ -0,0 +1,1883 @@ +{ + "!!": [ + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING" + ], + "-ly": [ + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN" + ], + "1990s": [ + "CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE" + ], + "1st": [ + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT" + ], + "24-hour time": [ + "CMOS.NUMBERS.TIME.TWENTY_FOUR_HOUR" + ], + "2nd": [ + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT" + ], + "3-em dash": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH" + ], + "?!": [ + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING" + ], + "a11y": [ + "HOUSE.A11Y.HEADINGS.NO_SKIPS" + ], + "abbreviated year": [ + "CMOS.NUMBERS.DATES.ABBREVIATED_YEAR" + ], + "access date": [ + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED", + "CMOS.CITATIONS.ONLINE.REVISION_DATE.DISTINCT" + ], + "accessibility": [ + "HOUSE.A11Y.LINK_TEXT.DESCRIPTIVE" + ], + "acknowledgments": [ + "CMOS.CITATIONS.NOTES.UNNUMBERED.NOT_FOR_SOURCES" + ], + "addresses": [ + "CMOS.NUMBERS.PLACES.STREETS", + "CMOS.PUNCTUATION.COMMAS.ADDRESSES" + ], + "adverb": [ + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN" + ], + "after block quote": [ + "BRING.LAYOUT.PARAGRAPH.NO_INDENT_AFTER_BLOCKS" + ], + "after heading": [ + "BRING.HEADINGS.PARAGRAPH_INDENT.AFTER_HEAD_NONE", + "BRING.LAYOUT.PARAGRAPH.NO_INDENT_AFTER_BLOCKS" + ], + "alignment": [ + "BRING.HEADINGS.ALIGNMENT.CONSISTENT_LEVEL", + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY", + "BRING.LAYOUT.LEADING.ALIGN_BASELINE_GRID", + "BRING.TABLES.COLUMN_ALIGNMENT.CONSISTENT", + "BRING.TABLES.FURNITURE.MINIMIZE", + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE" + ], + "alphabetical": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ALPHABETICAL", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR" + ], + "alt text": [ + "HOUSE.A11Y.IMAGES.ALT_REQUIRED" + ], + "alternative rule": [ + "CMOS.NUMBERS.RULE_SELECTION.ALTERNATIVE_ZERO_TO_NINE", + "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE" + ], + "alternatives": [ + "CMOS.PUNCTUATION.SLASHES.ALTERNATIVES" + ], + "ambiguity": [ + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN" + ], + "anonymous works": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.NO_AUTHOR.TITLE_LEAD" + ], + "apartment numbers": [ + "CMOS.NUMBERS.PLACES.BUILDINGS_APTS" + ], + "apostrophe": [ + "CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE" + ], + "apostrophes": [ + "CMOS.NUMBERS.PLURALS.SPELLED_OUT" + ], + "appositives": [ + "CMOS.PUNCTUATION.COMMAS.APPOSITIVES" + ], + "as follows": [ + "CMOS.PUNCTUATION.COLONS.AS_FOLLOWS" + ], + "asymmetry": [ + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD" + ], + "attribution": [ + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR" + ], + "author attribution": [ + "CMOS.HEADINGS.MULTIAUTHOR.AUTHOR_ATTRIBUTION_PLACEMENT" + ], + "author name": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.CONSISTENT_FORM", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.INITIALS_PREFERRED", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.PUBLISHED_FORM", + "CMOS.CITATIONS.BIBLIOGRAPHY.PSEUDONYMS.CONSISTENT" + ], + "author name order": [ + "CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO" + ], + "author order": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.MULTI_AUTHORS.ORDER" + ], + "author-date": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.EXTRA_INFO", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.MULTI_SOURCES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PLACEMENT", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REPEATED_NAMES", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED", + "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES", + "CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE" + ], + "backticks": [ + "HOUSE.CODE.INLINE.MONO_BACKTICKS" + ], + "balance": [ + "BRING.LAYOUT.COLUMNS.BALANCE_LENGTHS", + "BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE" + ], + "bare url": [ + "HOUSE.LINKS.TEXT.DESCRIPTIVE" + ], + "baseline": [ + "BRING.LAYOUT.GRID.ALIGN_ELEMENTS" + ], + "baseline grid": [ + "BRING.LAYOUT.LEADING.ALIGN_BASELINE_GRID", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES" + ], + "bce": [ + "CMOS.NUMBERS.ERAS.BCE_CE" + ], + "bibliography": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED", + "CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO" + ], + "bibliography entries": [ + "CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED" + ], + "bibliography order": [ + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR" + ], + "billion": [ + "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS" + ], + "binary": [ + "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING" + ], + "blank lines": [ + "BRING.LAYOUT.PARAGRAPH.BLANK_LINES.SPARING" + ], + "block quotation": [ + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS" + ], + "block quotes": [ + "BRING.HEADINGS.BLOCK_QUOTE.SPACING_AROUND", + "BRING.LAYOUT.BLOCK_QUOTES.AVOID_CROWDING", + "BRING.LAYOUT.BLOCK_QUOTES.BEFORE_AFTER_SPACING", + "BRING.LAYOUT.BLOCK_QUOTES.EXTRA_LEAD", + "BRING.LAYOUT.BLOCK_QUOTES.INDENT_OR_NARROW" + ], + "blockquote": [ + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS" + ], + "brackets": [ + "CMOS.PUNCTUATION.BRACKETS.NESTED_PARENS", + "CMOS.PUNCTUATION.PARENS.NESTING", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE" + ], + "broken urls": [ + "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE" + ], + "building numbers": [ + "CMOS.NUMBERS.PLACES.BUILDINGS_APTS" + ], + "capitalization": [ + "BRING.HEADINGS.CAPITALIZATION.CONSISTENT", + "CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT" + ], + "caps": [ + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS" + ], + "ce": [ + "CMOS.NUMBERS.ERAS.BCE_CE" + ], + "centered": [ + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY" + ], + "centuries": [ + "CMOS.NUMBERS.CENTURIES.SPELLED_OUT" + ], + "chapter citation": [ + "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK" + ], + "chapter endnotes": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.PLACEMENT" + ], + "chapter headings": [ + "CMOS.HEADINGS.MULTIAUTHOR.AUTHOR_ATTRIBUTION_PLACEMENT" + ], + "chapter numbering": [ + "CMOS.HEADINGS.MULTIAUTHOR.CHAPTER_NUMBERING" + ], + "chapter numbers": [ + "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE" + ], + "chapters": [ + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR" + ], + "characters per line": [ + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE" + ], + "citation management": [ + "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY" + ], + "citation placement": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PLACEMENT" + ], + "citation system": [ + "CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE" + ], + "clarity": [ + "BRING.TABLES.CAPTIONS.CLEAR", + "CMOS.PUNCTUATION.HYPHENATION.READABILITY" + ], + "click here": [ + "HOUSE.LINKS.TEXT.DESCRIPTIVE" + ], + "clipping": [ + "HOUSE.CODE.BLOCKS.NO_CLIPPING", + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT", + "HOUSE.TABLES.OVERFLOW.NO_CLIPPING" + ], + "closed compounds": [ + "CMOS.PUNCTUATION.HYPHENATION.COMPOUND_DEFINITION", + "CMOS.PUNCTUATION.HYPHENATION.TREND_CLOSED" + ], + "code blocks": [ + "BRING.LAYOUT.MEASURE.CODE_BLOCKS.WRAP_POLICY" + ], + "code fence": [ + "HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED" + ], + "code overflow": [ + "HOUSE.CODE.BLOCKS.NO_CLIPPING" + ], + "code wrapping": [ + "HOUSE.CODE.BLOCKS.WRAP_POLICY" + ], + "coherence": [ + "BRING.HEADINGS.RELATED_ELEMENTS.COHERENT" + ], + "colon capitalization": [ + "CMOS.PUNCTUATION.COLONS.CAPITALIZATION" + ], + "colon notation": [ + "CMOS.NUMBERS.RATIOS.FORMAT" + ], + "colon spacing": [ + "CMOS.PUNCTUATION.COLONS.SPACE_AFTER" + ], + "colon usage": [ + "CMOS.PUNCTUATION.COLONS.AS_FOLLOWS", + "CMOS.PUNCTUATION.COLONS.INTRO_QUOTE_QUESTION" + ], + "column consistency": [ + "BRING.TABLES.DATA_TYPES.NOT_MIXED" + ], + "column headers": [ + "BRING.TABLES.HEADERS.CONCISE", + "BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL" + ], + "columns": [ + "BRING.LAYOUT.COLUMNS.BALANCE_LENGTHS", + "BRING.LAYOUT.MEASURE.AVOID_TOO_SHORT", + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE", + "BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS", + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE", + "BRING.TABLES.HEADERS.ALIGN_WITH_COLUMNS" + ], + "comma grouping": [ + "CMOS.NUMBERS.INCLUSIVE.COMMAS" + ], + "comma placement": [ + "CMOS.PUNCTUATION.COMMAS.DATES", + "CMOS.PUNCTUATION.COMMAS.DEPENDENT_CLAUSE_AFTER", + "CMOS.PUNCTUATION.COMMAS.QUOTED_TITLES" + ], + "comma use": [ + "CMOS.PUNCTUATION.COMMAS.DESCRIPTIVE_PHRASES", + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_PHRASES", + "CMOS.PUNCTUATION.COMMAS.PARTICIPIAL_PHRASES", + "CMOS.PUNCTUATION.COMMAS.QUESTIONS", + "CMOS.PUNCTUATION.COMMAS.REPEATED_ADJECTIVES" + ], + "commas": [ + "CMOS.PUNCTUATION.COMMAS.ADDRESSES", + "CMOS.PUNCTUATION.COMMAS.COMPOUND_PREDICATES" + ], + "commas in quotes": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US" + ], + "commentary": [ + "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE" + ], + "commentary notes": [ + "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES" + ], + "complex series": [ + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE" + ], + "compound modifier": [ + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN" + ], + "compound predicate": [ + "CMOS.PUNCTUATION.COMMAS.COMPOUND_PREDICATES" + ], + "compounds": [ + "CMOS.PUNCTUATION.HYPHENATION.GENERAL_CHOICE" + ], + "concise": [ + "BRING.TABLES.HEADERS.CONCISE" + ], + "condense": [ + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION" + ], + "conjunctions": [ + "CMOS.PUNCTUATION.SEMICOLONS.BEFORE_CONJUNCTION" + ], + "conjunctive phrases": [ + "CMOS.PUNCTUATION.SEMICOLONS.CONJUNCTIVE_PHRASES" + ], + "consecutive lines": [ + "BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START", + "BRING.TYPOGRAPHY.HYPHENATION.MAX_CONSECUTIVE_LINES" + ], + "consistency": [ + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE", + "BRING.LAYOUT.LEADING.CONSISTENT_BODY", + "BRING.LAYOUT.PARAGRAPH.INDENT_SIZE.CONSISTENT", + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.CONSISTENT_FORM", + "CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID", + "CMOS.NUMBERS.DECADES.CONSISTENT_FORM" + ], + "continuous text": [ + "BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST" + ], + "contrast": [ + "BRING.HEADINGS.CONTRAST.CLEAR_HIERARCHY", + "BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT" + ], + "conventional use": [ + "CMOS.NUMBERS.ROMAN_NUMERALS.USE" + ], + "coordinate adjectives": [ + "CMOS.PUNCTUATION.COMMAS.REPEATED_ADJECTIVES" + ], + "copy paste": [ + "HOUSE.LINKS.WRAP.SAFE_BREAKS" + ], + "cross-reference": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.ALT_NAMES.CROSSREF", + "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE" + ], + "crossheads": [ + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY" + ], + "currency": [ + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT", + "CMOS.NUMBERS.CURRENCY.ISO_CODES" + ], + "currency code": [ + "CMOS.NUMBERS.CURRENCY.NON_US.DISAMBIGUATE" + ], + "currency symbols": [ + "CMOS.NUMBERS.CURRENCY.WORDS_VS_SYMBOLS" + ], + "dash spacing": [ + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US" + ], + "data types": [ + "BRING.TABLES.DATA_TYPES.NOT_MIXED" + ], + "date format": [ + "CMOS.NUMBERS.DATES.CONSISTENT_FORMAT", + "CMOS.NUMBERS.DATES.ISO_8601", + "CMOS.NUMBERS.DATES.MONTH_DAY_STYLE" + ], + "date order": [ + "CMOS.NUMBERS.DATES.ALL_NUMERAL" + ], + "date range": [ + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES" + ], + "dateline": [ + "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT" + ], + "dates": [ + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS", + "CMOS.NUMBERS.DATES.ABBREVIATED_YEAR", + "CMOS.NUMBERS.DATES.YEAR_NUMERALS", + "CMOS.PUNCTUATION.COMMAS.DATES" + ], + "decade plural": [ + "CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE" + ], + "decades": [ + "CMOS.NUMBERS.DECADES.CONSISTENT_FORM" + ], + "decimal": [ + "CMOS.NUMBERS.DECIMALS.LEADING_ZERO", + "CMOS.NUMBERS.DECIMALS.NUMERALS", + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS" + ], + "decimal alignment": [ + "HOUSE.TABLES.ALIGNMENT.DECIMALS" + ], + "decimal marker": [ + "CMOS.NUMBERS.DECIMAL_MARKER.LOCALE" + ], + "decimals": [ + "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS" + ], + "dense data": [ + "CMOS.NUMBERS.DENSE_CONTEXT.USE_NUMERALS" + ], + "dependent clause": [ + "CMOS.PUNCTUATION.COMMAS.DEPENDENT_CLAUSE_AFTER" + ], + "descriptive phrases": [ + "CMOS.PUNCTUATION.COMMAS.DESCRIPTIVE_PHRASES" + ], + "designations": [ + "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS" + ], + "diaries": [ + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS", + "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT", + "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT" + ], + "dictionary": [ + "BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH" + ], + "dictionary usage": [ + "CMOS.PUNCTUATION.HYPHENATION.TREND_CLOSED" + ], + "digit grouping": [ + "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING" + ], + "digits": [ + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS" + ], + "direct questions": [ + "CMOS.PUNCTUATION.QUESTION_MARK.USE" + ], + "direct quote": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES" + ], + "disambiguation": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.EXTRA_INFO", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_SURNAME.DISAMBIGUATE" + ], + "discursive notes": [ + "CMOS.CITATIONS.NOTES.AVOID_OVERLONG" + ], + "distort": [ + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION" + ], + "divisions": [ + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR" + ], + "doi": [ + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL" + ], + "doi.org": [ + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL" + ], + "dollar": [ + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT" + ], + "dot leaders": [ + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE" + ], + "double hyphen": [ + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US" + ], + "double quotes": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US" + ], + "double space": [ + "BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE" + ], + "edited book": [ + "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK" + ], + "editorial": [ + "BRING.TABLES.EDIT_AS_TEXT.READABILITY" + ], + "editorial insertions": [ + "CMOS.PUNCTUATION.BRACKETS.TRANSLATED_TEXT" + ], + "element relationships": [ + "BRING.LAYOUT.ELEMENT_RELATIONSHIPS.VISIBLE" + ], + "ellipsis": [ + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT" + ], + "em dash": [ + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US", + "CMOS.PUNCTUATION.DASHES.EM_INSTEAD_OF_QUOTES", + "CMOS.PUNCTUATION.DASHES.EM_LINE_BREAKS" + ], + "embedded questions": [ + "CMOS.PUNCTUATION.COMMAS.QUESTIONS" + ], + "en dash": [ + "CMOS.NUMBERS.RANGES.EN_DASH.USE", + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES" + ], + "endnote placement": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.PLACEMENT" + ], + "endnotes": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID", + "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS", + "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE" + ], + "era designations": [ + "CMOS.NUMBERS.ERAS.BCE_CE" + ], + "essential clause": [ + "CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF" + ], + "eur": [ + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT" + ], + "exceptions": [ + "BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT" + ], + "exclamation points": [ + "CMOS.PUNCTUATION.EXCLAMATION.USE_SPARINGLY", + "CMOS.PUNCTUATION.EXCLAMATION.VS_QUESTION" + ], + "expand": [ + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION" + ], + "extra spaces": [ + "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT" + ], + "facing pages": [ + "BRING.LAYOUT.MARGINS.FACING_PAGES.INNER_OUTER", + "BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES" + ], + "figure numbers": [ + "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE" + ], + "figures": [ + "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE" + ], + "file uri": [ + "HOUSE.LINKS.DISALLOW.FILE_URIS" + ], + "first note": [ + "CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE" + ], + "floats": [ + "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE" + ], + "flush left": [ + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY", + "BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT" + ], + "font stretch": [ + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION" + ], + "footnote continuation": [ + "CMOS.CITATIONS.NOTES.FOOTNOTES.PAGE_BREAKS" + ], + "footnote position": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.PLACEMENT_AFTER_PUNCT" + ], + "footnotes": [ + "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT" + ], + "format choice": [ + "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE" + ], + "fragment": [ + "BRING.LAYOUT.HYPHENATION.STUB_END_AVOID" + ], + "from to": [ + "CMOS.NUMBERS.RANGES.EN_DASH.USE" + ], + "full citation": [ + "CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE" + ], + "general rule": [ + "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE" + ], + "glosses": [ + "CMOS.PUNCTUATION.PARENS.GLOSSES_TRANSLATIONS" + ], + "grid": [ + "BRING.LAYOUT.GRID.ALIGN_ELEMENTS" + ], + "gridlines": [ + "BRING.TABLES.FURNITURE.MINIMIZE" + ], + "grouping": [ + "BRING.TABLES.GROUPING.WHITESPACE", + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY", + "CMOS.NUMBERS.TELEPHONE.FORMAT" + ], + "gutters": [ + "BRING.LAYOUT.MARGINS.FACING_PAGES.INNER_OUTER" + ], + "hair space": [ + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE" + ], + "hard wrap": [ + "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR" + ], + "hard wraps": [ + "BRING.LAYOUT.DEGRADED.HARD_WRAP_REFLOW", + "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS" + ], + "header alignment": [ + "BRING.TABLES.HEADERS.ALIGN_WITH_COLUMNS" + ], + "headers": [ + "BRING.TABLES.UNITS.IN_HEADERS" + ], + "heading case": [ + "BRING.HEADINGS.CAPITALIZATION.CONSISTENT" + ], + "heading depth": [ + "BRING.HEADINGS.HIERARCHY.NO_SKIPPED_LEVELS" + ], + "heading hierarchy": [ + "BRING.HEADINGS.STRUCTURE.MATCH_TEXT_LOGIC", + "BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT", + "HOUSE.A11Y.HEADINGS.NO_SKIPS" + ], + "heading inference": [ + "BRING.HEADINGS.DEGRADED.INFER_STRUCTURE" + ], + "heading levels": [ + "BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED" + ], + "heading notes": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.HEADINGS_END" + ], + "heading styles": [ + "BRING.HEADINGS.STYLE.PALETTE_LIMIT" + ], + "heading visibility": [ + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY" + ], + "headings": [ + "BRING.HEADINGS.ALIGNMENT.CONSISTENT_LEVEL", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE", + "BRING.LAYOUT.BLOCK_QUOTES.AVOID_CROWDING", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES", + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS" + ], + "headline style": [ + "CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT" + ], + "hexadecimal": [ + "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING" + ], + "hierarchy": [ + "BRING.HEADINGS.CONTRAST.CLEAR_HIERARCHY", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE", + "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE" + ], + "hierarchy depth": [ + "BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED" + ], + "highways": [ + "CMOS.NUMBERS.PLACES.HIGHWAYS" + ], + "historical currency": [ + "CMOS.NUMBERS.CURRENCY.HISTORICAL.YEAR_CONTEXT" + ], + "https": [ + "HOUSE.LINKS.URLS.PREFER_HTTPS" + ], + "hundreds": [ + "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT" + ], + "hybrid system": [ + "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES" + ], + "hyphenation": [ + "BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION", + "BRING.LAYOUT.HYPHENATION.STUB_END_AVOID", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_PROPER_NAMES", + "BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH", + "BRING.TYPOGRAPHY.HYPHENATION.MAX_CONSECUTIVE_LINES", + "BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT", + "CMOS.NUMBERS.SI_PREFIXES.NO_HYPHEN", + "CMOS.PUNCTUATION.HYPHENATION.GENERAL_CHOICE", + "CMOS.PUNCTUATION.HYPHENATION.READABILITY", + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN", + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN", + "HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE" + ], + "ibid": [ + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID", + "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID" + ], + "images": [ + "HOUSE.A11Y.IMAGES.ALT_REQUIRED" + ], + "in-text citation": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL" + ], + "incidental material": [ + "CMOS.PUNCTUATION.PARENS.USE" + ], + "inclusive numbers": [ + "CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN" + ], + "inclusive range": [ + "CMOS.NUMBERS.INCLUSIVE.COMMAS" + ], + "inclusive years": [ + "CMOS.NUMBERS.INCLUSIVE.YEARS" + ], + "indent": [ + "BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT" + ], + "indent size": [ + "BRING.LAYOUT.PARAGRAPH.INDENT_SIZE.CONSISTENT" + ], + "indentation": [ + "BRING.HEADINGS.PARAGRAPH_INDENT.AFTER_HEAD_NONE", + "BRING.LAYOUT.BLOCK_QUOTES.INDENT_OR_NARROW" + ], + "indents": [ + "BRING.LAYOUT.PARAGRAPH.INDENT_OR_SPACE_NOT_BOTH" + ], + "indirect questions": [ + "CMOS.PUNCTUATION.QUESTION_MARK.DIRECT_VS_INDIRECT" + ], + "initials": [ + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE", + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.INITIALS_PREFERRED" + ], + "inline code": [ + "HOUSE.CODE.INLINE.MONO_BACKTICKS" + ], + "interruption": [ + "BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION" + ], + "introductory clause": [ + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY" + ], + "introductory comma": [ + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY" + ], + "introductory phrases": [ + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_PHRASES" + ], + "inverted name": [ + "CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO" + ], + "iso 8601": [ + "CMOS.NUMBERS.DATES.CONSISTENT_FORMAT", + "CMOS.NUMBERS.DATES.ISO_8601" + ], + "iso codes": [ + "CMOS.NUMBERS.CURRENCY.ISO_CODES" + ], + "iso time": [ + "CMOS.NUMBERS.TIME.ISO_STYLE" + ], + "issue": [ + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS", + "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE" + ], + "j.c.l.": [ + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE" + ], + "journal article": [ + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS" + ], + "jurisdiction": [ + "CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT" + ], + "justification": [ + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE" + ], + "justified text": [ + "BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED" + ], + "keep with next": [ + "HOUSE.HEADINGS.KEEPS.AVOID_STRANDED", + "HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS" + ], + "kerning": [ + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE" + ], + "kerning tables": [ + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE" + ], + "lang attribute": [ + "HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE" + ], + "language": [ + "BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH" + ], + "language tag": [ + "HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED" + ], + "large amounts": [ + "CMOS.NUMBERS.CURRENCY.LARGE_AMOUNTS" + ], + "large numbers": [ + "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS" + ], + "layout clarity": [ + "BRING.LAYOUT.ELEMENT_RELATIONSHIPS.VISIBLE" + ], + "leading": [ + "BRING.LAYOUT.LEADING.ADJUST_FOR_SIZE_CHANGES", + "BRING.LAYOUT.LEADING.CHOOSE_BASE", + "BRING.LAYOUT.LEADING.CONSISTENT_BODY", + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT", + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES" + ], + "leading zero": [ + "CMOS.NUMBERS.DECIMALS.LEADING_ZERO" + ], + "leading zeros": [ + "CMOS.NUMBERS.TIME.ISO_STYLE" + ], + "legal citation": [ + "CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT" + ], + "legibility": [ + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT", + "BRING.TABLES.TYPE_SIZE.READABLE", + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID" + ], + "length": [ + "CMOS.HEADINGS.RUNNING_HEADS.LENGTH_SHORT" + ], + "letters": [ + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS", + "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT", + "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT" + ], + "letterspacing": [ + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS", + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID" + ], + "line break": [ + "BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START", + "BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS" + ], + "line breaks": [ + "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE", + "CMOS.PUNCTUATION.DASHES.EM_LINE_BREAKS", + "HOUSE.LINKS.WRAP.SAFE_BREAKS" + ], + "line length": [ + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE", + "BRING.LAYOUT.MEASURE.TARGET_RANGE_CHARS" + ], + "line spacing": [ + "BRING.LAYOUT.LEADING.CHOOSE_BASE", + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT" + ], + "link rot": [ + "CMOS.CITATIONS.ONLINE.URLS.STABLE" + ], + "link text": [ + "HOUSE.A11Y.LINK_TEXT.DESCRIPTIVE", + "HOUSE.LINKS.TEXT.DESCRIPTIVE" + ], + "links": [ + "HOUSE.LINKS.DISALLOW.FILE_URIS", + "HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT" + ], + "list items": [ + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE" + ], + "list punctuation": [ + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT" + ], + "lists": [ + "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS", + "BRING.TABLES.GUIDES.READING_DIRECTION" + ], + "locale": [ + "CMOS.NUMBERS.DECIMAL_MARKER.LOCALE", + "CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR" + ], + "locator": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES", + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS", + "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR", + "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS", + "CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED" + ], + "long lines": [ + "BRING.LAYOUT.MEASURE.AVOID_TOO_LONG" + ], + "long notes": [ + "CMOS.CITATIONS.NOTES.LONG_NOTES.PARAGRAPHING" + ], + "loose leading": [ + "BRING.LAYOUT.LEADING.AVOID_TOO_LOOSE" + ], + "lowercase": [ + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID" + ], + "margin heads": [ + "BRING.HEADINGS.MARGIN_HEADS.CLEAR_GUTTER", + "BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS" + ], + "margins": [ + "BRING.LAYOUT.PAGE.FRAME.TEXTBLOCK_BALANCE" + ], + "math fractions": [ + "CMOS.NUMBERS.FRACTIONS.MATH.NUMERALS" + ], + "measure": [ + "BRING.LAYOUT.MEASURE.ADJUST_FOR_TYPE_SIZE", + "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS", + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE", + "BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS", + "BRING.LAYOUT.MEASURE.TARGET_RANGE_CHARS", + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE", + "BRING.LAYOUT.TEXTBLOCK.CONSISTENT_WIDTH" + ], + "measure consistency": [ + "BRING.LAYOUT.MEASURE.CONSISTENT_WITHIN_SECTION" + ], + "measurement": [ + "CMOS.NUMBERS.UNITS.REPEATED.OMIT_REPEAT" + ], + "measurements": [ + "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS" + ], + "metadata": [ + "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY" + ], + "midnight": [ + "CMOS.NUMBERS.TIME.NOON_MIDNIGHT" + ], + "million": [ + "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS" + ], + "minimum left": [ + "BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT" + ], + "minimum right": [ + "BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT" + ], + "mixed fractions": [ + "CMOS.NUMBERS.FRACTIONS.MIXED.WHOLE_PLUS_FRACTION" + ], + "monarchs": [ + "CMOS.NUMBERS.NAMES.MONARCHS_POPES" + ], + "money": [ + "CMOS.NUMBERS.CURRENCY.LARGE_AMOUNTS", + "CMOS.NUMBERS.CURRENCY.WORDS_VS_SYMBOLS" + ], + "monospace": [ + "HOUSE.CODE.INLINE.MONO_BACKTICKS" + ], + "month day year": [ + "CMOS.NUMBERS.DATES.CONSISTENT_FORMAT" + ], + "month-day": [ + "CMOS.NUMBERS.DATES.MONTH_DAY_STYLE" + ], + "multi-author": [ + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR", + "CMOS.HEADINGS.MULTIAUTHOR.CHAPTER_NUMBERING" + ], + "multi-line headers": [ + "BRING.TABLES.MULTI_LINE_HEADERS.AVOID" + ], + "multi-page tables": [ + "HOUSE.TABLES.HEADERS.REPEAT_ON_PAGEBREAK" + ], + "multiple authors": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.MULTI_AUTHORS.ORDER" + ], + "multiple citations": [ + "CMOS.CITATIONS.NOTES.MULTIPLE_CITATIONS.SINGLE_NOTE" + ], + "multiple punctuation": [ + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING" + ], + "multiple sources": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.MULTI_SOURCES" + ], + "name variants": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.ALT_NAMES.CROSSREF" + ], + "navigation": [ + "CMOS.HEADINGS.RUNNING_HEADS.DEFINITION", + "CMOS.HEADINGS.RUNNING_HEADS.DIVISION_MATCH", + "CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE" + ], + "negative leading": [ + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT" + ], + "nested parentheses": [ + "CMOS.PUNCTUATION.PARENS.NESTING" + ], + "nested punctuation": [ + "CMOS.PUNCTUATION.BRACKETS.NESTED_PARENS" + ], + "nested quotes": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US" + ], + "non-breaking space": [ + "BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS" + ], + "non-us dollar": [ + "CMOS.NUMBERS.CURRENCY.NON_US.DISAMBIGUATE" + ], + "nonbreaking space": [ + "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS" + ], + "nonrestrictive clause": [ + "CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF" + ], + "noon": [ + "CMOS.NUMBERS.TIME.NOON_MIDNIGHT" + ], + "note consolidation": [ + "CMOS.CITATIONS.NOTES.MULTIPLE_CITATIONS.SINGLE_NOTE" + ], + "note marker placement": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.PLACEMENT_AFTER_PUNCT" + ], + "note markers": [ + "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT" + ], + "note numbers": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS" + ], + "note processing": [ + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID" + ], + "notes": [ + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED" + ], + "notes and bibliography": [ + "CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE", + "CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE" + ], + "number grouping": [ + "CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR" + ], + "number spelling": [ + "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE" + ], + "number unit": [ + "BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS" + ], + "numbered lists": [ + "CMOS.NUMBERS.LISTS.OUTLINE.NUMERAL_STYLE" + ], + "numbers": [ + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY" + ], + "numeral": [ + "CMOS.NUMBERS.SENTENCE_START.AVOID_NUMERAL" + ], + "numeral normalization": [ + "CMOS.NUMBERS.DEGRADED.NUMERAL_NORMALIZATION" + ], + "numerals": [ + "CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID", + "CMOS.NUMBERS.DECIMALS.NUMERALS", + "CMOS.NUMBERS.DENSE_CONTEXT.USE_NUMERALS", + "CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT", + "CMOS.NUMBERS.TIME.GENERAL_NUMERALS" + ], + "numeric columns": [ + "BRING.TABLES.COLUMN_ALIGNMENT.CONSISTENT", + "BRING.TABLES.NUMERIC_PRECISION.CONSISTENT", + "HOUSE.TABLES.ALIGNMENT.DECIMALS" + ], + "numeric dates": [ + "CMOS.NUMBERS.DATES.ALL_NUMERAL" + ], + "numeric fractions": [ + "CMOS.NUMBERS.FRACTIONS.MATH.NUMERALS" + ], + "numeric range": [ + "CMOS.NUMBERS.RANGES.EN_DASH.USE" + ], + "ocr": [ + "BRING.HEADINGS.DEGRADED.INFER_STRUCTURE", + "BRING.TABLES.DEGRADED.REBUILD_COLUMNS", + "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR", + "CMOS.NUMBERS.DEGRADED.NUMERAL_NORMALIZATION", + "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT" + ], + "omission": [ + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT" + ], + "one through one hundred": [ + "CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT" + ], + "online source": [ + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED" + ], + "online sources": [ + "CMOS.CITATIONS.ONLINE.URLS.STABLE", + "CMOS.CITATIONS.ONLINE.VERSION.CITE_RIGHT_VERSION" + ], + "open compounds": [ + "CMOS.PUNCTUATION.HYPHENATION.COMPOUND_DEFINITION" + ], + "opening paragraph": [ + "BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT" + ], + "opsec": [ + "HOUSE.LINKS.DISALLOW.FILE_URIS" + ], + "ordering": [ + "BRING.TABLES.ORDER.LOGICAL", + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER" + ], + "ordinal": [ + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT" + ], + "ordinal titles": [ + "CMOS.NUMBERS.NAMES.MONARCHS_POPES" + ], + "ordinals": [ + "CMOS.NUMBERS.CENTURIES.SPELLED_OUT" + ], + "original publication": [ + "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS" + ], + "orphan": [ + "BRING.LAYOUT.PAGINATION.ORPHANS_AVOID" + ], + "outline": [ + "CMOS.NUMBERS.LISTS.OUTLINE.NUMERAL_STYLE" + ], + "overflow": [ + "BRING.LAYOUT.MEASURE.CODE_BLOCKS.WRAP_POLICY", + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT" + ], + "overflow policy": [ + "HOUSE.CODE.BLOCKS.WRAP_POLICY" + ], + "overfull": [ + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT" + ], + "overlong notes": [ + "CMOS.CITATIONS.NOTES.AVOID_OVERLONG" + ], + "oxford comma": [ + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT" + ], + "page break": [ + "BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION", + "BRING.LAYOUT.PAGINATION.ORPHANS_AVOID", + "BRING.LAYOUT.PAGINATION.WIDOWS_AVOID", + "CMOS.CITATIONS.NOTES.FOOTNOTES.PAGE_BREAKS" + ], + "page density": [ + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE" + ], + "page headers": [ + "CMOS.HEADINGS.RUNNING_HEADS.DEFINITION" + ], + "page number": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL", + "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR", + "CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED" + ], + "page numbers": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS", + "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE", + "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE" + ], + "page range": [ + "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK", + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS", + "CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN", + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES" + ], + "pagination": [ + "HOUSE.HEADINGS.KEEPS.AVOID_STRANDED", + "HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS" + ], + "pairs": [ + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE" + ], + "paragraph": [ + "BRING.LAYOUT.PAGINATION.WIDOWS_AVOID" + ], + "paragraph breaks": [ + "BRING.LAYOUT.PARAGRAPH.BLANK_LINES.SPARING" + ], + "paragraph continuity": [ + "BRING.LAYOUT.PAGINATION.ORPHANS_AVOID" + ], + "paragraph indent": [ + "BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST" + ], + "paragraph spacing": [ + "BRING.LAYOUT.PARAGRAPH.INDENT_OR_SPACE_NOT_BOTH" + ], + "paragraphing": [ + "CMOS.CITATIONS.NOTES.LONG_NOTES.PARAGRAPHING" + ], + "parallel structure": [ + "CMOS.PUNCTUATION.HYPHENATION.SUSPENDED" + ], + "paratext": [ + "BRING.HEADINGS.RELATED_ELEMENTS.COHERENT" + ], + "parentheses": [ + "CMOS.PUNCTUATION.PARENS.USE", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE", + "CMOS.PUNCTUATION.PERIODS.WITH_PARENS" + ], + "parenthetical": [ + "CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF" + ], + "participial phrases": [ + "CMOS.PUNCTUATION.COMMAS.PARTICIPIAL_PHRASES" + ], + "percent": [ + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS", + "CMOS.NUMBERS.PERCENTAGES.NUMERALS" + ], + "percentage": [ + "CMOS.NUMBERS.PERCENTAGES.NUMERALS" + ], + "percentages": [ + "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS" + ], + "period placement": [ + "CMOS.PUNCTUATION.PERIODS.WITH_PARENS" + ], + "periods": [ + "CMOS.PUNCTUATION.PERIODS.USE" + ], + "periods in quotes": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US" + ], + "permalink": [ + "CMOS.CITATIONS.ONLINE.PERMALINKS.PREFERRED" + ], + "persistent identifier": [ + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL" + ], + "phone number": [ + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY" + ], + "phone numbers": [ + "CMOS.NUMBERS.TELEPHONE.FORMAT" + ], + "placement": [ + "BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT", + "BRING.TABLES.TITLES.CONSISTENT_PLACEMENT" + ], + "plural numbers": [ + "CMOS.NUMBERS.PLURALS.SPELLED_OUT" + ], + "popes": [ + "CMOS.NUMBERS.NAMES.MONARCHS_POPES" + ], + "powers of ten": [ + "CMOS.NUMBERS.SCIENTIFIC.POWERS_OF_TEN" + ], + "precision": [ + "BRING.TABLES.NUMERIC_PRECISION.CONSISTENT" + ], + "profiles": [ + "BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT" + ], + "proper names": [ + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_PROPER_NAMES" + ], + "pseudonyms": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.PSEUDONYMS.CONSISTENT" + ], + "public documents": [ + "CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT" + ], + "published form": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.PUBLISHED_FORM" + ], + "punctuation": [ + "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT", + "HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT" + ], + "punctuation with quotes": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US" + ], + "question marks": [ + "CMOS.PUNCTUATION.QUESTION_MARK.DIRECT_VS_INDIRECT", + "CMOS.PUNCTUATION.QUESTION_MARK.USE", + "CMOS.PUNCTUATION.QUESTION_MARK.WITH_PUNCT" + ], + "questions": [ + "CMOS.PUNCTUATION.EXCLAMATION.VS_QUESTION" + ], + "quotation": [ + "CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED" + ], + "quotation in note": [ + "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR" + ], + "quotation introduction": [ + "CMOS.PUNCTUATION.COLONS.INTRO_QUOTE_QUESTION" + ], + "quotation marks": [ + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS", + "CMOS.PUNCTUATION.DASHES.EM_INSTEAD_OF_QUOTES", + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US", + "CMOS.PUNCTUATION.QUOTES.SMART_QUOTES" + ], + "quoted titles": [ + "CMOS.PUNCTUATION.COMMAS.QUOTED_TITLES" + ], + "ragged right": [ + "BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED" + ], + "range": [ + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES" + ], + "ratios": [ + "CMOS.NUMBERS.RATIOS.FORMAT" + ], + "readability": [ + "BRING.LAYOUT.LEADING.AVOID_TOO_TIGHT", + "BRING.LAYOUT.MEASURE.AVOID_TOO_LONG", + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE", + "BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT", + "BRING.TABLES.EDIT_AS_TEXT.READABILITY", + "BRING.TABLES.ROW_SPACING.READABLE" + ], + "reading direction": [ + "BRING.TABLES.GUIDES.READING_DIRECTION" + ], + "reference list": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ALPHABETICAL", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED", + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED" + ], + "reference list entries": [ + "CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED" + ], + "reference list format": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR" + ], + "reflow": [ + "BRING.LAYOUT.DEGRADED.HARD_WRAP_REFLOW", + "HOUSE.TABLES.OVERFLOW.NO_CLIPPING" + ], + "repeat headers": [ + "HOUSE.TABLES.HEADERS.REPEAT_ON_PAGEBREAK" + ], + "repeated names": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REPEATED_NAMES", + "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH" + ], + "repeated units": [ + "CMOS.NUMBERS.UNITS.REPEATED.OMIT_REPEAT" + ], + "repeated word": [ + "BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START" + ], + "reprints": [ + "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS" + ], + "research": [ + "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY" + ], + "resolve citations": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED", + "CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED" + ], + "responsive": [ + "BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS" + ], + "restrictive": [ + "CMOS.PUNCTUATION.COMMAS.APPOSITIVES" + ], + "restrictive clause": [ + "CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF" + ], + "revision date": [ + "CMOS.CITATIONS.ONLINE.REVISION_DATE.DISTINCT" + ], + "rhythm": [ + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE" + ], + "right aligned": [ + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY" + ], + "rivers": [ + "BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED", + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE" + ], + "roman numerals": [ + "CMOS.NUMBERS.ROMAN_NUMERALS.USE" + ], + "rotation": [ + "BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL" + ], + "round numbers": [ + "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT" + ], + "route numbers": [ + "CMOS.NUMBERS.PLACES.HIGHWAYS" + ], + "row labels": [ + "BRING.TABLES.STUB_COLUMN.USE" + ], + "row order": [ + "BRING.TABLES.ORDER.LOGICAL" + ], + "row spacing": [ + "BRING.TABLES.ROW_SPACING.READABLE" + ], + "rules": [ + "BRING.TABLES.GUIDES.READING_DIRECTION" + ], + "run-in heads": [ + "BRING.HEADINGS.RUN_IN.STANDALONE.CONSISTENT" + ], + "running heads": [ + "BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS", + "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS", + "CMOS.HEADINGS.RUNNING_HEADS.DEFINITION", + "CMOS.HEADINGS.RUNNING_HEADS.DIVISION_MATCH", + "CMOS.HEADINGS.RUNNING_HEADS.LENGTH_SHORT", + "CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE" + ], + "same author": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER" + ], + "same surname": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_SURNAME.DISAMBIGUATE" + ], + "scalex": [ + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION" + ], + "scanability": [ + "HOUSE.TABLES.ALIGNMENT.DECIMALS" + ], + "scientific notation": [ + "CMOS.NUMBERS.SCIENTIFIC.POWERS_OF_TEN" + ], + "screen pdf": [ + "HOUSE.CODE.BLOCKS.WRAP_POLICY" + ], + "screen readers": [ + "HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE", + "HOUSE.A11Y.IMAGES.ALT_REQUIRED" + ], + "section context": [ + "CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE" + ], + "section headers": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS" + ], + "section layout": [ + "BRING.LAYOUT.MEASURE.CONSISTENT_WITHIN_SECTION" + ], + "security": [ + "HOUSE.LINKS.URLS.PREFER_HTTPS" + ], + "see note": [ + "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE" + ], + "semicolon": [ + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE" + ], + "semicolons": [ + "CMOS.PUNCTUATION.SEMICOLONS.BEFORE_CONJUNCTION", + "CMOS.PUNCTUATION.SEMICOLONS.CONJUNCTIVE_PHRASES" + ], + "sentence after colon": [ + "CMOS.PUNCTUATION.COLONS.CAPITALIZATION" + ], + "sentence spacing": [ + "BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE" + ], + "sentence start": [ + "CMOS.NUMBERS.SENTENCE_START.AVOID_NUMERAL" + ], + "sentence style": [ + "CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT" + ], + "sequence": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS" + ], + "serial comma": [ + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT" + ], + "serial number": [ + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY" + ], + "short form": [ + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID", + "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID", + "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS", + "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE", + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM" + ], + "short line": [ + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE" + ], + "short lines": [ + "BRING.LAYOUT.MEASURE.AVOID_TOO_SHORT" + ], + "short title": [ + "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS", + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM" + ], + "shortened range": [ + "CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN" + ], + "shoulderheads": [ + "BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS" + ], + "shrink limit": [ + "BRING.TABLES.TYPE_SIZE.READABLE" + ], + "si grouping": [ + "CMOS.NUMBERS.DIGIT_GROUPING.SI_SPACE" + ], + "si prefixes": [ + "CMOS.NUMBERS.SI_PREFIXES.NO_HYPHEN" + ], + "sidebars": [ + "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS" + ], + "sideheads": [ + "BRING.HEADINGS.MARGIN_HEADS.CLEAR_GUTTER", + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY" + ], + "signatures": [ + "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT" + ], + "simple fractions": [ + "CMOS.NUMBERS.FRACTIONS.SIMPLE.SPELL_OUT" + ], + "single quotes": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US" + ], + "single space": [ + "BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE", + "CMOS.PUNCTUATION.COLONS.SPACE_AFTER" + ], + "size": [ + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY", + "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE" + ], + "skipped levels": [ + "BRING.HEADINGS.HIERARCHY.NO_SKIPPED_LEVELS" + ], + "slashes": [ + "CMOS.PUNCTUATION.SLASHES.ALTERNATIVES", + "CMOS.PUNCTUATION.SLASHES.TWO_YEAR_SPANS" + ], + "small caps": [ + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS" + ], + "smart quotes": [ + "CMOS.PUNCTUATION.QUOTES.SMART_QUOTES" + ], + "soft hyphen": [ + "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE" + ], + "source citation": [ + "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE" + ], + "source notes": [ + "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS" + ], + "sources": [ + "BRING.TABLES.SOURCES.NOTES.DISTINCT" + ], + "spacing": [ + "BRING.HEADINGS.BLOCK_QUOTE.SPACING_AROUND", + "BRING.HEADINGS.SPACING.VERTICAL_RHYTHM", + "BRING.LAYOUT.BLOCK_QUOTES.BEFORE_AFTER_SPACING", + "BRING.LAYOUT.BLOCK_QUOTES.EXTRA_LEAD", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES", + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE" + ], + "spell out numbers": [ + "CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT" + ], + "spelled out": [ + "CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID", + "CMOS.NUMBERS.FRACTIONS.SIMPLE.SPELL_OUT" + ], + "spread": [ + "BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES" + ], + "stable identifier": [ + "CMOS.CITATIONS.ONLINE.PERMALINKS.PREFERRED" + ], + "stacked punctuation": [ + "CMOS.PUNCTUATION.QUESTION_MARK.WITH_PUNCT" + ], + "stand-alone heads": [ + "BRING.HEADINGS.RUN_IN.STANDALONE.CONSISTENT" + ], + "stranded heading": [ + "HOUSE.HEADINGS.KEEPS.AVOID_STRANDED", + "HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS" + ], + "streets": [ + "CMOS.NUMBERS.PLACES.STREETS" + ], + "structure": [ + "BRING.HEADINGS.STRUCTURE.MATCH_TEXT_LOGIC", + "BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED", + "HOUSE.A11Y.HEADINGS.NO_SKIPS" + ], + "stub": [ + "BRING.LAYOUT.HYPHENATION.STUB_END_AVOID" + ], + "stub column": [ + "BRING.TABLES.STUB_COLUMN.USE" + ], + "style": [ + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE" + ], + "style palette": [ + "BRING.HEADINGS.STYLE.PALETTE_LIMIT" + ], + "subheads": [ + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE" + ], + "subsequent notes": [ + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM" + ], + "substantive notes": [ + "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE" + ], + "suffix": [ + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT" + ], + "superscript": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT" + ], + "suspended hyphens": [ + "CMOS.PUNCTUATION.HYPHENATION.SUSPENDED" + ], + "symmetry": [ + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD" + ], + "syntax highlighting": [ + "HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED" + ], + "tab leaders": [ + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE" + ], + "table captions": [ + "BRING.TABLES.CAPTIONS.CLEAR" + ], + "table guides": [ + "BRING.TABLES.GUIDES.READING_DIRECTION" + ], + "table notes": [ + "BRING.TABLES.SOURCES.NOTES.DISTINCT" + ], + "table overflow": [ + "BRING.TABLES.TYPE_SIZE.READABLE", + "HOUSE.TABLES.OVERFLOW.NO_CLIPPING" + ], + "table reconstruction": [ + "BRING.TABLES.DEGRADED.REBUILD_COLUMNS" + ], + "table redesign": [ + "BRING.TABLES.MULTI_LINE_HEADERS.AVOID" + ], + "table rules": [ + "BRING.TABLES.FURNITURE.MINIMIZE" + ], + "table titles": [ + "BRING.TABLES.TITLES.CONSISTENT_PLACEMENT" + ], + "tables": [ + "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE", + "BRING.TABLES.EDIT_AS_TEXT.READABILITY", + "BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL", + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE" + ], + "terminal punctuation": [ + "CMOS.PUNCTUATION.PERIODS.USE" + ], + "text block": [ + "BRING.LAYOUT.PAGE.FRAME.TEXTBLOCK_BALANCE", + "BRING.LAYOUT.TEXTBLOCK.CONSISTENT_WIDTH" + ], + "text cohesion": [ + "BRING.LAYOUT.LEADING.AVOID_TOO_LOOSE" + ], + "text color": [ + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE" + ], + "thin space": [ + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE", + "CMOS.NUMBERS.DIGIT_GROUPING.SI_SPACE" + ], + "thousands": [ + "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT" + ], + "thousands separator": [ + "CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR" + ], + "three dots": [ + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT" + ], + "tight leading": [ + "BRING.LAYOUT.LEADING.AVOID_TOO_TIGHT" + ], + "time format": [ + "CMOS.NUMBERS.TIME.TWENTY_FOUR_HOUR" + ], + "time of day": [ + "CMOS.NUMBERS.TIME.GENERAL_NUMERALS" + ], + "title lead": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.NO_AUTHOR.TITLE_LEAD" + ], + "title notes": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.HEADINGS_END" + ], + "tone": [ + "CMOS.PUNCTUATION.EXCLAMATION.USE_SPARINGLY" + ], + "tracking": [ + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS", + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID" + ], + "trailing period": [ + "HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT" + ], + "translated text": [ + "CMOS.PUNCTUATION.BRACKETS.TRANSLATED_TEXT" + ], + "translations": [ + "CMOS.PUNCTUATION.PARENS.GLOSSES_TRANSLATIONS" + ], + "two-year spans": [ + "CMOS.PUNCTUATION.SLASHES.TWO_YEAR_SPANS" + ], + "type size": [ + "BRING.LAYOUT.LEADING.ADJUST_FOR_SIZE_CHANGES", + "BRING.LAYOUT.MEASURE.ADJUST_FOR_TYPE_SIZE" + ], + "units": [ + "BRING.TABLES.UNITS.IN_HEADERS", + "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS" + ], + "units of measure": [ + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS" + ], + "unnumbered notes": [ + "CMOS.CITATIONS.NOTES.UNNUMBERED.NOT_FOR_SOURCES" + ], + "url wrapping": [ + "HOUSE.LINKS.WRAP.SAFE_BREAKS" + ], + "urls": [ + "CMOS.CITATIONS.ONLINE.URLS.STABLE", + "HOUSE.LINKS.URLS.PREFER_HTTPS" + ], + "usd": [ + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT" + ], + "vehicles": [ + "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS" + ], + "version": [ + "CMOS.CITATIONS.ONLINE.VERSION.CITE_RIGHT_VERSION" + ], + "versioned web": [ + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED" + ], + "vertical rhythm": [ + "BRING.HEADINGS.SPACING.VERTICAL_RHYTHM", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES" + ], + "vessels": [ + "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS" + ], + "volume": [ + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS", + "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE" + ], + "w.b.": [ + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE" + ], + "weight": [ + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY", + "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE" + ], + "whitespace": [ + "BRING.TABLES.FURNITURE.MINIMIZE", + "BRING.TABLES.GROUPING.WHITESPACE" + ], + "whole numbers": [ + "CMOS.NUMBERS.FRACTIONS.MIXED.WHOLE_PLUS_FRACTION" + ], + "widow": [ + "BRING.LAYOUT.PAGINATION.WIDOWS_AVOID" + ], + "word space": [ + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE" + ], + "word spacing": [ + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE" + ], + "wrap": [ + "HOUSE.CODE.BLOCKS.NO_CLIPPING" + ], + "wrapping": [ + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT" + ], + "year": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL", + "CMOS.NUMBERS.CURRENCY.HISTORICAL.YEAR_CONTEXT", + "CMOS.NUMBERS.DATES.YEAR_NUMERALS" + ], + "year placement": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR" + ], + "year range": [ + "CMOS.NUMBERS.INCLUSIVE.YEARS" + ], + "zero through nine": [ + "CMOS.NUMBERS.RULE_SELECTION.ALTERNATIVE_ZERO_TO_NINE" + ] +} diff --git a/spec/indexes/source_refs_all.json b/spec/indexes/source_refs_all.json new file mode 100644 index 0000000..8d4aafe --- /dev/null +++ b/spec/indexes/source_refs_all.json @@ -0,0 +1,732 @@ +{ + "BRING \u00a71.2.2 p20 (scan p19)": [ + "BRING.HEADINGS.DEGRADED.INFER_STRUCTURE", + "BRING.HEADINGS.STRUCTURE.MATCH_TEXT_LOGIC" + ], + "BRING \u00a71.2.3 p21 (scan p20)": [ + "BRING.HEADINGS.RELATED_ELEMENTS.COHERENT" + ], + "BRING \u00a71.2.5 p23 (scan p22)": [ + "BRING.LAYOUT.ELEMENT_RELATIONSHIPS.VISIBLE", + "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE", + "BRING.LAYOUT.GRID.ALIGN_ELEMENTS", + "BRING.LAYOUT.MARGINS.FACING_PAGES.INNER_OUTER", + "BRING.LAYOUT.PAGE.FRAME.TEXTBLOCK_BALANCE", + "BRING.LAYOUT.TEXTBLOCK.CONSISTENT_WIDTH" + ], + "BRING \u00a72-4.1 p42": [ + "BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT" + ], + "BRING \u00a72-4.10 p44": [ + "BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES" + ], + "BRING \u00a72-4.11 p44": [ + "BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION" + ], + "BRING \u00a72-4.2 p42": [ + "BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT" + ], + "BRING \u00a72-4.3 p42": [ + "BRING.LAYOUT.HYPHENATION.STUB_END_AVOID" + ], + "BRING \u00a72-4.4 p43": [ + "BRING.TYPOGRAPHY.HYPHENATION.MAX_CONSECUTIVE_LINES" + ], + "BRING \u00a72-4.5 p43": [ + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_PROPER_NAMES" + ], + "BRING \u00a72-4.6 p43": [ + "BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH" + ], + "BRING \u00a72-4.7 p43": [ + "BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS" + ], + "BRING \u00a72-4.8 p43": [ + "BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START", + "BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE" + ], + "BRING \u00a72-4.9 p44": [ + "BRING.LAYOUT.PAGINATION.ORPHANS_AVOID", + "BRING.LAYOUT.PAGINATION.WIDOWS_AVOID" + ], + "BRING \u00a72.1.1 p25": [ + "BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE" + ], + "BRING \u00a72.1.10 p35 (scan p34)": [ + "BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE" + ], + "BRING \u00a72.1.2 p26": [ + "BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE", + "BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS" + ], + "BRING \u00a72.1.2 p26 (scan p25)": [ + "BRING.LAYOUT.COLUMNS.BALANCE_LENGTHS", + "BRING.LAYOUT.MEASURE.ADJUST_FOR_TYPE_SIZE", + "BRING.LAYOUT.MEASURE.AVOID_TOO_LONG", + "BRING.LAYOUT.MEASURE.AVOID_TOO_SHORT", + "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS", + "BRING.LAYOUT.MEASURE.CODE_BLOCKS.WRAP_POLICY", + "BRING.LAYOUT.MEASURE.CONSISTENT_WITHIN_SECTION", + "BRING.LAYOUT.MEASURE.TARGET_RANGE_CHARS" + ], + "BRING \u00a72.1.3 p27": [ + "BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED" + ], + "BRING \u00a72.1.4 p27": [ + "BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE" + ], + "BRING \u00a72.1.5 p30 (scan p29)": [ + "BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE" + ], + "BRING \u00a72.1.6 p30 (scan p29)": [ + "BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY", + "BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS" + ], + "BRING \u00a72.1.7 p31 (scan p30)": [ + "BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID" + ], + "BRING \u00a72.1.8 p32-33": [ + "BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE" + ], + "BRING \u00a72.1.9 p35 (scan p34)": [ + "BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION" + ], + "BRING \u00a72.2.1 p36 (scan p35)": [ + "BRING.LAYOUT.LEADING.ADJUST_FOR_SIZE_CHANGES", + "BRING.LAYOUT.LEADING.ALIGN_BASELINE_GRID", + "BRING.LAYOUT.LEADING.AVOID_TOO_LOOSE", + "BRING.LAYOUT.LEADING.AVOID_TOO_TIGHT", + "BRING.LAYOUT.LEADING.CHOOSE_BASE", + "BRING.LAYOUT.LEADING.CONSISTENT_BODY" + ], + "BRING \u00a72.2.2 p37 (scan p36)": [ + "BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT", + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES" + ], + "BRING \u00a72.2.2 p38 (scan p37)": [ + "BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES" + ], + "BRING \u00a72.2.3 p39 (scan p38)": [ + "BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE" + ], + "BRING \u00a72.3.1 p39 (scan p38)": [ + "BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT" + ], + "BRING \u00a72.3.2 p39 (scan p38)": [ + "BRING.HEADINGS.PARAGRAPH_INDENT.AFTER_HEAD_NONE", + "BRING.LAYOUT.DEGRADED.HARD_WRAP_REFLOW", + "BRING.LAYOUT.PARAGRAPH.BLANK_LINES.SPARING", + "BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST", + "BRING.LAYOUT.PARAGRAPH.INDENT_OR_SPACE_NOT_BOTH", + "BRING.LAYOUT.PARAGRAPH.INDENT_SIZE.CONSISTENT", + "BRING.LAYOUT.PARAGRAPH.NO_INDENT_AFTER_BLOCKS" + ], + "BRING \u00a72.3.3 p40 (scan p39)": [ + "BRING.HEADINGS.BLOCK_QUOTE.SPACING_AROUND", + "BRING.LAYOUT.BLOCK_QUOTES.AVOID_CROWDING", + "BRING.LAYOUT.BLOCK_QUOTES.BEFORE_AFTER_SPACING", + "BRING.LAYOUT.BLOCK_QUOTES.EXTRA_LEAD", + "BRING.LAYOUT.BLOCK_QUOTES.INDENT_OR_NARROW" + ], + "BRING \u00a74.2 p65 (scan p64)": [ + "BRING.HEADINGS.CAPITALIZATION.CONSISTENT", + "BRING.HEADINGS.SPACING.VERTICAL_RHYTHM", + "BRING.HEADINGS.STYLE.PALETTE_LIMIT", + "BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE", + "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE" + ], + "BRING \u00a74.2.1 p65 (scan p64)": [ + "BRING.HEADINGS.ALIGNMENT.CONSISTENT_LEVEL", + "BRING.HEADINGS.HIERARCHY.NO_SKIPPED_LEVELS", + "BRING.HEADINGS.MARGIN_HEADS.CLEAR_GUTTER", + "BRING.HEADINGS.RUN_IN.STANDALONE.CONSISTENT", + "BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY", + "BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED", + "BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS", + "BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY" + ], + "BRING \u00a74.2.2 p65 (scan p64)": [ + "BRING.HEADINGS.CONTRAST.CLEAR_HIERARCHY", + "BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT", + "BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD" + ], + "BRING \u00a74.4 p70 (scan p69)": [ + "BRING.TABLES.CAPTIONS.CLEAR", + "BRING.TABLES.TITLES.CONSISTENT_PLACEMENT" + ], + "BRING \u00a74.4.1 p70 (scan p69)": [ + "BRING.TABLES.COLUMN_ALIGNMENT.CONSISTENT", + "BRING.TABLES.DATA_TYPES.NOT_MIXED", + "BRING.TABLES.DEGRADED.REBUILD_COLUMNS", + "BRING.TABLES.EDIT_AS_TEXT.READABILITY", + "BRING.TABLES.FURNITURE.MINIMIZE", + "BRING.TABLES.GROUPING.WHITESPACE", + "BRING.TABLES.GUIDES.READING_DIRECTION", + "BRING.TABLES.HEADERS.ALIGN_WITH_COLUMNS", + "BRING.TABLES.HEADERS.CONCISE", + "BRING.TABLES.MULTI_LINE_HEADERS.AVOID", + "BRING.TABLES.NUMERIC_PRECISION.CONSISTENT", + "BRING.TABLES.ORDER.LOGICAL", + "BRING.TABLES.ROW_SPACING.READABLE", + "BRING.TABLES.SOURCES.NOTES.DISTINCT", + "BRING.TABLES.STUB_COLUMN.USE", + "BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL", + "BRING.TABLES.TYPE_SIZE.READABLE", + "BRING.TABLES.UNITS.IN_HEADERS" + ], + "CMOS18 \u00a71.10 p8 (scan p30)": [ + "CMOS.HEADINGS.RUNNING_HEADS.DEFINITION", + "CMOS.HEADINGS.RUNNING_HEADS.DIVISION_MATCH", + "CMOS.HEADINGS.RUNNING_HEADS.LENGTH_SHORT", + "CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE" + ], + "CMOS18 \u00a71.56 p33 (scan p55)": [ + "CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR", + "CMOS.HEADINGS.MULTIAUTHOR.AUTHOR_ATTRIBUTION_PLACEMENT", + "CMOS.HEADINGS.MULTIAUTHOR.CHAPTER_NUMBERING" + ], + "CMOS18 \u00a71.58 p33 (scan p55)": [ + "CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS", + "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT", + "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT" + ], + "CMOS18 \u00a712.31 p746 (scan p768)": [ + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS" + ], + "CMOS18 \u00a712.59 p760 (scan p782)": [ + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT" + ], + "CMOS18 \u00a713 p775": [ + "CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED", + "CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED", + "CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE", + "CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT" + ], + "CMOS18 \u00a713 p783": [ + "CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE" + ], + "CMOS18 \u00a713 p819": [ + "CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO" + ], + "CMOS18 \u00a713 p833": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL" + ], + "CMOS18 \u00a713 p837": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR", + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED" + ], + "CMOS18 \u00a713.112 p838 (scan p860)": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ALPHABETICAL" + ], + "CMOS18 \u00a713.113 p838 (scan p860)": [ + "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REPEATED_NAMES" + ], + "CMOS18 \u00a713.117 p840 (scan p862)": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS" + ], + "CMOS18 \u00a713.118 p841 (scan p863)": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.EXTRA_INFO" + ], + "CMOS18 \u00a713.119 p841 (scan p863)": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PLACEMENT" + ], + "CMOS18 \u00a713.120 p842 (scan p864)": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES" + ], + "CMOS18 \u00a713.124 p844 (scan p866)": [ + "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.MULTI_SOURCES" + ], + "CMOS18 \u00a713.13 p781 (scan p803)": [ + "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY" + ], + "CMOS18 \u00a713.14 p782 (scan p804)": [ + "CMOS.CITATIONS.ONLINE.VERSION.CITE_RIGHT_VERSION" + ], + "CMOS18 \u00a713.15 p782 (scan p804)": [ + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED" + ], + "CMOS18 \u00a713.16 p782 (scan p804)": [ + "CMOS.CITATIONS.ONLINE.REVISION_DATE.DISTINCT" + ], + "CMOS18 \u00a713.25 p787 (scan p809)": [ + "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK" + ], + "CMOS18 \u00a713.26 p787 (scan p809)": [ + "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS" + ], + "CMOS18 \u00a713.27 p787 (scan p809)": [ + "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR", + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT" + ], + "CMOS18 \u00a713.28 p788 (scan p810)": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS" + ], + "CMOS18 \u00a713.29 p788 (scan p810)": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.PLACEMENT_AFTER_PUNCT" + ], + "CMOS18 \u00a713.30 p789 (scan p811)": [ + "CMOS.CITATIONS.NOTES.NOTE_MARKERS.HEADINGS_END" + ], + "CMOS18 \u00a713.31 p789 (scan p811)": [ + "CMOS.CITATIONS.NOTES.MULTIPLE_CITATIONS.SINGLE_NOTE" + ], + "CMOS18 \u00a713.32 p790 (scan p812)": [ + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM" + ], + "CMOS18 \u00a713.33 p790 (scan p812)": [ + "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS" + ], + "CMOS18 \u00a713.34 p790 (scan p812)": [ + "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE" + ], + "CMOS18 \u00a713.37 p791 (scan p813)": [ + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID" + ], + "CMOS18 \u00a713.41 p794 (scan p816)": [ + "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR" + ], + "CMOS18 \u00a713.42 p794 (scan p816)": [ + "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE" + ], + "CMOS18 \u00a713.43 p794 (scan p816)": [ + "CMOS.CITATIONS.NOTES.LONG_NOTES.PARAGRAPHING" + ], + "CMOS18 \u00a713.44 p794 (scan p816)": [ + "CMOS.CITATIONS.NOTES.FOOTNOTES.PAGE_BREAKS" + ], + "CMOS18 \u00a713.46 p796 (scan p818)": [ + "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE" + ], + "CMOS18 \u00a713.49 p797 (scan p819)": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.PLACEMENT" + ], + "CMOS18 \u00a713.50 p797 (scan p819)": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS" + ], + "CMOS18 \u00a713.51 p800 (scan p822)": [ + "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID" + ], + "CMOS18 \u00a713.53 p801 (scan p823)": [ + "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES" + ], + "CMOS18 \u00a713.55 p801 (scan p823)": [ + "CMOS.CITATIONS.NOTES.UNNUMBERED.NOT_FOR_SOURCES" + ], + "CMOS18 \u00a713.58 p805 (scan p827)": [ + "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS" + ], + "CMOS18 \u00a713.6 p778 (scan p800)": [ + "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE", + "CMOS.CITATIONS.ONLINE.URLS.STABLE" + ], + "CMOS18 \u00a713.60 p807 (scan p829)": [ + "CMOS.CITATIONS.NOTES.AVOID_OVERLONG" + ], + "CMOS18 \u00a713.65 p809 (scan p831)": [ + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED" + ], + "CMOS18 \u00a713.69 p816 (scan p838)": [ + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR" + ], + "CMOS18 \u00a713.7 p778 (scan p800)": [ + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL" + ], + "CMOS18 \u00a713.70 p816 (scan p838)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER" + ], + "CMOS18 \u00a713.73 p818 (scan p840)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH" + ], + "CMOS18 \u00a713.74 p819 (scan p841)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.CONSISTENT_FORM" + ], + "CMOS18 \u00a713.75 p819 (scan p841)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.PUBLISHED_FORM" + ], + "CMOS18 \u00a713.76 p820 (scan p842)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.INITIALS_PREFERRED" + ], + "CMOS18 \u00a713.78 p821 (scan p843)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.MULTI_AUTHORS.ORDER" + ], + "CMOS18 \u00a713.79 p821 (scan p843)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_SURNAME.DISAMBIGUATE" + ], + "CMOS18 \u00a713.8 p779 (scan p801)": [ + "CMOS.CITATIONS.ONLINE.PERMALINKS.PREFERRED" + ], + "CMOS18 \u00a713.81 p822 (scan p844)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.NO_AUTHOR.TITLE_LEAD" + ], + "CMOS18 \u00a713.82 p823 (scan p845)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.PSEUDONYMS.CONSISTENT" + ], + "CMOS18 \u00a713.84 p824 (scan p846)": [ + "CMOS.CITATIONS.BIBLIOGRAPHY.ALT_NAMES.CROSSREF" + ], + "CMOS18 \u00a714 p935": [ + "CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT" + ], + "CMOS18 \u00a76.101 p422 (scan p444)": [ + "CMOS.PUNCTUATION.PARENS.USE", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE" + ], + "CMOS18 \u00a76.102 p422 (scan p444)": [ + "CMOS.PUNCTUATION.PARENS.GLOSSES_TRANSLATIONS" + ], + "CMOS18 \u00a76.103 p422 (scan p444)": [ + "CMOS.PUNCTUATION.PARENS.NESTING", + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE" + ], + "CMOS18 \u00a76.106 p424 (scan p446)": [ + "CMOS.PUNCTUATION.BRACKETS.TRANSLATED_TEXT" + ], + "CMOS18 \u00a76.107 p424 (scan p446)": [ + "CMOS.PUNCTUATION.BRACKETS.NESTED_PARENS" + ], + "CMOS18 \u00a76.113 p426 (scan p448)": [ + "CMOS.PUNCTUATION.SLASHES.ALTERNATIVES" + ], + "CMOS18 \u00a76.114 p426 (scan p448)": [ + "CMOS.PUNCTUATION.SLASHES.TWO_YEAR_SPANS" + ], + "CMOS18 \u00a76.12 p383 (scan p405)": [ + "CMOS.PUNCTUATION.PERIODS.USE" + ], + "CMOS18 \u00a76.122 p428 (scan p450)": [ + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US", + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US" + ], + "CMOS18 \u00a76.123 p428 (scan p450)": [ + "CMOS.PUNCTUATION.QUOTES.SMART_QUOTES" + ], + "CMOS18 \u00a76.13 p383 (scan p405)": [ + "CMOS.PUNCTUATION.PERIODS.WITH_PARENS" + ], + "CMOS18 \u00a76.131 p432 (scan p454)": [ + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING" + ], + "CMOS18 \u00a76.17 p385 (scan p407)": [ + "CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF" + ], + "CMOS18 \u00a76.19 p385 (scan p407)": [ + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT" + ], + "CMOS18 \u00a76.24 p388 (scan p410)": [ + "CMOS.PUNCTUATION.COMMAS.COMPOUND_PREDICATES" + ], + "CMOS18 \u00a76.26 p390 (scan p412)": [ + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY" + ], + "CMOS18 \u00a76.27 p390 (scan p412)": [ + "CMOS.PUNCTUATION.COMMAS.DEPENDENT_CLAUSE_AFTER" + ], + "CMOS18 \u00a76.29 p392 (scan p414)": [ + "CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF" + ], + "CMOS18 \u00a76.30 p392 (scan p414)": [ + "CMOS.PUNCTUATION.COMMAS.APPOSITIVES" + ], + "CMOS18 \u00a76.32 p394 (scan p416)": [ + "CMOS.PUNCTUATION.COMMAS.DESCRIPTIVE_PHRASES" + ], + "CMOS18 \u00a76.33 p394 (scan p416)": [ + "CMOS.PUNCTUATION.COMMAS.PARTICIPIAL_PHRASES" + ], + "CMOS18 \u00a76.36 p396 (scan p418)": [ + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_PHRASES" + ], + "CMOS18 \u00a76.40 p398 (scan p420)": [ + "CMOS.PUNCTUATION.COMMAS.REPEATED_ADJECTIVES" + ], + "CMOS18 \u00a76.41 p398 (scan p420)": [ + "CMOS.PUNCTUATION.COMMAS.DATES" + ], + "CMOS18 \u00a76.42 p398 (scan p420)": [ + "CMOS.PUNCTUATION.COMMAS.ADDRESSES" + ], + "CMOS18 \u00a76.44 p400 (scan p422)": [ + "CMOS.PUNCTUATION.COMMAS.QUOTED_TITLES" + ], + "CMOS18 \u00a76.45 p400 (scan p422)": [ + "CMOS.PUNCTUATION.COMMAS.QUESTIONS" + ], + "CMOS18 \u00a76.62 p408 (scan p430)": [ + "CMOS.PUNCTUATION.SEMICOLONS.CONJUNCTIVE_PHRASES" + ], + "CMOS18 \u00a76.63 p408 (scan p430)": [ + "CMOS.PUNCTUATION.SEMICOLONS.BEFORE_CONJUNCTION" + ], + "CMOS18 \u00a76.64 p408 (scan p430)": [ + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE" + ], + "CMOS18 \u00a76.66 p410 (scan p432)": [ + "CMOS.PUNCTUATION.COLONS.SPACE_AFTER", + "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT" + ], + "CMOS18 \u00a76.67 p410 (scan p432)": [ + "CMOS.PUNCTUATION.COLONS.CAPITALIZATION" + ], + "CMOS18 \u00a76.68 p410 (scan p432)": [ + "CMOS.PUNCTUATION.COLONS.AS_FOLLOWS" + ], + "CMOS18 \u00a76.69 p410 (scan p432)": [ + "CMOS.PUNCTUATION.COLONS.INTRO_QUOTE_QUESTION" + ], + "CMOS18 \u00a76.72 p412 (scan p434)": [ + "CMOS.PUNCTUATION.QUESTION_MARK.USE" + ], + "CMOS18 \u00a76.73 p412 (scan p434)": [ + "CMOS.PUNCTUATION.QUESTION_MARK.DIRECT_VS_INDIRECT" + ], + "CMOS18 \u00a76.74 p413 (scan p435)": [ + "CMOS.PUNCTUATION.QUESTION_MARK.WITH_PUNCT" + ], + "CMOS18 \u00a76.75 p413 (scan p435)": [ + "CMOS.PUNCTUATION.EXCLAMATION.USE_SPARINGLY" + ], + "CMOS18 \u00a76.76 p413 (scan p435)": [ + "CMOS.PUNCTUATION.EXCLAMATION.VS_QUESTION" + ], + "CMOS18 \u00a76.83 p415 (scan p437)": [ + "CMOS.NUMBERS.RANGES.EN_DASH.USE", + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES" + ], + "CMOS18 \u00a76.89 p418 (scan p440)": [ + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US" + ], + "CMOS18 \u00a76.91 p418 (scan p440)": [ + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US" + ], + "CMOS18 \u00a76.96 p420 (scan p442)": [ + "CMOS.PUNCTUATION.DASHES.EM_LINE_BREAKS" + ], + "CMOS18 \u00a76.97 p420 (scan p442)": [ + "CMOS.PUNCTUATION.DASHES.EM_INSTEAD_OF_QUOTES" + ], + "CMOS18 \u00a77.87 p474 (scan p496)": [ + "CMOS.PUNCTUATION.HYPHENATION.GENERAL_CHOICE" + ], + "CMOS18 \u00a77.88 p475 (scan p497)": [ + "CMOS.PUNCTUATION.HYPHENATION.COMPOUND_DEFINITION" + ], + "CMOS18 \u00a77.89 p475 (scan p497)": [ + "CMOS.PUNCTUATION.HYPHENATION.TREND_CLOSED" + ], + "CMOS18 \u00a77.90 p475 (scan p497)": [ + "CMOS.PUNCTUATION.HYPHENATION.READABILITY" + ], + "CMOS18 \u00a77.91 p476 (scan p498)": [ + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN" + ], + "CMOS18 \u00a77.93 p476 (scan p498)": [ + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN" + ], + "CMOS18 \u00a77.95 p477 (scan p499)": [ + "CMOS.PUNCTUATION.HYPHENATION.SUSPENDED" + ], + "CMOS18 \u00a79.10 p596 (scan p618)": [ + "CMOS.NUMBERS.SCIENTIFIC.POWERS_OF_TEN" + ], + "CMOS18 \u00a79.11 p596 (scan p618)": [ + "CMOS.NUMBERS.SI_PREFIXES.NO_HYPHEN" + ], + "CMOS18 \u00a79.12 p596 (scan p618)": [ + "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING" + ], + "CMOS18 \u00a79.14 p597 (scan p619)": [ + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS" + ], + "CMOS18 \u00a79.15 p597 (scan p619)": [ + "CMOS.NUMBERS.FRACTIONS.SIMPLE.SPELL_OUT" + ], + "CMOS18 \u00a79.16 p598 (scan p620)": [ + "CMOS.NUMBERS.FRACTIONS.MIXED.WHOLE_PLUS_FRACTION" + ], + "CMOS18 \u00a79.17 p598 (scan p620)": [ + "CMOS.NUMBERS.FRACTIONS.MATH.NUMERALS" + ], + "CMOS18 \u00a79.18 p598 (scan p620)": [ + "CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS" + ], + "CMOS18 \u00a79.19 p599 (scan p621)": [ + "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS", + "CMOS.NUMBERS.UNITS.REPEATED.OMIT_REPEAT" + ], + "CMOS18 \u00a79.2 p590 (scan p612)": [ + "CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID", + "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE", + "CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT" + ], + "CMOS18 \u00a79.20 p599 (scan p621)": [ + "CMOS.NUMBERS.PERCENTAGES.NUMERALS" + ], + "CMOS18 \u00a79.21 p600 (scan p622)": [ + "CMOS.NUMBERS.DECIMALS.LEADING_ZERO", + "CMOS.NUMBERS.DECIMALS.NUMERALS" + ], + "CMOS18 \u00a79.22 p600 (scan p622)": [ + "CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT", + "CMOS.NUMBERS.CURRENCY.WORDS_VS_SYMBOLS" + ], + "CMOS18 \u00a79.23 p601 (scan p623)": [ + "CMOS.NUMBERS.CURRENCY.NON_US.DISAMBIGUATE" + ], + "CMOS18 \u00a79.25 p602 (scan p624)": [ + "CMOS.NUMBERS.CURRENCY.ISO_CODES" + ], + "CMOS18 \u00a79.26 p602 (scan p624)": [ + "CMOS.NUMBERS.CURRENCY.LARGE_AMOUNTS" + ], + "CMOS18 \u00a79.27 p602 (scan p624)": [ + "CMOS.NUMBERS.CURRENCY.HISTORICAL.YEAR_CONTEXT" + ], + "CMOS18 \u00a79.28 p603 (scan p625)": [ + "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE" + ], + "CMOS18 \u00a79.29 p603 (scan p625)": [ + "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE" + ], + "CMOS18 \u00a79.3 p590 (scan p612)": [ + "CMOS.NUMBERS.RULE_SELECTION.ALTERNATIVE_ZERO_TO_NINE" + ], + "CMOS18 \u00a79.31 p604 (scan p626)": [ + "CMOS.NUMBERS.DATES.CONSISTENT_FORMAT", + "CMOS.NUMBERS.DATES.YEAR_NUMERALS" + ], + "CMOS18 \u00a79.32 p604 (scan p626)": [ + "CMOS.NUMBERS.DATES.ABBREVIATED_YEAR" + ], + "CMOS18 \u00a79.33 p604 (scan p626)": [ + "CMOS.NUMBERS.DATES.MONTH_DAY_STYLE" + ], + "CMOS18 \u00a79.34 p605 (scan p627)": [ + "CMOS.NUMBERS.CENTURIES.SPELLED_OUT" + ], + "CMOS18 \u00a79.35 p605 (scan p627)": [ + "CMOS.NUMBERS.DECADES.CONSISTENT_FORM" + ], + "CMOS18 \u00a79.36 p606 (scan p628)": [ + "CMOS.NUMBERS.ERAS.BCE_CE" + ], + "CMOS18 \u00a79.37 p607 (scan p629)": [ + "CMOS.NUMBERS.DATES.ALL_NUMERAL" + ], + "CMOS18 \u00a79.38 p607 (scan p629)": [ + "CMOS.NUMBERS.DATES.ISO_8601" + ], + "CMOS18 \u00a79.39 p607 (scan p629)": [ + "CMOS.NUMBERS.TIME.GENERAL_NUMERALS" + ], + "CMOS18 \u00a79.4 p591 (scan p613)": [ + "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT" + ], + "CMOS18 \u00a79.40 p608 (scan p630)": [ + "CMOS.NUMBERS.TIME.NOON_MIDNIGHT" + ], + "CMOS18 \u00a79.41 p608 (scan p630)": [ + "CMOS.NUMBERS.TIME.TWENTY_FOUR_HOUR" + ], + "CMOS18 \u00a79.42 p609 (scan p631)": [ + "CMOS.NUMBERS.TIME.ISO_STYLE" + ], + "CMOS18 \u00a79.43 p609 (scan p631)": [ + "CMOS.NUMBERS.NAMES.MONARCHS_POPES" + ], + "CMOS18 \u00a79.46 p610 (scan p632)": [ + "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS" + ], + "CMOS18 \u00a79.5 p591 (scan p613)": [ + "CMOS.NUMBERS.SENTENCE_START.AVOID_NUMERAL" + ], + "CMOS18 \u00a79.52 p611 (scan p633)": [ + "CMOS.NUMBERS.PLACES.HIGHWAYS" + ], + "CMOS18 \u00a79.53 p611 (scan p633)": [ + "CMOS.NUMBERS.PLACES.STREETS" + ], + "CMOS18 \u00a79.54 p611 (scan p633)": [ + "CMOS.NUMBERS.PLACES.BUILDINGS_APTS" + ], + "CMOS18 \u00a79.55 p612 (scan p634)": [ + "CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE", + "CMOS.NUMBERS.PLURALS.SPELLED_OUT" + ], + "CMOS18 \u00a79.56 p612 (scan p634)": [ + "CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR" + ], + "CMOS18 \u00a79.57 p613 (scan p635)": [ + "CMOS.NUMBERS.DECIMAL_MARKER.LOCALE", + "CMOS.NUMBERS.DEGRADED.NUMERAL_NORMALIZATION" + ], + "CMOS18 \u00a79.58 p613 (scan p635)": [ + "CMOS.NUMBERS.DIGIT_GROUPING.SI_SPACE" + ], + "CMOS18 \u00a79.59 p614 (scan p636)": [ + "CMOS.NUMBERS.TELEPHONE.FORMAT" + ], + "CMOS18 \u00a79.6 p592 (scan p614)": [ + "CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT" + ], + "CMOS18 \u00a79.60 p615 (scan p637)": [ + "CMOS.NUMBERS.RATIOS.FORMAT" + ], + "CMOS18 \u00a79.61 p615 (scan p637)": [ + "CMOS.NUMBERS.LISTS.OUTLINE.NUMERAL_STYLE" + ], + "CMOS18 \u00a79.62 p615 (scan p637)": [ + "CMOS.NUMBERS.RANGES.EN_DASH.USE" + ], + "CMOS18 \u00a79.63 p616 (scan p638)": [ + "CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN" + ], + "CMOS18 \u00a79.65 p617 (scan p639)": [ + "CMOS.NUMBERS.INCLUSIVE.COMMAS" + ], + "CMOS18 \u00a79.66 p617 (scan p639)": [ + "CMOS.NUMBERS.INCLUSIVE.YEARS" + ], + "CMOS18 \u00a79.67 p617 (scan p639)": [ + "CMOS.NUMBERS.ROMAN_NUMERALS.USE" + ], + "CMOS18 \u00a79.7 p592 (scan p614)": [ + "CMOS.NUMBERS.DENSE_CONTEXT.USE_NUMERALS" + ], + "CMOS18 \u00a79.8 p593 (scan p615)": [ + "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS" + ], + "CMOS18 \u00a79.9 p595 (scan p617)": [ + "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS" + ], + "HOUSE \u00a7A11Y.BASICS p2": [ + "HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE", + "HOUSE.A11Y.HEADINGS.NO_SKIPS", + "HOUSE.A11Y.IMAGES.ALT_REQUIRED", + "HOUSE.A11Y.LINK_TEXT.DESCRIPTIVE", + "HOUSE.LINKS.TEXT.DESCRIPTIVE" + ], + "HOUSE \u00a7QA.CODE_OVERFLOW p2": [ + "HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED", + "HOUSE.CODE.BLOCKS.NO_CLIPPING", + "HOUSE.CODE.BLOCKS.WRAP_POLICY", + "HOUSE.CODE.INLINE.MONO_BACKTICKS" + ], + "HOUSE \u00a7QA.KEEPS p1": [ + "HOUSE.HEADINGS.KEEPS.AVOID_STRANDED", + "HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS" + ], + "HOUSE \u00a7QA.LINK_WRAP p2": [ + "HOUSE.LINKS.DISALLOW.FILE_URIS", + "HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT", + "HOUSE.LINKS.URLS.PREFER_HTTPS", + "HOUSE.LINKS.WRAP.SAFE_BREAKS" + ], + "HOUSE \u00a7QA.OVERFLOW p1": [ + "HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT" + ], + "HOUSE \u00a7QA.TABLE_OVERFLOW p2": [ + "HOUSE.TABLES.ALIGNMENT.DECIMALS", + "HOUSE.TABLES.HEADERS.REPEAT_ON_PAGEBREAK", + "HOUSE.TABLES.OVERFLOW.NO_CLIPPING" + ] +} diff --git a/spec/manifest.yaml b/spec/manifest.yaml new file mode 100644 index 0000000..22c0167 --- /dev/null +++ b/spec/manifest.yaml @@ -0,0 +1,142 @@ +version: "0.1.0" +registry_id: "pubstyle" +description: > + Machine-readable style+typesetting rules for a Markdown→HTML→PDF pipeline, + backed by primary references (Chicago / Bringhurst) and optional house rules. + Rules are paraphrases only; sources are referenced by pointer strings. + +id_naming: + prefixes: + CMOS: "Editorial/style usage rules derived primarily from Chicago." + BRING: "Typographic/layout rules derived primarily from Bringhurst." + HOUSE: "Project-specific rules not directly sourced to Chicago/Bringhurst." + pattern: "PREFIX.DOMAIN.TOPIC[.SUBTOPIC[.DETAIL...]]" + delimiter: "." + casing: "UPPER_SNAKE for segments" + stability: + rule_ids_are_immutable: true + rename_policy: "Deprecate old id; introduce new id; keep mapping in report diffs." + examples: + - "CMOS.PUNCTUATION.DASHES.EM_DASH" + - "BRING.LAYOUT.WIDOWS_ORPHANS.AVOID" + - "HOUSE.CITATIONS.DOI.PREFER_HTTPS" + +source_pointer_scheme: + goal: "Provide auditable traceability without reproducing sources." + pointer_format_primary: "CMOS18 §
p" + pointer_format_secondary: "BRING §
p" + pointer_format_house: "HOUSE §
p" + optional_scan_hint: "(scan p)" + allowed_page_numbering: + - arabic + - roman + notes: + - "Pointers must be sufficient for a reader with the book to locate the guidance." + - "Never store verbatim passages; paraphrase only." + - "If a rule depends on exact wording, rule_text must say: Exact wording required—refer to pointer." + +category_taxonomy: + - editorial + - typography + - layout + - headings + - citations + - numbers + - punctuation + - abbreviations + - links + - tables + - figures + - code + - frontmatter + - backmatter + - accessibility + - i18n + +profiles: + - web_pdf + - print_pdf + - dense_tech + - memo + - slide_deck + +planned_rule_counts: + target_total_range: [800, 1500] + target_by_category: + editorial: 120 + typography: 170 + layout: 140 + headings: 70 + citations: 140 + numbers: 90 + punctuation: 120 + abbreviations: 60 + links: 50 + tables: 60 + figures: 50 + code: 70 + frontmatter: 40 + backmatter: 40 + accessibility: 90 + i18n: 60 + +coverage_contract: + must_rules: + enforceability_requirement: > + Every MUST rule must be enforceable by at least one of: lint, typeset, postrender; + otherwise it must be explicitly labeled as a manual checklist item and emitted in + a checklist output artifact. + manual_checklist_tag: "manual_checklist=true" + checklist_artifact: "manual-checklist.md (and JSON mirror)" + should_rules: + policy: "Should rules should be enforceable when practical; otherwise allowed as manual with explicit rationale." + warn_rules: + policy: "Warnings may be non-blocking and advisory; still require source pointers." + enforcement_definitions: + lint: "Static analysis over normalized Markdown/HTML AST. Deterministic." + typeset: "CSS/tokens shaping decisions prior to rendering (pagination, keeps, hyphenation parameters)." + postrender: "PDF/HTML layout inspection (widows/orphans, overflow, keep failures, numbering mismatches)." + manual: "Human review; system must still produce checklist items and traceability pointers." + ci_guardrails: + coverage_floor: + must_implemented_min_percent: 95 + overall_implemented_min_percent: 80 + regression_rule: "CI fails if implemented coverage decreases from main branch." + +degraded_mode_contract: + purpose: "Handle badly-structured inputs safely without crashing; still provide useful output." + triggers: + - "Markdown parse errors / invalid UTF-8" + - "Missing heading hierarchy (no H1/H2 etc.)" + - "Garbage extraction (e.g., line breaks every word, excessive hard wraps)" + - "Mixed language with no lang metadata" + behavior: + normalize: + attempt_repairs: + - "Normalize whitespace and line endings" + - "Detect and unwrap hard-wrapped paragraphs heuristically" + - "Infer heading levels from patterns (e.g., 1., 1.1, ALL CAPS lines) with low confidence" + if_unrecoverable: + - "Fall back to minimal AST: paragraphs + code blocks + raw spans" + - "Mark document structure confidence = low" + enforcement_in_degraded_mode: + lint: + run_subset: + - "safety" + - "sanity" + - "catastrophic typography (double spaces, broken links)" + downgrade_some_must_to_warn: true + typeset: + use_fallback_tokens: true + disable_aggressive_hyphenation: true + postrender: + run_core_gates_only: + - "overfull_lines" + - "table_overflow_incidents" + - "code_overflow_incidents" + reporting: + always_emit: + - "layout-report.json" + - "coverage-report.json" + - "degraded-mode-report.json (what was inferred and why)" + diff --git a/spec/profiles/dense_tech.yaml b/spec/profiles/dense_tech.yaml new file mode 100644 index 0000000..6bcd14f --- /dev/null +++ b/spec/profiles/dense_tech.yaml @@ -0,0 +1,84 @@ +profile_id: "dense_tech" +description: "Technical papers and specs: denser copy, more code/table tolerance, strict numbering and citations." + +page: + size: "A4" + orientation: "portrait" + two_sided: false + margins: + top: "18mm" + bottom: "18mm" + inner: "18mm" + outer: "18mm" + +fonts: + body: + family: ["Noto Serif", "STIX Two Text", "serif"] + size: "10pt" + line_height: 1.35 + heading: + family: ["Noto Sans", "Source Sans 3", "sans-serif"] + mono: + family: ["Noto Sans Mono", "Source Code Pro", "monospace"] + size: "9pt" + line_height: 1.25 + +measure_targets: + columns: 1 + body_chars_per_line: + min: 65 + ideal: 75 + max: 90 + +hyphenation: + enabled: true + strategy: "balanced" + min_left: 2 + min_right: 3 + max_consecutive_hyphenated_lines: 3 + avoid_proper_names_when_possible: true + +headings: + keep_with_next_lines: 2 + avoid_stranded_headings: true + numbering: + enabled: true + style: "decimal" + require_monotonic_increase: true + +widows_orphans: + widow_lines: 2 + orphan_lines: 2 + balance_facing_pages: false + +code: + block: + font_size: "8.8pt" + line_height: 1.20 + wrap: true + overflow_policy: "wrap_then_shrink_minor" + shrink_limit: 0.90 + +tables: + cell_padding: "2pt 4pt" + header_repeat: true + overflow_policy: "shrink_then_wrap" + shrink_limit: 0.85 + +severity_overrides: + - selector: { category: "citations" } + severity: "must" + - selector: { category: "headings", tag: "numbering" } + severity: "must" + - selector: { category: "layout", tag: "widows_orphans" } + severity: "should" + +locale_defaults: + primary_language: "en" + fallback_languages: ["fr"] + quotation_style: "us" + date_format: "YYYY-MM-DD" + number_format: + decimal_separator: "." + thousands_separator: "," + diff --git a/spec/profiles/memo.yaml b/spec/profiles/memo.yaml new file mode 100644 index 0000000..e7ce939 --- /dev/null +++ b/spec/profiles/memo.yaml @@ -0,0 +1,76 @@ +profile_id: "memo" +description: "Short internal documents: lenient pagination, strong clarity, minimal typographic complexity." + +page: + size: "Letter" + orientation: "portrait" + two_sided: false + margins: + top: "1in" + bottom: "1in" + inner: "1in" + outer: "1in" + +fonts: + body: + family: ["Noto Sans", "Source Sans 3", "Arial", "sans-serif"] + size: "11pt" + line_height: 1.40 + heading: + family: ["Noto Sans", "Source Sans 3", "Arial", "sans-serif"] + mono: + family: ["Noto Sans Mono", "Consolas", "monospace"] + size: "10pt" + line_height: 1.30 + +measure_targets: + columns: 1 + body_chars_per_line: + min: 55 + ideal: 70 + max: 85 + +hyphenation: + enabled: false + strategy: "off_for_memos" + +headings: + keep_with_next_lines: 2 + avoid_stranded_headings: true + numbering: + enabled: false + +widows_orphans: + widow_lines: 1 + orphan_lines: 1 + balance_facing_pages: false + +code: + block: + font_size: "9.5pt" + line_height: 1.25 + wrap: true + overflow_policy: "wrap" + shrink_limit: 1.0 + +tables: + cell_padding: "3pt 6pt" + header_repeat: false + overflow_policy: "wrap" + shrink_limit: 1.0 + +severity_overrides: + - selector: { category: "layout", tag: "widows_orphans" } + severity: "warn" + - selector: { category: "accessibility" } + severity: "must" + +locale_defaults: + primary_language: "en" + fallback_languages: ["fr"] + quotation_style: "us" + date_format: "YYYY-MM-DD" + number_format: + decimal_separator: "." + thousands_separator: "," + diff --git a/spec/profiles/print_pdf.yaml b/spec/profiles/print_pdf.yaml new file mode 100644 index 0000000..71a1f0a --- /dev/null +++ b/spec/profiles/print_pdf.yaml @@ -0,0 +1,88 @@ +profile_id: "print_pdf" +description: "Print-oriented PDF with stricter pagination, book-like rhythm, and stronger keep constraints." + +page: + size: "6in×9in" + orientation: "portrait" + two_sided: true + margins: + top: "18mm" + bottom: "20mm" + inner: "22mm" + outer: "18mm" + +fonts: + body: + family: ["STIX Two Text", "Noto Serif", "Georgia", "serif"] + size: "10.5pt" + line_height: 1.50 + heading: + family: ["STIX Two Text", "Noto Serif", "serif"] + mono: + family: ["Noto Sans Mono", "Source Code Pro", "Consolas", "monospace"] + size: "9.5pt" + line_height: 1.30 + +measure_targets: + columns: 1 + body_chars_per_line: + min: 55 + ideal: 66 + max: 72 + +hyphenation: + enabled: true + strategy: "print_quality" + min_left: 2 + min_right: 3 + max_consecutive_hyphenated_lines: 2 + avoid_proper_names_when_possible: true + +paragraphs: + first_paragraph_indent: "0" + indent: "1em" + +headings: + keep_with_next_lines: 3 + avoid_stranded_headings: true + numbering: + enabled: true + style: "decimal" + require_monotonic_increase: true + +widows_orphans: + widow_lines: 2 + orphan_lines: 2 + balance_facing_pages: true + +code: + block: + font_size: "9pt" + line_height: 1.25 + wrap: false + overflow_policy: "shrink_then_scroll_indicator" + shrink_limit: 0.90 + +tables: + cell_padding: "2.5pt 5pt" + header_repeat: true + overflow_policy: "shrink_then_rotate_if_allowed" + shrink_limit: 0.88 + +severity_overrides: + - selector: { category: "layout", tag: "widows_orphans" } + severity: "must" + - selector: { category: "layout", tag: "keep_constraints" } + severity: "must" + - selector: { category: "typography", tag: "spacing_consistency" } + severity: "must" + +locale_defaults: + primary_language: "en" + fallback_languages: ["fr"] + quotation_style: "us" + date_format: "Month D, YYYY" + number_format: + decimal_separator: "." + thousands_separator: "," + diff --git a/spec/profiles/slide_deck.yaml b/spec/profiles/slide_deck.yaml new file mode 100644 index 0000000..a50587e --- /dev/null +++ b/spec/profiles/slide_deck.yaml @@ -0,0 +1,76 @@ +profile_id: "slide_deck" +description: "Paged slides (16:9). Emphasis on hierarchy, short lines, and avoiding overflows." + +page: + size: "13.333in×7.5in" + orientation: "landscape" + two_sided: false + margins: + top: "0.5in" + bottom: "0.5in" + inner: "0.6in" + outer: "0.6in" + +fonts: + body: + family: ["Noto Sans", "Source Sans 3", "Arial", "sans-serif"] + size: "24pt" + line_height: 1.15 + heading: + family: ["Noto Sans", "Source Sans 3", "Arial", "sans-serif"] + mono: + family: ["Noto Sans Mono", "Consolas", "monospace"] + size: "20pt" + line_height: 1.10 + +measure_targets: + columns: 1 + body_chars_per_line: + min: 25 + ideal: 40 + max: 55 + +hyphenation: + enabled: false + strategy: "off_for_slides" + +headings: + keep_with_next_lines: 1 + avoid_stranded_headings: true + numbering: + enabled: false + +widows_orphans: + widow_lines: 1 + orphan_lines: 1 + balance_facing_pages: false + +code: + block: + font_size: "18pt" + line_height: 1.10 + wrap: true + overflow_policy: "wrap_then_shrink_minor" + shrink_limit: 0.92 + +tables: + cell_padding: "6pt 10pt" + header_repeat: false + overflow_policy: "shrink_then_wrap" + shrink_limit: 0.88 + +severity_overrides: + - selector: { category: "layout", tag: "overflow" } + severity: "must" + - selector: { category: "accessibility" } + severity: "must" + +locale_defaults: + primary_language: "en" + fallback_languages: ["fr"] + quotation_style: "us" + date_format: "YYYY-MM-DD" + number_format: + decimal_separator: "." + thousands_separator: "," + diff --git a/spec/profiles/web_pdf.yaml b/spec/profiles/web_pdf.yaml new file mode 100644 index 0000000..fab3b37 --- /dev/null +++ b/spec/profiles/web_pdf.yaml @@ -0,0 +1,96 @@ +profile_id: "web_pdf" +description: "Screen-first PDF for sharing and reading; conservative pagination and strong accessibility defaults." + +page: + size: "Letter" + orientation: "portrait" + two_sided: false + margins: + top: "22mm" + bottom: "22mm" + inner: "20mm" + outer: "20mm" + +fonts: + body: + family: ["Noto Serif", "STIX Two Text", "Times New Roman", "serif"] + size: "11pt" + line_height: 1.45 + heading: + family: ["Noto Sans", "Source Sans 3", "Arial", "sans-serif"] + mono: + family: ["Noto Sans Mono", "Source Code Pro", "Consolas", "monospace"] + size: "10pt" + line_height: 1.35 + +measure_targets: + columns: 1 + body_chars_per_line: + min: 55 + ideal: 66 + max: 75 + footnote_chars_per_line: + min: 50 + ideal: 60 + max: 70 + +hyphenation: + enabled: true + strategy: "balanced" + language_driven: true + min_left: 2 + min_right: 3 + max_consecutive_hyphenated_lines: 2 + avoid_proper_names_when_possible: true + avoid_after_short_lines: true + +paragraphs: + first_paragraph_indent: "0" + indent: "1em" + block_paragraph_spacing: "0.6em" + +headings: + keep_with_next_lines: 2 + avoid_stranded_headings: true + numbering: + enabled: true + style: "decimal" + require_monotonic_increase: true + +widows_orphans: + widow_lines: 2 + orphan_lines: 2 + balance_facing_pages: false + +code: + inline: + use_mono: true + block: + font_size: "9.5pt" + line_height: 1.35 + wrap: true + max_wrap_penalty: "medium" + overflow_policy: "wrap_then_shrink_minor" + shrink_limit: 0.92 + +tables: + cell_padding: "3pt 6pt" + header_repeat: true + overflow_policy: "shrink_then_wrap" + shrink_limit: 0.9 + +severity_overrides: + - selector: { category: "layout", tag: "widows_orphans" } + severity: "should" + - selector: { category: "accessibility" } + severity: "must" + +locale_defaults: + primary_language: "en" + fallback_languages: ["fr"] + quotation_style: "us" + date_format: "YYYY-MM-DD" + number_format: + decimal_separator: "." + thousands_separator: "," + diff --git a/spec/quality_gates.yaml b/spec/quality_gates.yaml new file mode 100644 index 0000000..0c402ba --- /dev/null +++ b/spec/quality_gates.yaml @@ -0,0 +1,132 @@ +version: "0.1.0" +description: > + Post-render QA gates. All thresholds are hard numeric limits used to fail builds + (unless a gate is explicitly marked as warn-only by the invoking CLI flags). + +metrics: + max_widows_per_10_pages: "Count of widow lines across any 10 consecutive pages." + max_orphans_per_10_pages: "Count of orphan lines across any 10 consecutive pages." + max_stranded_headings: "Count of headings at page bottom with insufficient following content per keep rule." + max_overfull_lines: "Count of lines exceeding measure by overflow threshold (render-time measured)." + max_table_overflow_incidents: "Count of tables that overflow page/column bounds or are clipped." + max_code_overflow_incidents: "Count of code blocks with horizontal overflow or clipping." + max_link_wrap_incidents: "Count of wrapped URLs/DOIs/emails violating link wrap policy." + max_heading_numbering_errors: "Count of numbering sequence/format violations." + max_citation_format_errors: "Count of citations not matching configured style format." + +overflow_detection: + overfull_line_threshold_css_px: 1.0 + consider_clipping_as_overflow: true + ignore_decorative_elements: true + +profiles: + web_pdf: + default: + max_widows_per_10_pages: 1 + max_orphans_per_10_pages: 1 + max_stranded_headings: 0 + max_overfull_lines: 2 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 1 + max_link_wrap_incidents: 2 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + strict: + max_widows_per_10_pages: 0 + max_orphans_per_10_pages: 0 + max_stranded_headings: 0 + max_overfull_lines: 0 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 0 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + + print_pdf: + default: + max_widows_per_10_pages: 0 + max_orphans_per_10_pages: 0 + max_stranded_headings: 0 + max_overfull_lines: 0 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 0 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + strict: + max_widows_per_10_pages: 0 + max_orphans_per_10_pages: 0 + max_stranded_headings: 0 + max_overfull_lines: 0 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 0 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + + dense_tech: + default: + max_widows_per_10_pages: 1 + max_orphans_per_10_pages: 1 + max_stranded_headings: 0 + max_overfull_lines: 3 + max_table_overflow_incidents: 1 + max_code_overflow_incidents: 2 + max_link_wrap_incidents: 3 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + strict: + max_widows_per_10_pages: 0 + max_orphans_per_10_pages: 0 + max_stranded_headings: 0 + max_overfull_lines: 1 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 1 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + + memo: + default: + max_widows_per_10_pages: 3 + max_orphans_per_10_pages: 3 + max_stranded_headings: 0 + max_overfull_lines: 2 + max_table_overflow_incidents: 1 + max_code_overflow_incidents: 1 + max_link_wrap_incidents: 4 + max_heading_numbering_errors: 1 + max_citation_format_errors: 1 + strict: + max_widows_per_10_pages: 1 + max_orphans_per_10_pages: 1 + max_stranded_headings: 0 + max_overfull_lines: 0 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 2 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + + slide_deck: + default: + max_widows_per_10_pages: 5 + max_orphans_per_10_pages: 5 + max_stranded_headings: 0 + max_overfull_lines: 0 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 0 + max_heading_numbering_errors: 0 + max_citation_format_errors: 1 + strict: + max_widows_per_10_pages: 2 + max_orphans_per_10_pages: 2 + max_stranded_headings: 0 + max_overfull_lines: 0 + max_table_overflow_incidents: 0 + max_code_overflow_incidents: 0 + max_link_wrap_incidents: 0 + max_heading_numbering_errors: 0 + max_citation_format_errors: 0 + diff --git a/spec/rules/accessibility/v1_accessibility_001.ndjson b/spec/rules/accessibility/v1_accessibility_001.ndjson new file mode 100644 index 0000000..d86c0b8 --- /dev/null +++ b/spec/rules/accessibility/v1_accessibility_001.ndjson @@ -0,0 +1,4 @@ +{"id":"HOUSE.A11Y.HEADINGS.NO_SKIPS","title":"Heading hierarchy must not skip levels","source_refs":["HOUSE §A11Y.BASICS p2"],"category":"accessibility","severity":"must","applies_to":"all","rule_text":"Do not skip heading levels (e.g., H2 directly to H4). Maintain a coherent heading hierarchy to support navigation and assistive tech.","rationale":"Skipped levels break document structure for readers and tools.","enforcement":"lint","autofix":"suggest","autofix_notes":"Report heading-level skips and suggest the nearest valid level based on surrounding context.","tags":["accessibility","headings"],"keywords":["heading hierarchy","a11y","structure"],"dependencies":[],"exceptions":["If a source document is intentionally flat (no headings), treat as degraded-mode and require manual structure decisions."],"status":"active"} +{"id":"HOUSE.A11Y.IMAGES.ALT_REQUIRED","title":"Images must have alt text (or explicit empty alt when decorative)","source_refs":["HOUSE §A11Y.BASICS p2"],"category":"accessibility","severity":"must","applies_to":"html","rule_text":"All images must include alt text; if an image is decorative, use an explicit empty alt so assistive tech can skip it.","rationale":"Alt text enables non-visual readers to understand content.","enforcement":"lint","autofix":"suggest","autofix_notes":"Warn on images without alt text and suggest adding a concise description or empty alt.","tags":["accessibility","images"],"keywords":["alt text","images","screen readers"],"dependencies":[],"exceptions":["For purely decorative background images, empty alt is preferred."],"status":"active"} +{"id":"HOUSE.A11Y.LINK_TEXT.DESCRIPTIVE","title":"Links should have meaningful text","source_refs":["HOUSE §A11Y.BASICS p2"],"category":"accessibility","severity":"should","applies_to":"all","rule_text":"Avoid generic link text (“here”, “this”) and ensure link labels convey destination or action.","rationale":"Meaningful links improve navigation for assistive tech users.","enforcement":"lint","autofix":"suggest","autofix_notes":"Warn on low-information link text and suggest a descriptive label.","tags":["accessibility","links"],"keywords":["link text","accessibility"],"dependencies":["HOUSE.LINKS.TEXT.DESCRIPTIVE"],"exceptions":["In UI components with adjacent context, short link text may be acceptable if the context is explicitly included."],"status":"active"} +{"id":"HOUSE.A11Y.DOCUMENT_LANGUAGE.DECLARE","title":"Declare document language when exporting HTML","source_refs":["HOUSE §A11Y.BASICS p2"],"category":"accessibility","severity":"should","applies_to":"html","rule_text":"Declare the primary language of the document (and mark language changes when feasible) to support correct pronunciation and hyphenation.","rationale":"Language metadata improves accessibility and typesetting quality.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Set for HTML outputs based on profile locale defaults; warn when mixed-language segments are detected without marking.","tags":["accessibility","i18n"],"keywords":["lang attribute","hyphenation","screen readers"],"dependencies":[],"exceptions":["Short foreign phrases may not require explicit lang marking if they do not affect pronunciation or hyphenation."],"status":"active"} diff --git a/spec/rules/citations/v1_citations_001.ndjson b/spec/rules/citations/v1_citations_001.ndjson new file mode 100644 index 0000000..86cd5d7 --- /dev/null +++ b/spec/rules/citations/v1_citations_001.ndjson @@ -0,0 +1,16 @@ +{"id":"CMOS.CITATIONS.SYSTEM.CONSISTENT_CHOICE","title":"Choose one citation system and apply it consistently","source_refs":["CMOS18 §13 p775"],"category":"citations","severity":"must","applies_to":"all","rule_text":"Choose an established citation system (e.g., notes/bibliography or author-date) and apply it consistently within the document; do not mix systems without an explicit editorial decision.","rationale":"Consistency is the baseline for auditability and reader trust.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_system","manual_checklist=true"],"keywords":["notes and bibliography","author-date","citation system"],"dependencies":[],"exceptions":["Collected volumes may contain mixed styles across chapters; require an explicit house decision and clear boundaries."],"status":"active"} +{"id":"CMOS.CITATIONS.NOTES_BIBLIO.FIRST_NOTE.FULL_REFERENCE","title":"In notes/bibliography, provide a full citation on first mention in a note","source_refs":["CMOS18 §13 p783"],"category":"citations","severity":"must","applies_to":"all","rule_text":"In notes/bibliography style, the first citation of a source in a note should provide sufficient publication details for identification (author, title, publication facts, and locator where relevant).","rationale":"Full first-note citations let readers identify sources without guesswork.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=notes_bibliography","manual_checklist=true"],"keywords":["first note","full citation","notes and bibliography"],"dependencies":[],"exceptions":["Short-form citations may be acceptable only when a complete bibliography entry is immediately available and policy allows it."],"status":"active"} +{"id":"CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM","title":"In notes/bibliography, use a consistent short form for subsequent notes","source_refs":["CMOS18 §13.32 p790 (scan p812)"],"category":"citations","severity":"should","applies_to":"all","rule_text":"After the first full note for a source, use a consistent shortened form for subsequent notes (e.g., author + short title + locator) per the chosen style policy.","rationale":"Short forms reduce redundancy while maintaining traceability.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=notes_bibliography","manual_checklist=true"],"keywords":["short form","subsequent notes","short title"],"dependencies":[],"exceptions":["If shortened forms could be ambiguous across multiple works by the same author, expand the short form or add distinguishing details."],"status":"active"} +{"id":"CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED","title":"Include a bibliography when using notes/bibliography style (when required by the deliverable)","source_refs":["CMOS18 §13.65 p809 (scan p831)"],"category":"citations","severity":"should","applies_to":"all","rule_text":"When the deliverable calls for notes/bibliography style, include a bibliography/reference list per the style and scope of the work; treat this as a project requirement, not an afterthought.","rationale":"A bibliography is the primary navigational index for sources.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=notes_bibliography","manual_checklist=true"],"keywords":["bibliography","reference list","notes"],"dependencies":[],"exceptions":["Some short formats (brief memos) may omit the bibliography by design; document the omission explicitly."],"status":"active"} +{"id":"CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR","title":"Sort bibliography entries consistently (typically by author)","source_refs":["CMOS18 §13.69 p816 (scan p838)"],"category":"citations","severity":"must","applies_to":"all","rule_text":"Sort bibliography entries consistently, typically alphabetically by author (or by title when no author is present), and apply the same ordering rules throughout.","rationale":"Sorting enables fast verification and reduces reviewer friction.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=notes_bibliography","manual_checklist=true"],"keywords":["bibliography order","alphabetical"],"dependencies":[],"exceptions":["Legal citation collections may require jurisdiction-first ordering; follow the governing standard for that corpus."],"status":"active"} +{"id":"CMOS.CITATIONS.NOTES_BIBLIO.NAME_ORDER.NOTES_VS_BIBLIO","title":"Use appropriate name order for notes vs bibliography","source_refs":["CMOS18 §13 p819"],"category":"citations","severity":"should","applies_to":"all","rule_text":"In notes, present author names in natural order; in bibliography entries, invert the first-listed author name (surname first) per the chosen citation style.","rationale":"This aligns with common indexing expectations and improves lookup.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=notes_bibliography","manual_checklist=true"],"keywords":["author name order","inverted name","bibliography"],"dependencies":[],"exceptions":["Corporate authors and organizations may follow different inversion conventions; use the style’s guidance and consistency."],"status":"active"} +{"id":"CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PARENTHETICAL","title":"In author-date style, include author and year in the text citation","source_refs":["CMOS18 §13 p833"],"category":"citations","severity":"must","applies_to":"all","rule_text":"In author-date style, include the author’s surname and the year of publication in the in-text citation; include a locator (e.g., page) when quoting or citing a specific passage.","rationale":"Author-date citations are only useful when they can be matched to the reference list.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=author_date","manual_checklist=true"],"keywords":["author-date","in-text citation","year","page number"],"dependencies":[],"exceptions":["Some standards bodies define their own in-text formats; follow the governing standard when applicable."],"status":"active"} +{"id":"CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REQUIRED","title":"In author-date style, include a reference list and ensure in-text citations resolve to it","source_refs":["CMOS18 §13 p837"],"category":"citations","severity":"must","applies_to":"all","rule_text":"In author-date style, include a reference list and ensure every in-text citation has a corresponding entry; treat missing entries as a correctness failure.","rationale":"A reference list is the verification surface for author-date citations.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=author_date","manual_checklist=true"],"keywords":["reference list","author-date","resolve citations"],"dependencies":[],"exceptions":["If the deliverable is intentionally citation-light, avoid author-date style and use an alternative scheme appropriate to the format."],"status":"active"} +{"id":"CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ORDER_AND_YEAR","title":"Format author-date reference entries consistently (including year placement)","source_refs":["CMOS18 §13 p837"],"category":"citations","severity":"should","applies_to":"all","rule_text":"Format author-date reference list entries consistently, including placement of the year and required publication fields, according to the chosen style policy.","rationale":"Consistent entry formatting prevents review churn and improves traceability.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citation_style=author_date","manual_checklist=true"],"keywords":["reference list format","year placement","author-date"],"dependencies":[],"exceptions":["If an entry lacks a date, use the style’s guidance for undated works and be consistent."],"status":"active"} +{"id":"CMOS.CITATIONS.QUOTATIONS.LOCATORS.PAGE_REQUIRED","title":"Include page/locator information for direct quotations and specific claims","source_refs":["CMOS18 §13 p775"],"category":"citations","severity":"should","applies_to":"all","rule_text":"When quoting directly or pointing to a specific passage, include a locator (page, section, or equivalent) suitable to the source type.","rationale":"Locators are necessary for verifiable checking.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","locators"],"keywords":["page number","locator","quotation"],"dependencies":[],"exceptions":["Web sources without stable pagination may require section anchors or archived snapshots."],"status":"active"} +{"id":"CMOS.CITATIONS.DOI.PREFERRED_OVER_URL","title":"Prefer durable identifiers (DOI) over bare URLs when available","source_refs":["CMOS18 §13.7 p778 (scan p800)"],"category":"citations","severity":"should","applies_to":"all","rule_text":"When a durable identifier such as a DOI is available, prefer it over a bare URL; represent identifiers as stable HTTPS links per the chosen house policy.","rationale":"Durable identifiers reduce link rot and make citations easier to validate.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest converting DOI strings into https://doi.org/ form and flagging unstable redirect/shortener URLs; do not rewrite without confirmation.","tags":["doi","links"],"keywords":["DOI","doi.org","persistent identifier"],"dependencies":[],"exceptions":["Some sources (legal docs, standards) do not use DOIs; cite their official publication link instead."],"status":"active"} +{"id":"CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED","title":"Record access dates for online sources when required by policy","source_refs":["CMOS18 §13.15 p782 (scan p804)"],"category":"citations","severity":"should","applies_to":"all","rule_text":"When citing online material that is likely to change or lacks a stable publication record, record an access date according to the chosen citation policy.","rationale":"Access dates help explain discrepancies when sources change.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["online_sources","manual_checklist=true"],"keywords":["access date","online source","versioned web"],"dependencies":[],"exceptions":["Stable, versioned archives may allow omitting access dates if the version identifier is captured."],"status":"active"} +{"id":"CMOS.CITATIONS.LEGAL_PUBLIC_DOCS.USE_JURISDICTIONAL_FORMAT","title":"Use appropriate formats for legal and public documents","source_refs":["CMOS18 §14 p935"],"category":"citations","severity":"should","applies_to":"all","rule_text":"For legal and public documents, follow an appropriate jurisdictional format and include identifiers sufficient for retrieval (jurisdiction, issuing body, document number, date, and locator).","rationale":"Public document citations must be independently retrievable.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["legal","public_documents","manual_checklist=true"],"keywords":["legal citation","public documents","jurisdiction"],"dependencies":[],"exceptions":["If another standard governs the citation style for the corpus (e.g., Bluebook), treat Chicago pointers as contextual and follow the governing standard."],"status":"active"} +{"id":"CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID","title":"Avoid relying on ibid-style backreferences unless the policy requires it","source_refs":["CMOS18 §13.37 p791 (scan p813)"],"category":"citations","severity":"warn","applies_to":"all","rule_text":"Avoid making traceability depend on ibid-style backreferences when citations are processed or reflowed; prefer forms that remain unambiguous when notes are reordered.","rationale":"Ibid-style references can break under reformatting and automated processing.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","ibid"],"keywords":["ibid","short form","note processing"],"dependencies":[],"exceptions":["If the deliverable explicitly requires ibid, enforce it consistently and ensure notes are not programmatically reordered."],"status":"active"} +{"id":"CMOS.CITATIONS.TITLES.CAPITALIZATION.CONSISTENT","title":"Use consistent title capitalization policy within citations","source_refs":["CMOS18 §13 p775"],"category":"citations","severity":"should","applies_to":"all","rule_text":"Use a consistent title capitalization policy within citations (headline-style vs sentence-style) according to the selected style and locale; apply it uniformly.","rationale":"Inconsistent capitalization is a common audit distraction.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","capitalization"],"keywords":["headline style","sentence style","capitalization"],"dependencies":[],"exceptions":["If titles are quoted verbatim from a catalog record, preserve their casing and note the source."],"status":"active"} +{"id":"CMOS.CITATIONS.MATCHING.BIBLIO_ENTRY_REQUIRED","title":"Ensure citations resolve to a full entry when the format requires it","source_refs":["CMOS18 §13 p775"],"category":"citations","severity":"must","applies_to":"all","rule_text":"Where the chosen citation format requires a full entry (bibliography or reference list), ensure that each cited work resolves to exactly one corresponding entry.","rationale":"Unresolvable citations undermine verification and credibility.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","resolution"],"keywords":["resolve citations","bibliography entries","reference list entries"],"dependencies":[],"exceptions":["Some short formats may allow citations without full entries; treat this as an explicit project decision."],"status":"active"} diff --git a/spec/rules/citations/v1_citations_002.ndjson b/spec/rules/citations/v1_citations_002.ndjson new file mode 100644 index 0000000..816bc52 --- /dev/null +++ b/spec/rules/citations/v1_citations_002.ndjson @@ -0,0 +1,45 @@ +{"id": "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SUPERSCRIPT_TEXT", "title": "Use superscript note markers in running text", "source_refs": ["CMOS18 \u00a713.27 p787 (scan p809)"], "category": "citations", "severity": "should", "applies_to": "md", "rule_text": "Use superscript note markers in running text and keep the note numbers in the notes list; avoid inline bracketed numbers in narrative prose when a footnote system is in use.", "rationale": "Superscripts separate citations from the sentence flow.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect bracketed or inline numeric markers in prose and suggest converting to Markdown footnotes.", "tags": ["notes", "note_markers"], "keywords": ["note markers", "superscript", "footnotes"], "dependencies": [], "exceptions": ["Legal or technical styles that require bracketed numbers should be handled by profile."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.NOTE_MARKERS.SEQUENCE_CONTINUOUS", "title": "Keep note numbers sequential within scope", "source_refs": ["CMOS18 \u00a713.28 p788 (scan p810)"], "category": "citations", "severity": "must", "applies_to": "md", "rule_text": "Number notes sequentially within the chosen scope (whole document or chapter) and do not restart or skip numbers without a clear boundary.", "rationale": "Sequential numbering preserves traceability.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Flag gaps, duplicates, or unexpected resets in note numbering.", "tags": ["notes", "note_markers", "sequence"], "keywords": ["note numbers", "sequence", "footnotes"], "dependencies": [], "exceptions": ["Appendixes or per-chapter notes may restart if labeled clearly."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.NOTE_MARKERS.PLACEMENT_AFTER_PUNCT", "title": "Place note markers after related punctuation", "source_refs": ["CMOS18 \u00a713.29 p788 (scan p810)"], "category": "citations", "severity": "should", "applies_to": "md", "rule_text": "Place note markers after the punctuation that ends the referenced clause or sentence, not before it.", "rationale": "Consistent placement improves readability.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "When a note marker precedes terminal punctuation, suggest moving it after the punctuation.", "tags": ["notes", "note_markers", "punctuation"], "keywords": ["note marker placement", "footnote position"], "dependencies": [], "exceptions": ["Markers tied to a specific word may follow that word immediately."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.NOTE_MARKERS.HEADINGS_END", "title": "Place note markers at the end of headings", "source_refs": ["CMOS18 \u00a713.30 p789 (scan p811)"], "category": "citations", "severity": "should", "applies_to": "md", "rule_text": "When a heading or title needs a note, place the marker at the end of the heading rather than inserting it mid-phrase.", "rationale": "Mid-heading markers disrupt scanning.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Suggest moving inline note markers in headings to the end of the heading.", "tags": ["notes", "headings"], "keywords": ["heading notes", "title notes"], "dependencies": [], "exceptions": ["Markers that cite a specific word in the heading may remain inline with editorial approval."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.MULTIPLE_CITATIONS.SINGLE_NOTE", "title": "Combine multiple citations into one note when possible", "source_refs": ["CMOS18 \u00a713.31 p789 (scan p811)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When several sources support the same statement, prefer a single note that contains multiple citations rather than multiple note markers in the text.", "rationale": "Fewer markers reduce visual clutter.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true"], "keywords": ["multiple citations", "note consolidation"], "dependencies": [], "exceptions": ["Separate notes may be needed when commentary differs per source."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.SHORT_FORM.BASIC_ELEMENTS", "title": "Short-form notes should include author, short title, and locator", "source_refs": ["CMOS18 \u00a713.33 p790 (scan p812)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Use a consistent short-form note that includes author (or editor), a shortened title, and a locator when available.", "rationale": "Short forms remain identifiable only with key elements.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "short_form", "manual_checklist=true"], "keywords": ["short form", "short title", "locator"], "dependencies": [], "exceptions": ["If a bibliography entry is immediately adjacent, a shorter form may be acceptable by policy."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.SHORT_FORM.CROSS_REFERENCE", "title": "Use cross-references to full notes when needed", "source_refs": ["CMOS18 \u00a713.34 p790 (scan p812)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When a source has a full note elsewhere, you may cross-reference that note rather than repeating full details; ensure the reference is unambiguous.", "rationale": "Cross-references reduce redundancy without losing traceability.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "short_form", "manual_checklist=true"], "keywords": ["cross-reference", "see note", "short form"], "dependencies": [], "exceptions": ["Do not cross-reference if notes will be reordered or filtered in output."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.CHAPTER_IN_EDITED_BOOK", "title": "Cite chapters in edited volumes with chapter and book details", "source_refs": ["CMOS18 \u00a713.25 p787 (scan p809)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "For a chapter or essay in an edited book, cite the chapter author and title plus the book title, editor, and relevant page range.", "rationale": "Chapter citations must be traceable to both the chapter and the host volume.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true"], "keywords": ["chapter citation", "edited book", "page range"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.JOURNAL.ARTICLE_ELEMENTS", "title": "Journal article citations must include journal, volume, issue, and pages", "source_refs": ["CMOS18 \u00a713.26 p787 (scan p809)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Cite journal articles with the journal title, volume and issue (or equivalent), year, and page range or locator.", "rationale": "These elements uniquely identify the article.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true"], "keywords": ["journal article", "volume", "issue", "page range"], "dependencies": [], "exceptions": ["Online-only journals without pagination should use stable locators or article IDs."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.QUOTE_IN_NOTE.LOCATOR", "title": "Quoted material inside notes still needs a locator", "source_refs": ["CMOS18 \u00a713.41 p794 (scan p816)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "If a note contains a direct quotation, include a locator for the quoted passage in that note.", "rationale": "Quoted content must remain verifiable even when placed in notes.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true", "locators"], "keywords": ["quotation in note", "locator", "page number"], "dependencies": [], "exceptions": ["Brief epigraph-style notes may be exempt if the source is immediately clear."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.SUBSTANTIVE.SEPARATE_FROM_SOURCE", "title": "Keep substantive note commentary distinct from source citation", "source_refs": ["CMOS18 \u00a713.42 p794 (scan p816)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When a note contains discursive commentary, keep the source citation clear and separate so the reader can identify it quickly.", "rationale": "Mixing commentary and citations obscures the source trail.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true"], "keywords": ["substantive notes", "commentary", "source citation"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.LONG_NOTES.PARAGRAPHING", "title": "Paragraph long notes to preserve readability", "source_refs": ["CMOS18 \u00a713.43 p794 (scan p816)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Long notes should be paragraph-separated rather than run as a single block.", "rationale": "Paragraphing reduces reader fatigue in note sections.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true"], "keywords": ["long notes", "paragraphing"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.FOOTNOTES.PAGE_BREAKS", "title": "Handle footnotes that break across pages cleanly", "source_refs": ["CMOS18 \u00a713.44 p794 (scan p816)"], "category": "citations", "severity": "should", "applies_to": "pdf", "rule_text": "When a footnote must break across pages, mark the continuation clearly and avoid splitting very short notes.", "rationale": "Clear continuation reduces reader confusion.", "enforcement": "postrender", "autofix": "reflow", "autofix_notes": "Adjust keep-with-next settings or reflow notes to avoid short splits; re-render and re-check.", "tags": ["footnotes", "pagination"], "keywords": ["footnote continuation", "page break"], "dependencies": [], "exceptions": ["Very dense pages may force a split; record in QA notes."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.FOOTNOTES_VS_ENDNOTES.CHOOSE", "title": "Choose footnotes or endnotes based on format and reader needs", "source_refs": ["CMOS18 \u00a713.46 p796 (scan p818)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Select footnotes or endnotes according to the format and reader workflow; do not mix without a clear rationale.", "rationale": "The note system affects usability and page makeup.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["notes", "manual_checklist=true"], "keywords": ["footnotes", "endnotes", "format choice"], "dependencies": [], "exceptions": ["Dual systems may be acceptable in critical editions if clearly labeled."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.ENDNOTES.PLACEMENT", "title": "Place endnotes at a consistent structural boundary", "source_refs": ["CMOS18 \u00a713.49 p797 (scan p819)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When using endnotes, place them consistently at the end of chapters or the end of the work, and label the section clearly.", "rationale": "Consistent placement improves navigability.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["endnotes", "manual_checklist=true"], "keywords": ["endnote placement", "chapter endnotes"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.ENDNOTES.RUNNING_HEADS", "title": "Use clear running heads for endnote sections when present", "source_refs": ["CMOS18 \u00a713.50 p797 (scan p819)"], "category": "citations", "severity": "should", "applies_to": "pdf", "rule_text": "If endnotes are grouped in a dedicated section, use running heads or section headers that identify the notes clearly.", "rationale": "Readers should know they are in the notes section.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["endnotes", "manual_checklist=true", "running_heads"], "keywords": ["endnotes", "running heads", "section headers"], "dependencies": [], "exceptions": ["Short documents may omit running heads."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.ENDNOTES.AVOID_IBID", "title": "Avoid ibid in endnotes when it reduces traceability", "source_refs": ["CMOS18 \u00a713.51 p800 (scan p822)"], "category": "citations", "severity": "warn", "applies_to": "all", "rule_text": "In endnotes, avoid ibid-style references that require readers to flip pages; prefer short forms that remain clear at a distance.", "rationale": "Endnotes are less convenient to navigate than footnotes.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["endnotes", "manual_checklist=true", "ibid"], "keywords": ["ibid", "endnotes", "short form"], "dependencies": [], "exceptions": ["If ibid is required by policy, keep endnotes short and stable."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.AUTHOR_DATE_PLUS_NOTES", "title": "Use author-date citations with notes for commentary when appropriate", "source_refs": ["CMOS18 \u00a713.53 p801 (scan p823)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "To reduce heavy annotation, consider author-date citations for sources and reserve notes for substantive commentary.", "rationale": "Separating sources and commentary keeps pages readable.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date", "notes"], "keywords": ["author-date", "commentary notes", "hybrid system"], "dependencies": [], "exceptions": ["Projects that require full notes for sources should keep the notes system consistent."], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.UNNUMBERED.NOT_FOR_SOURCES", "title": "Use unnumbered notes only for non-source material", "source_refs": ["CMOS18 \u00a713.55 p801 (scan p823)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Unnumbered notes should be reserved for brief explanatory or acknowledgment material, not for source citations.", "rationale": "Source citations must be traceable and countable.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "notes"], "keywords": ["unnumbered notes", "acknowledgments"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.SOURCE_NOTES.REPRINTS", "title": "Cite original sources for reprints and previously published material", "source_refs": ["CMOS18 \u00a713.58 p805 (scan p827)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "For reprints or previously published material, include source notes that identify the original publication details.", "rationale": "Readers need the primary source to verify provenance.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "notes"], "keywords": ["reprints", "source notes", "original publication"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.NOTES.AVOID_OVERLONG", "title": "Avoid overlong notes; integrate lengthy commentary into the text", "source_refs": ["CMOS18 \u00a713.60 p807 (scan p829)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "If notes become long and discursive, rewrite or integrate the material into the main text and keep notes focused.", "rationale": "Overlong notes disrupt reading and layout.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "notes"], "keywords": ["overlong notes", "discursive notes"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_AUTHOR.ORDER", "title": "Order multiple works by the same author consistently", "source_refs": ["CMOS18 \u00a713.70 p816 (scan p838)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When listing multiple works by the same author, order them consistently (e.g., by title or by date) per the chosen policy.", "rationale": "Consistent ordering reduces lookup time.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["same author", "ordering", "bibliography"], "dependencies": [], "exceptions": ["Chronological ordering may be preferred in historical bibliographies."], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.REPEATED_NAMES.THREE_EM_DASH", "title": "Use a 3-em dash for repeated author names when allowed", "source_refs": ["CMOS18 \u00a713.73 p818 (scan p840)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When policy allows, replace repeated author names in consecutive bibliography entries with a 3-em dash; use it consistently or not at all.", "rationale": "Consistent repetition handling keeps lists compact and clear.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography", "repeated_names"], "keywords": ["3-em dash", "repeated names", "bibliography"], "dependencies": [], "exceptions": ["Some house styles require full names on every entry."], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.CONSISTENT_FORM", "title": "Use a consistent author name form across entries", "source_refs": ["CMOS18 \u00a713.74 p819 (scan p841)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Use a consistent form of the author name across all entries for that person.", "rationale": "Inconsistent name forms fragment an author's works.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["author name", "consistency"], "dependencies": [], "exceptions": ["If a name change is documented, include cross-references."], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.PUBLISHED_FORM", "title": "Respect the published form of an author's name", "source_refs": ["CMOS18 \u00a713.75 p819 (scan p841)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Use the form of the author's name as it appears in the source when practical, and do not invent expansions without evidence.", "rationale": "Preserving the published form reduces misattribution.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["author name", "published form"], "dependencies": [], "exceptions": ["If a house style requires standardization, document the choice."], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.AUTHOR_NAME.INITIALS_PREFERRED", "title": "Honor authors who publish under initials", "source_refs": ["CMOS18 \u00a713.76 p820 (scan p842)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "If an author is known to publish under initials, keep those initials rather than expanding to full names.", "rationale": "Respecting the author's preferred form avoids confusion.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["initials", "author name"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.MULTI_AUTHORS.ORDER", "title": "Preserve author order for multi-author works", "source_refs": ["CMOS18 \u00a713.78 p821 (scan p843)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "List multiple authors in the order given in the source and keep the formatting consistent.", "rationale": "Author order is part of the citation identity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["multiple authors", "author order"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.SAME_SURNAME.DISAMBIGUATE", "title": "Disambiguate authors with the same surname", "source_refs": ["CMOS18 \u00a713.79 p821 (scan p843)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When different authors share a surname, include initials or full given names to disambiguate.", "rationale": "Disambiguation prevents misattribution.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["same surname", "disambiguation"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.NO_AUTHOR.TITLE_LEAD", "title": "Use the title to lead entries without authors", "source_refs": ["CMOS18 \u00a713.81 p822 (scan p844)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "If no author is listed, start the entry with the title and alphabetize by that title.", "rationale": "Title-led entries are the only way to locate anonymous works.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["anonymous works", "title lead"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.PSEUDONYMS.CONSISTENT", "title": "Treat pseudonyms as author names and use them consistently", "source_refs": ["CMOS18 \u00a713.82 p823 (scan p845)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Treat pseudonyms as the author name and use the same pseudonym form consistently across entries.", "rationale": "Consistency makes pseudonymous works discoverable.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["pseudonyms", "author name"], "dependencies": [], "exceptions": ["If a house style prefers real names, add a cross-reference."], "status": "active"} +{"id": "CMOS.CITATIONS.BIBLIOGRAPHY.ALT_NAMES.CROSSREF", "title": "Add cross-references for authors with multiple real names", "source_refs": ["CMOS18 \u00a713.84 p824 (scan p846)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When an author publishes under multiple real names, add cross-references so readers can connect the identities.", "rationale": "Cross-references prevent fragmentation across name variants.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "bibliography"], "keywords": ["name variants", "cross-reference"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.ALPHABETICAL", "title": "Alphabetize author-date reference list entries", "source_refs": ["CMOS18 \u00a713.112 p838 (scan p860)"], "category": "citations", "severity": "must", "applies_to": "all", "rule_text": "Arrange author-date reference list entries alphabetically by author (or by title when no author is present).", "rationale": "Alphabetical order is the primary access method for author-date lists.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date", "reference_list"], "keywords": ["reference list", "alphabetical"], "dependencies": [], "exceptions": ["If a governing standard mandates a different order, document the override."], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.REFERENCE_LIST.REPEATED_NAMES", "title": "Handle repeated author names consistently in author-date lists", "source_refs": ["CMOS18 \u00a713.113 p838 (scan p860)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "If the style uses a repeated-name dash in reference lists, apply it consistently to consecutive entries by the same author(s).", "rationale": "Consistent repetition handling avoids list ambiguity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date", "reference_list"], "keywords": ["repeated names", "author-date"], "dependencies": [], "exceptions": ["Some house styles require repeating the full name."], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.LOCATORS", "title": "Include locators for specific passages in author-date citations", "source_refs": ["CMOS18 \u00a713.117 p840 (scan p862)"], "category": "citations", "severity": "must", "applies_to": "all", "rule_text": "When citing a specific passage in author-date style, include a locator such as a page or section number after the year.", "rationale": "Locators make claims verifiable.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date", "locators"], "keywords": ["page numbers", "locator", "author-date"], "dependencies": [], "exceptions": ["Sources without stable pagination should use section or paragraph markers."], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.EXTRA_INFO", "title": "Add extra information to author-date citations only when needed", "source_refs": ["CMOS18 \u00a713.118 p841 (scan p863)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Add clarifying information inside author-date citations only when needed (e.g., edition, chapter, or other disambiguators).", "rationale": "Extra details should aid clarity, not clutter.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date"], "keywords": ["disambiguation", "author-date"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.PLACEMENT", "title": "Place author-date citations where they least interrupt the sentence", "source_refs": ["CMOS18 \u00a713.119 p841 (scan p863)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Place author-date citations in a position that does not interrupt the sentence more than necessary, typically at the end of the relevant clause.", "rationale": "Better placement improves readability.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date"], "keywords": ["citation placement", "author-date"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.DIRECT_QUOTES", "title": "Direct quotes in author-date style require a locator", "source_refs": ["CMOS18 \u00a713.120 p842 (scan p864)"], "category": "citations", "severity": "must", "applies_to": "all", "rule_text": "For direct quotations in author-date style, include a locator with the citation.", "rationale": "Quoted text must be verifiable.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date", "locators"], "keywords": ["direct quote", "author-date", "locator"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.AUTHOR_DATE.IN_TEXT.MULTI_SOURCES", "title": "Separate multiple sources clearly in author-date citations", "source_refs": ["CMOS18 \u00a713.124 p844 (scan p866)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When listing multiple sources in one author-date citation, separate them clearly (e.g., with semicolons) and use a consistent order.", "rationale": "Clear separation avoids misreading combined citations.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "author_date"], "keywords": ["multiple sources", "author-date"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.ONLINE.URLS.STABLE", "title": "Use stable URLs for online sources", "source_refs": ["CMOS18 \u00a713.6 p778 (scan p800)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "For online sources, use stable URLs and avoid transient session or search-result links.", "rationale": "Stable URLs reduce link rot and aid verification.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["online_sources", "manual_checklist=true"], "keywords": ["URLs", "online sources", "link rot"], "dependencies": [], "exceptions": ["If no stable URL exists, include the best available URL and an access date."], "status": "active"} +{"id": "CMOS.CITATIONS.ONLINE.PERMALINKS.PREFERRED", "title": "Prefer permalinks or stable identifiers for online sources", "source_refs": ["CMOS18 \u00a713.8 p779 (scan p801)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Prefer permalinks or stable identifiers for online sources when available instead of temporary URLs.", "rationale": "Permalinks are more likely to survive over time.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["online_sources", "manual_checklist=true"], "keywords": ["permalink", "stable identifier"], "dependencies": [], "exceptions": ["When only a temporary URL is available, record an access date."], "status": "active"} +{"id": "CMOS.CITATIONS.ONLINE.VERSION.CITE_RIGHT_VERSION", "title": "Cite the specific version consulted for online sources", "source_refs": ["CMOS18 \u00a713.14 p782 (scan p804)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "When multiple versions exist, cite the specific version consulted and include version or edition information when available.", "rationale": "Versioning prevents disputes when content changes.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["online_sources", "manual_checklist=true"], "keywords": ["version", "online sources"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.ONLINE.REVISION_DATE.DISTINCT", "title": "Distinguish revision dates from access dates", "source_refs": ["CMOS18 \u00a713.16 p782 (scan p804)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "If a source provides a revision or last-modified date, treat it separately from the access date and include it when relevant.", "rationale": "Revision dates clarify which version was consulted.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["online_sources", "manual_checklist=true"], "keywords": ["revision date", "access date"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.RESEARCH.METADATA.CAPTURE_EARLY", "title": "Capture citation metadata during research", "source_refs": ["CMOS18 \u00a713.13 p781 (scan p803)"], "category": "citations", "severity": "should", "applies_to": "all", "rule_text": "Capture full citation metadata during research (author, title, publication facts, identifiers) rather than reconstructing it late.", "rationale": "Early capture prevents missing or incorrect details.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "research"], "keywords": ["metadata", "research", "citation management"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.CITATIONS.DEGRADED.NOTE_MARKERS.REPAIR", "title": "Repair degraded note markers during ingestion", "source_refs": ["CMOS18 \u00a713.27 p787 (scan p809)"], "category": "citations", "severity": "warn", "applies_to": "md", "rule_text": "When ingesting OCR or hard-wrapped text, normalize mangled note markers (e.g., split digits or bracketed numbers) into valid footnote syntax and flag ambiguous cases.", "rationale": "Degraded inputs often corrupt note markers and break resolution.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect likely footnote markers corrupted by line breaks or brackets and suggest repairs.", "tags": ["notes", "degraded_input", "note_markers"], "keywords": ["OCR", "hard wrap", "note markers"], "dependencies": [], "exceptions": ["Skip conversion inside code blocks or literal number lists."], "status": "active"} +{"id": "CMOS.CITATIONS.DEGRADED.URL_LINEBREAKS.NORMALIZE", "title": "Restore URLs broken by line wraps in citations", "source_refs": ["CMOS18 \u00a713.6 p778 (scan p800)"], "category": "citations", "severity": "warn", "applies_to": "md", "rule_text": "When citations include URLs broken by line wraps or soft hyphens, restore the URL before publication and flag uncertain repairs.", "rationale": "Broken URLs undermine source verification.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect soft-hyphen or line-broken URLs in citation contexts and suggest recombining.", "tags": ["online_sources", "degraded_input"], "keywords": ["broken URLs", "line breaks", "soft hyphen"], "dependencies": [], "exceptions": ["Do not rewrite URLs in code blocks or literal data extracts."], "status": "active"} diff --git a/spec/rules/code/v1_code_001.ndjson b/spec/rules/code/v1_code_001.ndjson new file mode 100644 index 0000000..50ba73b --- /dev/null +++ b/spec/rules/code/v1_code_001.ndjson @@ -0,0 +1,4 @@ +{"id":"HOUSE.CODE.INLINE.MONO_BACKTICKS","title":"Inline code should use backticks and monospace rendering","source_refs":["HOUSE §QA.CODE_OVERFLOW p2"],"category":"code","severity":"should","applies_to":"all","rule_text":"Render inline code as monospace and ensure it is delimited consistently (e.g., backticks in Markdown) so it is visually distinct from prose.","rationale":"Code tokens should be scannable and unambiguous.","enforcement":"lint","autofix":"suggest","autofix_notes":"Detect common inline-code patterns lacking backticks and suggest wrapping.","tags":["code"],"keywords":["inline code","monospace","backticks"],"dependencies":[],"exceptions":["Do not force backticks inside already-monospace contexts like code blocks."],"status":"active"} +{"id":"HOUSE.CODE.BLOCKS.LANGUAGE_TAGS.PREFERRED","title":"Code blocks should include a language tag when known","source_refs":["HOUSE §QA.CODE_OVERFLOW p2"],"category":"code","severity":"warn","applies_to":"md","rule_text":"Prefer language-tagged code fences (e.g., ```json) when the language is known; untagged blocks reduce syntax highlighting and reviewer comprehension.","rationale":"Language tags improve readability and tooling.","enforcement":"lint","autofix":"suggest","autofix_notes":"Warn on untagged code fences and suggest a language where it can be inferred safely (otherwise leave untagged).","tags":["code"],"keywords":["code fence","language tag","syntax highlighting"],"dependencies":[],"exceptions":["If language cannot be inferred safely, keep untagged."],"status":"active"} +{"id":"HOUSE.CODE.BLOCKS.NO_CLIPPING","title":"Code blocks must not clip; handle overflow explicitly","source_refs":["HOUSE §QA.CODE_OVERFLOW p2"],"category":"code","severity":"must","applies_to":"pdf","rule_text":"If a code block exceeds the measure, the renderer must wrap or apply bounded shrink per profile; it must never clip code.","rationale":"Clipped code is a correctness failure.","enforcement":"postrender","autofix":"reflow","autofix_notes":"If code overflow is detected, re-render using wrap-then-shrink policy within profile limits.","tags":["overflow","code"],"keywords":["code overflow","clipping","wrap"],"dependencies":[],"exceptions":["For print targets where wrapping harms meaning, allow a visible “scroll indicator” affordance if supported."],"status":"active"} +{"id":"HOUSE.CODE.BLOCKS.WRAP_POLICY","title":"Prefer wrapping long code lines for screen PDFs","source_refs":["HOUSE §QA.CODE_OVERFLOW p2"],"category":"code","severity":"should","applies_to":"pdf","rule_text":"For screen-first PDFs, prefer wrapping long code lines over horizontal scrolling; apply bounded shrink only as a secondary measure.","rationale":"Readers should not lose content off-screen.","enforcement":"typeset","autofix":"reflow","autofix_notes":"Apply profile token policy wrap_then_shrink_minor where available.","tags":["code","wrap"],"keywords":["code wrapping","screen pdf","overflow policy"],"dependencies":[],"exceptions":["For fixed-format standards, preserve lines when wrapping changes semantics and the profile allows it."],"status":"active"} diff --git a/spec/rules/headings/v1_headings_001.ndjson b/spec/rules/headings/v1_headings_001.ndjson new file mode 100644 index 0000000..6992665 --- /dev/null +++ b/spec/rules/headings/v1_headings_001.ndjson @@ -0,0 +1,12 @@ +{"id":"BRING.HEADINGS.SUBHEADS.STYLE_CONTRIBUTES_TO_WHOLE","title":"Headings should contribute to the style of the whole","source_refs":["BRING §4.2 p65 (scan p64)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"Choose heading forms (size, weight, case, alignment) that reinforce the document’s overall typographic voice rather than fighting it.","rationale":"Headings are part of the page texture, not just labels.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Report when heading styles diverge sharply (e.g., mixed alignments/case patterns) and suggest normalizing to a small set of styles.","tags":["headings","consistency"],"keywords":["headings","subheads","hierarchy","style","consistency"],"dependencies":[],"exceptions":["Different heading styles may be appropriate for distinct document parts (e.g., appendices) if the hierarchy is explicit."],"status":"active"} +{"id":"BRING.HEADINGS.SUBHEADS.CROSSHEADS_SIDEHEADS.CHOOSE_DELIBERATELY","title":"Choose a consistent symmetric vs asymmetric heading scheme","source_refs":["BRING §4.2.1 p65 (scan p64)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"Decide whether headings are primarily symmetric (centered) or asymmetric (sideheads), and keep the choice consistent across levels unless there is an explicit hierarchy reason to vary.","rationale":"Stable alignment choices reduce cognitive load.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Detect mixed heading alignments within the same level and suggest normalizing alignment per level.","tags":["headings","alignment"],"keywords":["crossheads","sideheads","alignment","centered","flush left"],"dependencies":[],"exceptions":["A single contrasting style can be used at the top or bottom of a heading hierarchy when applied consistently."],"status":"active"} +{"id":"BRING.HEADINGS.SUBHEADS.RIGHT_SIDEHEADS.VISIBILITY","title":"Right-aligned headings require strong visual emphasis","source_refs":["BRING §4.2.1 p65 (scan p64)"],"category":"headings","severity":"warn","applies_to":"all","rule_text":"If using right-aligned headings, ensure they have enough size/weight/spacing to be reliably noticed and do not disappear into the margin.","rationale":"Right-aligned one-line headings are easy to miss.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Flag right-aligned headings below a size/weight threshold relative to body text.","tags":["headings","alignment","visibility"],"keywords":["right aligned","heading visibility","weight","size"],"dependencies":[],"exceptions":["Short right-aligned headings can work as primary headings in generous layouts with clear hierarchy."],"status":"active"} +{"id":"BRING.HEADINGS.SUBHEADS.MARGIN_HEADS.RUNNING_SHOULDERHEADS","title":"Margin headings can replace size for prominence","source_refs":["BRING §4.2.1 p65 (scan p64)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"When you need prominence without making headings large, consider shifting headings into the margin (margin heads) rather than escalating size and weight.","rationale":"Margin placement can create emphasis without disrupting rhythm.","enforcement":"typeset","autofix":"none","autofix_notes":"","tags":["headings","layout"],"keywords":["margin heads","running heads","shoulderheads"],"dependencies":[],"exceptions":["Margin heads require sufficient margin width and should not collide with running headers/footers."],"status":"active"} +{"id":"BRING.HEADINGS.SUBHEADS.LEVELS.AS_MANY_AS_NEEDED","title":"Use as many heading levels as needed, no more","source_refs":["BRING §4.2.1 p65 (scan p64)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"Keep the number of heading levels to the minimum that expresses the document’s structure; avoid inventing extra levels that are not meaningfully distinct.","rationale":"Over-deep hierarchies create noise and ambiguity.","enforcement":"lint","autofix":"suggest","autofix_notes":"Warn when heading depth exceeds a configured maximum for the profile (e.g., >4 levels) and suggest consolidation.","tags":["headings","hierarchy"],"keywords":["heading levels","structure","hierarchy depth"],"dependencies":[],"exceptions":["Long technical standards may require deeper hierarchies; prefer disambiguating labels over visual noise."],"status":"active"} +{"id":"BRING.HEADINGS.SUBHEADS.MIXING_SYMM_ASYMM.AVOID_HAPHAZARD","title":"Avoid haphazard mixing of symmetric and asymmetric subheads","source_refs":["BRING §4.2.2 p65 (scan p64)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"If you mix symmetric and asymmetric subheads, do so systematically; avoid ad hoc alternation that creates both stylistic and logical confusion.","rationale":"Inconsistent heading geometry undermines hierarchy.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Detect alternating centered/flush styles within a single level and suggest a single dominant scheme.","tags":["headings","consistency"],"keywords":["subheads","symmetry","asymmetry","consistency"],"dependencies":[],"exceptions":["A small number of deliberate, consistently applied combinations can expand available hierarchy levels."],"status":"active"} +{"id":"BRING.HEADINGS.SUBHEADS.MIXING.HIERARCHY_PLACEMENT","title":"If mixing styles, place the contrast at hierarchy extremes","source_refs":["BRING §4.2.2 p65 (scan p64)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"When adding a contrasting heading style into a series, prefer placing it at the top or bottom of the hierarchy rather than in the middle layers.","rationale":"Mid-hierarchy variation is easiest to misread.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","headings"],"keywords":["heading hierarchy","contrast","placement"],"dependencies":[],"exceptions":["If a middle layer must be distinct, use other channels (spacing, size, weight) consistently."],"status":"active"} +{"id":"CMOS.HEADINGS.RUNNING_HEADS.DEFINITION","title":"Define and use running heads consistently","source_refs":["CMOS18 §1.10 p8 (scan p30)"],"category":"headings","severity":"should","applies_to":"pdf","rule_text":"Treat running heads as navigational headings at the top of pages; keep their style and content consistent with the document’s division and heading scheme.","rationale":"Running heads help readers orient in longer documents.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","running_heads"],"keywords":["running heads","navigation","page headers"],"dependencies":[],"exceptions":["Short memos and slide-like PDFs may omit running heads entirely."],"status":"active"} +{"id":"CMOS.HEADINGS.RUNNING_HEADS.NAVIGATION_SCOPE","title":"Running heads should help locate the right section","source_refs":["CMOS18 §1.10 p8 (scan p30)"],"category":"headings","severity":"should","applies_to":"pdf","rule_text":"If you include running heads, ensure they convey useful location context (e.g., section/chapter range) rather than repeating a generic label that provides no navigation value.","rationale":"Navigation elements should reduce search time.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","running_heads"],"keywords":["running heads","section context","navigation"],"dependencies":[],"exceptions":["Very short documents may use a single running head for branding rather than navigation."],"status":"active"} +{"id":"CMOS.HEADINGS.DIVISIONS.CHAPTERS.MULTIAUTHOR","title":"Multi-author chapter divisions need consistent chapter framing","source_refs":["CMOS18 §1.56 p33 (scan p55)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"For multi-author works, use a consistent chapter division pattern so readers can recognize chapter boundaries and attribution placement.","rationale":"Consistent division framing prevents structural ambiguity.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","frontmatter"],"keywords":["multi-author","chapters","divisions","attribution"],"dependencies":[],"exceptions":["Single-author docs can keep chapter framing minimal if headings are unambiguous."],"status":"active"} +{"id":"CMOS.HEADINGS.DIVISIONS.LETTERS_DIARIES.HEADINGS","title":"Letters and diary entries use dates and signatures as headings","source_refs":["CMOS18 §1.58 p33 (scan p55)"],"category":"headings","severity":"should","applies_to":"all","rule_text":"When presenting letters or diary entries, treat dates and signatures (or both) as part of the heading structure and keep them consistently formatted.","rationale":"Document genres carry their own navigational conventions.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true"],"keywords":["letters","diaries","dates","headings"],"dependencies":[],"exceptions":["If entries are excerpted, note omissions explicitly rather than silently changing the heading scheme."],"status":"active"} +{"id":"HOUSE.HEADINGS.KEEPS.AVOID_STRANDED","title":"Avoid stranded headings via keep-with-next constraints","source_refs":["HOUSE §QA.KEEPS p1"],"category":"headings","severity":"must","applies_to":"pdf","rule_text":"A heading must not appear at the bottom of a page/column without sufficient following content; enforce keep-with-next rules at render time.","rationale":"Stranded headings harm readability and signal low-quality pagination.","enforcement":"postrender","autofix":"reflow","autofix_notes":"If a heading is stranded, reflow by moving the heading to the next page (or adjusting keeps within profile limits).","tags":["keep_constraints","widows_orphans"],"keywords":["keep with next","stranded heading","pagination"],"dependencies":[],"exceptions":["In narrow slide-like formats, a heading may be alone if it functions as a section break page."],"status":"active"} diff --git a/spec/rules/headings/v1_headings_002.ndjson b/spec/rules/headings/v1_headings_002.ndjson new file mode 100644 index 0000000..00c1c1e --- /dev/null +++ b/spec/rules/headings/v1_headings_002.ndjson @@ -0,0 +1,20 @@ +{"id": "BRING.HEADINGS.STRUCTURE.MATCH_TEXT_LOGIC", "title": "Align heading hierarchy with text structure", "source_refs": ["BRING \u00a71.2.2 p20 (scan p19)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Make the heading hierarchy reflect the logical structure of the text rather than arbitrary styling.", "rationale": "Structural alignment improves navigation and comprehension.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings", "hierarchy"], "keywords": ["heading hierarchy", "structure"], "dependencies": [], "exceptions": ["Editorial or creative layouts may require intentional deviations."], "status": "active"} +{"id": "BRING.HEADINGS.RELATED_ELEMENTS.COHERENT", "title": "Keep headings coherent with related elements", "source_refs": ["BRING \u00a71.2.3 p21 (scan p20)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Keep headings, captions, notes, and other paratext in a coherent visual system so relationships are clear.", "rationale": "Coherent styling helps readers connect related elements.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings"], "keywords": ["paratext", "coherence"], "dependencies": [], "exceptions": ["Special sections may use a distinct style if the break is explicit."], "status": "active"} +{"id": "BRING.HEADINGS.DEGRADED.INFER_STRUCTURE", "title": "Infer heading structure cautiously in degraded inputs", "source_refs": ["BRING \u00a71.2.2 p20 (scan p19)"], "category": "headings", "severity": "warn", "applies_to": "md", "rule_text": "When headings are missing due to OCR or imported text, infer structure cautiously and flag uncertain cases for review.", "rationale": "Mis-inferred headings distort document structure.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Suggest heading inference based on typography or numbering and mark low-confidence cases.", "tags": ["degraded_input", "headings"], "keywords": ["OCR", "heading inference"], "dependencies": [], "exceptions": ["Do not infer headings inside code blocks or tables."], "status": "active"} +{"id": "BRING.HEADINGS.CAPITALIZATION.CONSISTENT", "title": "Use consistent capitalization within each heading level", "source_refs": ["BRING \u00a74.2 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "md", "rule_text": "Use a consistent capitalization style within each heading level (headline or sentence case).", "rationale": "Consistent case signals hierarchy and improves scanability.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect mixed capitalization styles within the same heading level and suggest normalization.", "tags": ["headings", "capitalization"], "keywords": ["heading case", "capitalization"], "dependencies": [], "exceptions": ["Quoted titles may preserve the source capitalization."], "status": "active"} +{"id": "BRING.HEADINGS.WEIGHT_SIZE.HIERARCHY_SCALE", "title": "Scale heading size and weight with hierarchy", "source_refs": ["BRING \u00a74.2 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Scale size and weight so higher-level headings read as more prominent than lower levels.", "rationale": "Clear scaling prevents hierarchy confusion.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Recommend a size and weight scale per heading level and validate in output.", "tags": ["headings", "hierarchy", "typeset"], "keywords": ["size", "weight", "hierarchy"], "dependencies": [], "exceptions": ["Short documents may compress the scale if levels are few."], "status": "active"} +{"id": "BRING.HEADINGS.SPACING.VERTICAL_RHYTHM", "title": "Keep heading spacing consistent", "source_refs": ["BRING \u00a74.2 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Maintain consistent spacing above and below headings to preserve vertical rhythm.", "rationale": "Consistent spacing improves scanability and rhythm.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply per-level spacing tokens and re-render to confirm consistent rhythm.", "tags": ["headings", "spacing", "typeset"], "keywords": ["spacing", "vertical rhythm"], "dependencies": [], "exceptions": ["Section breaks may intentionally use extra space."], "status": "active"} +{"id": "BRING.HEADINGS.STYLE.PALETTE_LIMIT", "title": "Limit the number of distinct heading styles", "source_refs": ["BRING \u00a74.2 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Limit the number of distinct heading styles and reuse a small palette across levels.", "rationale": "Too many styles reduce hierarchy clarity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings"], "keywords": ["style palette", "heading styles"], "dependencies": [], "exceptions": ["Appendices or special sections may add a distinct style if labeled."], "status": "active"} +{"id": "BRING.HEADINGS.RUN_IN.STANDALONE.CONSISTENT", "title": "Choose run-in or stand-alone heads per level", "source_refs": ["BRING \u00a74.2.1 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Choose run-in or stand-alone headings per level and apply the choice consistently.", "rationale": "Consistent heading form improves navigation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings"], "keywords": ["run-in heads", "stand-alone heads"], "dependencies": [], "exceptions": ["Short sections may use run-in heads for compactness."], "status": "active"} +{"id": "BRING.HEADINGS.ALIGNMENT.CONSISTENT_LEVEL", "title": "Keep alignment consistent within each heading level", "source_refs": ["BRING \u00a74.2.1 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Keep alignment consistent within a heading level (flush left, centered, or sidehead).", "rationale": "Consistent alignment reinforces hierarchy.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Detect mixed alignments within a level and suggest a single alignment per profile.", "tags": ["headings", "alignment", "typeset"], "keywords": ["alignment", "headings"], "dependencies": [], "exceptions": ["A deliberate contrast can be used for a single level if documented."], "status": "active"} +{"id": "BRING.HEADINGS.MARGIN_HEADS.CLEAR_GUTTER", "title": "Ensure margin heads have enough gutter space", "source_refs": ["BRING \u00a74.2.1 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Ensure margin or side heads have enough gutter space and do not collide with body text.", "rationale": "Crowded margins undermine legibility.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Adjust margin width or sidehead offset to avoid collisions.", "tags": ["headings", "margin_heads", "typeset"], "keywords": ["margin heads", "sideheads"], "dependencies": [], "exceptions": ["Very narrow formats may need a different heading style."], "status": "active"} +{"id": "BRING.HEADINGS.HIERARCHY.NO_SKIPPED_LEVELS", "title": "Avoid skipping heading levels", "source_refs": ["BRING \u00a74.2.1 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "md", "rule_text": "Do not skip heading levels (e.g., jump from H1 to H3) without structural justification.", "rationale": "Skipping levels confuses hierarchy and navigation.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect level jumps greater than one and flag for review.", "tags": ["headings", "hierarchy"], "keywords": ["skipped levels", "heading depth"], "dependencies": [], "exceptions": ["Legal or technical standards may mandate specific numbering."], "status": "active"} +{"id": "BRING.HEADINGS.CONTRAST.CLEAR_HIERARCHY", "title": "Ensure clear contrast between heading levels", "source_refs": ["BRING \u00a74.2.2 p65 (scan p64)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Ensure contrast between heading levels is clear and systematic across the document.", "rationale": "Clear contrast supports hierarchy recognition.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings"], "keywords": ["contrast", "hierarchy"], "dependencies": [], "exceptions": ["Minimalist designs may use subtle contrast but must remain legible."], "status": "active"} +{"id": "BRING.HEADINGS.PARAGRAPH_INDENT.AFTER_HEAD_NONE", "title": "Omit paragraph indents after headings", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Do not indent the paragraph immediately following a heading; use spacing to signal the break.", "rationale": "Indenting after a heading is redundant and can look cramped.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Remove first-line indent after headings and rely on heading spacing.", "tags": ["headings", "typeset"], "keywords": ["indentation", "after heading"], "dependencies": [], "exceptions": ["Run-in heads may require different treatment."], "status": "active"} +{"id": "BRING.HEADINGS.BLOCK_QUOTE.SPACING_AROUND", "title": "Maintain hierarchy when a section opens with a block quote", "source_refs": ["BRING \u00a72.3.3 p40 (scan p39)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "If a section opens with a block quote, adjust spacing so the heading and quote maintain a clear hierarchy.", "rationale": "Spacing keeps block quotes from overpowering headings.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Tune spacing between headings and block quotes to preserve hierarchy.", "tags": ["headings", "typeset"], "keywords": ["block quotes", "spacing"], "dependencies": [], "exceptions": ["Epigraph pages may intentionally feature large spacing differences."], "status": "active"} +{"id": "CMOS.HEADINGS.RUNNING_HEADS.LENGTH_SHORT", "title": "Keep running heads short", "source_refs": ["CMOS18 \u00a71.10 p8 (scan p30)"], "category": "headings", "severity": "should", "applies_to": "pdf", "rule_text": "Keep running heads short enough to fit comfortably with folios and avoid crowding.", "rationale": "Short heads maintain a clean page header.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "running_heads"], "keywords": ["running heads", "length"], "dependencies": [], "exceptions": ["Short documents may omit running heads entirely."], "status": "active"} +{"id": "CMOS.HEADINGS.RUNNING_HEADS.DIVISION_MATCH", "title": "Keep running heads aligned with the current division", "source_refs": ["CMOS18 \u00a71.10 p8 (scan p30)"], "category": "headings", "severity": "should", "applies_to": "pdf", "rule_text": "Update running heads to reflect the current division or section; avoid stale or generic heads.", "rationale": "Accurate running heads improve navigation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "running_heads"], "keywords": ["running heads", "navigation"], "dependencies": [], "exceptions": ["Very short sections may reuse a higher-level head by design."], "status": "active"} +{"id": "CMOS.HEADINGS.MULTIAUTHOR.CHAPTER_NUMBERING", "title": "Use consistent chapter numbering in multi-author volumes", "source_refs": ["CMOS18 \u00a71.56 p33 (scan p55)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Use consistent chapter numbering and titling patterns across multi-author volumes.", "rationale": "Consistency helps readers recognize chapter boundaries.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings", "multi_author"], "keywords": ["chapter numbering", "multi-author"], "dependencies": [], "exceptions": ["Introductions or forewords may follow a different pattern if labeled."], "status": "active"} +{"id": "CMOS.HEADINGS.MULTIAUTHOR.AUTHOR_ATTRIBUTION_PLACEMENT", "title": "Place author attribution consistently in multi-author works", "source_refs": ["CMOS18 \u00a71.56 p33 (scan p55)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Place author names or affiliations in a consistent position relative to chapter headings.", "rationale": "Consistent attribution supports clear authorship cues.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings", "multi_author"], "keywords": ["author attribution", "chapter headings"], "dependencies": [], "exceptions": ["Contributed essays may require special attribution blocks."], "status": "active"} +{"id": "CMOS.HEADINGS.LETTERS_DIARIES.DATELINE_FORMAT", "title": "Format datelines consistently in letters and diaries", "source_refs": ["CMOS18 \u00a71.58 p33 (scan p55)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "In letters and diaries, format datelines consistently as part of the heading block.", "rationale": "Datelines function as navigational headings in these genres.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings"], "keywords": ["dateline", "letters", "diaries"], "dependencies": [], "exceptions": ["Excerpts may need contextual notes if the dateline is missing."], "status": "active"} +{"id": "CMOS.HEADINGS.LETTERS_DIARIES.SIGNATURE_FORMAT", "title": "Format signatures consistently in letters and diaries", "source_refs": ["CMOS18 \u00a71.58 p33 (scan p55)"], "category": "headings", "severity": "should", "applies_to": "all", "rule_text": "Keep signatures in letters and diary entries formatted consistently and distinct from body text.", "rationale": "Consistent signatures preserve genre conventions.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "headings"], "keywords": ["signatures", "letters", "diaries"], "dependencies": [], "exceptions": ["Some diary formats omit signatures by design."], "status": "active"} diff --git a/spec/rules/layout/v1_layout_001.ndjson b/spec/rules/layout/v1_layout_001.ndjson new file mode 100644 index 0000000..9418fde --- /dev/null +++ b/spec/rules/layout/v1_layout_001.ndjson @@ -0,0 +1,12 @@ +{"id":"BRING.LAYOUT.MEASURE.COMFORTABLE_RANGE","title":"Choose a comfortable line length (measure)","source_refs":["BRING §2.1.2 p26"],"category":"layout","severity":"must","applies_to":"all","rule_text":"Set body text line length to a readable range; use a clear target band rather than leaving measure accidental. Treat narrow multi-column layouts as a separate measure regime.","rationale":"Line length strongly controls reading speed, fatigue, and hyphenation pressure.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Recommend a profile-specific max-width/column policy and validate against measured characters-per-line targets.","tags":["measure","line_length"],"keywords":["measure","line length","characters per line","columns"],"dependencies":[],"exceptions":["Some genres (tables, code) require different measures; handle via style tokens."],"status":"active"} +{"id":"BRING.LAYOUT.JUSTIFICATION.RAGGED_RIGHT_IF_NEEDED","title":"Prefer ragged-right when good spacing cannot be maintained","source_refs":["BRING §2.1.3 p27"],"category":"layout","severity":"should","applies_to":"all","rule_text":"If fully-justified text cannot maintain even spacing without visible artifacts, prefer a ragged-right setting over forcing justification that produces rivers and distortion.","rationale":"Uneven justification artifacts are more distracting than a well-managed rag.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Suggest switching justification mode or adjusting hyphenation/spacing parameters; requires rendered review.","tags":["justification","ragged_right"],"keywords":["ragged right","justified text","rivers"],"dependencies":[],"exceptions":["Print layouts with strict grids may accept tighter justification constraints if artifacts are controlled."],"status":"active"} +{"id":"BRING.LAYOUT.PAGINATION.ORPHANS_AVOID","title":"Avoid orphan lines at the top of a page","source_refs":["BRING §2-4.9 p44"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Avoid starting a page with the last line of a multi-line paragraph; adjust pagination so paragraph continuity is preserved.","rationale":"Orphans interrupt comprehension by separating dependent lines.","enforcement":"postrender","autofix":"reflow","autofix_notes":"When detected, adjust keep/avoid-break settings or move a small amount of content across the page break; requires re-render.","tags":["widows_orphans","pagination"],"keywords":["orphan","page break","paragraph continuity"],"dependencies":[],"exceptions":["Very short pages (e.g., slides) may tolerate this if overflow constraints dominate."],"status":"active"} +{"id":"BRING.LAYOUT.PAGINATION.WIDOWS_AVOID","title":"Avoid widow lines at the bottom of a page","source_refs":["BRING §2-4.9 p44"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Avoid leaving a single short last line of a paragraph isolated at the bottom of a page when it breaks the paragraph’s rhythm; prefer reflow to keep at least a small run of lines together.","rationale":"Widows create visual and semantic discontinuity.","enforcement":"postrender","autofix":"reflow","autofix_notes":"When detected, adjust keep-with-next/avoid-break parameters or move content so paragraph lines balance across pages; requires re-render.","tags":["widows_orphans","pagination"],"keywords":["widow","page break","paragraph"],"dependencies":[],"exceptions":["In dense technical docs, strict overflow constraints may force occasional widows; record as QA warnings."],"status":"active"} +{"id":"BRING.LAYOUT.PAGINATION.BALANCE_FACING_PAGES","title":"Balance facing pages in book-style layouts","source_refs":["BRING §2-4.10 p44"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"In two-sided print layouts, prefer balancing facing pages by shifting small amounts of content rather than leaving large blank areas or uneven spreads.","rationale":"Balanced spreads improve perceived quality and reduce visual distraction.","enforcement":"postrender","autofix":"suggest","autofix_notes":"Suggest enabling facing-page balancing in print profiles; requires a paged-media aware renderer.","tags":["pagination","print_quality"],"keywords":["facing pages","spread","balance"],"dependencies":[],"exceptions":["Screen-first PDFs may disable spread balancing for predictability."],"status":"active"} +{"id":"BRING.LAYOUT.LINEBREAKS.AVOID_SAME_WORD_START","title":"Avoid starting consecutive lines with the same word","source_refs":["BRING §2-4.8 p43"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Avoid line-break patterns that start multiple consecutive lines with the same word (especially in justified text); adjust breaks to reduce obvious repetition at the margin.","rationale":"Repeated line starts create visual ruts and reduce scanability.","enforcement":"postrender","autofix":"suggest","autofix_notes":"Suggest minor reflow (tracking/hyphenation tweaks) when detected; requires re-render to confirm improvement.","tags":["line_breaks","justification"],"keywords":["line break","consecutive lines","repeated word"],"dependencies":[],"exceptions":["Poetry or deliberate parallel structure may override this for meaning."],"status":"active"} +{"id":"BRING.LAYOUT.HYPHENATION.AVOID_NEAR_INTERRUPTION","title":"Avoid hyphen breaks near major interruptions (e.g., page breaks)","source_refs":["BRING §2-4.11 p44"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Avoid hyphenating a word on a line adjacent to a major interruption (such as a page break) when it increases the sense of fragmentation; prefer alternative breaks when practical.","rationale":"Hyphens near hard breaks amplify the perception of chopped text.","enforcement":"postrender","autofix":"suggest","autofix_notes":"Suggest reflow or adjusting hyphenation penalties around breaks; requires paginated layout context.","tags":["hyphenation","pagination"],"keywords":["hyphenation","page break","interruption"],"dependencies":[],"exceptions":["Narrow measures may force hyphenation; record as QA warning when unavoidable."],"status":"active"} +{"id":"BRING.LAYOUT.HYPHENATION.STUB_END_AVOID","title":"Avoid leaving a very short fragment after hyphenation","source_refs":["BRING §2-4.3 p42"],"category":"layout","severity":"should","applies_to":"all","rule_text":"Avoid hyphenation patterns that leave an obviously tiny fragment at the end of a line or start of the next; prefer adjusting breaks or reflow.","rationale":"Stub fragments look accidental and reduce legibility.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Tune hyphenation settings (min fragment sizes, penalties) and confirm in rendering.","tags":["hyphenation"],"keywords":["hyphenation","stub","fragment"],"dependencies":[],"exceptions":["Some languages and narrow columns may require relaxing constraints."],"status":"active"} +{"id":"BRING.LAYOUT.RHYTHM.RULES_SERVE_TEXT","title":"Treat hyphenation/pagination rules as subordinate to readability","source_refs":["BRING §2-4.1 p42"],"category":"layout","severity":"warn","applies_to":"all","rule_text":"Use typographic rules to serve clarity, not to satisfy checklists: if a constraint consistently harms readability in a specific context, document the exception and apply a more appropriate profile.","rationale":"Rigid enforcement can produce worse output than a principled override.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","readability_override"],"keywords":["readability","exceptions","profiles"],"dependencies":[],"exceptions":["Overrides should be traceable (recorded in QA reports and profile diffs)."],"status":"active"} +{"id":"BRING.LAYOUT.MEASURE.MULTICOLUMN_TARGETS","title":"Use distinct measure targets for multi-column layouts","source_refs":["BRING §2.1.2 p26"],"category":"layout","severity":"should","applies_to":"all","rule_text":"When using multiple columns, use a tighter measure target than single-column body text; treat column count as a typographic decision, not a responsive afterthought.","rationale":"Column width affects hyphenation frequency and reading rhythm.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Recommend per-profile column and max-width targets; validate against measured characters-per-line constraints.","tags":["measure","columns"],"keywords":["columns","measure","responsive"],"dependencies":[],"exceptions":["Tables and code blocks may need full-width breaks or scrolling strategies."],"status":"active"} +{"id":"HOUSE.LAYOUT.OVERFLOW.OVERFULL_LINES.REPORT","title":"Detect and constrain overfull lines as a layout failure signal","source_refs":["HOUSE §QA.OVERFLOW p1"],"category":"layout","severity":"must","applies_to":"pdf","rule_text":"Treat overfull lines (content exceeding the intended measure) as a reportable failure; resolve via wrapping, reflow, or bounded font scaling rather than clipping.","rationale":"Overflow is a visible sign of broken layout constraints.","enforcement":"postrender","autofix":"reflow","autofix_notes":"When detected, attempt wrap-first strategies for code/URLs and minor font scaling within configured limits; re-render and re-check.","tags":["overflow","qa_gate"],"keywords":["overfull","overflow","clipping","wrapping"],"dependencies":[],"exceptions":["Some figures or long identifiers may require manual intervention; emit a checklist item when unresolved."],"status":"active"} +{"id":"HOUSE.LAYOUT.PAGINATION.KEEP_WITH_NEXT.HEADINGS","title":"Avoid stranded headings by keeping headings with following content","source_refs":["HOUSE §QA.KEEPS p1"],"category":"layout","severity":"must","applies_to":"pdf","rule_text":"Prevent headings from landing at the bottom of a page without sufficient following content; enforce keep-with-next policies for headings.","rationale":"Stranded headings break navigation and reduce perceived quality.","enforcement":"postrender","autofix":"reflow","autofix_notes":"Apply keep-with-next policies to headings and reflow pagination until headings meet the configured keep threshold.","tags":["keep_constraints","headings","pagination"],"keywords":["stranded heading","keep with next","pagination"],"dependencies":[],"exceptions":["Very short pages may relax keep thresholds via profile override; record in QA reports."],"status":"active"} diff --git a/spec/rules/layout/v1_layout_002.ndjson b/spec/rules/layout/v1_layout_002.ndjson new file mode 100644 index 0000000..862af29 --- /dev/null +++ b/spec/rules/layout/v1_layout_002.ndjson @@ -0,0 +1,30 @@ +{"id": "BRING.LAYOUT.PAGE.FRAME.TEXTBLOCK_BALANCE", "title": "Frame the text block with balanced margins", "source_refs": ["BRING \u00a71.2.5 p23 (scan p22)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Shape the page so the text block is framed by balanced margins; avoid cramped margins that crowd the text.", "rationale": "Balanced margins improve readability and perceived quality.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Suggest margin and text block adjustments to achieve balanced framing.", "tags": ["layout", "margins", "typeset"], "keywords": ["margins", "text block"], "dependencies": [], "exceptions": ["Narrow formats may require tighter margins by necessity."], "status": "active"} +{"id": "BRING.LAYOUT.MARGINS.FACING_PAGES.INNER_OUTER", "title": "Balance inner and outer margins on facing pages", "source_refs": ["BRING \u00a71.2.5 p23 (scan p22)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "In facing-page layouts, keep inner and outer margins balanced to preserve the spread.", "rationale": "Balanced margins create stable spreads and comfortable gutters.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Adjust inner and outer margins to maintain consistent spread balance.", "tags": ["layout", "margins", "typeset"], "keywords": ["facing pages", "gutters"], "dependencies": [], "exceptions": ["Full-bleed designs may intentionally reduce margins."], "status": "active"} +{"id": "BRING.LAYOUT.ELEMENT_RELATIONSHIPS.VISIBLE", "title": "Make relationships between elements visually clear", "source_refs": ["BRING \u00a71.2.5 p23 (scan p22)"], "category": "layout", "severity": "should", "applies_to": "all", "rule_text": "Arrange headings, figures, notes, and tables so their relationship to the text is visually clear.", "rationale": "Clear relationships reduce reader confusion.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["layout", "manual_checklist=true"], "keywords": ["element relationships", "layout clarity"], "dependencies": [], "exceptions": ["Complex technical layouts may require explicit labels instead."], "status": "active"} +{"id": "BRING.LAYOUT.TEXTBLOCK.CONSISTENT_WIDTH", "title": "Keep text block width consistent within sections", "source_refs": ["BRING \u00a71.2.5 p23 (scan p22)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Keep the text block width consistent within a section; avoid jittery shifts in measure.", "rationale": "Consistent measure stabilizes reading rhythm.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Detect measure changes within a section and suggest a consistent width.", "tags": ["layout", "measure", "typeset"], "keywords": ["measure", "text block"], "dependencies": [], "exceptions": ["Sidebars and callouts may use a distinct measure by design."], "status": "active"} +{"id": "BRING.LAYOUT.FLOATS.PLACEMENT.NEAR_REFERENCE", "title": "Place floats near their first reference", "source_refs": ["BRING \u00a71.2.5 p23 (scan p22)"], "category": "layout", "severity": "should", "applies_to": "all", "rule_text": "Place figures and tables close to their first reference to keep reading flow intact.", "rationale": "Distant floats disrupt comprehension.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["layout", "manual_checklist=true"], "keywords": ["floats", "figures", "tables"], "dependencies": [], "exceptions": ["Complex layouts may require grouping figures in a dedicated section."], "status": "active"} +{"id": "BRING.LAYOUT.GRID.ALIGN_ELEMENTS", "title": "Align major elements to a common grid", "source_refs": ["BRING \u00a71.2.5 p23 (scan p22)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Align major elements to a common grid or baseline where practical to keep page texture coherent.", "rationale": "Grid alignment reduces visual jitter.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Use a baseline or layout grid and snap element positions to it.", "tags": ["layout", "grid", "typeset"], "keywords": ["grid", "baseline"], "dependencies": [], "exceptions": ["Illustrations may intentionally break the grid for emphasis."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.TARGET_RANGE_CHARS", "title": "Aim for a readable line length", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Aim for a comfortable line length (roughly 45-75 characters per line) in body text.", "rationale": "Readable line length improves tracking and comprehension.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Suggest measure adjustments to keep lines within target range.", "tags": ["layout", "measure", "typeset"], "keywords": ["line length", "measure"], "dependencies": [], "exceptions": ["Narrow formats may require shorter lines."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.ADJUST_FOR_TYPE_SIZE", "title": "Adjust measure when type size changes", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Adjust measure when type size changes; larger type generally needs a shorter line length.", "rationale": "Measure and type size should stay in balance.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Recommend measure changes based on body text size shifts.", "tags": ["layout", "measure", "typeset"], "keywords": ["type size", "measure"], "dependencies": [], "exceptions": ["Display typography may use different measures for effect."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.AVOID_TOO_LONG", "title": "Avoid overly long lines", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Avoid overly long lines that make tracking to the next line difficult.", "rationale": "Long lines reduce reading speed and accuracy.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Shorten measure or increase column count when lines exceed target range.", "tags": ["layout", "measure", "typeset"], "keywords": ["long lines", "readability"], "dependencies": [], "exceptions": ["Wide tables or code blocks may need special handling."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.AVOID_TOO_SHORT", "title": "Avoid overly short lines", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Avoid overly short lines that create choppy reading; consider columns or layout changes instead.", "rationale": "Very short lines disrupt reading rhythm.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Adjust measure or column count to avoid excessively short lines.", "tags": ["layout", "measure", "typeset"], "keywords": ["short lines", "columns"], "dependencies": [], "exceptions": ["Poetry and narrow marginalia may intentionally use short lines."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.CONSISTENT_WITHIN_SECTION", "title": "Keep measure consistent within sections", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Keep measure consistent within a section unless a structural shift justifies a change.", "rationale": "Consistent measure stabilizes the reading experience.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Detect measure shifts within a section and recommend normalization.", "tags": ["layout", "measure", "typeset"], "keywords": ["measure consistency", "section layout"], "dependencies": [], "exceptions": ["Sidebars or callouts may intentionally vary measure."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.CHANGE_FOR_LISTS", "title": "Adjust measure for lists and sidebars", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Adjust measure or indentation for lists and sidebars so they read as distinct structures.", "rationale": "Distinct measures signal structural changes.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply list and sidebar measure tokens separate from body text.", "tags": ["layout", "measure", "typeset"], "keywords": ["lists", "sidebars", "measure"], "dependencies": [], "exceptions": ["Very short lists may remain at body measure by design."], "status": "active"} +{"id": "BRING.LAYOUT.MEASURE.CODE_BLOCKS.WRAP_POLICY", "title": "Apply a consistent policy for code and long identifiers", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "all", "rule_text": "Set a clear policy for code and long identifiers: wrap, scroll, or adjust measure consistently.", "rationale": "A consistent policy prevents ad hoc overflow fixes.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["layout", "manual_checklist=true"], "keywords": ["code blocks", "overflow"], "dependencies": [], "exceptions": ["Security-sensitive outputs may disable wrapping to preserve exact text."], "status": "active"} +{"id": "BRING.LAYOUT.COLUMNS.BALANCE_LENGTHS", "title": "Balance multi-column lengths", "source_refs": ["BRING \u00a72.1.2 p26 (scan p25)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "When using multiple columns, balance column lengths to avoid awkward white gaps.", "rationale": "Balanced columns improve visual cohesion.", "enforcement": "postrender", "autofix": "reflow", "autofix_notes": "Adjust column balance settings and re-render to reduce large gaps.", "tags": ["layout", "columns"], "keywords": ["columns", "balance"], "dependencies": [], "exceptions": ["Short columns may be acceptable for intentional design breaks."], "status": "active"} +{"id": "BRING.LAYOUT.LEADING.CHOOSE_BASE", "title": "Choose a base leading that suits the typeface and measure", "source_refs": ["BRING \u00a72.2.1 p36 (scan p35)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Choose a base leading that suits the typeface, text size, and measure.", "rationale": "Proper leading supports comfortable reading.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Recommend a leading value appropriate to the typeface and measure.", "tags": ["layout", "leading", "typeset"], "keywords": ["leading", "line spacing"], "dependencies": [], "exceptions": ["Display text may use tighter or looser leading for effect."], "status": "active"} +{"id": "BRING.LAYOUT.LEADING.CONSISTENT_BODY", "title": "Keep body text leading consistent", "source_refs": ["BRING \u00a72.2.1 p36 (scan p35)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Keep body text leading consistent within a section.", "rationale": "Consistent leading improves rhythm and appearance.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Detect inconsistent leading settings and suggest normalization.", "tags": ["layout", "leading", "typeset"], "keywords": ["leading", "consistency"], "dependencies": [], "exceptions": ["Sidebars may use a different leading by design."], "status": "active"} +{"id": "BRING.LAYOUT.LEADING.ADJUST_FOR_SIZE_CHANGES", "title": "Adjust leading when type size changes", "source_refs": ["BRING \u00a72.2.1 p36 (scan p35)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Adjust leading when type size changes so line spacing stays balanced.", "rationale": "Leading must scale with type size to remain legible.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Scale leading values with type size changes.", "tags": ["layout", "leading", "typeset"], "keywords": ["leading", "type size"], "dependencies": [], "exceptions": ["Captions may intentionally use tighter leading."], "status": "active"} +{"id": "BRING.LAYOUT.LEADING.AVOID_TOO_TIGHT", "title": "Avoid overly tight leading", "source_refs": ["BRING \u00a72.2.1 p36 (scan p35)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Avoid overly tight leading that causes lines to collide or create dark bands.", "rationale": "Tight leading reduces readability.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Increase leading when line collisions or dark bands appear.", "tags": ["layout", "leading", "typeset"], "keywords": ["tight leading", "readability"], "dependencies": [], "exceptions": ["Display text may use tighter leading as a design choice."], "status": "active"} +{"id": "BRING.LAYOUT.LEADING.AVOID_TOO_LOOSE", "title": "Avoid overly loose leading", "source_refs": ["BRING \u00a72.2.1 p36 (scan p35)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Avoid overly loose leading that breaks the text block and slows reading.", "rationale": "Loose leading weakens text cohesion.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Reduce leading if the text block loses cohesion.", "tags": ["layout", "leading", "typeset"], "keywords": ["loose leading", "text cohesion"], "dependencies": [], "exceptions": ["Generous leading may be acceptable in large-print editions."], "status": "active"} +{"id": "BRING.LAYOUT.LEADING.ALIGN_BASELINE_GRID", "title": "Align text to a baseline grid when used", "source_refs": ["BRING \u00a72.2.1 p36 (scan p35)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "When a baseline grid is used, align text and headings to it consistently.", "rationale": "Baseline grids create visual harmony across the page.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Snap text and heading baselines to the grid where possible.", "tags": ["layout", "leading", "typeset"], "keywords": ["baseline grid", "alignment"], "dependencies": [], "exceptions": ["Large display elements may intentionally break the grid."], "status": "active"} +{"id": "BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST", "title": "Indent paragraphs after the first in continuous text", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "In continuous text, indent paragraphs after the first to signal new paragraphs.", "rationale": "Indentation is a clear paragraph cue.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply a consistent first-line indent to paragraphs after the first.", "tags": ["layout", "paragraphs", "typeset"], "keywords": ["paragraph indent", "continuous text"], "dependencies": [], "exceptions": ["Lists or block quotes may use different paragraph styling."], "status": "active"} +{"id": "BRING.LAYOUT.PARAGRAPH.INDENT_OR_SPACE_NOT_BOTH", "title": "Do not combine indents with extra paragraph spacing", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Use either paragraph indents or extra space between paragraphs, not both in the same context.", "rationale": "Combining both signals can look clumsy.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Choose one paragraph separation method per profile and normalize.", "tags": ["layout", "paragraphs", "typeset"], "keywords": ["paragraph spacing", "indents"], "dependencies": [], "exceptions": ["Section breaks may use extra space as a separator."], "status": "active"} +{"id": "BRING.LAYOUT.PARAGRAPH.NO_INDENT_AFTER_BLOCKS", "title": "Avoid indents after headings and display elements", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Do not indent the paragraph immediately following a heading, block quote, or other display element.", "rationale": "Indenting after a display element adds unnecessary noise.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Remove first-line indents following display elements.", "tags": ["layout", "paragraphs", "typeset"], "keywords": ["after heading", "after block quote"], "dependencies": [], "exceptions": ["Run-in heads may be treated differently."], "status": "active"} +{"id": "BRING.LAYOUT.PARAGRAPH.INDENT_SIZE.CONSISTENT", "title": "Keep paragraph indent size consistent", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Keep paragraph indent size consistent across body text.", "rationale": "Inconsistent indents disrupt rhythm.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Normalize indent size to the profile's setting.", "tags": ["layout", "paragraphs", "typeset"], "keywords": ["indent size", "consistency"], "dependencies": [], "exceptions": ["Special paragraphs may use a different indent by design."], "status": "active"} +{"id": "BRING.LAYOUT.PARAGRAPH.BLANK_LINES.SPARING", "title": "Use blank lines only for structural breaks", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "layout", "severity": "should", "applies_to": "all", "rule_text": "Avoid inserting blank lines between paragraphs in continuous text unless used as a structural break.", "rationale": "Excess blank lines fragment the text block.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["layout", "manual_checklist=true"], "keywords": ["blank lines", "paragraph breaks"], "dependencies": [], "exceptions": ["Screen-first content may use extra spacing for readability if consistent."], "status": "active"} +{"id": "BRING.LAYOUT.DEGRADED.HARD_WRAP_REFLOW", "title": "Reflow hard-wrapped paragraphs from degraded inputs", "source_refs": ["BRING \u00a72.3.2 p39 (scan p38)"], "category": "layout", "severity": "warn", "applies_to": "md", "rule_text": "When ingesting hard-wrapped text, reflow paragraphs to restore proper line breaks and spacing.", "rationale": "Hard wraps break reflow and create uneven text blocks.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect hard-wrapped paragraphs and suggest reflow into single paragraphs.", "tags": ["degraded_input", "layout"], "keywords": ["hard wraps", "reflow"], "dependencies": [], "exceptions": ["Do not reflow poetry or code blocks."], "status": "active"} +{"id": "BRING.LAYOUT.BLOCK_QUOTES.EXTRA_LEAD", "title": "Give block quotes extra spacing", "source_refs": ["BRING \u00a72.3.3 p40 (scan p39)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Give block quotations extra lead or spacing so they stand apart from body text.", "rationale": "Spacing distinguishes quotations from narrative.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply block-quote spacing tokens and confirm in output.", "tags": ["layout", "block_quotes", "typeset"], "keywords": ["block quotes", "spacing"], "dependencies": [], "exceptions": ["Short epigraphs may use custom spacing by design."], "status": "active"} +{"id": "BRING.LAYOUT.BLOCK_QUOTES.BEFORE_AFTER_SPACING", "title": "Use consistent spacing before and after block quotes", "source_refs": ["BRING \u00a72.3.3 p40 (scan p39)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Use consistent spacing before and after block quotes to maintain rhythm.", "rationale": "Consistent spacing keeps block quotes from interrupting flow.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Normalize block-quote spacing to the profile settings.", "tags": ["layout", "block_quotes", "typeset"], "keywords": ["block quotes", "spacing"], "dependencies": [], "exceptions": ["Design-led layouts may vary spacing for emphasis."], "status": "active"} +{"id": "BRING.LAYOUT.BLOCK_QUOTES.INDENT_OR_NARROW", "title": "Set block quotes with indentation or narrower measure", "source_refs": ["BRING \u00a72.3.3 p40 (scan p39)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Set block quotes with an indent or narrower measure rather than relying on quotation marks.", "rationale": "Distinct formatting signals quoted material.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply block-quote indentation or measure adjustments per profile.", "tags": ["layout", "block_quotes", "typeset"], "keywords": ["block quotes", "indentation"], "dependencies": [], "exceptions": ["Short quotations may remain inline instead of block quoted."], "status": "active"} +{"id": "BRING.LAYOUT.BLOCK_QUOTES.AVOID_CROWDING", "title": "Avoid crowding block quotes against headings", "source_refs": ["BRING \u00a72.3.3 p40 (scan p39)"], "category": "layout", "severity": "should", "applies_to": "pdf", "rule_text": "Avoid crowding block quotes against headings or captions; ensure clear separation.", "rationale": "Separation preserves hierarchy and readability.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Increase spacing between headings and block quotes to maintain hierarchy.", "tags": ["layout", "block_quotes", "typeset"], "keywords": ["block quotes", "headings"], "dependencies": [], "exceptions": ["Epigraph layouts may intentionally pair a heading and quote tightly."], "status": "active"} diff --git a/spec/rules/layout/v1_layout_003.ndjson b/spec/rules/layout/v1_layout_003.ndjson new file mode 100644 index 0000000..ed1324f --- /dev/null +++ b/spec/rules/layout/v1_layout_003.ndjson @@ -0,0 +1,4 @@ +{"id":"BRING.LAYOUT.VERTICAL_RHYTHM.MEASURED_INTERVALS.MULTIPLES","title":"Add and delete vertical space in measured intervals","source_refs":["BRING §2.2.2 p37 (scan p36)","BRING §2.2.2 p38 (scan p37)"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"When adding or removing vertical space (around headings, block quotes, figures, etc.), do it in measured intervals aligned to the document’s basic leading/baseline grid so the text returns to the rhythm cleanly.","rationale":"Consistent vertical rhythm improves readability and page coherence.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Suggest spacing values as multiples of base leading to preserve vertical rhythm.","tags":["layout","vertical_rhythm","baseline_grid","typeset"],"keywords":["vertical rhythm","baseline grid","leading","spacing","headings"],"dependencies":["BRING.LAYOUT.LEADING.CHOOSE_BASE"],"exceptions":["Some sections (title pages, TOCs, display-heavy spreads) may intentionally break the rhythm."],"status":"active"} +{"id":"BRING.LAYOUT.LEADING.NEGATIVE.AVOID_CONTINUOUS_TEXT","title":"Avoid negative leading for continuous text","source_refs":["BRING §2.2.2 p37 (scan p36)"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Avoid negative leading (line spacing tighter than the type size) for continuous text; prefer positive leading unless the face and setting have been tested for legibility.","rationale":"Overly tight leading reduces legibility and can cause collisions.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Detect excessively tight line-height and suggest increasing leading for body text.","tags":["layout","leading","typeset"],"keywords":["leading","line spacing","negative leading","legibility"],"dependencies":["BRING.LAYOUT.LEADING.CHOOSE_BASE"],"exceptions":["Display text may be set solid or tighter when legibility is preserved."],"status":"active"} +{"id":"BRING.LAYOUT.PAGE.DENSITY.DONT_SUFFOCATE","title":"Don’t suffocate the page","source_refs":["BRING §2.2.3 p39 (scan p38)"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Keep page density comfortable: avoid packing long lines with too many lines per page. Adjust measure, leading, or columns so the page can ‘breathe’.","rationale":"Overdense pages reduce comprehension and increase fatigue.","enforcement":"manual","autofix":"none","autofix_notes":"No deterministic autofix; flag dense pages for manual review or profile adjustment (measure/leading/columns).","tags":["layout","page_density","manual_checklist=true"],"keywords":["page density","measure","leading","columns","readability"],"dependencies":[],"exceptions":["Reference tables, data appendices, and indices may intentionally be denser than narrative text."],"status":"active"} +{"id":"BRING.LAYOUT.PARAGRAPH.OPENING_FLUSH_LEFT","title":"Set opening paragraphs flush left","source_refs":["BRING §2.3.1 p39 (scan p38)"],"category":"layout","severity":"should","applies_to":"pdf","rule_text":"Set the opening paragraph of a section flush left (no indent); use paragraph indents for subsequent paragraphs in continuous text.","rationale":"A flush-left opening avoids redundant indents and clarifies structure.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Detect the first paragraph after a heading and suggest removing its indent while keeping indents for subsequent paragraphs.","tags":["layout","paragraphs","indent","typeset"],"keywords":["opening paragraph","indent","flush left"],"dependencies":["BRING.LAYOUT.PARAGRAPH.INDENT_AFTER_FIRST"],"exceptions":["If the opening paragraph is preceded by a subhead or title that already provides separation, treat the choice as a house style decision."],"status":"active"} diff --git a/spec/rules/links/v1_links_001.ndjson b/spec/rules/links/v1_links_001.ndjson new file mode 100644 index 0000000..c81d874 --- /dev/null +++ b/spec/rules/links/v1_links_001.ndjson @@ -0,0 +1,5 @@ +{"id":"HOUSE.LINKS.TEXT.DESCRIPTIVE","title":"Link text should be descriptive (avoid bare URLs and “click here”)","source_refs":["HOUSE §A11Y.BASICS p2"],"category":"links","severity":"should","applies_to":"all","rule_text":"Prefer human-readable link text that describes the destination; avoid “click here” and avoid exposing long raw URLs in running prose when a label will do.","rationale":"Descriptive links improve comprehension and accessibility.","enforcement":"lint","autofix":"suggest","autofix_notes":"Warn on links with text like “click here” or raw URLs; suggest replacing with a descriptive label.","tags":["links","accessibility"],"keywords":["link text","click here","bare URL"],"dependencies":[],"exceptions":["In a references section, showing the full URL may be appropriate if formatting stays readable."],"status":"active"} +{"id":"HOUSE.LINKS.URLS.PREFER_HTTPS","title":"Prefer HTTPS URLs when available","source_refs":["HOUSE §QA.LINK_WRAP p2"],"category":"links","severity":"should","applies_to":"all","rule_text":"Prefer HTTPS links when an HTTPS equivalent exists; avoid plaintext HTTP for published documents unless the source is explicitly HTTP-only.","rationale":"HTTPS is the modern baseline for integrity and privacy.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest upgrading http:// to https:// when the host is known to support HTTPS (manual confirmation may be required).","tags":["links"],"keywords":["https","urls","security"],"dependencies":[],"exceptions":["Do not rewrite URLs when the HTTPS target is unknown or changes meaning."],"status":"active"} +{"id":"HOUSE.LINKS.PUNCTUATION.NO_TRAILING_PUNCT","title":"Avoid trailing punctuation inside link targets","source_refs":["HOUSE §QA.LINK_WRAP p2"],"category":"links","severity":"must","applies_to":"all","rule_text":"Do not include trailing sentence punctuation (.,;:) inside link URLs; keep punctuation outside the link target.","rationale":"Trailing punctuation frequently breaks links.","enforcement":"lint","autofix":"rewrite","autofix_notes":"When a link target ends with a common punctuation character, move that punctuation outside the link.","tags":["links"],"keywords":["links","punctuation","trailing period"],"dependencies":[],"exceptions":["If the URL legitimately ends with punctuation, require manual confirmation."],"status":"active"} +{"id":"HOUSE.LINKS.WRAP.SAFE_BREAKS","title":"If a URL must wrap, break at safe separators","source_refs":["HOUSE §QA.LINK_WRAP p2"],"category":"links","severity":"should","applies_to":"pdf","rule_text":"When URLs wrap, prefer breaking at safe separators (/, -, ., ?, &, =) and avoid breaking the scheme/host portion where meaning is most fragile.","rationale":"Readable wrapping reduces copy/paste errors and verifier failures.","enforcement":"typeset","autofix":"reflow","autofix_notes":"Apply CSS/renderer line-break hints for URLs; post-render QA should flag wrapped URLs that violate policy.","tags":["links","wrap"],"keywords":["URL wrapping","line breaks","copy paste"],"dependencies":[],"exceptions":["In code blocks, URL wrapping rules follow code overflow policy instead."],"status":"active"} +{"id":"HOUSE.LINKS.DISALLOW.FILE_URIS","title":"Do not emit file:// links in published outputs","source_refs":["HOUSE §QA.LINK_WRAP p2"],"category":"links","severity":"must","applies_to":"all","rule_text":"Do not emit file:// URIs in output documents; use relative paths (for repo-local docs) or HTTPS URLs (for public assets).","rationale":"file:// links do not work for external readers and may leak local paths.","enforcement":"lint","autofix":"suggest","autofix_notes":"Warn on file:// links and suggest an HTTPS or relative alternative.","tags":["links","opsec"],"keywords":["file uri","opsec","links"],"dependencies":[],"exceptions":["Internal-only docs may use file:// for local workflows, but must be removed for publication."],"status":"active"} diff --git a/spec/rules/numbers/v1_numbers_001.ndjson b/spec/rules/numbers/v1_numbers_001.ndjson new file mode 100644 index 0000000..7e4ee54 --- /dev/null +++ b/spec/rules/numbers/v1_numbers_001.ndjson @@ -0,0 +1,12 @@ +{"id":"CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT","title":"Spell out whole numbers in running text by default","source_refs":["CMOS18 §9.2 p590 (scan p612)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"In general prose, spell out whole numbers in a basic range (commonly one through one hundred) unless context favors numerals (measurements, statistics, dense technical material).","rationale":"Spelled-out numbers read more smoothly in narrative text.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","number_spelling"],"keywords":["spell out numbers","one through one hundred","numerals"],"dependencies":[],"exceptions":["Technical profiles may prefer numerals for consistency; document profile choice."],"status":"active"} +{"id":"CMOS.NUMBERS.NUMERALS.MEASUREMENTS.UNITS","title":"Use numerals with units of measure","source_refs":["CMOS18 §9.14 p597 (scan p619)","CMOS18 §9.18 p598 (scan p620)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"Use numerals with units of measure (including percentages and decimals) when presenting quantitative information; keep the unit formatting consistent.","rationale":"Numerals are faster to scan and reduce ambiguity in measurements.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["measurements","units","manual_checklist=true"],"keywords":["units of measure","percent","decimal"],"dependencies":[],"exceptions":["Narrative contexts may still spell out numbers when no unit is attached and readability benefits."],"status":"active"} +{"id":"CMOS.NUMBERS.DECIMALS.LEADING_ZERO","title":"Use a leading zero for decimals less than one","source_refs":["CMOS18 §9.21 p600 (scan p622)"],"category":"numbers","severity":"must","applies_to":"md","rule_text":"For decimal values less than one, include a leading zero before the decimal point (e.g., 0.5).","rationale":"A leading zero prevents misreading and improves accessibility.","enforcement":"lint","autofix":"rewrite","autofix_notes":"Rewrite occurrences of ‘.N’ to ‘0.N’ in numeric contexts outside code blocks, avoiding version numbers and dotted identifiers where ambiguous.","tags":["decimals","leading_zero"],"keywords":["leading zero","decimal"],"dependencies":[],"exceptions":["Do not rewrite dotted identifiers (e.g., version strings) or code."],"status":"active"} +{"id":"CMOS.NUMBERS.GROUPING.THOUSANDS_SEPARATOR","title":"Use a consistent thousands separator for large numbers","source_refs":["CMOS18 §9.56 p612 (scan p634)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"Use a consistent thousands separator for numerals of four digits or more, according to the document locale/profile.","rationale":"Consistent grouping improves scanability and reduces errors.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest inserting the configured separator (e.g., comma) for plain integers ≥ 1000 when unambiguous; do not rewrite IDs or code.","tags":["number_format","locale"],"keywords":["thousands separator","number grouping","locale"],"dependencies":[],"exceptions":["Identifiers, part numbers, and hashes should not be regrouped."],"status":"active"} +{"id":"CMOS.NUMBERS.PLURALS.DECADE.NO_APOSTROPHE","title":"Form plurals of decades without apostrophes","source_refs":["CMOS18 §9.55 p612 (scan p634)"],"category":"numbers","severity":"must","applies_to":"md","rule_text":"Form plurals of decades with a plain s (e.g., 1990s), not an apostrophe (1990’s).","rationale":"Apostrophes signal possession, not pluralization.","enforcement":"lint","autofix":"rewrite","autofix_notes":"Rewrite common decade plural patterns like ‘1990’s’ to ‘1990s’ outside code blocks.","tags":["plurals","decades"],"keywords":["1990s","decade plural","apostrophe"],"dependencies":[],"exceptions":["Possessive forms remain valid when intended (e.g., ‘1990’s legacy’ meaning ‘of 1990’); require human review if ambiguous."],"status":"active"} +{"id":"CMOS.NUMBERS.RANGES.EN_DASH.USE","title":"Use en dashes for numeric ranges written in numerals","source_refs":["CMOS18 §9.62 p615 (scan p637)","CMOS18 §6.83 p415 (scan p437)"],"category":"numbers","severity":"should","applies_to":"md","rule_text":"When expressing a numeric range in numerals, use an en dash (e.g., 10–15) rather than a hyphen; do not mix ‘from/between’ with an en-dash range.","rationale":"This distinguishes ranges from hyphenation and reduces ambiguity.","enforcement":"lint","autofix":"suggest","autofix_notes":"Prefer the punctuation-level en-dash normalization; here the linter should flag mixed constructions like ‘from 10–15’.","tags":["ranges","en_dash"],"keywords":["numeric range","en dash","from to"],"dependencies":["CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES"],"exceptions":["Minus signs and negative ranges require careful handling; avoid automatic rewrites when a leading minus is present."],"status":"active"} +{"id":"CMOS.NUMBERS.ORDINALS.SUFFIX.CORRECT","title":"Use correct ordinal suffixes","source_refs":["CMOS18 §9.6 p592 (scan p614)"],"category":"numbers","severity":"should","applies_to":"md","rule_text":"When using ordinal numerals, use the correct suffix (e.g., 1st, 2nd, 3rd, 4th) and apply consistently.","rationale":"Incorrect ordinals look careless and reduce trust in numerical detail.","enforcement":"lint","autofix":"suggest","autofix_notes":"Flag common ordinal suffix errors (e.g., 1th) and suggest the correct form; do not rewrite automatically in ambiguous contexts.","tags":["ordinals"],"keywords":["ordinal","suffix","1st","2nd"],"dependencies":[],"exceptions":["Spelled-out ordinals (first, second) are acceptable; follow profile and context."],"status":"active"} +{"id":"CMOS.NUMBERS.INCLUSIVE_RANGES.PAGE_NUMBERS.SHORTEN","title":"Use consistent inclusive number ranges (page ranges)","source_refs":["CMOS18 §9.63 p616 (scan p638)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"For inclusive number ranges (especially page ranges), follow a consistent shortening rule appropriate to the style (e.g., avoid repeating unchanged leading digits when the context remains clear).","rationale":"Consistent inclusive ranges reduce visual clutter in citations and references.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["citations","page_ranges","manual_checklist=true"],"keywords":["inclusive numbers","page range","shortened range"],"dependencies":[],"exceptions":["When shortening would reduce clarity (e.g., across thousands boundaries), retain the full range."],"status":"active"} +{"id":"CMOS.NUMBERS.DATES.CONSISTENT_FORMAT","title":"Use a consistent date format per locale/profile","source_refs":["CMOS18 §9.31 p604 (scan p626)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"Use a consistent date format throughout the document according to locale and profile (e.g., ISO 8601 in technical specs, or month-day-year in US prose).","rationale":"Mixed date formats increase the risk of misinterpretation.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["dates","locale","manual_checklist=true"],"keywords":["date format","ISO 8601","month day year"],"dependencies":[],"exceptions":["Quoted material and data extracts may preserve source formatting; annotate if needed."],"status":"active"} +{"id":"CMOS.NUMBERS.CURRENCY.FORMAT.SYMBOL_PLACEMENT","title":"Format currency consistently","source_refs":["CMOS18 §9.22 p600 (scan p622)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"Format currency consistently: keep symbols and codes stable, avoid mixing symbol and code styles within the same document, and ensure grouping/decimals follow the locale profile.","rationale":"Consistent currency formatting avoids costly misunderstandings.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["currency","locale","manual_checklist=true"],"keywords":["currency","dollar","USD","EUR"],"dependencies":[],"exceptions":["Financial statements may require strict standards; follow the governing standard when applicable."],"status":"active"} +{"id":"CMOS.NUMBERS.SENTENCE_START.AVOID_NUMERAL","title":"Avoid starting a sentence with a numeral in prose contexts","source_refs":["CMOS18 §9.5 p591 (scan p613)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"In prose contexts, avoid starting a sentence with a numeral; rewrite the sentence or spell out the number to improve readability.","rationale":"Sentence-initial numerals can read awkwardly and hinder scanning.","enforcement":"manual","autofix":"suggest","autofix_notes":"Suggest rephrasing when a sentence begins with a numeral in body text, excluding list items and headings.","tags":["readability","manual_checklist=true"],"keywords":["sentence start","numeral"],"dependencies":[],"exceptions":["Lists and tabular contexts may begin with numerals; treat separately."],"status":"active"} +{"id":"CMOS.NUMBERS.CONSISTENCY.MIXED_FORMS.AVOID","title":"Avoid mixing spelled-out numbers and numerals within a comparable set","source_refs":["CMOS18 §9.2 p590 (scan p612)"],"category":"numbers","severity":"should","applies_to":"all","rule_text":"Within a comparable set (e.g., a list of quantities), avoid mixing spelled-out numbers and numerals without a clear reason; choose a consistent form.","rationale":"Mixed forms look arbitrary and reduce legibility.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["consistency","manual_checklist=true"],"keywords":["consistency","spelled out","numerals"],"dependencies":[],"exceptions":["Adjacent items with different contexts (e.g., one with units, one without) may justify different forms; consider rephrasing for uniformity."],"status":"active"} diff --git a/spec/rules/numbers/v1_numbers_002.ndjson b/spec/rules/numbers/v1_numbers_002.ndjson new file mode 100644 index 0000000..605b0ee --- /dev/null +++ b/spec/rules/numbers/v1_numbers_002.ndjson @@ -0,0 +1,50 @@ +{"id": "CMOS.NUMBERS.RULE_SELECTION.GENERAL_OR_ALTERNATIVE", "title": "Choose a primary number spelling rule", "source_refs": ["CMOS18 \u00a79.2 p590 (scan p612)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Choose either the general or alternative spelling rule for numbers in narrative text and apply it consistently within the document.", "rationale": "A single rule reduces distracting inconsistency.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["number_spelling", "consistency", "manual_checklist=true"], "keywords": ["general rule", "alternative rule", "number spelling"], "dependencies": [], "exceptions": ["Technical profiles may mandate numerals throughout."], "status": "active"} +{"id": "CMOS.NUMBERS.RULE_SELECTION.ALTERNATIVE_ZERO_TO_NINE", "title": "Alternative rule spells out zero through nine", "source_refs": ["CMOS18 \u00a79.3 p590 (scan p612)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "If using the alternative rule, spell out zero through nine and use numerals for 10 and above.", "rationale": "The alternative rule standardizes small-number spelling.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["number_spelling", "manual_checklist=true"], "keywords": ["zero through nine", "alternative rule"], "dependencies": [], "exceptions": ["Measurements and statistical contexts may still require numerals."], "status": "active"} +{"id": "CMOS.NUMBERS.ROUND_NUMBERS.SPELL_OUT", "title": "Spell out round numbers in narrative text", "source_refs": ["CMOS18 \u00a79.4 p591 (scan p613)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Spell out round numbers in the hundreds and thousands when the general rule applies; use numerals when precision or context demands.", "rationale": "Spelled-out round numbers read smoothly in prose.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["round_numbers", "manual_checklist=true"], "keywords": ["hundreds", "thousands", "round numbers"], "dependencies": [], "exceptions": ["Technical documents may prefer numerals for all quantities."], "status": "active"} +{"id": "CMOS.NUMBERS.DENSE_CONTEXT.USE_NUMERALS", "title": "Prefer numerals in number-dense passages", "source_refs": ["CMOS18 \u00a79.7 p592 (scan p614)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "In passages with many numbers or dense data, prefer numerals for readability even when some numbers could be spelled out.", "rationale": "Numerals are easier to scan in data-heavy text.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dense_numbers", "manual_checklist=true"], "keywords": ["dense data", "numerals"], "dependencies": [], "exceptions": ["Narrative sections may still favor spelled-out numbers for tone."], "status": "active"} +{"id": "CMOS.NUMBERS.CONTEXTS.ALWAYS_NUMERALS", "title": "Use numerals in conventionally numeric contexts", "source_refs": ["CMOS18 \u00a79.8 p593 (scan p615)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals in contexts that are conventionally numeric (measurements, percentages, decimals, numbered parts) unless a profile overrides.", "rationale": "Conventional numeric contexts benefit from compact notation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["contexts", "manual_checklist=true"], "keywords": ["measurements", "percentages", "decimals"], "dependencies": [], "exceptions": ["Sentence-initial numbers may be spelled out by policy."], "status": "active"} +{"id": "CMOS.NUMBERS.LARGE_VALUES.MILLIONS_BILLIONS", "title": "Combine numerals with million/billion for large values", "source_refs": ["CMOS18 \u00a79.9 p595 (scan p617)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Combine numerals with words like million or billion for large round values when it improves clarity.", "rationale": "Mixed forms reduce long digit strings in prose.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["large_numbers", "manual_checklist=true"], "keywords": ["million", "billion", "large numbers"], "dependencies": [], "exceptions": ["Financial tables may require full numerals for precision."], "status": "active"} +{"id": "CMOS.NUMBERS.SCIENTIFIC.POWERS_OF_TEN", "title": "Use scientific notation for very large or small quantities", "source_refs": ["CMOS18 \u00a79.10 p596 (scan p618)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "For very large or very small quantities, use scientific notation or powers of ten when precision matters.", "rationale": "Scientific notation prevents misreading large magnitudes.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["scientific_notation", "manual_checklist=true"], "keywords": ["scientific notation", "powers of ten"], "dependencies": [], "exceptions": ["General audience prose may prefer rounded values."], "status": "active"} +{"id": "CMOS.NUMBERS.SI_PREFIXES.NO_HYPHEN", "title": "Write SI prefixes without hyphens", "source_refs": ["CMOS18 \u00a79.11 p596 (scan p618)"], "category": "numbers", "severity": "should", "applies_to": "md", "rule_text": "Write SI prefixes without a hyphen or space unless the unit convention requires it.", "rationale": "Standard SI forms reduce ambiguity.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Flag hyphenated SI prefix forms and suggest the standard unhyphenated form when safe.", "tags": ["si_prefixes", "number_format"], "keywords": ["SI prefixes", "hyphenation"], "dependencies": [], "exceptions": ["Follow the unit convention if a hyphen is standard for a specific term."], "status": "active"} +{"id": "CMOS.NUMBERS.BASES.NON_DECIMAL.NO_GROUPING", "title": "Avoid digit grouping in non-decimal bases", "source_refs": ["CMOS18 \u00a79.12 p596 (scan p618)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "For numbers in non-decimal bases (binary, octal, hexadecimal), avoid inserting thousands separators unless the style explicitly calls for it.", "rationale": "Grouping can obscure the base representation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["number_format", "bases", "manual_checklist=true"], "keywords": ["binary", "hexadecimal", "digit grouping"], "dependencies": [], "exceptions": ["Some coding standards allow grouping for readability; document the choice."], "status": "active"} +{"id": "CMOS.NUMBERS.FRACTIONS.SIMPLE.SPELL_OUT", "title": "Spell out simple fractions in running text", "source_refs": ["CMOS18 \u00a79.15 p597 (scan p619)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Spell out simple fractions when they appear alone in running text.", "rationale": "Spelled-out simple fractions read naturally in prose.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["fractions", "manual_checklist=true"], "keywords": ["simple fractions", "spelled out"], "dependencies": [], "exceptions": ["Technical contexts may prefer numeric fractions."], "status": "active"} +{"id": "CMOS.NUMBERS.FRACTIONS.MIXED.WHOLE_PLUS_FRACTION", "title": "Format mixed numbers consistently", "source_refs": ["CMOS18 \u00a79.16 p598 (scan p620)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Express mixed numbers (whole number plus fraction) consistently and avoid ambiguous spacing.", "rationale": "Consistent mixed-number formatting reduces misreading.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["fractions", "manual_checklist=true"], "keywords": ["mixed fractions", "whole numbers"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.FRACTIONS.MATH.NUMERALS", "title": "Use numeric fractions in mathematical contexts", "source_refs": ["CMOS18 \u00a79.17 p598 (scan p620)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "In mathematical contexts, use numerals and explicit fraction formatting rather than spelled-out fractions.", "rationale": "Math contexts demand unambiguous numeric forms.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["fractions", "math", "manual_checklist=true"], "keywords": ["math fractions", "numeric fractions"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.UNITS.REPEATED.OMIT_REPEAT", "title": "Omit repeated units when quantities share a unit", "source_refs": ["CMOS18 \u00a79.19 p599 (scan p621)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "When listing multiple quantities that share a unit, omit repeating the unit if the grouping remains unambiguous.", "rationale": "Omitting redundant units reduces clutter.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["units", "manual_checklist=true"], "keywords": ["repeated units", "measurement"], "dependencies": [], "exceptions": ["Repeat the unit when there is any risk of ambiguity."], "status": "active"} +{"id": "CMOS.NUMBERS.PERCENTAGES.NUMERALS", "title": "Use numerals for percentages", "source_refs": ["CMOS18 \u00a79.20 p599 (scan p621)"], "category": "numbers", "severity": "should", "applies_to": "md", "rule_text": "Use numerals for percentages except when a sentence begins with the number.", "rationale": "Numerals make percentages easier to scan.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Flag spelled-out percentages and suggest numerals where safe.", "tags": ["percentages"], "keywords": ["percent", "percentage"], "dependencies": [], "exceptions": ["Sentence-initial percentages may be spelled out by policy."], "status": "active"} +{"id": "CMOS.NUMBERS.DECIMALS.NUMERALS", "title": "Use numerals for decimal fractions", "source_refs": ["CMOS18 \u00a79.21 p600 (scan p622)"], "category": "numbers", "severity": "should", "applies_to": "md", "rule_text": "Express decimal fractions as numerals rather than words in running text.", "rationale": "Decimal fractions are clearer in numeric form.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Flag spelled-out decimals and suggest numerals when unambiguous.", "tags": ["decimals"], "keywords": ["decimal", "numerals"], "dependencies": [], "exceptions": ["Do not rewrite inside code blocks or data extracts."], "status": "active"} +{"id": "CMOS.NUMBERS.CURRENCY.WORDS_VS_SYMBOLS", "title": "Use symbols for exact currency amounts", "source_refs": ["CMOS18 \u00a79.22 p600 (scan p622)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use currency symbols with numerals for exact amounts; reserve spelled-out currency for approximate or narrative contexts.", "rationale": "Symbols with numerals make exact amounts easier to verify.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["currency", "manual_checklist=true"], "keywords": ["currency symbols", "money"], "dependencies": [], "exceptions": ["Regulated financial documents may impose their own formats."], "status": "active"} +{"id": "CMOS.NUMBERS.CURRENCY.NON_US.DISAMBIGUATE", "title": "Disambiguate non-US dollar symbols", "source_refs": ["CMOS18 \u00a79.23 p601 (scan p623)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "When the dollar symbol represents a non-US currency, disambiguate with a currency code or context.", "rationale": "The dollar symbol is used by multiple currencies.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["currency", "manual_checklist=true"], "keywords": ["currency code", "non-US dollar"], "dependencies": [], "exceptions": ["If the context is unambiguous (e.g., a local-only report), note the assumption."], "status": "active"} +{"id": "CMOS.NUMBERS.CURRENCY.ISO_CODES", "title": "Use ISO codes or clear symbols for other currencies", "source_refs": ["CMOS18 \u00a79.25 p602 (scan p624)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "For other currencies, use ISO currency codes or clear symbols and keep the format consistent.", "rationale": "Standard codes prevent ambiguity across locales.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["currency", "manual_checklist=true"], "keywords": ["ISO codes", "currency"], "dependencies": [], "exceptions": ["Narrative prose may spell out the currency name if clarity remains high."], "status": "active"} +{"id": "CMOS.NUMBERS.CURRENCY.LARGE_AMOUNTS", "title": "Format large monetary amounts compactly", "source_refs": ["CMOS18 \u00a79.26 p602 (scan p624)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "For large monetary amounts, use numerals plus a clear unit (e.g., million) rather than long digit strings.", "rationale": "Compact forms reduce reading errors.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["currency", "manual_checklist=true"], "keywords": ["large amounts", "money"], "dependencies": [], "exceptions": ["Tables may require full digits for calculation."], "status": "active"} +{"id": "CMOS.NUMBERS.CURRENCY.HISTORICAL.YEAR_CONTEXT", "title": "Add year context for historical currency values", "source_refs": ["CMOS18 \u00a79.27 p602 (scan p624)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "When citing historical monetary values, include the year or period to anchor the amount.", "rationale": "Currency values change over time.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["currency", "manual_checklist=true", "historical"], "keywords": ["historical currency", "year"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.REFERENCES.PAGE_CHAPTER_FIGURE", "title": "Use numerals for references to pages, chapters, tables, and figures", "source_refs": ["CMOS18 \u00a79.28 p603 (scan p625)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for references to pages, chapters, tables, and figures.", "rationale": "Numeric references are standard and easy to scan.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["references", "manual_checklist=true"], "keywords": ["page numbers", "chapter numbers", "figure numbers"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.PERIODICALS.VOLUME_ISSUE", "title": "Use numerals for periodical volume and issue numbers", "source_refs": ["CMOS18 \u00a79.29 p603 (scan p625)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for periodical volume, issue, and page references.", "rationale": "Standard numbering aids citation lookup.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["periodicals", "manual_checklist=true"], "keywords": ["volume", "issue", "page numbers"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.DATES.YEAR_NUMERALS", "title": "Use numerals for years", "source_refs": ["CMOS18 \u00a79.31 p604 (scan p626)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Express years as numerals rather than spelling them out.", "rationale": "Year numerals are standard and concise.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "manual_checklist=true"], "keywords": ["year", "dates"], "dependencies": [], "exceptions": ["Formal titles may preserve spelled-out years if required by the source."], "status": "active"} +{"id": "CMOS.NUMBERS.DATES.ABBREVIATED_YEAR", "title": "Use abbreviated years only when unambiguous", "source_refs": ["CMOS18 \u00a79.32 p604 (scan p626)"], "category": "numbers", "severity": "warn", "applies_to": "all", "rule_text": "Use abbreviated years only in informal contexts and ensure the abbreviation cannot be confused.", "rationale": "Abbreviated years are easy to misread.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "manual_checklist=true"], "keywords": ["abbreviated year", "dates"], "dependencies": [], "exceptions": ["Internal shorthand is acceptable in limited-scope working notes."], "status": "active"} +{"id": "CMOS.NUMBERS.DATES.MONTH_DAY_STYLE", "title": "Use numerals for the day in month-day dates", "source_refs": ["CMOS18 \u00a79.33 p604 (scan p626)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "In month-day constructions, use numerals for the day and a spelled-out month name.", "rationale": "This format is standard and readable in prose.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "manual_checklist=true"], "keywords": ["month-day", "date format"], "dependencies": [], "exceptions": ["Tables may use all-numeral dates for alignment."], "status": "active"} +{"id": "CMOS.NUMBERS.CENTURIES.SPELLED_OUT", "title": "Spell out centuries as ordinals", "source_refs": ["CMOS18 \u00a79.34 p605 (scan p627)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Spell out centuries as ordinals and use lowercase when referring to a century in running text.", "rationale": "This is the conventional prose form for centuries.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "manual_checklist=true"], "keywords": ["centuries", "ordinals"], "dependencies": [], "exceptions": ["Headings may use numerals if the style requires it."], "status": "active"} +{"id": "CMOS.NUMBERS.DECADES.CONSISTENT_FORM", "title": "Use a consistent form for decades", "source_refs": ["CMOS18 \u00a79.35 p605 (scan p627)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Express decades consistently (spelled out or numerals) and avoid mixing forms within a document.", "rationale": "Mixed decade forms look inconsistent and confuse readers.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "decades", "manual_checklist=true"], "keywords": ["decades", "consistency"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.ERAS.BCE_CE", "title": "Use a consistent era designation", "source_refs": ["CMOS18 \u00a79.36 p606 (scan p628)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use a consistent era designation (BCE/CE or BC/AD) with numerals across the document.", "rationale": "Consistent era markers prevent historical ambiguity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "eras", "manual_checklist=true"], "keywords": ["BCE", "CE", "era designations"], "dependencies": [], "exceptions": ["Quoted material may retain the original era markers."], "status": "active"} +{"id": "CMOS.NUMBERS.DATES.ALL_NUMERAL", "title": "Keep all-numeral dates consistent", "source_refs": ["CMOS18 \u00a79.37 p607 (scan p629)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "If using all-numeral dates, keep a single ordering and separator style throughout.", "rationale": "Mixed numeric date formats are easy to misread.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "manual_checklist=true"], "keywords": ["numeric dates", "date order"], "dependencies": [], "exceptions": ["Imported data may preserve source formats but should be labeled."], "status": "active"} +{"id": "CMOS.NUMBERS.DATES.ISO_8601", "title": "Use ISO 8601 dates in technical contexts", "source_refs": ["CMOS18 \u00a79.38 p607 (scan p629)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "In technical or data-driven contexts, use ISO 8601 date format (YYYY-MM-DD) for clarity and sorting.", "rationale": "ISO dates sort lexicographically and reduce ambiguity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["dates", "iso", "manual_checklist=true"], "keywords": ["ISO 8601", "date format"], "dependencies": [], "exceptions": ["Narrative prose may prefer spelled-out month names."], "status": "active"} +{"id": "CMOS.NUMBERS.TIME.GENERAL_NUMERALS", "title": "Use numerals for precise times of day", "source_refs": ["CMOS18 \u00a79.39 p607 (scan p629)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for precise times of day; reserve spelled-out forms for informal references.", "rationale": "Numeric times are faster to scan and verify.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["time", "manual_checklist=true"], "keywords": ["time of day", "numerals"], "dependencies": [], "exceptions": ["Dialogue may follow conversational phrasing."], "status": "active"} +{"id": "CMOS.NUMBERS.TIME.NOON_MIDNIGHT", "title": "Prefer noon and midnight over ambiguous 12 a.m./p.m.", "source_refs": ["CMOS18 \u00a79.40 p608 (scan p630)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use noon and midnight instead of ambiguous 12 a.m./p.m. unless the 24-hour system is used.", "rationale": "Noon and midnight avoid ambiguity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["time", "manual_checklist=true"], "keywords": ["noon", "midnight"], "dependencies": [], "exceptions": ["Data exports may require numeric times for consistency."], "status": "active"} +{"id": "CMOS.NUMBERS.TIME.TWENTY_FOUR_HOUR", "title": "Format 24-hour times consistently", "source_refs": ["CMOS18 \u00a79.41 p608 (scan p630)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "When using the 24-hour system, format times consistently and omit a.m./p.m.", "rationale": "Consistent 24-hour formatting improves clarity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["time", "manual_checklist=true"], "keywords": ["24-hour time", "time format"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.TIME.ISO_STYLE", "title": "Use ISO time format when specified", "source_refs": ["CMOS18 \u00a79.42 p609 (scan p631)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "If using ISO time, include leading zeros and a consistent separator.", "rationale": "ISO time avoids ambiguity and sorts predictably.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["time", "iso", "manual_checklist=true"], "keywords": ["ISO time", "leading zeros"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.NAMES.MONARCHS_POPES", "title": "Use numerals for monarchs and similar titles", "source_refs": ["CMOS18 \u00a79.43 p609 (scan p631)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for monarchs, popes, and similar ordinal titles in accordance with conventional naming.", "rationale": "Conventional naming aids recognition and indexing.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["names", "manual_checklist=true"], "keywords": ["monarchs", "popes", "ordinal titles"], "dependencies": [], "exceptions": ["Historical quotations may preserve the source's form."], "status": "active"} +{"id": "CMOS.NUMBERS.VEHICLES.VESSELS_NUMBERS", "title": "Use numerals for vehicle and vessel numbers", "source_refs": ["CMOS18 \u00a79.46 p610 (scan p632)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for vehicle and vessel numbers when they are part of the name or designation.", "rationale": "Numerals are standard in equipment designations.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["names", "vehicles", "manual_checklist=true"], "keywords": ["vehicles", "vessels", "designations"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.PLACES.HIGHWAYS", "title": "Use numerals for highway designations", "source_refs": ["CMOS18 \u00a79.52 p611 (scan p633)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for highway designations and follow local formatting conventions.", "rationale": "Highway numbering is a formal designation system.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["places", "manual_checklist=true"], "keywords": ["highways", "route numbers"], "dependencies": [], "exceptions": ["Spelled-out names may appear in historical titles."], "status": "active"} +{"id": "CMOS.NUMBERS.PLACES.STREETS", "title": "Use numerals for numbered streets and avenues", "source_refs": ["CMOS18 \u00a79.53 p611 (scan p633)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for numbered streets and keep directional abbreviations consistent.", "rationale": "Numerical street names are standard identifiers.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["places", "manual_checklist=true"], "keywords": ["streets", "addresses"], "dependencies": [], "exceptions": ["Local style may spell out streets below a threshold; document the choice."], "status": "active"} +{"id": "CMOS.NUMBERS.PLACES.BUILDINGS_APTS", "title": "Use numerals for building and apartment numbers", "source_refs": ["CMOS18 \u00a79.54 p611 (scan p633)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use numerals for building and apartment numbers and keep the format consistent.", "rationale": "Numerals match real-world identifiers.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["places", "manual_checklist=true"], "keywords": ["building numbers", "apartment numbers"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.PLURALS.SPELLED_OUT", "title": "Form plurals of spelled-out numbers without apostrophes", "source_refs": ["CMOS18 \u00a79.55 p612 (scan p634)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Form plurals of spelled-out numbers as regular nouns without apostrophes.", "rationale": "Apostrophes are not used for simple plurals.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["plurals", "manual_checklist=true"], "keywords": ["plural numbers", "apostrophes"], "dependencies": [], "exceptions": ["Possessive forms remain valid when intended."], "status": "active"} +{"id": "CMOS.NUMBERS.DECIMAL_MARKER.LOCALE", "title": "Use the locale-appropriate decimal marker", "source_refs": ["CMOS18 \u00a79.57 p613 (scan p635)"], "category": "numbers", "severity": "should", "applies_to": "md", "rule_text": "Use the decimal marker appropriate to the locale (period or comma) and do not mix markers.", "rationale": "Mixed decimal markers cause numeric ambiguity.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Flag mixed decimal markers and suggest normalizing to the profile locale.", "tags": ["number_format", "locale"], "keywords": ["decimal marker", "locale"], "dependencies": [], "exceptions": ["Quoted or source-derived data may retain original markers with a note."], "status": "active"} +{"id": "CMOS.NUMBERS.DIGIT_GROUPING.SI_SPACE", "title": "Use SI digit grouping when configured", "source_refs": ["CMOS18 \u00a79.58 p613 (scan p635)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "In SI-number style contexts, group digits with thin spaces rather than commas, per the selected profile.", "rationale": "SI grouping aligns with international scientific norms.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply the profile's digit-grouping settings during typesetting; verify in output.", "tags": ["number_format", "si", "typeset"], "keywords": ["SI grouping", "thin space"], "dependencies": [], "exceptions": ["Locales that mandate comma grouping should follow the locale profile instead."], "status": "active"} +{"id": "CMOS.NUMBERS.TELEPHONE.FORMAT", "title": "Format telephone numbers consistently", "source_refs": ["CMOS18 \u00a79.59 p614 (scan p636)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Format telephone numbers with a consistent grouping pattern appropriate to the locale.", "rationale": "Consistent grouping improves readability and dialing accuracy.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["telephone", "manual_checklist=true"], "keywords": ["phone numbers", "grouping"], "dependencies": [], "exceptions": ["International numbers may follow ITU conventions for grouping."], "status": "active"} +{"id": "CMOS.NUMBERS.RATIOS.FORMAT", "title": "Express ratios consistently", "source_refs": ["CMOS18 \u00a79.60 p615 (scan p637)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Express ratios consistently using numerals with either a colon or words like to; avoid mixing styles.", "rationale": "Consistent ratio notation reduces ambiguity.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["ratios", "manual_checklist=true"], "keywords": ["ratios", "colon notation"], "dependencies": [], "exceptions": ["Mathematical formulas may require a specific notation."], "status": "active"} +{"id": "CMOS.NUMBERS.LISTS.OUTLINE.NUMERAL_STYLE", "title": "Use a consistent numeral system for outlines", "source_refs": ["CMOS18 \u00a79.61 p615 (scan p637)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use a consistent numbering system (arabic or roman) for outlines and numbered lists.", "rationale": "Consistent list numbering improves navigation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["lists", "manual_checklist=true"], "keywords": ["numbered lists", "outline"], "dependencies": [], "exceptions": ["Nested outlines may use multiple numbering styles when clearly signaled."], "status": "active"} +{"id": "CMOS.NUMBERS.INCLUSIVE.COMMAS", "title": "Keep comma grouping consistent in inclusive ranges", "source_refs": ["CMOS18 \u00a79.65 p617 (scan p639)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "When inclusive numbers contain commas, apply the same grouping pattern to both ends of the range.", "rationale": "Consistent grouping prevents misreading ranges.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["inclusive_numbers", "manual_checklist=true"], "keywords": ["inclusive range", "comma grouping"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.INCLUSIVE.YEARS", "title": "Avoid ambiguous abbreviated year ranges", "source_refs": ["CMOS18 \u00a79.66 p617 (scan p639)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Abbreviate inclusive years only when the abbreviated form is unambiguous; otherwise use the full range.", "rationale": "Ambiguous year ranges lead to incorrect interpretation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["inclusive_numbers", "manual_checklist=true"], "keywords": ["inclusive years", "year range"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.NUMBERS.ROMAN_NUMERALS.USE", "title": "Use roman numerals only in conventional contexts", "source_refs": ["CMOS18 \u00a79.67 p617 (scan p639)"], "category": "numbers", "severity": "should", "applies_to": "all", "rule_text": "Use roman numerals only in contexts where they are conventional and apply the style consistently.", "rationale": "Roman numerals can be slower to parse in general text.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["roman_numerals", "manual_checklist=true"], "keywords": ["roman numerals", "conventional use"], "dependencies": [], "exceptions": ["Source titles may preserve roman numerals as published."], "status": "active"} +{"id": "CMOS.NUMBERS.DEGRADED.NUMERAL_NORMALIZATION", "title": "Normalize common OCR numeral confusions", "source_refs": ["CMOS18 \u00a79.57 p613 (scan p635)"], "category": "numbers", "severity": "warn", "applies_to": "md", "rule_text": "In OCR or imported text, normalize common numeral confusions (O vs 0, l vs 1) in numeric contexts and flag uncertain cases.", "rationale": "OCR errors in numbers can change meaning.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect common OCR confusions in numeric tokens and suggest corrections with a review flag.", "tags": ["degraded_input", "number_format"], "keywords": ["OCR", "numeral normalization"], "dependencies": [], "exceptions": ["Do not rewrite inside code blocks or identifiers."], "status": "active"} +{"id": "CMOS.NUMBERS.DEGRADED.HARD_WRAP_UNITS", "title": "Repair hard-wrapped number and unit pairs", "source_refs": ["CMOS18 \u00a79.19 p599 (scan p621)"], "category": "numbers", "severity": "warn", "applies_to": "md", "rule_text": "When number and unit pairs are split by line breaks in source text, restore the pair and apply nonbreaking spacing if required.", "rationale": "Broken number-unit pairs reduce readability and can be misread.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect number-unit splits across line breaks and suggest recombining with a nonbreaking space.", "tags": ["degraded_input", "units"], "keywords": ["hard wraps", "units", "nonbreaking space"], "dependencies": [], "exceptions": ["Avoid changes in code blocks and data tables."], "status": "active"} diff --git a/spec/rules/punctuation/v1_punctuation_001.ndjson b/spec/rules/punctuation/v1_punctuation_001.ndjson new file mode 100644 index 0000000..ccda7b6 --- /dev/null +++ b/spec/rules/punctuation/v1_punctuation_001.ndjson @@ -0,0 +1,15 @@ +{"id":"CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT","title":"Use the serial comma by default","source_refs":["CMOS18 §6.19 p385 (scan p407)"],"category":"punctuation","severity":"should","applies_to":"md","rule_text":"In running text, include a comma before the final conjunction in a simple series (serial comma) unless a profile/house style overrides.","rationale":"The serial comma reduces ambiguity in lists.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest adding a serial comma in simple three-item series when safe; do not rewrite complex lists automatically.","tags":["commas","serial_comma","lists"],"keywords":["serial comma","Oxford comma","list punctuation"],"dependencies":[],"exceptions":["Some house styles omit the serial comma; treat as profile override."],"status":"active"} +{"id":"CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY","title":"Use commas after introductory elements when needed for clarity","source_refs":["CMOS18 §6.26 p390 (scan p412)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"After a substantial introductory phrase or clause, use a comma when it improves readability; omit the comma for very short, unambiguous introductions.","rationale":"Introductory commas prevent misreading and help scanning.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["commas","manual_checklist=true"],"keywords":["introductory comma","introductory clause"],"dependencies":[],"exceptions":["Headlines and telegraphic styles may omit commas; follow the selected profile."],"status":"active"} +{"id":"CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF","title":"Set off nonrestrictive elements with commas","source_refs":["CMOS18 §6.17 p385 (scan p407)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"Use commas to set off nonrestrictive (parenthetical) clauses and phrases that add information but are not essential to the sentence’s core meaning.","rationale":"Properly marking nonrestrictive elements improves clarity and reduces misinterpretation.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["commas","clauses","manual_checklist=true"],"keywords":["nonrestrictive clause","parenthetical"],"dependencies":[],"exceptions":["Some technical writing prefers tighter punctuation; document any deviation in house rules."],"status":"active"} +{"id":"CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF","title":"Do not set off restrictive elements with commas","source_refs":["CMOS18 §6.29 p392 (scan p414)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"Do not use commas to set off restrictive clauses and phrases that are essential to identifying the noun they modify.","rationale":"Incorrectly comma-setting restrictive elements changes meaning.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["commas","clauses","manual_checklist=true"],"keywords":["restrictive clause","essential clause"],"dependencies":[],"exceptions":["If the distinction is ambiguous, prefer clarity and consider rewriting the sentence."],"status":"active"} +{"id":"CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE","title":"Use semicolons to separate complex list items","source_refs":["CMOS18 §6.64 p408 (scan p430)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"In a series where individual items contain internal commas, use semicolons to separate the major items to avoid ambiguity.","rationale":"Semicolons clarify nested list structure.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["semicolons","lists","manual_checklist=true"],"keywords":["semicolon","complex series","list items"],"dependencies":[],"exceptions":["Bulleted lists may reduce the need for semicolons; apply judgment based on layout."],"status":"active"} +{"id":"CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US","title":"Use em dashes without surrounding spaces (US style)","source_refs":["CMOS18 §6.89 p418 (scan p440)","CMOS18 §6.91 p418 (scan p440)"],"category":"punctuation","severity":"must","applies_to":"md","rule_text":"Use em dashes for parenthetical breaks in a sentence; in US style, set em dashes without surrounding spaces.","rationale":"Consistent dash styling improves readability and prevents mixed conventions.","enforcement":"lint","autofix":"rewrite","autofix_notes":"Normalize common em-dash patterns: convert spaced double-hyphen or spaced hyphen-hyphen to an em dash without spaces, excluding code blocks.","tags":["dashes","em_dash"],"keywords":["em dash","dash spacing","double hyphen"],"dependencies":[],"exceptions":["Some publishers use spaced em dashes; treat as profile override."],"status":"active"} +{"id":"CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES","title":"Use en dashes for numeric ranges","source_refs":["CMOS18 §6.83 p415 (scan p437)"],"category":"punctuation","severity":"should","applies_to":"md","rule_text":"Use en dashes (not hyphens) to express simple numeric ranges (pages, dates, amounts) when the range is written in numerals.","rationale":"En dashes disambiguate ranges from hyphenation and minus signs.","enforcement":"lint","autofix":"rewrite","autofix_notes":"Rewrite digit-hyphen-digit patterns into digit–digit (en dash) when both sides are numerals and the hyphen is not a leading minus; skip code blocks.","tags":["dashes","en_dash","ranges"],"keywords":["en dash","range","page range","date range"],"dependencies":[],"exceptions":["Use ‘from … to …’ or ‘between … and …’ constructions rather than mixing with an en dash."],"status":"active"} +{"id":"CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN","title":"Hyphenate compound modifiers before a noun when needed","source_refs":["CMOS18 §7.91 p476 (scan p498)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"Hyphenate compound modifiers that precede a noun when the hyphen prevents ambiguity; do not automatically hyphenate compounds used after the noun.","rationale":"Hyphenation can prevent misreading of compound adjectives.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["hyphenation","compounds","manual_checklist=true"],"keywords":["compound modifier","hyphenation","ambiguity"],"dependencies":[],"exceptions":["Familiar open compounds and proper names may follow established usage; document house preferences."],"status":"active"} +{"id":"CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN","title":"Do not hyphenate -ly adverbs with following adjectives","source_refs":["CMOS18 §7.93 p476 (scan p498)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"Do not hyphenate a compound consisting of an adverb ending in -ly and the adjective it modifies (e.g., ‘highly detailed’).","rationale":"-ly adverbs already mark the relationship and typically do not need hyphens.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["hyphenation","manual_checklist=true"],"keywords":["-ly","adverb","hyphenation"],"dependencies":[],"exceptions":["Some fixed expressions may be hyphenated in sources; preserve quotations verbatim."],"status":"active"} +{"id":"CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT","title":"Format ellipses consistently","source_refs":["CMOS18 §12.59 p760 (scan p782)"],"category":"punctuation","severity":"should","applies_to":"md","rule_text":"Represent ellipses consistently across the document (either a single ellipsis character or three periods). When omitting text, ensure spacing and punctuation remain readable.","rationale":"Inconsistent ellipsis formatting looks like copy/paste noise and harms legibility.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest normalizing runs of periods to the configured ellipsis style outside code blocks; avoid rewriting quotations.","tags":["ellipsis","consistency"],"keywords":["ellipsis","three dots","omission"],"dependencies":[],"exceptions":["Quoted material must preserve the source’s punctuation unless editorial policy permits normalization with a note."],"status":"active"} +{"id":"CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US","title":"Use double quotation marks as the primary quotation marks (US style)","source_refs":["CMOS18 §6.122 p428 (scan p450)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"In US style, use double quotation marks for primary quotations and single quotation marks for quotations within quotations.","rationale":"A consistent quotation hierarchy reduces ambiguity.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["quotation_marks","manual_checklist=true"],"keywords":["quotation marks","double quotes","single quotes","nested quotes"],"dependencies":[],"exceptions":["Code, identifiers, and literal strings may use single quotes by convention; do not rewrite code."],"status":"active"} +{"id":"CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US","title":"Place commas and periods inside closing quotation marks (US style)","source_refs":["CMOS18 §6.122 p428 (scan p450)"],"category":"punctuation","severity":"should","applies_to":"all","rule_text":"In US style, place commas and periods inside closing quotation marks; place other punctuation (such as colons and semicolons) outside unless it belongs to the quoted matter.","rationale":"Consistent punctuation placement is a high-signal editorial correctness marker.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["quotation_marks","punctuation_placement","manual_checklist=true"],"keywords":["punctuation with quotes","commas in quotes","periods in quotes"],"dependencies":[],"exceptions":["Technical docs may quote code where punctuation is literal; preserve code exactly."],"status":"active"} +{"id":"CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS","title":"Omit quotation marks for block quotations","source_refs":["CMOS18 §12.31 p746 (scan p768)"],"category":"punctuation","severity":"must","applies_to":"md","rule_text":"When a quotation is formatted as a block quote, omit quotation marks and use the block formatting to signal quoted status.","rationale":"Block quotes should not carry redundant quotation marks; it clutters the page.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest removing leading/trailing quote marks around Markdown blockquotes when the content is a single continuous quotation, excluding cases with nested quotes.","tags":["block_quotes","quotation_marks"],"keywords":["block quotation","blockquote","quotation marks"],"dependencies":[],"exceptions":["If a block quote contains multiple quoted fragments, retain internal quotation marks as needed."],"status":"active"} +{"id":"CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING","title":"Avoid stacking multiple punctuation marks","source_refs":["CMOS18 §6.131 p432 (scan p454)"],"category":"punctuation","severity":"warn","applies_to":"md","rule_text":"Avoid stacking punctuation marks (e.g., ‘?!’, ‘!!’, multiple consecutive question marks) in formal prose; prefer the single mark that conveys the intended meaning.","rationale":"Punctuation stacking reads as informal and weakens editorial tone.","enforcement":"lint","autofix":"suggest","autofix_notes":"Flag repeated punctuation runs outside code blocks and suggest a single appropriate mark.","tags":["punctuation_runs"],"keywords":["multiple punctuation","?!","!!"],"dependencies":[],"exceptions":["Direct quotations may preserve emphatic punctuation; keep quotes verbatim."],"status":"active"} +{"id":"CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE","title":"Ensure parentheses and brackets are balanced","source_refs":["CMOS18 §6.101 p422 (scan p444)","CMOS18 §6.103 p422 (scan p444)"],"category":"punctuation","severity":"must","applies_to":"md","rule_text":"Ensure parentheses and brackets are balanced and properly nested in running text.","rationale":"Unbalanced delimiters are a high-signal correctness failure.","enforcement":"lint","autofix":"none","autofix_notes":"","tags":["parentheses","brackets"],"keywords":["parentheses","brackets","balance"],"dependencies":[],"exceptions":["Code blocks and inline code may include unmatched delimiters as literals; exclude from lint."],"status":"active"} diff --git a/spec/rules/punctuation/v1_punctuation_002.ndjson b/spec/rules/punctuation/v1_punctuation_002.ndjson new file mode 100644 index 0000000..c80e088 --- /dev/null +++ b/spec/rules/punctuation/v1_punctuation_002.ndjson @@ -0,0 +1,40 @@ +{"id": "CMOS.PUNCTUATION.PERIODS.USE", "title": "Use periods to end declarative sentences", "source_refs": ["CMOS18 \u00a76.12 p383 (scan p405)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "End declarative sentences with a period in running prose; avoid dropping terminal punctuation in formal text.", "rationale": "Terminal punctuation prevents run-on reading.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "periods"], "keywords": ["periods", "terminal punctuation"], "dependencies": [], "exceptions": ["Display typography and headlines may omit periods by style."], "status": "active"} +{"id": "CMOS.PUNCTUATION.PERIODS.WITH_PARENS", "title": "Place periods correctly with parentheses", "source_refs": ["CMOS18 \u00a76.13 p383 (scan p405)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "When a parenthetical is a complete sentence, place the period inside the closing parenthesis; otherwise keep punctuation outside.", "rationale": "Placement clarifies whether the parenthetical stands alone.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "periods", "parentheses"], "keywords": ["parentheses", "period placement"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.COMPOUND_PREDICATES", "title": "Avoid commas between verbs in compound predicates", "source_refs": ["CMOS18 \u00a76.24 p388 (scan p410)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Do not insert a comma between verbs in a compound predicate unless needed for clarity.", "rationale": "Unnecessary commas interrupt sentence flow.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["compound predicate", "commas"], "dependencies": [], "exceptions": ["Insert a comma when needed to avoid misreading."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.DEPENDENT_CLAUSE_AFTER", "title": "Omit commas before dependent clauses that follow", "source_refs": ["CMOS18 \u00a76.27 p390 (scan p412)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "When a dependent clause follows the main clause, omit the comma unless needed for clarity.", "rationale": "Avoiding unnecessary commas keeps prose tight.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["dependent clause", "comma placement"], "dependencies": [], "exceptions": ["Insert a comma if it prevents ambiguity."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.APPOSITIVES", "title": "Use commas with appositives only when nonrestrictive", "source_refs": ["CMOS18 \u00a76.30 p392 (scan p414)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use commas to set off nonrestrictive appositives; avoid commas for restrictive appositives.", "rationale": "Comma placement changes meaning with appositives.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["appositives", "restrictive"], "dependencies": [], "exceptions": ["If the distinction is unclear, rewrite for clarity."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.DESCRIPTIVE_PHRASES", "title": "Set off nonessential descriptive phrases with commas", "source_refs": ["CMOS18 \u00a76.32 p394 (scan p416)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Set off descriptive phrases with commas when they are nonessential; omit commas when they are essential to identification.", "rationale": "Comma choice signals whether information is essential.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["descriptive phrases", "comma use"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.PARTICIPIAL_PHRASES", "title": "Use commas with participial phrases when nonrestrictive", "source_refs": ["CMOS18 \u00a76.33 p394 (scan p416)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use commas to set off participial phrases when they are introductory or nonrestrictive; omit when essential.", "rationale": "Participial phrases can be restrictive or parenthetical.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["participial phrases", "comma use"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_PHRASES", "title": "Use commas after substantial introductory phrases", "source_refs": ["CMOS18 \u00a76.36 p396 (scan p418)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use a comma after a substantial introductory phrase when it improves readability; omit for very short phrases.", "rationale": "Introductory commas help readers parse sentence structure.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["introductory phrases", "comma use"], "dependencies": [], "exceptions": ["Headlines and telegraphic styles may omit such commas."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.REPEATED_ADJECTIVES", "title": "Use commas between coordinate adjectives", "source_refs": ["CMOS18 \u00a76.40 p398 (scan p420)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Separate coordinate adjectives with commas when they independently modify the noun.", "rationale": "Commas clarify adjective relationships.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["coordinate adjectives", "comma use"], "dependencies": [], "exceptions": ["Do not separate cumulative adjectives with commas."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.DATES", "title": "Use commas in month-day-year dates in prose", "source_refs": ["CMOS18 \u00a76.41 p398 (scan p420)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use commas in month-day-year dates in running text; do not use them in day-month-year or all-numeral formats.", "rationale": "Comma placement signals date order.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas", "dates"], "keywords": ["dates", "comma placement"], "dependencies": [], "exceptions": ["Tables may use numeric formats without commas for alignment."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.ADDRESSES", "title": "Use commas to separate address elements in running text", "source_refs": ["CMOS18 \u00a76.42 p398 (scan p420)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use commas to separate elements in running-text addresses; avoid commas in postal address blocks.", "rationale": "Address punctuation differs between prose and postal formats.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["addresses", "commas"], "dependencies": [], "exceptions": ["Address blocks should follow postal standards without commas."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.QUOTED_TITLES", "title": "Place commas around quoted titles based on sentence structure", "source_refs": ["CMOS18 \u00a76.44 p400 (scan p422)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Place a comma after a quoted or italicized title only when the sentence requires it.", "rationale": "Punctuation should follow syntax, not decoration.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["quoted titles", "comma placement"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.COMMAS.QUESTIONS", "title": "Use commas with embedded questions only when required", "source_refs": ["CMOS18 \u00a76.45 p400 (scan p422)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use commas to set off a direct question embedded in a sentence only when required by structure.", "rationale": "Over-punctuation can obscure the question.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "commas"], "keywords": ["embedded questions", "comma use"], "dependencies": [], "exceptions": ["Rewrite if punctuation rules create awkward phrasing."], "status": "active"} +{"id": "CMOS.PUNCTUATION.SEMICOLONS.CONJUNCTIVE_PHRASES", "title": "Use semicolons before conjunctive phrases joining clauses", "source_refs": ["CMOS18 \u00a76.62 p408 (scan p430)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use a semicolon before conjunctive phrases like that is or for example when they join independent clauses.", "rationale": "Semicolons prevent comma splices in complex sentences.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "semicolons"], "keywords": ["semicolons", "conjunctive phrases"], "dependencies": [], "exceptions": ["A comma may be acceptable in short, informal sentences."], "status": "active"} +{"id": "CMOS.PUNCTUATION.SEMICOLONS.BEFORE_CONJUNCTION", "title": "Use semicolons before conjunctions in complex sentences", "source_refs": ["CMOS18 \u00a76.63 p408 (scan p430)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "A semicolon may precede a conjunction joining independent clauses when the separation needs emphasis or clarity.", "rationale": "Semicolons clarify long or complex clauses.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "semicolons"], "keywords": ["semicolons", "conjunctions"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.COLONS.SPACE_AFTER", "title": "Use a single space after a colon", "source_refs": ["CMOS18 \u00a76.66 p410 (scan p432)"], "category": "punctuation", "severity": "must", "applies_to": "md", "rule_text": "Use a single space after a colon in prose; avoid double spaces.", "rationale": "Extra spaces create inconsistent rhythm.", "enforcement": "lint", "autofix": "rewrite", "autofix_notes": "Normalize runs of spaces after a colon to a single space outside code blocks.", "tags": ["colons", "spacing"], "keywords": ["colon spacing", "single space"], "dependencies": [], "exceptions": ["Code and literal text may preserve spacing."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COLONS.CAPITALIZATION", "title": "Capitalize after a colon only when a full sentence follows", "source_refs": ["CMOS18 \u00a76.67 p410 (scan p432)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Capitalize the first word after a colon only when it introduces a complete sentence or a quotation per style.", "rationale": "Capitalization cues sentence structure.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "colons"], "keywords": ["colon capitalization", "sentence after colon"], "dependencies": [], "exceptions": ["Headlines or display text may follow different rules."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COLONS.AS_FOLLOWS", "title": "Use a colon after introductions like as follows", "source_refs": ["CMOS18 \u00a76.68 p410 (scan p432)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use a colon after introductions such as as follows or the following; avoid a colon after an incomplete clause.", "rationale": "Colons should follow a complete lead-in.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "colons"], "keywords": ["as follows", "colon usage"], "dependencies": [], "exceptions": ["In informal prose, a dash may replace a colon by choice."], "status": "active"} +{"id": "CMOS.PUNCTUATION.COLONS.INTRO_QUOTE_QUESTION", "title": "Use a colon to introduce quotations or questions when appropriate", "source_refs": ["CMOS18 \u00a76.69 p410 (scan p432)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "A colon may introduce a quotation or question when the lead-in is a complete clause.", "rationale": "Colons signal that what follows completes the lead-in.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "colons"], "keywords": ["quotation introduction", "colon usage"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.QUESTION_MARK.USE", "title": "Use question marks for direct questions", "source_refs": ["CMOS18 \u00a76.72 p412 (scan p434)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use a question mark for direct questions; do not add question marks to declarative statements.", "rationale": "Question marks mark interrogative sentences.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "question_marks"], "keywords": ["question marks", "direct questions"], "dependencies": [], "exceptions": ["Quoted material should preserve the source punctuation."], "status": "active"} +{"id": "CMOS.PUNCTUATION.QUESTION_MARK.DIRECT_VS_INDIRECT", "title": "Avoid question marks for indirect questions", "source_refs": ["CMOS18 \u00a76.73 p412 (scan p434)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Do not use a question mark for indirect questions unless the whole sentence is interrogative.", "rationale": "Indirect questions are statements.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "question_marks"], "keywords": ["indirect questions", "question marks"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.QUESTION_MARK.WITH_PUNCT", "title": "Do not stack question marks with other terminal punctuation", "source_refs": ["CMOS18 \u00a76.74 p413 (scan p435)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "When a question mark applies to the whole sentence, it replaces the period; avoid stacking terminal marks.", "rationale": "Stacked punctuation reads as informal and unclear.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "question_marks"], "keywords": ["stacked punctuation", "question marks"], "dependencies": [], "exceptions": ["Quoted material may preserve source punctuation."], "status": "active"} +{"id": "CMOS.PUNCTUATION.EXCLAMATION.USE_SPARINGLY", "title": "Use exclamation points sparingly in formal prose", "source_refs": ["CMOS18 \u00a76.75 p413 (scan p435)"], "category": "punctuation", "severity": "warn", "applies_to": "all", "rule_text": "Use exclamation points sparingly in formal prose; prefer declarative punctuation for emphasis.", "rationale": "Exclamation points can weaken a formal tone.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "exclamation_points"], "keywords": ["exclamation points", "tone"], "dependencies": [], "exceptions": ["Quoted dialogue may retain exclamation marks."], "status": "active"} +{"id": "CMOS.PUNCTUATION.EXCLAMATION.VS_QUESTION", "title": "Use question marks for questions even when emphatic", "source_refs": ["CMOS18 \u00a76.76 p413 (scan p435)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use a question mark for interrogative sentences even if the tone is forceful; avoid substituting exclamation points.", "rationale": "Question marks signal grammatical form, not tone.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "exclamation_points", "question_marks"], "keywords": ["questions", "exclamation points"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.DASHES.EM_LINE_BREAKS", "title": "Avoid line breaks adjacent to em dashes", "source_refs": ["CMOS18 \u00a76.96 p420 (scan p442)"], "category": "punctuation", "severity": "should", "applies_to": "pdf", "rule_text": "Avoid line breaks immediately before or after an em dash in justified text; reflow if needed.", "rationale": "Em-dash line breaks create awkward spacing.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Adjust line breaking or tracking to keep em dashes off line boundaries; re-render to confirm.", "tags": ["dashes", "line_breaks"], "keywords": ["em dash", "line breaks"], "dependencies": [], "exceptions": ["Very narrow columns may require a break; record as QA warning."], "status": "active"} +{"id": "CMOS.PUNCTUATION.DASHES.EM_INSTEAD_OF_QUOTES", "title": "Avoid using em dashes as quotation marks in formal prose", "source_refs": ["CMOS18 \u00a76.97 p420 (scan p442)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Do not substitute em dashes for quotation marks in formal prose unless the style explicitly calls for it.", "rationale": "Em-dash dialogue conventions are not universal.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "dashes"], "keywords": ["em dash", "quotation marks"], "dependencies": [], "exceptions": ["Fiction styles that use em-dash dialogue are exempt."], "status": "active"} +{"id": "CMOS.PUNCTUATION.PARENS.USE", "title": "Use parentheses for incidental material", "source_refs": ["CMOS18 \u00a76.101 p422 (scan p444)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use parentheses for incidental, nonessential material; do not use them where commas or dashes would be clearer.", "rationale": "Overuse of parentheses interrupts reading flow.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "parentheses"], "keywords": ["parentheses", "incidental material"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.PARENS.GLOSSES_TRANSLATIONS", "title": "Use parentheses for brief glosses or translations", "source_refs": ["CMOS18 \u00a76.102 p422 (scan p444)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use parentheses to provide brief glosses or translations in running text.", "rationale": "Parentheses signal supplementary information.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "parentheses"], "keywords": ["glosses", "translations"], "dependencies": [], "exceptions": ["Footnotes may be preferable for long explanations."], "status": "active"} +{"id": "CMOS.PUNCTUATION.PARENS.NESTING", "title": "Use brackets for parentheses within parentheses", "source_refs": ["CMOS18 \u00a76.103 p422 (scan p444)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "When parentheses are nested, use a different enclosure (such as brackets) for the inner level.", "rationale": "Distinct enclosures reduce nesting confusion.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "parentheses", "brackets"], "keywords": ["nested parentheses", "brackets"], "dependencies": [], "exceptions": ["Math notation may use other bracket conventions."], "status": "active"} +{"id": "CMOS.PUNCTUATION.BRACKETS.TRANSLATED_TEXT", "title": "Use square brackets for editorial insertions in translations", "source_refs": ["CMOS18 \u00a76.106 p424 (scan p446)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use square brackets to mark editorial insertions or clarifications in translated text.", "rationale": "Brackets distinguish editor additions from the translation.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "brackets"], "keywords": ["translated text", "editorial insertions"], "dependencies": [], "exceptions": [], "status": "active"} +{"id": "CMOS.PUNCTUATION.BRACKETS.NESTED_PARENS", "title": "Use brackets for parentheses within parentheses", "source_refs": ["CMOS18 \u00a76.107 p424 (scan p446)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use square brackets when you need parentheses inside parentheses to avoid confusion.", "rationale": "Distinct enclosures clarify structure.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "brackets"], "keywords": ["nested punctuation", "brackets"], "dependencies": [], "exceptions": ["Math notation may use braces or other conventions."], "status": "active"} +{"id": "CMOS.PUNCTUATION.SLASHES.ALTERNATIVES", "title": "Use slashes only for true alternatives", "source_refs": ["CMOS18 \u00a76.113 p426 (scan p448)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use slashes for true alternatives only; prefer words when clarity is at risk.", "rationale": "Slashes can be ambiguous without context.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "slashes"], "keywords": ["slashes", "alternatives"], "dependencies": [], "exceptions": ["Technical abbreviations may use slashes by convention."], "status": "active"} +{"id": "CMOS.PUNCTUATION.SLASHES.TWO_YEAR_SPANS", "title": "Use slashes for two-year spans only when the style allows", "source_refs": ["CMOS18 \u00a76.114 p426 (scan p448)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use slashes for two-year spans only when the style allows; otherwise use an en dash or words.", "rationale": "Consistent range notation avoids confusion.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "slashes"], "keywords": ["two-year spans", "slashes"], "dependencies": [], "exceptions": ["Budget years may follow institutional slash styles."], "status": "active"} +{"id": "CMOS.PUNCTUATION.QUOTES.SMART_QUOTES", "title": "Use typographic quotation marks in published output", "source_refs": ["CMOS18 \u00a76.123 p428 (scan p450)"], "category": "punctuation", "severity": "must", "applies_to": "md", "rule_text": "Use typographic (smart) quotation marks in published output; avoid straight quotes outside code.", "rationale": "Smart quotes improve typographic quality and readability.", "enforcement": "lint", "autofix": "rewrite", "autofix_notes": "Convert straight quotes to typographic quotes in prose; skip code blocks and inline code.", "tags": ["quotation_marks", "degraded_input"], "keywords": ["smart quotes", "quotation marks"], "dependencies": [], "exceptions": ["Literal strings and code must preserve straight quotes."], "status": "active"} +{"id": "CMOS.PUNCTUATION.HYPHENATION.GENERAL_CHOICE", "title": "Hyphenate only when it improves clarity", "source_refs": ["CMOS18 \u00a77.87 p474 (scan p496)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Hyphenate compounds only when doing so improves clarity; avoid unnecessary hyphens in familiar closed compounds.", "rationale": "Unnecessary hyphens add visual noise.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "hyphenation"], "keywords": ["hyphenation", "compounds"], "dependencies": [], "exceptions": ["Follow dictionary or house lists for specific compounds."], "status": "active"} +{"id": "CMOS.PUNCTUATION.HYPHENATION.COMPOUND_DEFINITION", "title": "Differentiate open, hyphenated, and closed compounds", "source_refs": ["CMOS18 \u00a77.88 p475 (scan p497)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Treat open, hyphenated, and closed compounds distinctly and follow dictionary or house usage for each.", "rationale": "Compound form affects readability and meaning.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "hyphenation"], "keywords": ["open compounds", "closed compounds"], "dependencies": [], "exceptions": ["Technical terms may follow domain-specific conventions."], "status": "active"} +{"id": "CMOS.PUNCTUATION.HYPHENATION.TREND_CLOSED", "title": "Prefer closed forms for well-established compounds", "source_refs": ["CMOS18 \u00a77.89 p475 (scan p497)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Prefer closing compounds that are well established; avoid hyphens that the dictionary no longer supports.", "rationale": "Closed forms reflect common usage and reduce clutter.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "hyphenation"], "keywords": ["closed compounds", "dictionary usage"], "dependencies": [], "exceptions": ["Hyphenate when necessary for clarity or according to house style."], "status": "active"} +{"id": "CMOS.PUNCTUATION.HYPHENATION.READABILITY", "title": "Use hyphens to prevent misreading", "source_refs": ["CMOS18 \u00a77.90 p475 (scan p497)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use a hyphen when it prevents misreading in compound modifiers or ambiguous phrases.", "rationale": "Hyphens can disambiguate compound meaning.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "hyphenation"], "keywords": ["hyphenation", "clarity"], "dependencies": [], "exceptions": ["Do not hyphenate familiar open compounds that are unambiguous."], "status": "active"} +{"id": "CMOS.PUNCTUATION.HYPHENATION.SUSPENDED", "title": "Use suspended hyphens only in clear parallel structures", "source_refs": ["CMOS18 \u00a77.95 p477 (scan p499)"], "category": "punctuation", "severity": "should", "applies_to": "all", "rule_text": "Use suspended hyphens only when the shared element is clear and the parallel structure is short.", "rationale": "Suspended hyphens can confuse readers if overused.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "hyphenation"], "keywords": ["suspended hyphens", "parallel structure"], "dependencies": [], "exceptions": ["If clarity suffers, repeat the full compound instead."], "status": "active"} +{"id": "CMOS.PUNCTUATION.DEGRADED.EXTRA_SPACES.AFTER_PUNCT", "title": "Normalize extra spaces after punctuation in degraded text", "source_refs": ["CMOS18 \u00a76.66 p410 (scan p432)"], "category": "punctuation", "severity": "warn", "applies_to": "md", "rule_text": "Normalize multiple spaces after punctuation that arise from OCR or hard wraps; keep a single space in prose.", "rationale": "Extra spaces break text rhythm and complicate reflow.", "enforcement": "lint", "autofix": "rewrite", "autofix_notes": "Collapse runs of spaces after punctuation to a single space outside code blocks.", "tags": ["degraded_input", "spacing"], "keywords": ["OCR", "extra spaces", "punctuation"], "dependencies": [], "exceptions": ["Preserve spacing in code blocks and preformatted text."], "status": "active"} diff --git a/spec/rules/tables/v1_tables_001.ndjson b/spec/rules/tables/v1_tables_001.ndjson new file mode 100644 index 0000000..19eb862 --- /dev/null +++ b/spec/rules/tables/v1_tables_001.ndjson @@ -0,0 +1,8 @@ +{"id":"BRING.TABLES.EDIT_AS_TEXT.READABILITY","title":"Edit tables as carefully as prose and set them to be read","source_refs":["BRING §4.4.1 p70 (scan p69)"],"category":"tables","severity":"should","applies_to":"all","rule_text":"Treat tables as text that must be readable; editorial cleanup and structure clarity matter as much as typographic alignment.","rationale":"Unreadable tables are usually an editorial problem first.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true"],"keywords":["tables","readability","editorial"],"dependencies":[],"exceptions":["For purely decorative tables, consider converting to a figure instead of forcing tabular typesetting."],"status":"active"} +{"id":"BRING.TABLES.FURNITURE.MINIMIZE","title":"Minimize table furniture and maximize information","source_refs":["BRING §4.4.1 p70 (scan p69)"],"category":"tables","severity":"should","applies_to":"all","rule_text":"Use the minimum necessary rules/boxes/dots; prefer whitespace and alignment for structure whenever possible.","rationale":"Excess rules add noise and reduce clarity.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Flag heavy grid styles and suggest reducing borders in favor of spacing and alignment.","tags":["tables","furniture"],"keywords":["table rules","gridlines","whitespace","alignment"],"dependencies":[],"exceptions":["Dense financial tables may require light rules; keep them subtle and consistent."],"status":"active"} +{"id":"BRING.TABLES.TEXT_ORIENTATION.HORIZONTAL","title":"Keep table text horizontal (avoid vertical column headers)","source_refs":["BRING §4.4.1 p70 (scan p69)"],"category":"tables","severity":"should","applies_to":"all","rule_text":"Avoid rotating column headers as a space-saving trick in Latin-alphabet documents; redesign the table instead of forcing vertical reading.","rationale":"Vertical headers are slower to read and easier to misinterpret.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true"],"keywords":["column headers","rotation","tables"],"dependencies":[],"exceptions":["Non-Latin scripts with vertical reading conventions may allow different orientation choices."],"status":"active"} +{"id":"BRING.TABLES.TYPE_SIZE.READABLE","title":"Do not solve table overflow by making type illegible","source_refs":["BRING §4.4.1 p70 (scan p69)"],"category":"tables","severity":"should","applies_to":"pdf","rule_text":"Avoid shrinking or condensing table type below comfortable readability; prefer reflow, wrapping, or rethinking the table structure.","rationale":"Legibility is more important than packing density.","enforcement":"typeset","autofix":"suggest","autofix_notes":"If table scaling exceeds the profile shrink limit, suggest wrapping or splitting the table.","tags":["tables","overflow"],"keywords":["table overflow","shrink limit","legibility"],"dependencies":[],"exceptions":["Very large appendices may use smaller type if the profile explicitly permits it."],"status":"active"} +{"id":"BRING.TABLES.GUIDES.READING_DIRECTION","title":"Guides and dividers should follow the reading direction","source_refs":["BRING §4.4.1 p70 (scan p69)"],"category":"tables","severity":"should","applies_to":"all","rule_text":"When guides (rules, tint blocks) are necessary, align them with the dominant reading direction (horizontal for most tables; vertical for certain lists/indices).","rationale":"Directional cues should match how readers scan the data.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true"],"keywords":["table guides","rules","reading direction","lists"],"dependencies":[],"exceptions":["Heatmap-style tables may use tint blocks if they improve data comprehension."],"status":"active"} +{"id":"HOUSE.TABLES.OVERFLOW.NO_CLIPPING","title":"Tables must not clip off-page; overflow must be handled explicitly","source_refs":["HOUSE §QA.TABLE_OVERFLOW p2"],"category":"tables","severity":"must","applies_to":"pdf","rule_text":"If a table exceeds the available measure, the renderer must wrap/shrink/split according to profile policy; clipping is never acceptable.","rationale":"Clipped tables silently destroy data.","enforcement":"postrender","autofix":"reflow","autofix_notes":"When a table overflow incident is detected, re-render using a stricter overflow strategy (wrap → bounded shrink → split).","tags":["overflow","tables"],"keywords":["table overflow","clipping","reflow"],"dependencies":[],"exceptions":["If a table cannot be reflowed without altering meaning, emit a clear overflow warning and require manual intervention."],"status":"active"} +{"id":"HOUSE.TABLES.HEADERS.REPEAT_ON_PAGEBREAK","title":"Repeat table header rows when tables break across pages","source_refs":["HOUSE §QA.TABLE_OVERFLOW p2"],"category":"tables","severity":"should","applies_to":"pdf","rule_text":"When a table spans multiple pages, repeat the header row to preserve meaning on each page.","rationale":"Readers should not need to flip back to decode columns.","enforcement":"typeset","autofix":"suggest","autofix_notes":"If the renderer supports repeated headers, enable it for multi-page tables; otherwise warn.","tags":["tables","header_repeat"],"keywords":["repeat headers","multi-page tables"],"dependencies":[],"exceptions":["For very small tables, header repetition is unnecessary."],"status":"active"} +{"id":"HOUSE.TABLES.ALIGNMENT.DECIMALS","title":"Align numeric columns by decimal when possible","source_refs":["HOUSE §QA.TABLE_OVERFLOW p2"],"category":"tables","severity":"should","applies_to":"all","rule_text":"For numeric columns, align values by decimal point (or consistent digit grouping) to make magnitude comparisons easier.","rationale":"Numeric alignment improves scanability.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Suggest decimal-aligned column styles for detected numeric columns.","tags":["tables","numbers"],"keywords":["decimal alignment","numeric columns","scanability"],"dependencies":[],"exceptions":["Mixed units or textual annotations may require ragged-right alignment."],"status":"active"} diff --git a/spec/rules/tables/v1_tables_002.ndjson b/spec/rules/tables/v1_tables_002.ndjson new file mode 100644 index 0000000..0900f5a --- /dev/null +++ b/spec/rules/tables/v1_tables_002.ndjson @@ -0,0 +1,15 @@ +{"id": "BRING.TABLES.TITLES.CONSISTENT_PLACEMENT", "title": "Place table titles consistently", "source_refs": ["BRING \u00a74.4 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Place table titles or labels consistently (above or below) and follow the same pattern throughout the document.", "rationale": "Consistent placement aids scanning.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["table titles", "placement"], "dependencies": [], "exceptions": ["If a style guide mandates placement, follow it consistently."], "status": "active"} +{"id": "BRING.TABLES.CAPTIONS.CLEAR", "title": "Write clear, descriptive table captions", "source_refs": ["BRING \u00a74.4 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Write table captions that state what the table shows, not just the topic.", "rationale": "Clear captions help readers interpret data.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["table captions", "clarity"], "dependencies": [], "exceptions": ["Very small tables may rely on surrounding text for context."], "status": "active"} +{"id": "BRING.TABLES.HEADERS.CONCISE", "title": "Keep column headers concise", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Keep column headers concise and readable; avoid unnecessary words.", "rationale": "Concise headers reduce visual clutter.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["column headers", "concise"], "dependencies": [], "exceptions": ["Regulatory tables may require full legal labels."], "status": "active"} +{"id": "BRING.TABLES.HEADERS.ALIGN_WITH_COLUMNS", "title": "Align headers with their data columns", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "pdf", "rule_text": "Align column headers with their data columns and avoid misalignment caused by decorative spacing.", "rationale": "Alignment helps readers map labels to data.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Adjust column widths and header alignment to match data columns.", "tags": ["tables", "typeset"], "keywords": ["header alignment", "columns"], "dependencies": [], "exceptions": ["Complex multi-level headers may need manual layout."], "status": "active"} +{"id": "BRING.TABLES.UNITS.IN_HEADERS", "title": "Put units in headers when possible", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Put units of measure in headers or stub labels rather than repeating them in every cell.", "rationale": "Shared units reduce repetition and improve readability.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables", "units"], "keywords": ["units", "headers"], "dependencies": [], "exceptions": ["If units vary by row, include them explicitly in the data."], "status": "active"} +{"id": "BRING.TABLES.STUB_COLUMN.USE", "title": "Use a stub column for row labels when needed", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Use a stub (row-header) column when it improves row identification.", "rationale": "Stub columns anchor row meaning.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["stub column", "row labels"], "dependencies": [], "exceptions": ["Very small tables may omit stubs if context is clear."], "status": "active"} +{"id": "BRING.TABLES.GROUPING.WHITESPACE", "title": "Use whitespace to group rows and columns", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "pdf", "rule_text": "Use whitespace and alignment to group rows and columns; avoid heavy rules when spacing will do.", "rationale": "Whitespace groups data without adding visual noise.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Reduce heavy rules and increase row or column spacing where possible.", "tags": ["tables", "typeset"], "keywords": ["whitespace", "grouping"], "dependencies": [], "exceptions": ["Dense financial tables may require light rules for clarity."], "status": "active"} +{"id": "BRING.TABLES.ROW_SPACING.READABLE", "title": "Keep row spacing readable", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "pdf", "rule_text": "Maintain adequate row spacing for readability; avoid crowding rows to fit width.", "rationale": "Crowded rows make tables hard to scan.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Increase row spacing or split the table when rows are crowded.", "tags": ["tables", "typeset"], "keywords": ["row spacing", "readability"], "dependencies": [], "exceptions": ["Very compact tables may accept tighter spacing if legibility remains."], "status": "active"} +{"id": "BRING.TABLES.COLUMN_ALIGNMENT.CONSISTENT", "title": "Align data consistently by type", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "pdf", "rule_text": "Align data consistently by type (text left, numerals right or decimal-aligned).", "rationale": "Consistent alignment improves scanability.", "enforcement": "typeset", "autofix": "suggest", "autofix_notes": "Apply column alignment rules based on detected data types.", "tags": ["tables", "typeset"], "keywords": ["alignment", "numeric columns"], "dependencies": [], "exceptions": ["Mixed-content columns may require manual alignment decisions."], "status": "active"} +{"id": "BRING.TABLES.SOURCES.NOTES.DISTINCT", "title": "Separate table notes and sources from data", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Separate table notes and source lines from the data body with spacing or a clear separator.", "rationale": "Distinct notes prevent misreading data as footnotes.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["table notes", "sources"], "dependencies": [], "exceptions": ["Very small tables may embed notes inline if clearly labeled."], "status": "active"} +{"id": "BRING.TABLES.MULTI_LINE_HEADERS.AVOID", "title": "Avoid stacked multi-line headers when possible", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Avoid multi-line stacked headers when possible; consider splitting or redesigning the table.", "rationale": "Stacked headers are slower to interpret.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["multi-line headers", "table redesign"], "dependencies": [], "exceptions": ["If stacking is required, ensure clear alignment and spacing."], "status": "active"} +{"id": "BRING.TABLES.NUMERIC_PRECISION.CONSISTENT", "title": "Keep numeric precision consistent within a column", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Use consistent numeric precision within a column unless variation is meaningful.", "rationale": "Consistent precision makes comparisons easier.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables", "numbers"], "keywords": ["precision", "numeric columns"], "dependencies": [], "exceptions": ["Scientific tables may vary precision intentionally; document the rule."], "status": "active"} +{"id": "BRING.TABLES.DATA_TYPES.NOT_MIXED", "title": "Avoid mixing text and numerals within a column", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Avoid mixing text and numerals within a single data column unless labeled clearly.", "rationale": "Mixed types reduce scanability and alignment.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["data types", "column consistency"], "dependencies": [], "exceptions": ["Annotated values may be necessary; use separate columns if possible."], "status": "active"} +{"id": "BRING.TABLES.ORDER.LOGICAL", "title": "Order rows and columns logically", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "should", "applies_to": "all", "rule_text": "Order rows and columns logically (alphabetical, chronological, or by magnitude) and document the choice.", "rationale": "Logical ordering helps readers find information.", "enforcement": "manual", "autofix": "none", "autofix_notes": "", "tags": ["manual_checklist=true", "tables"], "keywords": ["ordering", "row order"], "dependencies": [], "exceptions": ["Original source order may be retained if it carries meaning."], "status": "active"} +{"id": "BRING.TABLES.DEGRADED.REBUILD_COLUMNS", "title": "Rebuild column structure in degraded tables", "source_refs": ["BRING \u00a74.4.1 p70 (scan p69)"], "category": "tables", "severity": "warn", "applies_to": "md", "rule_text": "When OCR or imports collapse columns, reconstruct the column structure and flag uncertain alignments.", "rationale": "Degraded tables can misplace data values.", "enforcement": "lint", "autofix": "suggest", "autofix_notes": "Detect column-like spacing and suggest table reconstruction; flag low-confidence cells.", "tags": ["degraded_input", "tables"], "keywords": ["OCR", "table reconstruction"], "dependencies": [], "exceptions": ["Avoid auto-reconstruction for complex multi-level headers without review."], "status": "active"} diff --git a/spec/rules/typography/v1_typography_001.ndjson b/spec/rules/typography/v1_typography_001.ndjson new file mode 100644 index 0000000..8f7c70f --- /dev/null +++ b/spec/rules/typography/v1_typography_001.ndjson @@ -0,0 +1,8 @@ +{"id":"BRING.TYPOGRAPHY.SPACING.WORD_SPACE.DEFINE","title":"Define the word space relative to type size","source_refs":["BRING §2.1.1 p25"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Treat interword spacing as a first-class typographic parameter: aim for a consistent text texture appropriate to the face and size, and avoid extremes that create visible rivers or cramped color.","rationale":"Stable word spacing underpins readability and prevents distracting texture artifacts.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Suggest adjusting justification and word-spacing settings; requires human confirmation in rendered output.","tags":["word_spacing","justification","text_texture"],"keywords":["word space","word spacing","justification","rivers","text color"],"dependencies":[],"exceptions":["Display typography may intentionally vary spacing for effect."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.SPACING.SENTENCE_SPACE.SINGLE","title":"Use a single word space between sentences","source_refs":["BRING §2.1.4 p27"],"category":"typography","severity":"must","applies_to":"md","rule_text":"In body text, use one interword space between sentences; do not insert double spaces after terminal punctuation.","rationale":"Extra sentence spacing creates uneven rhythm and can break reflowed layouts.","enforcement":"lint","autofix":"rewrite","autofix_notes":"Normalize runs of 2+ spaces after sentence-ending punctuation to a single space, excluding code blocks and inline code.","tags":["sentence_spacing"],"keywords":["single space","double space","sentence spacing"],"dependencies":[],"exceptions":["Fixed-width/plaintext artifacts (e.g., ASCII tables) may require preservation."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.HYPHENATION.LANGUAGE_DICTIONARY.MATCH","title":"Hyphenation must follow the document language","source_refs":["BRING §2-4.6 p43"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Hyphenation decisions should follow the conventions of the text’s language; ensure the hyphenation dictionary matches the declared language(s).","rationale":"Wrong-language hyphenation yields incorrect breaks and reduces trust in the typeset output.","enforcement":"typeset","autofix":"suggest","autofix_notes":"If language metadata is missing or mismatched, recommend setting language explicitly for the document and sections before rendering.","tags":["hyphenation","i18n"],"keywords":["hyphenation","dictionary","language"],"dependencies":[],"exceptions":["Mixed-language documents may need per-section language tagging."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.HYPHENATION.AVOID_PROPER_NAMES","title":"Avoid hyphenating proper names when possible","source_refs":["BRING §2-4.5 p43"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Prefer not to hyphenate proper names; treat name breaks as a last resort and favor reflow or tracking adjustments first.","rationale":"Name breaks are visually jarring and can read as errors.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","hyphenation","proper_names"],"keywords":["proper names","hyphenation"],"dependencies":[],"exceptions":["Narrow measures may force a compromise; document the choice in QA notes."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.HYPHENATION.HARD_SPACES.SHORT_EXPRESSIONS","title":"Use hard spaces for short numeric expressions where appropriate","source_refs":["BRING §2-4.7 p43"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Prevent awkward line breaks in short numeric expressions (e.g., number + unit) by using non-breaking spacing where it improves clarity.","rationale":"Keeping tightly bound expressions together reduces misreading and layout noise.","enforcement":"lint","autofix":"suggest","autofix_notes":"Suggest inserting non-breaking spaces between common number-unit patterns when safe; never change inside code blocks.","tags":["non_breaking_space","numbers_units"],"keywords":["non-breaking space","number unit","line break"],"dependencies":[],"exceptions":["Do not force non-breaking behavior that causes overflow in narrow layouts."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.HYPHENATION.MIN_LEFT_RIGHT","title":"Set minimum characters around hyphen breaks","source_refs":["BRING §2-4.2 p42"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Configure hyphenation so that very short fragments are not left at either side of a hyphen break; require a minimum run of characters before and after the break.","rationale":"Tiny fragments look accidental and impair scanning.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Set hyphenation minimum-left and minimum-right parameters in the renderer profile.","tags":["hyphenation"],"keywords":["hyphenation","minimum left","minimum right"],"dependencies":[],"exceptions":["Languages with different hyphenation conventions may require different minima."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.HYPHENATION.MAX_CONSECUTIVE_LINES","title":"Limit consecutive hyphenated lines","source_refs":["BRING §2-4.4 p43"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Limit the number of consecutive hyphenated lines to reduce visual noise; prefer reflow or alternative breaks when a run of hyphenations would occur.","rationale":"Long runs of hyphens create a ragged texture that distracts from content.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Set a maximum consecutive hyphenated lines value in the renderer profile; requires hyphenation-aware layout engine.","tags":["hyphenation"],"keywords":["hyphenation","consecutive lines"],"dependencies":[],"exceptions":["Very narrow measures may require relaxing this rule; record as a QA warning."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.HYPHENATION.AVOID_AFTER_SHORT_LINE","title":"Avoid hyphenation following very short lines","source_refs":["BRING §2-4.8 p43"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Avoid hyphenating a word immediately after an unusually short line when it creates a pattern of broken rhythm; prefer adjusting line breaks instead.","rationale":"This pattern draws the eye to the margin and away from the text.","enforcement":"postrender","autofix":"none","autofix_notes":"","tags":["hyphenation","rhythm"],"keywords":["hyphenation","short line","rhythm"],"dependencies":[],"exceptions":["May be acceptable in very tight columns if overflow would otherwise occur."],"status":"active"} diff --git a/spec/rules/typography/v1_typography_002.ndjson b/spec/rules/typography/v1_typography_002.ndjson new file mode 100644 index 0000000..873940d --- /dev/null +++ b/spec/rules/typography/v1_typography_002.ndjson @@ -0,0 +1,7 @@ +{"id":"BRING.TYPOGRAPHY.SPACING.INITIALS.THIN_SPACE","title":"Use thin or no spaces in strings of initials","source_refs":["BRING §2.1.5 p30 (scan p29)"],"category":"typography","severity":"should","applies_to":"all","rule_text":"In sequences of personal initials separated by periods, avoid full word spaces between the initials; use very narrow spacing (or none) between the initial+period groups, then follow the final period with a normal word space before the surname.","rationale":"Full word spaces between initials create distracting gaps and uneven rhythm in running text.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","initials","spacing"],"keywords":["initials","W.B.","J.C.L.","hair space","thin space"],"dependencies":[],"exceptions":["If the output medium cannot represent thin spacing, prefer no extra space rather than a full word space."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.TRACKING.CAPS_SMALLCAPS_AND_LONG_DIGITS","title":"Add moderate tracking to caps/small caps and long digit strings","source_refs":["BRING §2.1.6 p30 (scan p29)"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Apply modest tracking to runs of ALL CAPS and Small Caps, and to long strings of digits, to improve legibility; keep the tracking subtle and verify that word and number recognition remain easy.","rationale":"Caps and long digit strings often need extra spacing to read cleanly at text sizes.","enforcement":"typeset","autofix":"suggest","autofix_notes":"When text is styled as all-caps or small caps, apply a conservative letter-spacing token; for long digit runs, allow a smaller tracking token. Validate in rendered output and avoid pushing words apart.","tags":["tracking","caps","small_caps","digits"],"keywords":["letterspacing","tracking","caps","small caps","digits"],"dependencies":[],"exceptions":["Do not apply this globally to mixed-case body text; restrict to explicit caps/small-caps styles and clearly identified digit runs."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.TRACKING.LOWERCASE.AVOID","title":"Avoid letterspacing lowercase without reason","source_refs":["BRING §2.1.7 p31 (scan p30)"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Do not apply letterspacing to lowercase body text by default. If tracking is used in lowercase for a specific design purpose, keep it minimal and review legibility.","rationale":"Lowercase letterspacing reduces legibility by disrupting word shapes and internal counters.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Ensure default body typography does not add letter-spacing to lowercase runs; if a display style applies tracking, confine it to short phrases and verify visually.","tags":["tracking","lowercase"],"keywords":["lowercase","letterspacing","tracking","legibility"],"dependencies":[],"exceptions":["Some display treatments may use tracking for effect; document the exception and verify readability."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.KERNING.CONSISTENT_OR_NONE","title":"Kern consistently and modestly (or not at all)","source_refs":["BRING §2.1.8 p32-33"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Use kerning consistently and modestly; inconsistent kerning is worse than none. Prefer font-provided kerning tables, and if automated kerning is applied, test and adjust only where it clearly improves consistency.","rationale":"Kerning errors and inconsistency stand out immediately and degrade trust in the typeset output.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","kerning"],"keywords":["kerning","kerning tables","pairs","consistency"],"dependencies":[],"exceptions":["Very small sizes and low-resolution output may limit perceived kerning benefits; prioritize consistency over micro-optimization."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.LETTERFORMS.AVOID_DISTORTION","title":"Don’t distort letterforms (avoid arbitrary width/shape edits)","source_refs":["BRING §2.1.9 p35 (scan p34)"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Do not arbitrarily condense, expand, or otherwise distort letterforms to make text fit. Prefer choosing a suitable face/measure, reflowing content, or using a designed condensed/expanded family instead of scaling letter shapes.","rationale":"Distorting letterforms harms readability and makes documents look unprofessional.","enforcement":"typeset","autofix":"suggest","autofix_notes":"Avoid applying horizontal/vertical scaling to body fonts in CSS/typeset tokens. Prefer wrap/reflow and conservative overflow policies before any shrink/scale adjustments.","tags":["letterforms","distortion","scaling"],"keywords":["condense","expand","distort","scaleX","font stretch"],"dependencies":[],"exceptions":["If a constrained medium forces a compromise, keep scaling minimal and record as a QA note for sign-off."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.ALIGNMENT.DONT_STRETCH_SPACE","title":"Don’t stretch spacing to force alignment (use leaders/tables instead)","source_refs":["BRING §2.1.10 p35 (scan p34)"],"category":"typography","severity":"should","applies_to":"md","rule_text":"Avoid using stretched or repeated spaces to align text in running copy (including table-of-contents and list-like layouts). Use proper tables, tab stops, or leader mechanisms instead of forcing alignment with whitespace.","rationale":"Whitespace-for-alignment breaks reflow, creates uneven texture, and fails across devices and renderers.","enforcement":"lint","autofix":"suggest","autofix_notes":"Detect lines with alignment-by-spacing patterns (e.g., long runs of spaces) outside code blocks and suggest converting to a table or a structured list with explicit layout.","tags":["alignment","spacing","leaders"],"keywords":["dot leaders","tab leaders","alignment","tables","spacing"],"dependencies":[],"exceptions":["Preformatted/code blocks and ASCII tables should not be altered."],"status":"active"} +{"id":"BRING.TYPOGRAPHY.NUMBER_STRINGS.SPACE_FOR_READABILITY","title":"Consider spacing for long strings of numbers","source_refs":["BRING §2.1.6 p30 (scan p29)"],"category":"typography","severity":"should","applies_to":"all","rule_text":"Where long numeric strings (serial numbers, phone numbers, IDs) appear in running text, ensure they remain readable by avoiding cramped setting; use appropriate grouping or subtle spacing consistent with the document’s locale and typography.","rationale":"Long number strings can become illegible when set too tightly, especially at small sizes.","enforcement":"manual","autofix":"none","autofix_notes":"","tags":["manual_checklist=true","numbers","readability"],"keywords":["numbers","serial number","phone number","grouping"],"dependencies":[],"exceptions":["Do not change the semantic content of identifiers; only adjust spacing/grouping when it is already permitted by the source representation."],"status":"active"} diff --git a/spec/schema/rule.schema.json b/spec/schema/rule.schema.json new file mode 100644 index 0000000..2500cb4 --- /dev/null +++ b/spec/schema/rule.schema.json @@ -0,0 +1,181 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.invalid/iftypeset/spec/schema/rule.schema.json", + "title": "Publication-quality rule record", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "source_refs", + "category", + "severity", + "applies_to", + "rule_text", + "rationale", + "enforcement", + "autofix", + "autofix_notes", + "tags", + "keywords", + "dependencies", + "exceptions", + "status" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable rule identifier. Prefix must be one of CMOS, BRING, HOUSE.", + "minLength": 6, + "maxLength": 120, + "pattern": "^(CMOS|BRING|HOUSE)\\.[A-Z0-9_]+(?:\\.[A-Z0-9_]+)*$" + }, + "title": { + "type": "string", + "description": "Short human-readable rule title.", + "minLength": 4, + "maxLength": 160 + }, + "source_refs": { + "type": "array", + "description": "Pointers back to sources (not quotes). Prefer: CMOS18 §X.Y pN / BRING §X.Y pN / HOUSE §X.Y pN.", + "minItems": 1, + "items": { + "type": "string", + "minLength": 8, + "maxLength": 120, + "pattern": "^(CMOS18|BRING|HOUSE)\\s§[0-9A-Za-z][0-9A-Za-z._\\-]*\\s+p[0-9ivxlcdmIVXLCDM]+(?:-[0-9ivxlcdmIVXLCDM]+)?(?:\\s\\(scan p[0-9]+\\))?$" + } + }, + "category": { + "type": "string", + "description": "Primary taxonomy bucket.", + "enum": [ + "editorial", + "typography", + "layout", + "headings", + "citations", + "numbers", + "punctuation", + "abbreviations", + "links", + "tables", + "figures", + "code", + "frontmatter", + "backmatter", + "accessibility", + "i18n" + ] + }, + "severity": { + "type": "string", + "description": "Normativity level. MUST blocks release unless downgraded by profile.", + "enum": ["must", "should", "warn"] + }, + "applies_to": { + "type": "string", + "description": "Which pipeline stage(s) the rule targets.", + "enum": ["md", "html", "pdf", "all"] + }, + "rule_text": { + "type": "string", + "description": "Paraphrased rule statement (no long quotes). If exact wording matters, note: Exact wording required—refer to pointer.", + "minLength": 10, + "maxLength": 800 + }, + "rationale": { + "type": "string", + "description": "One-line rationale.", + "minLength": 5, + "maxLength": 200 + }, + "enforcement": { + "type": "string", + "description": "Primary enforcement mechanism.", + "enum": ["lint", "typeset", "postrender", "manual"] + }, + "autofix": { + "type": "string", + "description": "Autofix capability, if any.", + "enum": ["none", "rewrite", "reflow", "suggest"] + }, + "autofix_notes": { + "type": "string", + "description": "Notes describing what can be fixed and how/when. Keep short; never include book quotes.", + "maxLength": 400 + }, + "tags": { + "type": "array", + "description": "Compact labels for routing/search/overrides (e.g. manual_checklist=true, widows_orphans, hyphenation).", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 48, + "pattern": "^[a-z0-9][a-z0-9_.:\\-/]*(?:=[a-z0-9_.:\\-/]+)?$" + }, + "maxItems": 64 + }, + "keywords": { + "type": "array", + "description": "Search keywords (human-oriented; not necessarily normalized).", + "items": { + "type": "string", + "minLength": 2, + "maxLength": 48 + }, + "maxItems": 64 + }, + "dependencies": { + "type": "array", + "description": "Rule IDs that should be applied/understood first.", + "items": { + "type": "string", + "pattern": "^(CMOS|BRING|HOUSE)\\.[A-Z0-9_]+(?:\\.[A-Z0-9_]+)*$" + }, + "maxItems": 32 + }, + "exceptions": { + "type": "array", + "description": "Free-text exceptions/caveats. Keep concise.", + "items": { + "type": "string", + "minLength": 3, + "maxLength": 240 + }, + "maxItems": 32 + }, + "examples_ref": { + "type": "array", + "description": "Optional references to separately stored examples (see spec/examples/README.md).", + "items": { + "type": "string", + "minLength": 6, + "maxLength": 80, + "pattern": "^EX\\.[A-Z0-9_]+\\.[A-Z0-9_]+\\.[0-9]{3,}$" + }, + "maxItems": 64 + }, + "implementation_notes": { + "type": "string", + "description": "Optional short notes for implementers (no quotes).", + "minLength": 3, + "maxLength": 600 + }, + "status": { + "type": "string", + "description": "Lifecycle state.", + "enum": ["draft", "active", "deprecated"] + } + }, + "allOf": [ + { + "if": { + "properties": { "autofix": { "enum": ["rewrite", "reflow", "suggest"] } }, + "required": ["autofix"] + }, + "then": { "properties": { "autofix_notes": { "minLength": 1 } } } + } + ] +} diff --git a/src/iftypeset/__init__.py b/src/iftypeset/__init__.py new file mode 100644 index 0000000..84b6217 --- /dev/null +++ b/src/iftypeset/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" + diff --git a/src/iftypeset/cli.py b/src/iftypeset/cli.py new file mode 100644 index 0000000..fbc1a29 --- /dev/null +++ b/src/iftypeset/cli.py @@ -0,0 +1,431 @@ +import argparse +import json +import sys +from dataclasses import asdict +from pathlib import Path + +from iftypeset import __version__ +from iftypeset.css_gen import generate_profile_css +from iftypeset.index_builder import build_indexes, write_indexes +from iftypeset.linting import collect_input_paths, lint_paths, manual_checklist +from iftypeset.qa import analyze_html, evaluate_gates, layout_report_dict +from iftypeset.rendering import render_html, render_pdf, renderer_info +from iftypeset.reporting import CoverageError, CoverageReport, build_coverage_report +from iftypeset.spec_loader import SpecError, load_spec + + +def _cmd_validate_spec(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + _write_json(Path(args.out) / "spec-validation.json", {"ok": False, "error": str(e)}) + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + out: dict[str, object] = { + "ok": True, + "spec_root": str(Path(args.spec).resolve()), + "manifest_version": spec.manifest.get("version"), + "profiles": sorted(spec.profiles.keys()), + } + + if args.build_indexes: + indexes = build_indexes(spec.rules) + write_indexes(indexes, Path(args.spec) / "indexes") + out["indexes_written"] = True + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + _write_json(out_dir / "spec-validation.json", out) + print(json.dumps(out, indent=2, sort_keys=True)) + return 0 + + +def _cmd_report(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + try: + report: CoverageReport = build_coverage_report(spec, strict=args.strict) + except CoverageError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 2 + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + (out_dir / "coverage-report.json").write_text( + json.dumps(asdict(report), indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + (out_dir / "coverage-summary.md").write_text(report.to_markdown(), encoding="utf-8") + + if args.build_indexes: + indexes = build_indexes(spec.rules) + write_indexes(indexes, Path(args.spec) / "indexes") + + print(json.dumps({"ok": True, "out_dir": str(out_dir.resolve())}, indent=2, sort_keys=True)) + return 0 + + +def _cmd_emit_css(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + profile = spec.profiles.get(str(args.profile)) + if not profile: + print(json.dumps({"ok": False, "error": f"Unknown profile_id: {args.profile}"})) + return 4 + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + css_out = generate_profile_css(profile) + (out_dir / "render.css").write_text(css_out.css, encoding="utf-8") + (out_dir / "typeset-report.json").write_text( + json.dumps(css_out.report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps({"ok": True, "out_dir": str(out_dir.resolve())}, indent=2, sort_keys=True)) + return 0 + + +def _cmd_lint(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + profile = spec.profiles.get(str(args.profile)) + if not profile: + print(json.dumps({"ok": False, "error": f"Unknown profile_id: {args.profile}"})) + return 4 + + input_path = Path(args.input) + if not input_path.exists(): + print(json.dumps({"ok": False, "error": f"Input path not found: {input_path}"})) + return 4 + paths = collect_input_paths(input_path) + if not paths: + print(json.dumps({"ok": False, "error": f"No markdown files found under {input_path}"})) + return 4 + result = lint_paths( + paths, + profile_id=str(args.profile), + fix=args.fix, + fix_mode=args.fix_mode, + degraded_ok=args.degraded_ok, + fail_on=args.fail_on, + ) + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + _write_json(out_dir / "lint-report.json", result.report) + + if result.degraded: + _write_json(out_dir / "degraded-mode-report.json", {"files": result.degraded}) + + checklist = manual_checklist(spec) + _write_json(out_dir / "manual-checklist.json", {"items": checklist}) + (out_dir / "manual-checklist.md").write_text(_manual_checklist_md(checklist), encoding="utf-8") + + if args.fix: + if args.fix_mode == "rewrite": + fixed_dir = out_dir / "fixed" + fixed_dir.mkdir(parents=True, exist_ok=True) + for path_str, content in result.fixed_outputs.items(): + path = Path(path_str) + rel = path.name if input_path.is_file() else path.relative_to(input_path) + out_path = fixed_dir / rel + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content, encoding="utf-8") + else: + _write_json(out_dir / "fix-suggestions.json", {"fixes": result.report.get("fixes", [])}) + + if args.format == "sarif": + _write_json(out_dir / "lint-report.sarif", _to_sarif(result.report)) + if args.format == "json": + print(json.dumps(result.report, indent=2, sort_keys=True)) + if args.format == "text": + print(_lint_text_summary(result.report)) + + return 0 if result.report.get("ok") else 2 + + +def _cmd_render_html(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + profile = spec.profiles.get(str(args.profile)) + if not profile: + print(json.dumps({"ok": False, "error": f"Unknown profile_id: {args.profile}"})) + return 4 + + input_path = Path(args.input) + if not input_path.exists() or input_path.is_dir(): + print(json.dumps({"ok": False, "error": f"Input markdown file not found: {input_path}"})) + return 4 + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + result = render_html(input_path, profile, self_contained=args.self_contained) + (out_dir / "render.html").write_text(result.html, encoding="utf-8") + (out_dir / "render.css").write_text(result.css, encoding="utf-8") + _write_json(out_dir / "typeset-report.json", result.typeset_report) + + if result.degraded: + _write_json(out_dir / "degraded-mode-report.json", {"files": result.degraded}) + + summary = { + "ok": True, + "out_dir": str(out_dir.resolve()), + "warnings": result.warnings, + "degraded": result.degraded, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + + if result.degraded and not args.degraded_ok: + return 2 + return 0 + + +def _cmd_render_pdf(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + profile = spec.profiles.get(str(args.profile)) + if not profile: + print(json.dumps({"ok": False, "error": f"Unknown profile_id: {args.profile}"})) + return 4 + + input_path = Path(args.input) + if not input_path.exists() or input_path.is_dir(): + print(json.dumps({"ok": False, "error": f"Input markdown file not found: {input_path}"})) + return 4 + out_dir = Path(args.out) + result = render_pdf(input_path, profile, out_dir, self_contained=args.self_contained) + + _write_json(out_dir / "render-log.json", result.log) + + if result.ok: + print(json.dumps({"ok": True, "engine": result.engine, "pdf": result.pdf_path}, indent=2, sort_keys=True)) + return 0 + print(json.dumps({"ok": False, "engine": result.engine, "error": result.log.get("error")}, indent=2, sort_keys=True)) + return 3 + + +def _cmd_qa(args: argparse.Namespace) -> int: + try: + spec = load_spec(Path(args.spec)) + except SpecError as e: + print(json.dumps({"ok": False, "error": str(e)})) + return 4 + + profile = spec.profiles.get(str(args.profile)) + if not profile: + print(json.dumps({"ok": False, "error": f"Unknown profile_id: {args.profile}"})) + return 4 + + out_dir = Path(args.out) + html_path = out_dir / "render.html" + if not html_path.exists(): + print(json.dumps({"ok": False, "error": f"Missing render.html at {html_path}"})) + return 4 + + html_text = html_path.read_text(encoding="utf-8") + layout = analyze_html(html_text, profile, analysis_mode="html") + + gates_all = spec.quality_gates.get("profiles", {}).get(str(args.profile)) + if not gates_all: + print(json.dumps({"ok": False, "error": f"Missing quality gates for profile {args.profile}"})) + return 4 + gates = gates_all.get("strict" if args.strict else "default") or {} + + qa_report = evaluate_gates(layout.metrics, gates, profile_id=str(args.profile), strict=args.strict) + qa_report["metrics"] = layout.metrics + + layout_dict = layout_report_dict(layout) + if not (out_dir / "render.pdf").exists(): + layout_dict.setdefault("warnings", []).append("PDF not available; HTML-only analysis used") + + _write_json(out_dir / "layout-report.json", layout_dict) + _write_json(out_dir / "qa-report.json", qa_report) + + print(json.dumps({"ok": qa_report.get("ok"), "out_dir": str(out_dir.resolve())}, indent=2, sort_keys=True)) + return 0 if qa_report.get("ok") else 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="iftypeset", + description="Publication-quality typesetting runtime (spec tooling).", + ) + parser.add_argument("--version", action="store_true", help="Print tool version and exit.") + sub = parser.add_subparsers(dest="command") + + validate = sub.add_parser("validate-spec", help="Validate spec files and rule records.") + validate.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + validate.add_argument("--out", default="out", help="Output directory (default: ./out).") + validate.add_argument("--build-indexes", action="store_true", help="Build and write indexes into spec/indexes/.") + validate.set_defaults(func=_cmd_validate_spec) + + report = sub.add_parser("report", help="Generate coverage report from the current spec and rule set.") + report.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + report.add_argument("--out", default="out", help="Output directory (default: ./out).") + report.add_argument("--strict", action="store_true", help="Fail if coverage floors are not met.") + report.add_argument("--build-indexes", action="store_true", help="Build and write indexes into spec/indexes/.") + report.set_defaults(func=_cmd_report) + + emit_css = sub.add_parser("emit-css", help="Emit deterministic CSS for a given typeset profile.") + emit_css.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + emit_css.add_argument("--profile", required=True, help="Profile id (e.g., web_pdf, print_pdf).") + emit_css.add_argument("--out", default="out", help="Output directory (default: ./out).") + emit_css.set_defaults(func=_cmd_emit_css) + + lint = sub.add_parser("lint", help="Run Markdown lint rules and emit diagnostics.") + lint.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + lint.add_argument("--input", required=True, help="Markdown file or directory.") + lint.add_argument("--out", default="out", help="Output directory (default: ./out).") + lint.add_argument("--profile", required=True, help="Profile id.") + lint.add_argument("--format", default="json", choices=["json", "sarif", "text"], help="Output format.") + lint.add_argument("--fail-on", default="must", choices=["must", "should", "warn"], help="Fail threshold.") + lint.add_argument("--degraded-ok", action="store_true", help="Allow degraded mode without failing.") + lint.add_argument("--fix", action="store_true", help="Apply safe deterministic fixes.") + lint.add_argument("--fix-mode", default="suggest", choices=["suggest", "rewrite"], help="Fix mode.") + lint.set_defaults(func=_cmd_lint) + + render_html_cmd = sub.add_parser("render-html", help="Render Markdown to deterministic HTML + CSS.") + render_html_cmd.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + render_html_cmd.add_argument("--input", required=True, help="Markdown file.") + render_html_cmd.add_argument("--out", default="out", help="Output directory (default: ./out).") + render_html_cmd.add_argument("--profile", required=True, help="Profile id.") + render_html_cmd.add_argument("--self-contained", action="store_true", help="Embed local images as data URIs.") + render_html_cmd.add_argument("--degraded-ok", action="store_true", help="Allow degraded mode without failing.") + render_html_cmd.set_defaults(func=_cmd_render_html) + + render_pdf_cmd = sub.add_parser("render-pdf", help="Render Markdown to PDF using available engines.") + render_pdf_cmd.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + render_pdf_cmd.add_argument("--input", required=True, help="Markdown file.") + render_pdf_cmd.add_argument("--out", default="out", help="Output directory (default: ./out).") + render_pdf_cmd.add_argument("--profile", required=True, help="Profile id.") + render_pdf_cmd.add_argument("--self-contained", action="store_true", help="Embed local images as data URIs.") + render_pdf_cmd.set_defaults(func=_cmd_render_pdf) + + qa = sub.add_parser("qa", help="Run post-render QA gates.") + qa.add_argument("--spec", default="spec", help="Spec root directory (default: ./spec).") + qa.add_argument("--out", default="out", help="Output directory (default: ./out).") + qa.add_argument("--profile", required=True, help="Profile id.") + qa.add_argument("--strict", action="store_true", help="Use strict thresholds.") + qa.set_defaults(func=_cmd_qa) + + return parser + + +def _write_json(path: Path, data: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _manual_checklist_md(items: list[dict[str, object]]) -> str: + lines = ["# Manual checklist", ""] + if not items: + lines.append("_No manual checklist items._") + lines.append("") + return "\n".join(lines) + for item in items: + lines.append(f"- {item.get('id')}: {item.get('title')}") + if item.get("rule_text"): + lines.append(f" - {item.get('rule_text')}") + if item.get("source_refs"): + refs = ", ".join([str(r) for r in item.get("source_refs") or []]) + lines.append(f" - Source: {refs}") + lines.append("") + return "\n".join(lines) + + +def _lint_text_summary(report: dict[str, object]) -> str: + summary = report.get("summary", {}) if isinstance(report.get("summary"), dict) else {} + counts = summary.get("diagnostic_counts", {}) if isinstance(summary.get("diagnostic_counts"), dict) else {} + degraded = summary.get("degraded_files", 0) + lines = [ + f"Diagnostics: must={counts.get('must', 0)}, should={counts.get('should', 0)}, warn={counts.get('warn', 0)}", + f"Degraded files: {degraded}", + ] + return "\n".join(lines) + + +def _to_sarif(report: dict[str, object]) -> dict[str, object]: + results = [] + for diag in report.get("diagnostics", []) or []: + if not isinstance(diag, dict): + continue + severity = diag.get("severity") + level = "warning" + if severity == "must": + level = "error" + elif severity == "warn": + level = "note" + results.append( + { + "ruleId": diag.get("code"), + "level": level, + "message": {"text": diag.get("message")}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": diag.get("path")}, + "region": {"startLine": diag.get("line") or 1}, + } + } + ], + } + ) + return { + "version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": [ + { + "tool": {"driver": {"name": "iftypeset", "version": __version__}}, + "results": results, + } + ], + } + + +def main(argv: list[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + if args.version: + print( + json.dumps( + { + "tool": "iftypeset", + "version": __version__, + "renderer": renderer_info(), + }, + indent=2, + sort_keys=True, + ) + ) + raise SystemExit(0) + if not getattr(args, "func", None): + parser.error("Command required") + try: + rc = int(args.func(args)) + except BrokenPipeError: + rc = 1 + raise SystemExit(rc) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/iftypeset/css_gen.py b/src/iftypeset/css_gen.py new file mode 100644 index 0000000..13db100 --- /dev/null +++ b/src/iftypeset/css_gen.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CssOutput: + css: str + report: dict[str, Any] + + +def generate_profile_css(profile: dict[str, Any]) -> CssOutput: + profile_id = str(profile.get("profile_id") or "").strip() + if not profile_id: + raise ValueError("profile_id is required") + + page = profile.get("page") or {} + fonts = profile.get("fonts") or {} + paragraphs = profile.get("paragraphs") or {} + hyphenation = profile.get("hyphenation") or {} + widows_orphans = profile.get("widows_orphans") or {} + headings = profile.get("headings") or {} + code = profile.get("code") or {} + tables = profile.get("tables") or {} + + page_size = str(page.get("size") or "Letter") + orientation = str(page.get("orientation") or "portrait") + two_sided = bool(page.get("two_sided") or False) + margins = page.get("margins") or {} + margin_top = str(margins.get("top") or "1in") + margin_bottom = str(margins.get("bottom") or "1in") + margin_inner = str(margins.get("inner") or "1in") + margin_outer = str(margins.get("outer") or "1in") + + body_font = fonts.get("body") or {} + heading_font = fonts.get("heading") or {} + mono_font = fonts.get("mono") or {} + + body_family = _font_stack(body_font.get("family") or []) + heading_family = _font_stack(heading_font.get("family") or []) + mono_family = _font_stack(mono_font.get("family") or []) + + body_size = str(body_font.get("size") or "11pt") + body_line_height = body_font.get("line_height") or 1.45 + heading_size = str(heading_font.get("size") or body_size) + mono_size = str(mono_font.get("size") or "10pt") + mono_line_height = mono_font.get("line_height") or 1.35 + + indent = str(paragraphs.get("indent") or "0") + first_paragraph_indent = str(paragraphs.get("first_paragraph_indent") or "0") + block_spacing = str(paragraphs.get("block_paragraph_spacing") or "0") + + hyphens_enabled = bool(hyphenation.get("enabled") or False) + widows = int(widows_orphans.get("widow_lines") or 2) + orphans = int(widows_orphans.get("orphan_lines") or 2) + + keep_with_next_lines = int(headings.get("keep_with_next_lines") or 2) + avoid_stranded = bool(headings.get("avoid_stranded_headings") or False) + + code_block = (code.get("block") or {}) if isinstance(code, dict) else {} + code_block_size = str(code_block.get("font_size") or mono_size) + code_block_line_height = code_block.get("line_height") or mono_line_height + code_block_wrap = bool(code_block.get("wrap") or False) + + table_padding = str(tables.get("cell_padding") or "3pt 6pt") + table_header_repeat = bool(tables.get("header_repeat") or False) + + # Build CSS deterministically. + css: list[str] = [] + css.append("/* iftypeset profile CSS */") + css.append(f"/* profile_id: {profile_id} */") + css.append("") + + css.append(":root {") + css.append(f" --if-body-font: {body_family};") + css.append(f" --if-heading-font: {heading_family};") + css.append(f" --if-mono-font: {mono_family};") + css.append(f" --if-body-size: {body_size};") + css.append(f" --if-heading-size: {heading_size};") + css.append(f" --if-mono-size: {mono_size};") + css.append("}") + css.append("") + + # Paged media + css.append("@page {") + css.append(f" size: {page_size} {orientation};") + css.append(f" margin-top: {margin_top};") + css.append(f" margin-bottom: {margin_bottom};") + if two_sided: + css.append("}") + css.append("@page:left {") + css.append(f" margin-left: {margin_inner};") + css.append(f" margin-right: {margin_outer};") + css.append("}") + css.append("@page:right {") + css.append(f" margin-left: {margin_outer};") + css.append(f" margin-right: {margin_inner};") + else: + css.append(f" margin-left: {margin_inner};") + css.append(f" margin-right: {margin_outer};") + css.append("}") + css.append("") + + css.append("html {") + css.append(" font-kerning: normal;") + css.append(" text-rendering: geometricPrecision;") + css.append("}") + css.append("") + + css.append("body {") + css.append(" font-family: var(--if-body-font);") + css.append(f" font-size: var(--if-body-size);") + css.append(f" line-height: {body_line_height};") + css.append(" color: #111827;") + css.append("}") + css.append("") + + css.append("h1, h2, h3, h4, h5, h6 {") + css.append(" font-family: var(--if-heading-font);") + css.append(" font-weight: 650;") + css.append(" break-after: avoid;") + if avoid_stranded: + css.append(" break-inside: avoid;") + css.append("}") + css.append("") + + css.append("h1 { font-size: 1.8em; }") + css.append("h2 { font-size: 1.5em; }") + css.append("h3 { font-size: 1.25em; }") + css.append("") + + css.append("p {") + if hyphens_enabled: + css.append(" hyphens: auto;") + css.append(f" widows: {widows};") + css.append(f" orphans: {orphans};") + if indent != "0": + css.append(f" text-indent: {indent};") + if block_spacing != "0": + css.append(f" margin-top: {block_spacing};") + css.append(f" margin-bottom: {block_spacing};") + css.append("}") + css.append("") + + # First paragraph in a section: no indent when configured. + if first_paragraph_indent == "0" and indent != "0": + css.append("main > p:first-of-type, section > p:first-of-type {") + css.append(" text-indent: 0;") + css.append("}") + css.append("") + + css.append("code, pre {") + css.append(" font-family: var(--if-mono-font);") + css.append(f" font-size: {mono_size};") + css.append(f" line-height: {mono_line_height};") + css.append("}") + css.append("") + + css.append("pre {") + css.append(f" font-size: {code_block_size};") + css.append(f" line-height: {code_block_line_height};") + css.append(" white-space: pre;") + if code_block_wrap: + css.append(" white-space: pre-wrap;") + css.append(" overflow-wrap: anywhere;") + else: + css.append(" overflow-x: auto;") + css.append("}") + css.append("") + + css.append("table {") + css.append(" border-collapse: collapse;") + css.append(" width: 100%;") + css.append("}") + css.append("") + css.append("th, td {") + css.append(f" padding: {table_padding};") + css.append(" border: 1px solid #e5e7eb;") + css.append(" vertical-align: top;") + css.append("}") + css.append("") + if table_header_repeat: + css.append("thead { display: table-header-group; }") + css.append("") + + # Approximation: keep headings with at least N following lines by avoiding breaks after heading. + # CSS cannot express "keep_with_next_lines" precisely; this is a pragmatic guard. + if keep_with_next_lines > 0: + css.append("h1, h2, h3 {") + css.append(" page-break-after: avoid;") + css.append("}") + css.append("") + + report = { + "profile_id": profile_id, + "page": { + "size": page_size, + "orientation": orientation, + "two_sided": two_sided, + "margins": {"top": margin_top, "bottom": margin_bottom, "inner": margin_inner, "outer": margin_outer}, + }, + "fonts": { + "body": {"family": body_family, "size": body_size, "line_height": body_line_height}, + "heading": {"family": heading_family, "size": heading_size}, + "mono": {"family": mono_family, "size": mono_size, "line_height": mono_line_height}, + }, + "hyphenation": {"enabled": hyphens_enabled}, + "widows_orphans": {"widows": widows, "orphans": orphans}, + } + + return CssOutput(css="\n".join(css).rstrip() + "\n", report=report) + + +def _font_stack(family: list[Any]) -> str: + parts: list[str] = [] + generic = { + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "math", + "emoji", + "fangsong", + } + for f in family: + s = str(f).strip() + if not s: + continue + if s.lower() in generic: + parts.append(s.lower()) + elif any(ch in s for ch in [" ", "-"]): + parts.append(f'"{s}"') + else: + parts.append(s) + return ", ".join(parts) if parts else "serif" diff --git a/src/iftypeset/index_builder.py b/src/iftypeset/index_builder.py new file mode 100644 index 0000000..d2c1ebb --- /dev/null +++ b/src/iftypeset/index_builder.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from iftypeset.rules import RuleRecord + + +def _norm_keyword(s: str) -> str: + return " ".join(str(s).strip().lower().split()) + + +@dataclass(frozen=True) +class IndexBundle: + keywords_all: dict[str, list[str]] + source_refs_all: dict[str, list[str]] + category: dict[str, list[str]] + enforcement: dict[str, list[str]] + + +def build_indexes(rules: list[RuleRecord]) -> IndexBundle: + keywords: dict[str, set[str]] = {} + source_refs: dict[str, set[str]] = {} + by_category: dict[str, set[str]] = {} + by_enforcement: dict[str, set[str]] = {} + + for r in rules: + rid = r.id + by_category.setdefault(r.category, set()).add(rid) + by_enforcement.setdefault(r.enforcement, set()).add(rid) + + for kw in (r.raw.get("keywords") or []): + k = _norm_keyword(kw) + if not k: + continue + keywords.setdefault(k, set()).add(rid) + + for ref in (r.raw.get("source_refs") or []): + ref_s = str(ref).strip() + if not ref_s: + continue + source_refs.setdefault(ref_s, set()).add(rid) + + def to_sorted(d: dict[str, set[str]]) -> dict[str, list[str]]: + return {k: sorted(v) for k, v in sorted(d.items(), key=lambda kv: kv[0])} + + return IndexBundle( + keywords_all=to_sorted(keywords), + source_refs_all=to_sorted(source_refs), + category=to_sorted(by_category), + enforcement=to_sorted(by_enforcement), + ) + + +def write_indexes(indexes: IndexBundle, indexes_dir: Path) -> None: + indexes_dir.mkdir(parents=True, exist_ok=True) + _write(indexes_dir / "keywords_all.json", indexes.keywords_all) + _write(indexes_dir / "source_refs_all.json", indexes.source_refs_all) + _write(indexes_dir / "category.json", indexes.category) + _write(indexes_dir / "enforcement.json", indexes.enforcement) + + +def _write(path: Path, data: Any) -> None: + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + diff --git a/src/iftypeset/linting.py b/src/iftypeset/linting.py new file mode 100644 index 0000000..f784d28 --- /dev/null +++ b/src/iftypeset/linting.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +import re +from typing import Any + +from iftypeset import __version__ +from iftypeset.md_parser import MdDocument, code_fence_lines, parse_markdown, read_markdown +from iftypeset.spec_loader import LoadedSpec + + +SEVERITY_ORDER = {"warn": 0, "should": 1, "must": 2} + + +@dataclass(frozen=True) +class LintDiagnostic: + code: str + message: str + severity: str + path: str + line: int | None = None + column: int | None = None + rule_id: str | None = None + + +@dataclass(frozen=True) +class LintFix: + code: str + description: str + count: int + mode: str + + +@dataclass(frozen=True) +class LintResult: + report: dict[str, Any] + fixed_outputs: dict[str, str] + degraded: dict[str, list[str]] + + +def collect_input_paths(input_path: Path) -> list[Path]: + if input_path.is_dir(): + return sorted([p for p in input_path.rglob("*.md") if p.is_file()]) + return [input_path] + + +def lint_paths( + input_paths: list[Path], + profile_id: str, + *, + fix: bool = False, + fix_mode: str = "suggest", + degraded_ok: bool = False, + fail_on: str = "must", +) -> LintResult: + diagnostics: list[LintDiagnostic] = [] + fixes: list[LintFix] = [] + fixed_outputs: dict[str, str] = {} + degraded: dict[str, list[str]] = {} + + for path in input_paths: + raw_text, read_reasons = read_markdown(path) + doc = parse_markdown(path, raw_text) + reasons = read_reasons + doc.degraded_reasons + if reasons: + degraded[str(path)] = reasons + + diagnostics.extend(_lint_document(doc)) + if fix: + fixed_text, applied = apply_fixes(raw_text, fix_mode=fix_mode) + if applied: + fixes.extend(applied) + fixed_outputs[str(path)] = fixed_text + + report = _build_report( + diagnostics=diagnostics, + fixes=fixes, + inputs=input_paths, + profile_id=profile_id, + degraded=degraded, + degraded_ok=degraded_ok, + fail_on=fail_on, + ) + + return LintResult(report=report, fixed_outputs=fixed_outputs, degraded=degraded) + + +def manual_checklist(spec: LoadedSpec) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for rule in spec.rules: + tags = [str(t) for t in (rule.raw.get("tags") or [])] + if "manual_checklist=true" not in tags: + continue + items.append( + { + "id": rule.id, + "title": rule.raw.get("title"), + "category": rule.raw.get("category"), + "severity": rule.raw.get("severity"), + "rule_text": rule.raw.get("rule_text"), + "source_refs": rule.raw.get("source_refs"), + } + ) + return items + + +def apply_fixes(text: str, *, fix_mode: str) -> tuple[str, list[LintFix]]: + if fix_mode not in {"suggest", "rewrite"}: + raise ValueError("fix_mode must be suggest or rewrite") + lines = text.splitlines() + fenced = code_fence_lines(text) + + new_lines, trailing_count = _strip_trailing_whitespace(lines, fenced) + new_lines, link_count = _normalize_link_spacing(new_lines, fenced) + + fixes: list[LintFix] = [] + if trailing_count: + fixes.append( + LintFix( + code="WS.TRAILING", + description="Trim trailing whitespace", + count=trailing_count, + mode=fix_mode, + ) + ) + if link_count: + fixes.append( + LintFix( + code="LINK.SPACING", + description="Normalize Markdown link spacing", + count=link_count, + mode=fix_mode, + ) + ) + + out_text = "\n".join(new_lines) + if text.endswith("\n"): + out_text += "\n" + if fix_mode == "suggest": + return text, fixes + return out_text, fixes + + +def _strip_trailing_whitespace(lines: list[str], fenced: set[int]) -> tuple[list[str], int]: + out: list[str] = [] + count = 0 + for idx, line in enumerate(lines, start=1): + if idx in fenced: + out.append(line) + continue + stripped = line.rstrip() + if stripped != line: + count += 1 + out.append(stripped) + return out, count + + +def _normalize_link_spacing(lines: list[str], fenced: set[int]) -> tuple[list[str], int]: + out: list[str] = [] + count = 0 + pattern = re.compile(r"\]\s+\(\s*([^)]+?)\s*\)") + for idx, line in enumerate(lines, start=1): + if idx in fenced: + out.append(line) + continue + new_line, n = pattern.subn(r"](\1)", line) + count += n + out.append(new_line) + return out, count + + +def _lint_document(doc: MdDocument) -> list[LintDiagnostic]: + diagnostics: list[LintDiagnostic] = [] + lines = doc.normalized_source.splitlines() + fenced = code_fence_lines(doc.normalized_source) + + for idx, line in enumerate(lines, start=1): + if idx in fenced: + continue + if line.rstrip() != line: + diagnostics.append( + LintDiagnostic( + code="WS.TRAILING", + message="Trailing whitespace", + severity="warn", + path=str(doc.path), + line=idx, + column=len(line), + ) + ) + + for block in doc.blocks: + if block.type in {"paragraph", "blockquote"}: + if re.search(r"\S \S", block.text): + diagnostics.append( + LintDiagnostic( + code="WS.DOUBLE_SPACE", + message="Double space in running text", + severity="should", + path=str(doc.path), + line=block.start_line, + column=1, + ) + ) + if block.type == "heading" and block.level: + if block.level > 1 and not _prior_heading_level(doc, block.level, block.start_line): + diagnostics.append( + LintDiagnostic( + code="HEADING.SKIP", + message="Heading level skips a level", + severity="should", + path=str(doc.path), + line=block.start_line, + column=1, + ) + ) + if block.type == "table": + if not any(h for h in block.headers): + diagnostics.append( + LintDiagnostic( + code="TABLE.HEADER.MISSING", + message="Table header row is empty", + severity="warn", + path=str(doc.path), + line=block.start_line, + column=1, + ) + ) + + for idx, line in enumerate(lines, start=1): + if idx in fenced: + continue + for match in re.finditer(r"(https?://\S+|www\.\S+)", line): + token = match.group(1) + if _long_unbroken(token, 80): + diagnostics.append( + LintDiagnostic( + code="LINK.WRAP.RISK", + message="Very long unbroken URL may wrap poorly", + severity="warn", + path=str(doc.path), + line=idx, + column=match.start(1) + 1, + ) + ) + + link_spacing = re.compile(r"\]\s+\(\s*([^)]+?)\s*\)") + for idx, line in enumerate(lines, start=1): + if idx in fenced: + continue + if link_spacing.search(line): + diagnostics.append( + LintDiagnostic( + code="LINK.SPACING", + message="Normalize spacing inside Markdown links", + severity="warn", + path=str(doc.path), + line=idx, + column=1, + ) + ) + break + + return diagnostics + + +def _prior_heading_level(doc: MdDocument, level: int, line_no: int) -> bool: + for block in doc.blocks: + if block.type != "heading" or block.level is None: + continue + if block.start_line >= line_no: + break + if block.level == level - 1: + return True + return level == 1 + + +def _long_unbroken(text: str, threshold: int) -> bool: + for token in re.findall(r"\S+", text): + if len(token) >= threshold: + return True + return False + + +def _build_report( + *, + diagnostics: list[LintDiagnostic], + fixes: list[LintFix], + inputs: list[Path], + profile_id: str, + degraded: dict[str, list[str]], + degraded_ok: bool, + fail_on: str, +) -> dict[str, Any]: + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + counts = {"must": 0, "should": 0, "warn": 0} + for diag in diagnostics: + counts[diag.severity] = counts.get(diag.severity, 0) + 1 + + failed = _fails_threshold(diagnostics, degraded, degraded_ok=degraded_ok, fail_on=fail_on) + + return { + "ok": not failed, + "tool": "iftypeset", + "tool_version": __version__, + "generated_at_utc": now, + "profile_id": profile_id, + "inputs": [str(p) for p in inputs], + "fail_on": fail_on, + "degraded_ok": degraded_ok, + "summary": { + "diagnostic_counts": counts, + "fix_counts": {f.code: f.count for f in fixes}, + "degraded_files": len(degraded), + }, + "degraded": degraded, + "diagnostics": [asdict(d) for d in diagnostics], + "fixes": [asdict(f) for f in fixes], + } + + +def _fails_threshold( + diagnostics: list[LintDiagnostic], + degraded: dict[str, list[str]], + *, + degraded_ok: bool, + fail_on: str, +) -> bool: + if degraded and not degraded_ok: + return True + if fail_on not in SEVERITY_ORDER: + raise ValueError(f"Unknown fail-on severity: {fail_on}") + threshold = SEVERITY_ORDER[fail_on] + return any(SEVERITY_ORDER.get(d.severity, 0) >= threshold for d in diagnostics) + + +def evaluate_fail_on(diagnostics: list[LintDiagnostic], fail_on: str) -> bool: + if fail_on not in SEVERITY_ORDER: + raise ValueError(f"Unknown fail-on severity: {fail_on}") + threshold = SEVERITY_ORDER[fail_on] + return any(SEVERITY_ORDER.get(d.severity, 0) >= threshold for d in diagnostics) diff --git a/src/iftypeset/md_parser.py b/src/iftypeset/md_parser.py new file mode 100644 index 0000000..03ed6c0 --- /dev/null +++ b/src/iftypeset/md_parser.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +import re +from typing import Iterable + + +@dataclass(frozen=True) +class MdBlock: + type: str + start_line: int + end_line: int + text: str = "" + level: int | None = None + items: list[str] = field(default_factory=list) + ordered: bool | None = None + info: str | None = None + headers: list[str] = field(default_factory=list) + rows: list[list[str]] = field(default_factory=list) + + +@dataclass(frozen=True) +class MdDocument: + path: Path + source: str + blocks: list[MdBlock] + degraded: bool + degraded_reasons: list[str] + normalized_source: str + + +def read_markdown(path: Path) -> tuple[str, list[str]]: + reasons: list[str] = [] + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + text = path.read_text(encoding="utf-8", errors="replace") + reasons.append("invalid_utf8_replaced") + return text, reasons + + +def normalize_newlines(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def detect_degraded(text: str) -> list[str]: + reasons: list[str] = [] + lines = [ln for ln in text.splitlines()] + fenced = code_fence_lines(text) + content_lines: list[str] = [] + for idx, ln in enumerate(lines, start=1): + if idx in fenced: + continue + if not ln.strip(): + continue + if re.match(r"^#{1,6}\s+", ln): + continue + if re.match(r"^\s*([-*+]\s+|\d+\.\s+)", ln): + continue + if ln.lstrip().startswith(">"): + continue + if "|" in ln and re.match(r"^\s*\|", ln): + continue + content_lines.append(ln.strip()) + + if content_lines: + lengths = sorted(len(ln) for ln in content_lines) + short = [l for l in lengths if l < 35] + ratio = len(short) / max(len(lengths), 1) + median = lengths[len(lengths) // 2] + if len(lengths) >= 20 and ((ratio > 0.8 and median < 40) or (ratio > 0.6 and median < 30)): + reasons.append("hard_wrap_density") + + if not any(re.match(r"^#{1,6}\s+", ln) for ln in lines): + reasons.append("missing_headings") + return reasons + + +def unwrap_hard_wrapped_paragraphs(text: str) -> str: + lines = text.splitlines() + out_lines: list[str] = [] + buf: list[str] = [] + + def flush_buf() -> None: + if not buf: + return + joined = "" + for i, ln in enumerate(buf): + ln = ln.strip() + if i == 0: + joined = ln + continue + if joined.endswith("-") and ln and ln[0].islower(): + joined = joined[:-1] + ln + else: + joined = joined + " " + ln + out_lines.append(joined) + buf.clear() + + for ln in lines: + if not ln.strip(): + flush_buf() + out_lines.append("") + continue + if re.match(r"^\s*(```|~~~)", ln) or re.match(r"^\s*([-*+]\s+|\d+\.\s+)", ln): + flush_buf() + out_lines.append(ln.rstrip()) + continue + if re.match(r"^#{1,6}\s+", ln) or ln.lstrip().startswith(">"): + flush_buf() + out_lines.append(ln.rstrip()) + continue + if "|" in ln and re.match(r"^\s*\|?\s*[-:]{3,}", ln): + flush_buf() + out_lines.append(ln.rstrip()) + continue + buf.append(ln) + flush_buf() + return "\n".join(out_lines) + + +def parse_markdown(path: Path, text: str) -> MdDocument: + original = normalize_newlines(text) + reasons = detect_degraded(original) + normalized = original + if reasons: + normalized = unwrap_hard_wrapped_paragraphs(original) + blocks = _parse_blocks(normalized) + return MdDocument( + path=path, + source=original, + blocks=blocks, + degraded=bool(reasons), + degraded_reasons=reasons, + normalized_source=normalized, + ) + + +def _parse_blocks(text: str) -> list[MdBlock]: + lines = text.splitlines() + blocks: list[MdBlock] = [] + i = 0 + while i < len(lines): + line = lines[i] + if not line.strip(): + i += 1 + continue + if _is_code_fence(line): + blocks.append(_parse_code_block(lines, i)) + i = blocks[-1].end_line + continue + heading = _parse_heading(line, i) + if heading: + blocks.append(heading) + i += 1 + continue + table = _parse_table(lines, i) + if table: + blocks.append(table) + i = table.end_line + continue + lst = _parse_list(lines, i) + if lst: + blocks.append(lst) + i = lst.end_line + continue + if line.lstrip().startswith(">"): + blocks.append(_parse_blockquote(lines, i)) + i = blocks[-1].end_line + continue + blocks.append(_parse_paragraph(lines, i)) + i = blocks[-1].end_line + return blocks + + +def _is_code_fence(line: str) -> bool: + return bool(re.match(r"^\s*(```|~~~)", line)) + + +def _parse_code_block(lines: list[str], start: int) -> MdBlock: + fence = lines[start] + m = re.match(r"^\s*(```|~~~)(.*)$", fence) + info = m.group(2).strip() if m else "" + i = start + 1 + code_lines: list[str] = [] + while i < len(lines): + if _is_code_fence(lines[i]): + i += 1 + break + code_lines.append(lines[i]) + i += 1 + text = "\n".join(code_lines) + return MdBlock(type="code", start_line=start + 1, end_line=i, text=text, info=info) + + +def _parse_heading(line: str, idx: int) -> MdBlock | None: + m = re.match(r"^(#{1,6})\s+(.*)$", line) + if not m: + return None + level = len(m.group(1)) + text = m.group(2).strip() + return MdBlock(type="heading", start_line=idx + 1, end_line=idx + 1, text=text, level=level) + + +def _parse_list(lines: list[str], start: int) -> MdBlock | None: + m = re.match(r"^\s*([-*+])\s+(.+)$", lines[start]) + ordered_m = re.match(r"^\s*(\d+)\.\s+(.+)$", lines[start]) + if not m and not ordered_m: + return None + items: list[str] = [] + ordered = False + i = start + while i < len(lines): + line = lines[i] + m = re.match(r"^\s*([-*+])\s+(.+)$", line) + ordered_m = re.match(r"^\s*(\d+)\.\s+(.+)$", line) + if m: + items.append(m.group(2).strip()) + elif ordered_m: + ordered = True + items.append(ordered_m.group(2).strip()) + else: + break + i += 1 + return MdBlock( + type="list", + start_line=start + 1, + end_line=i, + items=items, + ordered=ordered, + ) + + +def _parse_table(lines: list[str], start: int) -> MdBlock | None: + if start + 1 >= len(lines): + return None + header = lines[start] + sep = lines[start + 1] + if "|" not in header: + return None + if not _is_table_separator(sep): + return None + headers = _split_pipes(header) + rows: list[list[str]] = [] + i = start + 2 + while i < len(lines): + if "|" not in lines[i] or not lines[i].strip(): + break + if _is_code_fence(lines[i]) or re.match(r"^#{1,6}\s+", lines[i]): + break + rows.append(_split_pipes(lines[i])) + i += 1 + return MdBlock( + type="table", + start_line=start + 1, + end_line=i, + headers=headers, + rows=rows, + ) + + +def _is_table_separator(line: str) -> bool: + parts = _split_pipes(line) + if len(parts) < 2: + return False + for p in parts: + if not re.match(r"^:?-{3,}:?$", p.strip()): + return False + return True + + +def _split_pipes(line: str) -> list[str]: + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + return [p.strip() for p in stripped.split("|")] + + +def _parse_blockquote(lines: list[str], start: int) -> MdBlock: + i = start + parts: list[str] = [] + while i < len(lines): + line = lines[i] + if not line.lstrip().startswith(">"): + break + parts.append(line.lstrip()[1:].lstrip()) + i += 1 + text = " ".join([p for p in parts if p]) + return MdBlock(type="blockquote", start_line=start + 1, end_line=i, text=text) + + +def _parse_paragraph(lines: list[str], start: int) -> MdBlock: + i = start + parts: list[str] = [] + while i < len(lines): + line = lines[i] + if not line.strip(): + break + if _is_code_fence(line) or re.match(r"^#{1,6}\s+", line) or _parse_list(lines, i) or _parse_table(lines, i): + break + if line.lstrip().startswith(">"): + break + parts.append(line.strip()) + i += 1 + text = " ".join(parts) + return MdBlock(type="paragraph", start_line=start + 1, end_line=i, text=text) + + +def code_fence_lines(text: str) -> set[int]: + lines = text.splitlines() + in_fence = False + fenced_lines: set[int] = set() + for i, line in enumerate(lines, start=1): + if _is_code_fence(line): + in_fence = not in_fence + fenced_lines.add(i) + continue + if in_fence: + fenced_lines.add(i) + return fenced_lines + + +def iter_blocks(blocks: Iterable[MdBlock], type_name: str) -> Iterable[MdBlock]: + for b in blocks: + if b.type == type_name: + yield b diff --git a/src/iftypeset/qa.py b/src/iftypeset/qa.py new file mode 100644 index 0000000..f64a168 --- /dev/null +++ b/src/iftypeset/qa.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from html.parser import HTMLParser +from pathlib import Path +import re +from typing import Any + +from iftypeset import __version__ + + +@dataclass(frozen=True) +class LayoutIncident: + kind: str + detail: str + context: str | None = None + + +@dataclass(frozen=True) +class LayoutReport: + metrics: dict[str, int] + incidents: list[LayoutIncident] + warnings: list[str] + profile_id: str + analysis_mode: str + + +@dataclass +class _Block: + type: str + word_count: int + text: str = "" + level: int | None = None + line_count: int = 0 + + +class HtmlCollector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.headings: list[tuple[int, str]] = [] + self.links: list[tuple[str, str]] = [] + self.code_blocks: list[str] = [] + self.tables: list[dict[str, int]] = [] + self.blocks: list[_Block] = [] + self._heading_level: int | None = None + self._heading_text: list[str] = [] + self._link_href: str | None = None + self._link_text: list[str] = [] + self._in_pre = False + self._code_text: list[str] = [] + self._in_paragraph = False + self._paragraph_text: list[str] = [] + self._in_list_item = False + self._list_text: list[str] = [] + self._in_table = False + self._table_rows: list[int] = [] + self._table_cell_lengths: list[int] = [] + self._table_word_count = 0 + self._current_row_cols = 0 + self._cell_text: list[str] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: + self._heading_level = int(tag[1]) + self._heading_text = [] + if tag == "a": + href = "" + for k, v in attrs: + if k == "href" and v: + href = v + self._link_href = href + self._link_text = [] + if tag == "pre": + self._in_pre = True + self._code_text = [] + if tag == "p": + self._in_paragraph = True + self._paragraph_text = [] + if tag == "li": + self._in_list_item = True + self._list_text = [] + if tag == "table": + self._in_table = True + self._table_rows = [] + self._table_cell_lengths = [] + self._table_word_count = 0 + self._current_row_cols = 0 + if tag == "tr" and self._in_table: + self._current_row_cols = 0 + if tag in {"td", "th"} and self._in_table: + self._cell_text = [] + + def handle_endtag(self, tag: str) -> None: + if tag in {"h1", "h2", "h3", "h4", "h5", "h6"} and self._heading_level is not None: + text = " ".join(self._heading_text).strip() + self.headings.append((self._heading_level, text)) + self.blocks.append(_Block(type="heading", word_count=0, text=text, level=self._heading_level)) + self._heading_level = None + self._heading_text = [] + if tag == "a" and self._link_href is not None: + text = " ".join(self._link_text).strip() + self.links.append((self._link_href, text)) + self._link_href = None + self._link_text = [] + if tag == "pre" and self._in_pre: + text = "".join(self._code_text) + self.code_blocks.append(text) + wc = _word_count(text) + line_count = len([ln for ln in text.splitlines() if ln.strip()]) or 1 + self.blocks.append(_Block(type="code", word_count=wc, text=text, line_count=line_count)) + self._in_pre = False + self._code_text = [] + if tag == "p" and self._in_paragraph: + text = " ".join(self._paragraph_text).strip() + wc = _word_count(text) + self.blocks.append(_Block(type="paragraph", word_count=wc, text=text)) + self._in_paragraph = False + self._paragraph_text = [] + if tag == "li" and self._in_list_item: + text = " ".join(self._list_text).strip() + wc = _word_count(text) + self.blocks.append(_Block(type="list_item", word_count=wc, text=text)) + self._in_list_item = False + self._list_text = [] + if tag in {"td", "th"} and self._in_table and self._cell_text is not None: + text = " ".join(self._cell_text).strip() + self._table_cell_lengths.append(len(text)) + self._table_word_count += _word_count(text) + self._current_row_cols += 1 + self._cell_text = None + if tag == "tr" and self._in_table: + self._table_rows.append(self._current_row_cols) + self._current_row_cols = 0 + if tag == "table" and self._in_table: + max_cols = max(self._table_rows) if self._table_rows else 0 + max_cell = max(self._table_cell_lengths) if self._table_cell_lengths else 0 + row_count = len(self._table_rows) + self.tables.append({"max_cols": max_cols, "max_cell_len": max_cell, "rows": row_count}) + self.blocks.append(_Block(type="table", word_count=self._table_word_count, line_count=row_count)) + self._in_table = False + self._table_rows = [] + self._table_cell_lengths = [] + self._table_word_count = 0 + + def handle_data(self, data: str) -> None: + if self._heading_level is not None: + self._heading_text.append(data) + if self._link_href is not None: + self._link_text.append(data) + if self._in_pre: + self._code_text.append(data) + if self._in_paragraph: + self._paragraph_text.append(data) + if self._in_list_item: + self._list_text.append(data) + if self._cell_text is not None: + self._cell_text.append(data) + + +def analyze_html(html_text: str, profile: dict[str, Any], *, analysis_mode: str = "html") -> LayoutReport: + collector = HtmlCollector() + collector.feed(html_text) + + measure = profile.get("measure_targets") or {} + body_chars = (measure.get("body_chars_per_line") or {}).get("max") or 75 + body_chars = int(body_chars) + link_threshold = max(80, body_chars) + code_threshold = max(90, body_chars + 15) + table_max_cols = 6 + table_max_cell = 40 + + incidents: list[LayoutIncident] = [] + + link_wrap = 0 + for href, text in collector.links: + token = text or href + if _long_unbroken(token, link_threshold): + link_wrap += 1 + incidents.append(LayoutIncident(kind="link_wrap", detail=href, context=token[:120])) + + code_overflow = 0 + for code in collector.code_blocks: + max_len = max((len(line) for line in code.splitlines()), default=0) + if max_len > code_threshold: + code_overflow += 1 + incidents.append(LayoutIncident(kind="code_overflow", detail=f"line_len={max_len}")) + + table_overflow = 0 + for table in collector.tables: + if table.get("max_cols", 0) > table_max_cols or table.get("max_cell_len", 0) > table_max_cell: + table_overflow += 1 + incidents.append(LayoutIncident(kind="table_overflow", detail=str(table))) + + heading_errors, heading_incidents = _heading_numbering_errors(collector.headings) + incidents.extend(heading_incidents) + + stranded, stranded_incidents = _stranded_headings(collector.blocks, profile) + incidents.extend(stranded_incidents) + + overfull_lines = _overfull_lines(collector.blocks, body_chars) + metrics = { + "max_widows_per_10_pages": 0, + "max_orphans_per_10_pages": 0, + "max_stranded_headings": stranded, + "max_overfull_lines": overfull_lines, + "max_table_overflow_incidents": table_overflow, + "max_code_overflow_incidents": code_overflow, + "max_link_wrap_incidents": link_wrap, + "max_heading_numbering_errors": heading_errors, + "max_citation_format_errors": 0, + } + + return LayoutReport( + metrics=metrics, + incidents=incidents, + warnings=[], + profile_id=str(profile.get("profile_id") or ""), + analysis_mode=analysis_mode, + ) + + +def evaluate_gates( + metrics: dict[str, int], + gates: dict[str, int], + *, + profile_id: str, + strict: bool, +) -> dict[str, Any]: + results: dict[str, Any] = {} + failed: list[str] = [] + for metric, limit in gates.items(): + count = metrics.get(metric, 0) + status = "pass" if count <= limit else "fail" + results[metric] = {"count": count, "max": limit, "status": status} + if status == "fail": + failed.append(metric) + return { + "ok": not failed, + "profile_id": profile_id, + "strict": strict, + "gates": results, + "failed": failed, + } + + +def layout_report_dict(report: LayoutReport) -> dict[str, Any]: + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + return { + "ok": True, + "tool": "iftypeset", + "tool_version": __version__, + "generated_at_utc": now, + "profile_id": report.profile_id, + "analysis_mode": report.analysis_mode, + "metrics": report.metrics, + "incidents": [asdict(i) for i in report.incidents], + "warnings": report.warnings, + } + + +def _heading_numbering_errors(headings: list[tuple[int, str]]) -> tuple[int, list[LayoutIncident]]: + errors = 0 + incidents: list[LayoutIncident] = [] + last_numbers: list[int] = [] + for level, text in headings: + m = re.match(r"^(\d+(?:\.\d+)*)\s+", text) + if not m: + continue + parts = [int(p) for p in m.group(1).split(".")] + if not last_numbers: + last_numbers = parts + continue + if len(parts) == 1: + if parts[0] <= last_numbers[0]: + errors += 1 + incidents.append(LayoutIncident(kind="heading_numbering", detail=text)) + last_numbers = parts + continue + prefix = parts[:-1] + if prefix != last_numbers[: len(prefix)]: + errors += 1 + incidents.append(LayoutIncident(kind="heading_numbering", detail=text)) + last_numbers = parts + continue + prev = last_numbers[len(parts) - 1] if len(last_numbers) >= len(parts) else 0 + if parts[-1] != prev + 1 and not (prev == 0 and parts[-1] == 1): + errors += 1 + incidents.append(LayoutIncident(kind="heading_numbering", detail=text)) + last_numbers = parts + return errors, incidents + + +def _stranded_headings(blocks: list[_Block], profile: dict[str, Any]) -> tuple[int, list[LayoutIncident]]: + keep_lines = int((profile.get("headings") or {}).get("keep_with_next_lines") or 2) + measure = profile.get("measure_targets") or {} + ideal_chars = (measure.get("body_chars_per_line") or {}).get("ideal") or 66 + words_per_line = max(8, int(int(ideal_chars) / 6)) + + stranded = 0 + incidents: list[LayoutIncident] = [] + for i, block in enumerate(blocks): + if block.type != "heading": + continue + content_lines = 0 + for nxt in blocks[i + 1 :]: + if nxt.type == "heading": + break + if nxt.type in {"code", "table"}: + content_lines += max(keep_lines, nxt.line_count or keep_lines) + elif nxt.type == "list_item": + content_lines += 1 + else: + wc = max(0, int(nxt.word_count)) + content_lines += max(1, (wc + words_per_line - 1) // words_per_line) if wc else 0 + if content_lines >= keep_lines: + break + if content_lines < keep_lines: + stranded += 1 + incidents.append(LayoutIncident(kind="stranded_heading", detail=block.text)) + return stranded, incidents + + +def _overfull_lines(blocks: list[_Block], max_chars: int) -> int: + count = 0 + for block in blocks: + if block.type not in {"paragraph", "list_item"}: + continue + for token in re.findall(r"\S+", block.text): + if len(token) > max_chars: + count += 1 + break + return count + + +def _long_unbroken(text: str, threshold: int) -> bool: + for token in re.findall(r"\S+", text): + if len(token) >= threshold: + return True + return False + + +def _word_count(text: str) -> int: + return len([t for t in re.split(r"\s+", text.strip()) if t]) diff --git a/src/iftypeset/rendering.py b/src/iftypeset/rendering.py new file mode 100644 index 0000000..37d4274 --- /dev/null +++ b/src/iftypeset/rendering.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import base64 +import html +import importlib +import importlib.util +import json +import mimetypes +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any + +from iftypeset import __version__ +from iftypeset.css_gen import generate_profile_css +from iftypeset.md_parser import MdDocument, parse_markdown, read_markdown + + +@dataclass(frozen=True) +class HtmlRenderResult: + html: str + css: str + warnings: list[str] + degraded: dict[str, list[str]] + typeset_report: dict[str, Any] + + +@dataclass(frozen=True) +class PdfRenderResult: + ok: bool + engine: str | None + pdf_path: str | None + log: dict[str, Any] + + +def render_html( + input_path: Path, + profile: dict[str, Any], + *, + self_contained: bool = False, +) -> HtmlRenderResult: + raw_text, read_reasons = read_markdown(input_path) + doc = parse_markdown(input_path, raw_text) + degraded: dict[str, list[str]] = {} + reasons = read_reasons + doc.degraded_reasons + if reasons: + degraded[str(input_path)] = reasons + + css_out = generate_profile_css(profile) + warnings: list[str] = [] + html_out = _render_doc(doc, base_path=input_path.parent, self_contained=self_contained, warnings=warnings) + + return HtmlRenderResult( + html=html_out, + css=css_out.css, + warnings=warnings, + degraded=degraded, + typeset_report=css_out.report, + ) + + +def detect_pdf_engines() -> list[dict[str, Any]]: + engines: list[dict[str, Any]] = [] + if importlib.util.find_spec("playwright"): + engines.append({"name": "playwright", "detail": "python-module"}) + + chromium_path = _find_executable(["chromium", "chromium-browser", "google-chrome", "chrome"]) + if chromium_path: + engines.append({"name": "chromium", "detail": chromium_path}) + + wk_path = _find_executable(["wkhtmltopdf"]) + if wk_path: + engines.append({"name": "wkhtmltopdf", "detail": wk_path}) + + if importlib.util.find_spec("weasyprint"): + engines.append({"name": "weasyprint", "detail": "python-module"}) + + return engines + + +def renderer_info() -> dict[str, Any]: + engines = detect_pdf_engines() + return {"pdf_engines_detected": engines, "pdf_engine_versions": _engine_versions(engines)} + + +def render_pdf( + input_path: Path, + profile: dict[str, Any], + out_dir: Path, + *, + self_contained: bool = False, +) -> PdfRenderResult: + out_dir.mkdir(parents=True, exist_ok=True) + html_result = render_html(input_path, profile, self_contained=self_contained) + html_path = out_dir / "render.html" + css_path = out_dir / "render.css" + html_path.write_text(html_result.html, encoding="utf-8") + css_path.write_text(html_result.css, encoding="utf-8") + (out_dir / "typeset-report.json").write_text( + json.dumps(html_result.typeset_report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + engines = detect_pdf_engines() + log = _base_render_log(engines, html_result.warnings) + if not engines: + log["ok"] = False + log["error"] = "No PDF renderer detected. Install playwright, chromium, wkhtmltopdf, or weasyprint." + return PdfRenderResult(ok=False, engine=None, pdf_path=None, log=log) + + engine = engines[0]["name"] + pdf_path = out_dir / "render.pdf" + log["engine"] = engine + log["engine_versions"] = _engine_versions(engines) + log["html_path"] = str(html_path) + log["pdf_path"] = str(pdf_path) + + try: + if engine == "playwright": + _render_pdf_playwright(html_path, pdf_path) + elif engine == "chromium": + _render_pdf_chromium(html_path, pdf_path, engines[0]["detail"]) + elif engine == "wkhtmltopdf": + _render_pdf_wkhtmltopdf(html_path, pdf_path, engines[0]["detail"]) + elif engine == "weasyprint": + _render_pdf_weasyprint(html_path, pdf_path) + else: + raise RuntimeError(f"Unknown engine: {engine}") + except Exception as e: # noqa: BLE001 + log["ok"] = False + log["error"] = str(e) + return PdfRenderResult(ok=False, engine=engine, pdf_path=None, log=log) + + log["ok"] = True + return PdfRenderResult(ok=True, engine=engine, pdf_path=str(pdf_path), log=log) + + +def _render_doc( + doc: MdDocument, + *, + base_path: Path, + self_contained: bool, + warnings: list[str], +) -> str: + title = _doc_title(doc) + heading_ids: dict[str, int] = {} + body_lines: list[str] = [] + body_lines.append("
") + for block in doc.blocks: + if block.type == "heading": + level = block.level or 1 + slug = _unique_slug(block.text, heading_ids) + body_lines.append(f"{_render_inline(block.text, base_path, self_contained, warnings)}") + continue + if block.type == "paragraph": + body_lines.append(f"

{_render_inline(block.text, base_path, self_contained, warnings)}

") + continue + if block.type == "list": + tag = "ol" if block.ordered else "ul" + body_lines.append(f"<{tag}>") + for item in block.items: + body_lines.append(f"
  • {_render_inline(item, base_path, self_contained, warnings)}
  • ") + body_lines.append(f"") + continue + if block.type == "code": + lang = block.info.strip() + class_attr = f" class=\"language-{html.escape(lang)}\"" if lang else "" + body_lines.append(f"
    {html.escape(block.text)}
    ") + continue + if block.type == "blockquote": + body_lines.append(f"

    {_render_inline(block.text, base_path, self_contained, warnings)}

    ") + continue + if block.type == "table": + body_lines.append("") + body_lines.append(" ") + body_lines.append(" ") + for h in block.headers: + body_lines.append(f" ") + body_lines.append(" ") + body_lines.append(" ") + body_lines.append(" ") + for row in block.rows: + body_lines.append(" ") + for cell in row: + body_lines.append(f" ") + body_lines.append(" ") + body_lines.append(" ") + body_lines.append("
    {_render_inline(h, base_path, self_contained, warnings)}
    {_render_inline(cell, base_path, self_contained, warnings)}
    ") + continue + body_lines.append("
    ") + + html_lines: list[str] = [] + html_lines.append("") + html_lines.append("") + html_lines.append("") + html_lines.append(" ") + html_lines.append(" ") + html_lines.append(f" {html.escape(title)}") + html_lines.append(" ") + html_lines.append("") + html_lines.append("") + html_lines.extend([" " + line for line in body_lines]) + html_lines.append("") + html_lines.append("") + return "\n".join(html_lines).rstrip() + "\n" + + +def _doc_title(doc: MdDocument) -> str: + for block in doc.blocks: + if block.type == "heading" and block.level == 1: + return block.text.strip() or doc.path.stem + return doc.path.stem + + +def _unique_slug(text: str, used: dict[str, int]) -> str: + slug = _slugify(text) + if slug not in used: + used[slug] = 1 + return slug + used[slug] += 1 + return f"{slug}-{used[slug]}" + + +def _slugify(text: str) -> str: + slug = re.sub(r"[^a-z0-9\\s-]", "", text.lower()) + slug = re.sub(r"\\s+", "-", slug.strip()) + return slug or "section" + + +def _render_inline(text: str, base_path: Path, self_contained: bool, warnings: list[str]) -> str: + out: list[str] = [] + i = 0 + while i < len(text): + if text[i] == "`": + j = text.find("`", i + 1) + if j == -1: + out.append(html.escape(text[i:])) + break + code_text = text[i + 1 : j] + out.append(f"{html.escape(code_text)}") + i = j + 1 + continue + if text[i] == "!" and i + 1 < len(text) and text[i + 1] == "[": + img = _parse_bracket_link(text, i + 1) + if img: + alt, url, end_idx = img + src = _resolve_image_src(url, base_path, self_contained, warnings) + out.append(f"\"{html.escape(alt)}\"") + i = end_idx + continue + if text[i] == "[": + link = _parse_bracket_link(text, i) + if link: + label, url, end_idx = link + out.append(f"{html.escape(label)}") + i = end_idx + continue + out.append(html.escape(text[i])) + i += 1 + return "".join(out) + + +def _parse_bracket_link(text: str, idx: int) -> tuple[str, str, int] | None: + if text[idx] != "[": + return None + close = text.find("]", idx + 1) + if close == -1 or close + 1 >= len(text) or text[close + 1] != "(": + return None + end = text.find(")", close + 2) + if end == -1: + return None + label = text[idx + 1 : close] + url = text[close + 2 : end].strip() + return label, url, end + 1 + + +def _resolve_image_src(url: str, base_path: Path, self_contained: bool, warnings: list[str]) -> str: + if not self_contained: + return url + if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", url): + return url + img_path = (base_path / url).resolve() + if not img_path.exists() or not img_path.is_file(): + warnings.append(f"Image not found for embedding: {url}") + return url + mime = mimetypes.guess_type(str(img_path))[0] or "application/octet-stream" + data = base64.b64encode(img_path.read_bytes()).decode("ascii") + return f"data:{mime};base64,{data}" + + +def _find_executable(candidates: list[str]) -> str | None: + for name in candidates: + path = shutil.which(name) + if path: + return path + return None + + +def _render_pdf_playwright(html_path: Path, pdf_path: Path) -> None: + from playwright.sync_api import sync_playwright # type: ignore + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(html_path.resolve().as_uri()) + page.pdf(path=str(pdf_path), print_background=True) + browser.close() + + +def _render_pdf_chromium(html_path: Path, pdf_path: Path, chromium_path: str) -> None: + uri = html_path.resolve().as_uri() + cmd = [ + chromium_path, + "--headless", + "--disable-gpu", + "--print-to-pdf=" + str(pdf_path), + "--print-to-pdf-no-header", + "--allow-file-access-from-files", + uri, + ] + if os.geteuid() == 0: + cmd.insert(1, "--no-sandbox") + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def _render_pdf_wkhtmltopdf(html_path: Path, pdf_path: Path, wk_path: str) -> None: + cmd = [wk_path, "--quiet", str(html_path.resolve()), str(pdf_path)] + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def _render_pdf_weasyprint(html_path: Path, pdf_path: Path) -> None: + from weasyprint import HTML # type: ignore + + HTML(filename=str(html_path.resolve())).write_pdf(str(pdf_path)) + + +def _base_render_log(engines: list[dict[str, Any]], warnings: list[str]) -> dict[str, Any]: + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + return { + "ok": False, + "tool": "iftypeset", + "tool_version": __version__, + "generated_at_utc": now, + "engine": None, + "engines_detected": engines, + "warnings": warnings, + "python": sys.version.split()[0], + } + + +def _engine_versions(engines: list[dict[str, Any]]) -> dict[str, str]: + versions: dict[str, str] = {} + for entry in engines: + name = entry.get("name") + detail = entry.get("detail") + if name == "chromium" and isinstance(detail, str): + versions[name] = _run_version_cmd([detail, "--version"]) + elif name == "wkhtmltopdf" and isinstance(detail, str): + versions[name] = _run_version_cmd([detail, "--version"]) + elif name == "playwright": + versions[name] = _module_version("playwright") + elif name == "weasyprint": + versions[name] = _module_version("weasyprint") + return versions + + +def _run_version_cmd(cmd: list[str]) -> str: + try: + out = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + return out.stdout.strip() or out.stderr.strip() + except Exception: # noqa: BLE001 + return "unknown" + + +def _module_version(module_name: str) -> str: + try: + mod = importlib.import_module(module_name) + return getattr(mod, "__version__", "unknown") + except Exception: # noqa: BLE001 + return "unknown" diff --git a/src/iftypeset/reporting.py b/src/iftypeset/reporting.py new file mode 100644 index 0000000..08f7087 --- /dev/null +++ b/src/iftypeset/reporting.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from iftypeset.spec_loader import LoadedSpec + + +class CoverageError(RuntimeError): + pass + + +@dataclass(frozen=True) +class CoverageCounts: + total_rules: int + by_category: dict[str, int] + by_enforcement: dict[str, int] + by_severity: dict[str, int] + + +@dataclass(frozen=True) +class CoverageReport: + tool: str + tool_version: str + generated_at_utc: str + spec_root: str + manifest_version: str | None + profiles: list[str] + counts: CoverageCounts + notes: list[str] = field(default_factory=list) + + def to_markdown(self) -> str: + lines: list[str] = [] + lines.append("# iftypeset coverage report\n") + lines.append(f"- Generated (UTC): `{self.generated_at_utc}`") + lines.append(f"- Spec root: `{self.spec_root}`") + if self.manifest_version: + lines.append(f"- Manifest version: `{self.manifest_version}`") + lines.append(f"- Profiles: {', '.join(f'`{p}`' for p in self.profiles)}\n") + + lines.append("## Counts\n") + lines.append(f"- Total rules: `{self.counts.total_rules}`") + + def section(title: str, d: dict[str, int]) -> None: + lines.append(f"\n### {title}\n") + if not d: + lines.append("_None_\n") + return + for k, v in sorted(d.items(), key=lambda kv: kv[0]): + lines.append(f"- `{k}`: `{v}`") + lines.append("") + + section("By Category", self.counts.by_category) + section("By Enforcement", self.counts.by_enforcement) + section("By Severity", self.counts.by_severity) + + if self.notes: + lines.append("## Notes\n") + for n in self.notes: + lines.append(f"- {n}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def build_coverage_report(spec: LoadedSpec, *, strict: bool) -> CoverageReport: + by_category: dict[str, int] = {} + by_enforcement: dict[str, int] = {} + by_severity: dict[str, int] = {} + + for r in spec.rules: + by_category[r.category] = by_category.get(r.category, 0) + 1 + by_enforcement[r.enforcement] = by_enforcement.get(r.enforcement, 0) + 1 + by_severity[r.severity] = by_severity.get(r.severity, 0) + 1 + + notes: list[str] = [] + if not spec.rules: + notes.append("No rule batches found under spec/rules/. Phase 2 extraction has not been applied yet.") + + # This is intentionally conservative: without rule implementation handlers, we cannot compute implemented coverage. + if strict and not spec.rules: + raise CoverageError("Strict coverage requested, but no rules are present.") + + counts = CoverageCounts( + total_rules=len(spec.rules), + by_category=by_category, + by_enforcement=by_enforcement, + by_severity=by_severity, + ) + + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + from iftypeset import __version__ + + return CoverageReport( + tool="iftypeset", + tool_version=__version__, + generated_at_utc=now, + spec_root=str(spec.spec_root), + manifest_version=spec.manifest.get("version"), + profiles=sorted(spec.profiles.keys()), + counts=counts, + notes=notes, + ) diff --git a/src/iftypeset/rules.py b/src/iftypeset/rules.py new file mode 100644 index 0000000..41e0419 --- /dev/null +++ b/src/iftypeset/rules.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from jsonschema import Draft202012Validator + + +class RulesError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RuleRecord: + raw: dict[str, Any] + source_path: str + line_no: int + + @property + def id(self) -> str: + return str(self.raw.get("id", "")) + + @property + def category(self) -> str: + return str(self.raw.get("category", "")) + + @property + def enforcement(self) -> str: + return str(self.raw.get("enforcement", "")) + + @property + def severity(self) -> str: + return str(self.raw.get("severity", "")) + + +def _iter_ndjson_files(rules_root: Path) -> Iterable[Path]: + if not rules_root.exists(): + return [] + return sorted([p for p in rules_root.rglob("*.ndjson") if p.is_file()]) + + +def load_rules(rules_root: Path, *, validator: Draft202012Validator) -> list[RuleRecord]: + rules: list[RuleRecord] = [] + if not rules_root.exists(): + return rules + + seen_ids: dict[str, str] = {} + for path in _iter_ndjson_files(rules_root): + rel = str(path) + for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + raise RulesError(f"Invalid NDJSON ({rel}:{i}): {e}") + if not isinstance(obj, dict): + raise RulesError(f"Rule record must be a JSON object ({rel}:{i})") + + errors = sorted(validator.iter_errors(obj), key=lambda err: list(err.absolute_path)) + if errors: + first = errors[0] + path_bits = ".".join([str(p) for p in first.absolute_path]) if first.absolute_path else "" + raise RulesError(f"Rule schema violation ({rel}:{i}) at {path_bits}: {first.message}") + + rec = RuleRecord(raw=obj, source_path=rel, line_no=i) + rid = rec.id + if not rid: + raise RulesError(f"Rule missing id ({rel}:{i})") + if rid in seen_ids: + raise RulesError(f"Duplicate rule id '{rid}' in {rel}:{i} (already seen in {seen_ids[rid]})") + seen_ids[rid] = f"{rel}:{i}" + rules.append(rec) + + return rules + diff --git a/src/iftypeset/spec_loader.py b/src/iftypeset/spec_loader.py new file mode 100644 index 0000000..9eb17d4 --- /dev/null +++ b/src/iftypeset/spec_loader.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml +from jsonschema import Draft202012Validator + +from iftypeset.rules import RuleRecord, load_rules + + +class SpecError(RuntimeError): + pass + + +def _read_yaml(path: Path) -> Any: + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SpecError(f"Missing required file: {path}") + except Exception as e: # noqa: BLE001 - surface friendly error + raise SpecError(f"Invalid YAML: {path}: {e}") + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SpecError(f"Missing required file: {path}") + except json.JSONDecodeError as e: + raise SpecError(f"Invalid JSON: {path}: {e}") + + +@dataclass(frozen=True) +class LoadedSpec: + spec_root: Path + manifest: dict[str, Any] + rule_schema: dict[str, Any] + profiles: dict[str, dict[str, Any]] + quality_gates: dict[str, Any] + rules: list[RuleRecord] + rule_validator: Draft202012Validator + + +def load_spec(spec_root: Path) -> LoadedSpec: + spec_root = spec_root.resolve() + if not spec_root.exists(): + raise SpecError(f"Spec root does not exist: {spec_root}") + if not spec_root.is_dir(): + raise SpecError(f"Spec root is not a directory: {spec_root}") + + manifest_path = spec_root / "manifest.yaml" + schema_path = spec_root / "schema" / "rule.schema.json" + gates_path = spec_root / "quality_gates.yaml" + profiles_dir = spec_root / "profiles" + + manifest = _read_yaml(manifest_path) or {} + if not isinstance(manifest, dict): + raise SpecError("manifest.yaml must be a mapping/object.") + + rule_schema = _read_json(schema_path) or {} + if not isinstance(rule_schema, dict): + raise SpecError("rule.schema.json must be a JSON object.") + + try: + rule_validator = Draft202012Validator(rule_schema) + except Exception as e: # noqa: BLE001 + raise SpecError(f"Invalid JSON Schema: {schema_path}: {e}") + + gates = _read_yaml(gates_path) or {} + if not isinstance(gates, dict): + raise SpecError("quality_gates.yaml must be a mapping/object.") + + profiles: dict[str, dict[str, Any]] = {} + if not profiles_dir.exists(): + raise SpecError(f"Missing required directory: {profiles_dir}") + for path in sorted(profiles_dir.glob("*.yaml")): + data = _read_yaml(path) or {} + if not isinstance(data, dict): + raise SpecError(f"Profile must be a mapping/object: {path}") + pid = data.get("profile_id") + if not isinstance(pid, str) or not pid: + raise SpecError(f"Profile missing profile_id: {path}") + if pid in profiles: + raise SpecError(f"Duplicate profile_id '{pid}' in {path}") + profiles[pid] = data + + # Rules are optional until Phase 2 extraction delivers NDJSON batches. + rules: list[RuleRecord] = load_rules(spec_root / "rules", validator=rule_validator) + + return LoadedSpec( + spec_root=spec_root, + manifest=manifest, + rule_schema=rule_schema, + profiles=profiles, + quality_gates=gates, + rules=rules, + rule_validator=rule_validator, + ) + diff --git a/tests/test_iftypeset_smoke.py b/tests/test_iftypeset_smoke.py new file mode 100644 index 0000000..7bc1de2 --- /dev/null +++ b/tests/test_iftypeset_smoke.py @@ -0,0 +1,53 @@ +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + + +from iftypeset.spec_loader import load_spec # noqa: E402 +from iftypeset.css_gen import generate_profile_css # noqa: E402 +from iftypeset.index_builder import build_indexes # noqa: E402 +from iftypeset.rules import RuleRecord # noqa: E402 + + +class IfTypesetSmokeTests(unittest.TestCase): + def test_load_spec(self) -> None: + spec = load_spec(ROOT / "spec") + self.assertIn("web_pdf", spec.profiles) + self.assertIsInstance(spec.quality_gates, dict) + self.assertEqual(spec.manifest.get("registry_id"), "pubstyle") + + def test_emit_css_generic_font_not_quoted(self) -> None: + spec = load_spec(ROOT / "spec") + css_out = generate_profile_css(spec.profiles["web_pdf"]) + self.assertIn("sans-serif", css_out.css) + self.assertNotIn("\"sans-serif\"", css_out.css) + + def test_indexes_build(self) -> None: + rules = [ + RuleRecord( + raw={ + "id": "HOUSE.LAYOUT.WIDOWS_ORPHANS.AVOID", + "category": "layout", + "enforcement": "postrender", + "severity": "must", + "keywords": ["Widows", "Orphans"], + "source_refs": ["HOUSE §1.2 p1"], + }, + source_path="dummy.ndjson", + line_no=1, + ) + ] + idx = build_indexes(rules) + self.assertIn("widows", idx.keywords_all) + self.assertIn("layout", idx.category) + self.assertIn("postrender", idx.enforcement) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..f4625d4 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,93 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + +from iftypeset.rendering import detect_pdf_engines # noqa: E402 + + +class IntegrationTests(unittest.TestCase): + def test_pipeline_on_fixture(self) -> None: + fixture = ROOT / "fixtures" / "headings_basic.md" + self.assertTrue(fixture.exists()) + env = os.environ.copy() + env["PYTHONPATH"] = str(SRC) + + with tempfile.TemporaryDirectory() as tmp: + out_dir = Path(tmp) + lint_cmd = [ + sys.executable, + "-m", + "iftypeset.cli", + "lint", + "--input", + str(fixture), + "--out", + str(out_dir), + "--profile", + "web_pdf", + "--format", + "json", + "--degraded-ok", + ] + lint = subprocess.run(lint_cmd, env=env, capture_output=True, text=True) + self.assertEqual(lint.returncode, 0) + + render_cmd = [ + sys.executable, + "-m", + "iftypeset.cli", + "render-html", + "--input", + str(fixture), + "--out", + str(out_dir), + "--profile", + "web_pdf", + ] + render = subprocess.run(render_cmd, env=env, capture_output=True, text=True) + self.assertEqual(render.returncode, 0) + self.assertTrue((out_dir / "render.html").exists()) + self.assertTrue((out_dir / "render.css").exists()) + + if detect_pdf_engines(): + pdf_cmd = [ + sys.executable, + "-m", + "iftypeset.cli", + "render-pdf", + "--input", + str(fixture), + "--out", + str(out_dir), + "--profile", + "web_pdf", + ] + pdf = subprocess.run(pdf_cmd, env=env, capture_output=True, text=True) + self.assertIn(pdf.returncode, (0, 3)) + self.assertTrue((out_dir / "render-log.json").exists()) + + qa_cmd = [ + sys.executable, + "-m", + "iftypeset.cli", + "qa", + "--out", + str(out_dir), + "--profile", + "web_pdf", + ] + qa = subprocess.run(qa_cmd, env=env, capture_output=True, text=True) + self.assertIn(qa.returncode, (0, 2)) + self.assertTrue((out_dir / "layout-report.json").exists()) + self.assertTrue((out_dir / "qa-report.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_linting.py b/tests/test_linting.py new file mode 100644 index 0000000..b18e90a --- /dev/null +++ b/tests/test_linting.py @@ -0,0 +1,44 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + +from iftypeset.linting import lint_paths, manual_checklist # noqa: E402 +from iftypeset.spec_loader import load_spec # noqa: E402 + + +class LintingTests(unittest.TestCase): + def test_lint_diagnostics_and_fixes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "doc.md" + path.write_text( + "# Title\n\nLine with trailing spaces \n\nSee [link] ( https://example.com )\n", + encoding="utf-8", + ) + result = lint_paths( + [path], + profile_id="web_pdf", + fix=True, + fix_mode="suggest", + degraded_ok=True, + fail_on="warn", + ) + codes = {d.get("code") for d in result.report.get("diagnostics", [])} + self.assertIn("WS.TRAILING", codes) + self.assertIn("LINK.SPACING", codes) + self.assertTrue(result.report.get("fixes")) + + def test_manual_checklist_emits_rules(self) -> None: + spec = load_spec(ROOT / "spec") + items = manual_checklist(spec) + self.assertTrue(items) + ids = {item.get("id") for item in items} + self.assertIn("CMOS.NUMBERS.SPELLING.ONE_TO_ONE_HUNDRED.DEFAULT", ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py new file mode 100644 index 0000000..82d4a1d --- /dev/null +++ b/tests/test_md_parser.py @@ -0,0 +1,23 @@ +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + +from iftypeset.md_parser import parse_markdown # noqa: E402 + + +class MdParserTests(unittest.TestCase): + def test_degraded_unwrap(self) -> None: + text = "\n".join([f"Line {i}" for i in range(1, 26)]) + "\n" + doc = parse_markdown(Path("/tmp/sample.md"), text) + self.assertTrue(doc.degraded) + self.assertIn("hard_wrap_density", doc.degraded_reasons) + self.assertIn("missing_headings", doc.degraded_reasons) + self.assertIn("Line 1 Line 2 Line 3", doc.normalized_source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qa.py b/tests/test_qa.py new file mode 100644 index 0000000..3740245 --- /dev/null +++ b/tests/test_qa.py @@ -0,0 +1,38 @@ +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + +from iftypeset.qa import analyze_html, evaluate_gates # noqa: E402 +from iftypeset.spec_loader import load_spec # noqa: E402 + + +class QATests(unittest.TestCase): + def test_analyze_html_metrics(self) -> None: + spec = load_spec(ROOT / "spec") + profile = spec.profiles["web_pdf"] + html_text = """ + + +

    1 Intro

    +

    1.2 Skip

    +

    Paragraph with a very long URL https://example.com/this/is/a/very/long/url/that/should/trigger/and/keep/going/without/breaks

    +
    aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    + +""" + report = analyze_html(html_text, profile) + self.assertIn("max_link_wrap_incidents", report.metrics) + self.assertGreaterEqual(report.metrics["max_link_wrap_incidents"], 1) + + def test_gate_evaluation(self) -> None: + metrics = {"max_link_wrap_incidents": 3} + gates = {"max_link_wrap_incidents": 1} + result = evaluate_gates(metrics, gates, profile_id="web_pdf", strict=False) + self.assertFalse(result["ok"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..b969931 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,14 @@ +# iftypeset tools (ephemeral extraction helpers) + +These helpers exist to support **pointer-based** rule creation from purchased reference PDFs +without storing or committing copyrighted text. + +**Rules of engagement (non-negotiable):** +- Do **not** check in OCR output or full extracted book text. +- Use these tools to locate **where** guidance lives (section/page) and to inform **paraphrased** rule records. +- `source_refs` in `spec/rules/**.ndjson` must be pointers (e.g., `CMOS18 §6 p377`), not quotes. +- Keep any OCR artifacts **ephemeral** (prefer `/tmp`, delete images after OCR). + +Tools in this folder may print short snippets to stdout for operator convenience. +That is okay for local use; do not redirect that output into committed files. + diff --git a/tools/bringhurst_locate.py b/tools/bringhurst_locate.py new file mode 100644 index 0000000..c3817e3 --- /dev/null +++ b/tools/bringhurst_locate.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class PageInfo: + pdf_page: int + printed_page: str | None + text: str + + +def _run_pdftotext(pdf_path: Path) -> str: + proc = subprocess.run( + ["pdftotext", str(pdf_path), "-"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return proc.stdout.decode("utf-8", errors="replace") + + +_PRINTED_PAGE_RE = re.compile(r"^\d{1,4}$") +_SECTION_HEADING_RE = re.compile(r"^\s*(\d+(?:\.\d+){1,3})\s+(.+?)\s*$") + + +def _guess_printed_page(page_text: str) -> str | None: + lines = [ln.strip() for ln in page_text.splitlines() if ln.strip()] + for ln in reversed(lines[-8:]): # bottom-ish + if _PRINTED_PAGE_RE.match(ln): + return ln + return None + + +def _collect_section_headings(page_text: str) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for ln in page_text.splitlines(): + m = _SECTION_HEADING_RE.match(ln) + if m: + out.append((m.group(1), m.group(2))) + return out + + +def iter_pages(pdf_path: Path) -> list[PageInfo]: + text = _run_pdftotext(pdf_path) + # pdftotext uses form-feed between pages + raw_pages = text.split("\f") + pages: list[PageInfo] = [] + for i, p in enumerate(raw_pages, start=1): + if not p.strip(): + continue + pages.append(PageInfo(pdf_page=i, printed_page=_guess_printed_page(p), text=p)) + return pages + + +def _snippet(page_text: str, keyword_re: re.Pattern[str], *, max_lines: int = 4) -> str: + lines = page_text.splitlines() + hits: list[int] = [] + for i, ln in enumerate(lines): + if keyword_re.search(ln): + hits.append(i) + if not hits: + return "" + start = max(0, hits[0] - 1) + end = min(len(lines), hits[0] + 2) + snippet_lines = [ln.rstrip() for ln in lines[start:end] if ln.strip()] + return "\n".join(snippet_lines[:max_lines]).strip() + + +def main() -> None: + ap = argparse.ArgumentParser(description="Ephemeral locator for Bringhurst PDF (no output files).") + ap.add_argument("pdf", type=Path, help="Path to Bringhurst PDF") + ap.add_argument("keyword", help="Case-insensitive keyword/regex to search for") + ap.add_argument("--limit", type=int, default=50, help="Max hits to print") + ap.add_argument("--show-headings", action="store_true", help="Print section-number headings found on each hit page") + args = ap.parse_args() + + pdf_path: Path = args.pdf + if not pdf_path.exists(): + raise SystemExit(f"Missing PDF: {pdf_path}") + + pages = iter_pages(pdf_path) + kw = re.compile(args.keyword, re.IGNORECASE) + + printed = 0 + for p in pages: + if not kw.search(p.text): + continue + printed += 1 + pp = p.printed_page or "?" + print(f"\n=== hit {printed} — pdf_page={p.pdf_page} printed_p={pp} ===") + sn = _snippet(p.text, kw) + if sn: + print(sn) + if args.show_headings: + heads = _collect_section_headings(p.text) + if heads: + print("\nSection headings on page:") + for sec, title in heads[:12]: + print(f"- {sec} {title}") + if printed >= args.limit: + break + + +if __name__ == "__main__": + main() + diff --git a/tools/chicago_ocr.py b/tools/chicago_ocr.py new file mode 100644 index 0000000..0cf6ec8 --- /dev/null +++ b/tools/chicago_ocr.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import tempfile +from pathlib import Path + + +def _render_page(pdf_path: Path, *, page: int, dpi: int) -> Path: + tmpdir = Path(tempfile.mkdtemp(prefix="iftypeset_chi_")) + out_prefix = tmpdir / "page" + subprocess.run( + [ + "pdftoppm", + "-f", + str(page), + "-l", + str(page), + "-r", + str(dpi), + "-png", + "-singlefile", + str(pdf_path), + str(out_prefix), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + img = tmpdir / "page.png" + if not img.exists(): + raise RuntimeError("pdftoppm did not produce expected image") + return img + + +def _ocr_image(img: Path, *, psm: int = 6) -> str: + # stdout mode: "tesseract stdout" + proc = subprocess.run( + [ + "tesseract", + str(img), + "stdout", + "--dpi", + "200", + "--psm", + str(psm), + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return proc.stdout.decode("utf-8", errors="replace") + + +def _cleanup(path: Path) -> None: + try: + if path.is_file(): + path.unlink(missing_ok=True) + else: + for p in path.rglob("*"): + if p.is_file(): + p.unlink(missing_ok=True) + for p in sorted(path.rglob("*"), reverse=True): + if p.is_dir(): + p.rmdir() + path.rmdir() + except Exception: + pass + + +def ocr_page(pdf_path: Path, *, page: int, dpi: int) -> str: + img = _render_page(pdf_path, page=page, dpi=dpi) + try: + return _ocr_image(img) + finally: + _cleanup(img.parent) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Ephemeral OCR helper for Chicago scan PDF (prints to stdout; no files).") + ap.add_argument("pdf", type=Path, help="Path to Chicago scan PDF") + ap.add_argument("--page", type=int, required=True, help="PDF page number to OCR (1-based)") + ap.add_argument("--dpi", type=int, default=200) + ap.add_argument("--grep", default="", help="Optional regex filter; print only matching lines") + ap.add_argument( + "--max-lines", + type=int, + default=40, + help="Max number of lines to print (safety guard; applies after grep).", + ) + ap.add_argument( + "--unsafe-print-all", + action="store_true", + help="DANGEROUS: print full OCR output (avoid; may capture large copyrighted text).", + ) + args = ap.parse_args() + + if not args.pdf.exists(): + raise SystemExit(f"Missing PDF: {args.pdf}") + + if not args.grep and not args.unsafe_print_all: + raise SystemExit("Refusing to print full OCR output without --grep (use --unsafe-print-all to override).") + + text = ocr_page(args.pdf, page=args.page, dpi=args.dpi) + if args.grep: + r = re.compile(args.grep, re.IGNORECASE) + printed = 0 + for ln in text.splitlines(): + if not r.search(ln): + continue + print(ln) + printed += 1 + if printed >= args.max_lines: + break + else: + print(text) + + +if __name__ == "__main__": + main() diff --git a/tools/mappings/citations_source_refs_chicago.json b/tools/mappings/citations_source_refs_chicago.json new file mode 100644 index 0000000..627b0dd --- /dev/null +++ b/tools/mappings/citations_source_refs_chicago.json @@ -0,0 +1,20 @@ +{ + "CMOS.CITATIONS.DOI.PREFERRED_OVER_URL": [ + "CMOS18 §13.7 p778 (scan p800)" + ], + "CMOS.CITATIONS.ONLINE.ACCESS_DATE.WHEN_NEEDED": [ + "CMOS18 §13.15 p782 (scan p804)" + ], + "CMOS.CITATIONS.NOTES_BIBLIO.SUBSEQUENT_NOTES.SHORT_FORM": [ + "CMOS18 §13.32 p790 (scan p812)" + ], + "CMOS.CITATIONS.IBID.MINIMIZE_OR_AVOID": [ + "CMOS18 §13.37 p791 (scan p813)" + ], + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.INCLUDE_WHEN_USED": [ + "CMOS18 §13.65 p809 (scan p831)" + ], + "CMOS.CITATIONS.NOTES_BIBLIO.BIBLIOGRAPHY.SORT_BY_AUTHOR": [ + "CMOS18 §13.69 p816 (scan p838)" + ] +} diff --git a/tools/mappings/punctuation_source_refs_chicago.json b/tools/mappings/punctuation_source_refs_chicago.json new file mode 100644 index 0000000..55f52e7 --- /dev/null +++ b/tools/mappings/punctuation_source_refs_chicago.json @@ -0,0 +1,50 @@ +{ + "CMOS.PUNCTUATION.COMMAS.SERIAL_COMMA.DEFAULT": [ + "CMOS18 §6.19 p385 (scan p407)" + ], + "CMOS.PUNCTUATION.COMMAS.INTRODUCTORY_ELEMENTS.CLARITY": [ + "CMOS18 §6.26 p390 (scan p412)" + ], + "CMOS.PUNCTUATION.COMMAS.NONRESTRICTIVE.SET_OFF": [ + "CMOS18 §6.17 p385 (scan p407)" + ], + "CMOS.PUNCTUATION.COMMAS.RESTRICTIVE.NO_SET_OFF": [ + "CMOS18 §6.29 p392 (scan p414)" + ], + "CMOS.PUNCTUATION.SEMICOLONS.COMPLEX_SERIES.SEPARATE": [ + "CMOS18 §6.64 p408 (scan p430)" + ], + "CMOS.PUNCTUATION.DASHES.EN_DASH.RANGES": [ + "CMOS18 §6.83 p415 (scan p437)" + ], + "CMOS.PUNCTUATION.DASHES.EM_DASH.USE_WITHOUT_SPACES_US": [ + "CMOS18 §6.89 p418 (scan p440)", + "CMOS18 §6.91 p418 (scan p440)" + ], + "CMOS.PUNCTUATION.HYPHENS.COMPOUND_MODIFIERS.BEFORE_NOUN": [ + "CMOS18 §7.91 p476 (scan p498)" + ], + "CMOS.PUNCTUATION.HYPHENS.ADVERB_LY.NO_HYPHEN": [ + "CMOS18 §7.93 p476 (scan p498)" + ], + "CMOS.PUNCTUATION.ELLIPSIS.FORMAT.CONSISTENT": [ + "CMOS18 §12.59 p760 (scan p782)" + ], + "CMOS.PUNCTUATION.QUOTATION_MARKS.DOUBLE_PRIMARY_US": [ + "CMOS18 §6.122 p428 (scan p450)" + ], + "CMOS.PUNCTUATION.QUOTATION_MARKS.PUNCTUATION_PLACEMENT_US": [ + "CMOS18 §6.122 p428 (scan p450)" + ], + "CMOS.PUNCTUATION.BLOCK_QUOTES.NO_QUOTE_MARKS": [ + "CMOS18 §12.31 p746 (scan p768)" + ], + "CMOS.PUNCTUATION.MULTIPLE_MARKS.AVOID_STACKING": [ + "CMOS18 §6.131 p432 (scan p454)" + ], + "CMOS.PUNCTUATION.PARENS_BRACKETS.BALANCE": [ + "CMOS18 §6.101 p422 (scan p444)", + "CMOS18 §6.103 p422 (scan p444)" + ] +} + diff --git a/tools/ndjson_patch.py b/tools/ndjson_patch.py new file mode 100644 index 0000000..2c45cf8 --- /dev/null +++ b/tools/ndjson_patch.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any + + +SOURCE_REF_RE = re.compile( + r"^(CMOS18|BRING|HOUSE)\s§[0-9A-Za-z][0-9A-Za-z._\-]*\s+p[0-9ivxlcdmIVXLCDM]+" + r"(?:-[0-9ivxlcdmIVXLCDM]+)?(?:\s\(scan p[0-9]+\))?$" +) + + +def _load_ndjson(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError as e: + raise SystemExit(f"{path}:{idx}: invalid JSON ({e})") + return rows + + +def _write_ndjson(path: Path, rows: list[dict[str, Any]]) -> None: + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp: + tmp_path = Path(tmp.name) + for row in rows: + tmp.write(json.dumps(row, ensure_ascii=False, separators=(",", ":"))) + tmp.write("\n") + shutil.move(str(tmp_path), str(path)) + + +def _load_mapping(path: Path) -> dict[str, list[str]]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit(f"{path}: expected object mapping rule id -> source_refs list") + out: dict[str, list[str]] = {} + for k, v in data.items(): + if not isinstance(k, str): + raise SystemExit(f"{path}: mapping key must be string, got {type(k).__name__}") + if isinstance(v, str): + v = [v] + if not (isinstance(v, list) and all(isinstance(x, str) for x in v)): + raise SystemExit(f"{path}: mapping value for {k} must be string or list of strings") + out[k] = list(v) + return out + + +def _validate_source_refs(rule_id: str, source_refs: list[str]) -> list[str]: + errors: list[str] = [] + if not source_refs: + errors.append("source_refs must be non-empty") + return errors + for ref in source_refs: + if not SOURCE_REF_RE.match(ref): + errors.append(f"invalid source_ref: {ref!r}") + return errors + + +def patch_source_refs(ndjson_path: Path, mapping: dict[str, list[str]], *, strict: bool) -> int: + rows = _load_ndjson(ndjson_path) + changed = 0 + missing: set[str] = set(mapping.keys()) + problems: list[str] = [] + + for row in rows: + rule_id = row.get("id") + if rule_id in mapping: + new_refs = mapping[rule_id] + missing.discard(rule_id) + errs = _validate_source_refs(rule_id, new_refs) + if errs: + problems.extend([f"{rule_id}: {e}" for e in errs]) + continue + old_refs = row.get("source_refs") + if old_refs != new_refs: + row["source_refs"] = new_refs + changed += 1 + + if missing: + msg = f"{ndjson_path}: mapping includes unknown ids: {', '.join(sorted(missing))}" + if strict: + problems.append(msg) + else: + print(f"WARNING: {msg}", file=sys.stderr) + + if problems: + for p in problems: + print(f"ERROR: {p}", file=sys.stderr) + return 2 + + if changed: + _write_ndjson(ndjson_path, rows) + + print(json.dumps({"file": str(ndjson_path), "updated": changed}, indent=2)) + return 0 + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Patch NDJSON rule files without editing by hand.") + ap.add_argument("--file", required=True, type=Path, help="NDJSON file to patch in-place") + ap.add_argument("--mapping-json", required=True, type=Path, help="JSON mapping: rule id -> source_refs") + ap.add_argument( + "--strict", + action="store_true", + help="Fail if mapping contains unknown ids (default: warn only)", + ) + args = ap.parse_args(argv) + return patch_source_refs(args.file, _load_mapping(args.mapping_json), strict=args.strict) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))