diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index 35c1499..90631b9 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -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": "Нет примечаний к выпуску." + } + } + } } } } diff --git a/Sources/LockIME/UI/ReleaseNotesTheme.swift b/Sources/LockIME/UI/ReleaseNotesTheme.swift deleted file mode 100644 index 159c751..0000000 --- a/Sources/LockIME/UI/ReleaseNotesTheme.swift +++ /dev/null @@ -1,39 +0,0 @@ -import AppKit -import MarkdownUI -import SwiftUI - -extension Theme { - /// Markdown theme for the update window's release notes, on the macOS 13-pt - /// control scale. Derived from `.gitHub` (keeps its list, code-block, and - /// table styling) but drops the web-sized 16-pt base, the hard-coded page - /// background, and the underlined web headings in favor of native panel - /// typography (17/15/13 heading steps). - @MainActor static let releaseNotes = Theme.gitHub - .text { - FontSize(NSFont.systemFontSize) - } - .heading1 { configuration in - configuration.label - .markdownMargin(top: .em(1.4), bottom: .em(0.6)) - .markdownTextStyle { - FontWeight(.bold) - FontSize(.em(1.31)) - } - } - .heading2 { configuration in - configuration.label - .markdownMargin(top: .em(1.4), bottom: .em(0.6)) - .markdownTextStyle { - FontWeight(.semibold) - FontSize(.em(1.15)) - } - } - .heading3 { configuration in - configuration.label - .markdownMargin(top: .em(1.2), bottom: .em(0.5)) - .markdownTextStyle { - FontWeight(.semibold) - FontSize(.em(1)) - } - } -} diff --git a/Sources/LockIME/UI/ReleaseNotesView.swift b/Sources/LockIME/UI/ReleaseNotesView.swift new file mode 100644 index 0000000..2565bc2 --- /dev/null +++ b/Sources/LockIME/UI/ReleaseNotesView.swift @@ -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) + } + } +} diff --git a/Sources/LockIME/UI/UpdateWindowView.swift b/Sources/LockIME/UI/UpdateWindowView.swift index 2f7edcb..c119ce0 100644 --- a/Sources/LockIME/UI/UpdateWindowView.swift +++ b/Sources/LockIME/UI/UpdateWindowView.swift @@ -1,5 +1,4 @@ import AppKit -import MarkdownUI import SwiftUI /// Custom Sparkle update window in the style of Apple Software Update: app icon + @@ -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) diff --git a/Sources/LockIMEKit/Updates/ReleaseNotes.swift b/Sources/LockIMEKit/Updates/ReleaseNotes.swift new file mode 100644 index 0000000..d14e02e --- /dev/null +++ b/Sources/LockIMEKit/Updates/ReleaseNotes.swift @@ -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/`. + private static func linkifyingMentions(in input: AttributedString) -> AttributedString { + var output = input + let text = String(output.characters) + guard let regex = try? NSRegularExpression( + pattern: #"(? 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 + } +} diff --git a/Tests/LockIMEKitTests/ReleaseNotesTests.swift b/Tests/LockIMEKitTests/ReleaseNotesTests.swift new file mode 100644 index 0000000..27034fe --- /dev/null +++ b/Tests/LockIMEKitTests/ReleaseNotesTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +@Suite("ReleaseNotes markdown parsing") +struct ReleaseNotesTests { + @Test("empty or whitespace-only input yields no blocks") + func emptyInput() { + #expect(ReleaseNotes.blocks(fromMarkdown: "").isEmpty) + #expect(ReleaseNotes.blocks(fromMarkdown: " \n\n\t").isEmpty) + } + + @Test("an H2 heading parses with its level and trimmed text") + func heading() throws { + let blocks = ReleaseNotes.blocks(fromMarkdown: "## What's Changed") + #expect(blocks.count == 1) + guard case .heading(let level, let text) = try #require(blocks.first) else { + Issue.record("expected a heading, got \(String(describing: blocks.first))") + return + } + #expect(level == 2) + #expect(String(text.characters) == "What's Changed") + } + + @Test("an unordered list becomes one listItem per bullet") + func unorderedList() { + let blocks = ReleaseNotes.blocks(fromMarkdown: "* one\n* two\n* three") + #expect(blocks.count == 3) + #expect(blocks.allSatisfy { if case .listItem = $0 { true } else { false } }) + if case .listItem(let first) = blocks.first { + #expect(String(first.characters) == "one") + } + } + + @Test("inline bold and links survive on the block's text") + func inlineFormatting() throws { + let blocks = ReleaseNotes.blocks(fromMarkdown: "**bold** then ") + guard case .paragraph(let text) = try #require(blocks.first) else { + Issue.record("expected a paragraph") + return + } + #expect(text.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + #expect(text.runs.contains { $0.link != nil }) + } + + @Test("real GitHub-generated notes → heading + bullets + closing paragraph") + func realWorldSample() throws { + let notes = """ + ## What's Changed + * test: add CI-safe coverage by @octocat in https://github.com/o/r/pull/10 + * feat: detect overlays by @octocat in https://github.com/o/r/pull/11 + + **Full Changelog**: https://github.com/o/r/compare/v1.2.1...v1.2.2 + """ + let blocks = ReleaseNotes.blocks(fromMarkdown: notes) + #expect(blocks.count == 4) + guard case .heading(2, _) = blocks[0] else { + Issue.record("block 0 should be an H2 heading") + return + } + #expect({ if case .listItem = blocks[1] { true } else { false } }()) + #expect({ if case .listItem = blocks[2] { true } else { false } }()) + guard case .paragraph(let last) = blocks[3] else { + Issue.record("block 3 should be the Full Changelog paragraph") + return + } + // The bare compare URL is auto-linked, and "Full Changelog" is bold. + #expect(last.runs.contains { $0.link != nil }) + #expect(last.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + } + + @Test("block kinds outside the generated subset degrade to readable text") + func gracefulDegradation() { + // A fenced code block never appears in generated notes; it must still + // surface its contents rather than vanish or crash. + let markdown = """ + ## Heading + ``` + let answer = 42 + ``` + """ + let blocks = ReleaseNotes.blocks(fromMarkdown: markdown) + #expect(!blocks.isEmpty) + let everything = blocks.map { block -> String in + switch block { + case .heading(_, let text), .listItem(let text), .paragraph(let text): + String(text.characters) + } + }.joined(separator: "\n") + #expect(everything.contains("let answer = 42")) + } + + @Test("@mentions link to the GitHub profile, keeping the @name text") + func mentionsLinkified() throws { + let blocks = ReleaseNotes.blocks(fromMarkdown: "* fix by @BlackHole1 in https://github.com/o/r/pull/10") + guard case .listItem(let text) = try #require(blocks.first) else { + Issue.record("expected a list item") + return + } + let mention = try #require(text.runs.first { $0.link?.absoluteString == "https://github.com/BlackHole1" }) + #expect(String(text[mention.range].characters) == "@BlackHole1") + } + + @Test("PR and compare URLs shrink to #N / the ref range, keeping the link") + func compactGitHubLinks() throws { + let pr = ReleaseNotes.blocks(fromMarkdown: "* landed in https://github.com/o/r/pull/42") + guard case .listItem(let prText) = try #require(pr.first) else { + Issue.record("expected a list item") + return + } + #expect(String(prText.characters).contains("#42")) + #expect(!String(prText.characters).contains("/pull/42")) + #expect(prText.runs.contains { $0.link?.absoluteString == "https://github.com/o/r/pull/42" }) + + let cmp = ReleaseNotes.blocks(fromMarkdown: "**Full Changelog**: https://github.com/o/r/compare/v1.2.1...v1.2.2") + guard case .paragraph(let cmpText) = try #require(cmp.first) else { + Issue.record("expected a paragraph") + return + } + #expect(String(cmpText.characters).contains("v1.2.1...v1.2.2")) + #expect(!String(cmpText.characters).contains("/compare/")) + #expect(cmpText.runs.contains { $0.link?.absoluteString.contains("/compare/") == true }) + } + + @Test("an email local part is not mistaken for an @mention") + func emailIsNotAMention() throws { + let blocks = ReleaseNotes.blocks(fromMarkdown: "reach me at someone@example.com today") + guard case .paragraph(let text) = try #require(blocks.first) else { + Issue.record("expected a paragraph") + return + } + #expect(!text.runs.contains { $0.link?.absoluteString == "https://github.com/example" }) + } + + @Test("a @name inside an inline code span is left literal, not linkified") + func mentionInsideCodeNotLinkified() throws { + let blocks = ReleaseNotes.blocks(fromMarkdown: "handle the `@retroactive` attribute") + guard case .paragraph(let text) = try #require(blocks.first) else { + Issue.record("expected a paragraph") + return + } + #expect(!text.runs.contains { $0.link?.absoluteString == "https://github.com/retroactive" }) + } +} diff --git a/project.yml b/project.yml index fb95c13..b0985e2 100644 --- a/project.yml +++ b/project.yml @@ -47,9 +47,6 @@ packages: PermissionFlow: url: https://github.com/jaywcjlove/PermissionFlow.git from: "2.5.0" - MarkdownUI: - url: https://github.com/gonzalezreal/swift-markdown-ui.git - from: "2.4.1" targets: LockIMEKit: @@ -74,7 +71,6 @@ targets: - package: Sparkle - package: KeyboardShortcuts - package: PermissionFlow - - package: MarkdownUI settings: base: PRODUCT_NAME: LockIME diff --git a/scripts/update-lab/update-lab.sh b/scripts/update-lab/update-lab.sh index 7065ab2..d65bb23 100755 --- a/scripts/update-lab/update-lab.sh +++ b/scripts/update-lab/update-lab.sh @@ -178,12 +178,19 @@ update_item() { # $NEW_BUILD $NEW_SHORT