Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2631fc5ff2 | ||
|
|
f2109f49ba | ||
|
|
000f2699cc | ||
|
|
823b8f5ecc | ||
|
|
a0f1f88a4f | ||
|
|
ca644efc4e |
No files matched your search
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
[](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## 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
@@ -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
@@ -141,7 +141,9 @@
|
||||
Download for Mac
|
||||
</a>
|
||||
|
||||
<p class="req">macOS 13 Ventura or later · Free</p>
|
||||
<p class="req">macOS 13 Ventura or later · Free · <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,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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user