diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..e7b7559 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,15 @@ +# Default state for all rules +default: true + +# MD013/line-length - Line length +MD013: + line_length: 99999 + code_block_line_length: 99 + tables: false + +# MD024/no-duplicate-heading - Multiple headers with same content +MD024: false + +# MD035/hr-style - Horizontal rule style +MD035: + style: "---" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7418a8c..1db909c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,3 +27,26 @@ repos: - flake8-bugbear - flake8-builtins - flake8-comprehensions + +# tests with hallmark +- repo: local + hooks: + - id: mdformat + name: mdformat + entry: mdformat + language: python + types: [markdown] + files: pre-commit-test.md|CHANGELOG.md + stages: [pre-commit] +- repo: local + hooks: + - id: hallmark + name: hallmark + entry: hallmark + args: [fix, --verbose] + language: node + types: [markdown] + files: pre-commit-test.md|CHANGELOG.md + stages: [pre-commit] + additional_dependencies: + - hallmark@5.0.1 diff --git a/.pre-commit-hook.yaml b/.pre-commit-hook.yaml index e5a8efa..f5c10a4 100644 --- a/.pre-commit-hook.yaml +++ b/.pre-commit-hook.yaml @@ -1,6 +1,6 @@ -- id: mdformat-hallmark - name: mdformat-hallmark - description: "CommonMark compliant Markdown formatter" +- id: mdformat + name: mdformat + description: "CommonMark and Hallmark compliant Markdown formatter" entry: mdformat language: python types: [markdown] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..750756a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Common Changelog](https://common-changelog.org/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2025-08-24 + +### Added + +- Add corrected table styling and information around [MarkdownStyleGuide](https://cirosantilli.com/markdown-style-guide/) used by hallmark ([#3](https://github.com/calgray/mdformat-hallmark/pull/3); Callan Gray) +- Add `.pre-commit-hook.yaml` and corrected publishing information ([#2](https://github.com/calgray/mdformat-hallmark/pull/2); Callan Gray) +- Add `hallmark` extension ([#1](https://github.com/calgray/mdformat-hallmark/pull/1); Callan Gray) + +[0.1.0]: https://github.com/calgray/mdformat-hallmark/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 4af68a0..fa4f45c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ # mdformat-hallmark -[![Build Status](https://github.com/calgray/mdformat-hallmark/actions/workflows/tests.yml/badge.svg?branch=master)]() +[![Build Status](https://github.com/calgray/mdformat-hallmark/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/calgray/mdformat-hallmark/actions?query=workflow%3ATests+branch%3Amaster+event%3Apush) [![codecov.io](https://codecov.io/gh/calgray/mdformat-hallmark/branch/master/graph/badge.svg)](https://codecov.io/gh/calgray/mdformat-hallmark) -[![PyPI version](https://badge.fury.io/py/mdformat-hallmark.svg)]() +[![PyPI version](https://badge.fury.io/py/mdformat-hallmark.svg)](https://badge.fury.io/py/mdformat-hallmark) -An [mdformat](https://github.com/executablebooks/mdformat) plugin for compatibility with [hallmark](https://github.com/vweevers/hallmark) formatted Markdown and [Common Changelog](https://common-changelog.org/) that allows both formatters and linters to simultaneously pass for quality assurance. +An [mdformat](https://github.com/executablebooks/mdformat) plugin for compatibility with [hallmark](https://github.com/vweevers/hallmark), [MarkdownStyleGuide](https://cirosantilli.com/markdown-style-guide) and [Common Changelog](https://common-changelog.org/) that allows both formatters and linters to simultaneously pass for quality assurance. ## Features -- `hallmark` style reference formatting for changelogs with blank lines and sort by semantic version. +- `remark-preset-lint-markdown-style-guide` style compatibility. +- `hallmark` style formatting of definitions at end of the document with: + - blank line seperators + - keep label casing + - sort first by semantic version labels + - sort second by alphanumeric labels ## Install @@ -27,7 +32,29 @@ After installing the plugin, run `mdformat` for Markdown files including Common mdformat README.md CHANGELOG.md # with extension explicitly required -mdformat --extensions hallmark README.md CHANGELOG.md +mdformat --extensions hallmark --extensions tables README.md CHANGELOG.md +``` + +## Pre-Commit Usage + +```yaml +repos: + - repo: https://github.com/executablebooks/mdformat + rev: 0.7.19 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-hallmark +``` + +for latest developement version: + +```yaml +repos: + - repo: https://github.com/calgray/mdformat-hallmark + rev: master + hooks: + - id: mdformat ``` ## Development @@ -85,10 +112,3 @@ flit publish or trigger the GitHub Action job, by creating a release with a tag equal to the version, e.g. `v0.0.1`. Note, this requires generating an API key on PyPi and adding it to the repository `Settings/Secrets`, under the name `PYPI_KEY`. - -[ci-badge]: https://github.com/executablebooks/mdformat-hallmark/workflows/CI/badge.svg?branch=master -[ci-link]: https://github.com/executablebooks/mdformat/actions?query=workflow%3ACI+branch%3Amaster+event%3Apush -[cov-badge]: https://codecov.io/gh/executablebooks/mdformat-hallmark/branch/master/graph/badge.svg -[cov-link]: https://codecov.io/gh/executablebooks/mdformat-hallmark -[pypi-badge]: https://img.shields.io/pypi/v/mdformat-hallmark.svg -[pypi-link]: https://pypi.org/project/mdformat-hallmark diff --git a/mdformat_hallmark/__init__.py b/mdformat_hallmark/__init__.py index 809d488..bd576b6 100644 --- a/mdformat_hallmark/__init__.py +++ b/mdformat_hallmark/__init__.py @@ -5,6 +5,6 @@ __version__ = "0.0.1" -from .hallmark_links_extension import HallmarkLinksExtension +from .hallmark_definitions_extension import HallmarkDefinitionsExtension -__all__ = ["HallmarkLinksExtension"] +__all__ = ["HallmarkDefinitionsExtension"] diff --git a/mdformat_hallmark/hallmark_definitions_extension.py b/mdformat_hallmark/hallmark_definitions_extension.py new file mode 100644 index 0000000..31830a8 --- /dev/null +++ b/mdformat_hallmark/hallmark_definitions_extension.py @@ -0,0 +1,129 @@ +from contextlib import suppress +from dataclasses import dataclass +import re + +from markdown_it import MarkdownIt +from markdown_it.token import Token +from mdformat.plugins import ParserExtensionInterface +from mdformat.renderer import RenderContext, RenderTreeNode +from packaging.version import InvalidVersion, Version + +_DEFINITION_RE = re.compile( + r'^\[([^\]]+)\]:\s*(\S+)(?:\s+"([^"]+)")?$', + re.MULTILINE, +) # regex for `[label]: href "title"` + + +@dataclass +class SemverDefinition: + semver: Version + label: str + href: str + title: str | None + + +@dataclass +class Definition: + label: str + href: str + title: str | None + + +def _extract_references(src: str, is_changelog: bool) -> tuple[list[Definition], str]: + semver_defs: list[SemverDefinition] = [] + remark_defs: list[Definition] = [] + remove_spans: list[tuple[int, int]] = [] + + for m in _DEFINITION_RE.finditer(src): + label, href, title = m.groups() + semver = None + if is_changelog: + with suppress(InvalidVersion): + semver = Version(label) + semver_defs.append(SemverDefinition(semver, label, href, title)) + if not semver: + # remove definition has no references + reference_re = rf"\[{re.escape(label)}\](?!:)" + if re.search(reference_re, src, flags=re.MULTILINE): + remark_defs.append(Definition(label, href, title)) + remove_spans.append(m.span()) + + # remove references from the source + out_src = src + for start, end in reversed(remove_spans): + out_src = out_src[:start] + out_src[end:] + + # Normalize whitespace + out_src = re.sub(r"\n{3,}", "\n\n", out_src).rstrip() + + # sort semver references + semver_defs.sort(key=lambda ref: ref.semver, reverse=True) + + # sort remark references + remark_defs.sort(key=lambda ref: ref.label) + + out_refs = [Definition(ref.label, ref.href, title) for ref in semver_defs] + out_refs.extend(remark_defs) + return out_refs, out_src + + +class HallmarkDefinitionsExtension(ParserExtensionInterface): + """mdformat plugin extension to format definitions in remark and hallmark style. + + * Hallmark detected Changelog versions used as definition labels are + rendered first by descending semantic-version order. + * Remark detected definitions are rendered after by alphanumeric order. + * All definition labels keep the original casing. + """ + + @staticmethod + def update_mdit(mdit: MarkdownIt) -> None: + """Patch the default parser to render references in semver order.""" + + original_parse = mdit.parse + + def new_parse(src: str, env=None): + if env is None: + env = {} + + is_changelog = src.lstrip().startswith("# Changelog") + + # extract definitions and remove from src + remark_defs, out_src = _extract_references(src, is_changelog) + defs = { + match.label: {"href": match.href, "title": match.title} + for match in remark_defs + } + + # call original parse on the cleaned text + tokens = original_parse(out_src, env) + + # append a remark_defs token at the end + if defs: + token = Token("remark_defs", "", 0) + token.meta = {"refs": defs} + tokens.append(token) + + return tokens + + # patch parser + mdit.parse = new_parse + + @staticmethod + def _render_remark_defs(node: RenderTreeNode, ctx: RenderContext) -> str: + """Render collected reference defs in hallmark ordered format.""" + refs: dict[str, dict[str, str]] = node.meta["refs"] + out = [] + for label, ref in refs.items(): + line = f"[{label}]: {ref['href']}" + if ref.get("title"): + line += f' "{ref["title"]}"' + out.append(line) + return "\n\n".join(out) + + @staticmethod + def _render_hr(node: RenderTreeNode, context: RenderContext) -> str: + return "---" + + RENDERERS = {"remark_defs": _render_remark_defs, "hr": _render_hr} + CHANGES_AST = True diff --git a/mdformat_hallmark/hallmark_links_extension.py b/mdformat_hallmark/hallmark_links_extension.py deleted file mode 100644 index e224cdf..0000000 --- a/mdformat_hallmark/hallmark_links_extension.py +++ /dev/null @@ -1,103 +0,0 @@ -from dataclasses import dataclass -import re - -from markdown_it import MarkdownIt -from markdown_it.token import Token -from mdformat.plugins import ParserExtensionInterface -from mdformat.renderer import RenderContext, RenderTreeNode -from packaging.version import InvalidVersion, Version - -_REFERENCES_RE = re.compile( - r'^\[([^\]]+)\]:\s*(\S+)(?:\s+"([^"]+)")?$', - re.MULTILINE, -) # regex for `[label]: href "title"` - - -@dataclass -class SemverReference: - semver: Version - label: str - href: str - title: str | None - - -def _extract_semver_references(src: str) -> tuple[list[SemverReference], str]: - refs: list[SemverReference] = [] - remove_spans: list[tuple[int, int]] = [] - - for m in _REFERENCES_RE.finditer(src): - label, href, title = m.groups() - try: - semver = Version(label) - except InvalidVersion: - continue # skip non-semver labels - refs.append(SemverReference(semver, label, href, title)) - remove_spans.append(m.span()) - - # remove references from the source - out_src = src - for start, end in reversed(remove_spans): - out_src = out_src[:start] + out_src[end:] - - # Normalize whitespace - out_src = re.sub(r"\n{3,}", "\n\n", out_src).rstrip() - - # sort semver references - refs.sort(key=lambda ref: ref.semver, reverse=True) - - return refs, out_src - - -class HallmarkLinksExtension(ParserExtensionInterface): - """ - mdformat plugin extension to format references used by versions and - renders sorted by semantic-version order. - """ - - @staticmethod - def update_mdit(mdit: MarkdownIt) -> None: - """Patch the default parser to render references in semver order.""" - - original_parse = mdit.parse - - def new_parse(src: str, env=None): - if env is None: - env = {} - if not src.lstrip().startswith("# Changelog"): - return original_parse(src, env) - - # extract semver references and remove from src - matches, out_src = _extract_semver_references(src) - refs = { - match.label: {"href": match.href, "title": match.title} - for match in matches - } - - # call original parse on the cleaned text - tokens = original_parse(out_src, env) - - # append a hallmark_refs token at the end - if refs: - token = Token("hallmark_refs", "", 0) - token.meta = {"refs": refs} - tokens.append(token) - - return tokens - - # patch parser - mdit.parse = new_parse - - @staticmethod - def _render_hallmark_refs(node: RenderTreeNode, ctx: RenderContext) -> str: - """Render collected reference defs in hallmark ordered format.""" - refs: dict[str, dict[str, str]] = node.meta["refs"] - out = [] - for label, ref in refs.items(): - line = f"[{label}]: {ref['href']}" - if ref.get("title"): - line += f' "{ref["title"]}"' - out.append(line) - return "\n\n".join(out) - - RENDERERS = {"hallmark_refs": _render_hallmark_refs} - CHANGES_AST = True diff --git a/pyproject.toml b/pyproject.toml index a66eeb6..0abb203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ keywords = ["mdformat", "markdown", "markdown-it"] requires-python = ">=3.7" dependencies = [ "mdformat >=0.7.0,<0.8.0", + "mdformat-tables >= 1.0.0", "packaging >= 23.0", ] dynamic = ["version", "description"] @@ -34,7 +35,7 @@ dev = ["pre-commit"] Homepage = "https://github.com/calgray/mdformat-hallmark" [project.entry-points."mdformat.parser_extension"] -"hallmark" = "mdformat_hallmark:HallmarkLinksExtension" +"hallmark" = "mdformat_hallmark:HallmarkDefinitionsExtension" [tool.flit.sdist] include = [] diff --git a/tests/CHANGELOG.md b/tests/CHANGELOG.md new file mode 100644 index 0000000..5ab4ddb --- /dev/null +++ b/tests/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +Example changelog for testing compatibility. + +## [1.0.0] - 2025-01-01 + +### Added + +- Added features ([#2](https://example.com/issues/2); John Smith) + +| Name | Age | City | +| ----- | --- | ------------- | +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Carol | 27 | London | + +## [0.1.0] - 2025-01-01 + +### Added + +- Added feature ([#1](https://example.com/issues/1); John Smith) +- Normal [reference] + +## [0.1.0-alpha] - 2025-01-01 + +_:seedling: Initial release._ + +--- + +Extra information + +--- + +semver definitions followed by regular definitions always at the bottom. + +[1.0.0]: https://example.com/releases/tag/v1.0.0 + +[0.1.0]: https://example.com/releases/tag/v0.1.0 + +[0.1.0-alpha]: https://example.com/releases/tag/v0.1.0-alpha + +[reference]: https://example.com diff --git a/tests/fixtures.md b/tests/fixtures.md index ff46a81..4aed90c 100644 --- a/tests/fixtures.md +++ b/tests/fixtures.md @@ -3,26 +3,48 @@ not changelog test This is the input Markdown test, then below add the expected output. -- a [reference][] -- b [0.0.1] +- a [link] +- b [0.0.1][] * c [1.0.0] +___ + +| Name | Age | City | +|--------|-----|-------------| +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Carol | 27 | London | + +--- + [unused reference]: https://example.com [0.0.1]: https://example.com -[reference]: https://example.com +[link]: https://example.com [1.0.0]: https://example.com . This is the input Markdown test, then below add the expected output. -- a [reference] -- b [0.0.1] +- a [link] +- b [0.0.1][] * c [1.0.0] +--- + +| Name | Age | City | +| ----- | --- | ------------- | +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Carol | 27 | London | + +--- + [0.0.1]: https://example.com + [1.0.0]: https://example.com -[reference]: https://example.com + +[link]: https://example.com . correct changelog test @@ -160,7 +182,6 @@ unsorted changelog test [0.0.1]: https://example.com . - extra links changelog test . # Changelog @@ -169,16 +190,28 @@ extra links changelog test ### Added -- Commit [JIRA-2] +- Commit [JIRA-10] -## [1.1.0] - 2025-01-01 +## [1.1.0][] - 2025-01-01 ### Added +- Commit [JIRA-2] - Commit [JIRA-1] -[JIRA-2]: https://example.com -[JIRA-1]: https://example.com +___ + +| Name | Age | City | +| ----- | --- | ------------- | +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Carol | 27 | London | + +___ + +[JIRA-10]: https://example.com "ticket title" +[JIRA-2]: https://example.com "ticket title" +[JIRA-1]: https://example.com "ticket title" [2.10.0]: https://example.com [10.1.0]: https://example.com @@ -189,18 +222,32 @@ extra links changelog test ### Added -- Commit [JIRA-2] +- Commit [JIRA-10] -## [1.1.0] - 2025-01-01 +## [1.1.0][] - 2025-01-01 ### Added +- Commit [JIRA-2] - Commit [JIRA-1] +--- + +| Name | Age | City | +| ----- | --- | ------------- | +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Carol | 27 | London | + +--- + [10.1.0]: https://example.com [2.10.0]: https://example.com -[jira-1]: https://example.com -[jira-2]: https://example.com +[JIRA-1]: https://example.com "ticket title" + +[JIRA-10]: https://example.com "ticket title" + +[JIRA-2]: https://example.com "ticket title" . diff --git a/tests/pre-commit-test.md b/tests/pre-commit-test.md index 6e4b36f..174d69e 100644 --- a/tests/pre-commit-test.md +++ b/tests/pre-commit-test.md @@ -1,3 +1,19 @@ -# Test file +# Style Rules -add your syntax here +## Tables + +| Name | Age | City | +| ----- | --- | ------------- | +| Alice | 30 | New York | +| Bob | 25 | San Francisco | +| Carol | 27 | London | + +## Rulers + +always use '---' ruler + +--- + +### References + +Sort definitions alphanumerically diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index b2273d1..39435a7 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -12,5 +12,5 @@ "line,title,text,expected", fixtures, ids=[f[1] for f in fixtures] ) def test_fixtures(line, title, text, expected): - output = mdformat.text(text, extensions={"hallmark"}) + output = mdformat.text(text, extensions={"hallmark", "tables"}) assert output.rstrip() == expected.rstrip(), output