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.0</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="4188027" type="application/octet-stream" sparkle:edSignature="c1j2HlKXVEBF6bsfjG4DUNNhwOwOGIb+24OJoA7AKKSoBsZW1XnVqIuOlHWoKSiv8pL5lpwysOvaMK8hBc0DDg=="/>
|
||||
</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() }
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
+8
-1
@@ -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,11 +25,13 @@ 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"
|
||||
MARKETING_VERSION: "1.1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ENABLE_APP_SANDBOX: "NO"
|
||||
@@ -57,3 +62,5 @@ targets:
|
||||
- markdown
|
||||
- mdown
|
||||
- mkd
|
||||
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
Reference in new issue
Block a user