Show release notes in the update popup, sourced from CHANGELOG.md (#7432)
* Show release notes in the update popup, sourced from CHANGELOG.md The update banner only linked out to the online changelog, so there was no way to see what an update contains before taking it. Add CHANGELOG.md at the repo root as the source of release notes. Studio reads it from the default branch, so editing the file updates the popup without a release or rebuild, and falls back to the copy bundled in the install when the repo is unreachable. Notes are matched 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. When there is no match the popup links out to the online changelog instead. The collapsed popup previews the top bullets with the leading sentence highlighted; "Show release notes" expands the full notes in a scrollable panel. Applies to both the browser and desktop banners, and the desktop updater's own release body is used when CHANGELOG.md has no matching section. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: fence matching, nested bullets, BOM, updater notes field Track the opening fence marker and length so a ``` sample inside a ```` block does not close it early and let the sample's heading be indexed as a real release. Preserve list indentation in the preview and take only top-level bullets, so nested detail no longer consumes the four headline slots. Strip a UTF-8 BOM before parsing. An editor on Windows can leave one on the first line, which hid a section whose heading started the file. Read `notes`/`pub_date` from latest.json in the manual Linux updater path, with aliases for the older `body`/`date`. The workflow publishes Tauri's field names, so the manual path's release body was always empty. Also loop the preview tag strip until stable for CodeQL js/incomplete-multi-character -sanitization; the value renders as text, so this is defence in depth. * Address review: bare fence closers, HTML comments, underscores, notes URL A closing fence must carry nothing after the delimiter, so a ```` line with trailing text inside a ```` block is content rather than the end of it. Both the parser and the preview extractor follow that rule now. Skip headings inside HTML comments. A commented-out section is not rendered by Markdown, so it must not be indexed as a release. Strip only paired emphasis and park code spans first, so identifiers keep their underscores: UNSLOTH_DISABLE_UPDATE_CHECK was previewing as UNSLOTHDISABLEUPDATECHECK. Prefer the caller's release URL over the API's generic changelog link, so the desktop fallback points at the release page for the version being offered. Look at the repo-root CHANGELOG.md before the packaging snapshot, and remove the snapshot after build.sh, so an edited root file is never shadowed by a stale copy. Also nudge the notes container radius from 16px to 14px. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: comparison operators, hidden comments, remote failures Require a name character after "<" when stripping tags. A bullet reading "Support Python <3.15 and >3.9" previewed as "Support Python 3.9", because the operators were consumed as if they were a tag. Track HTML comments while collecting preview lines. A commented-out bullet was previewed as a published change even though Markdown never renders it. Report a remote lookup failure whenever nothing matched. The bundled changelog cannot know a version newer than the install, so discarding the error made an offline lookup read as "no notes were published". The hook now treats a reported failure as its retryable error state. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: code-span delimiters, stale notes, retry past cached failures Treat an HTML comment delimiter inside inline code as literal. A note reading "Type `<!--` to begin a comment" put the parser into comment state, so every release below it was swallowed into the entry above and became unfindable. Applied to the preview extractor too. Return no notes while the offered version differs from the fetched one. On the render where the version changes, the hook still held the previous release's notes, which the panel would show for a frame. Let retry bypass a cached remote failure via a refresh flag on the endpoint. Failures are cached for five minutes, so the visible Retry action could not recover until the TTL expired. A cached success is still reused, so retries cannot hammer the remote. * Address review: CommonMark indentation, desktop release notes link Allow up to three leading spaces on release headings and fences, and treat four as indented code. An indented heading was unreachable and its notes were appended to the release above, while an indented backtick line opened a fence that swallowed later headings. Link desktop release notes to the release page for the offered version on every platform. The existing URL is built only in manual Linux package mode, so in-app updates on macOS, Windows and AppImage fell back to the generic changelog. The install button keeps using the manual URL. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: wrapped prose, autolinks, abbreviations in the preview Accumulate contiguous prose lines into one preview item. A paragraph wrapped across source lines renders as one block but previewed as three fragments, which also ate the four-item limit. Keep Markdown autolinks. <https://example.com/notes> was stripped as if it were a tag, so "See <https://example.com/notes> for details" previewed as "See for details". Do not split the lead sentence at an abbreviation. "Supports several formats, e.g. GGUF and Safetensors." highlighted only up to "e.g." and dimmed the actual change; known abbreviations and single initials are skipped now. * Address review: park code spans first, skip indented code blocks Park code spans before any other inline transformation. Tags, links, images and emphasis inside a span are literal, but the strips ran first, so "Use `<button>` for actions" previewed as "Use for actions". Skip lines inside an indented code block when collecting bullets. A "- pip install ..." line in a four-space-indented block became the headline and pushed out the real prose, though Markdown renders it as code. Continuation lines of an open bullet are unaffected. * Studio: skip raw HTML blocks when reading release notes A <pre>, <script>, <style> or <textarea> block renders literally, so a sample '## 9.9.9' heading inside one was indexed as a release and cut the real section's body short. The preview had the same gap and listed sample bullets as notes. Both readers now track type 1 HTML blocks and skip their contents. Blocks open only at the start of a line, so a tag named mid-sentence stays inline text, and <details> is type 6 so its Markdown still parses. * Studio: read HTML blocks the way CommonMark renders them A fence inside a <pre> block was treated as a real fence, so the block's closing tag was swallowed and every release below it disappeared. Raw HTML state is now checked before fences, in both readers. Type 6 and 7 blocks (<details>, <div>, a bare tag on its own line) run to the next blank line, so a heading pressed against the opening tag is not a release either. Type 7 cannot interrupt a paragraph, so prose followed by a bare tag is unaffected. Checked against a CommonMark reference: 20000 generated well-formed changelogs now agree exactly on which headings are releases, and every previewed note is text the renderer really shows. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: restore preview types dropped in the scanner refactor The previous commit's refactor removed the Bullet and preview item interfaces, so tsc -b failed and every job that builds the frontend stopped there. * Studio: fix release-notes preview and packaging review findings Preview: a code span now closes on a run of the same length, so a note containing backticks keeps them; thematic breaks no longer take a preview slot; a quoted list is example output, so it stays out of the headline bullets and is only used when a section has none of its own. Popup: a failed lookup keeps the changelog link beside Retry, which the web banner always offered before, and the desktop popup waits briefly for the auto-auth token instead of recording a failure the user has to clear. Packaging: the changelog snapshot is made by the build backend, so python -m build, pip install . and sdist builds all ship the offline copy, not only build.sh. * Studio: scope the changelog fallback and hide staged sections Installed, the levels above studio/ are site-packages, so a stray CHANGELOG.md left there by another package outranked the bundled snapshot. Those levels are now searched only when a checkout marker (pyproject.toml or .git) is present, so a source checkout still serves the editable file. A section staged as only an HTML comment renders as nothing but was reported as matched, leaving an empty notes surface. Notes that render nothing now read as unpublished, so the popup links out instead. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: cover the remaining raw block forms and repository links Parser and preview: processing instructions, declarations and CDATA are literal like <pre>, so a sample heading or bullet inside one is no longer read as a release. ATX headings now need a space or tab after the hashes, matching CommonMark, so a pasted non-breaking space no longer truncates the release above it. Popup: the notes region follows the viewport and the card scrolls as a backstop, so a window under about 430px high no longer pushes the title and dismiss control off screen. Relative links in the notes resolve against the repository instead of Studio's origin, where the renderer blocked them. * Studio: reference-style images, empty previews and version queries Reference definitions now resolve against the raw host when the label is used as an image, so ![alt][arch] loads the file instead of its HTML page on GitHub. Labels are matched the way CommonMark compares them, and a reference written inside a fenced block does not count. Notes that preview as nothing, such as a lone command block, no longer leave an empty muted strip in the collapsed popup; expanding still shows them. A version query that cannot parse is rejected up front rather than looked up and reported as no notes. * Studio: Markdown scanning fixes across the release notes path Code spans are now scanned rather than matched by pattern, so a run of backticks closes only on a run of the same length. The preview and the link resolver share that scanner, so a link inside `a``b [x](y.md)` stays literal in both. Also: a closing fence may carry only spaces or tabs, so a delimiter with a non-breaking space after it stays code in all three scanners; escaped parentheses in a link target resolve to the literal path instead of being mangled; the collapsed preview decodes entities the way the expanded view renders them, while code spans stay literal; and release notes are fetched through authFetch so an expired access token is refreshed and retried. * Changelog: real 2026.7.5 notes, led by the AMD release Fills the section the popup reads with the actual headline changes, so the collapsed preview shows real content instead of placeholder notes. Leads with AMD support and covers the 23 July update: RDNA2 and Gorgon Halo, Strix Halo detection, RDNA4 and ROCm failure recovery, 2x faster unified memory loading, whisper.cpp dictation, and rollback environment cleanup. * Studio: fix release-notes text handling found by adversarial testing Line endings are normalised first: a CRLF body from the desktop updater no longer hides fences, so a code sample cannot become a headline bullet, and lone CR text splits into bullets. Preview: reference links and images render as their text, a definition line renders as nothing, parentheses in a destination no longer truncate the sentence, escaped punctuation stays literal, and a fence indented into a list item is treated as the block it is. Links: a badge resolves both its image and its outer link, indented code and code spans that cross a line are left alone, a definition cannot interrupt a paragraph, and image alt text no longer decides a label's host. Also: an escaped backtick cannot open a code span, park sentinels in the source cannot swap content, two in-flight requests for one version resolve in order, and repeated bullets no longer share a React key. Comment scanning no longer rescans code spans per delimiter and span lookup is a binary search: the worst inputs measured drop from 96ms to 1ms at the 20k cap, and from 544ms to 15ms at 200k. * Studio: parser and fetch fixes found by adversarial testing A comment marker written in prose no longer swallows the rest of the file. Only a comment that starts a line opens a block; one written mid-sentence is inline HTML and hides its own line at most. This was the worst case found: a single stray marker made every release below it unreachable and served their notes under the newer version's heading. Also in the parser: a closing delimiter takes its whole line, so a heading glued after it is not a release; an exact heading is never shadowed by a zero-padded one; setext headings are release boundaries; any heading, rule or definition ends a paragraph; and the code-span guard is a linear scan rather than a backtracking pattern, so 20k backticks parse in a millisecond instead of over a minute. Fetching: one deadline for the whole response with chunked reads, so a trickling server cannot hold a worker for minutes, waiters give up instead of queueing behind a stalled fetch, and identity encoding is requested so a compressing proxy cannot produce mojibake notes. Truncated notes close an open fence. UI: images and the renderer's own link dialog are held inside the card, which the shared preview's blanket max-width reset had let escape, and only the notes region scrolls so the dismiss control stays reachable on a short viewport. The developer update override no longer beats the documented opt-out, and its value has to parse as a version. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: CommonMark paragraph and block rules across the notes path Setext detection now requires plain paragraph text above the underline. A list item followed by --- is a list and a rule, not a heading: reading it as one discarded the bullet and every note after it. A backtick fence whose info string holds a backtick is not a fence, so such a line no longer swallows the releases below it in the parser, the preview and the link resolver. Preview: only an ordered list starting at 1 interrupts a paragraph, an unresolved reference keeps its brackets, a comment written mid-sentence hides its own line at most instead of the rest of the document, a raw block closer takes its whole line, and a code span closer after a backslash still closes, since escapes do not apply inside a span. Links: raw HTML blocks are literal, an escaped opener is not a link, and a definition under a heading is a definition. The overlay stack is capped to the viewport and both overlays can give up height, so a long download list no longer pushes the update card's title and dismiss control off screen. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: desktop notes by backend version, desktop stack cap, fetch budget latest.json now publishes the backend release the desktop build pins, and both desktop paths carry it: the manual metadata check through Rust, and the in-app updater through the raw metadata it already exposes. The popup looks release notes up by that version, so desktop stops asking CHANGELOG.md for an app SemVer it never contains and falling back to the generic installer text. Metadata without the field still parses and behaves as before. The desktop overlay stack is capped to the viewport like the browser one, since the download panel shares it and the card's own cap cannot see a sibling. The fetch budget now bounds each read, not just the gap between reads. Slow headers followed by a slow body held a worker for 5.6s against a 3s budget; it is 3.0s now, and a timeout is reported as one. * Studio: keep list-nested headings out of the release index A `## <version>` heading indented to a list item's content column is inside that item in CommonMark, not a release boundary. Reading it as one truncated the real release and indexed a version that does not exist. parse_changelog now tracks the open list items by the column their content starts at, and only counts a heading left of that column. Supporting rules, each checked against markdown-it (commonmark preset): a marker needs whitespace after it, so `2.0` stays a setext version; an item interrupts a paragraph only when it has content, and an ordered one only when it starts at 1; an empty item takes one blank line; a dedented fence, break or heading closes the item; and `- ## 2.0` is a heading inside the item. * Studio: whole-paragraph setext headings, uppercase declarations, escaped marks Three CommonMark conformance fixes on the notes path, each checked against markdown-it (commonmark preset). A setext heading is the whole paragraph above the underline, so a heading that wraps kept its version only on the first line while the parser read the last: `2026.7.5 - Release` over `July 25` left that release unindexed and its notes unreachable. The parser now tracks every line of the open paragraph, including lazy continuations, and stops at whatever really interrupts it: a quote marker, a bullet, or an ordered marker starting at 1. A type 4 HTML block needs an uppercase letter after `<!`, so prose mentioning `<!note` was hiding every release below it until the next `>`. In the link resolver, `\![alt][label]` renders as a link, so its definition resolves to the file's page on GitHub rather than the raw-content host. * Studio: the preview needs the uppercase declaration rule too The backend parser stopped treating `<!note` as an HTML block, but the collapsed preview still did, so prose mentioning one emptied the preview of every bullet below it while the expanded notes rendered them. A shipped test now pins the two to the same rule. * Treat an empty HTML comment as closed and always release the changelog fetch flag <!--> and <!---> are complete comments in CommonMark: the closer overlaps the opener, so searching for --> past the opener never found it and the scanner stayed in comment state for the rest of the file. An empty comment used as a section marker hid every release below it, in both the backend parser and the frontend preview. get_remote_changelog cleared its single-flight flag only after except Exception, so a BaseException stranded it and every later caller waited out the full deadline for the life of the process. Move the release into a finally. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Compare resolved changelog paths instead of a hardcoded checkout name The ordering assertion matched the string suffix /unsloth/CHANGELOG.md, so it raised StopIteration in any checkout not literally named unsloth, and on Windows the separator is a backslash so the suffix never matched there either. Both are unrelated to the ordering under test. Verified failing on ubuntu-24.04, macos-14-arm64 and windows-2025 alike, and passing after. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scan backtick runs once instead of rescanning the suffix per opener Every unmatched opener rescanned the rest of the line and the outer loop then advanced by a single run, so a line of runs of 1, 2, 3 ... backticks was quadratic: 321 KB took 7.688s, and release notes are reparsed on every popup request, so one malformed remote changelog could tie up backend workers across installed clients. Collect the runs in one pass and walk a cursor per run length, since a length that runs out of partners stays out. Same 321 KB now takes 0.013s and 5 MB takes 0.205s. Verified identical output against the old implementation on 30000 randomized lines. * Read type 6 and 7 HTML containers in the link resolver too The resolver masked only type 1 blocks (pre, script, style, textarea), while the backend parser and the collapsed preview already apply the type 6 and 7 rules, so the three disagreed on the same notes. A <details> or <div> with no blank line inside is a type 6 block whose contents render verbatim, so two things went wrong there: a relative link was rewritten into text the reader sees literally, and a fence inside the block was taken for a real fence, which silently stopped every link below it from resolving. A blank line, not the closing tag, ends these blocks, so the common '<div align="center">' followed by a blank line still holds Markdown and still resolves. * Mask comments before fences, split only on Markdown line endings, stage the snapshot Three separate reports, all confirmed against head. The link resolver tracked no comment state, so a fence delimiter hidden inside an HTML comment was read as a real fence. The fence then stayed open and every visible line below was classified as code, so none of its links resolved: one commented-out draft containing a stray backtick run silently broke the rest of the notes. Comments are masked now, but only outside a fence, since fenced content is literal and a comment opener in it is not one. Commented ranges join the code spans, so a link the reader cannot see is not rewritten either. Verified with 9 cases under node; 2 fail on the previous file. 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 that renders nowhere and truncate the notes above it: measured, the version list went from 2.0, 9.9.9, 1.0 to 2.0, 1.0 and the 2.0 body stopped being cut at the separator. The build wrote the snapshot beside the checked-in sources, so a PEP 517 build against an immutable checkout (Nix, Bazel, a read-only container mount) raised PermissionError before build_py started and produced no wheel at all. The source-tree copy is best effort now and the wheel takes its copy from the staging directory. Reproduced both ways against a read-only package dir. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use the backend's heading and quote marker rules in the preview An ATX heading needs an ASCII space or tab after the marker, which is exactly what _HEADING_PATTERN requires. The \s class also matches a non-breaking space, so prose beginning "## Important change" with one was classified as a heading and discarded by collectBullets, and a prose-only release then had no collapsed preview at all rather than a wrong one. A blockquote marker takes at most three leading spaces, like every other marker in this file. Accepting any run let an indented code sample containing "> - sample output" shed its indentation and enter the collector, so a release with no real bullets showed code as its summary. Both reproduced under node against the real module: the two cases fail on the previous file and pass now, with a real heading, a real quoted bullet and an ordinary bullet unchanged. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Collect preview reference labels only from lines that can be 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. The pre-scan ran over every line regardless, so the label was recorded and toPlainText stripped the brackets: the collapsed preview claimed a resolved reference the expanded notes do not have. It now 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, which the second case checks. Reproduced under node: the indented-code definition resolved "Beta support" before and keeps its brackets now. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Let a document-level HTML block close an open list item CommonMark HTML blocks of types 1 to 6 interrupt a paragraph, so a "<div>" to the left of an open list item closes it and a following one-to-three-space indented "## 2.0" is a real document heading. Two things stopped that: the block opener was blanked before the list tracker saw it, so it read as a blank line, and _may_be_lazy treated it as ordinary text that could continue the item's paragraph. The item therefore stayed open and the release below the block was swallowed entirely. The opener's indentation is now taken before it is hidden, the way a fence opener's already was, and an HTML block opener is no longer a candidate for lazy continuation. Type 7 cannot interrupt a paragraph and is deliberately excluded, since after_paragraph is the only state this helper is asked about. Measured on the reported shape: the version list went from 3.0, 1.0 to 3.0, 2.0, 1.0. The test also pins the two cases that must not change, an indented heading genuinely nested in an item and an ordinary lazy continuation, both of which still suppress the heading. * Let the download panel shrink inside the capped overlay stack The bottom-right stack is capped to the viewport, but a flex item defaults to min-height:auto, so the download panel's outer wrapper could not shrink below its own content. min-h-0 had been added to the nested panel and not to this wrapper, so on a short viewport the cap was absorbed by the update card, whose header and actions are fixed, instead of by the download list, which scrolls. Only the shared-stack branch takes it. Standalone is positioned fixed and is not a flex item at all. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten release notes comments Shorten the comments and docs added with the update popup release notes so each explains its line in as few words as possible. Comments only, no behaviour change. * Measure release-notes indentation from the container CommonMark measures a block's indentation from its container, not from the left margin (spec 0.31.2 sections 4.4 and 5.2). The three changelog scanners measured from the margin in different places, so they disagreed with the renderer and with each other. Under "- Details:" the content column is 2, so a four-space line is two columns in: a paragraph holding a link. The link resolver read it as an indented code block and left the destination relative, so it resolved against Studio's own origin instead of the repository. At document level the same four spaces really are code, and a top-level bullet is not indented enough to continue the block. The preview promoted an indented line that looked like a fence opener to a list-contained fence, so with no later closer every bullet below it was skipped and the collapsed popup lost its summary. A fence is scoped to its container too: with no closing line it runs to the end of the containing block, not the end of the document (section 4.5). A dedented "## 2.0" closes the list item the fence sits in, so it is a real release heading. Document-wide fence state kept the block open, so one missing closing line hid every release below it. Both frontend scanners now read their list columns from one module ported from the backend's own tracker, which keeps the three in step. Two smaller fixes ride along. A release body written as a GFM table rendered as a grid but previewed as its raw "| Change | Detail | | --- | --- |" delimiters, so table rows are now dropped from the collapsed summary the way a code block already is. 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, well inside the 2 MiB the fetcher accepts, took 10.9s and now takes 41ms. Differential fuzzing against a CommonMark reference implementation puts the parser's heading mismatches at 11 of 14275 documents, down from 617, and the link resolver's at 147 of 6000, down from 217. * Keep Retry reachable when the release notes fetch fails The panel took fallbackMarkdown for every response that did not match, error included, so markdown was always truthy on desktop and the error branch that carries the Retry button was unreachable. The fallback there is the updater's static install blurb, not this release's notes, so a transient failure showed "Download the Apple Silicon .dmg" where the notes should be, with no way to ask again until the cache expired. The hook already separates the two: a reported failure is error and retryable, "no section for this version" is ready and is not. The fallback now applies only to the second, which is the case its prop documents. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope an unclosed comment to its block and end a release on a bare ## Two CommonMark rules the changelog scanners read too strictly. An HTML block only opens when the line itself begins with a comment marker (spec 0.31.2 section 4.6, type 2). One written mid-sentence is inline raw HTML and, unclosed, is ordinary text. The link resolver carried the open state to every line below instead, so a note reading "- Type <!-- to begin a comment" masked the relative links under it and they resolved against Studio's own origin rather than the repository. maskComments now separates the block form from the inline one and skips an opener sitting inside a code span, the way _strip_comments and stripCommentSpans already do. The spans are scanned only once an opener turns up, so a line without one costs what it did before. An ATX heading's opening sequence may also be followed by the end of the line (section 4.2), so a bare ## is an empty level-two heading. Both heading patterns required whitespace after the hashes, so everything below such a line stayed inside the release above it and the popup could show unrelated notes under that version. An empty heading carries no version, so it ends the release without indexing one of its own. Differential runs against markdown-it-py: section bodies 7769 to 0 mismatches over 36069 generated documents, comment-heavy link resolution 705 to 53 over 6000, and previews leaking a bare marker as headline text 22484 to 0 over 40000. The residual link cases are all one shape, a comment block opened inside a list item that outlives the item, which the fence tracker scopes and the comment tracker does not, in all three scanners alike. * Give a hidden comment its own column and balance link destinations A comment is an HTML block, so one written at the margin under a bullet is not indented enough to continue that item and closes the list. All three scanners blanked the line before list tracking saw it, which reads as a blank line and leaves the item open, so a release heading below it looked like nested item content and the new release merged into the one above. A hidden line now keeps its own column through _hidden_structure and hiddenStructure, and only its column, since the text a comment or a raw block hides is not Markdown and must not open a list of its own. A line inside a block already open is that block's content and still keeps nothing. A link destination may hold parentheses while they balance, so [x]((draft).md) points at (draft).md. The resolver stopped at the first paren, matched an empty destination and left the markdown alone, so the link resolved against Studio's own origin. The balanced form counts only while a closing paren or a title still ends the link, so the stray paren in [x](a(b.md) stays the closer the way CommonMark reads it rather than being swallowed into a link across lines. * Scope paragraph state to the container a line is written in Two lines the parser read as block starts are lazy paragraph text, so the list they were written under closed early and the heading indented to the item's content column was indexed as a release the renderer never shows. A setext underline may never be a lazy continuation line (spec 0.31.2 section 4.3), so `===` written left of an open item is more of that item's paragraph. Rejecting every underline-shaped line ended the list there. A row of three dashes is still a thematic break, which does end it. 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. Paragraph state is now scoped to its container: a quote line leaves open only the quote's own paragraph, an underline needs one in its own container, a definition ends one only when there is none to continue, and a line four columns past its container is code, which may not interrupt. The frontend pair reads the same tracker, so both scanners now carry the quote state and a fence inside a list item ends with the item in the preview the way it already did on the backend. Measured against markdown-it-py (CommonMark 0.31.2) over 264k generated documents: 3368 sections now match the renderer, none regressed, and every list and quote corpus is exact. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read a fence and an HTML block from the container it opens in A block is measured from its container and not from the left margin (spec 0.31.2 sections 4.5 and 5.2), but the link resolver's fence, raw HTML and type 6 expressions all started at the margin, so a fence behind a quote marker and one three columns under a nested bullet opened nothing. The sample inside was then read as prose, and a relative link written in a code block or a details body was rewritten into text the reader is shown verbatim. Matching runs of backticks hid some of it by accident, since the code span scanner pairs them across lines, but a tilde fence, a closer of a different length and every HTML block went through. Each line is now read from the container it is written in, which the list tracker already knew, and a block is scoped to that container the way a fence inside an item already was: a line to the left of the item, or outside the quote, ends the block along with it, and a bare quote marker is the blank line that ends a type 6 block. A destination holds parentheses while they balance, and a path may nest them, so [x](((draft)).md) points at ((draft)).md. One nesting level was all the expression allowed, so anything deeper fell through to the plain form, matched an empty destination and left the link resolving against Studio's own origin. The pairs are unrolled to the 32 levels cmark counts, and the balanced form is still gated on a closer following it, so the stray paren in [x](a(b.md) stays the closer the way CommonMark reads it rather than inventing a link across lines. Measured against markdown-it-py (CommonMark 0.31.2) over 66k generated documents, comparing the rendered HTML rather than the destinations alone: 7286 documents in the parenthesis corpus and 313 in the container corpus now match the renderer, and the link and definition corpora are unchanged. One container document regresses, where closing the HTML block correctly exposes an unrelated gap of its own: a link reference definition still leaves a paragraph open, so the indented line below it reads as prose rather than as code. The list tracker still matches the backend on every step, the repo's own CHANGELOG resolves identically, and the pathological inputs measure the same. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read a block from the item its marker opens, and let a comment reach its paragraph Four things the three changelog scanners read differently from a renderer. A fence written straight after a list marker is the item's own first content, measured from the column that content starts, so "- ```md" opens one. All three scanners matched the whole line and saw nothing, so the code sample below it was prose: the resolver rewrote a destination the reader sees verbatim, and the preview offered the info string as a headline bullet. A shared itemContent / _item_content reads past a marker that really opens an item, capping the padding the way the list tracker caps it so an over-indented line is still indented code. An HTML block opener is read the same way, and its marker survives into the structural line so the item it opens is still tracked. An HTML block holds no lazy continuation line, so one opened on an item's continuation line ends where the item does, exactly as a fence there already did. The backend and the preview ended it only on a blank line, so it ran past the item and swallowed the next release heading, which made those notes unreachable and dropped every bullet below it from the collapsed popup. A raw block inside an item ends on a blank line too, which is where cmark puts it. 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. Ending it 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 preview quoting text the popup body does not show. A shared commentClosesBelow answers whether the closer arrives before the paragraph breaks; where it does not, the opener stays the ordinary text a renderer shows, so a note that merely mentions "<!--" still hides nothing. Only ASCII punctuation is escapable, 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 survives as a separator, so a Windows or namespaced path pointed at the wrong file either way. The destination expression now escapes only punctuation, which also means a space still ends a destination: "[x](a b.md)" and "[x](a(b.md)" are not links, so their paths are left alone rather than half-rewritten. A destination that runs out of line still resolves, since its closer is on the line below. Fuzzed against markdown-it (CommonMark 0.31.2) over 20k-document corpora, with the whole rewritten document rendered and compared, not just its destinations. Release headings: 117 to 16 on containers, 88 to 10 on markers, 17 to 12, nothing new anywhere. Link destinations: 8823 to 104 on markers, 114 to 98 on comments, nothing new. Whole-document renders: 9271 to 220, 5116 to 245, 1265 to 671. The Python and TypeScript list trackers still agree over 26861 steps, and itemContent and hiddenStructure agree over another 6335. 321 KB of unmatched backticks still measures the same. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Let a definition follow a definition, and read a comment from the item it opens in Three CommonMark conformance fixes in the changelog scanners. A link reference definition is a block of its own that may not interrupt a paragraph, so it opens none either: definitions are allowed to run consecutively (spec 0.31.2 section 4.7). The link resolver counted one as paragraph text, so every definition after the first fell outside the set of lines a definition may start on and kept its relative destination, which then resolved against Studio's own origin. The backend already read the line this way. The guard asking whether a `-->` is reachable from an opener read any line whose first character was punctuation as the start of a new block. A `-->` written on a line of its own is how a multiline comment is ordinarily closed, and a wrapped line may open with emphasis, so neither counted as more of the paragraph carrying the comment. The comment never closed and the collapsed popup showed the author's internal note to the reader. It now tests for a block that may actually interrupt a paragraph. A comment is an HTML block too (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. All three 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. The opener is now read from the item's content, the marker survives into the structural line so the item it opens is still tracked, and the block is scoped to that item the way a fence there is. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the release notes comments without losing the reasons they record --------- Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
This commit is contained in:
parent
7b068090b2
commit
d74d03d350
25 changed files with 5874 additions and 35 deletions
3
.github/workflows/release-desktop.yml
vendored
3
.github/workflows/release-desktop.yml
vendored
|
|
@ -766,6 +766,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_REPO: ${{ github.repository }}
|
GH_REPO: ${{ github.repository }}
|
||||||
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
||||||
|
PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
|
||||||
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
||||||
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||||
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
|
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()
|
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
|
||||||
metadata = {
|
metadata = {
|
||||||
'version': os.environ['APP_VERSION'],
|
'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,
|
'notes': notes,
|
||||||
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||||
'platforms': {
|
'platforms': {
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -208,6 +208,9 @@ tmp/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
auth.db
|
auth.db
|
||||||
|
|
||||||
|
# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
|
||||||
|
studio/CHANGELOG.md
|
||||||
|
|
||||||
# Tauri local build/generated output
|
# Tauri local build/generated output
|
||||||
studio/src-tauri/target/
|
studio/src-tauri/target/
|
||||||
studio/src-tauri/gen/
|
studio/src-tauri/gen/
|
||||||
|
|
|
||||||
88
CHANGELOG.md
Normal file
88
CHANGELOG.md
Normal file
|
|
@ -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.
|
||||||
|
|
||||||
|
<!-- Add new releases directly below this line. -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
2
MANIFEST.in
Normal file
2
MANIFEST.in
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
include _changelog_build.py
|
||||||
|
include CHANGELOG.md
|
||||||
36
_changelog_build.py
Normal file
36
_changelog_build.py
Normal file
|
|
@ -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)
|
||||||
6
build.sh
6
build.sh
|
|
@ -103,9 +103,13 @@ else
|
||||||
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
|
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
|
||||||
fi
|
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
|
python -m build
|
||||||
|
|
||||||
|
# Drop the snapshot so a source checkout never serves a stale copy.
|
||||||
|
rm -f studio/CHANGELOG.md
|
||||||
|
|
||||||
if [ "${1:-}" = "publish" ]; then
|
if [ "${1:-}" = "publish" ]; then
|
||||||
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
|
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,14 @@ version = {attr = "unsloth.models._utils.__version__"}
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
include-package-data = true
|
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]
|
[tool.setuptools.package-data]
|
||||||
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
|
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
|
||||||
studio = [
|
studio = [
|
||||||
|
"CHANGELOG.md",
|
||||||
"*.sh",
|
"*.sh",
|
||||||
"*.ps1",
|
"*.ps1",
|
||||||
"*.bat",
|
"*.bat",
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ from utils.update_status import (
|
||||||
get_studio_install_source_status,
|
get_studio_install_source_status,
|
||||||
get_studio_update_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.studio_version import get_studio_version
|
||||||
from utils.api_errors import install_api_error_handlers
|
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)
|
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(
|
@app.get(
|
||||||
"/api/studio/download-transport-capabilities",
|
"/api/studio/download-transport-capabilities",
|
||||||
response_model = TransportCapabilities,
|
response_model = TransportCapabilities,
|
||||||
|
|
|
||||||
1056
studio/backend/utils/changelog.py
Normal file
1056
studio/backend/utils/changelog.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
|
||||||
PYPI_FAILURE_TTL_SECONDS = 60 * 60
|
PYPI_FAILURE_TTL_SECONDS = 60 * 60
|
||||||
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
|
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
|
||||||
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
|
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
|
||||||
|
FAKE_UPDATE_ENV_VAR = "UNSLOTH_STUDIO_FAKE_UPDATE"
|
||||||
|
|
||||||
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
|
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]:
|
def get_studio_update_status(current_version: str) -> dict[str, Any]:
|
||||||
"""Return public, read-only update status for the web UI."""
|
"""Return public, read-only update status for the web UI."""
|
||||||
install_source = detect_install_source()
|
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(
|
return _status_response(
|
||||||
current_version = current_version,
|
current_version = current_version,
|
||||||
latest_version = None,
|
latest_version = None,
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,8 @@ function TauriUpdateLayer({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<UpdateBanner
|
||||||
status={update.status}
|
status={update.status}
|
||||||
info={update.info}
|
info={update.info}
|
||||||
|
|
@ -223,6 +224,7 @@ function TauriUpdateLayer({
|
||||||
isExternalServer={isExternalServer}
|
isExternalServer={isExternalServer}
|
||||||
updatePolicyMode={update.updatePolicyMode}
|
updatePolicyMode={update.updatePolicyMode}
|
||||||
manualReleaseUrl={update.manualReleaseUrl}
|
manualReleaseUrl={update.manualReleaseUrl}
|
||||||
|
releasePageUrl={update.releasePageUrl}
|
||||||
positioned={false}
|
positioned={false}
|
||||||
onInstall={update.installUpdate}
|
onInstall={update.installUpdate}
|
||||||
onDismiss={update.dismiss}
|
onDismiss={update.dismiss}
|
||||||
|
|
@ -379,9 +381,11 @@ function TauriWrapper({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{/* One bottom-right stack so overlays never overlap; they stack with a
|
{/* One bottom-right stack so overlays never overlap: download panel at the
|
||||||
gap, download panel anchored at the corner with banners above. */}
|
corner, banners above, each owning its width. */}
|
||||||
<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 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
|
<WebUpdateBanner
|
||||||
positioned={false}
|
positioned={false}
|
||||||
enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)}
|
enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export function LlamaUpdateBanner({
|
||||||
className={cn(
|
className={cn(
|
||||||
positioned
|
positioned
|
||||||
? "fixed bottom-4 right-4 z-[9998] w-[calc(100vw-2rem)] max-w-[400px]"
|
? "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"
|
data-testid="llama-update-banner"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
|
||||||
import type {
|
import type {
|
||||||
DesktopUpdatePolicyMode,
|
DesktopUpdatePolicyMode,
|
||||||
RetainedUpdateFailure,
|
RetainedUpdateFailure,
|
||||||
|
|
@ -22,6 +23,8 @@ interface UpdateBannerProps {
|
||||||
isExternalServer?: boolean;
|
isExternalServer?: boolean;
|
||||||
updatePolicyMode: DesktopUpdatePolicyMode;
|
updatePolicyMode: DesktopUpdatePolicyMode;
|
||||||
manualReleaseUrl: string | null;
|
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.
|
// false fills a shared overlay stack; true self-anchors.
|
||||||
positioned?: boolean;
|
positioned?: boolean;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
|
|
@ -30,6 +33,7 @@ interface UpdateBannerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
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 {
|
function formatVersion(version: string | null | undefined): string {
|
||||||
if (!version) return "";
|
if (!version) return "";
|
||||||
|
|
@ -44,6 +48,7 @@ export function UpdateBanner({
|
||||||
isExternalServer = false,
|
isExternalServer = false,
|
||||||
updatePolicyMode,
|
updatePolicyMode,
|
||||||
manualReleaseUrl,
|
manualReleaseUrl,
|
||||||
|
releasePageUrl = null,
|
||||||
positioned = true,
|
positioned = true,
|
||||||
onInstall,
|
onInstall,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
|
@ -52,6 +57,8 @@ export function UpdateBanner({
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
const [manualReport, setManualReport] = useState<string | null>(null);
|
const [manualReport, setManualReport] = useState<string | null>(null);
|
||||||
const [manualMessage, setManualMessage] = 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 showFailure = Boolean(lastFailure) && !dismissed;
|
||||||
const showAvailable = status === "available" && !dismissed && !showFailure;
|
const showAvailable = status === "available" && !dismissed && !showFailure;
|
||||||
const show = showFailure || (showAvailable && Boolean(info));
|
const show = showFailure || (showAvailable && Boolean(info));
|
||||||
|
|
@ -62,6 +69,11 @@ export function UpdateBanner({
|
||||||
const currentVersion = formatVersion(info?.currentVersion);
|
const currentVersion = formatVersion(info?.currentVersion);
|
||||||
const latestVersion = formatVersion(info?.version);
|
const latestVersion = formatVersion(info?.version);
|
||||||
const Icon = showFailure ? CircleAlert : Download;
|
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() {
|
async function handleCopyDiagnostics() {
|
||||||
setCopying(true);
|
setCopying(true);
|
||||||
|
|
@ -94,13 +106,14 @@ export function UpdateBanner({
|
||||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Wider than the other overlays: notes preview plus three buttons.
|
||||||
positioned
|
positioned
|
||||||
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
|
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
|
||||||
: "pointer-events-auto w-full",
|
: "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
|
||||||
)}
|
)}
|
||||||
data-testid="tauri-update-banner"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
|
|
@ -160,7 +173,40 @@ export function UpdateBanner({
|
||||||
</p>
|
</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 ? (
|
{showFailure ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -187,28 +233,31 @@ export function UpdateBanner({
|
||||||
onClick={onInstall}
|
onClick={onInstall}
|
||||||
disabled={installDisabled}
|
disabled={installDisabled}
|
||||||
>
|
>
|
||||||
{isManualLinuxPackage ? "Open release page" : "Retry update"}
|
{isManualLinuxPackage
|
||||||
|
? "Open release page"
|
||||||
|
: "Retry update"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
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}
|
onClick={onDismiss}
|
||||||
>
|
>
|
||||||
Remind me later
|
Remind me later
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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}
|
onClick={onInstall}
|
||||||
disabled={installDisabled}
|
disabled={installDisabled}
|
||||||
>
|
>
|
||||||
{isManualLinuxPackage ? "Open release page" : "Update"}
|
{isManualLinuxPackage ? "Open release page" : "Update"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{manualMessage && (
|
{manualMessage && (
|
||||||
|
|
|
||||||
251
studio/frontend/src/components/update/release-notes-panel.tsx
Normal file
251
studio/frontend/src/components/update/release-notes-panel.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ReleaseNotesPanel } from "@/components/update/release-notes-panel";
|
||||||
import { type DeviceType, usePlatformStore } from "@/config/env";
|
import { type DeviceType, usePlatformStore } from "@/config/env";
|
||||||
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
|
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
|
||||||
import { isTauri } from "@/lib/api-base";
|
import { isTauri } from "@/lib/api-base";
|
||||||
|
|
@ -40,6 +41,7 @@ export function WebUpdateBanner({
|
||||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||||
const installCmd = installCommandForDevice(deviceType);
|
const installCmd = installCommandForDevice(deviceType);
|
||||||
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
|
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
|
||||||
|
const [notesVersion, setNotesVersion] = useState<string | null>(null);
|
||||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -68,6 +70,8 @@ export function WebUpdateBanner({
|
||||||
}
|
}
|
||||||
|
|
||||||
const copied = status != null && copiedVersion === status.latestVersion;
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -78,13 +82,14 @@ export function WebUpdateBanner({
|
||||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
// Wider than the other overlays: notes preview plus three buttons.
|
||||||
positioned
|
positioned
|
||||||
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
|
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[448px]"
|
||||||
: "pointer-events-auto w-full",
|
: "pointer-events-auto flex min-h-0 w-[calc(100vw-2rem)] max-w-[448px] flex-col",
|
||||||
)}
|
)}
|
||||||
data-testid="web-update-banner"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
|
|
@ -127,22 +132,33 @@ export function WebUpdateBanner({
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<a
|
<Button
|
||||||
href={RELEASE_NOTES_URL}
|
size="sm"
|
||||||
target="_blank"
|
variant="ghost"
|
||||||
rel="noopener noreferrer"
|
className="-ml-2 h-auto whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground"
|
||||||
className="-ml-2 whitespace-nowrap rounded-full px-2.5 py-2 text-ui-13 font-medium text-foreground transition-colors hover:bg-muted"
|
onClick={() =>
|
||||||
data-testid="web-update-release-notes-link"
|
setNotesVersion(notesOpen ? null : status.latestVersion)
|
||||||
|
}
|
||||||
|
aria-expanded={notesOpen}
|
||||||
|
data-testid="web-update-release-notes-toggle"
|
||||||
>
|
>
|
||||||
Release notes
|
{notesOpen ? "Hide release notes" : "Show release notes"}
|
||||||
</a>
|
</Button>
|
||||||
{/* wrap + right-align so buttons stack instead of clipping on very narrow banners */}
|
{/* 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">
|
<div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
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}
|
onClick={snooze}
|
||||||
data-testid="web-update-snooze-button"
|
data-testid="web-update-snooze-button"
|
||||||
>
|
>
|
||||||
|
|
@ -151,7 +167,7 @@ export function WebUpdateBanner({
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
// -mr optically aligns the filled pill's edge with the card padding
|
// -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}
|
onClick={handleCopyCommand}
|
||||||
data-testid="web-update-copy-button"
|
data-testid="web-update-copy-button"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,10 @@ export function DownloadManagerPanel({
|
||||||
className={cn(
|
className={cn(
|
||||||
// Standalone: anchor bottom-right. In a shared stack (positioned=false)
|
// Standalone: anchor bottom-right. In a shared stack (positioned=false)
|
||||||
// flow as a right-aligned row so overlays stack instead of overlapping.
|
// 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",
|
"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 ? (
|
{collapsed ? (
|
||||||
|
|
@ -229,7 +231,7 @@ export function DownloadManagerPanel({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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">
|
<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">
|
<span className="min-w-0 flex-1 truncate text-ui-12p5 font-semibold text-foreground">
|
||||||
{headerLabel}
|
{headerLabel}
|
||||||
|
|
|
||||||
146
studio/frontend/src/hooks/use-release-notes.ts
Normal file
146
studio/frontend/src/hooks/use-release-notes.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,8 @@ export type UpdateStatus =
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
|
// Backend release this build pins; CHANGELOG.md is keyed by it, not the SemVer.
|
||||||
|
pypiVersion?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -42,10 +44,17 @@ interface DesktopUpdatePolicy {
|
||||||
interface ManualUpdateInfo {
|
interface ManualUpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
|
pypiVersion?: string | null;
|
||||||
body?: string;
|
body?: string;
|
||||||
date?: 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 {
|
export interface RetainedUpdateFailure {
|
||||||
error: string;
|
error: string;
|
||||||
phase: UpdatePhase;
|
phase: UpdatePhase;
|
||||||
|
|
@ -162,6 +171,7 @@ export function useTauriUpdate(isExternalServer = false) {
|
||||||
setInfo({
|
setInfo({
|
||||||
version: manualUpdate.version,
|
version: manualUpdate.version,
|
||||||
currentVersion: manualUpdate.currentVersion,
|
currentVersion: manualUpdate.currentVersion,
|
||||||
|
pypiVersion: manualUpdate.pypiVersion ?? undefined,
|
||||||
body: manualUpdate.body,
|
body: manualUpdate.body,
|
||||||
date: manualUpdate.date,
|
date: manualUpdate.date,
|
||||||
});
|
});
|
||||||
|
|
@ -197,6 +207,7 @@ export function useTauriUpdate(isExternalServer = false) {
|
||||||
setInfo({
|
setInfo({
|
||||||
version: update.version,
|
version: update.version,
|
||||||
currentVersion: update.currentVersion,
|
currentVersion: update.currentVersion,
|
||||||
|
pypiVersion: rawPypiVersion(update.rawJson),
|
||||||
body: update.body,
|
body: update.body,
|
||||||
date: update.date,
|
date: update.date,
|
||||||
});
|
});
|
||||||
|
|
@ -384,10 +395,13 @@ export function useTauriUpdate(isExternalServer = false) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install target for Linux packages that cannot self-update.
|
||||||
const manualReleaseUrl =
|
const manualReleaseUrl =
|
||||||
updatePolicy.mode === "manual_linux_package" && info
|
updatePolicy.mode === "manual_linux_package" && info
|
||||||
? manualReleasePageUrl(updatePolicy, info.version)
|
? manualReleasePageUrl(updatePolicy, info.version)
|
||||||
: null;
|
: null;
|
||||||
|
// Release page for the offered version, on every platform, for the notes link.
|
||||||
|
const releasePageUrl = info ? manualReleasePageUrl(updatePolicy, info.version) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|
@ -401,6 +415,7 @@ export function useTauriUpdate(isExternalServer = false) {
|
||||||
isExternalServer,
|
isExternalServer,
|
||||||
updatePolicyMode: updatePolicy.mode,
|
updatePolicyMode: updatePolicy.mode,
|
||||||
manualReleaseUrl,
|
manualReleaseUrl,
|
||||||
|
releasePageUrl,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
retryUpdate,
|
retryUpdate,
|
||||||
skipAndRestart,
|
skipAndRestart,
|
||||||
|
|
|
||||||
664
studio/frontend/src/lib/changelog-links.ts
Normal file
664
studio/frontend/src/lib/changelog-links.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
123
studio/frontend/src/lib/markdown-code-spans.ts
Normal file
123
studio/frontend/src/lib/markdown-code-spans.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
62
studio/frontend/src/lib/markdown-inline-comments.ts
Normal file
62
studio/frontend/src/lib/markdown-inline-comments.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
357
studio/frontend/src/lib/markdown-list-columns.ts
Normal file
357
studio/frontend/src/lib/markdown-list-columns.ts
Normal file
|
|
@ -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}(?|\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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1005
studio/frontend/src/lib/release-notes-preview.ts
Normal file
1005
studio/frontend/src/lib/release-notes-preview.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,8 @@ pub(crate) struct DesktopUpdatePolicy {
|
||||||
pub(crate) struct ManualUpdateInfo {
|
pub(crate) struct ManualUpdateInfo {
|
||||||
version: String,
|
version: String,
|
||||||
current_version: String,
|
current_version: String,
|
||||||
|
// Backend release this desktop build pins; CHANGELOG.md is keyed by it.
|
||||||
|
pypi_version: Option<String>,
|
||||||
body: Option<String>,
|
body: Option<String>,
|
||||||
date: Option<String>,
|
date: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -34,8 +36,12 @@ pub(crate) struct ManualUpdateInfo {
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct ChannelMetadata {
|
struct ChannelMetadata {
|
||||||
version: String,
|
version: String,
|
||||||
body: Option<String>,
|
// latest.json publishes Tauri's `notes`/`pub_date`; aliases keep older metadata working.
|
||||||
date: Option<String>,
|
pypi_version: Option<String>,
|
||||||
|
#[serde(alias = "body")]
|
||||||
|
notes: Option<String>,
|
||||||
|
#[serde(alias = "date")]
|
||||||
|
pub_date: Option<String>,
|
||||||
platforms: HashMap<String, ChannelPlatform>,
|
platforms: HashMap<String, ChannelPlatform>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,8 +105,9 @@ pub(crate) async fn check_desktop_manual_update() -> Result<Option<ManualUpdateI
|
||||||
Ok(Some(ManualUpdateInfo {
|
Ok(Some(ManualUpdateInfo {
|
||||||
version: latest_version,
|
version: latest_version,
|
||||||
current_version: current_version.to_string(),
|
current_version: current_version.to_string(),
|
||||||
body: metadata.body,
|
pypi_version: metadata.pypi_version,
|
||||||
date: metadata.date,
|
body: metadata.notes,
|
||||||
|
date: metadata.pub_date,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1906
tests/studio/test_update_release_notes.py
Normal file
1906
tests/studio/test_update_release_notes.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue