From d1902962f8b56e92b38560d13742497615c9d4ad Mon Sep 17 00:00:00 2001 From: chad-loder <26261238+chad-loder@users.noreply.github.com> Date: Wed, 13 May 2026 12:52:13 -0700 Subject: [PATCH] docs(pypi): backlink to PyPI from README, docs, CHANGELOG, and future releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README badge row gains a PyPI version badge; install section text-links the package name to /project/yarlpattern/. Same in docs/index.md (a cross-domain backlink: chad-loder.github.io → pypi.org). - CHANGELOG header points at /project/yarlpattern/#history. - Custom PSR Jinja template under .semantic-release/templates/ emits a versioned PyPI deep-link on every new release heading: `## vX.Y.Z (date) — [Release](…) · [PyPI](…)`. Uses PSR's built-in `create_release_url` / `create_pypi_url` filters so the same template works across HVCS backends. - Only file deviating from PSR's bundled conventional/md/ templates is .components/versioned_changes.md.j2; the other eight are vendored verbatim because PSR uses templates/ as a binary switch (no merge with defaults). The heading format keeps the `# vX.Y.Z ` substring that PSR's update-mode dedup check requires, so CI retries are idempotent (verified against real git history). - Update mode preserves existing 0.1.0/0.2.0 entries verbatim; only releases cut from now on use the new format. Old end-of-file PSR stamp marker replaced with `` near the top, matching the v10 default insertion flag. --- .../.components/changelog_header.md.j2 | 9 + .../.components/changelog_init.md.j2 | 29 +++ .../.components/changelog_update.md.j2 | 71 +++++++ .../templates/.components/changes.md.j2 | 129 ++++++++++++ .../templates/.components/first_release.md.j2 | 18 ++ .../templates/.components/macros.md.j2 | 199 ++++++++++++++++++ .../.components/unreleased_changes.md.j2 | 7 + .../.components/versioned_changes.md.j2 | 34 +++ .../templates/.release_notes.md.j2 | 62 ++++++ .semantic-release/templates/CHANGELOG.md.j2 | 21 ++ CHANGELOG.md | 6 +- README.md | 3 + docs/index.md | 2 + pyproject.toml | 7 + 14 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 .semantic-release/templates/.components/changelog_header.md.j2 create mode 100644 .semantic-release/templates/.components/changelog_init.md.j2 create mode 100644 .semantic-release/templates/.components/changelog_update.md.j2 create mode 100644 .semantic-release/templates/.components/changes.md.j2 create mode 100644 .semantic-release/templates/.components/first_release.md.j2 create mode 100644 .semantic-release/templates/.components/macros.md.j2 create mode 100644 .semantic-release/templates/.components/unreleased_changes.md.j2 create mode 100644 .semantic-release/templates/.components/versioned_changes.md.j2 create mode 100644 .semantic-release/templates/.release_notes.md.j2 create mode 100644 .semantic-release/templates/CHANGELOG.md.j2 diff --git a/.semantic-release/templates/.components/changelog_header.md.j2 b/.semantic-release/templates/.components/changelog_header.md.j2 new file mode 100644 index 0000000..268792f --- /dev/null +++ b/.semantic-release/templates/.components/changelog_header.md.j2 @@ -0,0 +1,9 @@ +# CHANGELOG + +{% if ctx.changelog_mode == "update" +%}{# # IMPORTANT: add insertion flag for next version update +#}{{ + insertion_flag ~ "\n" + +}}{% endif +%} diff --git a/.semantic-release/templates/.components/changelog_init.md.j2 b/.semantic-release/templates/.components/changelog_init.md.j2 new file mode 100644 index 0000000..8d10a1a --- /dev/null +++ b/.semantic-release/templates/.components/changelog_init.md.j2 @@ -0,0 +1,29 @@ +{# +This changelog template initializes a full changelog for the project, +it follows the following logic: + 1. Header + 2. Any Unreleased Details (uncommon) + 3. all previous releases except the very first release + 4. the first release + +#}{# + # Header +#}{% include "changelog_header.md.j2" +-%}{# + # Any Unreleased Details (uncommon) +#}{% include "unreleased_changes.md.j2" +-%}{# + # Since this is initialization, we are generating all the previous + # release notes per version. The very first release notes is specialized +#}{% if releases | length > 0 +%}{% for release in releases +%}{{ "\n" +}}{% if loop.last and ctx.mask_initial_release +%}{%- include "first_release.md.j2" +-%}{% else +%}{%- include "versioned_changes.md.j2" +-%}{% endif +%}{{ "\n" +}}{% endfor +%}{% endif +%} diff --git a/.semantic-release/templates/.components/changelog_update.md.j2 b/.semantic-release/templates/.components/changelog_update.md.j2 new file mode 100644 index 0000000..98ff81f --- /dev/null +++ b/.semantic-release/templates/.components/changelog_update.md.j2 @@ -0,0 +1,71 @@ +{# +This Update changelog template uses the following logic: + + 1. Read previous changelog file (ex. project_root/CHANGELOG.md) + 2. Split on insertion flag (ex. ) + 3. Print top half of previous changelog + 3. New Changes (unreleased commits & newly released) + 4. Print bottom half of previous changelog + + Note: if a previous file was not found, it does not write anything at the bottom + but render does NOT fail + +#}{% set prev_changelog_contents = prev_changelog_file | read_file | safe +%}{% set changelog_parts = prev_changelog_contents.split(insertion_flag, maxsplit=1) +%}{# +#}{% if changelog_parts | length < 2 +%}{# # insertion flag was not found, check if the file was empty or did not exist +#}{% if prev_changelog_contents | length > 0 +%}{# # File has content but no insertion flag, therefore, file will not be updated +#}{{ changelog_parts[0] +}}{% else +%}{# # File was empty or did not exist, therefore, it will be created from scratch +#}{% include "changelog_init.md.j2" +%}{% endif +%}{% else +%}{# + # Previous Changelog Header + # - Depending if there is header content, then it will separate the insertion flag + # with a newline from header content, otherwise it will just print the insertion flag +#}{% set prev_changelog_top = changelog_parts[0] | trim +%}{% if prev_changelog_top | length > 0 +%}{{ + "%s\n\n%s\n" | format(prev_changelog_top, insertion_flag | trim) + +}}{% else +%}{{ + "%s\n" | format(insertion_flag | trim) + +}}{% endif +%}{# + # Any Unreleased Details (uncommon) +#}{% include "unreleased_changes.md.j2" +-%}{# +#}{% if releases | length > 0 +%}{# # Latest Release Details +#}{% set release = releases[0] +%}{# +#}{% if releases | length == 1 and ctx.mask_initial_release +%}{# # First Release detected +#}{{ "\n" +}}{%- include "first_release.md.j2" +-%}{{ "\n" +}}{# +#}{% elif "# " ~ release.version.as_semver_tag() ~ " " not in changelog_parts[1] +%}{# # The release version is not already in the changelog so we add it +#}{{ "\n" +}}{%- include "versioned_changes.md.j2" +-%}{{ "\n" +}}{# +#}{% endif +%}{% endif +%}{# + # Previous Changelog Footer + # - skips printing footer if empty, which happens when the insertion_flag + # was at the end of the file (ignoring whitespace) +#}{% set previous_changelog_bottom = changelog_parts[1] | trim +%}{% if previous_changelog_bottom | length > 0 +%}{{ "\n%s\n" | format(previous_changelog_bottom) +}}{% endif +%}{% endif +%} diff --git a/.semantic-release/templates/.components/changes.md.j2 b/.semantic-release/templates/.components/changes.md.j2 new file mode 100644 index 0000000..c81b0fa --- /dev/null +++ b/.semantic-release/templates/.components/changes.md.j2 @@ -0,0 +1,129 @@ +{% from 'macros.md.j2' import apply_alphabetical_ordering_by_brk_descriptions +%}{% from 'macros.md.j2' import apply_alphabetical_ordering_by_descriptions +%}{% from 'macros.md.j2' import apply_alphabetical_ordering_by_release_notices +%}{% from 'macros.md.j2' import format_breaking_changes_description, format_commit_summary_line +%}{% from 'macros.md.j2' import format_release_notice +%}{# +EXAMPLE: + +### Features + +- Add new feature ([#10](https://domain.com/namespace/repo/pull/10), + [`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +- **scope**: Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +### Bug Fixes + +- Fix bug ([#11](https://domain.com/namespace/repo/pull/11), + [`abcdef1`](https://domain.com/namespace/repo/commit/HASH)) + +### Breaking Changes + +- With the change _____, the change causes ___ effect. Ultimately, this section + it is a more detailed description of the breaking change. With an optional + scope prefix like the commit messages above. + +- **scope**: this breaking change has a scope to identify the part of the code that + this breaking change applies to for better context. + +### Additional Release Information + +- This is a release note that provides additional information about the release + that is not a breaking change or a feature/bug fix. + +- **scope**: this release note has a scope to identify the part of the code that + this release note applies to for better context. + +#}{% set max_line_width = max_line_width | default(100) +%}{% set hanging_indent = hanging_indent | default(2) +%}{# +#}{% for type_, commits in commit_objects if type_ != "unknown" +%}{# PREPROCESS COMMITS (order by description & format description line) +#}{% set ns = namespace(commits=commits) +%}{% set _ = apply_alphabetical_ordering_by_descriptions(ns) +%}{# +#}{% set commit_descriptions = [] +%}{# +#}{% for commit in ns.commits +%}{# # Add reference links to the commit summary line +#}{% set description = "- %s" | format(format_commit_summary_line(commit)) +%}{% set description = description | autofit_text_width(max_line_width, hanging_indent) +%}{% set _ = commit_descriptions.append(description) +%}{% endfor +%}{# + # # PRINT SECTION (header & commits) +#}{% if commit_descriptions | length > 0 +%}{{ "\n" +}}{{ "### %s\n" | format(type_ | title) +}}{{ "\n" +}}{{ "%s\n" | format(commit_descriptions | unique | join("\n\n")) +}}{% endif +%}{% endfor +%}{# + # Determine if there are any breaking change commits by filtering the list by breaking descriptions + # commit_objects is a list of tuples [("Features", [ParsedCommit(), ...]), ("Bug Fixes", [ParsedCommit(), ...])] + # HOW: Filter out breaking change commits that have no breaking descriptions + # 1. Re-map the list to only the list of commits under the breaking category from the list of tuples + # 2. Peel off the outer list to get a list of ParsedCommit objects + # 3. Filter the list of ParsedCommits to only those with a breaking description +#}{% set breaking_commits = commit_objects | map(attribute="1.0") +%}{% set breaking_commits = breaking_commits | rejectattr("error", "defined") | selectattr("breaking_descriptions.0") | list +%}{# +#}{% if breaking_commits | length > 0 +%}{# PREPROCESS COMMITS +#}{% set brk_ns = namespace(commits=breaking_commits) +%}{% set _ = apply_alphabetical_ordering_by_brk_descriptions(brk_ns) +%}{# +#}{% set brking_descriptions = [] +%}{# +#}{% for commit in brk_ns.commits +%}{% set full_description = "- %s" | format( + format_breaking_changes_description(commit).split("\n\n") | join("\n\n- ") + ) +%}{% set _ = brking_descriptions.append( + full_description | autofit_text_width(max_line_width, hanging_indent) + ) +%}{% endfor +%}{# + # # PRINT BREAKING CHANGE DESCRIPTIONS (header & descriptions) +#}{{ "\n" +}}{{ "### Breaking Changes\n" +}}{{ + "\n%s\n" | format(brking_descriptions | unique | join("\n\n")) +}}{# +#}{% endif +%}{# + # Determine if there are any commits with release notice information by filtering the list by release_notices + # commit_objects is a list of tuples [("Features", [ParsedCommit(), ...]), ("Bug Fixes", [ParsedCommit(), ...])] + # HOW: Filter out commits that have no release notices + # 1. Re-map the list to only the list of commits from the list of tuples + # 2. Peel off the outer list to get a list of ParsedCommit objects + # 3. Filter the list of ParsedCommits to only those with a release notice +#}{% set notice_commits = commit_objects | map(attribute="1.0") +%}{% set notice_commits = notice_commits | rejectattr("error", "defined") | selectattr("release_notices.0") | list +%}{# +#}{% if notice_commits | length > 0 +%}{# PREPROCESS COMMITS +#}{% set notice_ns = namespace(commits=notice_commits) +%}{% set _ = apply_alphabetical_ordering_by_release_notices(notice_ns) +%}{# +#}{% set release_notices = [] +%}{# +#}{% for commit in notice_ns.commits +%}{% set full_description = "- %s" | format( + format_release_notice(commit).split("\n\n") | join("\n\n- ") + ) +%}{% set _ = release_notices.append( + full_description | autofit_text_width(max_line_width, hanging_indent) + ) +%}{% endfor +%}{# + # # PRINT RELEASE NOTICE INFORMATION (header & descriptions) +#}{{ "\n" +}}{{ "### Additional Release Information\n" +}}{{ + "\n%s\n" | format(release_notices | unique | join("\n\n")) +}}{# +#}{% endif +%} diff --git a/.semantic-release/templates/.components/first_release.md.j2 b/.semantic-release/templates/.components/first_release.md.j2 new file mode 100644 index 0000000..d0e44f7 --- /dev/null +++ b/.semantic-release/templates/.components/first_release.md.j2 @@ -0,0 +1,18 @@ +{# EXAMPLE: + +## vX.X.X (YYYY-MMM-DD) + +_This release is published under the MIT License._ # Release Notes Only + +- Initial Release + +#}{{ +"## %s (%s)\n" | format( + release.version.as_semver_tag(), + release.tagged_date.strftime("%Y-%m-%d") +) +}}{% if license_name is defined and license_name +%}{{ "\n_This release is published under the %s License._\n" | format(license_name) +}}{% endif +%} +- Initial Release diff --git a/.semantic-release/templates/.components/macros.md.j2 b/.semantic-release/templates/.components/macros.md.j2 new file mode 100644 index 0000000..13cc18f --- /dev/null +++ b/.semantic-release/templates/.components/macros.md.j2 @@ -0,0 +1,199 @@ +{# + MACRO: format a inline link reference in Markdown +#}{% macro format_link(link, label) +%}{{ "[%s](%s)" | format(label, link) +}}{% endmacro +%} + + +{# + MACRO: Capitalize the first letter of a string only +#}{% macro capitalize_first_letter_only(sentence) +%}{{ (sentence[0] | upper) ~ sentence[1:] +}}{% endmacro +%} + + +{# + MACRO: commit message links or PR/MR links of commit +#}{% macro commit_msg_links(commit) +%}{% if commit.error is undefined +%}{# + # # Initialize variables +#}{% set link_references = [] +%}{% set summary_line = capitalize_first_letter_only( + commit.descriptions[0] | safe + ) +%}{# +#}{% if commit.linked_merge_request != "" +%}{# # Add PR references with a link to the PR +#}{% set _ = link_references.append( + format_link( + commit.linked_merge_request | pull_request_url, + commit.linked_merge_request + ) + ) +%}{% endif +%}{# + # # DEFAULT: Always include the commit hash as a link +#}{% set _ = link_references.append( + format_link( + commit.hexsha | commit_hash_url, + "`%s`" | format(commit.short_hash) + ) + ) +%}{# +#}{% set formatted_links = "" +%}{% if link_references | length > 0 +%}{% set formatted_links = " (%s)" | format(link_references | join(", ")) +%}{% endif +%}{# + # Return the modified summary_line +#}{{ summary_line ~ formatted_links +}}{% endif +%}{% endmacro +%} + + +{# + MACRO: format commit summary line +#}{% macro format_commit_summary_line(commit) +%}{# # Check for Parsing Error +#}{% if commit.error is undefined +%}{# + # # Add any message links to the commit summary line +#}{% set summary_line = commit_msg_links(commit) +%}{# +#}{% if commit.scope +%}{% set summary_line = "**%s**: %s" | format(commit.scope, summary_line) +%}{% endif +%}{# + # # Return the modified summary_line +#}{{ summary_line +}}{# +#}{% else +%}{# # Return the first line of the commit if there was a Parsing Error +#}{{ (commit.commit.message | string).split("\n", maxsplit=1)[0] +}}{% endif +%}{% endmacro +%} + + +{# + MACRO: format a commit descriptions list by: + - Capitalizing the first line of the description + - Adding an optional scope prefix + - Joining the rest of the descriptions with a double newline +#}{% macro format_attr_paragraphs(commit, attribute) +%}{# NOTE: requires namespace because of the way Jinja2 handles variable scoping with loops +#}{% set ns = namespace(full_description="") +%}{# +#}{% if commit.error is undefined +%}{% for paragraph in commit | attr(attribute) +%}{% if paragraph | trim | length > 0 +%}{# +#}{% set ns.full_description = [ + ns.full_description, + capitalize_first_letter_only(paragraph) | trim | safe, + ] | join("\n\n") +%}{# +#}{% endif +%}{% endfor +%}{# +#}{% set ns.full_description = ns.full_description | trim +%}{# +#}{% if commit.scope +%}{% set ns.full_description = "**%s**: %s" | format( + commit.scope, ns.full_description + ) +%}{% endif +%}{% endif +%}{# +#}{{ ns.full_description +}}{% endmacro +%} + + +{# + MACRO: format the breaking changes description by: + - Capitalizing the description + - Adding an optional scope prefix +#}{% macro format_breaking_changes_description(commit) +%}{{ format_attr_paragraphs(commit, 'breaking_descriptions') +}}{% endmacro +%} + + +{# + MACRO: format the release notice by: + - Capitalizing the description + - Adding an optional scope prefix +#}{% macro format_release_notice(commit) +%}{{ format_attr_paragraphs(commit, "release_notices") +}}{% endmacro +%} + + +{# + MACRO: order commits alphabetically by scope and attribute + - Commits are sorted based on scope and then the attribute alphabetically + - Commits without scope are placed first and sorted alphabetically by the attribute + - parameter: ns (namespace) object with a commits list + - parameter: attr (string) attribute to sort by + - returns None but modifies the ns.commits list in place +#}{% macro order_commits_alphabetically_by_scope_and_attr(ns, attr) +%}{% set ordered_commits = [] +%}{# + # # Eliminate any ParseError commits from input set +#}{% set filtered_commits = ns.commits | rejectattr("error", "defined") | list +%}{# + # # grab all commits with no scope and sort alphabetically by attr +#}{% for commit in filtered_commits | rejectattr("scope") | sort(attribute=attr) +%}{% set _ = ordered_commits.append(commit) +%}{% endfor +%}{# + # # grab all commits with a scope and sort alphabetically by the scope and then attr +#}{% for commit in filtered_commits | selectattr("scope") | sort(attribute=(['scope', attr] | join(","))) +%}{% set _ = ordered_commits.append(commit) +%}{% endfor +%}{# + # # Return the ordered commits +#}{% set ns.commits = ordered_commits +%}{% endmacro +%} + + +{# + MACRO: apply smart ordering of commits objects based on alphabetized summaries and then scopes + - Commits are sorted based on the commit type and the commit message + - Commits are grouped by the commit type + - parameter: ns (namespace) object with a commits list + - returns None but modifies the ns.commits list in place +#}{% macro apply_alphabetical_ordering_by_descriptions(ns) +%}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'descriptions.0') +%}{% endmacro +%} + + +{# + MACRO: apply smart ordering of commits objects based on alphabetized breaking changes and then scopes + - Commits are sorted based on the commit type and the commit message + - Commits are grouped by the commit type + - parameter: ns (namespace) object with a commits list + - returns None but modifies the ns.commits list in place +#}{% macro apply_alphabetical_ordering_by_brk_descriptions(ns) +%}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'breaking_descriptions.0') +%}{% endmacro +%} + + +{# + MACRO: apply smart ordering of commits objects based on alphabetized release notices and then scopes + - Commits are sorted based on the commit type and the commit message + - Commits are grouped by the commit type + - parameter: ns (namespace) object with a commits list + - returns None but modifies the ns.commits list in place +#}{% macro apply_alphabetical_ordering_by_release_notices(ns) +%}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'release_notices.0') +%}{% endmacro +%} diff --git a/.semantic-release/templates/.components/unreleased_changes.md.j2 b/.semantic-release/templates/.components/unreleased_changes.md.j2 new file mode 100644 index 0000000..8a8a204 --- /dev/null +++ b/.semantic-release/templates/.components/unreleased_changes.md.j2 @@ -0,0 +1,7 @@ +{% if unreleased_commits | length > 0 +%}{{ "\n## Unreleased\n" +}}{% set commit_objects = unreleased_commits +%}{% include "changes.md.j2" +-%}{{ "\n" +}}{% endif +%} diff --git a/.semantic-release/templates/.components/versioned_changes.md.j2 b/.semantic-release/templates/.components/versioned_changes.md.j2 new file mode 100644 index 0000000..5248b6d --- /dev/null +++ b/.semantic-release/templates/.components/versioned_changes.md.j2 @@ -0,0 +1,34 @@ +{# Customised from PSR's bundled template to deep-link version headings + to BOTH the GitHub release tag and the matching PyPI release page, + e.g.: + + ## v0.3.0 (2026-06-01) — [Release](https://github.com/owner/repo/releases/tag/v0.3.0) · [PyPI](https://pypi.org/project/repo/0.3.0) + + Format constraint: the heading MUST contain the literal substring + ``# vX.Y.Z `` (one hash + space + tag + space) so PSR's update-mode + dedup check in ``changelog_update.md.j2`` recognises an already-added + release and does not double-add on a CI retry. The format here + satisfies that (``## vX.Y.Z (...)`` → search finds ``# vX.Y.Z `` at + offset 1) while still surfacing the deep-links inline. + + Uses PSR's built-in ``create_release_url`` (registered by the HVCS + client) and ``create_pypi_url`` filters, so the same template works + whether the HVCS is GitHub, GitLab, or Codeberg without edits. + + Assumes the PyPI distribution name equals ``ctx.repo_name``. Hard-code + the package name in the ``create_pypi_url`` call below if it ever + diverges from the GitHub repo name. +#}{{ +"## %s (%s) — [Release](%s) · [PyPI](%s)\n" | format( + release.version.as_semver_tag(), + release.tagged_date.strftime("%Y-%m-%d"), + release.version.as_semver_tag() | create_release_url, + ctx.repo_name | create_pypi_url(release.version | string), +) +}}{% if license_name is defined and license_name +%}{{ "\n_This release is published under the %s License._\n" | format(license_name) +}}{% endif +%}{# +#}{% set commit_objects = release["elements"] | dictsort +%}{% include "changes.md.j2" +-%} diff --git a/.semantic-release/templates/.release_notes.md.j2 b/.semantic-release/templates/.release_notes.md.j2 new file mode 100644 index 0000000..7fe7d92 --- /dev/null +++ b/.semantic-release/templates/.release_notes.md.j2 @@ -0,0 +1,62 @@ +{# EXAMPLE: + +## v1.0.0 (2020-01-01) + +_This release is published under the MIT License._ + +### Features + +- Add new feature ([#10](https://domain.com/namespace/repo/pull/10), [`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +- **scope**: Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) + +### Bug Fixes + +- Fix bug (#11, [`abcdef1`](https://domain.com/namespace/repo/commit/HASH)) + +### Breaking Changes + +- With the change _____, the change causes ___ effect. Ultimately, this section it is a more detailed description of the breaking change. With an optional scope prefix like the commit messages above. + +- **scope**: this breaking change has a scope to identify the part of the code that this breaking change applies to for better context. + +### Additional Release Information + +- This is a release note that provides additional information about the release that is not a breaking change or a feature/bug fix. + +- **scope**: this release note has a scope to identify the part of the code that this release note applies to for better context. + +--- + +**Detailed Changes**: [vX.X.X...vX.X.X](https://domain.com/namespace/repo/compare/vX.X.X...vX.X.X) + +#}{# # Set line width to 1000 to avoid wrapping as GitHub will handle it +#}{% set max_line_width = max_line_width | default(1000) +%}{% set hanging_indent = hanging_indent | default(2) +%}{% set license_name = license_name | default("", True) +%}{% set releases = context.history.released.values() | list +%}{% set curr_release_index = releases.index(release) +%}{# +#}{% if mask_initial_release and curr_release_index == releases | length - 1 +%}{# # On a first release, generate our special message +#}{% include ".components/first_release.md.j2" +%}{% else +%}{# # Not the first release so generate notes normally +#}{% include ".components/versioned_changes.md.j2" +-%}{# +#}{% set prev_release_index = curr_release_index + 1 +%}{# +#}{% if 'compare_url' is filter and prev_release_index < releases | length +%}{% set prev_version_tag = releases[prev_release_index].version.as_tag() +%}{% set new_version_tag = release.version.as_tag() +%}{% set version_compare_url = prev_version_tag | compare_url(new_version_tag) +%}{% set detailed_changes_link = '[{}...{}]({})'.format( + prev_version_tag, new_version_tag, version_compare_url + ) +%}{{ "\n" +}}{{ "---\n" +}}{{ "\n" +}}{{ "**Detailed Changes**: %s" | format(detailed_changes_link) +}}{% endif +%}{% endif +%} diff --git a/.semantic-release/templates/CHANGELOG.md.j2 b/.semantic-release/templates/CHANGELOG.md.j2 new file mode 100644 index 0000000..982226a --- /dev/null +++ b/.semantic-release/templates/CHANGELOG.md.j2 @@ -0,0 +1,21 @@ +{# + This changelog template controls which changelog creation occurs + based on which mode is provided. + + Modes: + - init: Initialize a full changelog from scratch + - update: Insert new version details where the placeholder exists in the current changelog + +#}{% set insertion_flag = ctx.changelog_insertion_flag +%}{% set unreleased_commits = ctx.history.unreleased | dictsort +%}{% set releases = ctx.history.released.values() | list +%}{# +#}{% if ctx.changelog_mode == "init" +%}{% include ".components/changelog_init.md.j2" +%}{# +#}{% elif ctx.changelog_mode == "update" +%}{% set prev_changelog_file = ctx.prev_changelog_file +%}{% include ".components/changelog_update.md.j2" +%}{# +#}{% endif +%} diff --git a/CHANGELOG.md b/CHANGELOG.md index 63cec68..f99690c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Released versions are published to PyPI at . + + + ## [0.2.0](https://github.com/chad-loder/yarlpattern/releases/tag/v0.2.0) — 2026-05-13 v0.2 closes the remaining tentative-spec gap, adds a parallel polyfill @@ -124,5 +128,3 @@ Chromium, Safari, and Firefox validate against). - Auto-regenerated [WPT Conformance report](https://chad-loder.github.io/yarlpattern/wpt-compliance/) pins the corpus SHA so the 469 / 469 number is reproducible at any future date. - - diff --git a/README.md b/README.md index 5004333..5ea023c 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Stable spec API](https://img.shields.io/badge/stable%20API-implemented-2ea043?labelColor=24292f)](https://urlpattern.spec.whatwg.org/) [![Tentative spec API](https://img.shields.io/badge/tentative%20API-implemented-2ea043?labelColor=24292f)](https://urlpattern.spec.whatwg.org/) [![Python](https://img.shields.io/badge/python-3.12%2B-3776ab?labelColor=24292f&logo=python&logoColor=white)](https://www.python.org/) +[![PyPI](https://img.shields.io/pypi/v/yarlpattern.svg?labelColor=24292f&color=3775a9)](https://pypi.org/project/yarlpattern/) [![License](https://img.shields.io/badge/license-Apache--2.0-6e7681?labelColor=24292f)](LICENSE) **WHATWG URLPattern for Python — 100% conformance** to the upstream @@ -141,6 +142,8 @@ philosophy yarlpattern shares with the rest of aio-libs. ## Install +Install [`yarlpattern`](https://pypi.org/project/yarlpattern/) from PyPI: + ```bash pip install yarlpattern # stdlib re backend pip install 'yarlpattern[regex]' # full 100% conformance — see Conformance § above diff --git a/docs/index.md b/docs/index.md index 1e5721e..65e5318 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,8 @@ compile once, then ask `.test(url)` or `.exec(url)` from anywhere a ## Install +Install [`yarlpattern`](https://pypi.org/project/yarlpattern/) from PyPI: + ```bash pip install yarlpattern # stdlib re backend (99.5% WPT conformance) pip install 'yarlpattern[regex]' # full 100% conformance via Matthew Barnett's regex package diff --git a/pyproject.toml b/pyproject.toml index 1fa42ce..99b8b65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -531,6 +531,13 @@ build_command = "uv lock --upgrade-package yarlpattern" [tool.semantic_release.changelog] mode = "update" +# Custom Jinja2 templates under .semantic-release/templates/ inject a versioned +# PyPI deep-link into each release heading — see +# .semantic-release/templates/.components/versioned_changes.md.j2. When this +# directory exists with any .j2 files, PSR uses these templates in place of its +# bundled defaults (binary switch, no merge with defaults). +template_dir = ".semantic-release/templates" +insertion_flag = "" [tool.semantic_release.changelog.default_templates] changelog_file = "CHANGELOG.md"