22 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
Darin Kelkhoff be5995bb55 release 1.1.5 (adding dmg) 2026-06-08 11:54:51 -05:00
Darin KelkhoffandClaude Sonnet 4.6 3a3884155f release: derive CURRENT_PROJECT_VERSION from MARKETING_VERSION, fix DMG staging
Build number is computed as major*10000+minor*100+patch so Sparkle always
sees a strictly-increasing CFBundleVersion without a separate manual bump.

DMG is now staged from a clean temp directory containing only the .app,
so xcodebuild export artifacts (DistributionSummary.plist, etc.) no longer
appear in the mounted volume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:54:27 -05:00
Darin Kelkhoff 97f398276a release: update appcast for v1.1.4 2026-06-08 11:38:07 -05:00
Darin Kelkhoff f60bb0aa53 release 1.1.4 2026-06-08 11:29:14 -05:00
Darin Kelkhoff 654a134753 widen hero and features max-width to 1200px 2026-06-08 11:28:28 -05:00
Darin Kelkhoff e8ad868277 add .nojekyll to prevent Jekyll from interfering with static assets 2026-06-08 11:27:12 -05:00
Darin KelkhoffandClaude Sonnet 4.6 3fbbe57c6f add app icon and screenshot to landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:25:41 -05:00
Darin KelkhoffandClaude Sonnet 4.6 6e71826787 add DMG creation to release recipe and GitHub Pages landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:23:47 -05:00
Darin KelkhoffandClaude Sonnet 4.6 e2a6aeafb2 guard release recipe against re-releasing an already-published version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:10:39 -05:00
Darin Kelkhoff 2ed08c1e86 release: update appcast for v1.1.3 2026-06-08 10:07:18 -05:00
Darin KelkhoffandClaude Sonnet 4.6 e09b6713cc bump to 1.1.3; set CURRENT_PROJECT_VERSION=3 so Sparkle detects updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:54:00 -05:00
Darin Kelkhoff a793a26977 release: update appcast for v1.1.2 2026-06-08 09:50:34 -05:00
Darin KelkhoffandClaude Sonnet 4.6 9efc6b1518 bump to 1.1.2; fix CFBundleShortVersionString in project.yml to survive xcodegen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:48:53 -05:00
Darin KelkhoffandClaude Sonnet 4.6 d57ca37e86 fix CFBundleShortVersionString to use MARKETING_VERSION build setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:47:41 -05:00
Darin KelkhoffandClaude Sonnet 4.6 b451f36d71 add LSApplicationCategoryType to suppress archive warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:46:27 -05:00
Darin Kelkhoff d0e74cc99a release: update appcast for v1.1.1 2026-06-08 09:39:31 -05:00
18 changed files with 1506 additions and 18 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.
+21 -5
View File
@@ -6,12 +6,28 @@
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>1.0</title>
<pubDate>Mon, 08 Jun 2026 08:18:43 -0500</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0</sparkle:shortVersionString>
<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.0/markdownViewr-1.1.0.zip" length="4188045" type="application/octet-stream" sparkle:edSignature="70LiSpW246aGNT9FYU7bZ1yEkqOPuDRTF3eKFZ7tz3wPkTz5aHrO36nUnqhATLg3ikq0A4hTBMXjvJx2ydV6Bg=="/>
<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>
<sparkle:version>3</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.4/markdownViewr-1.1.4.zip" length="4202362" type="application/octet-stream" sparkle:edSignature="R5jaamgXCpnoMHHRU4H+WsT+VNfEhRZEMIdXchNKXgzPZyCEMV4lRbEr7TJN/TvxxmiSEJorOXeJjhjH5tMzCA=="/>
</item>
</channel>
</rss>
View File
Whitespace-only changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>markdownViewr</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f7;
color: #1d1d1f;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 1200px;
}
.icon {
width: 128px;
height: 128px;
margin-bottom: 24px;
border-radius: 22%;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
}
.screenshot-wrap {
margin-top: 48px;
width: 100%;
max-width: 780px;
}
.screenshot-wrap img {
width: 100%;
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0,0,0,0.18);
}
h1 {
font-size: 2.4rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.tagline {
font-size: 1.15rem;
color: #555;
line-height: 1.5;
margin-bottom: 32px;
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: #0071e3;
color: #fff;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
padding: 14px 28px;
border-radius: 980px;
transition: background 0.15s;
}
.download-btn:hover { background: #0077ed; }
.download-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.req {
margin-top: 12px;
font-size: 0.82rem;
color: #888;
}
.features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 40px;
max-width: 1200px;
}
.feature {
background: #fff;
border-radius: 12px;
padding: 10px 16px;
font-size: 0.88rem;
color: #444;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
footer {
margin-top: 56px;
font-size: 0.8rem;
color: #aaa;
}
footer a { color: #aaa; }
@media (prefers-color-scheme: dark) {
body { background: #1c1c1e; color: #f5f5f7; }
.tagline { color: #aaa; }
.feature { background: #2c2c2e; color: #ccc; box-shadow: none; }
.req { color: #666; }
footer, footer a { color: #555; }
}
</style>
</head>
<body>
<div class="hero">
<img class="icon" src="icon.png" alt="markdownViewr icon">
<h1>markdownViewr</h1>
<p class="tagline">
A fast, beautiful markdown viewer for macOS.<br>
Open any markdown file. Live-reloads as you edit.
</p>
<a class="download-btn"
href="https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg">
<svg viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v9.586l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V3a1 1 0 011-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"/></svg>
Download for Mac
</a>
<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>
<span class="feature">Syntax-highlighted code blocks</span>
<span class="feature">Fully customizable themes</span>
<span class="feature">Live file watching</span>
<span class="feature">Vim navigation keys</span>
<span class="feature">Find in document</span>
<span class="feature">YAML frontmatter support</span>
<span class="feature">Open in external editor</span>
<span class="feature">Automatic updates</span>
</div>
</div>
<div class="screenshot-wrap">
<img src="screenshot.png" alt="markdownViewr showing a markdown document">
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

@@ -0,0 +1,732 @@
# Markdown Extensions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add four toggleable inline markdown extensions (highlight, superscript, subscript, underline) with a Markdown settings tab, a theme-aware highlight color, and a post-processing HTML pass.
**Architecture:** A new `MarkdownExtensions` struct holds four booleans stored in UserDefaults via `ThemeManager`. `MarkdownDocument.convertToHTML` runs a post-processing pass (skipping `<pre>` blocks) after the swift-markdown AST walk. `ThemeColors` gains `highlightBackground`/`highlightText` fields (with a custom decoder for backward compatibility) that feed CSS variables used by `mark {}` in the HTML template.
**Tech Stack:** SwiftUI, AppKit (NSWindow), swift-markdown 0.7.3, NSRegularExpression, UserDefaults, XcodeGen (sources glob — new Swift files auto-included, no regeneration needed).
---
### Task 1: MarkdownExtensions struct + ThemeColors highlight fields
**Files:**
- Create: `markdownViewr/MarkdownExtensions.swift`
- Modify: `markdownViewr/Theme.swift`
- [ ] **Step 1: Create `markdownViewr/MarkdownExtensions.swift`**
```swift
import Foundation
struct MarkdownExtensions: Codable {
var highlight: Bool = true
var superscript: Bool = true
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
var underline: Bool = true
}
```
- [ ] **Step 2: Add `highlightBackground` and `highlightText` to `ThemeColors` in `Theme.swift`**
Add the two new properties after `blockquoteBackground`:
```swift
var blockquoteBackground: String
var highlightBackground: String
var highlightText: String
```
Update the `init(...)` signature and body to include them after `blockquoteBackground`:
```swift
init(
background: String = "#1e1e2e",
text: String = "#cdd6f4",
heading1: String = "#cba6f7",
heading2: String = "#89b4fa",
heading3: String = "#89b4fa",
heading4: String = "#74c7ec",
heading5: String = "#74c7ec",
heading6: String = "#74c7ec",
link: String = "#89dceb",
codeBackground: String = "#181825",
codeText: String = "#a6e3a1",
blockquoteBorder: String = "#cba6f7",
blockquoteBackground: String = "#252535",
highlightBackground: String = "#ffd700",
highlightText: String = "#1a1a1a"
) {
self.background = background
self.text = text
self.heading1 = heading1
self.heading2 = heading2
self.heading3 = heading3
self.heading4 = heading4
self.heading5 = heading5
self.heading6 = heading6
self.link = link
self.codeBackground = codeBackground
self.codeText = codeText
self.blockquoteBorder = blockquoteBorder
self.blockquoteBackground = blockquoteBackground
self.highlightBackground = highlightBackground
self.highlightText = highlightText
}
```
- [ ] **Step 3: Add explicit `CodingKeys` and custom `init(from:)` to `ThemeColors` for backward compatibility**
Existing user theme JSONs don't have these fields. Swift's auto-synthesized decoder throws on missing keys, so add the following inside `struct ThemeColors`:
```swift
enum CodingKeys: String, CodingKey {
case background, text
case heading1, heading2, heading3, heading4, heading5, heading6
case link, codeBackground, codeText
case blockquoteBorder, blockquoteBackground
case highlightBackground, highlightText
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
background = try c.decode(String.self, forKey: .background)
text = try c.decode(String.self, forKey: .text)
heading1 = try c.decode(String.self, forKey: .heading1)
heading2 = try c.decode(String.self, forKey: .heading2)
heading3 = try c.decode(String.self, forKey: .heading3)
heading4 = try c.decode(String.self, forKey: .heading4)
heading5 = try c.decode(String.self, forKey: .heading5)
heading6 = try c.decode(String.self, forKey: .heading6)
link = try c.decode(String.self, forKey: .link)
codeBackground = try c.decode(String.self, forKey: .codeBackground)
codeText = try c.decode(String.self, forKey: .codeText)
blockquoteBorder = try c.decode(String.self, forKey: .blockquoteBorder)
blockquoteBackground = try c.decode(String.self, forKey: .blockquoteBackground)
highlightBackground = try c.decodeIfPresent(String.self, forKey: .highlightBackground) ?? "#ffd700"
highlightText = try c.decodeIfPresent(String.self, forKey: .highlightText) ?? "#1a1a1a"
}
```
- [ ] **Step 4: Build to verify**
```bash
just build
```
Expected: build succeeds with no errors.
- [ ] **Step 5: Commit**
```bash
git add markdownViewr/MarkdownExtensions.swift markdownViewr/Theme.swift
git commit -m "add MarkdownExtensions struct and highlight color fields to ThemeColors"
```
---
### Task 2: ThemeManager — extensions property and CSS variables
**Files:**
- Modify: `markdownViewr/ThemeManager.swift`
- [ ] **Step 1: Add `markdownExtensions` as the last `@Published` property in `ThemeManager`**
Add after the `frontmatterMode` property block (around line 21–26):
```swift
@Published var markdownExtensions: MarkdownExtensions {
didSet {
if let data = try? JSONEncoder().encode(markdownExtensions) {
UserDefaults.standard.set(data, forKey: "markdownExtensions")
}
}
}
```
- [ ] **Step 2: Initialize `markdownExtensions` in `ThemeManager.init()`**
In the `init()` body, after the `frontmatterMode` initialization, add:
```swift
if let data = UserDefaults.standard.data(forKey: "markdownExtensions"),
let ext = try? JSONDecoder().decode(MarkdownExtensions.self, from: data) {
self.markdownExtensions = ext
} else {
self.markdownExtensions = MarkdownExtensions()
}
```
- [ ] **Step 3: Add `--highlight-bg` and `--highlight-text` to `generateCSS`**
In `generateCSS(for:)`, inside the `:root {` block, add these two lines after `--blockquote-bg`:
```swift
--highlight-bg: \(theme.colors.highlightBackground);
--highlight-text: \(theme.colors.highlightText);
```
- [ ] **Step 4: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 3: HTML template — `mark` CSS rule
**Files:**
- Modify: `markdownViewr/Resources/template.html`
- [ ] **Step 1: Add `mark` rule to `base-css` in `template.html`**
After the `del { opacity: 0.6; }` rule (around line 223–225), add:
```css
mark {
background-color: var(--highlight-bg);
color: var(--highlight-text);
border-radius: 2px;
padding: 0 2px;
}
```
Note: find marks created by the in-page find feature use inline styles that override this rule, so there is no conflict.
- [ ] **Step 2: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 4: Update built-in theme JSON files
**Files:**
- Modify: all 20 files in `markdownViewr/Resources/themes/`
Each file needs `"highlightBackground"` and `"highlightText"` added inside the `"colors"` object, after `"blockquoteBackground"`.
- [ ] **Step 1: Update `catppuccin-mocha.json`** (dark)
Add after `"blockquoteBackground"`:
```json
"highlightBackground": "#f9e2af",
"highlightText": "#1e1e2e"
```
- [ ] **Step 2: Update `catppuccin-latte.json`** (light)
```json
"highlightBackground": "#f9e2af",
"highlightText": "#4c4f69"
```
- [ ] **Step 3: Update `dracula.json`** (dark)
```json
"highlightBackground": "#f1fa8c",
"highlightText": "#282a36"
```
- [ ] **Step 4: Update `everforest-dark.json`** (dark)
```json
"highlightBackground": "#dbbc7f",
"highlightText": "#2d353b"
```
- [ ] **Step 5: Update `github-dark.json`** (dark)
```json
"highlightBackground": "#e3b341",
"highlightText": "#0d1117"
```
- [ ] **Step 6: Update `github-light.json`** (light)
```json
"highlightBackground": "#fff8c5",
"highlightText": "#1f2328"
```
- [ ] **Step 7: Update `gruvbox-dark.json`** (dark)
```json
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
```
- [ ] **Step 8: Update `gruvbox-light.json`** (light)
```json
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
```
- [ ] **Step 9: Update `kanagawa.json`** (dark)
```json
"highlightBackground": "#ff9e3b",
"highlightText": "#1f1f28"
```
- [ ] **Step 10: Update `monokai.json`** (dark)
```json
"highlightBackground": "#e6db74",
"highlightText": "#272822"
```
- [ ] **Step 11: Update `nord.json`** (dark)
```json
"highlightBackground": "#ebcb8b",
"highlightText": "#2e3440"
```
- [ ] **Step 12: Update `one-dark.json`** (dark)
```json
"highlightBackground": "#e5c07b",
"highlightText": "#282c34"
```
- [ ] **Step 13: Update `one-light.json`** (light)
```json
"highlightBackground": "#ffd700",
"highlightText": "#383a42"
```
- [ ] **Step 14: Update `rose-pine.json`** (dark)
```json
"highlightBackground": "#f6c177",
"highlightText": "#191724"
```
- [ ] **Step 15: Update `rose-pine-dawn.json`** (light)
```json
"highlightBackground": "#f6c177",
"highlightText": "#575279"
```
- [ ] **Step 16: Update `solarized-dark.json`** (dark)
```json
"highlightBackground": "#b58900",
"highlightText": "#002b36"
```
- [ ] **Step 17: Update `solarized-light.json`** (light)
```json
"highlightBackground": "#b58900",
"highlightText": "#fdf6e3"
```
- [ ] **Step 18: Update `tokyo-night.json`** (dark)
```json
"highlightBackground": "#e0af68",
"highlightText": "#1a1b26"
```
- [ ] **Step 19: Update `tokyo-night-light.json`** (light)
```json
"highlightBackground": "#f9c859",
"highlightText": "#343b58"
```
- [ ] **Step 20: Update `zenburn.json`** (dark)
```json
"highlightBackground": "#f0dfaf",
"highlightText": "#3f3f3f"
```
- [ ] **Step 21: Build to verify**
```bash
just build
```
Expected: build succeeds.
- [ ] **Step 22: Commit**
```bash
git add markdownViewr/ThemeManager.swift markdownViewr/Resources/template.html markdownViewr/Resources/themes/
git commit -m "add highlight CSS variable, mark rule, and highlight colors to all built-in themes"
```
---
### Task 5: MarkdownDocument — post-processing pass
**Files:**
- Modify: `markdownViewr/MarkdownDocument.swift`
- [ ] **Step 1: Update `convertToHTML` signature to accept `extensions`**
Change the function signature from:
```swift
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide) -> String {
```
to:
```swift
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide, extensions: MarkdownExtensions = MarkdownExtensions()) -> String {
```
- [ ] **Step 2: Call `applyExtensions` as the last step in `convertToHTML`**
At the end of `convertToHTML`, change the final two lines from:
```swift
var htmlVisitor = HTMLConverter()
html += htmlVisitor.visit(document)
return html
```
to:
```swift
var htmlVisitor = HTMLConverter()
html += htmlVisitor.visit(document)
return applyExtensions(html, extensions: extensions)
```
- [ ] **Step 3: Add the three private static helpers at the bottom of `MarkdownDocument`** (before the closing brace, after `escapeHTML`)
```swift
private static func applyExtensions(_ html: String, extensions: MarkdownExtensions) -> String {
// Split on <pre>...</pre> blocks so code blocks are never transformed.
var segments = html.components(separatedBy: "<pre>")
for i in 0..<segments.count {
if i == 0 {
segments[i] = applyExtensionRegexes(segments[i], extensions: extensions)
} else {
// Each segment begins with the pre block content up to </pre>,
// followed by regular content.
if let endRange = segments[i].range(of: "</pre>") {
let preContent = String(segments[i][..<endRange.upperBound])
let afterPre = applyExtensionRegexes(
String(segments[i][endRange.upperBound...]),
extensions: extensions
)
segments[i] = preContent + afterPre
}
}
}
return segments.joined(separator: "<pre>")
}
private static func applyExtensionRegexes(_ text: String, extensions: MarkdownExtensions) -> String {
var result = text
if extensions.highlight {
result = applyRegex(result, pattern: "==(.+?)==", template: "<mark>$1</mark>")
}
if extensions.superscript {
result = applyRegex(result, pattern: "\\^(.+?)\\^", template: "<sup>$1</sup>")
}
if extensions.subscript_ {
result = applyRegex(result, pattern: "~(.+?)~", template: "<sub>$1</sub>")
}
if extensions.underline {
result = applyRegex(result, pattern: "\\+\\+(.+?)\\+\\+", template: "<ins>$1</ins>")
}
return result
}
private static func applyRegex(_ text: String, pattern: String, template: String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return text }
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: template)
}
```
- [ ] **Step 4: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 6: ContentView — wire extensions into rerender
**Files:**
- Modify: `markdownViewr/ContentView.swift`
- [ ] **Step 1: Update `rerender()` to pass `extensions`**
Change:
```swift
private func rerender() {
renderedHTML = MarkdownDocument.convertToHTML(currentMarkdown, frontmatterMode: themeManager.frontmatterMode)
}
```
to:
```swift
private func rerender() {
renderedHTML = MarkdownDocument.convertToHTML(
currentMarkdown,
frontmatterMode: themeManager.frontmatterMode,
extensions: themeManager.markdownExtensions
)
}
```
- [ ] **Step 2: Add `onReceive` for `markdownExtensions`**
After the existing `.onReceive(themeManager.$frontmatterMode)` block, add:
```swift
.onReceive(themeManager.$markdownExtensions) { _ in
DispatchQueue.main.async { rerender() }
}
```
- [ ] **Step 3: Build to verify**
```bash
just build
```
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add markdownViewr/MarkdownDocument.swift markdownViewr/ContentView.swift
git commit -m "add post-processing pass for markdown extensions (highlight, superscript, subscript, underline)"
```
---
### Task 7: Settings UI — Markdown tab
**Files:**
- Modify: `markdownViewr/SettingsView.swift`
- [ ] **Step 1: Add `MarkdownSettingsView` struct to `SettingsView.swift`**
Add this new struct before the `ThemeEditorItem` struct (before line 265):
```swift
struct MarkdownSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
GroupBox {
VStack(spacing: 0) {
extensionRow(
name: "Highlight",
example: "==text==",
isOn: Binding(
get: { themeManager.markdownExtensions.highlight },
set: { themeManager.markdownExtensions.highlight = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Superscript",
example: "^text^",
isOn: Binding(
get: { themeManager.markdownExtensions.superscript },
set: { themeManager.markdownExtensions.superscript = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Subscript",
example: "~text~",
isOn: Binding(
get: { themeManager.markdownExtensions.subscript_ },
set: { themeManager.markdownExtensions.subscript_ = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Underline",
example: "++text++",
isOn: Binding(
get: { themeManager.markdownExtensions.underline },
set: { themeManager.markdownExtensions.underline = $0 }
)
)
}
.padding(4)
}
Spacer()
}
.padding(20)
}
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
HStack {
Text(name)
.frame(width: 100, alignment: .leading)
Text(example)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Toggle("", isOn: isOn)
.toggleStyle(.checkbox)
.labelsHidden()
}
.padding(.vertical, 2)
}
}
```
- [ ] **Step 2: Add the Markdown tab to `SettingsView`**
In `SettingsView.body`, add the new tab after the Themes tab:
```swift
MarkdownSettingsView()
.tabItem {
Label("Markdown", systemImage: "doc.plaintext")
}
```
- [ ] **Step 3: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 8: Theme Editor — highlight color pickers
**Files:**
- Modify: `markdownViewr/ThemeEditorView.swift`
- Modify: `markdownViewr/SampleMarkdown.swift`
- [ ] **Step 1: Add highlight color rows to `colorsSection` in `ThemeEditorView.swift`**
In the `LazyVGrid` inside `colorsSection`, after `colorRow("Quote BG", $colors.blockquoteBackground)`, add:
```swift
colorRow("Highlight BG", $colors.highlightBackground)
colorRow("Highlight Text", $colors.highlightText)
```
- [ ] **Step 2: Add highlight/extension examples to `sampleMarkdown` in `SampleMarkdown.swift`**
Find the sample markdown string and add a new section so the Theme Editor preview shows the highlight color in action. Add this block to the sample (a sensible place is near the inline formatting examples):
```swift
## Extensions
This sentence uses ==highlighted text==, H~2~O for subscript, E=mc^2^ for superscript, and ++underlined text++.
```
- [ ] **Step 3: Build to verify**
```bash
just build
```
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add markdownViewr/SettingsView.swift markdownViewr/ThemeEditorView.swift markdownViewr/SampleMarkdown.swift
git commit -m "add Markdown settings tab and highlight color pickers to theme editor"
```
---
### Task 9: Manual test
- [ ] **Step 1: Launch the app**
```bash
just run
```
- [ ] **Step 2: Open a test file with all four extension types**
Create `/tmp/extensions-test.md` with the following content:
```markdown
# Extension Test
Regular text with ==highlighted phrase== inline.
E=mc^2^ uses superscript.
H~2~O uses subscript.
This is ++underlined text++ using plus markers.
Code blocks must NOT be transformed:
==not highlighted==
^not superscript^
~not subscript~
++not underlined++
Fenced code blocks must also be skipped:
```text
==still not highlighted==
```
Open this file in the app.
- [ ] **Step 3: Verify each extension renders correctly**
- `==highlighted phrase==` → yellow/amber background highlight (matching the active theme's highlight color)
- `mc^2^` → `2` as superscript
- `H~2~O` → `2` as subscript
- `++underlined text++` → underline styling
- Content inside code blocks: no transformation, literal syntax visible
- [ ] **Step 4: Verify toggles work**
Open Settings → Markdown tab. Toggle Highlight off. Verify the highlighted phrase no longer has a background and shows `==highlighted phrase==` literally. Toggle it back on and verify it re-renders immediately.
- [ ] **Step 5: Verify highlight color in Theme Editor**
Open Settings → Themes → select a theme → Edit. Verify "Highlight BG" and "Highlight Text" color pickers appear in the Colors section. Change the highlight color and verify the preview updates immediately.
- [ ] **Step 6: Verify theme switching**
Switch between a dark theme and a light theme in the toolbar. Verify the highlight background color changes appropriately with the theme.
@@ -0,0 +1,134 @@
# Markdown Extensions Design
**Date:** 2026-06-04
**Status:** Approved
## Overview
Add a Markdown tab to the Settings window that lets users toggle four inline
syntax extensions. Enabled extensions are applied via a post-processing pass
on the generated HTML.
## Extensions
| Syntax | Name | HTML output | Default |
|---|---|---|---|
| `==text==` | Highlight | `<mark>text</mark>` | on |
| `^text^` | Superscript | `<sup>text</sup>` | on |
| `~text~` | Subscript | `<sub>text</sub>` | on |
| `++text++` | Underline | `<ins>text</ins>` | on |
Strikethrough (`~~text~~`) is already handled at the parser level (swift-markdown
GFM extension) and is not part of this feature.
## Data Model
New `MarkdownExtensions` struct in its own file (`MarkdownExtensions.swift`):
```swift
struct MarkdownExtensions: Codable {
var highlight: Bool = true
var superscript: Bool = true
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
var underline: Bool = true
}
```
`ThemeManager` gets one new `@Published var markdownExtensions: MarkdownExtensions`
property. On `didSet` it encodes to JSON and writes to UserDefaults key
`markdownExtensions`. On init it reads and decodes from UserDefaults, falling
back to `MarkdownExtensions()` (all on) if the key is absent or malformed.
## Post-Processing
`MarkdownDocument` gains a private static `applyExtensions(_:extensions:) -> String`
helper. It is called as the final step of `convertToHTML`, after `HTMLConverter`
produces its output.
The helper splits the HTML on `<pre>` / `</pre>` boundaries. Segments inside
`<pre>` blocks are passed through unchanged. Segments outside have each enabled
extension's regex applied using `NSRegularExpression`.
Patterns (applied in the order listed):
| Extension | Regex | Replacement |
|---|---|---|
| Highlight | `==(.+?)==` | `<mark>$1</mark>` |
| Superscript | `\^(.+?)\^` | `<sup>$1</sup>` |
| Subscript | `~(.+?)~` | `<sub>$1</sub>` |
| Underline | `\+\+(.+?)\+\+` | `<ins>$1</ins>` |
`convertToHTML` signature gains one new parameter:
```swift
static func convertToHTML(
_ markdown: String,
frontmatterMode: FrontmatterMode = .hide,
extensions: MarkdownExtensions = MarkdownExtensions()
) -> String
```
All existing callers that omit `extensions` get the all-on defaults.
## Settings UI
A fourth tab is added to the `TabView` in `SettingsView`:
- Label: "Markdown", system image: `doc.plaintext`
- New struct `MarkdownSettingsView` in `SettingsView.swift`
- One `GroupBox` containing a `VStack` of four rows
- Each row: extension name (left), example syntax in secondary monospaced text
(center), `Toggle` with `.checkbox` style (right)
- Toggling writes through `themeManager.markdownExtensions` immediately
## Re-render Trigger
`ContentView` already observes `themeManager` via `@EnvironmentObject` and
re-renders on published changes. Adding `onReceive(themeManager.$markdownExtensions)`
(same pattern as `frontmatterMode`) triggers a re-render when any toggle changes.
No restart required.
## Theme Color: Highlight
`<mark>` has a browser-default yellow background that looks wrong on dark themes.
`ThemeColors` gets two new fields:
```swift
var highlightBackground: String // default: "#ffd700" (gold — readable on dark)
var highlightText: String // default: "#1e1e2e" (dark — pairs with gold bg)
```
`ThemeManager.generateCSS` emits `--highlight-bg` and `--highlight-text` CSS
variables. The HTML template adds:
```css
mark {
background-color: var(--highlight-bg);
color: var(--highlight-text);
border-radius: 2px;
padding: 0 2px;
}
```
All 20 built-in theme JSON files get the two new color fields with
theme-appropriate values. The Theme Editor color list gains a "Highlight"
section (background + text), matching the existing "Code" section layout.
User themes that predate this change omit these fields in their JSON. Swift's
auto-synthesized `Decodable` throws on missing keys, so `ThemeColors` needs a
custom `init(from decoder:)` that uses `decodeIfPresent` for the two new fields
and falls back to the `ThemeColors.init` defaults when absent.
## Files Changed
| File | Change |
|---|---|
| `MarkdownExtensions.swift` | New — struct definition |
| `Theme.swift` | Add `highlightBackground`, `highlightText` to `ThemeColors` |
| `ThemeManager.swift` | Add `markdownExtensions` published property; emit `--highlight-bg`/`--highlight-text` in `generateCSS` |
| `MarkdownDocument.swift` | Add `applyExtensions` helper, update `convertToHTML` signature |
| `SettingsView.swift` | Add `MarkdownSettingsView`, add Markdown tab |
| `ContentView.swift` | Add `onReceive` for `markdownExtensions` |
| `ThemeEditorView.swift` | Add Highlight color pickers |
| `Resources/template.html` | Add `mark { … }` CSS rule |
| `Resources/themes/*.json` (×20) | Add `highlightBackground` + `highlightText` per theme |
+46 -3
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
@@ -30,17 +34,29 @@ release: kill
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
MAJOR=$(echo "$VERSION" | cut -d. -f1)
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."
exit 1
fi
ARCHIVE="/tmp/markdownViewr.xcarchive"
EXPORT="/tmp/markdownViewr-export"
ZIP="/tmp/markdownViewr-$VERSION.zip"
echo "==> Archiving v$VERSION..."
echo "==> Archiving v$VERSION (build $BUILD_NUMBER)..."
rm -rf "$ARCHIVE" "$EXPORT" "$ZIP"
xcodebuild archive \
-project markdownViewr.xcodeproj \
-scheme markdownViewr \
-archivePath "$ARCHIVE" \
-destination 'generic/platform=macOS' \
ENABLE_HARDENED_RUNTIME=YES | xcpretty || true
ENABLE_HARDENED_RUNTIME=YES \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
echo "==> Exporting with Developer ID signing..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
@@ -58,8 +74,24 @@ release: kill
xcrun stapler staple "$APP"
echo "==> Packaging final release..."
ditto -c -k --keepParent "$APP" "$ZIP"
echo "==> Creating DMG..."
DMG="/tmp/markdownViewr.dmg"
DMG_STAGING="/tmp/markdownViewr-dmg-staging"
rm -f "$DMG"
rm -rf "$DMG_STAGING"
mkdir "$DMG_STAGING"
cp -R "$APP" "$DMG_STAGING/"
create-dmg \
--volname "markdownViewr" \
--window-size 660 400 \
--icon-size 128 \
--icon "markdownViewr.app" 180 185 \
--hide-extension "markdownViewr.app" \
--app-drop-link 480 185 \
"$DMG" \
"$DMG_STAGING/"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$ZIP" \
gh release create "v$VERSION" "$DMG" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
--title "markdownViewr v$VERSION" \
--generate-notes
@@ -80,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:
+4 -2
View File
@@ -38,9 +38,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml</string>
<key>SUPublicEDKey</key>
+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
}
}
+18 -2
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,8 +44,8 @@ targets:
base:
INFOPLIST_FILE: markdownViewr/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
MARKETING_VERSION: "1.1.1"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.1.6"
CURRENT_PROJECT_VERSION: "3"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ENABLE_APP_SANDBOX: "NO"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
@@ -62,5 +75,8 @@ targets:
- markdown
- mdown
- mkd
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
LSApplicationCategoryType: "public.app-category.productivity"
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="