Compare commits
No files matched your search
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>developer-id</string>
|
||||
<key>teamID</key>
|
||||
<string>9MG4YT2G93</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>markdownViewr</title>
|
||||
<link>https://github.com/darinkelkhoff/markdownViewr</link>
|
||||
<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.1.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.2/markdownViewr-1.1.2.zip" length="4202333" type="application/octet-stream" sparkle:edSignature="FZ19CRV6Ib9Dbpt7Nr5PK4tCcaLJ8rHPlBOr2eGLjV1GnJxBETSnCp0FCEiOsvgWqpgsOlJc/eEQHDbIK/8UBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,348 @@
|
||||
# Sparkle Auto-Update 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 Sparkle 2.x auto-update support so users are notified of new releases and can install them without manually visiting GitHub.
|
||||
|
||||
**Architecture:** Sparkle is added as an SPM dependency. `SPUStandardUpdaterController` is initialized at app launch and handles background update checks and the update UI. An `appcast.xml` file lives in the repo root and is served via raw.githubusercontent.com; the `just release` recipe gains a step to regenerate it with each release.
|
||||
|
||||
**Tech Stack:** Sparkle 2.x (SPM), XcodeGen (`project.yml`), `generate_appcast` CLI tool (bundled with Sparkle in DerivedData), `just` for release automation.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `project.yml` | Add Sparkle SPM package; add to target dependencies; add `SUFeedURL` and `SUPublicEDKey` to `info.properties` |
|
||||
| `markdownViewr/MarkdownViewrApp.swift` | Add `import Sparkle`; add `SPUStandardUpdaterController` stored property; add "Check for Updates..." menu item |
|
||||
| `appcast.xml` | New — empty initial appcast committed to repo root |
|
||||
| `justfile` | Update `release` recipe to run `generate_appcast`, commit, and push `appcast.xml` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Sparkle as an SPM dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `project.yml`
|
||||
|
||||
- [ ] **Step 1: Add Sparkle to the `packages` block in `project.yml`**
|
||||
|
||||
The `packages` block currently has one entry. Add `sparkle` directly after it:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
swift-markdown:
|
||||
url: https://github.com/apple/swift-markdown.git
|
||||
from: "0.4.0"
|
||||
sparkle:
|
||||
url: https://github.com/sparkle-project/Sparkle.git
|
||||
from: "2.0.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Sparkle to the target's `dependencies` list**
|
||||
|
||||
The `dependencies` block currently has one entry. Add `sparkle` directly after it:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
- package: swift-markdown
|
||||
product: Markdown
|
||||
- package: sparkle
|
||||
product: Sparkle
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Regenerate the Xcode project**
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
Expected: `Generating project markdownViewr.xcodeproj` with no errors.
|
||||
|
||||
- [ ] **Step 4: Build to verify Sparkle resolves and compiles**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: `** BUILD SUCCEEDED **`. Sparkle will be downloaded and compiled on first build — this may take a minute.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add project.yml markdownViewr.xcodeproj
|
||||
git commit -m "add Sparkle SPM dependency for auto-update"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Generate EdDSA keys and add to Info.plist
|
||||
|
||||
Sparkle signs each update with an ed25519 key so the app can verify the update came from you. This task generates the key pair, stores the private key in your keychain, and bakes the public key into the app.
|
||||
|
||||
**Files:**
|
||||
- Modify: `project.yml`
|
||||
|
||||
- [ ] **Step 1: Locate the `generate_keys` tool from the resolved Sparkle package**
|
||||
|
||||
After the build in Task 1, Sparkle's tools are in DerivedData. Find them:
|
||||
|
||||
```bash
|
||||
find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin/generate_keys" 2>/dev/null | head -1
|
||||
```
|
||||
|
||||
Expected: a path like `~/Library/Developer/Xcode/DerivedData/markdownViewr-<hash>/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys`
|
||||
|
||||
- [ ] **Step 2: Run `generate_keys`**
|
||||
|
||||
```bash
|
||||
$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin/generate_keys" 2>/dev/null | head -1)
|
||||
```
|
||||
|
||||
The tool will:
|
||||
1. Prompt for a keychain item name — enter `markdownViewr-sparkle`
|
||||
2. Store the private key in your macOS keychain
|
||||
3. Print a public key that looks like:
|
||||
|
||||
```
|
||||
Public Key (SUPublicEDKey):
|
||||
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
|
||||
Add the following to your app's Info.plist:
|
||||
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</string>
|
||||
```
|
||||
|
||||
Copy the base64 public key string (the `AAAA...=` part).
|
||||
|
||||
- [ ] **Step 3: Add `SUFeedURL` and `SUPublicEDKey` to `project.yml`'s `info.properties`**
|
||||
|
||||
The `info:` section currently ends with `UTImportedTypeDeclarations`. Add two new properties after it (substitute the actual base64 key from Step 2):
|
||||
|
||||
```yaml
|
||||
info:
|
||||
path: markdownViewr/Info.plist
|
||||
properties:
|
||||
CFBundleDocumentTypes:
|
||||
... # leave existing content unchanged
|
||||
UTImportedTypeDeclarations:
|
||||
... # leave existing content unchanged
|
||||
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
SUPublicEDKey: "PASTE_YOUR_BASE64_PUBLIC_KEY_HERE"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate the Xcode project to write keys into Info.plist**
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify both keys appear in the generated Info.plist**
|
||||
|
||||
```bash
|
||||
grep -A1 "SUFeedURL\|SUPublicEDKey" markdownViewr/Info.plist
|
||||
```
|
||||
|
||||
Expected: both keys present with their values.
|
||||
|
||||
- [ ] **Step 6: Build to confirm no regressions**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: `** BUILD SUCCEEDED **`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add project.yml markdownViewr/Info.plist
|
||||
git commit -m "add Sparkle EdDSA public key and feed URL to Info.plist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire up SPUStandardUpdaterController and menu item
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/MarkdownViewrApp.swift`
|
||||
|
||||
- [ ] **Step 1: Add `import Sparkle` at the top of `MarkdownViewrApp.swift`**
|
||||
|
||||
After `import SwiftUI`:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the updater controller as a stored property**
|
||||
|
||||
Add this property to `MarkdownViewrApp`, immediately before the existing `@StateObject` declarations:
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MarkdownViewrApp: App {
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var editorManager = EditorManager()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add "Check for Updates..." to the app menu**
|
||||
|
||||
In the `.commands { }` block, add a new `CommandGroup` as the first entry, before the existing `CommandGroup(replacing: .appSettings)`:
|
||||
|
||||
```swift
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
// ... existing settings button
|
||||
}
|
||||
// ... rest of existing commands
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: `** BUILD SUCCEEDED **`
|
||||
|
||||
- [ ] **Step 5: Launch the app and verify "Check for Updates..." appears in the app menu**
|
||||
|
||||
```bash
|
||||
just run
|
||||
```
|
||||
|
||||
Open the app menu (the menu with the app name, leftmost in the menu bar). "Check for Updates..." should appear below "About markdownViewr".
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownViewrApp.swift
|
||||
git commit -m "wire up SPUStandardUpdaterController and Check for Updates menu item"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create the initial appcast.xml
|
||||
|
||||
**Files:**
|
||||
- Create: `appcast.xml`
|
||||
|
||||
- [ ] **Step 1: Create `appcast.xml` in the repo root**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||
<channel>
|
||||
<title>markdownViewr</title>
|
||||
<link>https://github.com/darinkelkhoff/markdownViewr</link>
|
||||
<description>markdownViewr releases</description>
|
||||
<language>en</language>
|
||||
</channel>
|
||||
</rss>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file is valid XML**
|
||||
|
||||
```bash
|
||||
xmllint --noout appcast.xml && echo "valid"
|
||||
```
|
||||
|
||||
Expected: `valid`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add appcast.xml
|
||||
git commit -m "add initial empty appcast.xml for Sparkle auto-update"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update `just release` to regenerate the appcast
|
||||
|
||||
After each GitHub release, the appcast needs to be updated with the new version's entry (signed with your private key) and pushed so existing users are notified.
|
||||
|
||||
**Files:**
|
||||
- Modify: `justfile`
|
||||
|
||||
- [ ] **Step 1: Add the appcast update step to the `release` recipe**
|
||||
|
||||
The current `release` recipe ends with:
|
||||
|
||||
```bash
|
||||
echo ""
|
||||
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
|
||||
```
|
||||
|
||||
Replace that block with:
|
||||
|
||||
```bash
|
||||
echo "==> Updating appcast..."
|
||||
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
|
||||
if [[ -z "$SPARKLE_BIN" ]]; then
|
||||
echo "Error: Sparkle tools not found in DerivedData. Run 'just build' first."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p /tmp/markdownViewr-appcast-input
|
||||
cp "$ZIP" /tmp/markdownViewr-appcast-input/
|
||||
"$SPARKLE_BIN/generate_appcast" \
|
||||
--download-url-prefix "https://github.com/darinkelkhoff/markdownViewr/releases/download/v$VERSION/" \
|
||||
-o appcast.xml \
|
||||
/tmp/markdownViewr-appcast-input/
|
||||
echo "==> Committing and pushing appcast..."
|
||||
git add appcast.xml
|
||||
git commit -m "release: update appcast for v$VERSION"
|
||||
git push
|
||||
echo ""
|
||||
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify `justfile` has no syntax errors**
|
||||
|
||||
```bash
|
||||
just --list
|
||||
```
|
||||
|
||||
Expected: lists all recipes including `release` with no errors.
|
||||
|
||||
- [ ] **Step 3: Dry-run verify by checking `generate_appcast` is findable after a build**
|
||||
|
||||
```bash
|
||||
find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1
|
||||
```
|
||||
|
||||
Expected: prints a path (not empty). If empty, run `just build` first.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add justfile
|
||||
git commit -m "update release recipe to regenerate and push appcast after each release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation Verification
|
||||
|
||||
After all tasks are complete:
|
||||
|
||||
1. Run `just run` — confirm "Check for Updates..." appears in the app menu
|
||||
2. Click "Check for Updates..." — Sparkle should show either "You're up to date" or a first-launch permission dialog asking whether to enable automatic checks
|
||||
3. Run `just release` on a version bump (bump `MARKETING_VERSION` in `project.yml` first) — confirm `appcast.xml` is updated with the new version entry after the release completes
|
||||
4. On a machine with the previous version installed, open the app — Sparkle should detect the new version and prompt for an update
|
||||
@@ -0,0 +1,123 @@
|
||||
# Sparkle Auto-Update Design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add automatic update support to markdownViewr using Sparkle 2.x. On first launch after install, Sparkle prompts the user whether to enable background update checks. When a new release is published, the app notifies users and handles download and installation. Users can also trigger a manual check via "Check for Updates..." in the app menu.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Sparkle 2.x added to `project.yml` as a Swift Package dependency:
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
sparkle:
|
||||
url: https://github.com/sparkle-project/Sparkle.git
|
||||
from: "2.0.0"
|
||||
```
|
||||
|
||||
Added to the target's `dependencies` alongside `swift-markdown`. After adding, run `xcodegen generate` to regenerate the `.xcodeproj`.
|
||||
|
||||
## Signing Keys
|
||||
|
||||
Sparkle 2.x uses EdDSA (ed25519) signatures to verify update authenticity.
|
||||
|
||||
- Generate a key pair using Sparkle's bundled `generate_keys` tool (run once, at setup time).
|
||||
- The **private key** is stored in the macOS keychain under the profile name `markdownViewr-sparkle` (never committed to the repo).
|
||||
- The **public key** is stored in `Info.plist` as `SUPublicEDKey`.
|
||||
|
||||
## App Integration
|
||||
|
||||
`MarkdownViewrApp.swift` gets one new stored property:
|
||||
|
||||
```swift
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
```
|
||||
|
||||
`startingUpdater: true` starts the background updater on launch. Sparkle handles the first-launch permission prompt automatically.
|
||||
|
||||
A "Check for Updates..." menu item is added to the app menu using `CommandGroup(after: .appInfo)`:
|
||||
|
||||
```swift
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Info.plist
|
||||
|
||||
Two keys added to `markdownViewr/Info.plist`:
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `SUFeedURL` | `https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml` |
|
||||
| `SUPublicEDKey` | _(EdDSA public key generated at setup)_ |
|
||||
|
||||
## Appcast
|
||||
|
||||
`appcast.xml` lives in the repo root. It is a standard Sparkle RSS appcast with one `<item>` per release. Sparkle's `generate_appcast` tool creates and updates it automatically.
|
||||
|
||||
Example structure:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||
<channel>
|
||||
<title>markdownViewr</title>
|
||||
<link>https://github.com/darinkelkhoff/markdownViewr</link>
|
||||
<description>markdownViewr releases</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>markdownViewr 1.1.0</title>
|
||||
<sparkle:version>2</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
|
||||
<pubDate>Thu, 05 Jun 2026 12:00:00 +0000</pubDate>
|
||||
<enclosure
|
||||
url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.0/markdownViewr-1.1.0.zip"
|
||||
sparkle:edSignature="..."
|
||||
length="..."
|
||||
type="application/octet-stream"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
```
|
||||
|
||||
The `generate_appcast` tool reads the private key from the keychain, signs the zip, extracts version info from the app bundle, and writes the updated `appcast.xml`.
|
||||
|
||||
## Release Workflow Changes
|
||||
|
||||
The `just release` recipe gains a final step after creating the GitHub release:
|
||||
|
||||
1. Run `generate_appcast` against the export directory containing the notarized zip.
|
||||
2. Commit the updated `appcast.xml` to the repo.
|
||||
3. Push the commit.
|
||||
|
||||
The `generate_appcast` tool is accessed via the path inside the resolved Sparkle SPM package in DerivedData, or via a symlink/path set up at key-generation time.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `project.yml` | Add Sparkle SPM package; add to target dependencies |
|
||||
| `markdownViewr/Info.plist` | Add `SUFeedURL` and `SUPublicEDKey` |
|
||||
| `markdownViewr/MarkdownViewrApp.swift` | Add `SPUStandardUpdaterController`; add "Check for Updates..." menu item |
|
||||
| `appcast.xml` | New — initial empty appcast committed to repo root |
|
||||
| `justfile` | Update `release` recipe to run `generate_appcast`, commit, and push `appcast.xml` |
|
||||
|
||||
## Setup Steps (One-Time, Not in Implementation Plan)
|
||||
|
||||
Before the implementation plan tasks can be completed, the developer must:
|
||||
|
||||
1. Run Sparkle's `generate_keys` tool to create the EdDSA key pair.
|
||||
2. Store the private key in the keychain under profile `markdownViewr-sparkle`.
|
||||
3. Note the public key for insertion into `Info.plist`.
|
||||
|
||||
These are manual one-time steps performed in the terminal, not automated tasks.
|
||||
@@ -24,3 +24,61 @@ generate:
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr clean
|
||||
|
||||
# Archive, notarize, and publish a GitHub release (bump MARKETING_VERSION in project.yml first)
|
||||
release: kill
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
ARCHIVE="/tmp/markdownViewr.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-export"
|
||||
ZIP="/tmp/markdownViewr-$VERSION.zip"
|
||||
echo "==> Archiving v$VERSION..."
|
||||
rm -rf "$ARCHIVE" "$EXPORT" "$ZIP"
|
||||
xcodebuild archive \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewr \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-destination 'generic/platform=macOS' \
|
||||
ENABLE_HARDENED_RUNTIME=YES | xcpretty || true
|
||||
echo "==> Exporting with Developer ID signing..."
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportPath "$EXPORT" \
|
||||
-exportOptionsPlist ExportOptions.plist
|
||||
APP="$EXPORT/markdownViewr.app"
|
||||
if [[ ! -d "$APP" ]]; then
|
||||
echo "Error: expected $APP but found: $(ls "$EXPORT")"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Notarizing (this takes a minute)..."
|
||||
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||
xcrun notarytool submit "$ZIP" --keychain-profile "markdownViewr-notarytool" --wait
|
||||
echo "==> Stapling notarization ticket..."
|
||||
xcrun stapler staple "$APP"
|
||||
echo "==> Packaging final release..."
|
||||
ditto -c -k --keepParent "$APP" "$ZIP"
|
||||
echo "==> Creating GitHub release v$VERSION..."
|
||||
gh release create "v$VERSION" "$ZIP" \
|
||||
--repo darinkelkhoff/markdownViewr \
|
||||
--title "markdownViewr v$VERSION" \
|
||||
--generate-notes
|
||||
echo "==> Updating appcast..."
|
||||
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
|
||||
if [[ -z "$SPARKLE_BIN" ]]; then
|
||||
echo "Error: Sparkle tools not found in DerivedData. Run 'just build' first."
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/markdownViewr-appcast-input
|
||||
mkdir -p /tmp/markdownViewr-appcast-input
|
||||
cp "$ZIP" /tmp/markdownViewr-appcast-input/
|
||||
"$SPARKLE_BIN/generate_appcast" \
|
||||
--download-url-prefix "https://github.com/darinkelkhoff/markdownViewr/releases/download/v$VERSION/" \
|
||||
-o appcast.xml \
|
||||
/tmp/markdownViewr-appcast-input/
|
||||
echo "==> Committing and pushing appcast..."
|
||||
git add appcast.xml
|
||||
git commit -m "release: update appcast for v$VERSION"
|
||||
git push origin main
|
||||
echo ""
|
||||
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
|
||||
@@ -37,7 +37,11 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func rerender() {
|
||||
renderedHTML = MarkdownDocument.convertToHTML(currentMarkdown, frontmatterMode: themeManager.frontmatterMode)
|
||||
renderedHTML = MarkdownDocument.convertToHTML(
|
||||
currentMarkdown,
|
||||
frontmatterMode: themeManager.frontmatterMode,
|
||||
extensions: themeManager.markdownExtensions
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -57,6 +61,9 @@ struct ContentView: View {
|
||||
.onReceive(themeManager.$frontmatterMode) { _ in
|
||||
DispatchQueue.main.async { rerender() }
|
||||
}
|
||||
.onReceive(themeManager.$markdownExtensions) { _ in
|
||||
DispatchQueue.main.async { rerender() }
|
||||
}
|
||||
.onReceive(liveContent.$rawMarkdown) { _ in
|
||||
DispatchQueue.main.async { rerender() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
private enum HelpTopic: String, CaseIterable, Identifiable {
|
||||
case overview = "Overview"
|
||||
case openingFiles = "Opening Files"
|
||||
case frontmatter = "Frontmatter"
|
||||
case extensions = "Markdown Extensions"
|
||||
case themes = "Themes"
|
||||
case editors = "External Editors"
|
||||
case shortcuts = "Keyboard Shortcuts"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct HelpView: View {
|
||||
@State private var selection: HelpTopic = .overview
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(HelpTopic.allCases, selection: $selection) { topic in
|
||||
Text(topic.rawValue).tag(topic)
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 160, ideal: 180)
|
||||
} detail: {
|
||||
HelpWebView(markdown: helpMarkdown(for: selection))
|
||||
.id(selection)
|
||||
}
|
||||
}
|
||||
|
||||
private func helpMarkdown(for topic: HelpTopic) -> String {
|
||||
switch topic {
|
||||
case .overview:
|
||||
return """
|
||||
# Overview
|
||||
|
||||
markdownViewr is a fast, view-only markdown file viewer for macOS. It renders \
|
||||
markdown to HTML and displays it in a styled WebView with full theming support.
|
||||
|
||||
Files are watched for external changes and automatically reloaded — open a file \
|
||||
in your editor and markdownViewr keeps pace as you save.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders standard Markdown and GitHub Flavored Markdown (GFM): bold, italic, \
|
||||
strikethrough, tables, code blocks, task lists, and more
|
||||
- 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
|
||||
- Vim-style keyboard navigation
|
||||
- Find in document with match count
|
||||
- One-click open in any configured external editor
|
||||
- Automatic updates via Sparkle
|
||||
"""
|
||||
|
||||
case .openingFiles:
|
||||
return """
|
||||
# Opening Files
|
||||
|
||||
markdownViewr opens any plain text file. It registers `.md`, `.markdown`, \
|
||||
`.mdown`, and `.mkd` with the OS so Finder offers markdownViewr for those \
|
||||
file types by default.
|
||||
|
||||
## Ways to open a file
|
||||
|
||||
- **File > Open** (⌘O) — standard open dialog
|
||||
- Drag a file onto the Dock icon or an open window
|
||||
- Double-click a file in Finder if markdownViewr is set as the default app
|
||||
- Right-click a file in Finder > Open With > markdownViewr
|
||||
|
||||
## Setting as the default viewer
|
||||
|
||||
To make markdownViewr open all `.md` files by default: right-click any `.md` \
|
||||
file in Finder, choose **Get Info**, expand **Open with**, select markdownViewr, \
|
||||
then click **Change All...**.
|
||||
|
||||
## Live reloading
|
||||
|
||||
When an open file is modified on disk by another app (your editor, a script, \
|
||||
etc.), markdownViewr detects the change and re-renders automatically. Scroll \
|
||||
position is preserved across reloads.
|
||||
"""
|
||||
|
||||
case .frontmatter:
|
||||
return """
|
||||
# Frontmatter
|
||||
|
||||
YAML frontmatter is a block of metadata at the very top of a Markdown file, \
|
||||
delimited by `---` lines:
|
||||
|
||||
```
|
||||
---
|
||||
title: My Document
|
||||
author: Jane Smith
|
||||
date: 2026-01-01
|
||||
---
|
||||
|
||||
# Heading
|
||||
|
||||
Body text...
|
||||
```
|
||||
|
||||
The **Frontmatter** setting (Settings > General) controls how this block is handled.
|
||||
|
||||
## Hide (default)
|
||||
|
||||
The frontmatter block is stripped before rendering. The body begins immediately \
|
||||
after the closing `---` line.
|
||||
|
||||
## Show as Metadata
|
||||
|
||||
The frontmatter is rendered as a styled key–value table above the document body. \
|
||||
Keys appear on the left, values on the right.
|
||||
|
||||
## Show as Markdown
|
||||
|
||||
The frontmatter is passed to the Markdown parser as-is. The `---` delimiters \
|
||||
render as horizontal rules and the content is treated as normal Markdown text.
|
||||
"""
|
||||
|
||||
case .extensions:
|
||||
return """
|
||||
# Markdown Extensions
|
||||
|
||||
Extensions add syntax beyond standard GFM. Each can be toggled independently \
|
||||
in **Settings > Extensions**.
|
||||
|
||||
## Highlight
|
||||
|
||||
Wrapping text in `== ==` renders it as highlighted text.
|
||||
|
||||
```
|
||||
==highlighted text==
|
||||
```
|
||||
|
||||
## Superscript
|
||||
|
||||
Wrapping a single word in `^ ^` renders it as superscript. No spaces allowed \
|
||||
inside — this avoids false positives on prose like "It's ^ that simple".
|
||||
|
||||
```
|
||||
E = mc^2^
|
||||
|
||||
Footnote reference^1^
|
||||
```
|
||||
|
||||
## Subscript
|
||||
|
||||
Wrapping a single word in `~ ~` renders it as subscript. No spaces allowed \
|
||||
inside — single-word only to avoid conflict with GFM strikethrough (`~~ ~~`).
|
||||
|
||||
```
|
||||
H~2~O
|
||||
|
||||
CO~2~
|
||||
```
|
||||
|
||||
## Underline
|
||||
|
||||
Wrapping text in `++ ++` renders it as underlined text.
|
||||
|
||||
```
|
||||
++underlined text++
|
||||
```
|
||||
|
||||
## Standard GFM (always on)
|
||||
|
||||
- `**bold**` / `__bold__`
|
||||
- `*italic*` / `_italic_`
|
||||
- `~~strikethrough~~`
|
||||
- `` `inline code` ``
|
||||
- Fenced code blocks
|
||||
- Tables
|
||||
- `- [ ]` task lists
|
||||
"""
|
||||
|
||||
case .themes:
|
||||
return """
|
||||
# Themes
|
||||
|
||||
Themes control the colors, fonts, and overall appearance of rendered documents. \
|
||||
markdownViewr ships with a set of built-in themes and supports unlimited \
|
||||
user-created themes.
|
||||
|
||||
## Switching themes
|
||||
|
||||
Use the **Theme** menu or the keyboard shortcuts **⌘⇧↓** / **⌘⇧↑** to cycle \
|
||||
through enabled themes. The active theme applies to all open windows.
|
||||
|
||||
## Managing themes
|
||||
|
||||
**Settings > Themes** lists all themes. You can:
|
||||
|
||||
- Enable or disable themes (disabled themes are skipped when cycling)
|
||||
- Reorder themes by dragging
|
||||
- Delete user themes
|
||||
- Restore deleted built-in themes
|
||||
|
||||
## Creating and editing themes
|
||||
|
||||
Click **New Theme** (or **Edit** on an existing user theme) to open the Theme \
|
||||
Editor. Changes are previewed live against a sample document.
|
||||
|
||||
The editor covers:
|
||||
|
||||
- Background and text colors
|
||||
- Heading, link, inline code, and blockquote colors
|
||||
- Table border and alternating row colors
|
||||
- Body, heading, and code font families and sizes
|
||||
- Custom CSS — injected after all base styles, so any rule can be overridden
|
||||
|
||||
## Custom CSS
|
||||
|
||||
Custom CSS is the escape hatch for anything the theme editor doesn't expose. \
|
||||
It targets the same HTML the renderer produces, so standard CSS selectors work:
|
||||
|
||||
```css
|
||||
/* Wider line spacing */
|
||||
body { line-height: 1.9; }
|
||||
|
||||
/* Justify paragraphs */
|
||||
p { text-align: justify; }
|
||||
|
||||
/* Larger code font */
|
||||
code { font-size: 0.95em; }
|
||||
```
|
||||
|
||||
## 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).
|
||||
"""
|
||||
|
||||
case .editors:
|
||||
return """
|
||||
# External Editors
|
||||
|
||||
markdownViewr can open the current file in an external editor with one click. \
|
||||
Any app that accepts a file path as a command-line argument can be configured.
|
||||
|
||||
## Adding an editor
|
||||
|
||||
Go to **Settings > Editors** and click the **+** button. Give the editor a name, \
|
||||
then click the path field to browse for the executable or app bundle. Most \
|
||||
editors install a command-line tool (e.g. `/usr/local/bin/code` for VS Code), \
|
||||
but you can also browse directly to the `.app` in `/Applications`.
|
||||
|
||||
Enable **Opens folder instead of file** if the editor works better when opened \
|
||||
to the containing directory rather than the file itself — useful for \
|
||||
project-based editors like VS Code or Nova, terminal emulators like Terminal \
|
||||
(`/System/Applications/Utilities/Terminal.app`), or Finder \
|
||||
(`/System/Library/CoreServices/Finder.app`).
|
||||
|
||||
## Using the toolbar button
|
||||
|
||||
When at least one editor is configured, an **Open In** button appears in the \
|
||||
document toolbar. Click it to open the current file (or its containing folder) \
|
||||
in your configured editor. If you have multiple editors configured, clicking \
|
||||
the button shows a menu to choose which one.
|
||||
"""
|
||||
|
||||
case .shortcuts:
|
||||
return """
|
||||
# Keyboard Shortcuts
|
||||
|
||||
## Navigation
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j` / `k` | Scroll down / up one line |
|
||||
| `Ctrl+d` / `Ctrl+u` | Scroll half page down / up |
|
||||
| `Ctrl+f` / `Ctrl+b` | Scroll full page down / up |
|
||||
| `g g` | Jump to top of document |
|
||||
| `G` | Jump to bottom of document |
|
||||
| `]]` / `[[` | Jump to next / previous heading |
|
||||
|
||||
## Find
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `⌘ F` | Show / hide find bar |
|
||||
| `⌘ G` | Find next match |
|
||||
| `⌘ ⇧ G` | Find previous match |
|
||||
| `/` (in document) | Focus find bar (vim-style) |
|
||||
| `n` / `N` (in document) | Next / previous match (vim-style) |
|
||||
|
||||
## View
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `⌘ +` | Zoom in |
|
||||
| `⌘ −` | Zoom out |
|
||||
| `⌘ 0` | Actual size |
|
||||
| `⌘ ⇧ ↓` | Next theme |
|
||||
| `⌘ ⇧ ↑` | Previous theme |
|
||||
|
||||
## General
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `⌘ ,` | Open Settings |
|
||||
| `⌘ ?` | Open Help |
|
||||
| `⌘ O` | Open file |
|
||||
| `⌘ W` | Close window |
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HelpWebView: NSViewRepresentable {
|
||||
let markdown: String
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
let body = MarkdownDocument.convertToHTML(markdown)
|
||||
let html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding: 20px 24px;
|
||||
max-width: 680px;
|
||||
}
|
||||
h1 { font-size: 1.5em; margin: 0 0 12px 0; }
|
||||
h2 { font-size: 1.1em; margin: 20px 0 6px 0; }
|
||||
p { margin: 0 0 8px 0; }
|
||||
ul, ol { margin: 0 0 8px 0; padding-left: 1.5em; }
|
||||
li { margin-bottom: 3px; }
|
||||
code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
background: rgba(0,0,0,0.06);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 10px; }
|
||||
th, td { text-align: left; padding: 5px 10px; border-bottom: 1px solid rgba(0,0,0,0.1); }
|
||||
th { font-weight: 600; border-bottom: 2px solid rgba(0,0,0,0.15); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #e8e8e8; }
|
||||
code { background: rgba(255,255,255,0.1); }
|
||||
pre { background: rgba(255,255,255,0.08); }
|
||||
th, td { border-bottom-color: rgba(255,255,255,0.12); }
|
||||
th { border-bottom-color: rgba(255,255,255,0.2); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>\(body)</body>
|
||||
</html>
|
||||
"""
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,15 @@
|
||||
<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>
|
||||
<string>flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc=</string>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -42,7 +42,7 @@ struct MarkdownDocument: FileDocument {
|
||||
case markdown = "Show as Markdown"
|
||||
}
|
||||
|
||||
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide) -> String {
|
||||
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide, extensions: MarkdownExtensions = MarkdownExtensions()) -> String {
|
||||
let (frontmatter, body) = parseFrontmatter(markdown)
|
||||
|
||||
var html = ""
|
||||
@@ -58,11 +58,12 @@ struct MarkdownDocument: FileDocument {
|
||||
}
|
||||
}
|
||||
|
||||
let content = frontmatter != nil ? body : markdown
|
||||
let rawContent = frontmatter != nil ? body : markdown
|
||||
let content = preProcessSubscript(rawContent, enabled: extensions.subscript_)
|
||||
let document = Document(parsing: content)
|
||||
var htmlVisitor = HTMLConverter()
|
||||
html += htmlVisitor.visit(document)
|
||||
return html
|
||||
return applyExtensions(html, extensions: extensions)
|
||||
}
|
||||
|
||||
private static func parseFrontmatter(_ markdown: String) -> (frontmatter: String?, body: String) {
|
||||
@@ -138,6 +139,137 @@ struct MarkdownDocument: FileDocument {
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
|
||||
// Converts ~text~ to <sub>text</sub> in raw markdown before the parser runs,
|
||||
// because cmark-gfm treats single-tilde pairs as strikethrough and consumes them.
|
||||
private static func preProcessSubscript(_ markdown: String, enabled: Bool) -> String {
|
||||
var result = ""
|
||||
var inFence = false
|
||||
var fenceChar: Character = "`"
|
||||
var fenceLength = 0
|
||||
|
||||
let lines = markdown.components(separatedBy: "\n")
|
||||
for (i, line) in lines.enumerated() {
|
||||
let suffix = i < lines.count - 1 ? "\n" : ""
|
||||
if !inFence {
|
||||
let stripped = line.drop(while: { $0 == " " || $0 == "\t" })
|
||||
let backticks = stripped.prefix(while: { $0 == "`" }).count
|
||||
let tildes = stripped.prefix(while: { $0 == "~" }).count
|
||||
if backticks >= 3 || tildes >= 3 {
|
||||
let (ch, count) = backticks >= 3 ? (Character("`"), backticks) : (Character("~"), tildes)
|
||||
inFence = true; fenceChar = ch; fenceLength = count
|
||||
result += line + suffix
|
||||
} else {
|
||||
result += processSubscriptInLine(line, enabled: enabled) + suffix
|
||||
}
|
||||
} else {
|
||||
let stripped = line.drop(while: { $0 == " " || $0 == "\t" })
|
||||
let delimCount = stripped.prefix(while: { $0 == fenceChar }).count
|
||||
let rest = String(stripped.dropFirst(delimCount)).trimmingCharacters(in: .whitespaces)
|
||||
if delimCount >= fenceLength && rest.isEmpty { inFence = false }
|
||||
result += line + suffix
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func processSubscriptInLine(_ line: String, enabled: Bool) -> String {
|
||||
guard let codeSpanRegex = try? NSRegularExpression(pattern: "`+[^`]*`+", options: []) else {
|
||||
return applySubscriptRegex(line, enabled: enabled)
|
||||
}
|
||||
var result = ""
|
||||
var lastEnd = line.startIndex
|
||||
let matches = codeSpanRegex.matches(in: line, range: NSRange(line.startIndex..., in: line))
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: line) else { continue }
|
||||
result += applySubscriptRegex(String(line[lastEnd..<range.lowerBound]), enabled: enabled)
|
||||
result += String(line[range])
|
||||
lastEnd = range.upperBound
|
||||
}
|
||||
result += applySubscriptRegex(String(line[lastEnd...]), enabled: enabled)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func applySubscriptRegex(_ text: String, enabled: Bool) -> String {
|
||||
// When disabled, backslash-escape the tildes so cmark-gfm renders them as literal ~text~
|
||||
// instead of treating them as strikethrough.
|
||||
let template = enabled ? "<sub>$1</sub>" : "\\\\~$1\\\\~"
|
||||
guard let regex = try? NSRegularExpression(pattern: "(?<!~)~([^\\s~]+)~(?!~)", options: []) else { return text }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: template)
|
||||
}
|
||||
|
||||
private static func applyExtensions(_ html: String, extensions: MarkdownExtensions) -> String {
|
||||
// Split on <pre...> tags (with optional attributes) so code blocks are never transformed.
|
||||
guard let preOpenRegex = try? NSRegularExpression(pattern: "<pre(?:\\s[^>]*)?>", options: []) else {
|
||||
return html // fail safe: pass through unchanged
|
||||
}
|
||||
|
||||
var result = ""
|
||||
var searchRange = html.startIndex..<html.endIndex
|
||||
|
||||
while let match = preOpenRegex.firstMatch(in: html, range: NSRange(searchRange, in: html)) {
|
||||
guard let matchRange = Range(match.range, in: html) else { break }
|
||||
|
||||
// Process text before this <pre...> tag
|
||||
let before = String(html[searchRange.lowerBound..<matchRange.lowerBound])
|
||||
result += applyExtensionRegexes(before, extensions: extensions)
|
||||
|
||||
// Find the matching </pre>
|
||||
let afterOpenTag = matchRange.upperBound
|
||||
if let closeRange = html.range(of: "</pre>", range: afterOpenTag..<html.endIndex) {
|
||||
// Include the <pre...>content</pre> verbatim
|
||||
result += String(html[matchRange.lowerBound..<closeRange.upperBound])
|
||||
searchRange = closeRange.upperBound..<html.endIndex
|
||||
} else {
|
||||
// No closing </pre> found — pass through the rest verbatim
|
||||
result += String(html[matchRange.lowerBound...])
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining text after the last </pre>
|
||||
result += applyExtensionRegexes(String(html[searchRange]), extensions: extensions)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func applyExtensionRegexes(_ text: String, extensions: MarkdownExtensions) -> String {
|
||||
guard let codeRegex = try? NSRegularExpression(pattern: "<code(?:\\s[^>]*)?>.*?</code>", options: [.dotMatchesLineSeparators]) else {
|
||||
return applyRawExtensionRegexes(text, extensions: extensions)
|
||||
}
|
||||
var result = ""
|
||||
var searchRange = text.startIndex..<text.endIndex
|
||||
while let match = codeRegex.firstMatch(in: text, range: NSRange(searchRange, in: text)),
|
||||
let matchRange = Range(match.range, in: text) {
|
||||
result += applyRawExtensionRegexes(String(text[searchRange.lowerBound..<matchRange.lowerBound]), extensions: extensions)
|
||||
result += String(text[matchRange])
|
||||
searchRange = matchRange.upperBound..<text.endIndex
|
||||
}
|
||||
result += applyRawExtensionRegexes(String(text[searchRange]), extensions: extensions)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func applyRawExtensionRegexes(_ 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: "\\^([^\\s\\^]+)\\^", template: "<sup>$1</sup>")
|
||||
}
|
||||
// subscript: handled in preProcessSubscript before the parser runs,
|
||||
// because cmark-gfm treats ~text~ as strikethrough before we can post-process.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HTMLConverter: MarkupWalker {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
@main
|
||||
struct MarkdownViewrApp: App {
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var editorManager = EditorManager()
|
||||
|
||||
@@ -17,6 +23,13 @@ struct MarkdownViewrApp: App {
|
||||
}
|
||||
.defaultSize(width: 700, height: 900)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
.disabled(!updaterController.updater.canCheckForUpdates)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings...") {
|
||||
SettingsWindowController.shared.show(
|
||||
@@ -44,6 +57,13 @@ struct MarkdownViewrApp: App {
|
||||
.keyboardShortcut("g", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("markdownViewr Help") {
|
||||
HelpWindowController.shared.show()
|
||||
}
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .toolbar) {
|
||||
Button("Zoom In") {
|
||||
themeManager.zoomIn()
|
||||
@@ -94,6 +114,41 @@ struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
class HelpWindowController: NSObject, NSWindowDelegate {
|
||||
static let shared = HelpWindowController()
|
||||
private var window: NSWindow?
|
||||
|
||||
func show() {
|
||||
if let window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let hostingView = NSHostingView(rootView: HelpView())
|
||||
hostingView.frame = NSRect(x: 0, y: 0, width: 620, height: 540)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 620, height: 540),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "markdownViewr Help"
|
||||
window.contentView = hostingView
|
||||
window.minSize = NSSize(width: 460, height: 340)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
window = nil
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
static let shared = SettingsWindowController()
|
||||
private var window: NSWindow?
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{\rtf1\ansi\ansicpg1252\cocoartf2639
|
||||
\cocoatextscaling0\cocoaplatform0
|
||||
\pard\pardirnatural\qc\partightenfactor0
|
||||
Directed by Darin Kelkhoff\
|
||||
Written by Claude Code}
|
||||
@@ -224,6 +224,13 @@ del {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: var(--highlight-bg);
|
||||
color: var(--highlight-text);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.frontmatter {
|
||||
margin: 0 0 24px 0;
|
||||
padding: 12px 16px;
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#e6e9ef",
|
||||
"codeText": "#40a02b",
|
||||
"blockquoteBorder": "#8839ef",
|
||||
"blockquoteBackground": "#e6e9ef"
|
||||
"blockquoteBackground": "#e6e9ef",
|
||||
"highlightBackground": "#f9e2af",
|
||||
"highlightText": "#4c4f69"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#181825",
|
||||
"codeText": "#a6e3a1",
|
||||
"blockquoteBorder": "#cba6f7",
|
||||
"blockquoteBackground": "#24243a"
|
||||
"blockquoteBackground": "#24243a",
|
||||
"highlightBackground": "#f9e2af",
|
||||
"highlightText": "#1e1e2e"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#1e1f29",
|
||||
"codeText": "#50fa7b",
|
||||
"blockquoteBorder": "#bd93f9",
|
||||
"blockquoteBackground": "#2e3042"
|
||||
"blockquoteBackground": "#2e3042",
|
||||
"highlightBackground": "#f1fa8c",
|
||||
"highlightText": "#282a36"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#272e33",
|
||||
"codeText": "#a7c080",
|
||||
"blockquoteBorder": "#d699b6",
|
||||
"blockquoteBackground": "#343f44"
|
||||
"blockquoteBackground": "#343f44",
|
||||
"highlightBackground": "#dbbc7f",
|
||||
"highlightText": "#2d353b"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#161b22",
|
||||
"codeText": "#e6edf3",
|
||||
"blockquoteBorder": "#3b434b",
|
||||
"blockquoteBackground": "#141a22"
|
||||
"blockquoteBackground": "#141a22",
|
||||
"highlightBackground": "#e3b341",
|
||||
"highlightText": "#0d1117"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#f6f8fa",
|
||||
"codeText": "#1f2328",
|
||||
"blockquoteBorder": "#d0d7de",
|
||||
"blockquoteBackground": "#f6f8fa"
|
||||
"blockquoteBackground": "#f6f8fa",
|
||||
"highlightBackground": "#fff8c5",
|
||||
"highlightText": "#1f2328"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#1d2021",
|
||||
"codeText": "#b8bb26",
|
||||
"blockquoteBorder": "#d65d0e",
|
||||
"blockquoteBackground": "#32302f"
|
||||
"blockquoteBackground": "#32302f",
|
||||
"highlightBackground": "#fabd2f",
|
||||
"highlightText": "#282828"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#f2e5bc",
|
||||
"codeText": "#79740e",
|
||||
"blockquoteBorder": "#d65d0e",
|
||||
"blockquoteBackground": "#f2e5bc"
|
||||
"blockquoteBackground": "#f2e5bc",
|
||||
"highlightBackground": "#fabd2f",
|
||||
"highlightText": "#282828"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#16161d",
|
||||
"codeText": "#98bb6c",
|
||||
"blockquoteBorder": "#957fb8",
|
||||
"blockquoteBackground": "#2a2a37"
|
||||
"blockquoteBackground": "#2a2a37",
|
||||
"highlightBackground": "#ff9e3b",
|
||||
"highlightText": "#1f1f28"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#1e1f1c",
|
||||
"codeText": "#a6e22e",
|
||||
"blockquoteBorder": "#f92672",
|
||||
"blockquoteBackground": "#2e2f2a"
|
||||
"blockquoteBackground": "#2e2f2a",
|
||||
"highlightBackground": "#e6db74",
|
||||
"highlightText": "#272822"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#3b4252",
|
||||
"codeText": "#a3be8c",
|
||||
"blockquoteBorder": "#81a1c1",
|
||||
"blockquoteBackground": "#3b4252"
|
||||
"blockquoteBackground": "#3b4252",
|
||||
"highlightBackground": "#ebcb8b",
|
||||
"highlightText": "#2e3440"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#21252b",
|
||||
"codeText": "#98c379",
|
||||
"blockquoteBorder": "#c678dd",
|
||||
"blockquoteBackground": "#2c313c"
|
||||
"blockquoteBackground": "#2c313c",
|
||||
"highlightBackground": "#e5c07b",
|
||||
"highlightText": "#282c34"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#f0f0f0",
|
||||
"codeText": "#50a14f",
|
||||
"blockquoteBorder": "#a626a4",
|
||||
"blockquoteBackground": "#f0f0f0"
|
||||
"blockquoteBackground": "#f0f0f0",
|
||||
"highlightBackground": "#ffd700",
|
||||
"highlightText": "#383a42"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#f2e9e1",
|
||||
"codeText": "#d7827e",
|
||||
"blockquoteBorder": "#907aa9",
|
||||
"blockquoteBackground": "#f2e9e1"
|
||||
"blockquoteBackground": "#f2e9e1",
|
||||
"highlightBackground": "#f6c177",
|
||||
"highlightText": "#575279"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#1f1d2e",
|
||||
"codeText": "#9ccfd8",
|
||||
"blockquoteBorder": "#c4a7e7",
|
||||
"blockquoteBackground": "#1f1d2e"
|
||||
"blockquoteBackground": "#1f1d2e",
|
||||
"highlightBackground": "#f6c177",
|
||||
"highlightText": "#191724"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#073642",
|
||||
"codeText": "#859900",
|
||||
"blockquoteBorder": "#b58900",
|
||||
"blockquoteBackground": "#073642"
|
||||
"blockquoteBackground": "#073642",
|
||||
"highlightBackground": "#b58900",
|
||||
"highlightText": "#002b36"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#eee8d5",
|
||||
"codeText": "#859900",
|
||||
"blockquoteBorder": "#6c71c4",
|
||||
"blockquoteBackground": "#eee8d5"
|
||||
"blockquoteBackground": "#eee8d5",
|
||||
"highlightBackground": "#b58900",
|
||||
"highlightText": "#fdf6e3"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#cbccd1",
|
||||
"codeText": "#33635c",
|
||||
"blockquoteBorder": "#5a4a78",
|
||||
"blockquoteBackground": "#cbccd1"
|
||||
"blockquoteBackground": "#cbccd1",
|
||||
"highlightBackground": "#f9c859",
|
||||
"highlightText": "#343b58"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#16161e",
|
||||
"codeText": "#9ece6a",
|
||||
"blockquoteBorder": "#bb9af7",
|
||||
"blockquoteBackground": "#1f2335"
|
||||
"blockquoteBackground": "#1f2335",
|
||||
"highlightBackground": "#e0af68",
|
||||
"highlightText": "#1a1b26"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"codeBackground": "#2b2b2b",
|
||||
"codeText": "#7f9f7f",
|
||||
"blockquoteBorder": "#cc9393",
|
||||
"blockquoteBackground": "#4f4f4f"
|
||||
"blockquoteBackground": "#4f4f4f",
|
||||
"highlightBackground": "#f0dfaf",
|
||||
"highlightText": "#3f3f3f"
|
||||
},
|
||||
"fonts": {
|
||||
"body": "System",
|
||||
|
||||
@@ -19,6 +19,10 @@ $ markdownviewr --version
|
||||
- [Hyperlinks](https://example.com) styled to your palette
|
||||
- Inline `code spans` and fenced code blocks
|
||||
|
||||
## Extensions
|
||||
|
||||
This sentence uses ==highlighted text==, H~2~O for subscript, E=mc^2^ for superscript, and ++underlined text++.
|
||||
|
||||
### Code Example
|
||||
|
||||
```swift
|
||||
|
||||
@@ -18,6 +18,11 @@ struct SettingsView: View {
|
||||
.tabItem {
|
||||
Label("Themes", systemImage: "paintpalette")
|
||||
}
|
||||
|
||||
MarkdownSettingsView()
|
||||
.tabItem {
|
||||
Label("Markdown", systemImage: "doc.plaintext")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
}
|
||||
@@ -262,6 +267,82 @@ struct EditorsSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeEditorItem: Identifiable {
|
||||
let id = UUID()
|
||||
let theme: Theme?
|
||||
|
||||
@@ -48,6 +48,8 @@ struct ThemeColors: Codable, Hashable {
|
||||
var codeText: String
|
||||
var blockquoteBorder: String
|
||||
var blockquoteBackground: String
|
||||
var highlightBackground: String
|
||||
var highlightText: String
|
||||
|
||||
init(
|
||||
background: String = "#1e1e2e",
|
||||
@@ -62,7 +64,9 @@ struct ThemeColors: Codable, Hashable {
|
||||
codeBackground: String = "#181825",
|
||||
codeText: String = "#a6e3a1",
|
||||
blockquoteBorder: String = "#cba6f7",
|
||||
blockquoteBackground: String = "#252535"
|
||||
blockquoteBackground: String = "#252535",
|
||||
highlightBackground: String = "#ffd700",
|
||||
highlightText: String = "#1a1a1a"
|
||||
) {
|
||||
self.background = background
|
||||
self.text = text
|
||||
@@ -77,6 +81,35 @@ struct ThemeColors: Codable, Hashable {
|
||||
self.codeText = codeText
|
||||
self.blockquoteBorder = blockquoteBorder
|
||||
self.blockquoteBackground = blockquoteBackground
|
||||
self.highlightBackground = highlightBackground
|
||||
self.highlightText = highlightText
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ struct ThemeEditorView: View {
|
||||
colorRow("Code Text", $colors.codeText)
|
||||
colorRow("Quote Border", $colors.blockquoteBorder)
|
||||
colorRow("Quote BG", $colors.blockquoteBackground)
|
||||
colorRow("Highlight BG", $colors.highlightBackground)
|
||||
colorRow("Highlight Text", $colors.highlightText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ class ThemeManager: ObservableObject {
|
||||
UserDefaults.standard.set(frontmatterMode.rawValue, forKey: "frontmatterMode")
|
||||
}
|
||||
}
|
||||
@Published var markdownExtensions: MarkdownExtensions {
|
||||
didSet {
|
||||
if let data = try? JSONEncoder().encode(markdownExtensions) {
|
||||
UserDefaults.standard.set(data, forKey: "markdownExtensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var activeTheme: Theme {
|
||||
themes.first { $0.name == activeThemeName } ?? themes.first ?? Self.fallbackTheme
|
||||
@@ -37,6 +44,12 @@ class ThemeManager: ObservableObject {
|
||||
self.zoomScale = savedZoom > 0 ? savedZoom : 1.0
|
||||
let modeRaw = UserDefaults.standard.string(forKey: "frontmatterMode") ?? "Hide"
|
||||
self.frontmatterMode = MarkdownDocument.FrontmatterMode(rawValue: modeRaw) ?? .hide
|
||||
if let data = UserDefaults.standard.data(forKey: "markdownExtensions"),
|
||||
let ext = try? JSONDecoder().decode(MarkdownExtensions.self, from: data) {
|
||||
self.markdownExtensions = ext
|
||||
} else {
|
||||
self.markdownExtensions = MarkdownExtensions()
|
||||
}
|
||||
loadThemes()
|
||||
}
|
||||
|
||||
@@ -299,6 +312,8 @@ class ThemeManager: ObservableObject {
|
||||
--code-text: \(theme.colors.codeText);
|
||||
--blockquote-border: \(theme.colors.blockquoteBorder);
|
||||
--blockquote-bg: \(theme.colors.blockquoteBackground);
|
||||
--highlight-bg: \(theme.colors.highlightBackground);
|
||||
--highlight-text: \(theme.colors.highlightText);
|
||||
--body-font: \(bodyFont);
|
||||
--heading-font: \(headingFont);
|
||||
--code-font: \(codeFont);
|
||||
|
||||
+12
-2
@@ -10,6 +10,9 @@ packages:
|
||||
swift-markdown:
|
||||
url: https://github.com/apple/swift-markdown.git
|
||||
from: "0.4.0"
|
||||
sparkle:
|
||||
url: https://github.com/sparkle-project/Sparkle.git
|
||||
from: "2.0.0"
|
||||
|
||||
targets:
|
||||
markdownViewr:
|
||||
@@ -22,12 +25,14 @@ targets:
|
||||
dependencies:
|
||||
- package: swift-markdown
|
||||
product: Markdown
|
||||
- package: sparkle
|
||||
product: Sparkle
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: markdownViewr/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "1.1.3"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ENABLE_APP_SANDBOX: "NO"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
@@ -57,3 +62,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