Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Sources/LockIME/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -9361,6 +9361,58 @@
}
}
}
},
"No release notes.": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "暂无更新说明。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "暫無更新說明。"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リリースノートはありません。"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Aucune note de version."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine Versionshinweise."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "No hay notas de versión."
}
},
"pt": {
"stringUnit": {
"state": "translated",
"value": "Sem notas de versão."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Нет примечаний к выпуску."
}
}
}
}
}
}
39 changes: 0 additions & 39 deletions Sources/LockIME/UI/ReleaseNotesTheme.swift

This file was deleted.

49 changes: 49 additions & 0 deletions Sources/LockIME/UI/ReleaseNotesView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import AppKit
import LockIMEKit
import SwiftUI

/// Renders an update's release-notes Markdown in the update window. We parse the
/// notes into blocks (`ReleaseNotes.blocks`) and lay out the block structure —
/// heading, bullet list, paragraphs — ourselves; `Text` renders the inline
/// bold/code/links each block already carries. This stands in for a full
/// Markdown view library: the generated notes only use that small subset.
struct ReleaseNotesView: View {
let markdown: String

var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.md) {
ForEach(Array(ReleaseNotes.blocks(fromMarkdown: markdown).enumerated()), id: \.offset) { _, block in
switch block {
case .heading(let level, let text):
Text(text)
.font(Self.headingFont(forLevel: level))
.padding(.top, DS.Spacing.sm)
case .listItem(let text):
HStack(alignment: .firstTextBaseline, spacing: DS.Spacing.sm) {
Text(verbatim: "•")
.foregroundStyle(.secondary)
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
}
case .paragraph(let text):
Text(text)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.tint(DS.Palette.accent)
}

/// Heading sizes on the 13-pt control scale, matching the former
/// swift-markdown-ui theme's 1.31 / 1.15 / 1.0 em steps. Notes in practice
/// only ever use H2, but the full ladder keeps any hand-written heading sane.
private static func headingFont(forLevel level: Int) -> Font {
let base = NSFont.systemFontSize
return switch level {
case 1: .system(size: base * 1.31, weight: .bold)
case 2: .system(size: base * 1.15, weight: .semibold)
default: .system(size: base, weight: .semibold)
}
}
}
11 changes: 7 additions & 4 deletions Sources/LockIME/UI/UpdateWindowView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import AppKit
import MarkdownUI
import SwiftUI

/// Custom Sparkle update window in the style of Apple Software Update: app icon +
Expand Down Expand Up @@ -60,9 +59,13 @@ struct UpdateWindowView: View {
if !model.availableVersion.isEmpty {
notesHeadline
}
Markdown(model.releaseNotesMarkdown.isEmpty ? "_No release notes._" : model.releaseNotesMarkdown)
.markdownTheme(.releaseNotes)
.textSelection(.enabled)
let notes = model.releaseNotesMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
if notes.isEmpty {
Text("No release notes.")
.foregroundStyle(.secondary)
} else {
ReleaseNotesView(markdown: notes)
}
}
.padding(DS.Spacing.xl)
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down
170 changes: 170 additions & 0 deletions Sources/LockIMEKit/Updates/ReleaseNotes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import Foundation

/// A renderable block of an update's release notes, parsed from the appcast
/// item's Markdown.
///
/// We render notes ourselves rather than embedding a full Markdown view library:
/// the notes are GitHub's generated changelog, which only ever uses a tiny
/// subset — an `##` heading, a flat bullet list, paragraphs, and inline
/// bold/code/links — and `AttributedString(markdown:)` parses all of it.
/// SwiftUI's `Text` already renders the *inline* attributes (bold, code, links);
/// only the *block* layout (heading sizes, list bullets, paragraph spacing) is
/// ours to do, which is what these cases carry.
public enum ReleaseNoteBlock: Equatable, Sendable {
/// A heading; `level` is the Markdown depth (`#` → 1, `##` → 2, …).
case heading(level: Int, text: AttributedString)
/// One bullet-list item. Ordered and unordered lists both render as bullets
/// (generated notes only ever produce unordered lists).
case listItem(AttributedString)
/// A plain paragraph. Any block kind we don't special-case — a fenced code
/// block, a block quote, a table cell — degrades to this, so notes that ever
/// stray outside the generated subset stay readable instead of vanishing.
case paragraph(AttributedString)
}

public enum ReleaseNotes {
/// Parse release-notes Markdown into renderable blocks. Inline formatting
/// (bold, code, links) is preserved on each block's `AttributedString` for
/// `Text` to render; block structure is surfaced as the cases above.
///
/// Never throws: malformed Markdown degrades to a single plain paragraph,
/// and empty/whitespace input yields no blocks.
public static func blocks(fromMarkdown markdown: String) -> [ReleaseNoteBlock] {
let options = AttributedString.MarkdownParsingOptions(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
)
guard let parsed = try? AttributedString(markdown: markdown, options: options) else {
let plain = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
return plain.isEmpty ? [] : [.paragraph(AttributedString(plain))]
}

var blocks: [ReleaseNoteBlock] = []
var bufferKey: Int?
var bufferComponents: [PresentationIntent.IntentType] = []
var buffer = AttributedString()

func flush() {
let trimmed = trimmingWhitespace(buffer)
if !trimmed.characters.isEmpty {
blocks.append(classify(styledForGitHub(trimmed), components: bufferComponents))
}
buffer = AttributedString()
}

for run in parsed.runs {
// `components` is ordered innermost-first, so its first element is the
// smallest enclosing block — a distinct identity per paragraph, list
// item, or heading. A change there marks a block boundary; top-level
// text carries no intent (key -1).
let components = run.presentationIntent?.components ?? []
let key = components.first?.identity ?? -1
if key != bufferKey {
flush()
bufferKey = key
bufferComponents = components
}
buffer.append(parsed[run.range])
}
flush()
return blocks
}

private static func classify(
_ text: AttributedString,
components: [PresentationIntent.IntentType]
) -> ReleaseNoteBlock {
for component in components {
if case .header(let level) = component.kind {
return .heading(level: level, text: text)
}
}
for component in components {
if case .listItem = component.kind {
return .listItem(text)
}
}
return .paragraph(text)
}

/// Apply the reading conveniences GitHub layers on top of plain Markdown,
/// which cmark — and so the old swift-markdown-ui path — left as literal
/// text: link `@name` to its profile, and relabel `…/pull/N`, `…/issues/N`,
/// and `…/compare/A...B` links as `#N` / `A...B` (the link target is kept).
/// Applied per block, so it never disturbs the block split.
private static func styledForGitHub(_ block: AttributedString) -> AttributedString {
linkifyingMentions(in: shorteningGitHubLinks(block))
}

/// Relabel GitHub PR/issue/compare link runs to their short form, preserving
/// the link target and any inline styling.
private static func shorteningGitHubLinks(_ input: AttributedString) -> AttributedString {
var result = AttributedString()
for run in input.runs {
guard let url = run.link, let label = compactGitHubLabel(for: url) else {
result.append(input[run.range])
continue
}
var replacement = AttributedString(label)
replacement.link = url
replacement.inlinePresentationIntent = run.inlinePresentationIntent
result.append(replacement)
}
return result
}

/// `#N` for a pull/issue URL, the bare ref range for a compare URL, else nil.
private static func compactGitHubLabel(for url: URL) -> String? {
guard
let host = url.host?.lowercased(),
host == "github.com" || host.hasSuffix(".github.com")
else { return nil }
let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard parts.count >= 4 else { return nil }
switch parts[2] {
case "pull", "issues": return "#\(parts[3])"
case "compare": return parts[3]
default: return nil
}
}

/// Link every `@username` (GitHub's 1–39 char, no leading/trailing-hyphen
/// rule) that isn't already inside a link — skipping email locals via the
/// look-behind — to `https://github.com/<username>`.
private static func linkifyingMentions(in input: AttributedString) -> AttributedString {
var output = input
let text = String(output.characters)
guard let regex = try? NSRegularExpression(
pattern: #"(?<![A-Za-z0-9_@/.])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)"#
) else { return output }
for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) {
guard let whole = Range(match.range, in: text),
let name = Range(match.range(at: 1), in: text),
let url = URL(string: "https://github.com/\(text[name])") else { continue }
let lower = output.index(output.startIndex, offsetByCharacters: text.distance(from: text.startIndex, to: whole.lowerBound))
let upper = output.index(output.startIndex, offsetByCharacters: text.distance(from: text.startIndex, to: whole.upperBound))
// Leave a `@name` that is already a link or sits inside an inline
// code span (e.g. a PR title's `@retroactive`) untouched.
let target = output[lower..<upper]
let isCode = target.runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }
if !isCode, target.runs.allSatisfy({ $0.link == nil }) {
output[lower..<upper].link = url
}
}
return output
}

/// Trim leading/trailing whitespace — including the newlines Markdown leaves
/// at block edges — without disturbing inline attributes on the interior.
private static func trimmingWhitespace(_ value: AttributedString) -> AttributedString {
var value = value
while let first = value.characters.first, first.isWhitespace {
value.removeSubrange(value.startIndex ..< value.characters.index(after: value.startIndex))
}
while let last = value.characters.last, last.isWhitespace {
value.removeSubrange(value.characters.index(before: value.endIndex) ..< value.endIndex)
}
return value
}
}
Loading
Loading