Skip to content

feat(model): keep the markdown structure in the card's changelog - #45

Merged
stanlyzoolo merged 4 commits into
mainfrom
worktree-changelog-markdown
Jul 28, 2026
Merged

feat(model): keep the markdown structure in the card's changelog#45
stanlyzoolo merged 4 commits into
mainfrom
worktree-changelog-markdown

Conversation

@stanlyzoolo

Copy link
Copy Markdown
Owner

The [changelog] section of panel [2] destroyed the markdown it was handed. stripMarkdown trimmed a leading */- off every line, so list markers went; it left [text](url) raw, ignored fenced code, and its <...> strip swallowed <https://...> autolinks. wrapText then rebuilt lines from strings.Fields, losing every indent.

markdownToLines replaces it — one line pass with two state flags (inside a fence, inside an HTML comment) returning pre-wrapped lines tagged mdBody or mdHeading. Wrapping lives inside the converter because hanging indents need the block structure, and because styling must land on whole finished lines: wrapLine counts runes and knows nothing about ANSI. renderChangelogBlock is now just the consumer — headings take the new ui.ChangelogHeadingStyle (bold ColorText, one step above the muted body, no new color to compete with the peach section labels), everything else InfoStyle.

Before / after on keepkit's own v0.1.0 notes:

- Three-panel TUI: tracker list, tool          • Three-panel TUI: tracker list, tool
card (repo, stars, languages, notes),            card (repo, stars, languages, notes),
docs viewer (rendered README / --help)           docs viewer (rendered README / --help)
Install                                        Install                    <- bold
brew install stanlyzoolo/apps/keepkit            brew install stanlyzoolo/apps/keepkit

Rules worth naming

  • CRLF is normalized first. GitHub bodies arrive CRLF from the web form, and a \r-suffixed closing fence would fail to match and swallow the rest of the body as code.
  • Headings and list markers require the space after the marker, so #123 fixed ... stays an issue ref and **Breaking** stays a paragraph — the old Trim(line, "*_") bug class cannot come back.
  • ---/***/___ collapse to a blank line, never a bullet: that is the separator above "Full Changelog".
  • Inline order is load-bearing — images, links, autolinks, then the HTML strip that used to eat them.
  • The underscore emphasis pattern requires a non-word rune outside both delimiters or update_cmd would lose its underscore, and it repeats until stable because a match consumes the boundaries it asserts (_a_ _b_ needs two passes).
  • Two kinds only, no mdCode: everything that makes code special (verbatim, no inline processing, no wrap, a 2-space indent) lives in the converter, so a code tag would have no reader.

The empty-result fallback now also covers a non-empty body the converter consumed whole — a comment-only or separator-only release note lands on no release notes available. instead of a blank block.

Deliberately not glamour: the card stays muted and the render is synchronous inside Update().

Unchanged

The leading release-URL line, buildCard's clickable-line index and "a content line is a screen row" — TestBriefContentLineIsScreenRow, TestBuildCardLinks and TestMouseBriefLinkClick are untouched and green.

Test plan

  • 62 new table rows across internal/model/textutil_test.go (new file) plus three renderChangelogBlock tests in render_test.go; TestStripMarkdown deleted with the function.
  • markdownToLines, mdInline, mdEmitListItem, mdEmitWrapped, mdListLevel, mdCutComments and renderChangelogBlock are all at 100% of statements.
  • Full CI matrix locally: go build / go vet ./... / go test -race ./... / golangci-lint run (0 issues), plus the cross-compile step.
  • Eyeballed against keepkit's live v0.1.0 release notes at briefW 58 and 30.

Known limitation, unchanged from the code this replaces

The HTML-tag strip cuts any <...> outside a fenced block, so --tool <name> in prose or `Vec<String>` in an inline code span loses it. A "looks like a real tag" pattern does not help — <name> is the shape of an HTML tag. Protecting inline code spans from the strip is the natural follow-up.

🤖 Generated with Claude Code

stanlyzoolo and others added 4 commits July 27, 2026 23:57
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The [changelog] section destroyed the markdown it was handed.
stripMarkdown trimmed a leading `*`/`-` off every line, so list markers
went; it left `[text](url)` raw, ignored fenced code, and its `<...>`
strip swallowed `<https://...>` autolinks. wrapText then rebuilt lines
from strings.Fields, losing every indent.

markdownToLines replaces it: one line pass over the body with two state
flags (inside a fence, inside an HTML comment) returning pre-wrapped
lines tagged mdBody or mdHeading. Wrapping lives inside the converter
because hanging indents need the block structure, and because styling
must land on whole finished lines — wrapLine counts runes and knows
nothing about ANSI. renderChangelogBlock is now just the consumer:
headings take ui.ChangelogHeadingStyle (bold ColorText, one step above
the muted body and no new color to compete with the peach section
labels), everything else InfoStyle.

Two kinds only, no mdCode. Everything that makes code special (verbatim,
no inline processing, no wrap, a 2-space indent) lives in the converter,
so a code tag would have no reader.

Rules worth naming. CRLF is normalized first: GitHub bodies arrive CRLF
from the web form, and a \r-suffixed closing fence would fail to match
and swallow the rest of the body as code. Headings and list markers
require the space after the marker, so `#123 fixed ...` stays an issue
ref and `**Breaking**` stays a paragraph — the old Trim(line, "*_") bug
class cannot come back. ---/***/___ collapse to a blank line and are
never bullets, since that is the separator above "Full Changelog".
Inline order is load-bearing: images, links, autolinks, then the HTML
strip that used to eat them. The underscore emphasis pattern requires a
non-word rune outside both delimiters or update_cmd would lose its
underscore, and it repeats until stable because a match consumes the
boundaries it asserts (_a_ _b_ needs two passes).

The empty-result fallback now also covers a non-empty body the converter
consumed whole: a comment-only or separator-only release note lands on
"no release notes available." instead of a blank block.

Deliberately not glamour — the card stays muted and the render is
synchronous inside Update(). Unchanged: the leading release-URL line,
buildCard's clickable-line index, and "a content line is a screen row".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. The fence pattern enforced CommonMark's 3-space indent limit, where 4+
spaces means an indented code block. This converter does not implement
those at all, so the limit only mis-read a fence nested under a list item
— "1. " aligns its content at column 4 — and the language tag leaked out
as a body line reading "go" while the sample lost its verbatim
treatment. The fence now takes any indent.

2. A setext underline survived as a literal row of equals signs: "---"
already collapsed as a thematic break, "===" matched nothing. Both are
now rule lines that drop to a blank. Full setext support (retagging the
paragraph above as a heading) stays out of scope — the text and the
layout are right, only the emphasis is lost.

3. A heading whose markup collapsed to nothing (## with only an image)
emitted an empty line still tagged mdHeading, bypassing the blank
collapse the body path went through. Harmless in today's renderer, which
checks text before kind, but it is a line that is not a heading wearing
the tag. Heading and paragraph now share mdEmitInline, so the blank can
only arrive through emitBlank — and the duplicated guard is gone with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The whole card is rebuilt on every spinner frame — the spinner.TickMsg
handler calls SetContent(renderCard()) about twelve times a second for as
long as a [r] refresh or a [u] update runs — so the release body went
through the converter's regexes twelve times a second to animate one
glyph. Measured on a 49 KB body: 3.3 ms and 1 MB of garbage per pass,
against 15 ns for a cache hit. A typical 1.2 KB body costs 140 µs per
pass, which is invisible on its own; the point is that it repeats on a
timer and the new converter is ~10x the old string-trimming one.

changelogRenderCache is readmeRenderCache's shape, one entry keyed by
(body, width) — the only two inputs markdownToLines has. It hangs off
Model as a pointer because the card renderers are value receivers and
could not fill a plain field, and its method tolerates a nil receiver: a
cache-less but correct mode, which is what the ~50 tests building Model{}
literals get without touching any of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stanlyzoolo
stanlyzoolo merged commit 027b62f into main Jul 28, 2026
3 checks passed
@stanlyzoolo
stanlyzoolo deleted the worktree-changelog-markdown branch July 28, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant