diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 081eda4e32..0a8d71610d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -766,6 +766,7 @@ jobs: env: GH_REPO: ${{ github.repository }} APP_VERSION: ${{ needs.prepare-version.outputs.app_version }} + PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }} STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }} DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }} DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }} @@ -911,6 +912,8 @@ jobs: notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text() metadata = { 'version': os.environ['APP_VERSION'], + # App version is SemVer; CHANGELOG.md is keyed by the backend release. + 'pypi_version': os.environ['PYPI_VERSION'], 'notes': notes, 'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'), 'platforms': { diff --git a/.gitignore b/.gitignore index fafd17aa95..fa6997cb06 100644 --- a/.gitignore +++ b/.gitignore @@ -208,6 +208,9 @@ tmp/ **/node_modules/ auth.db +# Packaging snapshot of the root CHANGELOG.md (written by build.sh) +studio/CHANGELOG.md + # Tauri local build/generated output studio/src-tauri/target/ studio/src-tauri/gen/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..241e013cea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +Release notes for Unsloth and Unsloth Studio. + +Unsloth Studio reads this file to show release notes inside the "New Unsloth +version" update popup. Edit it here and the popup picks the change up on the +next update check, with no release or rebuild required. + +## Format + +Every release is a level-2 heading whose first token is the version, optionally +followed by a date: + +```md +## 2026.7.6 - 2026-07-22 +``` + +`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a +heading, up to the next level-2 heading, is that release's notes and renders as +Markdown in the popup. + +Notes are matched to one exact version. When Studio offers an update to +`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section +is missing, the popup links out to the online changelog rather than showing +notes from an unrelated release, so a new version needs its own section here +before its notes can appear. + +Keep the newest release at the top. Lead each bullet with the change itself: +the collapsed popup highlights the first sentence and dims the rest. +`## Unreleased` is ignored by the popup, so it is safe to stage notes there and +rename the heading at release time. + + + +## Unreleased + +## 2026.7.5 + +### What's Changed + +- AMD support is here. Train, run RL, chat with and deploy 500+ models on + Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux, + up to 2x faster with 70% less VRAM and no accuracy loss. +- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and + training alongside the NVIDIA, AMD and Apple paths. +- Local speech to text dictation runs fully offline, with slim Whisper bundles + and a picker for custom models. +- DoRA training is available in Studio, selectable next to LoRA and full + fine-tuning in the training tab. +- The update popup previews release notes inline, pulled from this file and + matched to the exact version being offered. + +### AMD, 23 July update + +Our AMD collaboration, custom Triton kernels and math algorithms bring local +training and inference to AMD hardware. The 23 July update builds on the +[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta): + +- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to + detect GPUs on Strix Halo and other AMD cards. +- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed + automatically instead of stopping the install. +- Unified memory safetensors loading is 2x faster, with much faster gradient + checkpointing on unified memory devices. +- Voice dictation through whisper.cpp has preliminary support. +- Rollback environments left by installs no longer eat 5GB of disk. They are + cleaned up automatically. + +Optimized ROCm builds cover GGUF and safetensors inference, and ROCm +compatibility is improved for MI300X and MI325X. Full guide: +[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd). + +### Running larger models + +- Automatic GPU placement, or pick exactly which GPUs and layers to use. +- Move MoE expert layers into system memory so larger models fit. +- Split a model across several GPUs, or use tensor parallelism. +- Hardware settings are saved per model and quant. + +### Also in this release + +- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare. +- Web search reads PDF papers and manuals, and parallel tool calls, reasoning + output and tool retries are more reliable. +- The model download location is configurable, so weights can live on a second + drive instead of the default cache. +- Stalled Hugging Face XET downloads retry over standard HTTP, and existing + GGUF files are reused instead of downloaded again. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000..7bce036343 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include _changelog_build.py +include CHANGELOG.md diff --git a/_changelog_build.py b/_changelog_build.py new file mode 100644 index 0000000000..f5bcf2052c --- /dev/null +++ b/_changelog_build.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Snapshot CHANGELOG.md into the studio package at build time. + +CHANGELOG.md at the repo root stays the one file to edit. Copying it here, +rather than in build.sh, means every packaging path ships it, so release notes +still render when the popup cannot reach GitHub.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from setuptools.command.build_py import build_py as _build_py + +ROOT = Path(__file__).resolve().parent +SOURCE = ROOT / "CHANGELOG.md" +SNAPSHOT = ROOT / "studio" / "CHANGELOG.md" + + +class build_py(_build_py): + def run(self) -> None: + # Beside the sources only if writable (PEP 517 may build an immutable + # checkout); into the staging directory always. + if SOURCE.is_file(): + try: + shutil.copyfile(SOURCE, SNAPSHOT) + except OSError: + pass + super().run() + if not SOURCE.is_file(): + return + staged = Path(self.build_lib) / "studio" / "CHANGELOG.md" + staged.parent.mkdir(parents = True, exist_ok = True) + shutil.copyfile(SOURCE, staged) diff --git a/build.sh b/build.sh index 2a836e19d9..5b09a7791b 100644 --- a/build.sh +++ b/build.sh @@ -103,9 +103,13 @@ else STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)" fi -# 4. Build wheel/sdist +# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio +# package so release notes render offline. python -m build +# Drop the snapshot so a source checkout never serves a stale copy. +rm -f studio/CHANGELOG.md + if [ "${1:-}" = "publish" ]; then python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION" fi diff --git a/pyproject.toml b/pyproject.toml index ce19d21399..8895bf0686 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,14 @@ version = {attr = "unsloth.models._utils.__version__"} [tool.setuptools] include-package-data = true +[tool.setuptools.cmdclass] +# Snapshots CHANGELOG.md into studio/ so every build path ships it. +build_py = "_changelog_build.build_py" + [tool.setuptools.package-data] unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"] studio = [ + "CHANGELOG.md", "*.sh", "*.ps1", "*.bat", diff --git a/studio/backend/main.py b/studio/backend/main.py index 02f5a20106..9a2e598314 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -347,6 +347,7 @@ from utils.update_status import ( get_studio_install_source_status, get_studio_update_status, ) +from utils.changelog import get_release_notes, is_supported_version_query from utils.studio_version import get_studio_version from utils.api_errors import install_api_error_handlers @@ -1154,6 +1155,18 @@ def studio_update_status(_current_subject: str = Depends(get_current_subject)): return get_studio_update_status(UNSLOTH_VERSION) +@app.get("/api/studio/release-notes") +def studio_release_notes( + version: str = Query(..., max_length = 64), + refresh: bool = Query(False), + _current_subject: str = Depends(get_current_subject), +): + """Return CHANGELOG.md notes for exactly `version` (never a nearby one).""" + if not is_supported_version_query(version): + raise HTTPException(status_code = 422, detail = "Invalid version.") + return get_release_notes(version, refresh = refresh) + + @app.get( "/api/studio/download-transport-capabilities", response_model = TransportCapabilities, diff --git a/studio/backend/utils/changelog.py b/studio/backend/utils/changelog.py new file mode 100644 index 0000000000..84cd54df05 --- /dev/null +++ b/studio/backend/utils/changelog.py @@ -0,0 +1,1056 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Release notes for the update popup, sourced from CHANGELOG.md. + +Notes are keyed to one exact version: the popup asks for the version it is +offering and gets that section or nothing, so an older release's notes can +never appear next to a newer update. + +The remote copy on the default branch wins over the bundled one, since the +offered version is newer than the installed checkout. Both reads are lazy, +cached and skipped when update checks are off. +""" + +from __future__ import annotations + +import os +import re +import threading +import time +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from packaging.version import InvalidVersion, Version + +from .update_status import DISABLE_ENV_VAR, RELEASE_NOTES_URL + +CHANGELOG_FILENAME = "CHANGELOG.md" +CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/unslothai/unsloth/main/CHANGELOG.md" +CHANGELOG_URL_ENV_VAR = "UNSLOTH_CHANGELOG_URL" +CHANGELOG_PATH_ENV_VAR = "UNSLOTH_CHANGELOG_PATH" +CHANGELOG_TIMEOUT_SECONDS = 3 +CHANGELOG_MAX_BYTES = 2 * 1024 * 1024 +_CHANGELOG_CHUNK_BYTES = 64 * 1024 +_CHANGELOG_MIN_READ_SECONDS = 0.05 +CHANGELOG_SUCCESS_TTL_SECONDS = 30 * 60 +CHANGELOG_FAILURE_TTL_SECONDS = 5 * 60 +RELEASE_NOTES_MAX_CHARS = 20_000 + +# CommonMark requires a space, tab or line end after the hashes: a non-breaking +# space copied from rich text renders as text, not a heading, but a bare `##` is +# an empty heading and still ends the release above. +_HEADING_PATTERN = re.compile(r"^ {0,3}##(?:[ \t]+(?P.*?))?[ \t]*$") +_FENCE_PATTERN = re.compile(r"^ {0,3}(?P<marker>`{3,}|~{3,})(?P<rest>.*)$") +# CommonMark type 1 HTML blocks: contents are literal until a closing tag, +# which the spec says need not be the one that opened the block. +_RAW_HTML_OPEN = re.compile(r"^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)", re.IGNORECASE) +_RAW_HTML_CLOSE = re.compile(r"</(pre|script|style|textarea)\s*>", re.IGNORECASE) +# Types 3 to 5 (processing instructions, declarations, CDATA) are literal too, +# each ending on its own delimiter. Comments open mid-line, so are separate. +_RAW_BLOCKS = ( + (_RAW_HTML_OPEN, _RAW_HTML_CLOSE), + (re.compile(r"^ {0,3}<\?"), re.compile(r"\?>")), + (re.compile(r"^ {0,3}<!\[CDATA\["), re.compile(r"\]\]>")), + # A declaration needs an uppercase letter, so `<!note` stays ordinary text. + (re.compile(r"^ {0,3}<![A-Z]"), re.compile(r">")), +) +# Type 6 blocks run to the next blank line, so `<details>` only holds Markdown +# once a blank line has closed the block. Open and close tags both start one. +_HTML_BLOCK_OPEN = re.compile(r"^ {0,3}</?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)") +# Blocks that break into an open paragraph, so none is open after them and one +# they are written below is closed rather than continued. +_INTERRUPTS = re.compile( + r"^ {0,3}(?:#{1,6}([ \t]|$)|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$)" +) +# A definition is a block of its own but may not interrupt a paragraph, so it +# ends the one above it only when there is none to continue. +_LINK_DEFINITION = re.compile(r"^ {0,3}\[(?:[^\[\]\\]|\\.)+\]:") +# Blocks that are not paragraph text, so a following underline is not setext. +_PARAGRAPH_TEXT = re.compile(r"^ {0,3}(?![-*+>]([ \t]|$)|\d{1,9}[.)]([ \t]|$))\S") +# A line of = or - under a paragraph line makes that line a heading. +_SETEXT_UNDERLINE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$") +# A quoted paragraph continues on unmarked lines, which belong to the quote. +_BLOCK_QUOTE = re.compile(r"^ {0,3}>") +_QUOTE_MARKER = re.compile(r"^ {0,3}>[ \t]?") +# A heading at an item's content column belongs to that item, not the document. +# The marker needs whitespace after it, so `2.0` is a version, not an item. +_LIST_ITEM = re.compile(r"^[ \t]*(?P<marker>[-*+]|\d{1,9}[.)])(?P<space>[ \t]+|$)") +_THEMATIC_BREAK = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$") +# Content indented more than this after a marker is an indented code block, so +# the item's content starts one column past the marker instead. +_MAX_ITEM_PADDING = 4 +_HTML_BLOCK_TAGS = frozenset( + """ +address article aside base basefont blockquote body caption center col colgroup +dd details dialog dir div dl dt fieldset figcaption figure footer form frame +frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu +menuitem nav noframes ol optgroup option p param search section summary table +tbody td tfoot th thead title tr track ul +""".split() +) +# Type 7: any other complete tag alone on a line. It cannot interrupt a +# paragraph, so it only counts after a break. +_HTML_ATTRIBUTE = ( + r"""(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)""" +) +_HTML_TAG_ONLY_LINE = re.compile( + rf"^ {{0,3}}(?:<[a-zA-Z][a-zA-Z0-9-]*{_HTML_ATTRIBUTE}*\s*/?>|</[a-zA-Z][a-zA-Z0-9-]*\s*>)\s*$" +) +# Levels above studio/ are the repo root in a checkout and site-packages in an +# install, so they are searched only when one of these markers is present. +_CHECKOUT_ONLY_LEVELS = (3, 4) +_CHECKOUT_MARKERS = ("pyproject.toml", ".git") +_COMMENT_BLOCK_OPEN = re.compile(r"^ {0,3}<!--") +_COMMENT_OPEN = "<!--" +_COMMENT_CLOSE = "-->" +# Stands in for a line the renderer hides. `#` is a block of its own, so list +# tracking reads it like a comment: never a marker, never a lazy continuation. +_HIDDEN_BLOCK = "#" +_VERSION_TOKEN_PATTERN = re.compile(r"^[\[(]?v?(?P<version>[0-9][0-9A-Za-z.!+-]*?)[\])]?$") +_SAFE_VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.!+-]{0,63}$") + + +@dataclass(frozen = True) +class _ListState: + """The open list items, innermost last, by the column their content starts.""" + + columns: tuple[int, ...] = () + # True while the innermost item has had no content since its marker. + empty_item: bool = False + + +@dataclass(frozen = True) +class ChangelogEntry: + """One `## <version>` section of the changelog.""" + + version: str + heading: str + body: str + + +@dataclass(frozen = True) +class ChangelogSource: + text: str | None + source: str | None + error: str | None = None + + +@dataclass +class _ChangelogCacheEntry: + source: ChangelogSource + expires_at: float + + +_cache_condition = threading.Condition() +_remote_cache: _ChangelogCacheEntry | None = None +_remote_fetching = False + + +def reset_changelog_cache() -> None: + """Clear the in-process changelog cache. Intended for tests.""" + global _remote_cache, _remote_fetching + with _cache_condition: + _remote_cache = None + _remote_fetching = False + _cache_condition.notify_all() + + +def is_supported_version_query(version: str) -> bool: + """Whether `version` is shaped like something we can look up at all. + + Sections are indexed only when their version parses, so a query that does + not parse (`latest`, `main`) can never match and is rejected outright.""" + candidate = version.strip() + if not _SAFE_VERSION_PATTERN.match(candidate): + return False + return _parse_version(candidate) is not None + + +def _markdown_lines(text: str) -> list[str]: + """``text`` split the way CommonMark ends lines. + + str.splitlines also breaks on U+2028, U+2029, NEL, vertical tab and form + feed, none of which end a line in Markdown. A separator sitting in prose + before "## 9.9.9" would otherwise index a release the renderer never shows + and truncate the notes above it. + """ + return text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + +def parse_changelog(text: str) -> list[ChangelogEntry]: + """Parse `## <version>` sections, in file order. + + Headings whose first token is not a version (`## Unreleased`, `## Format`) + end the previous section but are not indexed. + """ + # A Windows editor can leave a BOM on the first line, hiding a heading. + text = text.lstrip("") + entries: list[ChangelogEntry] = [] + heading: str | None = None + version: str | None = None + body: list[str] = [] + open_fence: str | None = None + # Content column of the list item the open block belongs to, 0 at document + # level. A fence and an HTML block are scoped to their container, so the + # item's end closes them. Only one of the three is ever open. + block_column = 0 + in_comment = False + in_raw_html: int | None = None + in_html_block = False + after_paragraph = False + paragraph: list[str] = [] + in_quote = False + quoted = False + lists = _ListState() + + def flush() -> None: + if version is not None and heading is not None: + entries.append( + ChangelogEntry( + version = version, + heading = heading, + body = "\n".join(body).strip(), + ) + ) + + for line in _markdown_lines(text): + # The line as list tracking sees it: blank wherever nothing renders. + structural = "" + opened_block = False + in_block = open_fence is not None or in_html_block or in_raw_html is not None or in_comment + # A fence, comment or HTML block inside a list item runs only to the end + # of that item, so a line dedented out of the item closes both. Lazy + # continuation reaches into none of them. A raw block or comment inside an + # item also ends on a blank line: the item takes the break, so what + # follows is a block of the item's own. + leaves = ( + _indent_width(line) < block_column + if line.strip() + else in_raw_html is not None or in_comment + ) + if in_block and block_column and leaves: + open_fence = None + in_html_block = False + in_raw_html = None + in_comment = False + block_column = 0 + # The paragraph the line could have continued is block content, so + # it closes the item rather than reading as more of it. + after_paragraph = False + # A fence written as a list item's first content opens inside that item, so + # an opener is read past a marker on the same line. Only an opener: fenced + # content is literal and a closer carries no marker. + fence_line = line if open_fence else _item_content(line, after_paragraph) + # Raw HTML first: its contents are literal, so a fence in it is not one. + if in_raw_html is not None: + visible, in_raw_html = _strip_raw_html(line, in_raw_html) + elif in_html_block: + # A blank line is the only thing that ends a type 6 block. + in_html_block = line.strip() != "" + visible = "" + elif (fence := _FENCE_PATTERN.match(fence_line)) and not in_comment: + was_open = open_fence + open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest")) + opened_block = was_open is None and open_fence is not None + # Hidden from heading matching, but its indent still closes items. + visible = "" + structural = line + elif open_fence: + visible = "" + else: + # A block already open owns this line, so it is content rather than a + # block written at the column it happens to start in. + hidden = in_comment or in_raw_html is not None + # A comment is an HTML block too, so one written as a list item's first + # content opens inside it exactly as a fence does: the opener is read + # past a marker on the same line. + block_open = ( + not in_comment + and _COMMENT_BLOCK_OPEN.match(_item_content(line, after_paragraph)) is not None + ) + # Commented-out sections are not rendered, so they are not releases. + visible, in_comment = _strip_comments(line, in_comment, block_open) + # An HTML block written as a list item's first content opens inside + # that item, as a fence does, so an opener is read past a marker on the + # same line. The marker stays, so its item is still tracked. A comment + # blanks its own line, so that line is read as written: the block + # renders as nothing, but the item it is content of still opens. + source = line if block_open else visible + content = _item_content(source, after_paragraph) + marker = source[: len(source) - len(content)] + # Nor is anything inside a raw HTML block such as <pre>. + stripped, in_raw_html = _strip_raw_html(content, in_raw_html) + opened_block = in_raw_html is not None or (block_open and in_comment) + # Taken before the opener is hidden: it renders as nothing, but its + # indent still closes a list item it sits left of, and a marker on its + # line still opens one. A comment or raw block keeps only those, since + # the text it hides is not Markdown and must open no list. + if block_open or stripped != content: + if not hidden: + structural = _hidden_structure(line, marker) + visible = "" + else: + visible = marker + stripped + if visible.strip(): + structural = visible + elif not hidden: + structural = _hidden_structure(line) + if stripped and _opens_html_block(stripped, after_paragraph): + in_html_block = True + opened_block = True + visible = "" + # A `##` inside a fenced block is sample markdown, not a real heading. + match = _HEADING_PATTERN.match(visible) if visible else None + # `1.0` over a line of dashes is the same heading written setext style. + setext = ( + after_paragraph + and match is None + and paragraph != [] + and _SETEXT_UNDERLINE.match(visible) is not None + and (visible.strip()[:1] == "-") + # Never a boundary inside a list item: dedented the dashes are a + # thematic break, and at the content column the heading is nested. + and not lists.columns + ) + if setext: + if version is not None: + # The whole paragraph is the heading, read as body on arrival. + del body[len(body) - len(paragraph) :] + flush() + # A wrapped heading keeps every line, so token one is the version. + heading = "\n".join(paragraph) + version = _version_from_heading(heading) + body = [] + paragraph = [] + after_paragraph = False + continue + # A dashed underline is not a list marker, so track lists after setext. + lazy_marker = _lazy_marker(structural, lists, after_paragraph, quoted) + lists = _open_lists(structural, lists, after_paragraph, quoted) + # Taken after the opening line closed the items it is dedented out of, + # so the block belongs to the item it is really written inside. + if opened_block: + block_column = lists.columns[-1] if lists.columns else 0 + elif open_fence is None and not in_html_block and in_raw_html is None and not in_comment: + block_column = 0 + # At an open item's content column a heading is nested, not a boundary. + if lists.columns and _indent_width(visible) >= lists.columns[0]: + match = None + # The line at its own nesting level: past the container's indentation + # and past a marker on the same line, so `- ## 2.0` reads as a heading. + column = lists.columns[-1] if lists.columns else 0 + content = _strip_indent(visible, column) + if (item := _LIST_ITEM.match(content)) is not None: + content = content[item.end() :] + # Only ordinary text continues a paragraph. Indented code counts four + # spaces past the container, so an item's own indent does not count. + indented_code = not after_paragraph and _indent_width(visible) - column >= 4 + # An underline ends the paragraph it underlines, so it needs one open in + # its own container: the quote above owns its own, and a row left of an + # open item is lazy text of the item's paragraph. Three dashes are a + # thematic break either way, which `_INTERRUPTS` already ends on. + underline = ( + _SETEXT_UNDERLINE.match(visible) is not None + and after_paragraph + and not quoted + and _indent_width(visible) >= column + ) + after_paragraph = ( + # Read inside its container, so an empty item and a fence written as an + # item's own content leave no paragraph open below them. A marker the + # paragraph above swallows is its text, not an item. + (bool(content.strip()) or lazy_marker) + and match is None + and _HEADING_PATTERN.match(content) is None + and _FENCE_PATTERN.match(content) is None + and not indented_code + and _INTERRUPTS.match(visible) is None + and (after_paragraph or _LINK_DEFINITION.match(visible) is None) + and not underline + ) + # A quote's paragraph runs on over plain text and owns every line of it. + # An empty quote holds none, so the line below starts the document's. + flush_left = visible.lstrip(" \t") + quote_line = _BLOCK_QUOTE.match(visible) is not None + in_quote = ( + _may_be_lazy(_quote_content(visible)) + if quote_line + else in_quote and _continues_paragraph(visible, column) + ) + if quote_line: + # The only paragraph a quote line leaves open is the quote's own, + # and a quote holding a heading or nothing at all leaves none. + after_paragraph = in_quote + # Whose paragraph the line below would continue. A quote owns the one its + # own lines hold, so a marker outside the quote is a block of its own + # rather than more of the text above it. + quoted = quote_line or in_quote + # The lines a later underline turns into one heading. A paragraph opens + # only on plain text and then runs on until something interrupts it. + continues = ( + not _interrupts_paragraph(flush_left) + if paragraph + else _PARAGRAPH_TEXT.match(flush_left) is not None + ) + # A paragraph inside an open item is that item's, and only one written + # at document level can be the heading a later underline makes of it. + if after_paragraph and not in_quote and not lists.columns and continues: + paragraph = [*paragraph, visible.strip()] + else: + paragraph = [] + if match is None: + if version is not None: + body.append(line) + continue + + flush() + # An empty heading has no title, so it ends the release above without + # indexing one: `_version_from_heading` finds no version and `flush` skips. + heading = match.group("title") or "" + version = _version_from_heading(heading) + body = [] + + flush() + return entries + + +def find_release_notes(text: str, version: str) -> ChangelogEntry | None: + """Return the section for exactly `version`, or None. + + Equality is version-aware (`2026.07.5` matches `2026.7.5`) but never fuzzy: + a near-miss returns None so the caller shows no notes, not the wrong ones. + """ + entries = parse_changelog(text) + for entry in entries: + # An exact heading wins, so `## 1.0` is never shadowed by `## 1.0.0`. + if entry.version == version: + return entry + + wanted = _parse_version(version) + for entry in entries: + if wanted is not None: + candidate = _parse_version(entry.version) + if candidate is not None and candidate == wanted: + return entry + return None + + +def get_release_notes(version: str, refresh: bool = False) -> dict[str, Any]: + """Return release notes for exactly `version` for the update popup. + + `refresh` retries a cached remote failure, so the UI's retry action is not + stuck behind the failure TTL once connectivity returns. + """ + version = version.strip() + if not is_supported_version_query(version): + return _notes_response(version = version, error = "Unsupported version.") + + local = _read_local_changelog() + remote = ChangelogSource(text = None, source = None) + if os.environ.get(DISABLE_ENV_VAR) != "1": + remote = get_remote_changelog(refresh = refresh) + + # Remote first: the offered version is newer than the local copy. + for candidate in (remote, local): + if not candidate.text: + continue + entry = find_release_notes(candidate.text, version) + if entry is not None: + return _notes_response( + version = version, + markdown = entry.body, + heading = entry.heading, + source = candidate.source, + ) + + # Nothing matched: the bundled copy cannot know a version newer than the + # install, so report a remote failure and let the UI offer a retry. + return _notes_response(version = version, error = remote.error) + + +def get_remote_changelog(refresh: bool = False) -> ChangelogSource: + """Fetch CHANGELOG.md from the repo using a small in-process TTL cache.""" + global _remote_cache, _remote_fetching + + if refresh: + # Only a cached failure is dropped, so retries cannot hammer the remote. + with _cache_condition: + if _remote_cache and _remote_cache.source.text is None: + _remote_cache = None + + # A caller waits for an in-flight fetch only as long as it may take, then + # answers locally rather than holding a worker behind a stalled upstream. + deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS + 1 + while True: + now = time.monotonic() + with _cache_condition: + if _remote_cache and _remote_cache.expires_at > now: + return _remote_cache.source + if not _remote_fetching: + _remote_fetching = True + break + if now >= deadline: + return ChangelogSource( + text = None, + source = None, + error = "Release notes are still loading.", + ) + _cache_condition.wait(timeout = deadline - now) + + try: + try: + source = _fetch_remote_changelog() + except Exception: + source = ChangelogSource( + text = None, + source = None, + error = "Could not fetch release notes.", + ) + + ttl = CHANGELOG_SUCCESS_TTL_SECONDS if source.text else CHANGELOG_FAILURE_TTL_SECONDS + with _cache_condition: + _remote_cache = _ChangelogCacheEntry(source = source, expires_at = time.monotonic() + ttl) + return source + finally: + # Released here, not on the Exception path: stranding the single-flight + # flag on BaseException makes every later caller wait out the deadline. + with _cache_condition: + _remote_fetching = False + _cache_condition.notify_all() + + +def _fetch_remote_changelog() -> ChangelogSource: + url = os.environ.get(CHANGELOG_URL_ENV_VAR, "").strip() or CHANGELOG_RAW_URL + if not url.startswith(("http://", "https://")): + return ChangelogSource(text = None, source = None, error = "Invalid changelog URL.") + + request = urllib.request.Request( + url, + headers = { + "User-Agent": "unsloth-studio-update-check", + # Or a compressing proxy hands back bytes we would decode as notes. + "Accept-Encoding": "identity", + }, + ) + deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS + try: + with urllib.request.urlopen(request, timeout = CHANGELOG_TIMEOUT_SECONDS) as response: + chunks: list[bytes] = [] + received = 0 + while received <= CHANGELOG_MAX_BYTES: + remaining = deadline - time.monotonic() + if remaining <= 0: + return ChangelogSource( + text = None, + source = None, + error = "Release notes took too long to load.", + ) + # The socket timeout is per operation, so re-cap it each read. + _limit_read(response, remaining) + chunk = response.read1(_CHANGELOG_CHUNK_BYTES) + if not chunk: + break + chunks.append(chunk) + received += len(chunk) + body = b"".join(chunks) + if len(body) > CHANGELOG_MAX_BYTES: + return ChangelogSource( + text = None, + source = None, + error = "Release notes response was too large.", + ) + return ChangelogSource(text = body.decode("utf-8", errors = "replace"), source = "remote") + except TimeoutError: + return ChangelogSource( + text = None, + source = None, + error = "Release notes took too long to load.", + ) + except OSError: + return ChangelogSource( + text = None, + source = None, + error = "Could not reach the changelog for release notes.", + ) + except UnicodeError: + return ChangelogSource(text = None, source = None, error = "Malformed changelog.") + + +def _limit_read(response: Any, remaining: float) -> None: + """Cap the next socket read at the time left in the fetch budget.""" + sock = getattr(getattr(response, "fp", None), "raw", None) + sock = getattr(sock, "_sock", None) + if sock is None: + return + try: + sock.settimeout(max(remaining, _CHANGELOG_MIN_READ_SECONDS)) + except OSError: + pass + + +def _read_local_changelog() -> ChangelogSource: + """Read the CHANGELOG.md bundled with this install, if there is one.""" + for path in _local_changelog_candidates(): + try: + if not path.is_file(): + continue + if path.stat().st_size > CHANGELOG_MAX_BYTES: + continue + return ChangelogSource( + text = path.read_text(encoding = "utf-8", errors = "replace"), + source = "local", + ) + except OSError: + continue + return ChangelogSource(text = None, source = None) + + +def _is_source_checkout(root: Path) -> bool: + """Whether `root` is this repository rather than an install directory.""" + try: + return any((root / marker).exists() for marker in _CHECKOUT_MARKERS) + except OSError: + return False + + +def _local_changelog_candidates() -> list[Path]: + override = os.environ.get(CHANGELOG_PATH_ENV_VAR, "").strip() + candidates: list[Path] = [] + if override: + candidates.append(Path(override).expanduser()) + + # changelog.py -> utils -> backend -> studio -> repo root. Repo root first + # so a checkout's editable file beats the snapshot packaging writes into + # studio/. Installed, those outer levels are site-packages, hence the marker. + parents = Path(__file__).resolve().parents + for index in (3, 2, 1, 4): + if index >= len(parents): + continue + root = parents[index] + if index in _CHECKOUT_ONLY_LEVELS and not _is_source_checkout(root): + continue + candidates.append(root / CHANGELOG_FILENAME) + + seen: set[Path] = set() + unique: list[Path] = [] + for candidate in candidates: + if candidate not in seen: + seen.add(candidate) + unique.append(candidate) + return unique + + +def _opens_fence(marker: str, rest: str) -> bool: + """A backtick fence's info string may not contain a backtick.""" + return marker[0] != "`" or "`" not in rest + + +def _next_fence_state(open_fence: str | None, marker: str, rest: str) -> str | None: + """Track the open fence marker. + + A closer must be the same character, at least as long, and carry nothing + after it. So neither a ``` sample nor a ```` line with trailing text ends + a ```` block early, while an opening fence may still have an info string. + Only spaces and tabs count as nothing: other Unicode whitespace is content. + """ + if open_fence is None: + return marker if _opens_fence(marker, rest) else None + closes = marker[0] == open_fence[0] and len(marker) >= len(open_fence) + if closes and not rest.strip(" \t"): + return None + return open_fence + + +def _code_span_ranges(line: str) -> list[tuple[int, int]]: + """Code span bounds. A run of backticks closes only on a run of its length.""" + # Collect the runs once: rescanning per opener is quadratic on a line of + # distinct unmatched runs, and notes are reparsed on every request. + runs: list[tuple[int, int]] = [] + index = 0 + while index < len(line): + if line[index] != "`" or _is_escaped(line, index): + index += 1 + continue + ticks = _run_length(line, index) + runs.append((index, ticks)) + index += ticks + + # A run closes only on a later run of its length, so one cursor per length. + by_length: dict[int, list[int]] = {} + for position, (_, ticks) in enumerate(runs): + by_length.setdefault(ticks, []).append(position) + + spans: list[tuple[int, int]] = [] + cursors: dict[int, int] = {} + current = 0 + while current < len(runs): + start, ticks = runs[current] + same = by_length[ticks] + cursor = cursors.get(ticks, 0) + while cursor < len(same) and same[cursor] <= current: + cursor += 1 + cursors[ticks] = cursor + if cursor >= len(same): + # Nothing closes this run, so it is literal text. + current += 1 + continue + closer = same[cursor] + cursors[ticks] = cursor + 1 + spans.append((start, runs[closer][0] + ticks)) + current = closer + 1 + return spans + + +def _run_length(line: str, index: int) -> int: + end = index + while end < len(line) and line[end] == "`": + end += 1 + return end - index + + +def _is_escaped(line: str, index: int) -> bool: + slashes = 0 + while index - 1 - slashes >= 0 and line[index - 1 - slashes] == "\\": + slashes += 1 + return slashes % 2 == 1 + + +def _strip_comments(line: str, in_comment: bool, block_open: bool) -> tuple[str, bool]: + """Return the line with HTML-comment spans removed, and the trailing state. + + Only a comment that starts a line opens a block and hides the lines below + it. One written mid-sentence is inline HTML: it hides the rest of its own + line at most, so a note mentioning `<!--` cannot swallow later releases. + Delimiters inside inline code are literal and hide nothing. + + "Starts a line" is read inside the container, so `block_open` is decided by + the caller from the item's content rather than from the raw line. + """ + if in_comment: + close = line.find(_COMMENT_CLOSE) + # The closing line belongs to the block, tail included. + return ("", False) if close != -1 else ("", True) + + if block_open: + # `<!-->` and `<!--->` are complete comments, so the closer may overlap + # the opener; searching past it would swallow every later release. + return ("", _COMMENT_CLOSE not in line) + + visible: list[str] = [] + index = 0 + spans = _code_span_ranges(line) + # Spans are ordered and disjoint and each opener sits at or past the one + # before, so the search resumes rather than restarts: restarting per opener is + # quadratic, and a long line of code spans is reparsed on every request. + cursor = 0 + while index < len(line): + opening = line.find(_COMMENT_OPEN, index) + if opening == -1: + visible.append(line[index:]) + break + + while cursor < len(spans) and spans[cursor][1] <= opening: + cursor += 1 + if cursor < len(spans) and spans[cursor][0] <= opening: + visible.append(line[index : spans[cursor][1]]) + index = spans[cursor][1] + continue + + visible.append(line[index:opening]) + close = line.find(_COMMENT_CLOSE, opening + len(_COMMENT_OPEN)) + if close == -1: + # Unterminated inline comment: it hides this line and no more. + break + index = close + len(_COMMENT_CLOSE) + return "".join(visible), False + + +def _hidden_structure(line: str, marker: str = "") -> str: + """`line` as list tracking sees it once the renderer hides its text. + + A comment or a raw HTML block renders nothing, but it is still a block + written at its own column, so it closes the items it sits to the left of. + Only the indentation survives: what is inside the block is not Markdown and + must not open a list of its own. `marker` is the part of the line that opens + a list item the block is the content of, which survives with it.""" + if marker: + return marker + _HIDDEN_BLOCK + if not line.strip(): + return "" + return line[: len(line) - len(line.lstrip(" \t"))] + _HIDDEN_BLOCK + + +def _indent_width(line: str) -> int: + """Columns of leading whitespace, counting a tab to the next stop of four.""" + width = 0 + for char in line: + if char == " ": + width += 1 + elif char == "\t": + width += 4 - width % 4 + else: + break + return width + + +def _strip_indent(line: str, columns: int) -> str: + """`line` with up to `columns` columns of leading whitespace removed.""" + width = 0 + index = 0 + while index < len(line) and width < columns and line[index] in " \t": + width += 1 if line[index] == " " else 4 - width % 4 + index += 1 + return line[index:] + + +def _interrupts_paragraph(line: str) -> bool: + """Whether `line` starts a block that can break into an open paragraph. + + A quote marker always can. A list item can only when it has content, and an + ordered one only when it starts at 1: anything else is text of the + paragraph it appears to interrupt.""" + if _BLOCK_QUOTE.match(line): + return True + item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line) + if item is None: + return False + marker = item.group("marker") + if not line[item.end() :].strip(): + return False + return marker[-1] not in ".)" or marker[:-1] == "1" + + +def _item_content(line: str, after_paragraph: bool) -> str: + """`line` read from the content column of a list item that opens on it. + + A block written as an item's first content sits inside that item, so + ``- ```` opens a fence even though its marker is not within three columns of + the container. The padding is capped the way `_open_lists` caps it, or + ``- ```` would read as a fence rather than the indented code it is. A + marker the paragraph above swallows opens no item, so its line is returned + whole, as is one four columns past its container. Ported to the frontend as + `itemContent` in markdown-list-columns.ts.""" + if _indent_width(line) >= 4 or (after_paragraph and not _interrupts_paragraph(line)): + return line + item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line) + if item is None: + return line + padding = _indent_width(item.group("space")) + # Over-indented content starts one column past the marker; the rest of the + # padding is the content's own indentation. + over = padding - 1 if padding > _MAX_ITEM_PADDING else 0 + return " " * over + line[item.end() :] + + +def _quote_content(line: str) -> str: + """What a blockquote line holds, with its markers stripped.""" + while (marker := _QUOTE_MARKER.match(line)) is not None: + line = line[marker.end() :] + return line + + +def _may_be_lazy(line: str) -> bool: + """Whether `line` can continue a paragraph it is indented out of. + + Only plain text can: a heading, a fence, a break or an HTML block starts a + block of its own, which closes the item instead. An underline is not one of + them: it may never be lazy, so `===` written left of an open item is read as + more of the item's paragraph. Nor is a definition, which is a block of its + own but may not interrupt a paragraph. A row of dashes still closes the + item, as `_INTERRUPTS` reads three or more as the thematic break they are.""" + return ( + _PARAGRAPH_TEXT.match(line) is not None + and _INTERRUPTS.match(line) is None + and _FENCE_PATTERN.match(line) is None + # Types 1 to 6 interrupt a paragraph, so a `<div>` left of an open item + # closes it. Type 7 cannot, and is deliberately excluded. + and not _opens_html_block(line, True) + ) + + +def _continues_paragraph(line: str, column: int) -> bool: + """Whether `line` reads as more of a paragraph open in its container. + + Measured from `column`, where that container's content starts: four columns + past it the line is an indented code block, which may not interrupt a + paragraph, so indentation alone never closes the one above it.""" + inner = _strip_indent(line, column) + return _indent_width(inner) >= 4 or _may_be_lazy(inner) + + +def _close_dedented( + columns: tuple[int, ...], line: str, indent: int, after_paragraph: bool +) -> tuple[int, ...]: + """`columns` with every item `line` is written to the left of closed. + + Read inside the container the item sits in, not from the margin: a line that + only looks indented there is lazy text of the item's paragraph, which leaves + the item open rather than closing it.""" + while columns and indent < columns[-1]: + outer = columns[-2] if len(columns) > 1 else 0 + if after_paragraph and _continues_paragraph(line, outer): + break + columns = columns[:-1] + return columns + + +def _lazy_marker(line: str, state: _ListState, after_paragraph: bool, quoted: bool) -> bool: + """Whether a marker-shaped `line` is really text of the paragraph above it. + + Only a marker inside the paragraph's own item interrupts it; one to the left + closes that item and opens a sibling. A quote owns the paragraph its lines + hold, so a marker written outside the quote opens a list of its own.""" + item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line) + columns = state.columns + return ( + item is not None + and after_paragraph + and not quoted + and (not columns or _indent_width(line) >= columns[-1]) + and not _interrupts_paragraph(line) + ) + + +def _open_lists( + line: str, + state: _ListState, + after_paragraph: bool, + quoted: bool = False, +) -> _ListState: + """The list items still open after `line`. + + A dedented line closes an item, unless it is a lazy paragraph continuation. + A new marker nests under a deeper column and replaces a sibling. `quoted` + marks a paragraph the blockquote above owns: a marker written outside the + quote is not text of it, so it opens a list of its own. + """ + columns = state.columns + if not line.strip(): + # A blank line leaves the list open, unless the item is still empty: an + # item may begin with one blank line, and later content is outside it. + return _ListState(columns[:-1] if state.empty_item else columns) + indent = _indent_width(line) + item = None if _THEMATIC_BREAK.match(line) else _LIST_ITEM.match(line) + empty = item is not None and not line[item.end() :].strip() + if _lazy_marker(line, state, after_paragraph, quoted): + # A lazy continuation or an underline, so the open items are untouched. + return state + columns = _close_dedented(columns, line, indent, after_paragraph) + # Four columns past its container the marker is an indented code block, or + # lazy text of the paragraph above it, so it opens no list of its own. + if item is None or indent - (columns[-1] if columns else 0) >= 4: + return _ListState(columns) + marker = item.group("marker") + padding = _indent_width(item.group("space")) + if padding == 0 or padding > _MAX_ITEM_PADDING: + # An empty or over-indented item still holds one column of content. + padding = 1 + while columns and columns[-1] > indent: + columns = columns[:-1] + return _ListState((*columns, indent + len(marker) + padding), empty_item = empty) + + +def _opens_html_block(line: str, after_paragraph: bool) -> bool: + """True if `line` starts a CommonMark type 6 or type 7 HTML block.""" + match = _HTML_BLOCK_OPEN.match(line) + if match is not None and match.group(1).lower() in _HTML_BLOCK_TAGS: + return True + return not after_paragraph and _HTML_TAG_ONLY_LINE.match(line) is not None + + +def _strip_raw_html(line: str, open_block: int | None) -> tuple[str, int | None]: + """Drop the parts of a line inside a raw block, and return the open block. + + The state is the index of the open block in `_RAW_BLOCKS`, or None.""" + if open_block is not None: + close = _RAW_BLOCKS[open_block][1].search(line) + return ("", None) if close else ("", open_block) + + # A block only opens at the start of a line; mid-line tags are inline HTML. + for index, (opener, closer) in enumerate(_RAW_BLOCKS): + opening = opener.match(line) + if opening is None: + continue + rest = line[opening.end() :] + close = closer.search(rest) + return ("", None) if close else ("", index) + return line, None + + +def _version_from_heading(heading: str) -> str | None: + token = heading.split()[0] if heading.split() else "" + match = _VERSION_TOKEN_PATTERN.match(token) + if match is None: + return None + version = match.group("version") + return version if _parse_version(version) is not None else None + + +def _parse_version(version: str) -> Version | None: + try: + return Version(version) + except InvalidVersion: + return None + + +def _close_open_fence(markdown: str) -> str: + """Close a fence the truncation cut in half, so the rest still renders.""" + open_fence: str | None = None + for line in _markdown_lines(markdown): + fence = _FENCE_PATTERN.match(line) + if fence: + open_fence = _next_fence_state(open_fence, fence.group("marker"), fence.group("rest")) + return f"{markdown}\n{open_fence}" if open_fence else markdown + + +def _renders_visibly(markdown: str) -> bool: + """Whether a section body renders anything at all.""" + in_comment = False + for line in _markdown_lines(markdown): + opens_raw = any(opener.match(line) for opener, _ in _RAW_BLOCKS) + if not in_comment and (_FENCE_PATTERN.match(line) or opens_raw): + # A code block or raw HTML block renders even when it is empty. + return True + # No containers are tracked here, so the opener is read at the margin. The + # answer does not turn on it: an item renders its marker whatever the block + # inside hides, so a commented-out item renders something either way. + visible, in_comment = _strip_comments( + line, in_comment, _COMMENT_BLOCK_OPEN.match(line) is not None + ) + if visible.strip(): + return True + return False + + +def _notes_response( + *, + version: str, + markdown: str | None = None, + heading: str | None = None, + source: str | None = None, + error: str | None = None, +) -> dict[str, Any]: + # A section that renders as nothing counts as unpublished, not as empty. + if markdown and not _renders_visibly(markdown): + markdown = None + source = None + + truncated = False + if markdown and len(markdown) > RELEASE_NOTES_MAX_CHARS: + markdown = _close_open_fence(markdown[:RELEASE_NOTES_MAX_CHARS].rstrip()) + truncated = True + + return { + "version": version, + "markdown": markdown or None, + "heading": heading, + # False means no notes for this exact version; the UI links out. + "matched": bool(markdown), + "truncated": truncated, + "source": source, + "release_notes_url": RELEASE_NOTES_URL, + "error": error, + } diff --git a/studio/backend/utils/update_status.py b/studio/backend/utils/update_status.py index ad9dabcf36..d4b8ca1c16 100644 --- a/studio/backend/utils/update_status.py +++ b/studio/backend/utils/update_status.py @@ -30,6 +30,7 @@ PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60 PYPI_FAILURE_TTL_SECONDS = 60 * 60 RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog" DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK" +FAKE_UPDATE_ENV_VAR = "UNSLOTH_STUDIO_FAKE_UPDATE" LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"} @@ -107,11 +108,32 @@ def get_studio_install_source_status(current_version: str) -> dict[str, Any]: ) +def _is_version(value: str) -> bool: + try: + Version(value) + except InvalidVersion: + return False + return True + + def get_studio_update_status(current_version: str) -> dict[str, Any]: """Return public, read-only update status for the web UI.""" install_source = detect_install_source() + disabled = os.environ.get(DISABLE_ENV_VAR) == "1" - if os.environ.get(DISABLE_ENV_VAR) == "1": + # Dev-only: the popup is PyPI-install-only, so fake a version to review it + # from a checkout. The documented opt-out still wins. + forced_version = os.environ.get(FAKE_UPDATE_ENV_VAR, "").strip() + if forced_version and not disabled and _is_version(forced_version): + return _status_response( + current_version = current_version, + latest_version = forced_version, + install_source = "pypi", + update_available = True, + can_show_web_notification = True, + ) + + if disabled: return _status_response( current_version = current_version, latest_version = None, diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index d746ed952c..b076c8cf8d 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -214,7 +214,8 @@ function TauriUpdateLayer({ } return ( - <div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2"> + // Capped like the browser stack: the download panel shares it, so both must fit. + <div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex max-h-[calc(100dvh_-_2rem)] flex-col items-end gap-2"> <UpdateBanner status={update.status} info={update.info} @@ -223,6 +224,7 @@ function TauriUpdateLayer({ isExternalServer={isExternalServer} updatePolicyMode={update.updatePolicyMode} manualReleaseUrl={update.manualReleaseUrl} + releasePageUrl={update.releasePageUrl} positioned={false} onInstall={update.installUpdate} onDismiss={update.dismiss} @@ -379,9 +381,11 @@ function TauriWrapper({ children }: { children: ReactNode }) { return ( <> {children} - {/* One bottom-right stack so overlays never overlap; they stack with a - gap, download panel anchored at the corner with banners above. */} - <div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2"> + {/* One bottom-right stack so overlays never overlap: download panel at the + corner, banners above, each owning its width. */} + {/* Capped to the viewport, or a long download list plus expanded notes + pushes the top of the stack off screen. */} + <div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex max-h-[calc(100dvh_-_2rem)] flex-col items-end gap-2"> <WebUpdateBanner positioned={false} enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 2729558630..5276eda858 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -134,7 +134,7 @@ export function LlamaUpdateBanner({ className={cn( positioned ? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]" - : "pointer-events-auto w-full", + : "pointer-events-auto w-[calc(100vw-2rem)] max-w-[400px]", )} data-testid="llama-update-banner" > diff --git a/studio/frontend/src/components/tauri/update-banner.tsx b/studio/frontend/src/components/tauri/update-banner.tsx index 6f5e655889..49c9c44aaa 100644 --- a/studio/frontend/src/components/tauri/update-banner.tsx +++ b/studio/frontend/src/components/tauri/update-banner.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Button } from "@/components/ui/button"; +import { ReleaseNotesPanel } from "@/components/update/release-notes-panel"; import type { DesktopUpdatePolicyMode, RetainedUpdateFailure, @@ -22,6 +23,8 @@ interface UpdateBannerProps { isExternalServer?: boolean; updatePolicyMode: DesktopUpdatePolicyMode; manualReleaseUrl: string | null; + // Release page for this version, preferred over the generic changelog. + releasePageUrl?: string | null; // false fills a shared overlay stack; true self-anchors. positioned?: boolean; onInstall: () => void; @@ -30,6 +33,7 @@ interface UpdateBannerProps { } const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; +const LEADING_V = /^v/; function formatVersion(version: string | null | undefined): string { if (!version) return ""; @@ -44,6 +48,7 @@ export function UpdateBanner({ isExternalServer = false, updatePolicyMode, manualReleaseUrl, + releasePageUrl = null, positioned = true, onInstall, onDismiss, @@ -52,6 +57,8 @@ export function UpdateBanner({ const [copying, setCopying] = useState(false); const [manualReport, setManualReport] = useState<string | null>(null); const [manualMessage, setManualMessage] = useState<string | null>(null); + // Version whose notes are expanded; a new offer collapses the panel. + const [notesVersion, setNotesVersion] = useState<string | null>(null); const showFailure = Boolean(lastFailure) && !dismissed; const showAvailable = status === "available" && !dismissed && !showFailure; const show = showFailure || (showAvailable && Boolean(info)); @@ -62,6 +69,11 @@ export function UpdateBanner({ const currentVersion = formatVersion(info?.currentVersion); const latestVersion = formatVersion(info?.version); const Icon = showFailure ? CircleAlert : Download; + // Keyed by the backend release, not the app's SemVer; headings drop the v. + const notesTargetVersion = + (info?.pypiVersion ?? info?.version)?.replace(LEADING_V, "") ?? null; + const notesOpen = + notesTargetVersion !== null && notesVersion === notesTargetVersion; async function handleCopyDiagnostics() { setCopying(true); @@ -94,13 +106,14 @@ export function UpdateBanner({ exit={{ opacity: 0, y: 8, scale: 0.97 }} transition={{ duration: 0.35, ease: EASE_OUT_QUART }} className={cn( + // Wider than the other overlays: notes preview plus three buttons. positioned - ? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]" - : "pointer-events-auto w-full", + ? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]" + : "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col", )} data-testid="tauri-update-banner" > - <div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]"> + <div className="relative flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]"> <button type="button" onClick={onDismiss} @@ -160,7 +173,40 @@ export function UpdateBanner({ </p> )} - <div className="mt-4 flex flex-wrap items-center justify-end gap-x-1 gap-y-2"> + {!showFailure && notesTargetVersion ? ( + <ReleaseNotesPanel + version={notesTargetVersion} + open={notesOpen} + // Used only if CHANGELOG.md has no section for this version. + fallbackMarkdown={info?.body ?? null} + className="min-h-0 flex-1" + releaseNotesUrl={releasePageUrl ?? manualReleaseUrl} + /> + ) : null} + + <div + className={cn( + "mt-4 flex flex-wrap items-center gap-x-1 gap-y-2", + !showFailure && notesTargetVersion + ? "justify-between" + : "justify-end", + )} + > + {!showFailure && notesTargetVersion ? ( + <Button + size="sm" + variant="ghost" + // same type size as the action buttons + className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground" + onClick={() => + setNotesVersion(notesOpen ? null : notesTargetVersion) + } + aria-expanded={notesOpen} + data-testid="tauri-update-release-notes-toggle" + > + {notesOpen ? "Hide release notes" : "Show release notes"} + </Button> + ) : null} {showFailure ? ( <> <Button @@ -187,28 +233,31 @@ export function UpdateBanner({ onClick={onInstall} disabled={installDisabled} > - {isManualLinuxPackage ? "Open release page" : "Retry update"} + {isManualLinuxPackage + ? "Open release page" + : "Retry update"} </Button> </> ) : ( - <> + // wrap + right-align so the action pair stays together + <div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2"> <Button size="sm" variant="ghost" - className="h-auto rounded-full px-3 py-2 text-ui-13 font-medium text-foreground" + className="h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground" onClick={onDismiss} > Remind me later </Button> <Button size="sm" - className="-mr-1 h-auto rounded-full px-3.5 py-2 text-ui-13" + className="-mr-1 h-auto whitespace-nowrap rounded-full px-3 py-2 text-ui-13" onClick={onInstall} disabled={installDisabled} > {isManualLinuxPackage ? "Open release page" : "Update"} </Button> - </> + </div> )} </div> {manualMessage && ( diff --git a/studio/frontend/src/components/update/release-notes-panel.tsx b/studio/frontend/src/components/update/release-notes-panel.tsx new file mode 100644 index 0000000000..d98c855daa --- /dev/null +++ b/studio/frontend/src/components/update/release-notes-panel.tsx @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { MarkdownPreview } from "@/components/markdown/markdown-preview"; +import { useReleaseNotes } from "@/hooks/use-release-notes"; +import { resolveChangelogLinks } from "@/lib/changelog-links"; +import { releaseNotesPreview } from "@/lib/release-notes-preview"; +import { cn } from "@/lib/utils"; +import { + type ReactElement, + type ReactNode, + useEffect, + useMemo, + useRef, +} from "react"; + +interface ReleaseNotesPanelProps { + // Notes are looked up for this exact version only. + version: string; + // Collapsed previews the top bullets; expanded scrolls the full notes. + open: boolean; + // Desktop updater's body, used only if CHANGELOG.md has no section here. + fallbackMarkdown?: string | null; + releaseNotesUrl?: string | null; + className?: string; +} + +const NOTES_LINK_CLASS = + "shrink-0 whitespace-nowrap text-ui-11 font-medium text-foreground underline underline-offset-2"; + +function NotesMessage({ + children, + action, +}: { + children: ReactNode; + action?: ReactNode; +}): ReactElement { + return ( + <div className="flex items-center justify-between gap-2 px-1 py-2"> + <p className="text-ui-11 text-muted-foreground">{children}</p> + {action} + </div> + ); +} + +function ChangelogLink({ href }: { href: string }): ReactElement { + return ( + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className={NOTES_LINK_CLASS} + data-testid="update-release-notes-link" + > + Open changelog + </a> + ); +} + +export function ReleaseNotesPanel({ + version, + open, + fallbackMarkdown = null, + releaseNotesUrl = null, + className, +}: ReleaseNotesPanelProps): ReactElement | null { + // Fetched with the popup: the collapsed preview needs the notes too. + const { state, notes, retry } = useReleaseNotes({ version, enabled: true }); + const scrollRef = useRef<HTMLElement | null>(null); + + // The fallback stands in for "no section in the changelog", which the hook + // reports as ready. An error is retryable, and the desktop fallback is the + // updater's static blurb, so taking it there would hide Retry until cache expiry. + const source = notes?.matched + ? notes.markdown + : state === "error" + ? null + : (fallbackMarkdown ?? null); + // Notes target the repository, so relative links must point back at it. + const markdown = useMemo( + () => (source === null ? null : resolveChangelogLinks(source)), + [source], + ); + + // Notes that are only a code block or a table preview as nothing. + const preview = useMemo( + () => (markdown === null ? null : releaseNotesPreview(markdown)), + [markdown], + ); + + // Start at the top on expand, and again once async notes land. + useEffect(() => { + if (open && markdown && scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [open, markdown]); + + // Caller's URL wins: the API returns only the generic changelog, while the + // desktop banner passes this version's release page. + const notesUrl = releaseNotesUrl ?? notes?.releaseNotesUrl; + const link = notesUrl ? <ChangelogLink href={notesUrl} /> : null; + + // Nothing previewable yet or ever: keep the collapsed popup compact. + if ( + !open && + (!markdown || + state === "loading" || + state === "idle" || + preview?.items.length === 0) + ) { + return null; + } + + return ( + <div + className={cn("mt-3 flex min-h-0 flex-col", className)} + data-testid="update-release-notes-panel" + data-notes-state={state} + data-notes-version={version} + data-notes-open={open} + > + {/* borderless fill, lighter than the card in dark mode */} + <div className="flex min-h-0 flex-col rounded-[14px] bg-muted/40 px-3 py-1 dark:bg-white/[0.06]"> + {markdown ? ( + open ? ( + <section + ref={scrollRef} + // biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard-scrollable region + tabIndex={0} + aria-label={`Release notes for version ${version}`} + // Long notes scroll here instead of pushing the buttons off screen. + className="hover-scrollbar max-h-64 min-h-0 flex-1 overflow-y-auto overscroll-contain py-3 pr-1" + data-testid="update-release-notes-scroll" + > + <MarkdownPreview + markdown={markdown} + // Streamdown ships headings at mt-6 and code at text-sm, and + // clears max-width on descendants, so rescale and re-cap both. + className="max-h-none overflow-visible border-0 bg-transparent p-0 text-ui-11 [&_[data-streamdown=link-safety-modal]>*]:max-w-md [&_img]:h-auto [&_img]:max-w-full [&>*:first-child]:mt-0 [&>*>*:first-child]:mt-0 [&_code]:text-[0.92em] [&_h1]:mt-4 [&_h1]:font-heading [&_h1]:text-ui-13 [&_h2]:mt-4 [&_h2]:font-heading [&_h2]:text-ui-13 [&_h3]:mt-4 [&_h3]:font-heading [&_h3]:text-ui-11 [&_pre]:text-[0.92em]" + /> + {notes?.truncated ? ( + <p className="mt-2 text-ui-10 text-muted-foreground/80"> + Notes truncated. See the full changelog. + </p> + ) : null} + </section> + ) : ( + <ReleaseNotesSummary preview={preview} /> + ) + ) : ( + <NotesStatus + state={state} + version={version} + link={link} + retry={retry} + /> + )} + </div> + {open && markdown && link ? ( + <div className="mt-2 flex justify-end px-1">{link}</div> + ) : null} + </div> + ); +} + +/** Collapsed view: the first few bullets, one line each where possible. */ +function ReleaseNotesSummary({ + preview, +}: { + preview: ReturnType<typeof releaseNotesPreview> | null; +}): ReactElement | null { + if (preview === null || preview.items.length === 0) { + return null; + } + const { items, remaining } = preview; + + return ( + <ul + className="space-y-1 py-2 pr-1" + data-testid="update-release-notes-summary" + > + {items.map((item, index) => ( + <li + // Two releases can carry the same bullet text, so index is the key. + key={`${index}-${item.lead}`} + className="flex gap-1.5 text-ui-11 leading-snug text-muted-foreground" + > + <span aria-hidden="true" className="text-muted-foreground/60"> + • + </span> + <span className="line-clamp-2 min-w-0"> + {/* lead sentence carries the change */} + <span className="font-medium text-foreground">{item.lead}</span> + {item.rest ? <span> {item.rest}</span> : null} + </span> + </li> + ))} + {remaining > 0 ? ( + <li className="pl-3 text-ui-10 text-muted-foreground/70"> + +{remaining} more + </li> + ) : null} + </ul> + ); +} + +function NotesStatus({ + state, + version, + link, + retry, +}: { + state: ReturnType<typeof useReleaseNotes>["state"]; + version: string; + link: ReactNode; + retry: () => void; +}): ReactElement { + if (state === "loading" || state === "idle") { + return <NotesMessage>Loading release notes...</NotesMessage>; + } + + if (state === "error") { + return ( + <NotesMessage + action={ + // The changelog page may be reachable when the lookup is not. + <span className="flex shrink-0 items-center gap-3"> + <button + type="button" + onClick={retry} + className={NOTES_LINK_CLASS} + data-testid="update-release-notes-retry" + > + Retry + </button> + {link} + </span> + } + > + Could not load release notes. + </NotesMessage> + ); + } + + // Matched nothing: link out rather than show another release's notes. + return ( + <NotesMessage action={link}> + No release notes published for {version} yet. + </NotesMessage> + ); +} diff --git a/studio/frontend/src/components/web/update-banner.tsx b/studio/frontend/src/components/web/update-banner.tsx index d8ae92bf5f..f36f5ec3cd 100644 --- a/studio/frontend/src/components/web/update-banner.tsx +++ b/studio/frontend/src/components/web/update-banner.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Button } from "@/components/ui/button"; +import { ReleaseNotesPanel } from "@/components/update/release-notes-panel"; import { type DeviceType, usePlatformStore } from "@/config/env"; import { useWebUpdateCheck } from "@/hooks/use-web-update-check"; import { isTauri } from "@/lib/api-base"; @@ -40,6 +41,7 @@ export function WebUpdateBanner({ const deviceType = usePlatformStore((s) => s.deviceType); const installCmd = installCommandForDevice(deviceType); const [copiedVersion, setCopiedVersion] = useState<string | null>(null); + const [notesVersion, setNotesVersion] = useState<string | null>(null); const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => { @@ -68,6 +70,8 @@ export function WebUpdateBanner({ } const copied = status != null && copiedVersion === status.latestVersion; + // Keyed by version so a new offer collapses the panel. + const notesOpen = status != null && notesVersion === status.latestVersion; return ( <AnimatePresence> @@ -78,13 +82,14 @@ export function WebUpdateBanner({ exit={{ opacity: 0, y: 8, scale: 0.97 }} transition={{ duration: 0.35, ease: EASE_OUT_QUART }} className={cn( + // Wider than the other overlays: notes preview plus three buttons. positioned - ? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]" - : "pointer-events-auto w-full", + ? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]" + : "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col", )} data-testid="web-update-banner" > - <div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]"> + <div className="relative flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]"> <button type="button" onClick={dismiss} @@ -127,22 +132,33 @@ export function WebUpdateBanner({ </div> </div> + <ReleaseNotesPanel + version={status.latestVersion} + open={notesOpen} + releaseNotesUrl={RELEASE_NOTES_URL} + className="min-h-0 flex-1" + /> + + {/* one row at one type size; wraps only on narrow viewports */} <div className="mt-4 flex flex-wrap items-center justify-between gap-y-2"> - <a - href={RELEASE_NOTES_URL} - target="_blank" - rel="noopener noreferrer" - className="-ml-2 whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground transition-colors hover:bg-muted" - data-testid="web-update-release-notes-link" + <Button + size="sm" + variant="ghost" + className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground" + onClick={() => + setNotesVersion(notesOpen ? null : status.latestVersion) + } + aria-expanded={notesOpen} + data-testid="web-update-release-notes-toggle" > - Release notes - </a> + {notesOpen ? "Hide release notes" : "Show release notes"} + </Button> {/* wrap + right-align so buttons stack instead of clipping on very narrow banners */} <div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2"> <Button size="sm" variant="ghost" - className="h-auto rounded-full px-3 py-2 text-ui-13 font-medium text-foreground" + className="h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground" onClick={snooze} data-testid="web-update-snooze-button" > @@ -151,7 +167,7 @@ export function WebUpdateBanner({ <Button size="sm" // -mr optically aligns the filled pill's edge with the card padding - className="-mr-1 h-auto rounded-full px-3.5 py-2 text-ui-13" + className="-mr-1 h-auto whitespace-nowrap rounded-full px-3 py-2 text-ui-13" onClick={handleCopyCommand} data-testid="web-update-copy-button" > diff --git a/studio/frontend/src/features/hub/download-manager/download-manager-panel.tsx b/studio/frontend/src/features/hub/download-manager/download-manager-panel.tsx index 3e5a86a879..d68aaa5ab3 100644 --- a/studio/frontend/src/features/hub/download-manager/download-manager-panel.tsx +++ b/studio/frontend/src/features/hub/download-manager/download-manager-panel.tsx @@ -201,8 +201,10 @@ export function DownloadManagerPanel({ className={cn( // Standalone: anchor bottom-right. In a shared stack (positioned=false) // flow as a right-aligned row so overlays stack instead of overlapping. + // min-h-0 there: a flex item's min-height defaults to auto, so the capped + // stack would squeeze the update card instead of this list. "pointer-events-none", - positioned ? "fixed bottom-4 right-4 z-50" : "flex justify-end", + positioned ? "fixed bottom-4 right-4 z-50" : "flex min-h-0 justify-end", )} > {collapsed ? ( @@ -229,7 +231,7 @@ export function DownloadManagerPanel({ </TooltipContent> </Tooltip> ) : ( - <div className="hub-download-panel pointer-events-auto w-[min(400px,calc(100vw-2rem))] overflow-hidden"> + <div className="hub-download-panel pointer-events-auto flex min-h-0 w-[min(400px,calc(100vw-2rem))] flex-col overflow-hidden"> <div className="flex items-center gap-2 border-b border-foreground/[0.07] py-2 pl-4 pr-3"> <span className="min-w-0 flex-1 truncate text-ui-12p5 font-semibold text-foreground"> {headerLabel} diff --git a/studio/frontend/src/hooks/use-release-notes.ts b/studio/frontend/src/hooks/use-release-notes.ts new file mode 100644 index 0000000000..7b1392fdf6 --- /dev/null +++ b/studio/frontend/src/hooks/use-release-notes.ts @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { authFetch, hasAuthToken } from "@/features/auth"; +import { apiUrl } from "@/lib/api-base"; +import { useCallback, useEffect, useRef, useState } from "react"; + +// Keyed to one exact version, so a new update never pairs with older notes. +export interface ReleaseNotes { + version: string; + markdown: string | null; + matched: boolean; + truncated: boolean; + source: string | null; + releaseNotesUrl: string | null; + // Set when the lookup itself failed, as opposed to a version with no notes. + error: string | null; +} + +export type ReleaseNotesState = "idle" | "loading" | "ready" | "error"; + +// Desktop auto-auth installs its token after first paint, so a startup popup can +// ask before one exists. Wait briefly rather than fail. +const AUTH_POLL_MS = 250; +const AUTH_POLL_LIMIT = 40; + +interface UseReleaseNotesOptions { + version: string | null | undefined; + enabled?: boolean; +} + +type ApiObject = Record<string, unknown>; + +function stringOrNull(value: ApiObject, key: string): string | null { + const field = value[key]; + return typeof field === "string" && field.length > 0 ? field : null; +} + +function toReleaseNotes(value: unknown, version: string): ReleaseNotes | null { + if (!value || typeof value !== "object") { + return null; + } + const payload = value as ApiObject; + const notesVersion = stringOrNull(payload, "version"); + // A response for another version is not usable here. + if (notesVersion !== version) { + return null; + } + const markdown = stringOrNull(payload, "markdown"); + return { + version, + markdown, + matched: payload.matched === true && markdown !== null, + truncated: payload.truncated === true, + source: stringOrNull(payload, "source"), + releaseNotesUrl: stringOrNull(payload, "release_notes_url"), + error: stringOrNull(payload, "error"), + }; +} + +async function fetchReleaseNotes( + version: string, + refresh = false, +): Promise<ReleaseNotes | null> { + const query = `version=${encodeURIComponent(version)}${refresh ? "&refresh=true" : ""}`; + // authFetch, not fetch: an expired token is refreshed and retried. + const res = await authFetch(apiUrl(`/api/studio/release-notes?${query}`)); + if (!res.ok) { + throw new Error(`Release notes request failed: ${res.status}`); + } + + return toReleaseNotes(await res.json(), version); +} + +export function useReleaseNotes({ + version, + enabled = true, +}: UseReleaseNotesOptions) { + const [state, setState] = useState<ReleaseNotesState>("idle"); + const [notes, setNotes] = useState<ReleaseNotes | null>(null); + // Version the current state belongs to; a change invalidates it. + const requestedVersionRef = useRef<string | null>(null); + // Identifies one request, so an earlier response cannot overwrite a later one. + const requestIdRef = useRef(0); + + const load = useCallback((target: string, refresh = false) => { + requestedVersionRef.current = target; + requestIdRef.current += 1; + const requestId = requestIdRef.current; + setState("loading"); + setNotes(null); + fetchReleaseNotes(target, refresh) + .then((next) => { + // A newer request owns the state now. + if (requestIdRef.current !== requestId) { + return; + } + setNotes(next); + // A reported failure is retryable; "no notes for this version" is not. + const failed = !next || (!next.matched && next.error !== null); + setState(failed ? "error" : "ready"); + }) + .catch(() => { + if (requestIdRef.current === requestId) { + setNotes(null); + setState("error"); + } + }); + }, []); + + useEffect(() => { + if (!enabled || !version || requestedVersionRef.current === version) { + return; + } + if (hasAuthToken()) { + load(version); + return; + } + let attempts = 0; + const timer = window.setInterval(() => { + attempts += 1; + if (hasAuthToken() || attempts >= AUTH_POLL_LIMIT) { + window.clearInterval(timer); + // Out of patience: load anyway so the panel settles on retry. + load(version); + } + }, AUTH_POLL_MS); + return () => window.clearInterval(timer); + }, [enabled, version, load]); + + const retry = useCallback(() => { + if (version) { + requestedVersionRef.current = null; + // Bypass the cached remote failure, or retry waits for it to expire. + load(version, true); + } + }, [version, load]); + + // Never hand back another version's notes: state lags `version` by a render. + const matchesVersion = notes !== null && notes.version === version; + return { + state: notes !== null && !matchesVersion ? "loading" : state, + notes: matchesVersion ? notes : null, + retry, + }; +} diff --git a/studio/frontend/src/hooks/use-tauri-update.ts b/studio/frontend/src/hooks/use-tauri-update.ts index 8ebb4d2980..196e3cea2b 100644 --- a/studio/frontend/src/hooks/use-tauri-update.ts +++ b/studio/frontend/src/hooks/use-tauri-update.ts @@ -21,6 +21,8 @@ export type UpdateStatus = export interface UpdateInfo { version: string; currentVersion: string; + // Backend release this build pins; CHANGELOG.md is keyed by it, not the SemVer. + pypiVersion?: string; body?: string; date?: string; } @@ -42,10 +44,17 @@ interface DesktopUpdatePolicy { interface ManualUpdateInfo { version: string; currentVersion: string; + pypiVersion?: string | null; body?: string; date?: string; } +/** `pypi_version` from latest.json, which the updater passes through raw. */ +function rawPypiVersion(raw: Record<string, unknown>): string | undefined { + const value = raw.pypi_version; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + export interface RetainedUpdateFailure { error: string; phase: UpdatePhase; @@ -162,6 +171,7 @@ export function useTauriUpdate(isExternalServer = false) { setInfo({ version: manualUpdate.version, currentVersion: manualUpdate.currentVersion, + pypiVersion: manualUpdate.pypiVersion ?? undefined, body: manualUpdate.body, date: manualUpdate.date, }); @@ -197,6 +207,7 @@ export function useTauriUpdate(isExternalServer = false) { setInfo({ version: update.version, currentVersion: update.currentVersion, + pypiVersion: rawPypiVersion(update.rawJson), body: update.body, date: update.date, }); @@ -384,10 +395,13 @@ export function useTauriUpdate(isExternalServer = false) { }); } + // Install target for Linux packages that cannot self-update. const manualReleaseUrl = updatePolicy.mode === "manual_linux_package" && info ? manualReleasePageUrl(updatePolicy, info.version) : null; + // Release page for the offered version, on every platform, for the notes link. + const releasePageUrl = info ? manualReleasePageUrl(updatePolicy, info.version) : null; return { status, @@ -401,6 +415,7 @@ export function useTauriUpdate(isExternalServer = false) { isExternalServer, updatePolicyMode: updatePolicy.mode, manualReleaseUrl, + releasePageUrl, installUpdate, retryUpdate, skipAndRestart, diff --git a/studio/frontend/src/lib/changelog-links.ts b/studio/frontend/src/lib/changelog-links.ts new file mode 100644 index 0000000000..16b3d3c8bc --- /dev/null +++ b/studio/frontend/src/lib/changelog-links.ts @@ -0,0 +1,664 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * A relative link in CHANGELOG.md means "somewhere in the Unsloth repository", + * but inside Studio it would resolve against Studio's own origin. Rewriting to + * absolute repository URLs makes them behave the way GitHub renders the file. + */ + +import { + type CodeSpan, + codeSpans, + insideSpan, +} from "@/lib/markdown-code-spans"; +import { commentClosesBelow } from "@/lib/markdown-inline-comments"; +import { + EMPTY_LIST_STATE, + type ListState, + NO_QUOTE, + type QuoteState, + containerContent, + hiddenStructure, + indentWidth, + itemContent, + openLists, + quoteDepth, + quoteState, +} from "@/lib/markdown-list-columns"; + +const LINK_BASE = "https://github.com/unslothai/unsloth/blob/main/"; +const IMAGE_BASE = "https://raw.githubusercontent.com/unslothai/unsloth/main/"; + +// Inline `](dest)` plus the `[label]: dest` reference form. The destination is +// either <bracketed> or runs to whitespace or the closing paren. +const NESTED_LABEL = String.raw`((?:[^[\]\\]|\\.|\[(?:[^[\]\\]|\\.)*\])*)`; +// Only ASCII punctuation is escapable, so the backslash in `a\ b.md` is an +// ordinary character of the destination and the space still ends it. +const ESCAPABLE = String.raw`[!-/:-@[-\`{-~]`; +const DESTINATION_CHAR = String.raw`\\${ESCAPABLE}|[^\s()]`; +// A destination may hold balanced parentheses, and a path may nest them, so +// `[x](((draft)).md)` points at `((draft)).md`. An expression cannot count, so +// pairs are unrolled to the depth cmark stops at, which is what GitHub renders. +const MAX_DESTINATION_NESTING = 32; + +/** A balanced parenthesised run nested up to `depth` levels deep. */ +function nestedParens(depth: number): string { + let group = String.raw`\((?:${DESTINATION_CHAR})*\)`; + for (let left = depth - 1; left > 0; left -= 1) { + group = String.raw`\((?:${DESTINATION_CHAR}|${group})*\)`; + } + return group; +} + +const BALANCED_DESTINATION = String.raw`(?:${DESTINATION_CHAR}|${nestedParens(MAX_DESTINATION_NESTING)})*`; +const PLAIN_DESTINATION = String.raw`(?:${DESTINATION_CHAR})*`; +// A balanced pair counts only while a `)` or a title still closes the link +// after it, or swallowing it would invent a link across lines. +const CLOSES_LINK = String.raw`(?=[ \t]*[)'"])`; +// A destination that runs out of line has its closer below it, the line being +// only part of the link. One stopping short of a closer is no destination at all, +// so `[x](a b.md)` and `[x](a(b.md)` stay plain text and keep the paths they name. +const CLOSES_OR_ENDS_LINE = String.raw`(?=[ \t]*(?:[)'"]|$))`; +const INLINE_TARGET = new RegExp( + String.raw`(!?)\[${NESTED_LABEL}\]\(\s*(<[^<>\n]*>|${BALANCED_DESTINATION}${CLOSES_LINK}|${PLAIN_DESTINATION}${CLOSES_OR_ENDS_LINE})`, + "g", +); +const REFERENCE_TARGET = /^( {0,3}\[((?:[^[\]\\]|\\.)*)\]:\s*)(<[^<>\n]*>|\S+)/; +// `![alt][label]`, `![label][]` and `![label]`: a definition they point at +// has to resolve to the raw file, not to its page on GitHub. +const IMAGE_REFERENCE = + /!\[((?:[^[\]\\]|\\.)*)\](?:\[((?:[^[\]\\]|\\.)*)\]|(?!\())/g; +const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; +// Four columns past the container start indented code, unless a paragraph is +// open. Inside a list item that is measured from the item's content column, so a +// link indented under a bullet is prose and still resolves. +const INDENTED_CODE_INDENT = 4; +// CommonMark type 1 HTML blocks show their contents verbatim. +const RAW_HTML_OPEN = /^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)/i; +const RAW_HTML_CLOSE = /<\/(pre|script|style|textarea)\s*>/i; +// Type 6 and 7 blocks are literal too and run to the next blank line, not to a +// closing tag, so `<details>` holds Markdown only after a blank line. Type 7 (any +// other complete tag alone on a line) cannot interrupt a paragraph. +const HTML_BLOCK_OPEN = /^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)/; +const HTML_ATTRIBUTE = + "(?:\\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\\s*=\\s*(?:[^\\s\"'=<>`]+|'[^']*'|\"[^\"]*\"))?)"; +const HTML_TAG_ONLY_LINE = new RegExp( + `^ {0,3}(?:<[a-zA-Z][a-zA-Z0-9-]*${HTML_ATTRIBUTE}*\\s*/?>|</[a-zA-Z][a-zA-Z0-9-]*\\s*>)\\s*$`, +); +const HTML_BLOCK_TAGS = new Set( + `address article aside base basefont blockquote body caption center col colgroup + dd details dialog dir div dl dt fieldset figcaption figure footer form frame + frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu + menuitem nav noframes ol optgroup option p param search section summary table + tbody td tfoot th thead title tr track ul`.split(/\s+/), +); +// Lines that are blocks in their own right, so no paragraph is open after. +const BLOCK_LINE = + /^ {0,3}(?:#{1,6}([ \t]|$)|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$|>|=+[ \t]*$)/; +// A definition is a block of its own but may not interrupt a paragraph, so it +// ends the one above only when there is none to continue. It opens none either, +// or consecutive definitions could never start (spec 0.31.2 section 4.7). Same +// rule as `_LINK_DEFINITION` in the backend's `after_paragraph`. +const LINK_DEFINITION = /^ {0,3}\[(?:[^[\]\\]|\\.)+\]:/; +const LINE_ENDINGS = /\r\n?/g; +// A scheme, a protocol-relative host, or a fragment: already absolute enough. +// `//` needs a host after it, so `///docs` stays a repository path. +const ABSOLUTE = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/[^/]|#)/; + +const COMMENT_OPEN = "<!--"; +const COMMENT_CLOSE = "-->"; +const COMMENT_BLOCK_OPEN = /^ {0,3}<!--/; + +/** + * `line` with its commented spans blanked, and whether a comment block is still + * open below it. Commented content renders as nothing, so it holds no fence, + * block or code span. Lengths are preserved so offsets still line up. + * + * Only a comment that starts a line opens a block (CommonMark type 2), and only + * that runs on to the line holding `-->`, tail included. One written mid-sentence + * is inline raw HTML belonging to its paragraph, so its `-->` may arrive on a + * later line and only the text up to it is hidden. `closesBelow` says one does; + * without it the opener is ordinary text, so a note merely mentioning `<!--` must + * not hide the links below it. + * + * "Starts a line" is read inside the container, so `blockOpen` comes from the + * item's content rather than the raw line. + */ +function maskComments( + line: string, + inComment: boolean, + runOn: boolean, + closesBelow: boolean, + blockOpen: boolean, +): [string, boolean, boolean] { + if (inComment) { + // The closing line belongs to the block, tail included. + return [" ".repeat(line.length), !line.includes(COMMENT_CLOSE), false]; + } + if (runOn) { + const closed = line.indexOf(COMMENT_CLOSE); + if (closed < 0) { + return [" ".repeat(line.length), false, true]; + } + // Only up to the closer: the tail is the paragraph's own text again. + const resumed = closed + COMMENT_CLOSE.length; + return maskInline(line, resumed, closesBelow); + } + if (blockOpen) { + // `<!-->` and `<!--->` are complete comments, so the closer may overlap the + // opener; searching past it would blank the rest of the file. + return [" ".repeat(line.length), !line.includes(COMMENT_CLOSE), false]; + } + return maskInline(line, 0, closesBelow); +} + +/** `maskComments` from `from`, where no comment block is open. */ +function maskInline( + line: string, + from: number, + closesBelow: boolean, +): [string, boolean, boolean] { + let out = " ".repeat(from); + let index = from; + // Scanned only once an opener turns up. Spans are ordered and disjoint and each + // opener sits at or past the last, so the search resumes rather than restarts. + let spans: CodeSpan[] | null = null; + let cursor = 0; + while (index < line.length) { + const start = line.indexOf(COMMENT_OPEN, index); + if (start < 0) { + return [out + line.slice(index), false, false]; + } + spans ??= codeSpans(line); + while (cursor < spans.length && (spans[cursor]?.end ?? 0) <= start) { + cursor += 1; + } + // A delimiter inside inline code is literal, not a comment opener. + const span = spans[cursor]; + if (span !== undefined && span.start <= start) { + out += line.slice(index, span.end); + index = span.end; + continue; + } + // `<!-->` and `<!--->` are complete comments, so the closer may overlap. + const close = line.indexOf(COMMENT_CLOSE, start + 2); + if (close < 0) { + if (closesBelow) { + // The paragraph carries the comment on, so the line from the opener is + // inside it, and so is the line below. + return [ + out + line.slice(index, start) + " ".repeat(line.length - start), + false, + true, + ]; + } + // Nothing closes it at all, so the renderer shows it as ordinary text. + return [out + line.slice(index), false, false]; + } + out += line.slice(index, start); + out += " ".repeat(close + COMMENT_CLOSE.length - start); + index = close + COMMENT_CLOSE.length; + } + return [out, false, false]; +} + +/** + * Whether `line` is written outside the container an open block belongs to. A + * fence and an HTML block hold no lazy continuation line, so content left of the + * item, or outside the quote, ends the block with its container. A raw block or + * comment inside a list item ends on a blank line too: the item takes the break, + * so what follows is a block of the item's own. + */ +function leavesContainer( + line: string, + quotes: number, + column: number, + blockQuotes: number, + rawInItem: boolean, +): boolean { + if (quotes < blockQuotes) { + return true; + } + if (!line.trim()) { + return rawInItem; + } + return column > 0 && indentWidth(line) < column; +} + +/** True if `line` starts a CommonMark type 6 or type 7 HTML block. */ +function opensHtmlBlock(line: string, afterParagraph: boolean): boolean { + const named = HTML_BLOCK_OPEN.exec(line); + if (named && HTML_BLOCK_TAGS.has((named[1] ?? "").toLowerCase())) { + return true; + } + return !afterParagraph && HTML_TAG_ONLY_LINE.test(line); +} + +/** A reference label as CommonMark compares them. */ +function label(text: string): string { + return text.trim().replace(/\s+/g, " ").toLowerCase(); +} + +const NEEDS_BRACKETS = /[()\s]/; +// `\(` in a destination is a literal paren. Only ASCII punctuation is escapable, +// so the backslash in `docs\alpha.md` is part of the path and has to survive. +const ESCAPE = new RegExp(String.raw`\\(${ESCAPABLE})`, "g"); +// A URL parser reads a backslash as a path separator, so `docs\a.md` would +// resolve to `docs/a.md`. Encode it first, the way a renderer normalises it. +const BACKSLASH = /\\/g; +// Only spaces and tabs may follow a closing fence. +const NON_SPACE = /[^ \t]/; +const LEADING_SLASHES = /^\/+/; + +function absolute(target: string, image: boolean): string { + const base = image ? IMAGE_BASE : LINK_BASE; + const trimmed = target.trim().replace(ESCAPE, "$1"); + if (!trimmed || ABSOLUTE.test(trimmed)) { + return target; + } + try { + // A leading slash means the repository root, not the site root, so append + // it to the base instead of replacing the base path. + const resolved = new URL( + trimmed.replace(LEADING_SLASHES, "").replace(BACKSLASH, "%5C"), + base, + ).toString(); + // `../` can climb out of the repository: leave those alone. + return resolved.startsWith(base) ? resolved : target; + } catch { + return target; + } +} + +/** True when `index` is escaped by an odd run of backslashes. */ +function isEscaped(line: string, index: number): boolean { + let slashes = 0; + while (line[index - 1 - slashes] === "\\") { + slashes += 1; + } + return slashes % 2 === 1; +} + +function unwrap(target: string): string { + return target.startsWith("<") && target.endsWith(">") + ? target.slice(1, -1) + : target; +} + +/** The destination as it goes back into the line. */ +function wrap(resolved: string, original: string): string { + const bracketed = original.startsWith("<") && original.endsWith(">"); + return bracketed || (resolved !== original && NEEDS_BRACKETS.test(resolved)) + ? `<${resolved}>` + : resolved; +} + +/** Rewrites one line's link and image targets, leaving code spans alone. */ +function rewriteLine( + line: string, + imageLabels: Set<string>, + spans: CodeSpan[], + base: number, + isDefinition: boolean, +): string { + const reference = isDefinition ? REFERENCE_TARGET.exec(line) : null; + if (reference) { + const target = reference[3] ?? ""; + const resolved = absolute( + unwrap(target), + imageLabels.has(label(reference[2] ?? "")), + ); + const rest = line.slice(reference[0].length); + return `${reference[1]}${wrap(resolved, target)}${rest}`; + } + + INLINE_TARGET.lastIndex = 0; + return line.replace(INLINE_TARGET, (match, bang, text, target, offset) => { + // `\\[` is a literal bracket, so the expression is not a link. + const opener = offset + (bang ? 1 : 0); + if (insideSpan(spans, base + offset) || isEscaped(line, opener)) { + return match; + } + // `\\!` is a literal mark, so what follows is a link, not an image. + const image = bang === "!" && !isEscaped(line, offset); + const resolved = absolute(unwrap(target), image); + // A badge nests an image inside a link, so the label is rewritten too. + const inner = text.includes("](") + ? rewriteLine(text, imageLabels, codeSpans(text), 0, false) + : text; + return `${bang}[${inner}](${wrap(resolved, target)}`; + }); +} + +interface Classified { + // Lines the renderer shows as Markdown, by index. + text: number[]; + // Same lines, blanked where the renderer shows code, for span scanning. + masked: string; + // Lines where a `[label]: dest` definition can start. + definition: Set<number>; + // Document ranges the renderer hides inside HTML comments. + comments: CodeSpan[]; +} + +/** + * Sorts lines into Markdown and code, masking the code so a span cannot pair + * across it. Offsets are preserved, so a mask span sits where it does in the doc. + */ +function classify(lines: string[]): Classified { + const text: number[] = []; + const definition = new Set<number>(); + const masked: string[] = []; + let openFence: string | null = null; + let inRawHtml = false; + let inHtmlBlock = false; + // Where the open block was written: the content column of the item it belongs + // to, 0 at document level, plus the blockquotes it sits inside. Only one is ever + // open, and none holds a lazy continuation line, so a line left of the item or + // outside the quote ends the block with its container. + let blockColumn = 0; + let blockQuotes = 0; + let inComment = false; + // True while an inline comment opened above runs on into this line, carried by + // the paragraph holding it. + let runOn = false; + const closesBelow = commentClosesBelow(lines); + let inCode = false; + let afterParagraph = false; + let quote: QuoteState = NO_QUOTE; + let lists: ListState = EMPTY_LIST_STATE; + const comments: CodeSpan[] = []; + let offset = 0; + + // The line as list tracking sees it: blank wherever nothing renders. Taken + // with the paragraph state from the line above, as the renderer would. + const track = (structural: string, above: QuoteState): void => { + lists = openLists(structural, lists, afterParagraph, above.quoted); + }; + // Where a block just opened sits, read after the opener closed the items it + // is dedented out of, so it belongs to the container it is really in. + const startBlock = (quotes: number): void => { + blockColumn = lists.columns.at(-1) ?? 0; + blockQuotes = quotes; + }; + const endBlock = (): void => { + blockColumn = 0; + blockQuotes = 0; + }; + + lines.forEach((original, index) => { + const start = offset; + offset += original.length + 1; + // The quote state from the line above, which is what list tracking asks + // about. Only plain text below rewrites it, so every block returning early + // leaves no quoted paragraph open behind it. + const above = quote; + quote = NO_QUOTE; + // A fence, comment or HTML block runs only to the end of the container it was + // written in, so a line dedented out of that item or outside that quote + // closes both. + const quotes = quoteDepth(original); + let inBlock = openFence !== null || inRawHtml || inHtmlBlock || inComment; + if ( + inBlock && + leavesContainer( + original, + quotes, + blockColumn, + blockQuotes, + (inRawHtml || inComment) && blockColumn > 0 && blockQuotes === 0, + ) + ) { + openFence = null; + inRawHtml = false; + inHtmlBlock = false; + inComment = false; + endBlock(); + inBlock = false; + } + // Read from the container the line is written in, so a fence three columns + // past a nested bullet or behind a quote marker still opens one. A block + // already open keeps only its own quote stripped, or a deeper marker in it + // would read as a closer. + const container = containerContent( + original, + lists, + inBlock ? blockQuotes : quotes, + ); + // A comment cannot open a fence and a fence hides a comment opener, so resolve + // them in that order or a hidden delimiter opens a phantom fence. An opener is + // read past a marker on the same line too, since a fence written as an item's + // first content opens inside it. Only an opener: fenced content is literal and + // a closer carries no marker. + const fenceSource = inComment + ? null + : FENCE.exec( + openFence === null + ? itemContent(container, afterParagraph) + : container, + ); + if (inRawHtml) { + track("", above); + inRawHtml = !RAW_HTML_CLOSE.test(container); + if (!inRawHtml) { + endBlock(); + } + masked.push(" ".repeat(original.length)); + afterParagraph = false; + return; + } + if (inHtmlBlock) { + track("", above); + // Only a blank line ends a type 6 or 7 block, so nothing inside one is a + // fence or a link. A bare quote marker holds nothing, so it ends one too. + inHtmlBlock = !!container.trim(); + if (!inHtmlBlock) { + endBlock(); + } + masked.push(" ".repeat(original.length)); + afterParagraph = false; + return; + } + const fence = fenceSource; + if (fence) { + // A fence renders as nothing, but its indent still closes an item. + track(original, above); + const marker = fence[1] ?? ""; + if (openFence === null) { + // A backtick fence's info string may not contain a backtick. + openFence = + marker[0] !== "`" || !(fence[2] ?? "").includes("`") ? marker : null; + if (openFence === null) { + text.push(index); + masked.push(original); + afterParagraph = true; + return; + } + startBlock(quotes); + } else if ( + // A closer matches the opening character and carries nothing after it. + marker[0] === openFence[0] && + marker.length >= openFence.length && + !NON_SPACE.test(fence[2] ?? "") + ) { + openFence = null; + endBlock(); + } + masked.push(" ".repeat(original.length)); + afterParagraph = false; + return; + } + if (openFence !== null) { + track("", above); + // Fenced content is literal, so a comment opener in it is not one. + masked.push(" ".repeat(original.length)); + return; + } + // A block already open owns this line, so it is content rather than a block + // written at the column it happens to start in. + const hidden = inComment; + const carried = runOn; + // A comment is an HTML block too, so one written as a list item's first + // content opens inside that item exactly as a fence does: read past a marker + // on the same line and from its container's column, not the line's margin. + const opensComment = + !(hidden || carried) && + COMMENT_BLOCK_OPEN.test(itemContent(container, afterParagraph)); + // Only now, outside every fence, does a comment hide what follows. + const [line, stillInComment, stillRunOn] = maskComments( + original, + inComment, + runOn, + closesBelow[index + 1] ?? false, + opensComment, + ); + inComment = stillInComment; + runOn = stillRunOn; + // A line an inline comment runs on into is still a line of the paragraph + // that carries it: only its text is hidden, never its block structure. + const structure = carried ? original : line; + // The same container reading as above, now the comments are masked. A comment + // blanks its own line, so that line is read as written: the block renders as + // nothing, but the item it is the content of still opens. + const source = opensComment ? original : line; + const visible = containerContent(source, lists, quotes); + // An HTML block written as a list item's first content opens inside that item, + // as a fence does, so an opener is read past a marker on the same line. The + // marker survives into the structural line, so its item is still tracked. + const content = itemContent(visible, afterParagraph); + const marker = + content === visible + ? "" + : source.slice(0, source.length - content.length); + // Taken before an HTML opener is hidden: it renders as nothing, but its indent + // still closes a list item it sits left of. A comment or a <pre> keeps only its + // column and marker, since the text it hides is not Markdown and opens no list. + const opensRaw = !carried && RAW_HTML_OPEN.test(content); + track( + !(hidden || carried) && (opensRaw || !line.trim()) + ? hiddenStructure(original, marker) + : structure, + above, + ); + // Read once the opener has closed the items it is dedented out of, so the + // comment block belongs to the item it is really written inside. + if (inComment !== hidden) { + if (inComment) { + startBlock(quotes); + } else { + endBlock(); + } + } + for (let at = 0; at < line.length; at += 1) { + if (line[at] === " " && original[at] !== " ") { + const from = at; + while (at < line.length && line[at] === " " && original[at] !== " ") { + at += 1; + } + comments.push({ start: start + from, end: start + at, content: "" }); + } + } + if (opensRaw) { + inRawHtml = !RAW_HTML_CLOSE.test(content.replace(RAW_HTML_OPEN, "")); + if (inRawHtml) { + startBlock(quotes); + } + masked.push(" ".repeat(line.length)); + afterParagraph = false; + return; + } + if (!carried && content.trim() && opensHtmlBlock(content, afterParagraph)) { + inHtmlBlock = true; + startBlock(quotes); + masked.push(" ".repeat(line.length)); + afterParagraph = false; + return; + } + const blank = !structure.trim(); + // Measured from the innermost open item's content column, not the margin: + // four spaces under "- Details:" is a paragraph, not a code block. + const column = lists.columns.at(-1) ?? 0; + const indented = indentWidth(structure) - column >= INDENTED_CODE_INDENT; + // Indented code starts only outside a paragraph and runs to a dedent. + if (inCode) { + inCode = blank || indented; + } else { + inCode = !afterParagraph && !blank && indented; + } + if (inCode) { + masked.push(" ".repeat(line.length)); + afterParagraph = false; + return; + } + // A definition cannot interrupt a paragraph. + if (!afterParagraph) { + definition.add(index); + } + text.push(index); + masked.push(line); + afterParagraph = + !blank && + !BLOCK_LINE.test(structure) && + (afterParagraph || !LINK_DEFINITION.test(structure)); + quote = quoteState(structure, above.inQuote); + }); + + return { text, masked: masked.join("\n"), definition, comments }; +} + +/** Absolute repository URLs for every relative link and image in `markdown`. */ +export function resolveChangelogLinks(markdown: string): string { + // The desktop updater body arrives with CRLF, which would hide fences. + const lines = markdown.replace(LINE_ENDINGS, "\n").split("\n"); + const { text, masked, definition, comments } = classify(lines); + // Scanned over the whole document, so a span may cross a line break. Commented + // ranges join them: the renderer shows neither, so a link in one is not + // followable and rewriting it would only mutate hidden text. + const spans = [...codeSpans(masked), ...comments].sort( + (a, b) => a.start - b.start, + ); + + // Offset of each line in the document, to place matches inside it. + const offsets: number[] = []; + let cursor = 0; + for (const line of lines) { + offsets.push(cursor); + cursor += line.length + 1; + } + + // Only images resolve against the raw host, so collect the image labels + // before rewriting any definition. + const imageLabels = new Set<string>(); + for (const index of text) { + const line = lines[index] ?? ""; + IMAGE_REFERENCE.lastIndex = 0; + for ( + let match = IMAGE_REFERENCE.exec(line); + match !== null; + match = IMAGE_REFERENCE.exec(line) + ) { + // An escaped mark makes it a link, so its definition stays a page URL. + if ( + insideSpan(spans, (offsets[index] ?? 0) + match.index) || + isEscaped(line, match.index) + ) { + continue; + } + const explicit = match[2] ?? ""; + imageLabels.add(label(explicit.trim() ? explicit : (match[1] ?? ""))); + } + } + + const rewritten = [...lines]; + for (const index of text) { + rewritten[index] = rewriteLine( + lines[index] ?? "", + imageLabels, + spans, + offsets[index] ?? 0, + definition.has(index), + ); + } + return rewritten.join("\n"); +} diff --git a/studio/frontend/src/lib/markdown-code-spans.ts b/studio/frontend/src/lib/markdown-code-spans.ts new file mode 100644 index 0000000000..537aabb1ab --- /dev/null +++ b/studio/frontend/src/lib/markdown-code-spans.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * CommonMark code spans: a backtick run closes only on an equal-length run. + * That needs lookbehind, which older Safari rejects, so runs are scanned by hand. + */ + +export interface CodeSpan { + // Offsets of the whole span, delimiters included. + start: number; + end: number; + // Between the delimiters, with the one space of padding removed. + content: string; +} + +function runLength(text: string, index: number): number { + let end = index; + while (text[end] === "`") { + end += 1; + } + return end - index; +} + +/** True when `index` is escaped by an odd run of backslashes. */ +function escaped(text: string, index: number): boolean { + let slashes = 0; + while (text[index - 1 - slashes] === "\\") { + slashes += 1; + } + return slashes % 2 === 1; +} + +/** CommonMark drops one space of padding, so `` ` a ` `` renders as "a". */ +function stripPadding(content: string): string { + if ( + content.length > 1 && + content.startsWith(" ") && + content.endsWith(" ") && + content.trim() !== "" + ) { + return content.slice(1, -1); + } + return content; +} + +/** Every code span in `text`, in order. Unclosed runs are ordinary text. */ +export function codeSpans(text: string): CodeSpan[] { + const spans: CodeSpan[] = []; + let index = 0; + + while (index < text.length) { + if (text[index] !== "`" || escaped(text, index)) { + index += 1; + continue; + } + const ticks = runLength(text, index); + const contentStart = index + ticks; + + let cursor = contentStart; + let closed = false; + while (cursor < text.length) { + // Escapes do not apply inside a span, so a run after a backslash closes it. + if (text[cursor] !== "`") { + cursor += 1; + continue; + } + const candidate = runLength(text, cursor); + if (candidate === ticks) { + spans.push({ + start: index, + end: cursor + ticks, + content: stripPadding(text.slice(contentStart, cursor)), + }); + index = cursor + ticks; + closed = true; + break; + } + cursor += candidate; + } + if (!closed) { + // Nothing closes this run: it is literal text, carry on after it. + index = contentStart; + } + } + return spans; +} + +/** Replaces every code span with `park(content)`, leaving the rest as is. */ +export function parkCodeSpans( + text: string, + park: (content: string) => string, +): string { + const spans = codeSpans(text); + if (spans.length === 0) { + return text; + } + let out = ""; + let cursor = 0; + for (const span of spans) { + out += text.slice(cursor, span.start) + park(span.content); + cursor = span.end; + } + return out + text.slice(cursor); +} + +/** True when `index` falls inside one of `spans`, which are in order. */ +export function insideSpan(spans: CodeSpan[], index: number): boolean { + let low = 0; + let high = spans.length - 1; + while (low <= high) { + const mid = (low + high) >> 1; + const span = spans[mid]; + if (span === undefined || index < span.start) { + high = mid - 1; + } else if (index >= span.end) { + low = mid + 1; + } else { + return true; + } + } + return false; +} diff --git a/studio/frontend/src/lib/markdown-inline-comments.ts b/studio/frontend/src/lib/markdown-inline-comments.ts new file mode 100644 index 0000000000..33bbfddc31 --- /dev/null +++ b/studio/frontend/src/lib/markdown-inline-comments.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * An HTML comment written mid-sentence is inline raw HTML, not a block, so it + * belongs to its paragraph: the `-->` may arrive on a later line of that same + * paragraph and everything between renders as nothing, while past the paragraph + * the `<!--` is ordinary text. Both changelog scanners share that answer here. + * + * The backend needs none of it: a heading closes the paragraph it sits under, so + * no heading can ever land inside one of these comments. + */ + +import { interruptsParagraph } from "@/lib/markdown-list-columns"; + +const COMMENT_CLOSE = "-->"; +// A line that cannot be more of the paragraph above it: blank, or a block that +// may interrupt one. Leading punctuation is not one: `-->` alone is the ordinary +// multiline close and a continuation may open with emphasis, so reading either as +// a break leaves the comment unclosed and its text on show. Indented code and link +// definitions are absent: neither may interrupt a paragraph (spec 0.31.2 4.4, 4.7). +const BLANK = /^[ \t]*$/; +const ATX_HEADING = /^ {0,3}#{1,6}([ \t]|$)/; +const FENCE = /^ {0,3}(?:`{3,}|~{3,})/; +const THEMATIC_BREAK = + /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/; +// A row of `=` or `-` alone makes the paragraph above it a setext heading, ending it. +const SETEXT_UNDERLINE = /^ {0,3}(?:=+|-+)[ \t]*$/; +// A tag, comment or declaration at the start of a line. HTML block types 1 to 6 +// interrupt a paragraph; type 7 does not, but reading one as a break only leaves +// the opener as plain text, which is what a leading `<` has always meant here. +const HTML_LINE = /^ {0,3}</; + +/** Whether `line` starts a block of its own rather than continuing a paragraph. */ +function startsBlock(line: string): boolean { + return ( + BLANK.test(line) || + ATX_HEADING.test(line) || + FENCE.test(line) || + THEMATIC_BREAK.test(line) || + SETEXT_UNDERLINE.test(line) || + HTML_LINE.test(line) || + // Blockquote, or a list item with content: the rule the other scanners share. + interruptsParagraph(line) + ); +} + +/** + * For each line, whether a `-->` is reachable without leaving the paragraph it + * starts in. Read at `index + 1` it answers whether an inline comment opened on + * `index` and left unclosed there is a comment at all. + */ +export function commentClosesBelow(lines: string[]): boolean[] { + const closes: boolean[] = new Array(lines.length + 1).fill(false); + for (let at = lines.length - 1; at >= 0; at -= 1) { + const line = lines[at] ?? ""; + closes[at] = + !startsBlock(line) && + (line.includes(COMMENT_CLOSE) || (closes[at + 1] ?? false)); + } + return closes; +} diff --git a/studio/frontend/src/lib/markdown-list-columns.ts b/studio/frontend/src/lib/markdown-list-columns.ts new file mode 100644 index 0000000000..761cdfac54 --- /dev/null +++ b/studio/frontend/src/lib/markdown-list-columns.ts @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * CommonMark measures a block's indentation from its container, not the left + * margin: four spaces at document level and four under a bullet mean different + * things. Tracking the open items lets both changelog scanners ask "is this + * indented code?" the way a renderer would. + * + * Ported from `_open_lists` in studio/backend/utils/changelog.py so the three + * scanners classify a line the same way. + */ + +/** The open list items, innermost last, by the column their content starts. */ +export interface ListState { + columns: number[]; + // True while the innermost item has had no content since its marker. + emptyItem: boolean; +} + +export const EMPTY_LIST_STATE: ListState = { columns: [], emptyItem: false }; + +// The marker needs whitespace after it, so `2.0` is a version, not an item. +const LIST_ITEM = /^[ \t]*([-*+]|\d{1,9}[.)])([ \t]+|$)/; +const THEMATIC_BREAK = + /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/; +const BLOCK_QUOTE = /^ {0,3}>/; +const QUOTE_MARKER = /^ {0,3}>[ \t]?/; +// Blocks that are not paragraph text, so they cannot continue one lazily. +const PARAGRAPH_TEXT = /^ {0,3}(?![-*+>]([ \t]|$)|\d{1,9}[.)]([ \t]|$))\S/; +// Blocks that break into an open paragraph, closing it rather than continuing +// it. A link reference definition is not one of them. +const INTERRUPTS = + /^ {0,3}(?:#{1,6}([ \t]|$)|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$)/; +const FENCE = /^ {0,3}(?:`{3,}|~{3,})/; +const HTML_BLOCK_OPEN = /^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)/; +const HTML_BLOCK_TAGS = new Set( + `address article aside base basefont blockquote body caption center col colgroup + dd details dialog dir div dl dt fieldset figcaption figure footer form frame + frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu + menuitem nav noframes ol optgroup option p param search section summary table + tbody td tfoot th thead title tr track ul`.split(/\s+/), +); +// Content indented more than this after a marker is an indented code block, so +// the item's content starts one column past the marker instead. +const MAX_ITEM_PADDING = 4; +// Columns past its container at which a line becomes an indented code block. +const INDENTED_CODE = 4; +// Stands in for a line the renderer hides. `#` is a block of its own, so list +// tracking reads it like a comment: never a marker, never a lazy continuation. +const HIDDEN_BLOCK = "#"; +const LEADING_SPACE = /^[ \t]*/; + +/** + * `line` as list tracking sees it once the renderer hides its text. A comment or + * raw HTML block renders nothing but is still a block at its own column, so it + * closes the items it sits left of. Only the indentation survives: what the block + * hides is not Markdown and must not open a list. `marker` is the part opening + * the item the block is content of, which survives too. Ported from + * `_hidden_structure` on the backend. + */ +export function hiddenStructure(line: string, marker = ""): string { + if (marker) { + return `${marker}${HIDDEN_BLOCK}`; + } + const indent = LEADING_SPACE.exec(line)?.[0] ?? ""; + return line.trim() ? `${indent}${HIDDEN_BLOCK}` : ""; +} + +/** Columns of leading whitespace, counting a tab to the next stop of four. */ +export function indentWidth(line: string): number { + let width = 0; + for (const char of line) { + if (char === " ") { + width += 1; + } else if (char === "\t") { + width += 4 - (width % 4); + } else { + break; + } + } + return width; +} + +/** + * Whether `line` starts a block that can break into an open paragraph. A quote + * marker always can; a list item only with content, an ordered one only at 1. + * Anything else is text of the paragraph it appears to interrupt. + */ +export function interruptsParagraph(line: string): boolean { + if (BLOCK_QUOTE.test(line)) { + return true; + } + const item = THEMATIC_BREAK.test(line) ? null : LIST_ITEM.exec(line); + if (item === null) { + return false; + } + const marker = item[1] ?? ""; + if (!line.slice(item[0].length).trim()) { + return false; + } + const ordered = marker.endsWith(".") || marker.endsWith(")"); + return !ordered || marker.slice(0, -1) === "1"; +} + +/** + * Whether a marker-shaped `line` is really text of the paragraph above. Only a + * marker inside the paragraph's own item interrupts it; one to the left closes + * that item and opens a sibling. A quote owns the paragraph its lines hold, so a + * marker outside the quote opens a list of its own. + */ +export function lazyMarker( + line: string, + state: ListState, + afterParagraph: boolean, + quoted: boolean, +): boolean { + const item = THEMATIC_BREAK.test(line) ? null : LIST_ITEM.exec(line); + const columns = state.columns; + const inside = + columns.length === 0 || indentWidth(line) >= (columns.at(-1) ?? 0); + return ( + item !== null && + afterParagraph && + !quoted && + inside && + !interruptsParagraph(line) + ); +} + +/** `columns` with every item whose content starts past `indent` closed. */ +function dropDeeper(columns: number[], indent: number): number[] { + let open = columns.length; + while (open > 0 && (columns[open - 1] ?? 0) > indent) { + open -= 1; + } + return open === columns.length ? columns : columns.slice(0, open); +} + +/** `line` with up to `columns` columns of leading whitespace removed. */ +function stripIndent(line: string, columns: number): string { + let width = 0; + let index = 0; + while (index < line.length && width < columns) { + const char = line[index]; + if (char !== " " && char !== "\t") { + break; + } + width += char === " " ? 1 : 4 - (width % 4); + index += 1; + } + return line.slice(index); +} + +/** + * Whether `line` can continue a paragraph it is indented out of. Only plain text + * can: a heading, fence, break or HTML block starts a block of its own, closing + * the item instead. An underline is not one: it may never be lazy, so `===` left + * of an open item is more of the item's paragraph. Nor is a definition, a block + * of its own that may not interrupt a paragraph. A row of dashes still closes the + * item: `INTERRUPTS` reads three or more as the thematic break they are. + */ +function mayBeLazy(line: string): boolean { + const named = HTML_BLOCK_OPEN.exec(line); + // Types 1 to 6 interrupt a paragraph, so a `<div>` left of an open item closes + // it. Type 7 cannot, and is deliberately excluded. + const htmlBlock = + named !== null && HTML_BLOCK_TAGS.has((named[1] ?? "").toLowerCase()); + return ( + PARAGRAPH_TEXT.test(line) && + !INTERRUPTS.test(line) && + !FENCE.test(line) && + !htmlBlock + ); +} + +/** + * Whether `line` reads as more of a paragraph open in its container, measured + * from `column` where that container's content starts: four columns past it the + * line is indented code, which may not interrupt a paragraph, so indentation + * alone never closes the one above. + */ +export function continuesParagraph(line: string, column: number): boolean { + const inner = stripIndent(line, column); + return indentWidth(inner) >= INDENTED_CODE || mayBeLazy(inner); +} + +/** `line` with up to `depth` blockquote markers removed, and how many went. */ +function stripQuotes(line: string, depth: number): [string, number] { + let rest = line; + let removed = 0; + let marker = removed < depth ? QUOTE_MARKER.exec(rest) : null; + while (marker !== null) { + rest = rest.slice(marker[0].length); + removed += 1; + marker = removed < depth ? QUOTE_MARKER.exec(rest) : null; + } + return [rest, removed]; +} + +/** What a blockquote line holds, with its markers stripped. */ +function quoteContent(line: string): string { + return stripQuotes(line, Number.POSITIVE_INFINITY)[0]; +} + +/** How many blockquotes `line` is written inside. */ +export function quoteDepth(line: string): number { + return stripQuotes(line, Number.POSITIVE_INFINITY)[1]; +} + +/** + * `line` as the container it is written in sees it, with `quotes` blockquote + * markers and the open item's content column removed. CommonMark measures a block + * from its container, not the margin (spec 0.31.2 sections 5.1, 5.2), so `> ~~~` + * and a fence under a nested bullet are openers despite sitting more than three + * columns in. + */ +export function containerContent( + line: string, + state: ListState, + quotes: number, +): string { + const [inner] = stripQuotes(line, quotes); + if (quotes > 0) { + // A list inside a quote is the quote's own; this tracker follows document + // level only, so its columns do not apply here. + return inner; + } + const columns = dropDeeper(state.columns, indentWidth(inner)); + return stripIndent(inner, columns.at(-1) ?? 0); +} + +/** + * `line` read from the content column of a list item that opens on it. A block + * written as an item's first content sits inside that item, so ``- ``` `` opens a + * fence even though its marker is not within three columns of the container (spec + * 0.31.2 section 5.2). Padding is capped the way `openLists` caps it, or + * ``- ``` `` would read as a fence rather than the indented code it is. A + * marker the paragraph above swallows opens no item, so its line is returned + * whole, as is one four columns past its container. + */ +export function itemContent(line: string, afterParagraph: boolean): string { + if ( + indentWidth(line) >= INDENTED_CODE || + (afterParagraph && !interruptsParagraph(line)) + ) { + return line; + } + const item = THEMATIC_BREAK.test(line) ? null : LIST_ITEM.exec(line); + if (item === null) { + return line; + } + const padding = indentWidth(item[2] ?? ""); + // Over-indented content starts one column past the marker; the rest of the + // padding is the content's own indentation. + const over = padding > MAX_ITEM_PADDING ? padding - 1 : 0; + return `${" ".repeat(over)}${line.slice(item[0].length)}`; +} + +/** Whether a blockquote owns the paragraph the line below could continue. */ +export interface QuoteState { + // True while a quoted paragraph is open, so plain text below is more of it. + inQuote: boolean; + // True whenever that paragraph is the quote's rather than the document's. + quoted: boolean; +} + +export const NO_QUOTE: QuoteState = { inQuote: false, quoted: false }; + +/** + * The quote state after `line`, given the state after the line above and the + * content column of the item `line` sits in. A quote owns the paragraph its own + * lines hold, so a marker written outside the quote opens a list of its own + * rather than reading as more of that paragraph. Ported from `in_quote` tracking + * in changelog.py. + */ +export function quoteState( + line: string, + inQuote: boolean, + column = 0, +): QuoteState { + if (BLOCK_QUOTE.test(line)) { + // An empty quote holds no paragraph, so the line below starts a new one. + return { inQuote: mayBeLazy(quoteContent(line)), quoted: true }; + } + const open = inQuote && continuesParagraph(line, column); + return { inQuote: open, quoted: open }; +} + +/** + * `columns` with every item `line` is written to the left of closed. Read inside + * the container the item sits in, not from the margin: a line that only looks + * dedented there is lazy text of the item's paragraph, leaving the item open. + */ +function closeDedented( + columns: number[], + line: string, + indent: number, + afterParagraph: boolean, +): number[] { + let open = columns.length; + while (open > 0 && (columns[open - 1] ?? 0) > indent) { + const outer = open > 1 ? (columns[open - 2] ?? 0) : 0; + if (afterParagraph && continuesParagraph(line, outer)) { + break; + } + open -= 1; + } + return open === columns.length ? columns : columns.slice(0, open); +} + +/** + * The list items still open after `line`. A dedented line closes an item unless + * it is a lazy paragraph continuation. A new marker nests under a deeper column + * and replaces a sibling. `quoted` marks a paragraph the blockquote above owns: + * a marker outside the quote is not text of it, so it opens a list of its own. + */ +export function openLists( + line: string, + state: ListState, + afterParagraph: boolean, + quoted = false, +): ListState { + let columns = state.columns; + if (!line.trim()) { + // A blank line leaves the list open, unless the item is still empty: an + // item may begin with one blank line, and later content is outside it. + return { + columns: state.emptyItem ? columns.slice(0, -1) : columns, + emptyItem: false, + }; + } + const indent = indentWidth(line); + const item = THEMATIC_BREAK.test(line) ? null : LIST_ITEM.exec(line); + const empty = item !== null && !line.slice(item[0].length).trim(); + if (lazyMarker(line, state, afterParagraph, quoted)) { + // A lazy continuation or an underline, so the open items are untouched. + return state; + } + columns = closeDedented(columns, line, indent, afterParagraph); + // Four columns past its container the marker is an indented code block, or + // lazy text of the paragraph above it, so it opens no list of its own. + if (item === null || indent - (columns.at(-1) ?? 0) >= INDENTED_CODE) { + return { columns, emptyItem: false }; + } + const marker = item[1] ?? ""; + let padding = indentWidth(item[2] ?? ""); + if (padding === 0 || padding > MAX_ITEM_PADDING) { + // An empty or over-indented item still holds one column of content. + padding = 1; + } + // A sibling marker replaces the item it lines up with. + return { + columns: [...dropDeeper(columns, indent), indent + marker.length + padding], + emptyItem: empty, + }; +} diff --git a/studio/frontend/src/lib/release-notes-preview.ts b/studio/frontend/src/lib/release-notes-preview.ts new file mode 100644 index 0000000000..97441b7cbb --- /dev/null +++ b/studio/frontend/src/lib/release-notes-preview.ts @@ -0,0 +1,1005 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Top changelog bullets, shown in the collapsed update popup. +import { codeSpans, parkCodeSpans } from "@/lib/markdown-code-spans"; +import { commentClosesBelow } from "@/lib/markdown-inline-comments"; +import { + EMPTY_LIST_STATE, + type ListState, + NO_QUOTE, + type QuoteState, + hiddenStructure, + indentWidth, + itemContent, + openLists, + quoteState, +} from "@/lib/markdown-list-columns"; + +export const RELEASE_NOTES_PREVIEW_ITEMS = 4; +const PREVIEW_ITEM_MAX_CHARS = 120; +// Bullets indented past the shallowest one are nested detail, not headlines. +const NESTED_INDENT_TOLERANCE = 1; +const TAB_WIDTH = 4; +// Four spaces starts an indented code block in Markdown. +const INDENTED_CODE_INDENT = 4; + +// At most three leading spaces: deeper is indented code, not a fence. +const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; +// An ATX heading needs a space, tab or line end after the marker, as in +// _HEADING_PATTERN. `\s` would match a non-breaking space and eat prose, and a +// bare `##` is an empty heading that still ends a bullet. +const HEADING = /^#{1,6}(?:[ \t]|$)/; +const BULLET = /^(?:[-*+]|(\d{1,9})[.)])[ \t]+(.*)$/; +// At most three leading spaces, as everywhere else: deeper is indented code, +// so a quoted line inside a code sample cannot reach the collector. +const BLOCKQUOTE = /^ {0,3}>[ \t]?/; +// A GFM delimiter cell is hyphens with an optional alignment colon each side. +const TABLE_DELIMITER_CELL = /^:?-+:?$/; +// "- - -" and "***" are horizontal rules, not bullets and not notes. +const THEMATIC_BREAK = + /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/; +// Destinations may escape or balance parentheses, and labels may nest one +// level so `[![alt](img)](link)` still resolves. +const DESTINATION = "\\((?:\\\\.|[^()\\\\]|\\([^()]*\\))*\\)"; +const LABEL = "((?:[^\\[\\]\\\\]|\\\\.|\\[(?:[^\\[\\]\\\\]|\\\\.)*\\])*)"; +const IMAGE = new RegExp(`!\\[${LABEL}\\]${DESTINATION}`, "g"); +const LINK = new RegExp(`\\[${LABEL}\\]${DESTINATION}`, "g"); +// Reference forms: `[text][label]`, `[text][]` and the shortcut `[text]`. +const IMAGE_REFERENCE = new RegExp(`!\\[${LABEL}\\](?:\\[([^\\]]*)\\])?`, "g"); +const LINK_REFERENCE = new RegExp(`\\[${LABEL}\\](?:\\[([^\\]]*)\\])?`, "g"); +// A definition line renders as nothing at all. +const DEFINITION = /^ {0,3}\[((?:[^\[\]\\]|\\.)+)\]:/; +// CommonMark: a backslash escapes ASCII punctuation. +const ESCAPE = /\\([!-/:-@[-`{-~])/g; +// Private-use sentinels park code spans, so document text cannot contain them. +const SENTINELS = /[\uE000\uE001]/g; +const LINE_ENDINGS = /\r\n?/g; +const TABS = /\t/g; +// Real tags only: a name character must follow "<", so a version constraint +// like "Support Python <3.15 and >3.9" keeps its operators. +const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; +// <https://x> and <a@b.c> are Markdown autolinks: keep the text they render. +const AUTOLINK = /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s<>]*|[^\s<>@]+@[^\s<>@]+)>/g; +// CommonMark type 1 HTML blocks render literally until a closing tag, which +// the spec says need not be the one that opened the block. +const RAW_HTML_OPEN = /^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)/i; +const RAW_HTML_CLOSE = /<\/(pre|script|style|textarea)\s*>/i; +// Types 3 to 5 (processing instructions, declarations, CDATA) are literal too, +// each ending on its own delimiter. Comments open mid-line, handled separately. +const RAW_BLOCKS: [RegExp, RegExp][] = [ + [RAW_HTML_OPEN, RAW_HTML_CLOSE], + [/^ {0,3}<\?/, /\?>/], + [/^ {0,3}<!\[CDATA\[/, /\]\]>/], + // A declaration needs an uppercase letter, so `<!note` stays ordinary text. + [/^ {0,3}<![A-Z]/, />/], +]; +// Type 6 and 7 blocks run to the next blank line, so `<details>` holds Markdown +// only after one. Type 7 (any complete tag alone) cannot interrupt a paragraph. +const HTML_BLOCK_OPEN = /^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>]|$)/; +const HTML_ATTRIBUTE = + "(?:\\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\\s*=\\s*(?:[^\\s\"'=<>`]+|'[^']*'|\"[^\"]*\"))?)"; +const HTML_TAG_ONLY_LINE = new RegExp( + `^ {0,3}(?:<[a-zA-Z][a-zA-Z0-9-]*${HTML_ATTRIBUTE}*\\s*/?>|</[a-zA-Z][a-zA-Z0-9-]*\\s*>)\\s*$`, +); +const HTML_BLOCK_TAGS = new Set( + `address article aside base basefont blockquote body caption center col colgroup + dd details dialog dir div dl dt fieldset figcaption figure footer form frame + frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu + menuitem nav noframes ol optgroup option p param search section summary table + tbody td tfoot th thead title tr track ul`.split(/\s+/), +); +// Only spaces and tabs may follow a closing fence. +const NON_SPACE = /[^ \t]/; +const HEADING_LINE = /^ {0,3}#{1,6}(?:[ \t]|$)/; +const COMMENT_BLOCK_OPEN = /^ {0,3}<!--/; +const COMMENT_OPEN = "<!--"; +const COMMENT_CLOSE = "-->"; +// Paired emphasis only. Underscores inside identifiers are literal, so +// UNSLOTH_DISABLE_UPDATE_CHECK keeps its name. +const BOLD_STAR = /\*\*(?=\S)([\s\S]*?\S)\*\*/g; +const BOLD_UNDERSCORE = /(^|[^\w])__(?=\S)([\s\S]*?\S)__(?=[^\w]|$)/g; +const ITALIC_STAR = /\*(?=\S)([^*\n]*?\S)\*/g; +const ITALIC_UNDERSCORE = /(^|[^\w])_(?=\S)([^_\n]*?\S)_(?=[^\w]|$)/g; +const BACKTICK = /`/g; +// A closer is a run of the same length, so `` `x` `` keeps its backticks. +// Streamdown renders `AT&T` as "AT&T", so the preview decodes entities too. +const NAMED_ENTITIES: Record<string, string> = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: "\u00a0", +}; +const ENTITY = /&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/g; +const PARKED = /\uE000(\d+)\uE001/g; +const WHITESPACE = /\s+/g; +// Sentence end followed by something that actually starts a sentence. +const SENTENCE_BREAK = /[.!?]\s+(?=["'“‘]?[A-Z0-9])/g; +const TRAILING_WORD = /(\S+)$/; +// A period here ends an abbreviation, not the sentence. +const ABBREVIATIONS = new Set([ + "e.g.", + "i.e.", + "etc.", + "vs.", + "cf.", + "approx.", + "no.", + "fig.", + "al.", + "dr.", + "mr.", + "mrs.", + "ms.", + "prof.", + "inc.", + "ltd.", + "st.", + "jr.", + "sr.", +]); +const INITIAL = /^[A-Za-z]\.$/; +const MIN_LEAD_CHARS = 12; + +/** Strip tags until stable, so a removal cannot re-form a tag. */ +function stripHtmlTags(text: string): string { + let out = text; + let previous: string; + do { + previous = out; + out = out.replace(HTML_TAG, ""); + } while (out !== previous); + return out; +} + +export interface ReleaseNotesPreviewItem { + // Leading sentence, highlighted in the preview. + lead: string; + // Rest of the bullet, de-emphasised. Empty for single-sentence bullets. + rest: string; +} + +export interface ReleaseNotesPreview { + items: ReleaseNotesPreviewItem[]; + // Bullets past the preview limit, for a "+N more" affordance. + remaining: number; +} + +interface Bullet { + text: string; + indent: number; +} + +/** Whether a reference points at a definition the document actually has. */ +function definedLabel( + labels: Set<string> | undefined, + reference: string | undefined, + text: string, +): boolean { + if (labels === undefined) { + return false; + } + const label = (reference?.trim() ? reference : text) + .trim() + .replace(WHITESPACE, " ") + .toLowerCase(); + return labels.has(label); +} + +/** One entity as the character it renders as, or unchanged if unknown. */ +function decodeEntity(match: string, body: string): string { + if (body.startsWith("#")) { + const hex = body[1] === "x" || body[1] === "X"; + const code = Number.parseInt( + hex ? body.slice(2) : body.slice(1), + hex ? 16 : 10, + ); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : match; + } + return NAMED_ENTITIES[body.toLowerCase()] ?? match; +} + +/** Inline markdown stripped to plain text. */ +function toPlainText(markdown: string, labels?: Set<string>): string { + // Park code spans first: their contents are literal and must survive below. + const codes: string[] = []; + const park = (text: string): string => { + codes.push(text); + return `\uE000${codes.length - 1}\uE001`; + }; + // Escaped punctuation is literal too, so `\*not italic\*` keeps its stars. + const parked = parkCodeSpans(markdown, park).replace(ESCAPE, (_match, char) => + park(char), + ); + + return stripHtmlTags( + parked + .replace(AUTOLINK, "$1") + .replace(IMAGE, "") + .replace(LINK, "$1") + .replace(IMAGE_REFERENCE, (match, text, ref) => + definedLabel(labels, ref, text) ? "" : match, + ) + .replace(LINK_REFERENCE, (match, text, ref) => + definedLabel(labels, ref, text) ? text : match, + ), + ) + .replace(BOLD_STAR, "$1") + .replace(BOLD_UNDERSCORE, "$1$2") + .replace(ITALIC_STAR, "$1") + .replace(ITALIC_UNDERSCORE, "$1$2") + .replace(BACKTICK, "") + .replace(ENTITY, decodeEntity) + .replace(PARKED, (_match, index: string) => codes[Number(index)] ?? "") + .replace(WHITESPACE, " ") + .trim(); +} + +function truncate(text: string): string { + if (text.length <= PREVIEW_ITEM_MAX_CHARS) { + return text; + } + const clipped = text.slice(0, PREVIEW_ITEM_MAX_CHARS); + const lastSpace = clipped.lastIndexOf(" "); + return `${(lastSpace > 40 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}...`; +} + +interface ContentLine { + text: string; + indent: number; + // Blockquoted lines are quoted examples, not the release's own bullets. + quoted: boolean; + // Content column of the innermost open list item. CommonMark measures + // indentation from here, so `indent - column` is the real depth. + column: number; +} + +/** + * `line` with its comments removed, whether a comment block stays open, and + * whether an inline comment runs on into the line below. + * + * Only a comment starting a line opens a block, which hides whole lines to the + * one holding `-->`. One written mid-sentence is inline HTML belonging to its + * paragraph, so its `-->` may arrive on a later line and only the text up to it + * is hidden. `closesBelow` says one does; without it the opener is ordinary text + * and hides nothing below. + * + * "Starting a line" is read inside the container, so `blockOpen` comes from the + * item's content rather than the raw line. + */ +function stripCommentSpans( + line: string, + startInComment: boolean, + runOn: boolean, + closesBelow: boolean, + blockOpen: boolean, +): [string, boolean, boolean] { + if (startInComment) { + // The closing line belongs to the block, tail included. + return ["", !line.includes(COMMENT_CLOSE), false]; + } + + let visible = ""; + let index = 0; + if (runOn) { + const closed = line.indexOf(COMMENT_CLOSE); + if (closed === -1) { + return ["", false, true]; + } + // Only up to the closer: the tail is the paragraph's own text again. + index = closed + COMMENT_CLOSE.length; + } else if (blockOpen) { + // `<!-->` and `<!--->` are complete comments, so the closer may overlap the + // opener; searching past it would hide every later release. + return ["", !line.includes(COMMENT_CLOSE), false]; + } + + const spans = codeSpans(line); + while (index < line.length) { + const open = line.indexOf(COMMENT_OPEN, index); + if (open === -1) { + visible += line.slice(index); + break; + } + // A delimiter inside inline code is literal, not a comment opener. + const span = spans.find( + (candidate) => candidate.start <= open && candidate.end > open, + ); + if (span) { + visible += line.slice(index, span.end); + index = span.end; + continue; + } + const close = line.indexOf(COMMENT_CLOSE, open + COMMENT_OPEN.length); + if (close === -1) { + if (closesBelow) { + // The paragraph carries the comment on, so this line and the next are in it. + return [visible + line.slice(index, open), false, true]; + } + // Nothing closes it at all, so the renderer shows it as text. + visible += line.slice(index); + break; + } + visible += line.slice(index, open); + index = close + COMMENT_CLOSE.length; + } + return [visible, false, false]; +} + +/** Strips raw block content. State is the open block's index, or null. */ +function stripRawHtml( + line: string, + openBlock: number | null, +): [string, number | null] { + if (openBlock !== null) { + return RAW_BLOCKS[openBlock]?.[1].test(line) ? ["", null] : ["", openBlock]; + } + // A block only opens at the start of a line; mid-line tags are inline HTML. + for (const [index, [opener, closer]] of RAW_BLOCKS.entries()) { + const open = opener.exec(line); + if (!open) { + continue; + } + const rest = line.slice(open[0].length); + return closer.test(rest) ? ["", null] : ["", index]; + } + return [line, null]; +} + +/** True if `line` starts a CommonMark type 6 or type 7 HTML block. */ +function opensHtmlBlock(line: string, afterParagraph: boolean): boolean { + const named = HTML_BLOCK_OPEN.exec(line); + if (named && HTML_BLOCK_TAGS.has((named[1] ?? "").toLowerCase())) { + return true; + } + return !afterParagraph && HTML_TAG_ONLY_LINE.test(line); +} + +/** + * The line as list tracking sees it. A comment or raw block renders nothing, but + * the line opening one is still a block at its own column, so it closes a list + * item it sits left of. Only the column survives, since the text it hides is not + * Markdown. A line inside a block already open is that block's content, so it + * keeps neither. A marker the hidden block is the content of survives with the + * column, so the item it opens is still tracked. + */ +function structuralLine( + line: string, + visible: string, + hidden: boolean, + marker: string, +): string { + if (visible.trim() || hidden) { + return visible; + } + return hiddenStructure(line, marker); +} + +interface ScanState { + openFence: string | null; + // Content column of the list item the open block belongs to, 0 at document + // level. A fence and an HTML block are scoped to their container, so the item's + // end closes them. Only one of the three is ever open. + blockColumn: number; + inComment: boolean; + // True while an inline comment opened above runs on into this line, carried by + // the paragraph holding it. + runOn: boolean; + inRawHtml: number | null; + inHtmlBlock: boolean; + afterParagraph: boolean; +} + +interface ScannedLine { + // What a reader would see: "" for structure and hidden blocks, null for + // fenced content, which is skipped so it cannot split a bullet. + text: string | null; + // The same line as list tracking sees it: blank wherever nothing renders, + // but kept whole where an indent still closes an open item. + structural: string; +} + +function visibleText( + line: string, + state: ScanState, + closesBelow: boolean, +): ScannedLine { + // Raw HTML first: its contents are literal, so a fence inside it is not one. + if (state.inRawHtml !== null) { + const [after, stillInRaw] = stripRawHtml(line, state.inRawHtml); + state.inRawHtml = stillInRaw; + return { text: after, structural: "" }; + } + if (state.inHtmlBlock) { + // A blank line is the only thing that ends a type 6 or 7 block. + state.inHtmlBlock = line.trim() !== ""; + return { text: "", structural: "" }; + } + // An opener is read past a marker on the same line, since a fence written as a + // list item's first content opens inside it. Only an opener: fenced content is + // literal and a closer carries no marker. + const commented = state.inComment || state.runOn; + const fence = commented + ? null + : FENCE.exec( + state.openFence === null + ? itemContent(line, state.afterParagraph) + : line, + ); + // A backtick fence whose info string holds a backtick is prose, not a fence. + if ( + fence && + (state.openFence !== null || opensFence(fence[1] ?? "", fence[2] ?? "")) + ) { + state.openFence = nextFence( + state.openFence, + fence[1] ?? "", + fence[2] ?? "", + ); + // Hidden from the collector, but its indent still closes an item. + return { text: "", structural: line }; + } + if (state.openFence !== null) { + return { text: null, structural: "" }; + } + return visibleContent(line, state, closesBelow); +} + +/** `visibleText` for a line no fence or HTML block already owns. */ +function visibleContent( + line: string, + state: ScanState, + closesBelow: boolean, +): ScannedLine { + // A block already open owns this line, so it is content rather than a block + // written at the column it happens to start in. + const hidden = state.inComment || state.inRawHtml !== null; + const carried = state.runOn; + // A comment is an HTML block too, so one written as a list item's first content + // opens inside that item exactly as a fence does: read past a marker on the + // same line rather than from the margin. + const content = itemContent(line, state.afterParagraph); + const opensComment = + !(state.inComment || carried) && COMMENT_BLOCK_OPEN.test(content); + // Commented-out notes are not rendered, so they are not previewed either. + const [uncommented, stillInComment, stillRunOn] = stripCommentSpans( + line, + state.inComment, + state.runOn, + closesBelow, + opensComment, + ); + state.inComment = stillInComment; + state.runOn = stillRunOn; + const [visible, stillInRaw] = stripRawHtml(uncommented, state.inRawHtml); + state.inRawHtml = stillInRaw; + // Taken before the opener is hidden: it renders as nothing, but its indent still + // closes a list item it sits left of, and a marker on its line still opens one. + // A line an inline comment runs on into is still a line of the paragraph that + // carries it, so only its text is hidden, never its block structure. + const marker = opensComment + ? line.slice(0, line.length - content.length) + : ""; + const structural = carried + ? line + : structuralLine(line, visible, hidden, marker); + if ( + !carried && + stillInRaw === null && + visible.trim() && + opensHtmlBlock(visible, state.afterParagraph) + ) { + state.inHtmlBlock = true; + return { text: "", structural }; + } + return { text: visible, structural }; +} + +/** + * Marker of a fence the line scanner skipped because it is indented. Only a line + * within three columns of its item's content column is one: deeper than that it + * is an indented code block, which a dedented bullet ends. + */ +function opensDeepFence(line: ContentLine): string | null { + if ( + line.indent < INDENTED_CODE_INDENT || + line.indent - line.column >= INDENTED_CODE_INDENT + ) { + return null; + } + const fence = FENCE.exec(line.text); + return fence ? (fence[1] ?? null) : null; +} + +/** + * True when `line` is the first one outside the deep fence opened with `marker` + * at `column`. A fence inside a list item runs only to the end of that item, so a + * line left of the item's content column closes both, as `fence_column` does on + * the backend. + */ +function endsDeepFence( + marker: string, + column: number, + line: ContentLine, +): boolean { + return line.indent < column || closesDeepFence(marker, line); +} + +/** True when `line` closes the deep fence opened with `marker`. */ +function closesDeepFence(marker: string, line: ContentLine): boolean { + const fence = FENCE.exec(line.text); + if (!fence) { + return false; + } + const closer = fence[1] ?? ""; + return ( + closer[0] === marker[0] && + closer.length >= marker.length && + !NON_SPACE.test(fence[2] ?? "") + ); +} + +/** + * Cells of a GFM table row, or null when the line holds no pipe at all. The + * optional leading and trailing pipes are delimiters, not empty cells, and a + * `\|` is literal text inside one. + */ +function tableCells(text: string): string[] | null { + if (!text.includes("|")) { + return null; + } + const cells: string[] = []; + let cell = ""; + for (let at = 0; at < text.length; at += 1) { + const char = text[at]; + if (char === "\\") { + cell += char + (text[at + 1] ?? ""); + at += 1; + continue; + } + if (char === "|") { + cells.push(cell); + cell = ""; + continue; + } + cell += char; + } + cells.push(cell); + if (cells.length > 1 && text.startsWith("|")) { + cells.shift(); + } + if (cells.length > 1 && text.endsWith("|")) { + cells.pop(); + } + return cells; +} + +/** Width of a GFM delimiter row such as `| --- |:-:|`, or null if not one. */ +function delimiterWidth(text: string): number | null { + const cells = tableCells(text); + if (cells === null || cells.length === 0) { + return null; + } + return cells.every((cell) => TABLE_DELIMITER_CELL.test(cell.trim())) + ? cells.length + : null; +} + +/** + * Line indices that belong to a GFM table. A table needs a header row and a + * delimiter row of the same width, and runs to a blank line or another block. Its + * cells render as a grid, not prose, so the preview drops them like a code block. + */ +function opensTable( + header: ContentLine | undefined, + delimiter: ContentLine | undefined, +): boolean { + if (header === undefined || delimiter === undefined) { + return false; + } + if (!header.text || header.quoted) { + return false; + } + if (header.indent - header.column >= INDENTED_CODE_INDENT) { + return false; + } + const width = delimiterWidth(delimiter.text); + const cells = tableCells(header.text); + return width !== null && cells !== null && cells.length === width; +} + +/** A blank line, a heading or a list marker: where GFM breaks a table. */ +function breaksTable(line: ContentLine | undefined): boolean { + return ( + !line?.text || + line.quoted || + HEADING.test(line.text) || + BULLET.test(line.text) || + line.indent - line.column >= INDENTED_CODE_INDENT + ); +} + +function tableLines(lines: ContentLine[]): Set<number> { + const rows = new Set<number>(); + let at = 0; + while (at + 1 < lines.length) { + if (!opensTable(lines[at], lines[at + 1])) { + at += 1; + continue; + } + rows.add(at); + rows.add(at + 1); + let row = at + 2; + while (row < lines.length && !breaksTable(lines[row])) { + rows.add(row); + row += 1; + } + at = row; + } + return rows; +} + +/** A backtick fence's info string may not contain a backtick. */ +function opensFence(marker: string, rest: string): boolean { + return marker[0] !== "`" || !rest.includes("`"); +} + +function nextFence( + open: string | null, + marker: string, + rest: string, +): string | null { + if (open === null) { + return opensFence(marker, rest) ? marker : null; + } + const closes = + marker[0] === open[0] && + marker.length >= open.length && + // Only spaces or tabs may follow a closer, per CommonMark. + !NON_SPACE.test(rest); + return closes ? null : open; +} + +/** Whether a fence, a raw block, a comment or an HTML block is open. */ +function inBlock(state: ScanState): boolean { + return ( + state.openFence !== null || + state.inRawHtml !== null || + state.inHtmlBlock || + state.inComment + ); +} + +/** + * A fence, comment or HTML block inside a list item runs only to the end of that + * item, so a line dedented out of the item closes both. Lazy continuation reaches + * into none of them, so any content left of the item ends it. + */ +function closeDedentedBlock(line: string, state: ScanState): void { + if (state.blockColumn === 0 || !inBlock(state)) { + return; + } + if (line.trim() && indentWidth(line) < state.blockColumn) { + state.openFence = null; + state.inRawHtml = null; + state.inHtmlBlock = false; + state.inComment = false; + state.blockColumn = 0; + } +} + +/** Ties a block just opened to the list item it is written inside. */ +function scopeBlock( + state: ScanState, + wasInBlock: boolean, + lists: ListState, +): void { + if (!inBlock(state)) { + state.blockColumn = 0; + return; + } + if (!wasInBlock) { + // The opener closed the items it is dedented out of first, so this is the + // column of the item the block really sits in. + state.blockColumn = lists.columns.at(-1) ?? 0; + } +} + +function contentLines(markdown: string): ContentLine[] { + const lines: ContentLine[] = []; + const state: ScanState = { + openFence: null, + blockColumn: 0, + inComment: false, + runOn: false, + inRawHtml: null, + inHtmlBlock: false, + afterParagraph: false, + }; + let lists: ListState = EMPTY_LIST_STATE; + let quote: QuoteState = NO_QUOTE; + + const rawLines = markdown + .split("\n") + .map((raw) => raw.replace(TABS, " ".repeat(TAB_WIDTH))); + const closesBelow = commentClosesBelow(rawLines); + for (const [index, line] of rawLines.entries()) { + closeDedentedBlock(line, state); + const wasInBlock = inBlock(state); + const carried = state.runOn; + const { text: visible, structural } = visibleText( + line, + state, + closesBelow[index + 1] ?? false, + ); + // The quote state from the line above, which is what list tracking asks about. + // Only a line of text below rewrites it, so a fenced, blank or hidden line + // leaves no quoted paragraph open behind it. + const above = quote; + quote = NO_QUOTE; + // Taken with the paragraph state from the line above, as a renderer would. + lists = openLists(structural, lists, state.afterParagraph, above.quoted); + scopeBlock(state, wasInBlock, lists); + if (visible === null) { + continue; + } + if (carried && !visible.trim()) { + // Wholly inside a comment its paragraph carries: no text, and no break. + continue; + } + if (!visible.trim() || THEMATIC_BREAK.test(visible)) { + // A rule separates notes, so it breaks a bullet just like a blank line. + state.afterParagraph = false; + lines.push({ text: "", indent: 0, quoted: false, column: 0 }); + continue; + } + const quoted = BLOCKQUOTE.test(visible); + const stripped = visible.replace(BLOCKQUOTE, ""); + const indent = stripped.length - stripped.trimStart().length; + // A quoted line is measured inside its quote, where the document's open + // list items do not reach. + const column = quoted ? 0 : (lists.columns.at(-1) ?? 0); + // Only ordinary text continues a paragraph; a heading or indented code line + // (four columns past its container, outside a paragraph) ends one. + const startsCode = + !state.afterParagraph && indent - column >= INDENTED_CODE_INDENT; + state.afterParagraph = !HEADING_LINE.test(stripped) && !startsCode; + quote = quoteState(visible, above.inQuote); + lines.push({ text: stripped.trim(), indent, quoted, column }); + } + return lines; +} + +/** + * Split a bullet at its first sentence boundary. Conservative: the next + * sentence must start like one, so "CHANGELOG.md in the repo" is not a break. + */ +function splitLeadSentence(text: string): ReleaseNotesPreviewItem { + SENTENCE_BREAK.lastIndex = 0; + let match = SENTENCE_BREAK.exec(text); + while (match) { + const cut = match.index + 1; + const word = + TRAILING_WORD.exec(text.slice(0, cut))?.[1]?.toLowerCase() ?? ""; + const isAbbreviation = ABBREVIATIONS.has(word) || INITIAL.test(word); + if (!isAbbreviation && cut >= MIN_LEAD_CHARS) { + return { lead: text.slice(0, cut).trim(), rest: text.slice(cut).trim() }; + } + match = SENTENCE_BREAK.exec(text); + } + return { lead: text, rest: "" }; +} + +/** Bullets in document order, plus prose for changelogs written as paragraphs. */ +interface Collector { + bullets: Bullet[]; + prose: string[]; + // Wrapped bullets continue on following lines and belong to one item. + current: Bullet | null; + paragraph: string; + // True while the open paragraph is a quote's, which owns its own text: a + // marker written outside the quote opens a list rather than continuing it. + quotedParagraph: boolean; +} + +function flush(collector: Collector): void { + if (collector.current?.text) { + collector.bullets.push({ + text: truncate(collector.current.text), + indent: collector.current.indent, + }); + } + collector.current = null; + if (collector.paragraph) { + collector.prose.push(truncate(collector.paragraph)); + collector.paragraph = ""; + } + collector.quotedParagraph = false; +} + +function takeBullet( + collector: Collector, + text: string, + line: ContentLine, + labels: Set<string>, +): void { + flush(collector); + const item = toPlainText(text, labels); + // A quoted list is example output: prose at best, never a headline bullet. + if (!line.quoted) { + collector.current = { text: item, indent: line.indent }; + } else if (item) { + collector.prose.push(truncate(item)); + } +} + +function takeText( + collector: Collector, + text: string, + labels: Set<string>, + quoted: boolean, +): void { + const plain = toPlainText(text, labels); + if (!plain) { + return; + } + if (collector.current === null) { + // Wrapped paragraphs render as one block, so preview them as one item. + collector.paragraph = collector.paragraph + ? `${collector.paragraph} ${plain}` + : plain; + collector.quotedParagraph = quoted; + return; + } + collector.current = { + text: `${collector.current.text} ${plain}`, + indent: collector.current.indent, + }; +} + +function collectBullets(markdown: string): { + bullets: Bullet[]; + prose: string[]; +} { + const collector: Collector = { + bullets: [], + prose: [], + current: null, + paragraph: "", + quotedParagraph: false, + }; + + const lines = contentLines(markdown); + const labels = new Set<string>(); + // Skips the same code the pass below skips: a definition-shaped line inside + // code is literal, and a real definition never indents past three spaces. + let labelFence: string | null = null; + let labelColumn = 0; + for (const line of lines) { + if (labelFence !== null && !endsDeepFence(labelFence, labelColumn, line)) { + continue; + } + if (labelFence !== null) { + const dedented = line.indent < labelColumn; + labelFence = null; + // Its own closing line is code too; only a dedented one is a new block. + if (!dedented) { + continue; + } + } + const opener = opensDeepFence(line); + if (opener !== null) { + labelFence = opener; + labelColumn = line.column; + continue; + } + if (line.indent - line.column >= INDENTED_CODE_INDENT) { + continue; + } + const definition = DEFINITION.exec(line.text); + if (definition) { + labels.add( + (definition[1] ?? "").trim().replace(WHITESPACE, " ").toLowerCase(), + ); + } + } + + const tables = tableLines(lines); + let deepFence: string | null = null; + let deepColumn = 0; + for (const [index, line] of lines.entries()) { + if (!line.text || HEADING.test(line.text)) { + flush(collector); + continue; + } + // A table renders as a grid, no more previewable than a code block, and it + // ends whatever came before it. + if (tables.has(index)) { + flush(collector); + continue; + } + // A link reference definition renders as nothing. + if (collector.current === null && DEFINITION.test(line.text)) { + continue; + } + // A fence indented past three spaces belongs to a list item, so the line + // scanner missed it. Its contents are code either way. + if (deepFence !== null && !endsDeepFence(deepFence, deepColumn, line)) { + continue; + } + if (deepFence !== null) { + const dedented = line.indent < deepColumn; + deepFence = null; + // Its own closing line is code too; only a dedented one is a new block. + if (!dedented) { + continue; + } + } + const opener = opensDeepFence(line); + if (opener !== null) { + deepFence = opener; + deepColumn = line.column; + continue; + } + // An indented code block renders as code, so a "- cmd" line in one is not + // a bullet. Inside an open bullet or paragraph it is just a wrapped line. + const insideBlock = + collector.current !== null || collector.paragraph !== ""; + if (!insideBlock && line.indent - line.column >= INDENTED_CODE_INDENT) { + continue; + } + const bullet = BULLET.exec(line.text); + // Only an ordered list starting at 1 may interrupt a paragraph, so "2. Restart + // Studio" under prose is prose. A list item is not a paragraph. + const interrupts = + collector.current === null && + collector.paragraph !== "" && + !collector.quotedParagraph; + if ( + bullet && + !(interrupts && bullet[1] !== undefined && bullet[1] !== "1") + ) { + takeBullet(collector, bullet[2] ?? "", line, labels); + continue; + } + takeText(collector, line.text, labels, line.quoted); + } + flush(collector); + + return { bullets: collector.bullets, prose: collector.prose }; +} + +/** + * Top-level bullets of a release section, in document order. Nested bullets are + * detail and are skipped; prose is used when a release has no bullets. + */ +export function releaseNotesPreview( + markdown: string | null | undefined, + limit: number = RELEASE_NOTES_PREVIEW_ITEMS, +): ReleaseNotesPreview { + if (!markdown) { + return { items: [], remaining: 0 }; + } + + // The updater body arrives with CRLF; sentinels would collide with parking. + const text = markdown.replace(LINE_ENDINGS, "\n").replace(SENTINELS, ""); + const { bullets, prose } = collectBullets(text); + // Shallowest bullet defines top level, so a uniformly indented list previews. + const baseIndent = bullets.reduce( + (min, bullet) => Math.min(min, bullet.indent), + Number.POSITIVE_INFINITY, + ); + const topLevel = bullets + .filter((bullet) => bullet.indent <= baseIndent + NESTED_INDENT_TOLERANCE) + .map((bullet) => bullet.text); + + const source = topLevel.length > 0 ? topLevel : prose; + return { + items: source.slice(0, limit).map(splitLeadSentence), + remaining: Math.max(source.length - limit, 0), + }; +} diff --git a/studio/src-tauri/src/desktop_update_policy.rs b/studio/src-tauri/src/desktop_update_policy.rs index c83f847eda..d186c2d01d 100644 --- a/studio/src-tauri/src/desktop_update_policy.rs +++ b/studio/src-tauri/src/desktop_update_policy.rs @@ -27,6 +27,8 @@ pub(crate) struct DesktopUpdatePolicy { pub(crate) struct ManualUpdateInfo { version: String, current_version: String, + // Backend release this desktop build pins; CHANGELOG.md is keyed by it. + pypi_version: Option<String>, body: Option<String>, date: Option<String>, } @@ -34,8 +36,12 @@ pub(crate) struct ManualUpdateInfo { #[derive(Debug, serde::Deserialize)] struct ChannelMetadata { version: String, - body: Option<String>, - date: Option<String>, + // latest.json publishes Tauri's `notes`/`pub_date`; aliases keep older metadata working. + pypi_version: Option<String>, + #[serde(alias = "body")] + notes: Option<String>, + #[serde(alias = "date")] + pub_date: Option<String>, platforms: HashMap<String, ChannelPlatform>, } @@ -99,8 +105,9 @@ pub(crate) async fn check_desktop_manual_update() -> Result<Option<ManualUpdateI Ok(Some(ManualUpdateInfo { version: latest_version, current_version: current_version.to_string(), - body: metadata.body, - date: metadata.date, + pypi_version: metadata.pypi_version, + body: metadata.notes, + date: metadata.pub_date, })) } diff --git a/tests/studio/test_update_release_notes.py b/tests/studio/test_update_release_notes.py new file mode 100644 index 0000000000..765ad55321 --- /dev/null +++ b/tests/studio/test_update_release_notes.py @@ -0,0 +1,1906 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Contracts for the update popup's release-notes preview. + +The popup renders CHANGELOG.md notes for the exact version it is offering. The +risk this file guards is showing notes from a different release: a near-miss +lookup must return nothing rather than the newest section it can find.""" + +from __future__ import annotations + +import http.server +import json +import os +import re +import shutil +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +BACKEND = REPO / "studio/backend" +FRONTEND = REPO / "studio/frontend/src" +CHANGELOG = REPO / "CHANGELOG.md" +PANEL = FRONTEND / "components/update/release-notes-panel.tsx" +NOTES_HOOK = FRONTEND / "hooks/use-release-notes.ts" +PREVIEW = FRONTEND / "lib/release-notes-preview.ts" +CODE_SPANS = FRONTEND / "lib/markdown-code-spans.ts" +LINKS = FRONTEND / "lib/changelog-links.ts" +LIST_COLUMNS = FRONTEND / "lib/markdown-list-columns.ts" +INLINE_COMMENTS = FRONTEND / "lib/markdown-inline-comments.ts" +WEB_BANNER = FRONTEND / "components/web/update-banner.tsx" +TAURI_BANNER = FRONTEND / "components/tauri/update-banner.tsx" + +# The scanners are the frontend half of the contract the parser implements, so they are +# run rather than read. Node strips the types and nothing imports a package: no install. +_TS_ALIAS = re.compile(r'"@/lib/([a-z-]+)"') +_TS_RUNNER = """ +import { resolveChangelogLinks } from "./changelog-links.ts"; +import { releaseNotesPreview } from "./release-notes-preview.ts"; + +const chunks: Buffer[] = []; +process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk)); +process.stdin.on("end", () => { + const markdown = Buffer.concat(chunks).toString("utf8"); + const result = + process.argv[2] === "links" + ? resolveChangelogLinks(markdown) + : releaseNotesPreview(markdown); + process.stdout.write(JSON.stringify(result)); +}); +""" + +SAMPLE = """# Changelog + +Intro prose that belongs to no release. + +## Format + +```md +## 9999.9.9 - fenced sample, not a real section +``` + +## Unreleased + +- staged note + +## 2026.7.6 - 2026-07-22 + +### What's Changed + +- newer thing + +## 2026.7.5 + +### What's Changed + +- older thing +""" + + +@pytest.fixture(scope = "module") +def changelog_module(): + sys.path.insert(0, str(BACKEND)) + try: + from utils import changelog + finally: + sys.path.pop(0) + changelog.reset_changelog_cache() + yield changelog + changelog.reset_changelog_cache() + + +@pytest.fixture +def isolated_changelog(changelog_module, tmp_path, monkeypatch): + """Point the module at a temp file and away from the network.""" + monkeypatch.setenv(changelog_module.DISABLE_ENV_VAR, "1") + path = tmp_path / "CHANGELOG.md" + path.write_text(SAMPLE, encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(path)) + changelog_module.reset_changelog_cache() + yield changelog_module + changelog_module.reset_changelog_cache() + + +def test_only_real_release_headings_become_sections(changelog_module): + versions = [entry.version for entry in changelog_module.parse_changelog(SAMPLE)] + # "Format"/"Unreleased" are not versions, and 9999.9.9 is fenced sample. + assert versions == ["2026.7.6", "2026.7.5"] + + +def test_section_body_stops_at_the_next_release(changelog_module): + entry = changelog_module.find_release_notes(SAMPLE, "2026.7.6") + assert entry is not None + assert "newer thing" in entry.body + assert "older thing" not in entry.body + + +def test_unknown_version_returns_no_notes_instead_of_a_nearby_release(changelog_module): + assert changelog_module.find_release_notes(SAMPLE, "2026.7.7") is None + assert changelog_module.find_release_notes(SAMPLE, "2026.7") is None + + +def test_version_equality_is_normalized_not_fuzzy(changelog_module): + entry = changelog_module.find_release_notes(SAMPLE, "2026.07.6") + assert entry is not None and entry.version == "2026.7.6" + + +def test_response_reports_no_match_without_markdown(isolated_changelog): + payload = isolated_changelog.get_release_notes("2026.7.7") + assert payload["matched"] is False + assert payload["markdown"] is None + assert payload["version"] == "2026.7.7" + # The UI still needs somewhere to send the user. + assert payload["release_notes_url"] + + +def test_response_matches_local_changelog_when_offline(isolated_changelog): + payload = isolated_changelog.get_release_notes("2026.7.6") + assert payload["matched"] is True + assert payload["source"] == "local" + assert "newer thing" in payload["markdown"] + + +def test_unsupported_version_query_is_rejected(isolated_changelog): + assert isolated_changelog.is_supported_version_query("2026.7.6") is True + for bad in ("../etc/passwd", "2026.7.6 OR 1", "", "a" * 80): + assert isolated_changelog.is_supported_version_query(bad) is False + assert isolated_changelog.get_release_notes("../etc/passwd")["matched"] is False + + +def test_remote_changelog_wins_over_bundled_copy(changelog_module, tmp_path, monkeypatch): + """The offered version is newer than the installed checkout, so the repo + copy has to be able to describe versions the local file has never heard of.""" + monkeypatch.delenv(changelog_module.DISABLE_ENV_VAR, raising = False) + local = tmp_path / "CHANGELOG.md" + local.write_text(SAMPLE, encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) + + remote_body = "# Changelog\n\n## 2026.8.0\n\n- shipped after this install\n" + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - stdlib naming + payload = remote_body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_args): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target = server.serve_forever, daemon = True) + thread.start() + try: + monkeypatch.setenv( + changelog_module.CHANGELOG_URL_ENV_VAR, + f"http://127.0.0.1:{server.server_port}/CHANGELOG.md", + ) + changelog_module.reset_changelog_cache() + payload = changelog_module.get_release_notes("2026.8.0") + assert payload["matched"] is True + assert payload["source"] == "remote" + assert "shipped after this install" in payload["markdown"] + finally: + server.shutdown() + server.server_close() + changelog_module.reset_changelog_cache() + + +def test_repo_changelog_exists_and_parses(changelog_module): + assert CHANGELOG.is_file(), "CHANGELOG.md is the editable source of release notes" + entries = changelog_module.parse_changelog(CHANGELOG.read_text(encoding = "utf-8")) + assert entries, "CHANGELOG.md needs at least one `## <version>` section" + + +def test_longer_outer_fence_does_not_leak_a_fake_section(changelog_module): + """A ``` sample inside a ```` block must not close the block and let the + sample's heading be indexed as a real release.""" + text = "## 1.0\n\n````md\n```\n## 9.9.9\n```\n````\n\n- real note\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + assert changelog_module.find_release_notes(text, "9.9.9") is None + + +def test_tilde_fence_is_not_closed_by_backticks(changelog_module): + text = "## 1.0\n\n~~~\n```\n## 9.9.9\n~~~\n\n- real\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + + +def test_utf8_bom_does_not_hide_the_first_section(changelog_module): + """Editors on Windows can leave a BOM on the first line.""" + assert [e.version for e in changelog_module.parse_changelog("\ufeff## 1.0\n\n- x\n")] == ["1.0"] + + +@pytest.mark.parametrize("newline", ["\r\n", "\r"]) +def test_non_unix_line_endings(changelog_module, newline): + text = f"## 1.0{newline}{newline}- windows note{newline}" + entry = changelog_module.find_release_notes(text, "1.0") + assert entry is not None and "windows note" in entry.body + assert "\r" not in entry.body + + +def test_closing_fence_must_carry_nothing_after_it(changelog_module): + """CommonMark: a closer is the delimiter plus whitespace only. A ```` line + with trailing text inside a ```` block is content, not the end.""" + text = "## 1.0\n\n````md\n```` not a closer\n## 9.9.9\n````\n\n- real\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + # An opening fence may still carry an info string. + info = "## 1.0\n\n```python\n## 9.9.9\n```\n\n- real\n" + assert [e.version for e in changelog_module.parse_changelog(info)] == ["1.0"] + + +@pytest.mark.parametrize( + "text", + [ + "## 1.0\n\n- real\n\n<!--\n## 9.9.9\n\n- unpublished\n-->\n", + "## 1.0\n\n- real\n\n<!-- ## 9.9.9 -->\n", + ], +) +def test_commented_out_sections_are_not_releases(changelog_module, text): + """Markdown does not render them, so they are not published notes.""" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + assert changelog_module.find_release_notes(text, "9.9.9") is None + + +def test_repo_root_changelog_is_preferred_over_the_build_snapshot(changelog_module): + """The build backend writes studio/CHANGELOG.md; the root file must win.""" + # Resolved paths, not name suffixes: a checkout may be renamed and Windows uses "\". + paths = [Path(p).resolve() for p in changelog_module._local_changelog_candidates()] + root = paths.index((REPO / changelog_module.CHANGELOG_FILENAME).resolve()) + packaged = paths.index((REPO / "studio" / changelog_module.CHANGELOG_FILENAME).resolve()) + assert root < packaged + build = (REPO / "build.sh").read_text(encoding = "utf-8") + assert "rm -f studio/CHANGELOG.md" in build, "snapshot must not linger after a build" + + +def test_preview_keeps_identifier_underscores(): + """UNSLOTH_DISABLE_UPDATE_CHECK must not render as UNSLOTHDISABLEUPDATECHECK.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "BOLD_UNDERSCORE" in src and "ITALIC_UNDERSCORE" in src + assert "parkCodeSpans" in src, "code spans are parked so their underscores survive" + assert "const EMPHASIS" not in src, "the blanket emphasis strip is gone" + + +def test_panel_prefers_the_callers_release_url(): + """The API only returns the generic changelog; the desktop banner passes + the exact release page for the version being offered.""" + src = PANEL.read_text(encoding = "utf-8") + assert "releaseNotesUrl ?? notes?.releaseNotesUrl" in src + + +def test_remote_failure_is_reported_so_the_ui_can_retry(changelog_module, tmp_path, monkeypatch): + """A bundled changelog cannot know a version newer than the install, so a + failed remote lookup must not read as "no notes were published".""" + monkeypatch.delenv(changelog_module.DISABLE_ENV_VAR, raising = False) + local = tmp_path / "CHANGELOG.md" + local.write_text("## 1.0\n\n- old release\n", encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) + # Port 9 (discard) refuses fast, standing in for an unreachable host. + monkeypatch.setenv(changelog_module.CHANGELOG_URL_ENV_VAR, "http://127.0.0.1:9/CHANGELOG.md") + changelog_module.reset_changelog_cache() + try: + payload = changelog_module.get_release_notes("2.0") + assert payload["matched"] is False + assert payload["error"], "remote failure must reach the UI" + finally: + changelog_module.reset_changelog_cache() + + +def test_preview_keeps_comparison_operators(): + """ "Support Python <3.15 and >3.9" must not lose its operators to the tag + strip, which would turn it into "Support Python 3.9".""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "/<\\/?[a-zA-Z][^>]*>/g" in src, "tag strip must require a name character" + + +def test_preview_hides_commented_out_notes(): + """Unpublished notes inside <!-- --> are not rendered, so not previewed.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "stripCommentSpans" in src and "COMMENT_OPEN" in src + + +def test_hook_treats_a_reported_failure_as_retryable(): + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "next.error !== null" in src + + +def test_comment_delimiter_in_inline_code_is_literal(changelog_module): + """A note documenting `<!--` used to put the parser into comment state, + swallowing every release below it.""" + text = "## 2.0\n\n- Type `<!--` to begin a comment\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + assert changelog_module.find_release_notes(text, "1.0") is not None + assert "older" not in changelog_module.find_release_notes(text, "2.0").body + + +def test_refresh_retries_a_cached_remote_failure(changelog_module, tmp_path, monkeypatch): + """Retry must reach the network again once connectivity returns, rather + than replaying the cached failure until its TTL expires.""" + monkeypatch.delenv(changelog_module.DISABLE_ENV_VAR, raising = False) + local = tmp_path / "CHANGELOG.md" + local.write_text("## 1.0\n\n- old\n", encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) + + hits = {"count": 0} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - stdlib naming + hits["count"] += 1 + self.send_response(500) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *_args): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target = server.serve_forever, daemon = True).start() + try: + monkeypatch.setenv( + changelog_module.CHANGELOG_URL_ENV_VAR, + f"http://127.0.0.1:{server.server_port}/CHANGELOG.md", + ) + changelog_module.reset_changelog_cache() + changelog_module.get_release_notes("2.0") + changelog_module.get_release_notes("2.0") + assert hits["count"] == 1, "the failure should be cached" + changelog_module.get_release_notes("2.0", refresh = True) + assert hits["count"] == 2, "refresh must bypass the cached failure" + finally: + server.shutdown() + server.server_close() + changelog_module.reset_changelog_cache() + + +def test_hook_never_returns_another_versions_notes(): + """On the render where the offered version changes, state still describes + the previous one until the effect runs.""" + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "notes.version === version" in src + assert "refresh" in src, "retry must ask the backend to bypass its cache" + + +@pytest.mark.parametrize("indent", ["", " ", " ", " "]) +def test_headings_and_fences_allow_commonmark_indentation(changelog_module, indent): + """Markdown renders up to three leading spaces, so the parser must agree + or an indented release is unreachable and its notes join the one above.""" + text = f"## 1.0\n\nOne.\n\n{indent}## 2.0\n\nTwo.\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + fenced = f"## 1.0\n\n{indent}```\n{indent}## 9.9.9\n{indent}```\n\n- real\n" + assert [e.version for e in changelog_module.parse_changelog(fenced)] == ["1.0"] + + +def test_four_space_indentation_is_code_not_structure(changelog_module): + """At four spaces Markdown switches to indented code, for both forms.""" + assert [ + e.version for e in changelog_module.parse_changelog(" ## 9.9.9\n\n## 1.0\n\n- real\n") + ] == ["1.0"] + assert [ + e.version + for e in changelog_module.parse_changelog( + "## 1.0\n\n ```\n sample\n\n## 2.0\n\n- two\n" + ) + ] == ["1.0", "2.0"] + + +def test_desktop_notes_link_to_the_release_page_on_every_platform(): + """manualReleaseUrl is Linux-package only, so in-app updates on macOS, + Windows and AppImage would otherwise link to the generic changelog.""" + hook = (FRONTEND / "hooks/use-tauri-update.ts").read_text(encoding = "utf-8") + assert "const releasePageUrl = info ?" in hook + banner = TAURI_BANNER.read_text(encoding = "utf-8") + assert "releaseNotesUrl={releasePageUrl ?? manualReleaseUrl}" in banner + provider = (FRONTEND / "app/provider.tsx").read_text(encoding = "utf-8") + assert "releasePageUrl={update.releasePageUrl}" in provider + + +def test_preview_matches_how_markdown_renders_prose_and_links(): + """Three rendering mismatches the preview must not reintroduce: wrapped + paragraphs split into fragments, autolinks eaten as tags, and a lead cut + at an abbreviation.""" + src = PREVIEW.read_text(encoding = "utf-8") + # Contiguous prose lines accumulate and flush at a paragraph boundary. + assert "collector.paragraph = collector.paragraph" in src + # <https://x> renders as link text, so it is not a tag. + assert "AUTOLINK" in src + # "e.g. GGUF" is not a sentence boundary. + assert "ABBREVIATIONS" in src and "INITIAL" in src + + +def test_preview_treats_code_as_literal(): + """Inside a code span, and inside an indented code block, Markdown renders + the text literally, so the preview must not transform or promote it.""" + src = PREVIEW.read_text(encoding = "utf-8") + # Code spans are parked before any other inline transformation. + park = src.index("parkCodeSpans(markdown") + assert park < src.index("stripHtmlTags(\n parked") + # A "- cmd" line inside an indented code block is not a headline bullet. + assert "INDENTED_CODE_INDENT" in src + + +def test_desktop_updater_metadata_maps_published_field_names(): + """latest.json publishes Tauri's `notes`/`pub_date`; the manual Linux path + must read those, not `body`/`date`, or its release notes are always empty.""" + rust = (REPO / "studio/src-tauri/src/desktop_update_policy.rs").read_text(encoding = "utf-8") + assert 'alias = "body"' in rust and "notes: Option<String>" in rust + assert 'alias = "date"' in rust and "pub_date: Option<String>" in rust + assert "body: metadata.notes" in rust and "date: metadata.pub_date" in rust + workflow = (REPO / ".github/workflows/release-desktop.yml").read_text(encoding = "utf-8") + assert "'notes': notes," in workflow, "workflow no longer publishes `notes`" + + +def test_backend_exposes_release_notes_route(): + src = (BACKEND / "main.py").read_text(encoding = "utf-8") + assert '@app.get("/api/studio/release-notes")' in src + assert "is_supported_version_query" in src + + +def test_panel_is_scrollable_and_version_scoped(): + src = PANEL.read_text(encoding = "utf-8") + assert "overflow-y-auto" in src, "release notes must scroll inside the popup" + assert "max-h-" in src, "the scroller needs a bounded height" + # Falls back to the payload's own body only, never to another version. + assert "fallbackMarkdown" in src + + +def test_notes_surface_is_borderless_and_lifts_in_dark_mode(): + src = PANEL.read_text(encoding = "utf-8") + assert "border border-border" not in src, "the notes box is a fill, not a bordered box" + # Lighter than the card behind it, rather than a darker inset. + assert "dark:bg-white/[0.06]" in src + # Streamdown's mt-6 clips the first heading against the scroller edge. + assert "[&>*>*:first-child]:mt-0" in src + # Shared utility: thumb hidden until the notes are hovered. + assert "hover-scrollbar" in src + # Streamdown renders code at text-sm, twice this panel's body size. + assert "[&_code]:text-[0.92em]" in src + + +def test_hook_discards_notes_for_a_different_version(): + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "notesVersion !== version" in src + + +def test_collapsed_panel_previews_the_top_bullets(): + """Collapsed popups show the headline changes without an extra click.""" + preview = PREVIEW.read_text(encoding = "utf-8") + assert "RELEASE_NOTES_PREVIEW_ITEMS = 4" in preview + # Wrapped bullets join into one item, or a preview ends mid-sentence. + assert "collectBullets" in preview and "flush" in preview + # Nested list items are detail, not headline changes. + assert "NESTED_INDENT_TOLERANCE" in preview + # Tag stripping repeats: one pass turns `<<b>b>` back into a live tag. + assert "while (out !== previous)" in preview + + panel = PANEL.read_text(encoding = "utf-8") + assert "releaseNotesPreview" in panel + assert 'data-testid="update-release-notes-summary"' in panel + # Fetched when the popup appears: the collapsed preview needs them too. + assert "enabled: true" in panel + + +def test_preview_highlights_the_leading_sentence(): + """Each bullet leads with its headline sentence, emphasised over the rest.""" + preview = PREVIEW.read_text(encoding = "utf-8") + assert "splitLeadSentence" in preview + # A period inside "CHANGELOG.md" or "e.g." must not read as a break. + assert "SENTENCE_BREAK" in preview and "(?=" in preview + + panel = PANEL.read_text(encoding = "utf-8") + assert '<span className="font-medium text-foreground">{item.lead}</span>' in panel + assert "item.rest" in panel + + +@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER]) +def test_update_popup_is_wider_than_the_other_overlays(banner): + """The card is sized for three same-size buttons on one row. + + Width moved from the shared overlay stack onto each overlay, so widening + the update popup does not widen the llama.cpp banner or download panel.""" + assert "max-w-[448px]" in banner.read_text(encoding = "utf-8") + provider = (FRONTEND / "app/provider.tsx").read_text(encoding = "utf-8") + assert "max-w-[400px]" not in provider, "stack must not cap overlay width" + llama = (FRONTEND / "components/llama-update-banner.tsx").read_text(encoding = "utf-8") + assert "max-w-[400px]" in llama, "unrelated overlays keep their width" + + +@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER]) +def test_banners_toggle_inline_release_notes(banner): + src = banner.read_text(encoding = "utf-8") + assert "ReleaseNotesPanel" in src + assert "Show release notes" in src and "Hide release notes" in src + # Keyed by version, so a new offer cannot leave old notes on screen. + assert "notesVersion" in src + + +@pytest.mark.parametrize( + "banner,toggle,action", + [ + (WEB_BANNER, "web-update-release-notes-toggle", "web-update-snooze-button"), + (TAURI_BANNER, "tauri-update-release-notes-toggle", "Remind me later"), + ], +) +def test_notes_toggle_shares_the_action_row(banner, toggle, action): + """The toggle sits in the same row as the actions, not on its own line.""" + src = banner.read_text(encoding = "utf-8") + row = src.index("mt-4 flex") + assert row < src.index(toggle) < src.index(action) + # Same type size as the actions beside it; nowrap keeps labels on one line. + toggle_line = next(line for line in src.splitlines() if toggle in line) + toggle_block = src[src.index("Button", row) : src.index(toggle_line)] + assert "text-ui-13" in toggle_block and "whitespace-nowrap" in toggle_block + + +def test_headings_inside_a_raw_html_block_are_not_releases(changelog_module): + """<pre> content is literal, so a sample heading in it must not become a + section and must not cut the real section's body short.""" + text = "## 1.0\n\n<pre>\n## 9.9.9\n</pre>\n\n- real note\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + assert "real note" in changelog_module.find_release_notes(text, "1.0").body + assert changelog_module.find_release_notes(text, "9.9.9") is None + + +def test_details_blocks_still_contain_markdown(changelog_module): + """<details> is a CommonMark type 6 block: headings inside it still count, + so collapsible sections keep working.""" + text = "## 2.0\n\n<details>\n<summary>More</summary>\n\n- note\n\n</details>\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + + +def test_inline_raw_html_tag_does_not_open_a_block(changelog_module): + """A block opens only at the start of a line. A tag named mid-sentence is + inline HTML and must not swallow the releases below it.""" + text = "## 2.0\n\n- Warn when a <script> tag is pasted\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + + +def test_preview_skips_raw_html_blocks(): + src = PREVIEW.read_text(encoding = "utf-8") + assert "stripRawHtml" in src + # Anchored: only a line-leading tag opens a block, matching the parser. + assert "/^ {0,3}<(pre|script|style|textarea)" in src + + +def test_fence_inside_a_raw_html_block_is_literal(changelog_module): + """Raw HTML contents are literal, so a stray ``` in a <pre> sample is not a + fence. Treating it as one left a block open and hid every later release.""" + text = "## 2.0\n\n<pre>\n```\n</pre>\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + + +def test_raw_html_block_closes_on_any_of_the_four_tags(changelog_module): + """CommonMark ends a type 1 block at the first `</pre>`, `</script>`, + `</style>` or `</textarea>`: the closer need not match the opener.""" + text = '## 1.0\n\n<script>\nconst sample = "</pre>";\n## 9.9.9\n</script>\n' + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "9.9.9"] + + +@pytest.mark.parametrize("tag", ["details", "div", "table"]) +def test_type_6_blocks_run_until_a_blank_line(changelog_module, tag): + """`<details>` holds Markdown only after a blank line closes the block, so + a heading pressed against the opening tag is not a release.""" + packed = f"## 1.0\n\n<{tag}>\n## 9.9.9\n</{tag}>\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(packed)] == ["1.0"] + spaced = f"## 1.0\n\n<{tag}>\n\n## 2.0\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(spaced)] == ["1.0", "2.0"] + + +def test_a_tag_only_line_cannot_interrupt_a_paragraph(changelog_module): + """Type 7 blocks do not interrupt a paragraph, so prose followed by a bare + tag keeps the releases below it reachable.""" + text = "## 2.0\n\nSome prose.\n<span>\n\n## 1.0\n\n- older\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + + +def test_preview_joins_an_indented_continuation_line(): + """Four spaces only start code outside a paragraph. Inside one the line is + a wrapped continuation, so it must not be dropped from the preview.""" + src = PREVIEW.read_text(encoding = "utf-8") + # Measured from the line's container, so an item's own indent does not count. + assert "!insideBlock && line.indent - line.column >= INDENTED_CODE_INDENT" in src + # A fence indented into a list item is a block, not a wrapped line. + assert "opensDeepFence" in src + + +def test_every_packaging_path_snapshots_the_changelog(): + """`python -m build` and `pip install .` must ship the offline copy too, + so the snapshot is made by the build backend rather than by build.sh.""" + pyproject = (REPO / "pyproject.toml").read_text(encoding = "utf-8") + assert 'build_py = "_changelog_build.build_py"' in pyproject + hook = (REPO / "_changelog_build.py").read_text(encoding = "utf-8") + assert "studio" in hook and "CHANGELOG.md" in hook + # The hook has to reach the sdist, or building from one loses the snapshot. + manifest = (REPO / "MANIFEST.in").read_text(encoding = "utf-8") + assert "include _changelog_build.py" in manifest + assert "include CHANGELOG.md" in manifest + + +def test_preview_code_spans_need_a_matching_closer(): + """A closer is a run of the same length, so ``Use `` `x` `` `` keeps the + inner backticks the expanded notes show.""" + src = CODE_SPANS.read_text(encoding = "utf-8") + assert "candidate === ticks" in src, "a closer is a run of the same length" + assert "stripPadding" in src, "one space of padding is dropped, as in Markdown" + + +def test_preview_skips_thematic_breaks(): + """`- - -` renders as a rule, so it must not take a preview slot.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "THEMATIC_BREAK" in src + assert "THEMATIC_BREAK.test(visible)" in src + + +def test_preview_keeps_quoted_examples_out_of_the_headlines(): + """A quoted list is example output, not a change, so it never competes + with the release's own bullets.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "quoted: boolean" in src + assert "if (!line.quoted)" in src, "quoted bullets never become headlines" + + +def test_notes_panel_keeps_the_link_when_the_lookup_fails(): + """Retry is not the only route: the changelog page can be reachable even + when the backend lookup is not.""" + src = PANEL.read_text(encoding = "utf-8") + error_branch = src[src.index('if (state === "error")') :] + retry = error_branch.index("update-release-notes-retry") + assert error_branch.index("{link}") > retry, "link sits beside retry" + + +def test_hook_waits_for_the_desktop_auth_token(): + """The desktop popup can render before auto-auth installs its token, so a + missing token must not be recorded as a failed lookup.""" + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "hasAuthToken()" in src and "AUTH_POLL_LIMIT" in src + + +def test_installed_layout_prefers_the_bundled_changelog(tmp_path): + """Installed, the levels above studio/ are site-packages. A stray + CHANGELOG.md left there by another package must not outrank the bundled + snapshot, so those levels are only searched in a source checkout.""" + site_packages = tmp_path / "site-packages" + package = site_packages / "studio/backend/utils" + package.mkdir(parents = True) + for name in ("changelog.py", "update_status.py"): + shutil.copy(BACKEND / "utils" / name, package / name) + for parent in (site_packages / "studio", package.parent, package): + (parent / "__init__.py").write_text("", encoding = "utf-8") + (site_packages / CHANGELOG.name).write_text("## 2.0\n\n- stray\n", encoding = "utf-8") + bundled = site_packages / "studio" / CHANGELOG.name + bundled.write_text("## 2.0\n\n- bundled\n", encoding = "utf-8") + + env = {**os.environ, "PYTHONPATH": str(site_packages)} + env.pop("UNSLOTH_CHANGELOG_PATH", None) + + def served() -> str: + # cwd is outside the checkout, so this imports the installed copy. + return subprocess.run( + [ + sys.executable, + "-c", + "from studio.backend.utils import changelog\n" + "print(changelog._read_local_changelog().text)", + ], + capture_output = True, + text = True, + env = env, + cwd = tmp_path, + check = True, + ).stdout + + assert "bundled" in served() and "stray" not in served() + + # A checkout marker there means it really is a repo root, so it wins again. + (site_packages / "pyproject.toml").write_text("", encoding = "utf-8") + assert "stray" in served() + + +def test_a_section_staged_as_a_comment_reads_as_unpublished( + changelog_module, tmp_path, monkeypatch +): + """Notes staged inside <!-- --> render as nothing, so the popup must say + no notes were published rather than show an empty surface.""" + monkeypatch.setenv(changelog_module.DISABLE_ENV_VAR, "1") + local = tmp_path / "CHANGELOG.md" + local.write_text("## 2.0\n\n<!-- not ready -->\n\n## 1.0\n\n- shipped\n", encoding = "utf-8") + monkeypatch.setenv(changelog_module.CHANGELOG_PATH_ENV_VAR, str(local)) + changelog_module.reset_changelog_cache() + try: + staged = changelog_module.get_release_notes("2.0") + assert staged["matched"] is False and staged["markdown"] is None + assert changelog_module.get_release_notes("1.0")["matched"] is True + finally: + changelog_module.reset_changelog_cache() + + +@pytest.mark.parametrize( + "body,visible", + [ + ("- note", True), + ("<!-- staged -->", False), + ("```\n```", True), + ("<pre>\n</pre>", True), + (" ", False), + ], +) +def test_visibility_check_only_hides_comments(changelog_module, body, visible): + assert changelog_module._renders_visibly(body) is visible + + +@pytest.mark.parametrize( + "block", + [ + "<?php\n## 9.9.9\n?>", + "<![CDATA[\n## 9.9.9\n]]>", + "<!DOCTYPE\n## 9.9.9\n>", + ], +) +def test_processing_instructions_and_declarations_are_literal(changelog_module, block): + """Raw block types 3 to 5 render literally, like <pre>, so a heading inside + one is a sample and not a release.""" + text = f"## 1.0\n\n{block}\n\n- real note\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + assert "real note" in changelog_module.find_release_notes(text, "1.0").body + + +def test_headings_need_a_space_or_tab_after_the_hashes(changelog_module): + """A non-breaking space pasted from rich text renders as ordinary text, so + the line must not end the release above it.""" + text = "## 1.0\n\n- real note\n\n## 9.9.9\n\n- not a release\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + assert changelog_module.find_release_notes(text, "9.9.9") is None + # A tab is valid and still opens a heading. + tabbed = "## 1.0\n\n- one\n\n##\t2.0\n\n- two\n" + assert [e.version for e in changelog_module.parse_changelog(tabbed)] == ["1.0", "2.0"] + + +def test_preview_skips_every_raw_block_form(): + """The extractor tracks the same block forms as the parser, so a sample + bullet inside one cannot become the collapsed headline.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "RAW_BLOCKS" in src + assert "CDATA" in src and "[A-Za-z]" in src + + +@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER]) +def test_expanded_popup_fits_a_short_viewport(banner): + """A window under roughly 430px high used to push the card's title and + dismiss control above the top of the screen.""" + panel = PANEL.read_text(encoding = "utf-8") + # The notes region shrinks inside the capped card, so header and actions stay on screen. + assert "min-h-0 flex-1" in panel, "notes height must follow the viewport" + src = banner.read_text(encoding = "utf-8") + assert "max-h-[calc(100dvh_-_2rem)]" in src, "card is the backstop on tiny viewports" + + +def test_relative_changelog_links_point_at_the_repository(): + """CHANGELOG.md links are repository-relative. Rendered as-is they resolve + against Studio's origin, so the renderer blocks them.""" + src = LINKS.read_text(encoding = "utf-8") + assert "https://github.com/unslothai/unsloth/blob/main/" in src + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/" in src + # Absolute targets, fragments, fenced code and code spans stay untouched. + assert "ABSOLUTE" in src and "codeSpans" in src and "FENCE" in src + panel = PANEL.read_text(encoding = "utf-8") + assert "resolveChangelogLinks" in panel + + +@pytest.mark.parametrize("query", ["latest", "main", "not-a-version", "abc"]) +def test_unparseable_versions_are_rejected(changelog_module, query): + """Sections are indexed only when their version parses, so a query that + cannot parse can never match and is a bad request, not an empty result.""" + assert changelog_module.is_supported_version_query(query) is False + + +@pytest.mark.parametrize("query", ["2026.7.5", "v2026.7.5", "2026.07.5", "1.0.0rc1"]) +def test_real_versions_are_still_accepted(changelog_module, query): + assert changelog_module.is_supported_version_query(query) is True + + +def test_reference_style_images_resolve_to_the_raw_host(): + """`![alt][arch]` with `[arch]: docs/arch.png` needs the raw file: the blob + URL is an HTML page, so the image would not load.""" + src = LINKS.read_text(encoding = "utf-8") + assert "IMAGE_REFERENCE" in src + assert "imageLabels" in src + + +def test_collapsed_notes_surface_is_hidden_when_nothing_previews(): + """Notes that are only a fenced command block preview as nothing, and an + empty muted strip is worse than no strip.""" + src = PANEL.read_text(encoding = "utf-8") + assert "preview?.items.length === 0" in src + + +def test_a_fence_closer_accepts_only_spaces_and_tabs(changelog_module): + """A delimiter followed by a non-breaking space is code content, so it must + not close the block and let a sample heading through.""" + text = "## 1.0\n\n```\n```\u00a0\n## 9.9.9\n```\n\n- real note\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + plain = "## 1.0\n\n```\nx\n```\t\n\n## 2.0\n\n- two\n" + assert [e.version for e in changelog_module.parse_changelog(plain)] == ["1.0", "2.0"] + # The same rule in both frontend scanners. + for source in (PREVIEW, LINKS): + assert "/[^ \\t]/" in source.read_text(encoding = "utf-8") + + +def test_code_spans_close_on_a_run_of_equal_length(): + """`a``b [x](y.md)` is one code span, so the link inside it is literal.""" + src = CODE_SPANS.read_text(encoding = "utf-8") + assert "candidate === ticks" in src, "closer length must match the opener" + # Shared, so the preview and the link resolver cannot drift apart. + assert "markdown-code-spans" in PREVIEW.read_text(encoding = "utf-8") + assert "markdown-code-spans" in LINKS.read_text(encoding = "utf-8") + + +def test_preview_decodes_entities_like_the_renderer(): + """Streamdown renders `AT&T` as AT&T, so the collapsed preview must + not show the raw entity.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "NAMED_ENTITIES" in src and "decodeEntity" in src + # Decoded before code spans are restored, so code keeps the literal text. + assert src.index(".replace(ENTITY, decodeEntity)") < src.index(".replace(PARKED") + + +def test_release_notes_request_refreshes_an_expired_token(): + """A direct fetch cannot recover from a 401; authFetch refreshes first.""" + src = NOTES_HOOK.read_text(encoding = "utf-8") + assert "authFetch(" in src + assert "getAuthToken" not in src + + +def test_preview_handles_the_desktop_updater_line_endings(): + """The updater body arrives with CRLF, which used to hide fences from the + extractor and promote a code sample to a headline.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "LINE_ENDINGS" in src + assert "LINE_ENDINGS" in LINKS.read_text(encoding = "utf-8") + + +def test_preview_renders_reference_links_as_text(): + """`[text][label]` and `![alt][label]` render as a link and an image, so + the preview must not show their raw markup.""" + src = PREVIEW.read_text(encoding = "utf-8") + assert "LINK_REFERENCE" in src and "IMAGE_REFERENCE" in src + # A definition line renders as nothing, so it is not a preview item. + assert "DEFINITION" in src + + +def test_preview_treats_escaped_punctuation_as_literal(): + """`\\*not italic\\*` keeps its stars and an escaped backtick does not open + a code span.""" + assert "ESCAPE" in PREVIEW.read_text(encoding = "utf-8") + assert "escaped(" in CODE_SPANS.read_text(encoding = "utf-8") + + +def test_link_resolver_skips_every_code_form(): + """Indented code and code spans crossing a line render as code, so their + contents must not be rewritten.""" + src = LINKS.read_text(encoding = "utf-8") + assert "INDENTED_CODE" in src + # Spans are scanned over the whole document, not line by line. + assert "codeSpans(masked)" in src + # A definition cannot interrupt a paragraph. + assert "definition.has(index)" in src + + +def test_badge_links_resolve_both_targets(): + """`[![alt](img)](link)` is the badge idiom: the outer link used to stay + relative because the label was not allowed to nest.""" + assert "NESTED_LABEL" in LINKS.read_text(encoding = "utf-8") + + +def test_in_flight_requests_are_identified_not_just_versioned(): + """Two requests for the same version could resolve out of order and leave + the panel showing the older result.""" + assert "requestIdRef" in NOTES_HOOK.read_text(encoding = "utf-8") + + +def test_notes_repair_the_shared_previews_width_reset(): + """MarkdownPreview clears max-width on every descendant, so a wide image + and the renderer's own link dialog escape the card.""" + src = PANEL.read_text(encoding = "utf-8") + assert "[&_img]:max-w-full" in src + assert "[&_[data-streamdown=link-safety-modal]>*]:max-w-md" in src + + +@pytest.mark.parametrize("banner", [WEB_BANNER, TAURI_BANNER]) +def test_only_the_notes_region_scrolls(banner): + """The dismiss control sits inside the card, so scrolling the card itself + carried it off screen on a short viewport.""" + src = banner.read_text(encoding = "utf-8") + assert "flex max-h-[calc(100dvh_-_2rem)] flex-col overflow-hidden" in src + assert 'className="min-h-0 flex-1"' in src + panel = PANEL.read_text(encoding = "utf-8") + assert "max-h-64 min-h-0 flex-1 overflow-y-auto" in panel + + +def test_a_comment_marker_in_prose_cannot_swallow_later_releases(changelog_module): + """A note that mentions `<!--` used to put the parser into comment state + for the rest of the file: the releases below it disappeared and their + notes were served under the newer version's heading.""" + text = ( + "## 2026.8.0\n\n- Studio strips <!-- markers from pasted prompts.\n\n" + "## 2026.7.5\n\n- SECRET: an older release\n" + ) + assert [e.version for e in changelog_module.parse_changelog(text)] == [ + "2026.8.0", + "2026.7.5", + ] + assert "SECRET" not in changelog_module.find_release_notes(text, "2026.8.0").body + assert changelog_module.find_release_notes(text, "2026.7.5") is not None + # A comment that starts a line is still a block and still hides its body. + hidden = "## 2.0\n\n<!--\n## 9.9.9\n-->\n\n- note\n" + assert [e.version for e in changelog_module.parse_changelog(hidden)] == ["2.0"] + + +def test_unmatched_backtick_runs_stay_linear(changelog_module): + """Rescanning the suffix for every opener was quadratic: a line of runs of + 1, 2, 3 ... backticks, none of which ever closes, took 7.7s at 321 KB and + is reparsed on every popup request, so one malformed remote changelog could + tie up backend workers.""" + line = "".join("`" * (i + 1) + "x" for i in range(800)) + assert len(line) > 300_000 + started = time.monotonic() + assert changelog_module._code_span_ranges(line) == [] + assert time.monotonic() - started < 2.0 + + +def test_a_base_exception_releases_the_single_flight_flag(changelog_module, monkeypatch): + """The flag was cleared only after `except Exception`, so a BaseException + (KeyboardInterrupt, SystemExit, CancelledError) stranded it and every later + caller then waited out the full deadline for the life of the process.""" + changelog_module.reset_changelog_cache() + + def explode(): + raise KeyboardInterrupt + + monkeypatch.setattr(changelog_module, "_fetch_remote_changelog", explode) + with pytest.raises(KeyboardInterrupt): + changelog_module.get_remote_changelog() + assert changelog_module._remote_fetching is False + changelog_module.reset_changelog_cache() + + +@pytest.mark.parametrize("marker", ["<!-->", "<!--->"]) +def test_an_empty_comment_does_not_swallow_later_releases(changelog_module, marker): + """`<!-->` and `<!--->` are complete comments in CommonMark: the closer + overlaps the opener. Searching for `-->` past the opener missed them, so an + empty comment used as a section marker hid every release below it.""" + text = f"## 2.0\n\n- new stuff\n\n{marker}\n\n## 1.0\n\n- old stuff\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + assert changelog_module.find_release_notes(text, "1.0") is not None + assert "old stuff" not in changelog_module.find_release_notes(text, "2.0").body + # The frontend scanner has to agree, or the preview and the body disagree. + assert "!line.includes(COMMENT_CLOSE)" in PREVIEW.read_text(encoding = "utf-8") + + +def test_an_unterminated_comment_still_hides_the_rest(changelog_module): + """The fix must not turn every `<!--` line into a no-op block.""" + text = "## 2.0\n\n<!-- never closed\n\n## 1.0\n\n- old stuff\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0"] + + +def test_a_closing_delimiter_takes_its_whole_line(changelog_module): + """CommonMark keeps the closing line inside the block, so a heading glued + after `-->` or `</pre>` is not a release.""" + for text in ( + "## 1.0\n\n<!-- hidden -->## 9.9.9\n\n- note\n", + "## 1.0\n\n<pre>\nx\n</pre>## 9.9.9\n\n- note\n", + ): + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + + +def test_an_exact_heading_is_never_shadowed(changelog_module): + """PEP 440 says 1.0 == 1.0.0, so the normalised match used to win even + when the file had a section spelled exactly as asked.""" + text = "## 1.0.0\n\n- padded\n\n## 1.0\n\n- exact\n" + assert changelog_module.find_release_notes(text, "1.0").body == "- exact" + assert changelog_module.find_release_notes(text, "1.0.0").body == "- padded" + # Normalised matching still applies when there is no exact heading. + assert changelog_module.find_release_notes("## 2026.7.6\n\n- x\n", "2026.07.6") is not None + + +def test_setext_headings_are_release_boundaries(changelog_module): + """A version over a line of dashes is the same heading in setext form.""" + text = "2.0\n---\n\n- new\n\n1.0\n---\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + assert changelog_module.find_release_notes(text, "2.0").body == "- new" + # A rule between sections is still a rule, and a setext h1 is not a release. + assert [ + e.version + for e in changelog_module.parse_changelog("## 2.0\n\n- a\n\n---\n\n## 1.0\n\n- b\n") + ] == ["2.0", "1.0"] + + +def test_a_long_backtick_run_does_not_stall_the_parser(changelog_module): + """The code-span guard used to backtrack: 20k backticks took over a minute + and every request re-parsed the file.""" + import time + + text = "## 1.0\n\n- " + "`" * 20_000 + " <!--\n" + started = time.perf_counter() + changelog_module.parse_changelog(text) + assert time.perf_counter() - started < 1.0 + + +def test_the_remote_fetch_has_a_total_deadline(changelog_module): + """The socket timeout resets on every read, so a trickling server could + hold a worker for minutes and still be treated as a success.""" + source = (BACKEND / "utils/changelog.py").read_text(encoding = "utf-8") + assert "deadline = time.monotonic() + CHANGELOG_TIMEOUT_SECONDS" in source + # read1 returns after one socket read, so the deadline is actually checked. + assert "response.read1(" in source + # Waiters give up rather than queue behind a stalled fetch. + assert "Release notes are still loading." in source + + +def test_truncated_notes_close_their_fence(changelog_module): + """A blind slice could end inside a code block and break the rendering.""" + body = "```\n" + "x\n" * 20_000 + "```\n" + payload = changelog_module._notes_response(version = "1.0", markdown = body, source = "local") + assert payload["truncated"] is True + assert payload["markdown"].rstrip().endswith("```") + + +def test_the_opt_out_beats_the_developer_override(): + """UNSLOTH_STUDIO_FAKE_UPDATE is a dev switch; the documented kill switch + still wins, and the value has to parse as a version.""" + source = (BACKEND / "utils/update_status.py").read_text(encoding = "utf-8") + assert "forced_version and not disabled and _is_version(forced_version)" in source + + +def test_a_list_item_over_dashes_is_not_a_setext_heading(changelog_module): + """`- first` followed by `---` is a list and a rule. Reading it as a + heading discarded the bullet and the rest of the section with it.""" + text = "## 1.0\n\n- first\n---\n\n- second\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + body = changelog_module.find_release_notes(text, "1.0").body + assert "first" in body and "second" in body + # Real setext headings still work. + setext = "2.0\n---\n\n- new\n\n1.0\n---\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(setext)] == ["2.0", "1.0"] + + +def test_a_backtick_in_a_fence_info_string_is_not_a_fence(changelog_module): + """CommonMark forbids backticks in a backtick fence's info string, so such + a line is prose and must not swallow the releases below it.""" + text = "## 2.0\n\n```bad`info\n\n## 1.0\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + # A tilde fence may hold backticks, and a normal fence still hides samples. + assert [ + e.version + for e in changelog_module.parse_changelog( + "## 2.0\n\n```md\n## 9.9.9\n```\n\n## 1.0\n\n- old\n" + ) + ] == ["2.0", "1.0"] + for source in (PREVIEW, LINKS): + assert "info string" in source.read_text(encoding = "utf-8") + + +def test_preview_follows_commonmark_paragraph_rules(): + """Only an ordered list starting at 1 may interrupt a paragraph, and an + unresolved reference keeps its brackets. A quote owns the paragraph its own + lines hold, so a marker written outside the quote interrupts nothing.""" + src = " ".join(PREVIEW.read_text(encoding = "utf-8").split()) + assert "const interrupts = collector.current === null" in src + assert "!collector.quotedParagraph;" in src + assert "definedLabel" in src, "a reference only renders as text when defined" + # A comment written mid-sentence hides its own line at most. + assert "COMMENT_BLOCK_OPEN" in src + + +def test_link_resolver_leaves_raw_blocks_and_escapes_alone(): + src = LINKS.read_text(encoding = "utf-8") + assert "RAW_HTML_OPEN" in src and "inRawHtml" in src + assert "isEscaped(line, opener)" in src + # A heading ends a paragraph, so a definition under one is a definition. + assert "BLOCK_LINE.test(structure)" in src + + +def test_code_span_closers_ignore_backslashes(): + """Escapes are not processed inside a code span, so a run after a + backslash still closes it.""" + src = CODE_SPANS.read_text(encoding = "utf-8") + body = src[src.index("export function codeSpans") :] + assert body.count("escaped(text") == 1, "only an opener can be escaped" + + +def test_the_overlay_stack_fits_the_viewport(): + """The update card's own cap does not account for a long download list + stacked beneath it.""" + provider = (FRONTEND / "app/provider.tsx").read_text(encoding = "utf-8") + assert "max-h-[calc(100dvh_-_2rem)]" in provider + panel = (FRONTEND / "features/hub/download-manager/download-manager-panel.tsx").read_text( + encoding = "utf-8" + ) + # Both overlays scroll internally, so they can give up height. + assert "flex min-h-0" in panel + assert "flex min-h-0" in WEB_BANNER.read_text(encoding = "utf-8") + + +def test_the_desktop_stack_is_capped_like_the_browser_one(): + """The download panel shares the desktop stack, so the update card's own + cap is not enough there either.""" + provider = (FRONTEND / "app/provider.tsx").read_text(encoding = "utf-8") + assert provider.count("max-h-[calc(100dvh_-_2rem)]") == 2, "both stacks are capped" + assert "flex min-h-0" in TAURI_BANNER.read_text(encoding = "utf-8") + + +def test_desktop_notes_are_looked_up_by_the_backend_version(): + """latest.json's `version` is the app SemVer while CHANGELOG.md is keyed by + the backend release, so the desktop popup used to find no section at all + and fall back to the updater's generic text.""" + workflow = (REPO / ".github/workflows/release-desktop.yml").read_text(encoding = "utf-8") + assert "'pypi_version': os.environ['PYPI_VERSION']" in workflow + assert "PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}" in workflow + rust = (REPO / "studio/src-tauri/src/desktop_update_policy.rs").read_text(encoding = "utf-8") + assert "pypi_version: Option<String>" in rust + hook = NOTES_HOOK.parent.joinpath("use-tauri-update.ts").read_text(encoding = "utf-8") + # Both desktop paths carry it: the plugin exposes the raw metadata. + assert "rawPypiVersion(update.rawJson)" in hook + assert "manualUpdate.pypiVersion" in hook + banner = TAURI_BANNER.read_text(encoding = "utf-8") + assert "info?.pypiVersion ?? info?.version" in banner + + +def test_one_slow_read_cannot_outlast_the_fetch_budget(changelog_module): + """The socket timeout is per operation, so slow headers followed by a slow + body could hold a worker for twice the advertised deadline.""" + source = (BACKEND / "utils/changelog.py").read_text(encoding = "utf-8") + assert "_limit_read(response, remaining)" in source + assert "sock.settimeout(max(remaining, _CHANGELOG_MIN_READ_SECONDS))" in source + + +def test_a_heading_indented_into_a_list_item_is_not_a_release(changelog_module): + """CommonMark keeps a heading at the item's content column inside the item. + Treating it as a boundary truncated the real release and indexed a version + that does not exist. Checked against markdown-it (commonmark preset).""" + text = "## 1.0\n\n- Example:\n ## 9.9.9\n\n- after\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"] + body = changelog_module.find_release_notes(text, "1.0").body + assert "9.9.9" in body and "after" in body + # One space short of the content column, the list ends and it is a release. + left = "## 1.0\n\n- Example:\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(left)] == ["1.0", "2.0"] + + +def test_a_closed_list_stops_holding_headings(changelog_module): + """Only an open item nests a heading, so a dedented paragraph, heading, + break or fence hands the following indentation back to the document.""" + + def versions(text): + return [e.version for e in changelog_module.parse_changelog(text)] + + assert versions("## 1.0\n\n- Example:\n\nText.\n\n ## 2.0\n") == ["1.0", "2.0"] + assert versions("## 1.0\n\n- Example:\n## 2.0\n ## 3.0\n") == ["1.0", "2.0", "3.0"] + assert versions("## 1.0\n\n- Example:\n Text.\n---\n ## 2.0\n") == ["1.0", "2.0"] + assert versions("## 1.0\n\n- Example:\n```\n```\n ## 2.0\n") == ["1.0", "2.0"] + # An item may begin with one blank line; content after that is outside it. + assert versions("## 1.0\n\n-\n\n ## 2.0\n") == ["1.0", "2.0"] + + +def test_a_version_line_is_not_an_ordered_list_marker(changelog_module): + """`2.` needs whitespace after it to be a marker, or list tracking would + read every setext version as a list item and lose the heading.""" + text = "2.0\n---\n\n- new\n\n1.0\n---\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + # An ordered item interrupts a paragraph only when it starts at 1. + assert [ + e.version for e in changelog_module.parse_changelog("## 1.0\n\nText.\n9) one\n ## 2.0\n") + ] == ["1.0", "2.0"] + + +def test_a_wrapped_setext_heading_is_still_a_release(changelog_module): + """CommonMark promotes the whole paragraph, so a heading that wraps keeps + the version in its first token. Reading only the last line left the release + unindexed and its notes unreachable.""" + text = "2026.7.5 - Release\nJuly 25\n---\n\n- note\n" + entries = changelog_module.parse_changelog(text) + assert [e.version for e in entries] == ["2026.7.5"] + # The heading lines are the heading, not the body. + assert entries[0].body == "- note" + assert "July 25" not in entries[0].body + + +def test_a_lowercase_declaration_is_not_a_raw_block(changelog_module): + """Only `<!` plus an uppercase letter opens one, so prose that mentions + `<!note` must not hide every release under it.""" + assert [ + e.version for e in changelog_module.parse_changelog("<!note\n\n## 1.0\n\n- real\n") + ] == ["1.0"] + # A real declaration still hides its own block. + assert [ + e.version for e in changelog_module.parse_changelog("<!DOCTYPE\n## 9.9.9\n>\n\n## 1.0\n") + ] == ["1.0"] + # The collapsed preview needs the same rule or it drops visible bullets. + assert "<![A-Z]" in PREVIEW.read_text(encoding = "utf-8") + + +def test_link_resolver_reads_html_containers_the_way_the_others_do(): + """A `<details>` or `<div>` with no blank line inside is a type 6 block, so + its contents render literally. Rewriting a link there mutates text the + reader sees verbatim, and a fence inside such a block was being taken for a + real fence, which stopped every link below it from resolving at all. The + backend parser and the collapsed preview already apply the type 6 and 7 + rules, so the resolver has to share them or the three disagree on the same + notes.""" + links = LINKS.read_text(encoding = "utf-8") + for source in (PREVIEW, LINKS): + text = source.read_text(encoding = "utf-8") + assert "HTML_BLOCK_TAGS" in text and "HTML_TAG_ONLY_LINE" in text + # A blank line ends the block, not the closing tag, and a bare quote marker counts as blank. + assert "inHtmlBlock = !!container.trim()" in links + # Type 7 cannot interrupt a paragraph, so prose above it keeps its links. + assert "return !afterParagraph && HTML_TAG_ONLY_LINE.test(line);" in links + + +def test_an_escaped_mark_makes_an_image_a_link(): + """`\\![alt](path)` renders as a link, so it resolves to the file's page on + GitHub rather than to the raw-content host.""" + links = LINKS.read_text(encoding = "utf-8") + assert 'const image = bang === "!" && !isEscaped(line, offset);' in links + # The reference pre-scan has to skip it too, or the definition flips host. + assert "isEscaped(line, match.index)" in links + + +def test_only_markdown_line_endings_split_the_changelog(changelog_module): + """str.splitlines also breaks on U+2028, U+2029, NEL, vertical tab and form + feed, none of which end a line in CommonMark. A separator sitting in prose + ahead of "## 9.9.9" made the parser index a release the renderer never shows + and truncate the notes above it.""" + text = "## 2.0\n\nnote with a separator 
## 9.9.9\n\n## 1.0\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0", "1.0"] + # The prose stays whole rather than being cut at the separator. + entry = changelog_module.find_release_notes(text, "2.0") + assert entry is not None and "9.9.9" in entry.body + for separator in ("
", "\x85", "\x0b", "\x0c"): + broken = f"## 2.0\n\nnote{separator}## 9.9.9\n\n## 1.0\n\n- old\n" + assert [e.version for e in changelog_module.parse_changelog(broken)] == ["2.0", "1.0"] + # The three real line endings still split. + for ending in ("\n", "\r\n", "\r"): + real = f"## 2.0{ending}{ending}- new{ending}{ending}## 1.0{ending}{ending}- old{ending}" + assert [e.version for e in changelog_module.parse_changelog(real)] == ["2.0", "1.0"] + + +def test_the_build_does_not_require_a_writable_source_tree(): + """A PEP 517 build may run against an immutable checkout (Nix, Bazel, a + read-only container mount). Writing the snapshot beside the sources raised + PermissionError before build_py started, so no wheel could be built at all. + """ + src = (REPO / "_changelog_build.py").read_text(encoding="utf-8") + # The source-tree copy is best effort. + assert "except OSError:" in src + # The wheel gets its copy from the staging directory either way. + assert 'Path(self.build_lib) / "studio" / "CHANGELOG.md"' in src + + +def test_link_resolver_reads_comments_before_fences(): + """A fence delimiter hidden inside an HTML comment is not a fence. Reading + it as one left the fence open, so every visible line below was classified as + code and none of its links were resolved, which is far worse than the + mutated-text case: the whole rest of the notes silently stops working. The + order matters both ways, so a comment opener inside a real fence is not a + comment either.""" + links = LINKS.read_text(encoding="utf-8") + # Fence state is read before comments are masked, the order the collapsed preview uses. + assert "const fenceSource = inComment\n ? null\n : FENCE.exec(" in links + # Masking happens only after the in-fence early return. + fence_return = links.index("// Fenced content is literal") + assert links.index("const [line, stillInComment, stillRunOn] = maskComments(") > fence_return + # Commented ranges join the code spans, so a hidden link is left alone. + assert "const spans = [...codeSpans(masked), ...comments].sort(" in links + + +def test_preview_heading_and_quote_markers_follow_the_backend_rule(): + """An ATX heading needs an ASCII space, a tab or the end of the line after + the marker, which is what _HEADING_PATTERN requires; `\\s` also matches a + non-breaking space, so prose beginning "## Important change" with one was + read as a heading and dropped, leaving a prose-only release with no + collapsed preview at all. A blockquote marker takes at most three leading + spaces for the same reason every other marker here does: accepting any run + let an indented code sample containing "> - sample output" shed its + indentation and be shown as the summary.""" + src = PREVIEW.read_text(encoding="utf-8") + assert "const HEADING = /^#{1,6}(?:[ \\t]|$)/;" in src + assert "const HEADING_LINE = /^ {0,3}#{1,6}(?:[ \\t]|$)/;" in src + assert "const BLOCKQUOTE = /^ {0,3}>[ \\t]?/;" in src + # The backend rule this mirrors. + backend = (BACKEND / "utils" / "changelog.py").read_text(encoding="utf-8") + assert "^ {0,3}##(?:[ \\t]+(?P<title>.*?))?[ \\t]*$" in backend + + +def test_preview_collects_labels_only_from_real_definitions(): + """A definition-shaped line inside an indented code block or a deep fence is + literal text, so CommonMark leaves a later "[Beta] support" unresolved with + its brackets showing. Recording the label anyway made toPlainText strip them + in the collapsed preview, so it disagreed with the expanded view. The + pre-scan skips the same code the collector pass skips; a real definition + takes at most three spaces of indentation, so the indent test cannot reject + one.""" + src = PREVIEW.read_text(encoding="utf-8") + scan = src.index("const labels = new Set<string>();") + collect = src.index("let deepFence: string | null = null;") + prescan = " ".join(src[scan:collect].split()) + assert "let labelFence: string | null = null;" in prescan + assert "if (line.indent - line.column >= INDENTED_CODE_INDENT) { continue; }" in prescan + assert "endsDeepFence(labelFence, labelColumn, line)" in prescan + + +def test_an_html_block_to_the_left_of_a_list_item_closes_it(changelog_module): + """Types 1 to 6 interrupt a paragraph, so an unindented <div> after "- item" + closes the item and a following one-to-three-space-indented "## 2.0" is a + real document heading. It was read as a lazy paragraph continuation, so the + item stayed open and the release below the block was swallowed.""" + text = "## 3.0\n\n- item\n<div>\nhidden\n</div>\n\n ## 2.0\n\n- two\n\n## 1.0\n\n- one\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["3.0", "2.0", "1.0"] + # Without the block the heading really is nested, so it stays suppressed. + nested = "## 3.0\n\n- item\n\n ## 2.0\n\n- two\n\n## 1.0\n\n- one\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["3.0", "1.0"] + # Ordinary lazy continuation is untouched. + lazy = "## 3.0\n\n- item\ncontinued\n\n ## 2.0\n\n## 1.0\n\n- one\n" + assert [e.version for e in changelog_module.parse_changelog(lazy)] == ["3.0", "1.0"] + + +def test_the_download_panel_can_shrink_inside_the_capped_stack(): + """The bottom-right stack is capped to the viewport, and a flex item defaults + to min-height:auto, so this wrapper could not shrink below its own content. + On a short viewport the cap was then absorbed by the update card, whose + header and actions are fixed, rather than by the download list, which + scrolls. Only the shared-stack branch needs it; standalone is positioned + fixed and is not a flex item at all.""" + panel = (FRONTEND / "features/hub/download-manager/download-manager-panel.tsx").read_text( + encoding="utf-8" + ) + assert 'positioned ? "fixed bottom-4 right-4 z-50" : "flex min-h-0 justify-end"' in panel + provider = (FRONTEND / "app/provider.tsx").read_text(encoding="utf-8") + assert "max-h-[calc(100dvh_-_2rem)]" in provider, "the cap this has to absorb" + + +@pytest.fixture(scope="module") +def run_scanner(tmp_path_factory): + """Run the frontend's markdown scanners under node. + + Their job is to classify a line the way a CommonMark renderer would, which + only a real run can show. The sources are copied with their "@/lib" aliases + rewritten, because that alias resolves through Vite and not through node.""" + node = shutil.which("node") + if node is None: + pytest.skip("node is needed to run the TypeScript scanners") + work = tmp_path_factory.mktemp("release-notes-scanners") + for source in (PREVIEW, CODE_SPANS, LINKS, LIST_COLUMNS, INLINE_COMMENTS): + rewritten = _TS_ALIAS.sub(r'"./\1.ts"', source.read_text(encoding="utf-8")) + (work / source.name).write_text(rewritten, encoding="utf-8") + (work / "run.ts").write_text(_TS_RUNNER, encoding="utf-8") + + def run(kind: str, markdown: str): + result = subprocess.run( + [node, "--experimental-strip-types", "--no-warnings", str(work / "run.ts"), kind], + input=markdown, + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.skip(f"node could not run the scanners: {result.stderr.strip()[:200]}") + return json.loads(result.stdout) + + return run + + +def preview_leads(preview) -> list[str]: + return [item["lead"] for item in preview["items"]] + + +def test_a_link_indented_under_a_bullet_still_resolves(run_scanner): + """CommonMark measures indentation from the container, not the margin + (spec 0.31.2 section 5.2, list items). Under "- Details:" the content column + is 2, so a four-space line is only two columns in: a paragraph holding a + link, which GitHub renders and follows. The scanner measured from the margin + instead, called it an indented code block (section 4.4) and left the + destination relative, so the link resolved against Studio's own origin.""" + resolved = run_scanner("links", "- Details:\n\n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in resolved + # The same prose one column further in really is code, and stays untouched. + code = run_scanner("links", "- Added.\n\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + # At document level four spaces is code, so that link is still left alone. + top = run_scanner("links", "Intro.\n\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in top and "github.com" not in top + + +def test_an_indented_fence_does_not_swallow_the_bullets_below_it(run_scanner): + """A four-space line at document level is an indented code block, and a + top-level bullet is not indented enough to continue it, so the block ends + and the list renders. Promoting the line to a list-contained fence left a + block open with no closer, so every bullet after it was skipped and the + collapsed popup lost its summary.""" + swallowed = "Example:\n\n ```\n\n- Added the exporter\n- Fixed the crash\n" + assert preview_leads(run_scanner("preview", swallowed)) == [ + "Added the exporter", + "Fixed the crash", + ] + # With nothing else to fall back on the summary disappeared entirely. + assert preview_leads(run_scanner("preview", " ```\n\n- Added the exporter\n")) == [ + "Added the exporter" + ] + # A fence that really is inside an item still hides that item's code. + nested = "- a\n - b\n ```\n - not a bullet\n ```\n\n- Added tests\n" + assert preview_leads(run_scanner("preview", nested)) == ["a", "Added tests"] + + +def test_a_table_only_release_previews_as_nothing(run_scanner): + """A release written as a GFM table renders as a grid, and the panel treats + notes that preview as nothing by staying collapsed rather than showing an + empty strip. Falling through to the prose collector put the raw + "| Change | Detail | | --- | --- |" delimiters in the popup instead.""" + table = "| Change | Detail |\n| --- | --- |\n| Exporter | Added GGUF |\n" + assert run_scanner("preview", table)["items"] == [] + # A table after prose is dropped too, rather than joined onto it. + assert preview_leads(run_scanner("preview", f"Some prose.\n\n{table}")) == ["Some prose."] + # A bullet right after the rows ends the table, so it still previews. + assert preview_leads(run_scanner("preview", f"{table}- Added tests\n")) == ["Added tests"] + # Mismatched header and delimiter widths are no table, as on GitHub, so both lines are prose. + assert preview_leads(run_scanner("preview", "| a | b |\n| --- |\n")) == ["| a | b | | --- |"] + + +def test_a_fence_inside_a_list_item_ends_with_the_item(changelog_module): + """A fence is scoped to its container: with no closer it runs to the end of + the containing block, not the document (spec 0.31.2 section 4.5). A + dedented "## 2.0" closes the list item, so it is a real release heading. + Document-wide fence state kept the block open and hid every release below + it, so one missing closing line emptied the rest of the changelog.""" + text = "## 1.0\n\n- item\n ```\n\n## 2.0\n\n- two\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + # A fence at document level still runs to the end of the file. + top = "## 1.0\n\n```\n\n## 2.0\n\n- two\n" + assert [e.version for e in changelog_module.parse_changelog(top)] == ["1.0"] + # A closed fence inside an item is unaffected, and its sample stays hidden. + closed = "## 1.0\n\n- Run:\n ```bash\n ## 9.9.9\n ```\n\n## 2.0\n\n- two\n" + assert [e.version for e in changelog_module.parse_changelog(closed)] == ["1.0", "2.0"] + # Content dedented out of the item ends the item and the fence with it. + assert changelog_module.find_release_notes(text, "2.0").body == "- two" + + +def test_stripping_comments_stays_linear_in_the_code_spans(changelog_module): + """The comment scanner restarted its code-span search at the first span for + every opener, so a line of N spans and N openers cost N squared. A 203 KiB + line is well inside the 2 MiB the fetcher accepts, and notes are reparsed on + every request, so one such line held a worker for over ten seconds.""" + line = "`a` <!--x--> " * 16_000 + assert len(line) < changelog_module.CHANGELOG_MAX_BYTES + started = time.monotonic() + visible, in_comment = changelog_module._strip_comments(line, False, False) + elapsed = time.monotonic() - started + # Roughly 40ms scanning forward against roughly 11s restarting each time. + assert elapsed < 2.0, f"comment stripping took {elapsed:.1f}s" + # Same result as before: the spans survive and the comments are gone. + assert in_comment is False + assert "<!--" not in visible and visible.count("`a`") == 16_000 + + +def test_the_three_scanners_share_one_list_column_rule(): + """The parser and both frontend scanners have to classify a line the same + way, and drifting apart on indentation is what put a paragraph link inside a + code block. The frontend pair reads its list columns from one module, ported + from the backend's own tracker.""" + shared = LIST_COLUMNS.read_text(encoding="utf-8") + assert "export function openLists(" in shared + assert "_open_lists" in shared, "the backend function this mirrors" + for source in (PREVIEW, LINKS): + src = source.read_text(encoding="utf-8") + assert 'from "@/lib/markdown-list-columns"' in src + assert "openLists(" in src + # Both sides measure indented code from the container, not from the margin. + backend = (BACKEND / "utils" / "changelog.py").read_text(encoding="utf-8") + assert "_indent_width(visible) - column >= 4" in backend + assert "indentWidth(structure) - column >= INDENTED_CODE_INDENT" in LINKS.read_text( + encoding="utf-8" + ) + + +def test_a_failed_fetch_keeps_retry_reachable(): + """The fallback stands in for "no section for this version", which the hook + reports as ready. A failed fetch is reported as error and is retryable, and on + desktop the fallback is the updater's static install blurb, so taking it there + replaced the Retry button with generic text until the cache expired.""" + src = " ".join(PANEL.read_text(encoding="utf-8").split()) + assert 'notes?.matched ? notes.markdown : state === "error" ? null' in src + # Only NotesStatus renders retry, in the else of the markdown branch: an error has no markdown. + assert "{markdown ? (" in src + assert "retry={retry}" in src + + hook = " ".join( + (FRONTEND / "hooks" / "use-release-notes.ts").read_text(encoding="utf-8").split() + ) + assert ( + "const failed = !next || (!next.matched && next.error !== null);" in hook + ), "the distinction this relies on" + + +def test_an_unclosed_comment_in_prose_cannot_hide_later_links(run_scanner): + """CommonMark opens an HTML block (spec 0.31.2 section 4.6, type 2) only + when the line itself begins with `<!--`; one written mid-sentence is inline + raw HTML and cannot outlive the block it sits in. The link resolver carried + the unclosed state to every following line instead, so a note that merely + mentions the delimiter masked the relative links under it and they resolved + against Studio's own origin.""" + repo = "https://github.com/unslothai/unsloth/blob/main/docs/a.md" + # A separate list item is a separate block, so the link below still renders. + item = run_scanner("links", "- Type <!-- to begin a comment\n- See [docs](docs/a.md)\n") + assert repo in item + # So does a paragraph the blank line already ended. + paragraph = run_scanner("links", "Type <!-- to begin\n\nSee [docs](docs/a.md)\n") + assert repo in paragraph + # A delimiter inside inline code is literal, as it is for the parser. + spanned = run_scanner("links", "- Wrap in `<!--` and `-->`\n- See [docs](docs/a.md)\n") + assert repo in spanned + # A comment starting a line is a block: it hides down to the closer's line, that line included. + block = run_scanner("links", "<!-- staged\n- See [docs](docs/a.md)\n-->\n") + assert repo not in block + closer = run_scanner("links", "<!-- staged\n--> See [docs](docs/a.md)\n") + assert repo not in closer + + +def test_a_bare_level_two_marker_ends_the_release(changelog_module, run_scanner): + """An ATX heading's opening sequence may be followed by the end of the line + (spec 0.31.2 section 4.2), so a bare `##` is an empty level-two heading. The + scanners required whitespace after the hashes, so everything below such a + line stayed inside the release above it and the popup showed unrelated notes + under that version.""" + text = "## 2.0\n\n- new thing\n\n##\n\n- SECRET: not part of 2.0\n" + entry = changelog_module.find_release_notes(text, "2.0") + assert "new thing" in entry.body + assert "SECRET" not in entry.body + # An empty heading has no version, so it ends a release without indexing one. + assert [e.version for e in changelog_module.parse_changelog(text)] == ["2.0"] + # Prose still needs a space or a tab: `##x` is a paragraph, not a heading. + prose = "## 2.0\n\n- new thing\n\n##x\n\n- still 2.0\n" + assert "still 2.0" in changelog_module.find_release_notes(prose, "2.0").body + # The preview agrees: an empty heading renders as nothing, so it ends the bullet. + preview = run_scanner("preview", "- new thing\n##\nUnrelated scratch notes\n") + assert preview_leads(preview) == ["new thing"] + + +def test_a_comment_between_bullets_closes_the_list(changelog_module, run_scanner): + """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one + written at the margin under a bullet is not indented enough to continue that + item and closes the list. The scanners blanked the line before list tracking + saw it, which reads as a blank line and leaves the item open, so the release + heading below it looked like nested item content and the new release was + merged into the one above.""" + text = "## 1.0\n\n- old item\n<!-- separator -->\n ## 2.0\n\n- new item\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + assert "new item" not in changelog_module.find_release_notes(text, "1.0").body + assert "new item" in changelog_module.find_release_notes(text, "2.0").body + # At the item's content column the comment stays inside it, so the heading under it is nested. + nested = "## 1.0\n\n- old item\n <!-- separator -->\n ## 2.0\n\n- new item\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # The link resolver reads the same column: list closed, four spaces is code, left untouched. + code = run_scanner("links", "- old item\n<!-- separator -->\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + # Inside the item those four spaces are two columns in, so it is prose and the link resolves. + prose = run_scanner("links", "- old item\n <!-- separator -->\n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in prose + # The preview agrees: the fence is indented code, not a fence swallowing the bullet below. + preview = run_scanner( + "preview", + "- Details:\n<!-- separator -->\n ```\n - hidden sample\n- Real second item\n", + ) + assert preview_leads(preview) == ["Details:", "Real second item"] + + +def test_a_parenthesised_link_destination_still_resolves(run_scanner): + """A destination may hold parentheses while they balance (spec 0.31.2 + section 6.3), so `[x]((draft).md)` points at `(draft).md`. The resolver's + destination expression stopped at the first paren, matched an empty + destination and left the markdown alone, so the link resolved against + Studio's own origin instead of the repository.""" + leading = run_scanner("links", "[details]((draft).md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/(draft).md" in leading + # An image resolves against the raw host the same way. + image = run_scanner("links", "![shield]((badge).png)\n") + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/(badge).png" in image + # A pair in the middle of a path balances too. + middle = run_scanner("links", "[api](docs/(v2)/api.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/(v2)/api.md" in middle + # An unbalanced paren makes the destination invalid, so `[x](a(b.md)` is plain text, not a link. + unbalanced = run_scanner("links", "[x](a(b.md)\n") + assert unbalanced == "[x](a(b.md)\n" + # One more closer balances the pair, and then it is a link again. + closed = run_scanner("links", "[x](a(b.md))\n") + assert "https://github.com/unslothai/unsloth/blob/main/a(b.md)" in closed + # Pairs nest, and one level was all the expression allowed, so a path with two stayed relative. + nested = run_scanner("links", "[x](((draft)).md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/((draft)).md" in nested + deep = run_scanner("links", "![shot](((((v2))))).png)\n") + assert "https://raw.githubusercontent.com/unslothai/unsloth/main/((((v2))))" in deep + # The closer must still be there: an unbalanced run below a nested pair is not a link. + across = run_scanner("links", "[x](((a).md\n[y](docs/y.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/y.md" in across + assert "[x](((a).md" in across + + +def test_a_fence_inside_a_container_still_hides_its_sample(run_scanner): + """A fence is measured from its container and not from the margin (spec + 0.31.2 section 4.5), so `> ~~~` and a fence three columns under a nested + bullet open one. Reading the margin instead never saw them, so the sample + inside was treated as prose and a relative link written in a code block was + rewritten into the text the reader sees verbatim.""" + quoted = run_scanner("links", "> ~~~\n> [guide](docs/a.md)\n> ~~~\n") + assert "[guide](docs/a.md)" in quoted and "github.com" not in quoted + nested = run_scanner("links", "- a\n - b\n ~~~\n [x](docs/x.md)\n ~~~\n") + assert "[x](docs/x.md)" in nested and "github.com" not in nested + # A longer closer is still a closer, so the pair is not something a code span hid. + uneven = run_scanner("links", "> ```\n> [guide](docs/a.md)\n> ````\n") + assert "[guide](docs/a.md)" in uneven and "github.com" not in uneven + # The fence ends with its container: a line outside the quote, or left of the item, is Markdown. + left = run_scanner("links", "> ~~~\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in left + dedented = run_scanner("links", "- a\n ~~~\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in dedented + # A document-level fence owns the quoted lines below, so the marker does not undo it. + document = run_scanner("links", "~~~\n> [guide](docs/a.md)\n~~~\n") + assert "[guide](docs/a.md)" in document and "github.com" not in document + # Four columns past the item's content column it is indented code, not a fence: still literal. + code = run_scanner("links", "- Details:\n\n ~~~\n [guide](docs/a.md)\n") + assert "[guide](docs/a.md)" in code and "github.com" not in code + + +def test_an_html_block_inside_a_container_is_literal_too(run_scanner): + """Type 1 and type 6 blocks are measured from their container the same way, + so a `<details>` under a nested bullet and a `<pre>` inside a quote both + show their contents verbatim. Missing the opener treated the body as + Markdown and rewrote the literal examples in it.""" + nested = run_scanner("links", "- a\n - b\n <details>\n [x](docs/x.md)\n </details>\n") + assert "[x](docs/x.md)" in nested and "github.com" not in nested + quoted = run_scanner("links", "> <pre>\n> [x](docs/x.md)\n> </pre>\n") + assert "[x](docs/x.md)" in quoted and "github.com" not in quoted + # The block ends with its container, so a line dedented out of the item is Markdown again. + dedented = run_scanner("links", "- a\n - b\n <details>\n[x](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in dedented + # Inside a quote a bare marker holds nothing, the blank line that ends a type 6 block. + blank = run_scanner("links", "> <details>\n>\n> [x](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in blank + + +def test_an_underline_left_of_an_item_is_lazy_text_of_it(changelog_module, run_scanner): + """A setext underline may never be a lazy continuation line (spec 0.31.2 + section 4.3), so `===` written left of an open list item is read as more of + the item's paragraph rather than as a block that closes it. Rejecting every + underline-shaped line ended the list there, which promoted the nested + "## 2.0" below it to a document-level heading and indexed a release the + renderer never shows.""" + nested = "## 1.0\n- old note\n===\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # A row of dashes is a thematic break, closing the item, so the heading is the next release. + broken = "## 1.0\n- old note\n---\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(broken)] == ["1.0", "2.0"] + # With no paragraph above it the underline opens one, so the blank line closes the item. + apart = "## 1.0\n- old note\n\n===\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0", "2.0"] + # The link scanner keeps the item open, so the four-space line is a paragraph and resolves. + resolved = run_scanner("links", "- Details:\n===\n\n [guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in resolved + + +def test_a_quote_keeps_its_paragraph_to_itself(changelog_module, run_scanner): + """Lazy continuation runs the other way too: a marker written outside a + blockquote is not text of the quote's paragraph, so `2. item` under + `> quote` opens a list even though an ordered marker past 1 may not + interrupt a paragraph (spec 0.31.2 section 5.2). Lending the quote's + paragraph to the document left the list closed, so the heading indented to + the item's content column read as a release of its own.""" + quoted = "## 1.0\n> quote\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(quoted)] == ["1.0"] + # A quote holding a heading leaves no paragraph, nor does an empty one, so the list opens. + heading = "## 1.0\n> # inner\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(heading)] == ["1.0"] + # An unquoted line the quote's paragraph swallows keeps it open, the marker still outside. + lazy = "## 1.0\n> quote\ntext\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(lazy)] == ["1.0"] + # Under an ordinary paragraph the marker is its text, so no list opens and the heading is real. + prose = "## 1.0\nprose\n2. item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(prose)] == ["1.0", "2.0"] + # The preview reads the marker as a bullet for the same reason. + assert preview_leads(run_scanner("preview", "> quote\n2. item\n")) == ["item"] + + +def test_indented_code_before_an_ordered_marker_still_opens_a_list(changelog_module): + """An indented code block ends at the first line that is not indented enough + to continue it, and no paragraph is open for the marker below to continue, + so `2. item` opens a list whatever its start number. Reading it as text of + the code block instead would leave the list closed and index the heading at + the item's content column as a release.""" + joined = "## 1.0\n\n code\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(joined)] == ["1.0"] + # A blank line between the two changes nothing: the list opens either way. + apart = "## 1.0\n\n code\n\n2. item\n ## 2.0\n- new\n" + assert [e.version for e in changelog_module.parse_changelog(apart)] == ["1.0"] + # Four columns past its container the marker is code, so no list opens and the heading stands. + inside = "## 1.0\n\n code\n - item\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(inside)] == ["1.0", "2.0"] + + +def test_a_fence_written_as_an_item_first_content_opens_in_that_item(run_scanner): + """A block written straight after a list marker is the item's own first + content, measured from the column that content starts (spec 0.31.2 section + 5.2), so "- ```md" opens a fence. Reading the whole line instead never saw + one, so the code sample below it was treated as prose: the resolver rewrote + a destination the reader sees verbatim, and the preview offered the info + string as a headline bullet.""" + sample = run_scanner("links", "- ```md\n [example](docs/a.md)\n ```\n") + assert "[example](docs/a.md)" in sample and "github.com" not in sample + ordered = run_scanner("links", "1. ~~~\n [example](docs/a.md)\n ~~~\n") + assert "[example](docs/a.md)" in ordered and "github.com" not in ordered + # The preview agrees: an item of only a code block previews as nothing; the next is a bullet. + preview = run_scanner("preview", "- ```md\n sample text\n ```\n- Added tests\n") + assert preview_leads(preview) == ["Added tests"] + # One column further in it is indented code inside the item, so the link is prose and resolves. + padded = run_scanner("links", "- ```\n [example](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in padded + # A marker the paragraph above swallows opens no item, so no fence: ordered items open at 1. + lazy = run_scanner("links", "Intro.\n2. ```\n[guide](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in lazy + + +def test_an_html_block_ends_with_the_item_it_was_written_in(changelog_module, run_scanner): + """An HTML block holds no lazy continuation line, so one opened on a list + item's continuation line ends where the item does, exactly as a fence there + does. Ending it only on a blank line let it run past the item and swallow + the next release heading, so those notes could never be found, and the + collapsed preview lost every bullet below it.""" + text = "## 1.0\n\n- item\n\n <div>\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0", "2.0"] + assert "new thing" in changelog_module.find_release_notes(text, "2.0").body + # A raw block such as <pre> is scoped the same way. + raw = "## 1.0\n\n- item\n\n <pre>\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(raw)] == ["1.0", "2.0"] + # At the item's content column the block holds the heading, which is nested and indexes nothing. + nested = "## 1.0\n\n- item\n\n <div>\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(nested)] == ["1.0"] + # The preview reads it the same way: the bullet below the block is a bullet. + preview = run_scanner("preview", "- item\n\n <div>\n- Added tests\n") + assert preview_leads(preview) == ["item", "Added tests"] + # An opener straight after a marker opens in that item, so the dedented heading is a release. + marked = "## 1.0\n\n- <div>\n## 2.0\n\n- new thing\n" + assert [e.version for e in changelog_module.parse_changelog(marked)] == ["1.0", "2.0"] + + +def test_a_comment_may_close_on_a_later_line_of_its_paragraph(run_scanner): + """A comment written mid-sentence is inline raw HTML belonging to the + paragraph around it, so its `-->` may arrive on a later line of that same + paragraph and everything between renders as nothing. Ending the comment at + its own line left a backtick inside it pairing with a real one below, which + hid a following link from the resolver, and left the collapsed preview + quoting text the popup body does not show.""" + carried = run_scanner("links", "Note <!-- ` open\nstill --> see [d](docs/a.md) and `x`\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in carried + # Text inside the comment renders as nothing, so it is left alone. + inside = run_scanner("links", "Note <!-- see [c](docs/c.md)\nmore --> end\n") + assert "[c](docs/c.md)" in inside and "github.com" not in inside + # The preview hides it too, rather than quoting the comment at the reader. + preview = run_scanner( + "preview", "- Added X <!-- TODO: rewrite\n this properly -->\n- Second\n" + ) + assert preview_leads(preview) == ["Added X", "Second"] + # An opener cannot outlive its paragraph: with it closed the `<!--` is text and hides nothing. + broken = run_scanner("links", "Note <!-- open\n\nsecret --> end [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken + # A heading breaks into the paragraph, so it ends the comment's reach too. + headed = run_scanner("links", "Note <!-- open\n## 2.0 --> end [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in headed + assert preview_leads(run_scanner("preview", "Note <!-- open\n\n- Second\n")) == ["Second"] + + +def test_only_punctuation_is_escapable_in_a_link_destination(run_scanner): + """CommonMark escapes ASCII punctuation and nothing else (spec 0.31.2 + section 2.4), so the backslash in `docs\\alpha.md` is a character of the + path. Dropping every backslash rewrote it to a path that does not exist, + and a URL parser reads what is left as a separator, so a Windows or + namespaced path pointed at the wrong file either way.""" + kept = run_scanner("links", "[guide](docs\\alpha.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs%5Calpha.md" in kept + # An escaped backslash is one literal backslash, which survives the same. + escaped = run_scanner("links", "[guide](docs\\\\alpha.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs%5Calpha.md" in escaped + # A real escape is still an escape: `\\(` is a paren of the path. + paren = run_scanner("links", "[guide](a\\(b.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/a(b.md" in paren + # A space still ends the destination, escaped or not, so there is no link. + spaced = run_scanner("links", "[guide](a\\ b.md)\n") + assert spaced == "[guide](a\\ b.md)\n" + + +def test_one_definition_does_not_hide_the_next(run_scanner): + """Definitions may run consecutively (spec 0.31.2 section 4.7): a block of + them is how a changelog collects its link targets. A definition is not + paragraph text, so it opens no paragraph for the next one to be unable to + interrupt. The resolver counted one as prose, which left every definition + after the first outside the set of lines a definition may start on, so only + the first was rewritten and the rest resolved against Studio's own origin. + The backend already reads the line this way.""" + text = ( + "- AMD support is here, see [the AMD guide][amd] and the\n" + " [Intel notes][xpu].\n\n" + "[amd]: docs/basics/amd.md\n" + "[xpu]: docs/basics/xpu.md\n" + ) + resolved = run_scanner("links", text) + base = "https://github.com/unslothai/unsloth/blob/main/docs/basics/" + assert f"[amd]: {base}amd.md" in resolved + assert f"[xpu]: {base}xpu.md" in resolved + # A run of them stays a run however long it is. + run = run_scanner("links", "[a]: docs/a.md\n[b]: docs/b.md\n[c]: docs/c.md\n") + assert run.count("https://github.com/unslothai/unsloth/blob/main/docs/") == 3 + # Prose between them opens a paragraph the next line may not interrupt, so it is not one. + prose = run_scanner("links", "[a]: docs/a.md\nintro\n[b]: docs/b.md\n") + assert "[b]: docs/b.md" in prose + + +def test_a_comment_closed_on_its_own_line_still_closes(run_scanner): + """A multiline comment is ordinarily closed by a `-->` written on a line of + its own, and a wrapped line may open with emphasis. The guard asking whether + the closer is reachable read any line whose first character was punctuation + as the start of a new block, so neither shape counted as more of the + paragraph carrying the comment. The comment then never closed, and the + collapsed popup showed the author's internal note to the user.""" + closer = run_scanner( + "preview", + "- DoRA training is available in Studio. <!-- TODO confirm the exact\n" + " flag name before release\n-->\n", + ) + assert preview_leads(closer) == ["DoRA training is available in Studio."] + # A continuation may open with emphasis, which is text and not a block. + starred = run_scanner( + "preview", + "- DoRA training is available. <!-- TODO confirm the\n *before* release -->\n", + ) + assert preview_leads(starred) == ["DoRA training is available."] + underscored = run_scanner( + "preview", + "- DoRA training is available. <!-- TODO confirm the\n _draft_ note -->\n", + ) + assert preview_leads(underscored) == ["DoRA training is available."] + # A real block still ends the paragraph, so the opener below one is text and hides nothing. + broken = run_scanner("links", "Note <!-- open\n## 2.0\nsecret --> [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in broken + # So does a list item with content, which may interrupt a paragraph. + item = run_scanner("links", "Note <!-- open\n- bullet\nsecret --> [d](docs/a.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/a.md" in item + + +def test_a_comment_written_as_an_item_first_content_is_a_block(changelog_module, run_scanner): + """A comment is an HTML block (spec 0.31.2 section 4.6, type 2), so one + written as a list item's first content opens inside that item, exactly as a + fence written there does. The scanners looked for the opener at the margin + of the line as written, so a marker in front of it hid the block: the + resolver rewrote a destination inside raw HTML, which Streamdown then shows + the reader as a literal URL, and the preview quoted the hidden note back at + them as though the bullet were Markdown.""" + item = run_scanner("links", "- <!-- new --> AMD support, see [the guide](docs/amd.md)\n") + assert item == "- <!-- new --> AMD support, see [the guide](docs/amd.md)\n" + # Every marker opens an item, and a nested one is still an item. + for text in ( + "* <!-- new --> see [the guide](docs/amd.md)\n", + "1. <!-- new --> see [the guide](docs/amd.md)\n", + "- outer\n - <!-- new --> see [the guide](docs/amd.md)\n", + ): + assert "github.com" not in run_scanner("links", text) + # The multiline form hides lines to the closer, as a comment at the item's content column did. + multiline = run_scanner("links", "- <!-- hidden\n [a](docs/x.md)\n -->\n") + assert "[a](docs/x.md)" in multiline and "github.com" not in multiline + # Still scoped to the item it was written in, so a line dedented out of it ends the block. + dedented = run_scanner("links", "- <!-- hidden\n[a](docs/x.md)\n") + assert "https://github.com/unslothai/unsloth/blob/main/docs/x.md" in dedented + # The preview agrees: an item of only the block previews as nothing; the next is a bullet. + preview = run_scanner("preview", "- <!-- new --> hidden note\n- Real bullet\n") + assert preview_leads(preview) == ["Real bullet"] + # The parser agrees too: the item keeps its column, so a heading inside is nested, not indexed. + text = "## 1.0\n\n- <!-- hidden\n\n ## 2.0\n" + assert [e.version for e in changelog_module.parse_changelog(text)] == ["1.0"]