6 Commits
Author SHA1 Message Date
Darin KelkhoffandClaude Sonnet 4.6 2631fc5ff2 add theme import/export, schema versioning, migration, unit tests, Homebrew cask
- Theme import/export with conflict resolution (overwrite/keep both/skip)
- Theme schema versioning: schemaVersion field, migrateIfNeeded on load with write-back
- Unit tests for theme decoding, migration, and round-trip (markdownViewrTests target)
- Max content width setting in General tab (checkbox + slider, default 1000px enabled)
- Homebrew cask: brew install --cask darinkelkhoff/tap/markdownviewr
- Copy button replaces Duplicate in Themes settings
- Settings min-width bumped to 560px
- Help updated for new features; CLAUDE.md documents migration process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:55:34 -05:00
Darin KelkhoffandClaude Sonnet 4.6 f2109f49ba add MIT license and public README; move GitHub link inline on landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:43:28 -05:00
Darin Kelkhoff 000f2699cc release: update appcast for v1.1.6 2026-06-08 12:01:59 -05:00
Darin Kelkhoff 823b8f5ecc release 1.1.6 (fix version in about dialog) 2026-06-08 11:59:56 -05:00
Darin KelkhoffandClaude Sonnet 4.6 a0f1f88a4f release: run xcodegen generate before archiving
Ensures the .xcodeproj reflects the current project.yml (MARKETING_VERSION,
etc.) before the archive step, so the built app's version strings match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:59:02 -05:00
Darin Kelkhoff ca644efc4e release: update appcast for v1.1.5 2026-06-08 11:56:45 -05:00
12 changed files with 435 additions and 19 deletions

No files matched your search

+21
View File
@@ -79,6 +79,27 @@ SwiftUI's `Settings` scene and `.sheet` don't support resizing on macOS. Both Se
- Theme order tracked in UserDefaults key `themeOrder`
- Active theme tracked in UserDefaults key `activeTheme`
## Theme Schema Versioning
Theme JSON files carry a `schemaVersion` integer field. The current version is in `Theme.currentSchemaVersion` (`Theme.swift`).
**When you add a new field to `Theme`, `ThemeColors`, `ThemeFonts`, or `ThemeSizes`:**
1. Increment `Theme.currentSchemaVersion`.
2. Add the new field with a sensible default in `init(from decoder:)` using `decodeIfPresent` so older files still load.
3. Update the built-in theme JSON files in `Resources/themes/` with the new field.
4. Add a migration step in `ThemeManager.migrateIfNeeded(_ theme: inout Theme)` — an `if theme.schemaVersion < N` block that fills in the new field and bumps `schemaVersion` to `N`. Stack these in order so a theme several versions behind migrates fully in one pass. `migrateIfNeeded` is already called from `loadUserThemes()` (with automatic write-back to disk if the version changed) and from `importTheme(_:resolution:)`. Do not remove the example comment block — it shows future-me the pattern.
5. Update `ThemeEditorView` to expose any new user-facing fields.
6. Update `ThemeManager.generateCSS(for:)` if the new field affects rendering.
**Import behaviour (already implemented in `SettingsView.importThemes()`):** if an imported theme's `schemaVersion` exceeds `currentSchemaVersion`, the user is warned before import proceeds. If it is lower, run `migrateIfNeeded` on it during import just as you would for any loaded theme.
**Schema version history:**
| Version | What changed |
|---|---|
| 1 | Initial versioned schema (colors, fonts, sizes, customCSS) |
## Find in Document
- Cmd+F toggles find bar per-window (each window has its own `FindBarController`)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Darin Kelkhoff
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+58
View File
@@ -0,0 +1,58 @@
# markdownViewr
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
[![Download](https://img.shields.io/badge/Download-macOS-blue?style=flat-square&logo=apple)](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
![macOS 13+](https://img.shields.io/badge/macOS-13%2B-lightgrey?style=flat-square)
![Free](https://img.shields.io/badge/price-free-brightgreen?style=flat-square)
![markdownViewr screenshot](docs/screenshot.png)
## Download
**[Download for Mac](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)** — macOS 13 Ventura or later, free.
Open the DMG, drag markdownViewr to Applications, and you're done.
Or install via Homebrew:
```bash
brew install --cask darinkelkhoff/tap/markdownviewr
```
## Features
- **GitHub Flavored Markdown** — tables, task lists, strikethrough, fenced code blocks
- **Syntax-highlighted code blocks** — automatic language detection
- **Fully customizable themes** — built-in theme editor, unlimited user themes, custom CSS
- **Live file watching** — re-renders on every save, scroll position preserved
- **Vim navigation keys** — `j`/`k`, `Ctrl+d`/`u`, `gg`/`G`, `]]`/`[[`
- **Find in document** — with match count and vim-style `/`, `n`, `N`
- **YAML frontmatter** — hide, show as a metadata table, or render as Markdown
- **Open in external editor** — configure any editor or app, one click from the toolbar
- **Automatic updates** — stays current via Sparkle
## Keyboard Shortcuts
| Key | Action |
|---|---|
| `j` / `k` | Scroll down / up |
| `Ctrl+d` / `Ctrl+u` | Half page down / up |
| `gg` / `G` | Top / bottom of document |
| `]]` / `[[` | Next / previous heading |
| `⌘F` | Find |
| `/` | Focus find bar (vim-style) |
| `n` / `N` | Next / previous match |
| `⌘⇧↓` / `⌘⇧↑` | Next / previous theme |
## Building from Source
Requires Xcode 15+, [XcodeGen](https://github.com/yonaskolb/XcodeGen), and [just](https://github.com/casey/just).
```bash
git clone https://github.com/darinkelkhoff/markdownViewr.git
cd markdownViewr
just run
```
`just --list` shows all available recipes.
+16 -8
View File
@@ -5,6 +5,22 @@
<link>https://github.com/darinkelkhoff/markdownViewr</link>
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>1.1.6</title>
<pubDate>Mon, 08 Jun 2026 12:01:59 -0500</pubDate>
<sparkle:version>10106</sparkle:version>
<sparkle:shortVersionString>1.1.6</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.6/markdownViewr-1.1.6.zip" length="4202331" type="application/octet-stream" sparkle:edSignature="PJHorFzsVMKacxkjrVMNfRYoUfoHrBvy0okZ7NO7+0fFSOqFEi50D0dMlog8DlCvOxeXEfkMXQoPjmugDArcCg=="/>
</item>
<item>
<title>1.1.3</title>
<pubDate>Mon, 08 Jun 2026 11:56:45 -0500</pubDate>
<sparkle:version>10105</sparkle:version>
<sparkle:shortVersionString>1.1.3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.5/markdownViewr-1.1.5.zip" length="4202371" type="application/octet-stream" sparkle:edSignature="8sD2QkODU6bzR134PtCSIQrmrEL7ihXcRelFWqZhnrB6RpdvrMmXs44uggcDxZDaNXERtJGP4JbISXFCLFedCg=="/>
</item>
<item>
<title>1.1.3</title>
<pubDate>Mon, 08 Jun 2026 10:07:17 -0500</pubDate>
@@ -13,13 +29,5 @@
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.4/markdownViewr-1.1.4.zip" length="4202362" type="application/octet-stream" sparkle:edSignature="R5jaamgXCpnoMHHRU4H+WsT+VNfEhRZEMIdXchNKXgzPZyCEMV4lRbEr7TJN/TvxxmiSEJorOXeJjhjH5tMzCA=="/>
</item>
<item>
<title>1.0</title>
<pubDate>Mon, 08 Jun 2026 08:18:43 -0500</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.1.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.2/markdownViewr-1.1.2.zip" length="4202333" type="application/octet-stream" sparkle:edSignature="FZ19CRV6Ib9Dbpt7Nr5PK4tCcaLJ8rHPlBOr2eGLjV1GnJxBETSnCp0FCEiOsvgWqpgsOlJc/eEQHDbIK/8UBw=="/>
</item>
</channel>
</rss>
+3 -4
View File
@@ -141,7 +141,9 @@
Download for Mac
</a>
<p class="req">macOS 13 Ventura or later &nbsp;·&nbsp; Free</p>
<p class="req">macOS 13 Ventura or later &nbsp;·&nbsp; Free &nbsp;·&nbsp; <a href="https://github.com/darinkelkhoff/markdownViewr" style="color:inherit">View on GitHub</a></p>
<p class="req" style="margin-top:8px">Also available via Homebrew: <code style="font-size:0.82rem">brew install --cask darinkelkhoff/tap/markdownviewr</code></p>
<div class="features">
<span class="feature">GitHub Flavored Markdown</span>
@@ -160,8 +162,5 @@
<img src="screenshot.png" alt="markdownViewr showing a markdown document">
</div>
<footer>
<a href="https://github.com/darinkelkhoff/markdownViewr">View on GitHub</a>
</footer>
</body>
</html>
+17
View File
@@ -17,6 +17,10 @@ run: kill build
open file: build
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
# Run the test suite
test:
xcodebuild test -project markdownViewr.xcodeproj -scheme markdownViewrTests -destination 'platform=macOS'
# Regenerate the Xcode project from project.yml
generate:
xcodegen generate
@@ -34,6 +38,8 @@ release: kill
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> Regenerating Xcode project..."
xcodegen generate
LATEST=$(gh release list --repo darinkelkhoff/markdownViewr --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "none")
if [[ "$LATEST" == "v$VERSION" ]]; then
echo "Error: v$VERSION is already released. Bump MARKETING_VERSION in project.yml first."
@@ -106,5 +112,16 @@ release: kill
git add appcast.xml
git commit -m "release: update appcast for v$VERSION"
git push origin main
echo "==> Updating Homebrew cask..."
DMG_SHA=$(shasum -a 256 "$DMG" | awk '{print $1}')
TAP_DIR="/tmp/markdownViewr-homebrew-tap"
rm -rf "$TAP_DIR"
git clone git@github.com:darinkelkhoff/homebrew-tap.git "$TAP_DIR"
sed -i '' "s/version \".*\"/version \"$VERSION\"/" "$TAP_DIR/Casks/markdownviewr.rb"
sed -i '' "s/sha256 \".*\"/sha256 \"$DMG_SHA\"/" "$TAP_DIR/Casks/markdownviewr.rb"
git -C "$TAP_DIR" add Casks/markdownviewr.rb
git -C "$TAP_DIR" commit -m "markdownViewr: update to v$VERSION"
git -C "$TAP_DIR" push origin main
echo ""
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
echo " brew install --cask darinkelkhoff/tap/markdownviewr"
+14 -2
View File
@@ -47,6 +47,7 @@ struct HelpView: View {
- Optional extensions: highlight, superscript, subscript, underline
- YAML frontmatter support: hide it, show it as a metadata table, or render it as Markdown
- Fully customizable themes with a built-in theme editor and custom CSS
- Max content width setting to keep line lengths readable on wide monitors
- Vim-style keyboard navigation
- Find in document with match count
- One-click open in any configured external editor
@@ -195,10 +196,12 @@ struct HelpView: View {
- Reorder themes by dragging
- Delete user themes
- Restore deleted built-in themes
- Import themes from `.json` files (one or more at a time)
- Export the selected theme as a `.json` file to share or back up
## Creating and editing themes
Click **New Theme** (or **Edit** on an existing user theme) to open the Theme \
Click **New** (or **Edit** on an existing user theme) to open the Theme \
Editor. Changes are previewed live against a sample document.
The editor covers:
@@ -225,11 +228,20 @@ struct HelpView: View {
code { font-size: 0.95em; }
```
## Max Content Width
**Settings > General > Max Content Width** limits how wide the document content \
grows. Enable the checkbox and drag the slider to set a maximum width in pixels. \
The width scales with zoom so the proportions stay consistent when you zoom in or out.
This is useful on wide monitors where full-width text lines become hard to read. \
A value around 800–1000 px works well for most documents.
## Theme storage
User themes are stored as JSON files in \
`~/Library/Application Support/markdownViewr/themes/`. Built-in themes are \
bundled with the app and cannot be edited (but can be duplicated).
bundled with the app and cannot be edited (but can be copied).
"""
case .editors:
+90 -2
View File
@@ -24,7 +24,7 @@ struct SettingsView: View {
Label("Markdown", systemImage: "doc.plaintext")
}
}
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
}
@@ -86,6 +86,27 @@ struct GeneralSettingsView: View {
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 8) {
Toggle("Max Content Width", isOn: $themeManager.contentWidthEnabled)
.toggleStyle(.checkbox)
if themeManager.contentWidthEnabled {
HStack(spacing: 10) {
Slider(value: $themeManager.contentWidthPx, in: 600...2400)
.onChange(of: themeManager.contentWidthPx) { v in
themeManager.contentWidthPx = (v / 10).rounded() * 10
}
Text(String(format: "%d px", Int(themeManager.contentWidthPx)))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 52, alignment: .trailing)
}
}
}
.padding(4)
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 6) {
HStack {
@@ -456,7 +477,7 @@ struct ThemeSettingsView: View {
editorItem = ThemeEditorItem(theme: nil)
}
Button("Duplicate") {
Button("Copy") {
guard let theme = selectedTheme else { return }
var copy = theme
copy.name = theme.name + " Copy"
@@ -496,6 +517,15 @@ struct ThemeSettingsView: View {
}
.disabled(selectedThemeName == nil || selectedThemeIndex == themeManager.allThemes.count - 1)
Button("Import...") {
importThemes()
}
Button("Export...") {
if let theme = selectedTheme { exportTheme(theme) }
}
.disabled(selectedThemeName == nil)
Spacer()
Button("Restore Built-ins") {
@@ -536,6 +566,64 @@ struct ThemeSettingsView: View {
}
}
}
private func importThemes() {
let panel = NSOpenPanel()
panel.title = "Import Themes"
panel.allowedContentTypes = [UTType.json]
panel.allowsMultipleSelection = true
panel.canChooseFiles = true
panel.canChooseDirectories = false
guard panel.runModal() == .OK else { return }
for url in panel.urls {
guard let data = try? Data(contentsOf: url),
let theme = try? JSONDecoder().decode(Theme.self, from: data)
else { continue }
if theme.schemaVersion > Theme.currentSchemaVersion {
let alert = NSAlert()
alert.messageText = "\"\(theme.name)\" was made with a newer version of markdownViewr"
alert.informativeText = "It may not display correctly. Import anyway?"
alert.addButton(withTitle: "Import")
alert.addButton(withTitle: "Skip")
if alert.runModal() != .alertFirstButtonReturn { continue }
}
let conflict = themeManager.allThemes.contains { $0.name == theme.name }
if conflict {
let alert = NSAlert()
alert.messageText = "\"\(theme.name)\" already exists"
alert.informativeText = "Choose how to handle the imported copy."
alert.addButton(withTitle: "Overwrite")
alert.addButton(withTitle: "Keep Both")
alert.addButton(withTitle: "Skip")
let resolution: ThemeManager.ImportConflictResolution
switch alert.runModal() {
case .alertFirstButtonReturn: resolution = .overwrite
case .alertSecondButtonReturn: resolution = .keepBoth
default: resolution = .skip
}
themeManager.importTheme(theme, resolution: resolution)
} else {
themeManager.importTheme(theme)
}
}
}
private func exportTheme(_ theme: Theme) {
let panel = NSSavePanel()
panel.title = "Export Theme"
panel.nameFieldStringValue = theme.name
.replacingOccurrences(of: " ", with: "-")
.lowercased()
.appending(".json")
panel.allowedContentTypes = [UTType.json]
guard panel.runModal() == .OK, let url = panel.url else { return }
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(theme) {
try? data.write(to: url)
}
}
}
class ThemeEditorWindowController: NSObject, NSWindowDelegate {
+15 -1
View File
@@ -2,8 +2,11 @@ import Foundation
import SwiftUI
struct Theme: Codable, Identifiable, Hashable {
static let currentSchemaVersion = 1
var id: String { name }
var schemaVersion: Int = 1
var name: String
var colors: ThemeColors
var fonts: ThemeFonts
@@ -14,7 +17,7 @@ struct Theme: Codable, Identifiable, Hashable {
var isBuiltIn: Bool = false
enum CodingKeys: String, CodingKey {
case name, colors, fonts, sizes, customCSS
case schemaVersion, name, colors, fonts, sizes, customCSS
}
init(
@@ -25,6 +28,7 @@ struct Theme: Codable, Identifiable, Hashable {
customCSS: String = "",
isBuiltIn: Bool = false
) {
self.schemaVersion = Self.currentSchemaVersion
self.name = name
self.colors = colors
self.fonts = fonts
@@ -32,6 +36,16 @@ struct Theme: Codable, Identifiable, Hashable {
self.customCSS = customCSS
self.isBuiltIn = isBuiltIn
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
name = try c.decode(String.self, forKey: .name)
colors = try c.decode(ThemeColors.self, forKey: .colors)
fonts = try c.decode(ThemeFonts.self, forKey: .fonts)
sizes = try c.decode(ThemeSizes.self, forKey: .sizes)
customCSS = try c.decodeIfPresent(String.self, forKey: .customCSS) ?? ""
}
}
struct ThemeColors: Codable, Hashable {
+57 -1
View File
@@ -30,6 +30,12 @@ class ThemeManager: ObservableObject {
}
}
}
@Published var contentWidthEnabled: Bool {
didSet { UserDefaults.standard.set(contentWidthEnabled, forKey: "contentWidthEnabled") }
}
@Published var contentWidthPx: Double {
didSet { UserDefaults.standard.set(contentWidthPx, forKey: "contentWidthPx") }
}
var activeTheme: Theme {
themes.first { $0.name == activeThemeName } ?? themes.first ?? Self.fallbackTheme
@@ -50,6 +56,11 @@ class ThemeManager: ObservableObject {
} else {
self.markdownExtensions = MarkdownExtensions()
}
self.contentWidthEnabled = UserDefaults.standard.object(forKey: "contentWidthEnabled") != nil
? UserDefaults.standard.bool(forKey: "contentWidthEnabled")
: true
let savedWidth = UserDefaults.standard.double(forKey: "contentWidthPx")
self.contentWidthPx = savedWidth > 0 ? savedWidth : 1000
loadThemes()
}
@@ -230,18 +241,59 @@ class ThemeManager: ObservableObject {
.filter { $0.pathExtension == "json" }
.compactMap { url in
guard let data = try? Data(contentsOf: url),
let theme = try? decoder.decode(Theme.self, from: data)
var theme = try? decoder.decode(Theme.self, from: data)
else { return nil }
let versionBefore = theme.schemaVersion
migrateIfNeeded(&theme)
if theme.schemaVersion != versionBefore {
try? writeUserThemeFile(theme)
}
return theme
}
.sorted { $0.name < $1.name }
}
func migrateIfNeeded(_ theme: inout Theme) {
// Add migration steps here as the schema evolves. Each `if` block
// should be a forward migration from one version to the next, in
// order, so a theme that is several versions behind migrates fully
// in a single load.
//
// Example (do not remove — shows the pattern):
// if theme.schemaVersion < 2 {
// theme.someNewField = derivedDefault(from: theme)
// theme.schemaVersion = 2
// }
_ = theme // suppress unused-inout warning until first real migration
}
func saveUserTheme(_ theme: Theme) throws {
try writeUserThemeFile(theme)
loadThemes()
}
enum ImportConflictResolution { case overwrite, keepBoth, skip }
func importTheme(_ theme: Theme, resolution: ImportConflictResolution = .keepBoth) {
var imported = theme
imported.isBuiltIn = false
migrateIfNeeded(&imported)
let conflict = allThemes.contains { $0.name == imported.name }
if conflict {
switch resolution {
case .overwrite:
try? saveUserTheme(imported)
case .keepBoth:
imported.name = Self.uniqueName(base: imported.name, existing: Set(allThemes.map(\.name)))
try? saveUserTheme(imported)
case .skip:
break
}
} else {
try? saveUserTheme(imported)
}
}
func zoomIn() {
zoomScale = min(zoomScale * 1.1, 5.0)
}
@@ -338,6 +390,10 @@ class ThemeManager: ObservableObject {
css += "\n\n/* Theme CSS */\n\(theme.customCSS)"
}
if contentWidthEnabled {
css += "\n\n#content { max-width: calc(\(Int(contentWidthPx))px * var(--zoom)); margin: auto; }"
}
return css
}
}
@@ -0,0 +1,109 @@
import XCTest
@testable import markdownViewr
final class ThemeMigrationTests: XCTestCase {
// MARK: - Decoding
func testDecodesCurrentVersionTheme() throws {
let json = minimalThemeJSON(schemaVersion: Theme.currentSchemaVersion)
let theme = try JSONDecoder().decode(Theme.self, from: json)
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
}
func testMissingSchemaVersionDefaultsToOne() throws {
// Themes written before versioning was added have no schemaVersion field.
let json = minimalThemeJSON(schemaVersion: nil)
let theme = try JSONDecoder().decode(Theme.self, from: json)
XCTAssertEqual(theme.schemaVersion, 1)
}
func testUnknownFieldsAreIgnored() throws {
// A theme from a future app version may have fields this version doesn't know about.
// Decoding should succeed — unknown keys are silently ignored by Codable.
var dict = minimalThemeDict(schemaVersion: Theme.currentSchemaVersion + 1)
dict["futureField"] = "someValue"
let json = try JSONSerialization.data(withJSONObject: dict)
XCTAssertNoThrow(try JSONDecoder().decode(Theme.self, from: json))
}
// MARK: - Migration
func testMigrateNoOpAtCurrentVersion() throws {
var theme = Theme(name: "Test")
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
let manager = ThemeManager()
let versionBefore = theme.schemaVersion
manager.migrateIfNeeded(&theme)
XCTAssertEqual(theme.schemaVersion, versionBefore, "migrateIfNeeded should not change version for a current-version theme")
}
func testNewThemeWrittenAtCurrentSchemaVersion() {
let theme = Theme(name: "Fresh")
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
}
// MARK: - Round-trip
func testEncodesSchemaVersion() throws {
let theme = Theme(name: "RoundTrip")
let data = try JSONEncoder().encode(theme)
let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
let encoded = try XCTUnwrap(dict["schemaVersion"] as? Int)
XCTAssertEqual(encoded, Theme.currentSchemaVersion)
}
func testRoundTripPreservesAllFields() throws {
let original = Theme(
name: "Round Trip",
colors: ThemeColors(background: "#ff0000", text: "#00ff00"),
fonts: ThemeFonts(body: "Georgia", heading: "Helvetica", code: "Menlo"),
sizes: ThemeSizes(baseFontSize: 16, lineHeight: 1.8),
customCSS: "body { margin: 0; }"
)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(Theme.self, from: data)
XCTAssertEqual(decoded.name, original.name)
XCTAssertEqual(decoded.schemaVersion, original.schemaVersion)
XCTAssertEqual(decoded.colors.background, original.colors.background)
XCTAssertEqual(decoded.colors.text, original.colors.text)
XCTAssertEqual(decoded.fonts.body, original.fonts.body)
XCTAssertEqual(decoded.sizes.baseFontSize, original.sizes.baseFontSize)
XCTAssertEqual(decoded.sizes.lineHeight, original.sizes.lineHeight)
XCTAssertEqual(decoded.customCSS, original.customCSS)
}
// MARK: - Helpers
private func minimalThemeJSON(schemaVersion: Int?) -> Data {
let dict = minimalThemeDict(schemaVersion: schemaVersion)
return try! JSONSerialization.data(withJSONObject: dict)
}
private func minimalThemeDict(schemaVersion: Int?) -> [String: Any] {
var dict: [String: Any] = [
"name": "Minimal",
"colors": [
"background": "#000000", "text": "#ffffff",
"heading1": "#ffffff", "heading2": "#ffffff",
"heading3": "#ffffff", "heading4": "#ffffff",
"heading5": "#ffffff", "heading6": "#ffffff",
"link": "#0000ff", "codeBackground": "#111111",
"codeText": "#ffffff", "blockquoteBorder": "#888888",
"blockquoteBackground": "#222222",
"highlightBackground": "#ffff00", "highlightText": "#000000"
],
"fonts": ["body": "System", "heading": "System", "code": "SF Mono"],
"sizes": [
"baseFontSize": 14, "h1Size": 28, "h2Size": 23, "h3Size": 20,
"h4Size": 18, "h5Size": 16, "h6Size": 15,
"codeFontSize": 13, "lineHeight": 1.7
],
"customCSS": ""
]
if let v = schemaVersion {
dict["schemaVersion"] = v
}
return dict
}
}
+14 -1
View File
@@ -15,6 +15,19 @@ packages:
from: "2.0.0"
targets:
markdownViewrTests:
type: bundle.unit-test
platform: macOS
sources:
- markdownViewrTests
dependencies:
- target: markdownViewr
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewrTests
MACOSX_DEPLOYMENT_TARGET: "13.0"
GENERATE_INFOPLIST_FILE: YES
markdownViewr:
type: application
platform: macOS
@@ -31,7 +44,7 @@ targets:
base:
INFOPLIST_FILE: markdownViewr/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
MARKETING_VERSION: "1.1.5"
MARKETING_VERSION: "1.1.6"
CURRENT_PROJECT_VERSION: "3"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ENABLE_APP_SANDBOX: "NO"