Compare commits
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.
|
||||
+21
-5
@@ -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>
|
||||
Whitespace-only changes.
Binary file not shown.
|
After Width: | Height: | Size: 1010 KiB |
+166
@@ -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 · 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>
|
||||
<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 |
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -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="
|
||||
Reference in new issue
Block a user