Markdown-Checker 1.2.2__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/PKG-INFO +4 -3
  2. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/README.md +1 -1
  3. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/explanation/link-detection.rst +10 -7
  4. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/pyproject-config.rst +1 -0
  5. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/skip-links.rst +4 -3
  6. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/reference/checks.rst +14 -9
  7. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/reference/cli.rst +24 -4
  8. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/reference/configuration.rst +4 -0
  9. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/reference/report-formats.rst +1 -1
  10. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/tutorial.rst +1 -1
  11. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/pyproject.toml +1 -1
  12. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/__init__.py +1 -1
  13. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checks/broken_paths.py +2 -1
  14. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checks/broken_urls.py +2 -5
  15. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checks/locale.py +1 -4
  16. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checks/tracking.py +12 -12
  17. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/cli.py +12 -0
  18. markdown_checker-1.3.0/src/markdown_checker/models/base.py +84 -0
  19. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/models/config.py +16 -5
  20. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/models/path.py +27 -53
  21. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/models/url.py +14 -0
  22. markdown_checker-1.3.0/src/markdown_checker/patterns.py +57 -0
  23. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/model.py +8 -0
  24. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/renderers/markdown.py +9 -3
  25. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/test_base.py +1 -1
  26. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/test_broken_paths.py +2 -1
  27. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/test_tracking.py +20 -0
  28. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/models/test_base.py +4 -0
  29. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/models/test_path.py +52 -16
  30. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/models/test_url.py +17 -0
  31. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/conftest.py +1 -1
  32. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/renderers/test_json.py +1 -1
  33. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/renderers/test_markdown.py +31 -0
  34. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/test_model.py +22 -0
  35. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/test_cli.py +12 -1
  36. markdown_checker-1.3.0/uv.lock +1835 -0
  37. markdown_checker-1.2.2/src/markdown_checker/models/base.py +0 -59
  38. markdown_checker-1.2.2/src/markdown_checker/patterns.py +0 -36
  39. markdown_checker-1.2.2/uv.lock +0 -1561
  40. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/LICENSE +0 -0
  41. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/CODE_OF_CONDUCT.rst +0 -0
  42. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/CONTRIBUTING.rst +0 -0
  43. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/checker.rst +0 -0
  44. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/checks.rst +0 -0
  45. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/cli.rst +0 -0
  46. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/index.rst +0 -0
  47. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/main.rst +0 -0
  48. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/models.rst +0 -0
  49. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/patterns.rst +0 -0
  50. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/reports.rst +0 -0
  51. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/api/utils.rst +0 -0
  52. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/changes.rst +0 -0
  53. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/conf.py +0 -0
  54. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/explanation/index.rst +0 -0
  55. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/explanation/url-checking.rst +0 -0
  56. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/github-actions.rst +0 -0
  57. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/index.rst +0 -0
  58. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/rate-limits.rst +0 -0
  59. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/report-formats.rst +0 -0
  60. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/select-files.rst +0 -0
  61. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/howto/shell-completions.rst +0 -0
  62. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/index.rst +0 -0
  63. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/license.rst +0 -0
  64. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/reference/exit-codes.rst +0 -0
  65. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/docs/reference/index.rst +0 -0
  66. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checker.py +0 -0
  67. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checks/__init__.py +0 -0
  68. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/checks/base.py +0 -0
  69. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/models/__init__.py +0 -0
  70. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/py.typed +0 -0
  71. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/__init__.py +0 -0
  72. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/base.py +0 -0
  73. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/registry.py +0 -0
  74. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/renderers/__init__.py +0 -0
  75. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/renderers/annotations.py +0 -0
  76. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/renderers/console.py +0 -0
  77. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/renderers/json.py +0 -0
  78. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/templates/paths/broken.md +0 -0
  79. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/templates/paths/tracking.md +0 -0
  80. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/templates/urls/broken.md +0 -0
  81. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/templates/urls/locale.md +0 -0
  82. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/templates/urls/tracking.md +0 -0
  83. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/reports/writer.py +0 -0
  84. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/__init__.py +0 -0
  85. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/extract_links.py +0 -0
  86. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/github_env.py +0 -0
  87. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/list_files.py +0 -0
  88. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/pyproject_config.py +0 -0
  89. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/spinner.py +0 -0
  90. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/src/markdown_checker/utils/url_pipeline.py +0 -0
  91. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/__init__.py +0 -0
  92. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/test_broken_urls.py +0 -0
  93. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/test_checks.py +0 -0
  94. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/checks/test_locale.py +0 -0
  95. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/conftest.py +0 -0
  96. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/models/__init__.py +0 -0
  97. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/models/test_config.py +0 -0
  98. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/models/test_models.py +0 -0
  99. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/__init__.py +0 -0
  100. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/renderers/__init__.py +0 -0
  101. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/renderers/test_annotations.py +0 -0
  102. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/renderers/test_console.py +0 -0
  103. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/renderers/test_renderers.py +0 -0
  104. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/test_base.py +0 -0
  105. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/test_registry.py +0 -0
  106. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/test_reports.py +0 -0
  107. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/reports/test_writer.py +0 -0
  108. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/resources/sample1.md +0 -0
  109. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/resources/sample2.md +0 -0
  110. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/resources/sample3.md +0 -0
  111. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/resources/sample4.md +0 -0
  112. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/smoke/test_cli_smoke.py +0 -0
  113. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/test_checker.py +0 -0
  114. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/test_markdown_checker.py +0 -0
  115. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/test_patterns.py +0 -0
  116. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/__init__.py +0 -0
  117. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_extract_links.py +0 -0
  118. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_github_env.py +0 -0
  119. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_list_files.py +0 -0
  120. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_pyproject_config.py +0 -0
  121. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_spinner.py +0 -0
  122. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_url_pipeline.py +0 -0
  123. {markdown_checker-1.2.2 → markdown_checker-1.3.0}/tests/utils/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: markdown-checker
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: A markdown link validation reporting tool.
5
5
  Author-email: John Aziz <johnaziz269@gmail.com>
6
6
  Maintainer-email: John Aziz <johnaziz269@gmail.com>
@@ -26,6 +26,7 @@ Project-URL: Documentation, https://markdown-checker.readthedocs.io/en/latest/
26
26
  Project-URL: Homepage, https://github.com/john0isaac/markdown-checker
27
27
  Project-URL: Issues, https://github.com/john0isaac/markdown-checker/issues
28
28
  Project-URL: Releases, https://github.com/john0isaac/markdown-checker/releases
29
+ Import-Name: markdown_checker
29
30
 
30
31
  [![PyPi](https://img.shields.io/pypi/v/markdown-checker)](https://pypi.org/project/markdown-checker/)
31
32
  [![Documentation Status](https://readthedocs.org/projects/markdown-checker/badge/?version=latest)](https://markdown-checker.readthedocs.io/en/latest/?badge=latest)
@@ -77,7 +78,7 @@ exits with a non-zero status:
77
78
  🔍 Checked 42 links in 10 files.
78
79
  😭 Found 1 issues in the following files:
79
80
  File 'docs/index.md', line 5
80
- ./missing.md is broken.
81
+ ./missing.md could not be found at /docs/missing.md.
81
82
  ```
82
83
 
83
84
  ## Using `markdown-checker` in GitHub Actions
@@ -48,7 +48,7 @@ exits with a non-zero status:
48
48
  🔍 Checked 42 links in 10 files.
49
49
  😭 Found 1 issues in the following files:
50
50
  File 'docs/index.md', line 5
51
- ./missing.md is broken.
51
+ ./missing.md could not be found at /docs/missing.md.
52
52
  ```
53
53
 
54
54
  ## Using `markdown-checker` in GitHub Actions
@@ -38,16 +38,19 @@ Fragments on paths
38
38
  ----------------------
39
39
 
40
40
  A path may carry a trailing ``?query`` and/or ``#anchor`` - for example
41
- ``docs/usage.md#configuration``. Both checks that operate on paths strip
42
- this fragment before resolving the file, so only the ``docs/usage.md``
43
- part needs to exist on disk; the anchor itself is never validated against
44
- the target file's headings.
41
+ ``docs/usage.md#configuration``. As in any URL, everything from the first
42
+ ``?`` or ``#`` onward is stripped before resolving the file, so only the
43
+ ``docs/usage.md`` part needs to exist on disk; the anchor itself is never
44
+ validated against the target file's headings. The remaining path is then
45
+ percent-decoded, so ``my%20file.md`` refers to ``my file.md``. A file name
46
+ that really contains ``#`` or ``?`` must be written as ``%23``/``%3F``.
45
47
 
46
48
  Resolving a path to a file
47
49
  --------------------------------
48
50
 
49
51
  ``check_broken_paths`` tries to resolve a path relative to the markdown
50
52
  file it was found in first (e.g. ``../img.png`` in ``docs/usage.md``
51
- resolves against ``docs/``), then falls back to resolving it as an
52
- absolute or current-working-directory-relative path. A path is reported as
53
- broken only if neither resolution finds an existing file.
53
+ resolves against ``docs/``), then relative to the current working
54
+ directory. Root-relative paths (``/docs/usage.md``) are tried the same way
55
+ with the leading ``/`` dropped. A path is reported as broken only if
56
+ neither resolution finds an existing file.
@@ -18,6 +18,7 @@ Add a ``[tool.markdown-checker]`` table to your project's
18
18
  extensions = [".md", ".ipynb"]
19
19
  skip-files = ["CODE_OF_CONDUCT.md", "SECURITY.md"]
20
20
  tracking-domains = ["github.com", "microsoft.com", "visualstudio.com", "aka.ms", "azure.com"]
21
+ tracking-param = ["wt.mc_id"]
21
22
  guide-url = "https://github.com/john0isaac/markdown-checker/blob/main/CONTRIBUTING.md"
22
23
  max-workers = 10
23
24
  report-format = "markdown"
@@ -17,8 +17,9 @@ check automatically (e.g. it blocks bots) but you still want to keep the link:
17
17
 
18
18
  markdown-checker . -f check_broken_urls --skip-domains=example.com,test.com
19
19
 
20
- Matching is a substring match against the URL's hostname, so
21
- ``--skip-domains=example.com`` also matches ``www.example.com``.
20
+ A domain matches the URL's hostname exactly or as a parent domain, so
21
+ ``--skip-domains=example.com`` also matches ``www.example.com`` (but not
22
+ ``notexample.com``).
22
23
 
23
24
  Skip URLs containing a substring
24
25
  ------------------------------------
@@ -35,7 +36,7 @@ List syntax
35
36
  -----------
36
37
 
37
38
  Every list option (``--skip-domains``, ``--skip-urls-containing``,
38
- ``--extensions``, ``--skip-files``, ``--tracking-domains``) takes a single
39
+ ``--extensions``, ``--skip-files``, ``--tracking-domains``, ``--tracking-param``) takes a single
39
40
  comma-separated value:
40
41
 
41
42
  - Correct: ``--skip-domains=example.com,test.com``
@@ -26,7 +26,7 @@ Sample finding
26
26
  .. code-block:: text
27
27
 
28
28
  File 'docs/index.md', line 5
29
- ./missing.md is broken.
29
+ ./missing.md could not be found at /docs/missing.md.
30
30
 
31
31
  ``check_broken_urls``
32
32
  ------------------------
@@ -55,8 +55,10 @@ Sample finding
55
55
 
56
56
  Flags
57
57
  URLs whose path contains a locale segment, e.g. ``/en-us/`` in
58
- ``https://example.com/en-us/docs``. A "locale segment" is a
59
- language-country pair matching ``xx-xx`` (case-insensitive).
58
+ ``https://example.com/en-us/docs`` or a trailing ``/en-us``. A "locale
59
+ segment" is a known language-country pair of the form ``xx-xx``
60
+ (case-insensitive), so look-alikes such as ``/ci-cd/`` aren't flagged.
61
+ The host, query string and anchor are not searched.
60
62
  Ignores
61
63
  ``www.nvidia.com`` (built in - its docs always include a locale
62
64
  segment), plus any host in ``--skip-domains`` or URL in
@@ -73,8 +75,8 @@ Sample finding
73
75
  --------------------------
74
76
 
75
77
  Flags
76
- URLs on a configured tracking domain that are missing a ``wt.mc_id``
77
- query parameter.
78
+ URLs on a configured tracking domain that are missing every
79
+ ``--tracking-param`` query parameter (``wt.mc_id`` by default).
78
80
  Ignores
79
81
  URLs on hosts *not* listed in ``--tracking-domains`` (the check does
80
82
  nothing for them), plus any host in ``--skip-domains`` or URL in
@@ -83,7 +85,8 @@ Default tracking domains
83
85
  ``github.com``, ``microsoft.com``, ``visualstudio.com``, ``aka.ms``,
84
86
  ``azure.com``.
85
87
  Options that affect it
86
- ``--tracking-domains``, ``--skip-domains``, ``--skip-urls-containing``.
88
+ ``--tracking-domains``, ``--tracking-param``, ``--skip-domains``,
89
+ ``--skip-urls-containing``.
87
90
  Sample finding
88
91
  .. code-block:: text
89
92
 
@@ -94,10 +97,12 @@ Sample finding
94
97
  ---------------------------
95
98
 
96
99
  Flags
97
- Every relative path link missing a ``wt.mc_id`` query parameter. Unlike
98
- ``check_urls_tracking``, there is no domain filter - all paths are checked.
100
+ Every relative path link missing every ``--tracking-param`` query
101
+ parameter (``wt.mc_id`` by default). Unlike ``check_urls_tracking``,
102
+ there is no domain filter - all paths are checked.
99
103
  Options that affect it
100
- ``SRC``/``--dir``, ``--extensions``, ``--skip-files`` only.
104
+ ``SRC``/``--dir``, ``--extensions``, ``--skip-files``,
105
+ ``--tracking-param``.
101
106
  Sample finding
102
107
  .. code-block:: text
103
108
 
@@ -78,14 +78,33 @@ Type
78
78
  Default
79
79
  ``github.com,microsoft.com,visualstudio.com,aka.ms,azure.com``.
80
80
  Constraints
81
- Only affects ``check_urls_tracking``.
81
+ Only affects ``check_urls_tracking``. Subdomains are included, e.g.
82
+ ``microsoft.com`` also matches ``learn.microsoft.com``.
82
83
  Description
83
- Hostnames that must carry a ``wt.mc_id`` tracking parameter.
84
+ Hostnames that must carry a tracking parameter (see ``--tracking-param``).
84
85
 
85
86
  .. code-block:: bash
86
87
 
87
88
  markdown-checker . -f check_urls_tracking --tracking-domains=github.com,microsoft.com
88
89
 
90
+ ``-tp``, ``--tracking-param``
91
+ ---------------------------------
92
+
93
+ Type
94
+ ``list[str]``, comma-separated.
95
+ Default
96
+ ``wt.mc_id``.
97
+ Constraints
98
+ Only affects ``check_urls_tracking`` and ``check_paths_tracking``.
99
+ Matched case-insensitively as a query parameter (after ``?`` or ``&``).
100
+ Description
101
+ Tracking query parameters a link must carry. A link passes if it has any
102
+ one of them.
103
+
104
+ .. code-block:: bash
105
+
106
+ markdown-checker . -f check_urls_tracking --tracking-param=wt.mc_id,utm_source
107
+
89
108
  ``-sf``, ``--skip-files``
90
109
  -----------------------------
91
110
 
@@ -110,8 +129,9 @@ Type
110
129
  Default
111
130
  ``[]`` (none).
112
131
  Constraints
113
- Only affects ``check_broken_urls`` and ``check_urls_locale``. Substring
114
- match against the URL's hostname.
132
+ Only affects ``check_broken_urls`` and ``check_urls_locale``. Matches the
133
+ URL's hostname exactly or as a parent domain (``example.com`` matches
134
+ ``www.example.com`` but not ``notexample.com``).
115
135
  Description
116
136
  Domains to exclude from URL checks.
117
137
 
@@ -47,6 +47,9 @@ Keys
47
47
  * - ``tracking-domains``
48
48
  - ``--tracking-domains``
49
49
  - array of strings
50
+ * - ``tracking-param``
51
+ - ``--tracking-param``
52
+ - array of strings
50
53
  * - ``skip-files``
51
54
  - ``--skip-files``
52
55
  - array of strings
@@ -96,6 +99,7 @@ Example
96
99
  skip-files = ["CODE_OF_CONDUCT.md", "SECURITY.md"]
97
100
  skip-domains = []
98
101
  tracking-domains = ["github.com", "microsoft.com", "visualstudio.com", "aka.ms", "azure.com"]
102
+ tracking-param = ["wt.mc_id"]
99
103
  guide-url = "https://github.com/john0isaac/markdown-checker/blob/main/CONTRIBUTING.md"
100
104
  timeout = 20
101
105
  retries = 3
@@ -46,7 +46,7 @@ A structured document including both errors and warnings:
46
46
  .. code-block:: json
47
47
 
48
48
  {
49
- "tool": {"name": "markdown-checker", "version": "1.2.2"},
49
+ "tool": {"name": "markdown-checker", "version": "1.3.0"},
50
50
  "check": "check_broken_urls",
51
51
  "summary": {
52
52
  "files_checked": 10,
@@ -63,7 +63,7 @@ You'll see output similar to:
63
63
  🔍 Checked 2 links in 2 files.
64
64
  😭 Found 1 issues in the following files:
65
65
  File 'docs-sample/index.md', line 5
66
- ./missing.md is broken.
66
+ ./missing.md could not be found at /docs/missing.md.
67
67
 
68
68
  Because an error-level issue was found, markdown-checker also writes a
69
69
  ``comment.md`` file in the current directory with the same information as a
@@ -67,7 +67,7 @@ typing = [
67
67
  ]
68
68
 
69
69
  [build-system]
70
- requires = ["flit_core<4"]
70
+ requires = ["flit_core<5"]
71
71
  build-backend = "flit_core.buildapi"
72
72
 
73
73
  [project.scripts]
@@ -2,7 +2,7 @@
2
2
  markdown-checker - a markdown link validation reporting tool.
3
3
  """
4
4
 
5
- __version__ = "1.2.2"
5
+ __version__ = "1.3.0"
6
6
 
7
7
  from markdown_checker.cli import main
8
8
  from markdown_checker.models import Config
@@ -28,6 +28,7 @@ class BrokenPathsCheck(BaseCheck[MarkdownPath]):
28
28
  detected_issues: list[MarkdownPath] = []
29
29
  for path in links.paths:
30
30
  if not path.exists():
31
- path.issue = "is broken"
31
+ path.resolved_path = path.get_full_path()
32
+ path.issue = f"could not be found at {path.resolved_path.as_posix()}"
32
33
  detected_issues.append(path)
33
34
  return detected_issues
@@ -58,7 +58,7 @@ class BrokenURLsCheck(BaseCheck[MarkdownURL]):
58
58
  :meth:`collect` to resolve later.
59
59
  """
60
60
  config = config or Config()
61
- effective_skip = [domain.lower() for domain in [*config.skip_domains, *_BUILTIN_SKIP_DOMAINS]]
61
+ effective_skip = [*config.skip_domains, *_BUILTIN_SKIP_DOMAINS]
62
62
  skip_urls_containing = config.skip_urls_containing
63
63
 
64
64
  # Standalone fallback (direct calls, tests): own a private service for this run only.
@@ -67,10 +67,7 @@ class BrokenURLsCheck(BaseCheck[MarkdownURL]):
67
67
 
68
68
  pending: list[tuple[MarkdownURL, Future[URLCheckResult]]] = []
69
69
  for url in links.urls:
70
- hostname = url.host_name().lower()
71
- if any(domain in hostname for domain in effective_skip) or any(
72
- substring in url.link for substring in skip_urls_containing
73
- ):
70
+ if url.matches_domain(effective_skip) or any(substring in url.link for substring in skip_urls_containing):
74
71
  continue
75
72
  pending.append((url, _service.submit(url)))
76
73
  return (pending, _service if owns_service else None)
@@ -40,10 +40,7 @@ class URLsLocaleCheck(BaseCheck[MarkdownURL]):
40
40
 
41
41
  detected_issues: list[MarkdownURL] = []
42
42
  for url in links.urls:
43
- hostname = url.host_name().lower()
44
- if any(domain.lower() in hostname for domain in effective_skip) or any(
45
- substring in url.link for substring in skip_urls_containing
46
- ):
43
+ if url.matches_domain(effective_skip) or any(substring in url.link for substring in skip_urls_containing):
47
44
  continue
48
45
  if url.has_locale():
49
46
  url.issue = "has locale"
@@ -1,4 +1,4 @@
1
- """The ``check_urls_tracking``/``check_paths_tracking`` checks: flag links missing a ``wt.mc_id`` tracking ID."""
1
+ """The ``check_urls_tracking``/``check_paths_tracking`` checks: flag links missing a tracking ID."""
2
2
 
3
3
  from markdown_checker.checks.base import BaseCheck
4
4
  from markdown_checker.models import Config
@@ -21,8 +21,9 @@ class URLsTrackingCheck(BaseCheck[MarkdownURL]):
21
21
  service: URLCheckService | None = None,
22
22
  ) -> list[MarkdownURL]:
23
23
  """Flag every URL in ``links.urls`` whose host matches
24
- ``config.tracking_domains`` but is missing a ``wt.mc_id`` query
25
- parameter (see :meth:`MarkdownLinkBase.has_tracking
24
+ ``config.tracking_domains`` but is missing all of the
25
+ ``config.tracking_param`` query parameters (see
26
+ :meth:`MarkdownLinkBase.has_tracking
26
27
  <markdown_checker.models.base.MarkdownLinkBase.has_tracking>`). URLs
27
28
  on other hosts, or matching ``config.skip_domains``/
28
29
  ``skip_urls_containing``, are ignored. Sets
@@ -36,12 +37,9 @@ class URLsTrackingCheck(BaseCheck[MarkdownURL]):
36
37
 
37
38
  detected_issues: list[MarkdownURL] = []
38
39
  for url in links.urls:
39
- hostname = url.host_name().lower()
40
- if any(domain.lower() in hostname for domain in skip_domains) or any(
41
- substring in url.link for substring in skip_urls_containing
42
- ):
40
+ if url.matches_domain(skip_domains) or any(substring in url.link for substring in skip_urls_containing):
43
41
  continue
44
- if any(domain.lower() in hostname for domain in tracking_domains) and not url.has_tracking():
42
+ if url.matches_domain(tracking_domains) and not url.has_tracking(config.tracking_param):
45
43
  url.issue = "is missing tracking id"
46
44
  detected_issues.append(url)
47
45
  return detected_issues
@@ -59,16 +57,18 @@ class PathsTrackingCheck(BaseCheck[MarkdownPath]):
59
57
  config: Config | None = None,
60
58
  service: URLCheckService | None = None,
61
59
  ) -> list[MarkdownPath]:
62
- """Flag every path in ``links.paths`` missing a ``wt.mc_id`` query
63
- parameter (see :meth:`MarkdownLinkBase.has_tracking
60
+ """Flag every path in ``links.paths`` missing all of the
61
+ ``config.tracking_param`` query parameters (see
62
+ :meth:`MarkdownLinkBase.has_tracking
64
63
  <markdown_checker.models.base.MarkdownLinkBase.has_tracking>`),
65
64
  setting ``issue="is missing tracking id"`` (error-level) on each.
66
65
  Unlike ``check_urls_tracking``, there is no domain filter: every
67
- path is checked. ``config`` and ``service`` are unused.
66
+ path is checked. ``service`` is unused.
68
67
  """
68
+ config = config or Config()
69
69
  detected_issues: list[MarkdownPath] = []
70
70
  for path in links.paths:
71
- if not path.has_tracking():
71
+ if not path.has_tracking(config.tracking_param):
72
72
  path.issue = "is missing tracking id"
73
73
  detected_issues.append(path)
74
74
  return detected_issues
@@ -11,6 +11,7 @@ import click
11
11
  from markdown_checker import __version__
12
12
  from markdown_checker.checker import run_check_on_files
13
13
  from markdown_checker.models.config import Config
14
+ from markdown_checker.patterns import DEFAULT_TRACKING_PARAMS
14
15
  from markdown_checker.reports import build_report
15
16
  from markdown_checker.reports import get_renderer
16
17
  from markdown_checker.reports import RENDERERS
@@ -123,6 +124,15 @@ class ListOfStrings(click.Option):
123
124
  help="List of tracking domains to check.",
124
125
  required=False,
125
126
  )
127
+ @click.option(
128
+ "-tp",
129
+ "--tracking-param",
130
+ cls=ListOfStrings,
131
+ type=list[str],
132
+ default=list(DEFAULT_TRACKING_PARAMS),
133
+ help="List of tracking query parameters; a link passes if it has any of them.",
134
+ required=False,
135
+ )
126
136
  @click.option(
127
137
  "-sf",
128
138
  "--skip-files",
@@ -258,6 +268,7 @@ def main(
258
268
  skip_domains: list[str],
259
269
  skip_urls_containing: list[str],
260
270
  tracking_domains: list[str],
271
+ tracking_param: list[str],
261
272
  timeout: int,
262
273
  retries: int,
263
274
  retry_on_429: bool,
@@ -309,6 +320,7 @@ def main(
309
320
  skip_domains=skip_domains,
310
321
  skip_urls_containing=skip_urls_containing,
311
322
  tracking_domains=tracking_domains,
323
+ tracking_param=tracking_param,
312
324
  timeout=timeout,
313
325
  retries=retries,
314
326
  retry_on_429=retry_on_429,
@@ -0,0 +1,84 @@
1
+ """Shared base dataclass for the two kinds of link markdown-checker extracts: URLs and paths."""
2
+
3
+ import locale
4
+ from abc import ABC
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+ from urllib.parse import urlparse
10
+
11
+ from markdown_checker.patterns import DEFAULT_TRACKING_PARAMS
12
+ from markdown_checker.patterns import LOCALE_PATTERN
13
+ from markdown_checker.patterns import tracking_pattern
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class MarkdownLinkBase(ABC):
18
+ """Base class for a single link found in a markdown file."""
19
+
20
+ link: str
21
+ """The raw link destination as written in the file (e.g.
22
+ ``"https://example.com"`` or ``"../img.png"``).
23
+ """
24
+
25
+ line_number: int
26
+ """1-based line number where the link was found."""
27
+
28
+ file_path: Path
29
+ """Path to the markdown file the link was found in."""
30
+
31
+ issue: str = ""
32
+ """Human-readable description set by a check when this link fails it
33
+ (e.g. ``"is broken"``); empty when no issue was found.
34
+ """
35
+
36
+ issue_level: Literal["error", "warning"] = "error"
37
+ """Severity of ``issue`` once set. ``"error"`` fails the CLI run (exit
38
+ code 1); ``"warning"`` is reported but never fails it.
39
+ """
40
+
41
+ resolved_path: Path | None = None
42
+ """The link's resolved on-disk target path, set by
43
+ ``check_broken_paths`` (see :meth:`MarkdownPath.get_full_path
44
+ <markdown_checker.models.path.MarkdownPath.get_full_path>`); left unset
45
+ (``None``) by every other check, including URL checks.
46
+ """
47
+
48
+ def has_locale(self) -> bool:
49
+ """
50
+ Check if the link's path contains a locale segment, e.g. ``/en-us/``.
51
+
52
+ Only the path is searched (not the host, query or anchor), and a
53
+ candidate ``xx-yy`` segment only counts if it's a known locale, so
54
+ segments like ``/ci-cd/`` aren't reported.
55
+
56
+ Returns:
57
+ True if the link has a locale, False otherwise
58
+ """
59
+ path = urlparse(self.link).path
60
+ return any(
61
+ f"{match['language']}_{match['region']}".lower() in locale.locale_alias
62
+ for match in LOCALE_PATTERN.finditer(path)
63
+ )
64
+
65
+ def has_tracking(self, params: Sequence[str] = DEFAULT_TRACKING_PARAMS) -> bool:
66
+ """
67
+ Check if the link carries any of the tracking query parameters in
68
+ ``params`` (case-insensitive), e.g. ``?wt.mc_id=`` or ``&wt.mc_id=``.
69
+
70
+ Args:
71
+ params: Tracking parameter names; any one of them counts.
72
+
73
+ Returns:
74
+ True if the link has a tracking ID, False otherwise
75
+ """
76
+ if not params:
77
+ return False
78
+ return bool(tracking_pattern(tuple(params)).search(self.link))
79
+
80
+ def __str__(self) -> str:
81
+ return self.link
82
+
83
+ def __repr__(self) -> str:
84
+ return self.link
@@ -4,6 +4,8 @@ from typing import Literal
4
4
 
5
5
  import httpx2
6
6
 
7
+ from markdown_checker.patterns import DEFAULT_TRACKING_PARAMS
8
+
7
9
  DEFAULT_HEADERS: dict[str, str] = {
8
10
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
9
11
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
@@ -33,8 +35,10 @@ class Config:
33
35
  """Holds all runtime configuration for a check run."""
34
36
 
35
37
  skip_domains: list[str] = field(default_factory=list)
36
- """Hostnames to exclude from URL checks (substring match against the
37
- URL's hostname). Used by ``check_broken_urls`` and ``check_urls_locale``.
38
+ """Domains to exclude from URL checks. A domain matches the URL's
39
+ hostname exactly or as a parent domain (``example.com`` matches
40
+ ``www.example.com`` but not ``notexample.com``). Used by
41
+ ``check_broken_urls``, ``check_urls_locale`` and ``check_urls_tracking``.
38
42
  """
39
43
 
40
44
  skip_urls_containing: list[str] = field(default_factory=list)
@@ -43,9 +47,16 @@ class Config:
43
47
  """
44
48
 
45
49
  tracking_domains: list[str] = field(default_factory=list)
46
- """Hostnames that must carry a ``wt.mc_id`` tracking parameter. Only
47
- used by ``check_urls_tracking``; URLs on other hosts are ignored by
48
- that check.
50
+ """Domains whose URLs must carry a tracking parameter (see
51
+ ``tracking_param``), matched like ``skip_domains`` (subdomains
52
+ included). Only used by ``check_urls_tracking``; URLs on other hosts are
53
+ ignored by that check.
54
+ """
55
+
56
+ tracking_param: list[str] = field(default_factory=lambda: list(DEFAULT_TRACKING_PARAMS))
57
+ """Tracking query parameter names a link must carry; any one of them
58
+ counts, matched case-insensitively. Used by ``check_urls_tracking`` and
59
+ ``check_paths_tracking``.
49
60
  """
50
61
 
51
62
  timeout: int = 20
@@ -1,8 +1,10 @@
1
1
  import os
2
2
  from dataclasses import dataclass
3
3
  from pathlib import Path
4
+ from urllib.parse import unquote
4
5
 
5
6
  from markdown_checker.models.base import MarkdownLinkBase
7
+ from markdown_checker.patterns import QUERY_OR_ANCHOR_PATTERN
6
8
  from markdown_checker.patterns import TRACKING_QUERY_PATTERN
7
9
 
8
10
 
@@ -26,46 +28,36 @@ class MarkdownPath(MarkdownLinkBase):
26
28
  def remove_fragments(self) -> str:
27
29
  """
28
30
  Strip the tracking query string and any trailing ``?query``/``#anchor``
29
- fragment from the link, leaving just the file path portion.
31
+ fragment from the link, then percent-decode what's left (e.g.
32
+ ``my%20file.md`` becomes ``my file.md``), leaving just the file path.
33
+
34
+ As in any URL, the first ``?`` or ``#`` starts the fragment, so a
35
+ literal ``#`` or ``?`` in a file name must be written as ``%23``/``%3F``.
30
36
 
31
37
  Returns:
32
38
  The link with its fragment removed, as a string.
33
39
  """
34
40
  cleaned = TRACKING_QUERY_PATTERN.sub("", self.link)
35
-
36
- # Find the last occurrence of the dot
37
- dot_index = cleaned.rfind(".")
38
-
39
- # If a dot is found, slice the string up to the end of the extension
40
- if dot_index != -1:
41
- # Find the next slash or fragment after the dot
42
- slash_index = cleaned.find("/", dot_index)
43
- fragment_index = cleaned.find("#", dot_index)
44
-
45
- # Determine the earliest occurrence of either slash or fragment or query
46
- end_index = min(
47
- slash_index if slash_index != -1 else len(cleaned),
48
- fragment_index if fragment_index != -1 else len(cleaned),
49
- )
50
- return cleaned[:end_index]
51
- else:
52
- return cleaned
41
+ return unquote(QUERY_OR_ANCHOR_PATTERN.sub("", cleaned))
53
42
 
54
43
  def get_full_path(self) -> Path:
55
44
  """
56
- Resolve the link (with its fragment stripped) to an absolute path,
57
- relative to the current working directory.
45
+ Resolve the link (with its fragment stripped) to an absolute path.
58
46
 
59
- This is used for links that are themselves absolute (e.g.
60
- ``/docs/usage.md``) or otherwise resolvable without knowing which
61
- file the link appeared in. If resolution fails (e.g. on an unusual
62
- path), falls back to :meth:`get_full_path_relative`.
47
+ Root-relative links (e.g. ``/docs/usage.md``) resolve against the
48
+ current working directory, which is treated as the repository root.
49
+ Other links resolve relative to the current working directory too;
50
+ if that fails (e.g. on an unusual path), this falls back to
51
+ :meth:`get_full_path_relative`.
63
52
 
64
53
  Returns:
65
54
  The resolved absolute path.
66
55
  """
56
+ cleaned = self.remove_fragments()
57
+ if cleaned.startswith("/"):
58
+ return Path(os.path.normpath(Path.cwd() / cleaned.lstrip("/")))
67
59
  try:
68
- return self.path_without_fragments.resolve()
60
+ return Path(cleaned).resolve()
69
61
  except (FileNotFoundError, RuntimeError):
70
62
  return Path(self.get_full_path_relative())
71
63
 
@@ -85,39 +77,21 @@ class MarkdownPath(MarkdownLinkBase):
85
77
  """
86
78
  Check whether the link's target file exists on disk.
87
79
 
88
- Tries resolution relative to the containing markdown file first
89
- (see :meth:`get_full_path_relative`), then as an absolute/CWD-relative
90
- path (see :meth:`get_full_path`). Used by ``check_broken_paths`` to
91
- flag links whose target is missing.
80
+ Tries resolution relative to the containing markdown file first, then
81
+ relative to the current working directory. Root-relative links (e.g.
82
+ ``/docs/usage.md``) are tried the same way with the leading ``/``
83
+ dropped. Used by ``check_broken_paths`` to flag links whose target is
84
+ missing.
92
85
 
93
86
  Returns:
94
87
  True if the path exists, False otherwise
95
88
  """
96
- # For paths starting with /, strip the leading / to allow relative resolution
97
- link = self.link.lstrip("/") if self.link.startswith("/") else self.link
98
- cleaned = TRACKING_QUERY_PATTERN.sub("", link)
99
-
100
- # Strip fragments/anchors the same way remove_fragments() does
101
- dot_index = cleaned.rfind(".")
102
- if dot_index != -1:
103
- slash_index = cleaned.find("/", dot_index)
104
- fragment_index = cleaned.find("#", dot_index)
105
- end_index = min(
106
- slash_index if slash_index != -1 else len(cleaned),
107
- fragment_index if fragment_index != -1 else len(cleaned),
108
- )
109
- cleaned = cleaned[:end_index]
110
-
111
- # Check relative path resolution
89
+ cleaned = self.remove_fragments().lstrip("/")
90
+
112
91
  relative_path = os.path.normpath(os.path.join(os.path.dirname(self.file_path), cleaned))
113
92
  if os.path.exists(relative_path):
114
93
  return True
115
- # Check absolute path resolution
116
94
  try:
117
- if Path(cleaned).resolve().exists():
118
- return True
95
+ return Path(cleaned).resolve().exists()
119
96
  except (FileNotFoundError, RuntimeError, OSError):
120
- resolved = Path(relative_path)
121
- if resolved.exists():
122
- return True
123
- return False
97
+ return False