diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea91ed..ceda2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 0.6.2 — 2026-10-07 + +A patch release of the converter's writing: a link written into GLPI opens in +a new window. Reading is unchanged. `easyvista-python-client` 0.4.2 makes the +same change, so the two converters still write the same HTML. + +### Changed + +- **Every link `to_transport` writes carries `target="_blank" + rel="noopener noreferrer"`**, as a link written in GLPI's own editor carries + `target="_blank"`. Without it a link opens in place of the page showing it. + Write models render through the same function, so a followup, task, + solution or ticket body written through them carries it too. Reading + ignores both attributes, so `from_transport(to_transport(m))` is unchanged + for every link. + ## 0.6.1 — 2026-10-02 The reader now gives the same Markdown as `easyvista-python-client` 0.4.1, diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 9498d7b..d537927 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -1838,7 +1838,9 @@ becomes: Writing, your Markdown is rendered by cmark-gfm. A newline is a line break, GFM tables work, and raw HTML passes through, so put a placeholder such as -```` in backticks. A write model keeps your Markdown as written, +```` in backticks. A link opens in a new window, as a link written in +GLPI's editor does: each ```` the renderer writes carries +``target="_blank" rel="noopener noreferrer"``, which reading ignores. A write model keeps your Markdown as written, stripped at both ends, unless it starts with ``<`` and holds an HTML element anywhere, in which case it is read as HTML. Stripping unindents the first line of a body that opens with an indented code block, which then diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 329d868..3bdb976 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -131,7 +131,7 @@ date_window, ) -__version__ = "0.6.1" +__version__ = "0.6.2" __all__ = [ "AsyncGlpiClient", diff --git a/glpi_python_client/_async/_testing.py b/glpi_python_client/_async/_testing.py index 20097db..34de740 100644 --- a/glpi_python_client/_async/_testing.py +++ b/glpi_python_client/_async/_testing.py @@ -127,10 +127,10 @@ async def _delete( ) return FakeResponse(status_code=self._delete_status, payload={}) - client._get_request = _get # type: ignore[method-assign, assignment] - client._post_request = _post # type: ignore[method-assign, assignment] - client._update_request = _patch # type: ignore[method-assign, assignment] - client._delete_request = _delete # type: ignore[method-assign, assignment] + client._get_request = _get # type: ignore[method-assign, assignment, unused-ignore] + client._post_request = _post # type: ignore[method-assign, assignment, unused-ignore] + client._update_request = _patch # type: ignore[method-assign, assignment, unused-ignore] + client._delete_request = _delete # type: ignore[method-assign, assignment, unused-ignore] class FailingTransportRecorder(TransportRecorder): diff --git a/glpi_python_client/_sync/_testing.py b/glpi_python_client/_sync/_testing.py index 8b35366..bdeccba 100644 --- a/glpi_python_client/_sync/_testing.py +++ b/glpi_python_client/_sync/_testing.py @@ -127,10 +127,10 @@ def _delete( ) return FakeResponse(status_code=self._delete_status, payload={}) - client._get_request = _get # type: ignore[method-assign, assignment] - client._post_request = _post # type: ignore[method-assign, assignment] - client._update_request = _patch # type: ignore[method-assign, assignment] - client._delete_request = _delete # type: ignore[method-assign, assignment] + client._get_request = _get # type: ignore[method-assign, assignment, unused-ignore] + client._post_request = _post # type: ignore[method-assign, assignment, unused-ignore] + client._update_request = _patch # type: ignore[method-assign, assignment, unused-ignore] + client._delete_request = _delete # type: ignore[method-assign, assignment, unused-ignore] class FailingTransportRecorder(TransportRecorder): diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py index 00df323..2b55f3e 100644 --- a/glpi_python_client/content/conversion.py +++ b/glpi_python_client/content/conversion.py @@ -715,12 +715,27 @@ def html_to_markdown(html: str) -> str: return str(_FORMATTER.render(_CONVERTER.convert_soup(soup))).strip() +#: A link as cmark-gfm writes one: an ``href``, an optional ``title``, nothing else. +_RENDERED_LINK = re.compile(r'') + +#: What GLPI's own editor writes on a link, so that it opens in a new window +#: rather than in place of the page showing it. ``noreferrer`` too, as +#: ``easyvista-python-client``'s twin writes, so the two render alike. +_NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"' + + def markdown_to_html(markdown: str) -> str: - """Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm.""" + """Render Markdown as HTML: CommonMark with GFM tables, through cmark-gfm. + + Every link opens in a new window, the way a link written in GLPI's editor + does: ``target="_blank" rel="noopener noreferrer"`` is added to each link + cmark-gfm writes. Reading ignores both attributes. + """ html: str = cmarkgfm.markdown_to_html_with_extensions( markdown, options=_RENDER_OPTIONS, extensions=["table"] ) + html = _RENDERED_LINK.sub(lambda link: link.group(0)[:-1] + _NEW_WINDOW + ">", html) return html.strip() diff --git a/glpi_python_client/content/tests/test_conversion.py b/glpi_python_client/content/tests/test_conversion.py index 6c70668..e69b9e4 100644 --- a/glpi_python_client/content/tests/test_conversion.py +++ b/glpi_python_client/content/tests/test_conversion.py @@ -17,6 +17,9 @@ read = GlpiContentConverter.from_transport render = GlpiContentConverter.to_transport +#: What every rendered link carries: GLPI's editor writes it on a link it makes. +NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"' + def test_content_is_markdown_in_python_and_html_for_glpi() -> None: assert read("

The printer is offline.

") == ( @@ -217,12 +220,12 @@ def test_deeply_nested_markdown_renders() -> None: ), pytest.param( "[x](javascript:alert(1))", - '

x

', + f'

x

', id="link-target", ), pytest.param( # python-markdown, before 0.6.0, left this as raw markup "", - '

javascript:alert(1)

', + f'

javascript:alert(1)

', id="autolink", ), ], @@ -242,3 +245,62 @@ def test_markup_a_body_displays_as_text_stays_text_both_ways() -> None: assert markdown == "\\