Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e25ec7963
|
||
|
|
1ab16bf4ed | ||
|
|
0d1133aafd | ||
|
|
fbb313ccb9 | ||
|
|
53a6ae7896 | ||
|
|
76ed62618c | ||
|
|
cb29d64a1d | ||
|
|
cde9351f11 | ||
|
|
ad2cc7e55f | ||
|
|
05d65cf0c7 | ||
|
|
ff7b942d46 | ||
|
|
01ff200f8d | ||
|
|
c9e8867ec3 | ||
|
|
2c2f153299 | ||
|
|
f6f4ac723c | ||
|
|
484d9af0ea | ||
|
|
87f8318f4d | ||
|
|
4199c8b375 | ||
|
|
6d9b5a32f5 | ||
|
|
baac7e71a8 | ||
|
|
1abc51eb25 | ||
|
|
91985c3fbc | ||
|
|
39a08ae42e | ||
|
|
8373722174 | ||
|
|
e06facc915 | ||
|
|
71d71df7dd | ||
|
|
842d6bf025 | ||
|
|
28458b0892 | ||
|
|
ad76a0c8e1 | ||
|
|
a3d91f50f4 | ||
|
|
88c983b51f | ||
|
|
562462714a | ||
|
|
63cd94886c | ||
|
|
50a9d2a01c | ||
|
|
0bf40661bf | ||
|
|
442a6ebae9 | ||
|
|
6f595b9370 | ||
|
|
7f23bb9393 | ||
|
|
fd0df14e95 | ||
|
|
4edb7f3bd7 | ||
|
|
82e12674b4 | ||
|
|
df8eeab4ad | ||
|
|
8d76a4bdca | ||
|
|
b07a9f87a1 | ||
|
|
e870838a73 | ||
|
|
f10dd1fa48 | ||
|
|
f1e96f2913 | ||
|
|
27447e6ca1 | ||
|
|
0822792868 | ||
|
|
a061eb3644 | ||
|
|
03259ccf14 | ||
|
|
ac930b116a | ||
|
|
f5d5ea7bdc | ||
|
|
3edfd7bedb | ||
|
|
2ef3e0b32c | ||
|
|
03f883af6b | ||
|
|
4dbda768af | ||
|
|
3d0c030fed | ||
|
|
a86203bd8f | ||
|
|
177d805d9e | ||
|
|
516946fec4 | ||
|
|
2631fc5ff2 | ||
|
|
f2109f49ba | ||
|
|
000f2699cc | ||
|
|
823b8f5ecc | ||
|
|
a0f1f88a4f | ||
|
|
ca644efc4e | ||
|
|
be5995bb55 | ||
|
|
3a3884155f | ||
|
|
97f398276a | ||
|
|
f60bb0aa53 | ||
|
|
654a134753 | ||
|
|
e8ad868277 | ||
|
|
3fbbe57c6f | ||
|
|
6e71826787 | ||
|
|
e2a6aeafb2 | ||
|
|
2ed08c1e86 |
No files matched your search
@@ -16,6 +16,29 @@ just kill # force-kill running app
|
||||
|
||||
After adding/removing Swift files or resources, run `xcodegen generate` before building.
|
||||
|
||||
## Distribution
|
||||
|
||||
The app ships through **two** channels from one codebase, built from **two application targets** that share a `targetTemplates: AppBase` (sources, resources, swift-markdown, version, bundle id):
|
||||
|
||||
| Channel | Target | Scheme | Signing | Updates | Recipe |
|
||||
|---|---|---|---|---|---|
|
||||
| **Developer ID** | `markdownViewr` | `markdownViewr` | Developer ID | Sparkle + Homebrew cask | `just release` |
|
||||
| **Mac App Store** | `markdownViewr-MAS` | `markdownViewr-MAS` | Apple Distribution | App Store | `just release-mas` |
|
||||
|
||||
Both targets use the standard `Debug`/`Release` configs (no special `-MAS` configs). They differ ONLY in three things: the `markdownViewr` target depends on the `sparkle` package and the `markdownViewr-MAS` target does not; the MAS target sets `SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD` and applies `markdownViewr/markdownViewr-MAS.entitlements` (sandbox + user-selected read-only + app-scope bookmarks + network client). The Developer ID build is unchanged at runtime — all sandbox behavior is gated behind `#if MAS_BUILD`.
|
||||
|
||||
**Why two targets, not one target with two configs:** SwiftPM **auto-links a declared package-product dependency regardless of whether the source imports it**, so `#if !MAS_BUILD` around `import Sparkle` removes Sparkle *usage* but not the *link/embed*. A single target therefore always embeds `Sparkle.framework`, whose non-sandboxable helper executables (Autoupdate, Updater.app, the XPC services) make the App Store reject the build (error 90296). The framework can't be stripped post-build either — the binary links it, so removal causes a dyld launch crash. The only fix is a separate target that doesn't depend on Sparkle at all. (Both targets output `markdownViewr.app` to the same `Build/Products/Release/` in shared DerivedData, so alternating local builds overwrite each other — harmless, since the release recipes archive to their own paths.)
|
||||
|
||||
What `MAS_BUILD` / the MAS target changes:
|
||||
|
||||
- **No Sparkle** — the MAS target omits the `sparkle` dependency (so nothing is linked or embedded), and `import Sparkle`, `SPUStandardUpdaterController`, and the "Check for Updates…" command are behind `#if !MAS_BUILD` (so the MAS target compiles without the Sparkle module). The `SUFeedURL`/`SUPublicEDKey` Info.plist keys live in the `markdownViewr` target's `info.properties` (so the Developer ID build keeps them); the MAS target reuses that generated `Info.plist` and deletes those two keys in its unconditional `Strip Sparkle keys from MAS Info.plist` post-build script. (`INFOPLIST_KEY_`-prefixed injection does NOT work for non-Apple custom keys — verified — hence the post-build strip.)
|
||||
- **Folder access** — `FolderAccessManager` resolves/persists an app-scoped security-scoped bookmark for the open document's folder, used only to read sibling images. The grant banner is shown **only when the document actually references local images** (`ImageInliner.containsLocalImage`) and access isn't held yet — image-less docs (the common case) never prompt. Bookmarks are keyed by folder path in `UserDefaults` (`folderBookmark:<path>`).
|
||||
- **File watching is decoupled from the grant** — `ContentView` starts the `FileWatcher` unconditionally on open. The opened document is accessible via the document architecture, so live-reload works for every doc with no prompt; the folder bookmark is needed only for sibling images.
|
||||
- **Image inlining** — `ContentView.rerender()` rewrites relative-local `<img>` tags to `data:` URLs via `ImageInliner`, reading bytes through the folder bookmark. This makes the preview HTML self-contained so `MarkdownWebView` only grants WKWebView read access to the container temp dir (not `/`). Remote images load via the network-client entitlement.
|
||||
- **External editors** — verified to work under sandbox via `NSWorkspace.open(_:withApplicationAt:)` with the stored path string; LaunchServices mediates the launch, so no per-editor bookmark is needed.
|
||||
|
||||
**Remaining manual App Store Connect steps** (not automated): register the App ID and create Apple Distribution + Mac Installer Distribution certificates and a provisioning profile; create the app record; upload screenshots; write the App Privacy label (collects nothing) and export-compliance answer (no non-exempt crypto); submit for review.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **SwiftUI** app shell (toolbar, settings, window management)
|
||||
@@ -25,6 +48,16 @@ After adding/removing Swift files or resources, run `xcodegen generate` before b
|
||||
- HTML rendered in a template (`Resources/template.html`) with CSS variable-based theming
|
||||
- Settings and Theme Editor use custom `NSWindow` controllers (not SwiftUI `Settings` scene) because SwiftUI's Settings scene doesn't support resizing
|
||||
|
||||
## Portability (Apple platforms)
|
||||
|
||||
The app can grow to iOS/iPadOS/visionOS without a rewrite, provided one boundary stays clean: the render/model core must stay free of AppKit.
|
||||
|
||||
- **Portable core (pure Swift / web — reuse as-is):** markdown→HTML (`MarkdownDocument`, swift-markdown), `ImageInliner`, `Theme`/`ThemeManager`, and the entire render layer (`Resources/template.html`, CSS, JS). Keep `NS*`/AppKit types out of these files.
|
||||
- **Platform layer (macOS-bound — needs per-platform twins):** `MarkdownWebView` (`NSViewRepresentable` → `UIViewRepresentable`; WKWebView itself exists on iOS), `SettingsWindowController`/`HelpWindowController` (`NSWindow` → sheets/navigation), `FolderAccessManager` (`NSOpenPanel` → `UIDocumentPickerViewController`), `EditorManager`/`EditorConfig` (`NSWorkspace` external-editor launch — no iOS equivalent), `WindowResizer`/`WindowAccessor`.
|
||||
- `DocumentGroup` (app entry) is already cross-platform. The MAS sandboxing also moves macOS toward the always-sandboxed iOS model (security-scoped access instead of broad file reads), so it helps rather than hinders an iOS port.
|
||||
|
||||
Don't build cross-platform abstractions speculatively — just keep the render/model code AppKit-free until a second platform is actually on the table.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
@@ -38,6 +71,8 @@ After adding/removing Swift files or resources, run `xcodegen generate` before b
|
||||
| `ThemeEditorView.swift` | Theme creation/editing UI with live preview |
|
||||
| `SettingsView.swift` | General, Editors, Themes tabs + `ThemeEditorWindowController` |
|
||||
| `EditorConfig.swift` | External editor model + manager |
|
||||
| `ImageInliner.swift` | Rewrites relative-local `<img>` tags to `data:` URLs (MAS build only) |
|
||||
| `FolderAccessManager.swift` | Security-scoped folder access via app-scoped bookmarks (MAS build only) |
|
||||
| `FileWatcher.swift` | `DispatchSource` file system watcher |
|
||||
| `FindBarController.swift` | Find state + notification names |
|
||||
| `CSSHelpView.swift` | Shared CSS reference popover |
|
||||
@@ -79,6 +114,27 @@ SwiftUI's `Settings` scene and `.sheet` don't support resizing on macOS. Both Se
|
||||
- Theme order tracked in UserDefaults key `themeOrder`
|
||||
- Active theme tracked in UserDefaults key `activeTheme`
|
||||
|
||||
## Theme Schema Versioning
|
||||
|
||||
Theme JSON files carry a `schemaVersion` integer field. The current version is in `Theme.currentSchemaVersion` (`Theme.swift`).
|
||||
|
||||
**When you add a new field to `Theme`, `ThemeColors`, `ThemeFonts`, or `ThemeSizes`:**
|
||||
|
||||
1. Increment `Theme.currentSchemaVersion`.
|
||||
2. Add the new field with a sensible default in `init(from decoder:)` using `decodeIfPresent` so older files still load.
|
||||
3. Update the built-in theme JSON files in `Resources/themes/` with the new field.
|
||||
4. Add a migration step in `ThemeManager.migrateIfNeeded(_ theme: inout Theme)` — an `if theme.schemaVersion < N` block that fills in the new field and bumps `schemaVersion` to `N`. Stack these in order so a theme several versions behind migrates fully in one pass. `migrateIfNeeded` is already called from `loadUserThemes()` (with automatic write-back to disk if the version changed) and from `importTheme(_:resolution:)`. Do not remove the example comment block — it shows future-me the pattern.
|
||||
5. Update `ThemeEditorView` to expose any new user-facing fields.
|
||||
6. Update `ThemeManager.generateCSS(for:)` if the new field affects rendering.
|
||||
|
||||
**Import behaviour (already implemented in `SettingsView.importThemes()`):** if an imported theme's `schemaVersion` exceeds `currentSchemaVersion`, the user is warned before import proceeds. If it is lower, run `migrateIfNeeded` on it during import just as you would for any loaded theme.
|
||||
|
||||
**Schema version history:**
|
||||
|
||||
| Version | What changed |
|
||||
|---|---|
|
||||
| 1 | Initial versioned schema (colors, fonts, sizes, customCSS) |
|
||||
|
||||
## Find in Document
|
||||
|
||||
- Cmd+F toggles find bar per-window (each window has its own `FindBarController`)
|
||||
|
||||
@@ -0,0 +1,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>app-store-connect</string>
|
||||
<key>teamID</key>
|
||||
<string>9MG4YT2G93</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Darin Kelkhoff
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,63 @@
|
||||
# markdownViewr
|
||||
|
||||
> **This is KofTwentyTwo's contribution fork of [darinkelkhoff/markdownViewr](https://github.com/darinkelkhoff/markdownViewr).** It publishes no builds. The download, Homebrew cask and Sparkle updates below all come from the upstream project. The fork carries printing work that is offered upstream: native printing merged as [darinkelkhoff/markdownViewr#2](https://github.com/darinkelkhoff/markdownViewr/pull/2), and the print-styling fixes are open as [#3](https://github.com/darinkelkhoff/markdownViewr/pull/3). Report app issues upstream. The Windows port is [KofTwentyTwo/MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
|
||||
|
||||
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
|
||||
|
||||
[](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## Download
|
||||
|
||||
**[Download for Mac](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)** — macOS 13 Ventura or later, free.
|
||||
|
||||
Open the DMG, drag markdownViewr to Applications, and you're done.
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew install --cask darinkelkhoff/tap/markdownviewr
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **GitHub Flavored Markdown** — tables, task lists, strikethrough, fenced code blocks
|
||||
- **Themed code blocks** — monospaced, with configurable code font and size
|
||||
- **Fully customizable themes** — built-in theme editor, unlimited user themes, custom CSS
|
||||
- **Table of contents** — toggleable, resizable, depth picker, depth markers, jump-to-heading
|
||||
- **Markdown source view** — side-by-side raw source, find across both panes, heading scroll-sync, collapse mirroring
|
||||
- **Collapsible headings** — click a heading to collapse its section, ⌥-click to anchor to it
|
||||
- **Live file watching** — re-renders on every save, scroll position preserved
|
||||
- **Vim navigation keys** — `j`/`k`, `Ctrl+d`/`u`, `gg`/`G`, `]]`/`[[`
|
||||
- **Find in document** — with match count and vim-style `/`, `n`, `N`
|
||||
- **YAML frontmatter** — hide, show as a metadata table, or render as Markdown
|
||||
- **Open in external editor** — configure any editor or app, one click from the toolbar
|
||||
- **Automatic updates** — stays current via Sparkle
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j` / `k` | Scroll down / up |
|
||||
| `Ctrl+d` / `Ctrl+u` | Half page down / up |
|
||||
| `gg` / `G` | Top / bottom of document |
|
||||
| `]]` / `[[` | Next / previous heading |
|
||||
| `⌘F` | Find |
|
||||
| `/` | Focus find bar (vim-style) |
|
||||
| `n` / `N` | Next / previous match |
|
||||
| `⌘⇧↓` / `⌘⇧↑` | Next / previous theme |
|
||||
|
||||
## Building from Source
|
||||
|
||||
Requires Xcode 15+, [XcodeGen](https://github.com/yonaskolb/XcodeGen), and [just](https://github.com/casey/just).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/darinkelkhoff/markdownViewr.git
|
||||
cd markdownViewr
|
||||
just run
|
||||
```
|
||||
|
||||
`just --list` shows all available recipes.
|
||||
+25
-6
@@ -6,12 +6,31 @@
|
||||
<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>
|
||||
<title>1.5.0</title>
|
||||
<pubDate>Thu, 23 Jul 2026 08:33:55 -0500</pubDate>
|
||||
<sparkle:version>10500</sparkle:version>
|
||||
<sparkle:shortVersionString>1.5.0</sparkle:shortVersionString>
|
||||
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.5.0.html</sparkle:releaseNotesLink>
|
||||
<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=="/>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.5.0/markdownViewr-1.5.0.zip" length="4426474" type="application/octet-stream" sparkle:edSignature="sIjjKAMzb8Fv9zjr0vkfbpxeriD+lmkdB/L+wcs/Ay0mcvYu+WIr4UkIpeYBLsLevOl2aYfM3+KOhHdTjAnXDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.4.0</title>
|
||||
<pubDate>Fri, 26 Jun 2026 09:00:05 -0500</pubDate>
|
||||
<sparkle:version>10400</sparkle:version>
|
||||
<sparkle:shortVersionString>1.4.0</sparkle:shortVersionString>
|
||||
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.4.0.html</sparkle:releaseNotesLink>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.4.0/markdownViewr-1.4.0.zip" length="4385892" type="application/octet-stream" sparkle:edSignature="7OtkBh/Qd6/BB5MYr38LVGJA3C24+hzFfd4OOe88gim2LEKShRoMkz8EXuuCCIhcVbDIDrtcU6XzckshCKKJBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.3.0</title>
|
||||
<pubDate>Fri, 19 Jun 2026 14:47:14 -0500</pubDate>
|
||||
<sparkle:version>10300</sparkle:version>
|
||||
<sparkle:shortVersionString>1.3.0</sparkle:shortVersionString>
|
||||
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.3.0.html</sparkle:releaseNotesLink>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.3.0/markdownViewr-1.3.0.zip" length="4256339" type="application/octet-stream" sparkle:edSignature="TNbtuUw4oI/YHBwiHqtfy4CFTu/pay+R/fGjF/usHw9DWVfKh+tRvgu8UyHVe0SbR7KFX6PGg1aVzeONMicqDw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
Whitespace-only changes.
Binary file not shown.
|
After Width: | Height: | Size: 1010 KiB |
+173
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 22%;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.screenshot-wrap {
|
||||
margin-top: 48px;
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.screenshot-wrap img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.15rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #0071e3;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 14px 28px;
|
||||
border-radius: 980px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.download-btn:hover { background: #0077ed; }
|
||||
|
||||
.download-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.req {
|
||||
margin-top: 12px;
|
||||
font-size: 0.82rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 40px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.feature {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.88rem;
|
||||
color: #444;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 56px;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
footer a { color: #aaa; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1c1c1e; color: #f5f5f7; }
|
||||
.tagline { color: #aaa; }
|
||||
.feature { background: #2c2c2e; color: #ccc; box-shadow: none; }
|
||||
.req { color: #666; }
|
||||
footer, footer a { color: #555; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<img class="icon" src="icon.png" alt="markdownViewr icon">
|
||||
|
||||
<h1>markdownViewr</h1>
|
||||
|
||||
<p class="tagline">
|
||||
A fast, beautiful markdown viewer for macOS.<br>
|
||||
Open any markdown file. Live-reloads as you edit.
|
||||
</p>
|
||||
|
||||
<a class="download-btn"
|
||||
href="https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg">
|
||||
<svg viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v9.586l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V3a1 1 0 011-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"/></svg>
|
||||
Download for Mac
|
||||
</a>
|
||||
|
||||
<p class="req">macOS 13 Ventura or later · Free · <a href="https://github.com/darinkelkhoff/markdownViewr" style="color:inherit">View on GitHub</a></p>
|
||||
|
||||
<p class="req" style="margin-top:8px">Also available via Homebrew: <code style="font-size:0.82rem">brew install --cask darinkelkhoff/tap/markdownviewr</code></p>
|
||||
|
||||
<div class="features">
|
||||
<span class="feature">GitHub Flavored Markdown</span>
|
||||
<span class="feature">Themed code blocks</span>
|
||||
<span class="feature">Fully customizable themes</span>
|
||||
<span class="feature">Table of contents</span>
|
||||
<span class="feature">Markdown source view</span>
|
||||
<span class="feature">Live file watching</span>
|
||||
<span class="feature">Vim navigation keys</span>
|
||||
<span class="feature">Find in document</span>
|
||||
<span class="feature">YAML frontmatter support</span>
|
||||
<span class="feature">Open in external editor</span>
|
||||
<span class="feature">Automatic updates</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screenshot-wrap">
|
||||
<img src="screenshot.png" alt="markdownViewr showing a markdown document">
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="privacy.html">Privacy Policy</a> ·
|
||||
<a href="https://github.com/darinkelkhoff/markdownViewr">GitHub</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr — Privacy Policy</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 22%;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.15);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.effective {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p { margin-bottom: 12px; }
|
||||
|
||||
.lead {
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
ul { margin: 0 0 12px 22px; }
|
||||
li { margin-bottom: 6px; }
|
||||
|
||||
a { color: #0071e3; }
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 720px;
|
||||
margin: 56px auto 0;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
footer a { color: #aaa; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1c1c1e; color: #f5f5f7; }
|
||||
.lead { color: #ccc; }
|
||||
.effective { color: #777; }
|
||||
a { color: #4ea1ff; }
|
||||
footer, footer a { color: #555; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img class="icon" src="icon.png" alt="markdownViewr icon">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="effective">markdownViewr · Effective June 12, 2026</p>
|
||||
|
||||
<p class="lead">
|
||||
markdownViewr does not collect, store, transmit, or share any personal
|
||||
information. There are no accounts, no analytics, and no tracking of any kind.
|
||||
</p>
|
||||
|
||||
<h2>Information we collect</h2>
|
||||
<p>
|
||||
None. markdownViewr collects no personal data. It does not contain analytics,
|
||||
advertising, tracking technologies, crash reporters, or any mechanism that
|
||||
gathers information about you or your device.
|
||||
</p>
|
||||
|
||||
<h2>Your files</h2>
|
||||
<p>
|
||||
markdownViewr is a view-only document app. It reads only the files you
|
||||
explicitly open, and processes them entirely on your Mac to render them for
|
||||
display. Your documents are never uploaded, copied to any server, or sent to
|
||||
the developer or anyone else. Any settings and themes you create are stored
|
||||
locally on your Mac.
|
||||
</p>
|
||||
|
||||
<h2>Network activity</h2>
|
||||
<p>
|
||||
The app itself sends no information about you anywhere. The only network
|
||||
requests it makes are ones you initiate through your own content or actions:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Remote images.</strong> If a document you open references an image
|
||||
by a remote (<code>http</code>/<code>https</code>) URL, the app loads that
|
||||
image so it can be displayed. That request goes directly to the server
|
||||
hosting the image, which — like any web request — can see your IP address.
|
||||
markdownViewr adds no identifying information to these requests.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Update checks (non‑App‑Store version only).</strong> The
|
||||
version downloaded directly (DMG or Homebrew) periodically checks for new
|
||||
releases by fetching a public update feed hosted on GitHub. This request
|
||||
contains no personal data. The Mac App Store version does not do this —
|
||||
it is updated through the App Store.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Third parties</h2>
|
||||
<p>
|
||||
markdownViewr shares no data with third parties, because it collects none.
|
||||
The remote servers and update host described above receive only the ordinary
|
||||
network request needed to fulfill your action, never personal information from
|
||||
the app.
|
||||
</p>
|
||||
|
||||
<h2>Children's privacy</h2>
|
||||
<p>
|
||||
markdownViewr does not collect personal information from anyone, including
|
||||
children under 13.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>
|
||||
If this policy changes, the updated version will be posted on this page with a
|
||||
new effective date.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about this policy can be sent to
|
||||
<a href="mailto:darin.kelkhoff@gmail.com">darin.kelkhoff@gmail.com</a>.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="./">← markdownViewr</a> ·
|
||||
<a href="https://github.com/darinkelkhoff/markdownViewr">GitHub</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr 1.3.0 Release Notes</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1d1d1f;
|
||||
background: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.release-notes {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0 0 24px;
|
||||
color: #6e6e73;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: #f5f5f7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
.date { color: #a1a1a6; }
|
||||
code { background: #2c2c2e; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="release-notes">
|
||||
<h1>markdownViewr 1.3.0</h1>
|
||||
<p class="date">Released June 19, 2026</p>
|
||||
|
||||
<h2>New: Markdown source view</h2>
|
||||
<ul>
|
||||
<li>Added a side-by-side view of raw Markdown source, with scrolling synchronized with the rendered view.</li>
|
||||
<li>Find works across the rendered document and source pane.</li>
|
||||
<li>Heading collapse state is mirrored between the rendered document and source pane.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Table of Contents updates</h2>
|
||||
<ul>
|
||||
<li>Made the table of contents pane resizable.</li>
|
||||
<li>Made TOC width, visibility, and heading depth persistent.</li>
|
||||
<li>Added optional TOC depth markers (bullets) and smoother active-heading tracking while scrolling long documents.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Customization and rendering polish</h2>
|
||||
<ul>
|
||||
<li>Improved scrollbar appearance by styling them to match the active theme.</li>
|
||||
<li>Expanded the CSS help with custom CSS examples, document structure notes, and scrollbar styling selectors.</li>
|
||||
<li>Fixed user-created theme overrides so they reliably take precedence over built-in themes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr 1.4.0 Release Notes</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1d1d1f;
|
||||
background: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.release-notes {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0 0 24px;
|
||||
color: #6e6e73;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: #f5f5f7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
.date { color: #a1a1a6; }
|
||||
code { background: #2c2c2e; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="release-notes">
|
||||
<h1>markdownViewr 1.4.0</h1>
|
||||
<p class="date">Released June 26, 2026</p>
|
||||
|
||||
<h2>Native macOS toolbar customization</h2>
|
||||
<ul>
|
||||
<li>Added native macOS toolbar customization for document windows.</li>
|
||||
</ul>
|
||||
|
||||
<h2>View settings now apply per window</h2>
|
||||
<ul>
|
||||
<li>TOC, TOC depth, Markdown Source, zoom, and active theme are now per-window settings.</li>
|
||||
<li>Added Settings options to define default settings for new windows.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Menu and shortcut improvements</h2>
|
||||
<ul>
|
||||
<li>Cleaned up the File menu by removing New, Save, and Revert, and adding Open in External Editor.</li>
|
||||
<li>Added View menu commands for TOC, Markdown Source, and TOC depth.</li>
|
||||
<li>Added <code>Cmd-T</code> to toggle TOC, <code>Cmd-U</code> to toggle Markdown Source, and <code>Cmd-E</code> to open in the external editor.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Window and settings polish</h2>
|
||||
<ul>
|
||||
<li>Improved default window sizing and side pane widths.</li>
|
||||
<li>Added per-file window size restoration.</li>
|
||||
<li>Added the ability to reorder external editors.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr 1.5.0 Release Notes</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1d1d1f;
|
||||
background: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.release-notes {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0 0 24px;
|
||||
color: #6e6e73;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: #f5f5f7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
.date { color: #a1a1a6; }
|
||||
code { background: #2c2c2e; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="release-notes">
|
||||
<h1>markdownViewr 1.5.0</h1>
|
||||
<p class="date">Released July 23, 2026</p>
|
||||
|
||||
<h2>Native printing</h2>
|
||||
<ul>
|
||||
<li>Added native macOS printing for rendered Markdown documents.</li>
|
||||
<li>Added a Printing tab in Settings for print theme, images, backgrounds, borders, page padding, zoom, and content width.</li>
|
||||
<li>Added a built-in Clean Printing theme for predictable print output.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Toolbar improvements</h2>
|
||||
<ul>
|
||||
<li>Added an optional Print toolbar item that can be added from Customize Toolbar.</li>
|
||||
<li>Replaced the custom small-space toolbar item with AppKit's native repeatable space item.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Window and navigation polish</h2>
|
||||
<ul>
|
||||
<li>Updated table-of-contents highlighting to follow the mouse cursor instead of just the top-of-window scroll position.</li>
|
||||
<li>Improved tabbed document window behavior by avoiding frame autosave on tab groups.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
@@ -0,0 +1,732 @@
|
||||
# Markdown Extensions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add four toggleable inline markdown extensions (highlight, superscript, subscript, underline) with a Markdown settings tab, a theme-aware highlight color, and a post-processing HTML pass.
|
||||
|
||||
**Architecture:** A new `MarkdownExtensions` struct holds four booleans stored in UserDefaults via `ThemeManager`. `MarkdownDocument.convertToHTML` runs a post-processing pass (skipping `<pre>` blocks) after the swift-markdown AST walk. `ThemeColors` gains `highlightBackground`/`highlightText` fields (with a custom decoder for backward compatibility) that feed CSS variables used by `mark {}` in the HTML template.
|
||||
|
||||
**Tech Stack:** SwiftUI, AppKit (NSWindow), swift-markdown 0.7.3, NSRegularExpression, UserDefaults, XcodeGen (sources glob — new Swift files auto-included, no regeneration needed).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: MarkdownExtensions struct + ThemeColors highlight fields
|
||||
|
||||
**Files:**
|
||||
- Create: `markdownViewr/MarkdownExtensions.swift`
|
||||
- Modify: `markdownViewr/Theme.swift`
|
||||
|
||||
- [ ] **Step 1: Create `markdownViewr/MarkdownExtensions.swift`**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
struct MarkdownExtensions: Codable {
|
||||
var highlight: Bool = true
|
||||
var superscript: Bool = true
|
||||
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
|
||||
var underline: Bool = true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `highlightBackground` and `highlightText` to `ThemeColors` in `Theme.swift`**
|
||||
|
||||
Add the two new properties after `blockquoteBackground`:
|
||||
|
||||
```swift
|
||||
var blockquoteBackground: String
|
||||
var highlightBackground: String
|
||||
var highlightText: String
|
||||
```
|
||||
|
||||
Update the `init(...)` signature and body to include them after `blockquoteBackground`:
|
||||
|
||||
```swift
|
||||
init(
|
||||
background: String = "#1e1e2e",
|
||||
text: String = "#cdd6f4",
|
||||
heading1: String = "#cba6f7",
|
||||
heading2: String = "#89b4fa",
|
||||
heading3: String = "#89b4fa",
|
||||
heading4: String = "#74c7ec",
|
||||
heading5: String = "#74c7ec",
|
||||
heading6: String = "#74c7ec",
|
||||
link: String = "#89dceb",
|
||||
codeBackground: String = "#181825",
|
||||
codeText: String = "#a6e3a1",
|
||||
blockquoteBorder: String = "#cba6f7",
|
||||
blockquoteBackground: String = "#252535",
|
||||
highlightBackground: String = "#ffd700",
|
||||
highlightText: String = "#1a1a1a"
|
||||
) {
|
||||
self.background = background
|
||||
self.text = text
|
||||
self.heading1 = heading1
|
||||
self.heading2 = heading2
|
||||
self.heading3 = heading3
|
||||
self.heading4 = heading4
|
||||
self.heading5 = heading5
|
||||
self.heading6 = heading6
|
||||
self.link = link
|
||||
self.codeBackground = codeBackground
|
||||
self.codeText = codeText
|
||||
self.blockquoteBorder = blockquoteBorder
|
||||
self.blockquoteBackground = blockquoteBackground
|
||||
self.highlightBackground = highlightBackground
|
||||
self.highlightText = highlightText
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add explicit `CodingKeys` and custom `init(from:)` to `ThemeColors` for backward compatibility**
|
||||
|
||||
Existing user theme JSONs don't have these fields. Swift's auto-synthesized decoder throws on missing keys, so add the following inside `struct ThemeColors`:
|
||||
|
||||
```swift
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case background, text
|
||||
case heading1, heading2, heading3, heading4, heading5, heading6
|
||||
case link, codeBackground, codeText
|
||||
case blockquoteBorder, blockquoteBackground
|
||||
case highlightBackground, highlightText
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
background = try c.decode(String.self, forKey: .background)
|
||||
text = try c.decode(String.self, forKey: .text)
|
||||
heading1 = try c.decode(String.self, forKey: .heading1)
|
||||
heading2 = try c.decode(String.self, forKey: .heading2)
|
||||
heading3 = try c.decode(String.self, forKey: .heading3)
|
||||
heading4 = try c.decode(String.self, forKey: .heading4)
|
||||
heading5 = try c.decode(String.self, forKey: .heading5)
|
||||
heading6 = try c.decode(String.self, forKey: .heading6)
|
||||
link = try c.decode(String.self, forKey: .link)
|
||||
codeBackground = try c.decode(String.self, forKey: .codeBackground)
|
||||
codeText = try c.decode(String.self, forKey: .codeText)
|
||||
blockquoteBorder = try c.decode(String.self, forKey: .blockquoteBorder)
|
||||
blockquoteBackground = try c.decode(String.self, forKey: .blockquoteBackground)
|
||||
highlightBackground = try c.decodeIfPresent(String.self, forKey: .highlightBackground) ?? "#ffd700"
|
||||
highlightText = try c.decodeIfPresent(String.self, forKey: .highlightText) ?? "#1a1a1a"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownExtensions.swift markdownViewr/Theme.swift
|
||||
git commit -m "add MarkdownExtensions struct and highlight color fields to ThemeColors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ThemeManager — extensions property and CSS variables
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/ThemeManager.swift`
|
||||
|
||||
- [ ] **Step 1: Add `markdownExtensions` as the last `@Published` property in `ThemeManager`**
|
||||
|
||||
Add after the `frontmatterMode` property block (around line 21–26):
|
||||
|
||||
```swift
|
||||
@Published var markdownExtensions: MarkdownExtensions {
|
||||
didSet {
|
||||
if let data = try? JSONEncoder().encode(markdownExtensions) {
|
||||
UserDefaults.standard.set(data, forKey: "markdownExtensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Initialize `markdownExtensions` in `ThemeManager.init()`**
|
||||
|
||||
In the `init()` body, after the `frontmatterMode` initialization, add:
|
||||
|
||||
```swift
|
||||
if let data = UserDefaults.standard.data(forKey: "markdownExtensions"),
|
||||
let ext = try? JSONDecoder().decode(MarkdownExtensions.self, from: data) {
|
||||
self.markdownExtensions = ext
|
||||
} else {
|
||||
self.markdownExtensions = MarkdownExtensions()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `--highlight-bg` and `--highlight-text` to `generateCSS`**
|
||||
|
||||
In `generateCSS(for:)`, inside the `:root {` block, add these two lines after `--blockquote-bg`:
|
||||
|
||||
```swift
|
||||
--highlight-bg: \(theme.colors.highlightBackground);
|
||||
--highlight-text: \(theme.colors.highlightText);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: HTML template — `mark` CSS rule
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html`
|
||||
|
||||
- [ ] **Step 1: Add `mark` rule to `base-css` in `template.html`**
|
||||
|
||||
After the `del { opacity: 0.6; }` rule (around line 223–225), add:
|
||||
|
||||
```css
|
||||
mark {
|
||||
background-color: var(--highlight-bg);
|
||||
color: var(--highlight-text);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
```
|
||||
|
||||
Note: find marks created by the in-page find feature use inline styles that override this rule, so there is no conflict.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update built-in theme JSON files
|
||||
|
||||
**Files:**
|
||||
- Modify: all 20 files in `markdownViewr/Resources/themes/`
|
||||
|
||||
Each file needs `"highlightBackground"` and `"highlightText"` added inside the `"colors"` object, after `"blockquoteBackground"`.
|
||||
|
||||
- [ ] **Step 1: Update `catppuccin-mocha.json`** (dark)
|
||||
|
||||
Add after `"blockquoteBackground"`:
|
||||
```json
|
||||
"highlightBackground": "#f9e2af",
|
||||
"highlightText": "#1e1e2e"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `catppuccin-latte.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#f9e2af",
|
||||
"highlightText": "#4c4f69"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `dracula.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#f1fa8c",
|
||||
"highlightText": "#282a36"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `everforest-dark.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#dbbc7f",
|
||||
"highlightText": "#2d353b"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `github-dark.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#e3b341",
|
||||
"highlightText": "#0d1117"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `github-light.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#fff8c5",
|
||||
"highlightText": "#1f2328"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update `gruvbox-dark.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#fabd2f",
|
||||
"highlightText": "#282828"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update `gruvbox-light.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#fabd2f",
|
||||
"highlightText": "#282828"
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Update `kanagawa.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#ff9e3b",
|
||||
"highlightText": "#1f1f28"
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Update `monokai.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#e6db74",
|
||||
"highlightText": "#272822"
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Update `nord.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#ebcb8b",
|
||||
"highlightText": "#2e3440"
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Update `one-dark.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#e5c07b",
|
||||
"highlightText": "#282c34"
|
||||
```
|
||||
|
||||
- [ ] **Step 13: Update `one-light.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#ffd700",
|
||||
"highlightText": "#383a42"
|
||||
```
|
||||
|
||||
- [ ] **Step 14: Update `rose-pine.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#f6c177",
|
||||
"highlightText": "#191724"
|
||||
```
|
||||
|
||||
- [ ] **Step 15: Update `rose-pine-dawn.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#f6c177",
|
||||
"highlightText": "#575279"
|
||||
```
|
||||
|
||||
- [ ] **Step 16: Update `solarized-dark.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#b58900",
|
||||
"highlightText": "#002b36"
|
||||
```
|
||||
|
||||
- [ ] **Step 17: Update `solarized-light.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#b58900",
|
||||
"highlightText": "#fdf6e3"
|
||||
```
|
||||
|
||||
- [ ] **Step 18: Update `tokyo-night.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#e0af68",
|
||||
"highlightText": "#1a1b26"
|
||||
```
|
||||
|
||||
- [ ] **Step 19: Update `tokyo-night-light.json`** (light)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#f9c859",
|
||||
"highlightText": "#343b58"
|
||||
```
|
||||
|
||||
- [ ] **Step 20: Update `zenburn.json`** (dark)
|
||||
|
||||
```json
|
||||
"highlightBackground": "#f0dfaf",
|
||||
"highlightText": "#3f3f3f"
|
||||
```
|
||||
|
||||
- [ ] **Step 21: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 22: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/ThemeManager.swift markdownViewr/Resources/template.html markdownViewr/Resources/themes/
|
||||
git commit -m "add highlight CSS variable, mark rule, and highlight colors to all built-in themes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: MarkdownDocument — post-processing pass
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/MarkdownDocument.swift`
|
||||
|
||||
- [ ] **Step 1: Update `convertToHTML` signature to accept `extensions`**
|
||||
|
||||
Change the function signature from:
|
||||
|
||||
```swift
|
||||
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide) -> String {
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```swift
|
||||
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide, extensions: MarkdownExtensions = MarkdownExtensions()) -> String {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Call `applyExtensions` as the last step in `convertToHTML`**
|
||||
|
||||
At the end of `convertToHTML`, change the final two lines from:
|
||||
|
||||
```swift
|
||||
var htmlVisitor = HTMLConverter()
|
||||
html += htmlVisitor.visit(document)
|
||||
return html
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```swift
|
||||
var htmlVisitor = HTMLConverter()
|
||||
html += htmlVisitor.visit(document)
|
||||
return applyExtensions(html, extensions: extensions)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the three private static helpers at the bottom of `MarkdownDocument`** (before the closing brace, after `escapeHTML`)
|
||||
|
||||
```swift
|
||||
private static func applyExtensions(_ html: String, extensions: MarkdownExtensions) -> String {
|
||||
// Split on <pre>...</pre> blocks so code blocks are never transformed.
|
||||
var segments = html.components(separatedBy: "<pre>")
|
||||
for i in 0..<segments.count {
|
||||
if i == 0 {
|
||||
segments[i] = applyExtensionRegexes(segments[i], extensions: extensions)
|
||||
} else {
|
||||
// Each segment begins with the pre block content up to </pre>,
|
||||
// followed by regular content.
|
||||
if let endRange = segments[i].range(of: "</pre>") {
|
||||
let preContent = String(segments[i][..<endRange.upperBound])
|
||||
let afterPre = applyExtensionRegexes(
|
||||
String(segments[i][endRange.upperBound...]),
|
||||
extensions: extensions
|
||||
)
|
||||
segments[i] = preContent + afterPre
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments.joined(separator: "<pre>")
|
||||
}
|
||||
|
||||
private static func applyExtensionRegexes(_ text: String, extensions: MarkdownExtensions) -> String {
|
||||
var result = text
|
||||
if extensions.highlight {
|
||||
result = applyRegex(result, pattern: "==(.+?)==", template: "<mark>$1</mark>")
|
||||
}
|
||||
if extensions.superscript {
|
||||
result = applyRegex(result, pattern: "\\^(.+?)\\^", template: "<sup>$1</sup>")
|
||||
}
|
||||
if extensions.subscript_ {
|
||||
result = applyRegex(result, pattern: "~(.+?)~", template: "<sub>$1</sub>")
|
||||
}
|
||||
if extensions.underline {
|
||||
result = applyRegex(result, pattern: "\\+\\+(.+?)\\+\\+", template: "<ins>$1</ins>")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func applyRegex(_ text: String, pattern: String, template: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return text }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: template)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: ContentView — wire extensions into rerender
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/ContentView.swift`
|
||||
|
||||
- [ ] **Step 1: Update `rerender()` to pass `extensions`**
|
||||
|
||||
Change:
|
||||
|
||||
```swift
|
||||
private func rerender() {
|
||||
renderedHTML = MarkdownDocument.convertToHTML(currentMarkdown, frontmatterMode: themeManager.frontmatterMode)
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```swift
|
||||
private func rerender() {
|
||||
renderedHTML = MarkdownDocument.convertToHTML(
|
||||
currentMarkdown,
|
||||
frontmatterMode: themeManager.frontmatterMode,
|
||||
extensions: themeManager.markdownExtensions
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `onReceive` for `markdownExtensions`**
|
||||
|
||||
After the existing `.onReceive(themeManager.$frontmatterMode)` block, add:
|
||||
|
||||
```swift
|
||||
.onReceive(themeManager.$markdownExtensions) { _ in
|
||||
DispatchQueue.main.async { rerender() }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownDocument.swift markdownViewr/ContentView.swift
|
||||
git commit -m "add post-processing pass for markdown extensions (highlight, superscript, subscript, underline)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Settings UI — Markdown tab
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/SettingsView.swift`
|
||||
|
||||
- [ ] **Step 1: Add `MarkdownSettingsView` struct to `SettingsView.swift`**
|
||||
|
||||
Add this new struct before the `ThemeEditorItem` struct (before line 265):
|
||||
|
||||
```swift
|
||||
struct MarkdownSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Markdown Extensions")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Enable or disable inline syntax extensions applied during rendering.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox {
|
||||
VStack(spacing: 0) {
|
||||
extensionRow(
|
||||
name: "Highlight",
|
||||
example: "==text==",
|
||||
isOn: Binding(
|
||||
get: { themeManager.markdownExtensions.highlight },
|
||||
set: { themeManager.markdownExtensions.highlight = $0 }
|
||||
)
|
||||
)
|
||||
Divider().padding(.vertical, 6)
|
||||
extensionRow(
|
||||
name: "Superscript",
|
||||
example: "^text^",
|
||||
isOn: Binding(
|
||||
get: { themeManager.markdownExtensions.superscript },
|
||||
set: { themeManager.markdownExtensions.superscript = $0 }
|
||||
)
|
||||
)
|
||||
Divider().padding(.vertical, 6)
|
||||
extensionRow(
|
||||
name: "Subscript",
|
||||
example: "~text~",
|
||||
isOn: Binding(
|
||||
get: { themeManager.markdownExtensions.subscript_ },
|
||||
set: { themeManager.markdownExtensions.subscript_ = $0 }
|
||||
)
|
||||
)
|
||||
Divider().padding(.vertical, 6)
|
||||
extensionRow(
|
||||
name: "Underline",
|
||||
example: "++text++",
|
||||
isOn: Binding(
|
||||
get: { themeManager.markdownExtensions.underline },
|
||||
set: { themeManager.markdownExtensions.underline = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
.frame(width: 100, alignment: .leading)
|
||||
Text(example)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Toggle("", isOn: isOn)
|
||||
.toggleStyle(.checkbox)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Markdown tab to `SettingsView`**
|
||||
|
||||
In `SettingsView.body`, add the new tab after the Themes tab:
|
||||
|
||||
```swift
|
||||
MarkdownSettingsView()
|
||||
.tabItem {
|
||||
Label("Markdown", systemImage: "doc.plaintext")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Theme Editor — highlight color pickers
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/ThemeEditorView.swift`
|
||||
- Modify: `markdownViewr/SampleMarkdown.swift`
|
||||
|
||||
- [ ] **Step 1: Add highlight color rows to `colorsSection` in `ThemeEditorView.swift`**
|
||||
|
||||
In the `LazyVGrid` inside `colorsSection`, after `colorRow("Quote BG", $colors.blockquoteBackground)`, add:
|
||||
|
||||
```swift
|
||||
colorRow("Highlight BG", $colors.highlightBackground)
|
||||
colorRow("Highlight Text", $colors.highlightText)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add highlight/extension examples to `sampleMarkdown` in `SampleMarkdown.swift`**
|
||||
|
||||
Find the sample markdown string and add a new section so the Theme Editor preview shows the highlight color in action. Add this block to the sample (a sensible place is near the inline formatting examples):
|
||||
|
||||
```swift
|
||||
## Extensions
|
||||
|
||||
This sentence uses ==highlighted text==, H~2~O for subscript, E=mc^2^ for superscript, and ++underlined text++.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
```bash
|
||||
just build
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/SettingsView.swift markdownViewr/ThemeEditorView.swift markdownViewr/SampleMarkdown.swift
|
||||
git commit -m "add Markdown settings tab and highlight color pickers to theme editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Manual test
|
||||
|
||||
- [ ] **Step 1: Launch the app**
|
||||
|
||||
```bash
|
||||
just run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open a test file with all four extension types**
|
||||
|
||||
Create `/tmp/extensions-test.md` with the following content:
|
||||
|
||||
```markdown
|
||||
# Extension Test
|
||||
|
||||
Regular text with ==highlighted phrase== inline.
|
||||
|
||||
E=mc^2^ uses superscript.
|
||||
|
||||
H~2~O uses subscript.
|
||||
|
||||
This is ++underlined text++ using plus markers.
|
||||
|
||||
Code blocks must NOT be transformed:
|
||||
|
||||
==not highlighted==
|
||||
^not superscript^
|
||||
~not subscript~
|
||||
++not underlined++
|
||||
|
||||
Fenced code blocks must also be skipped:
|
||||
|
||||
```text
|
||||
==still not highlighted==
|
||||
```
|
||||
|
||||
Open this file in the app.
|
||||
|
||||
- [ ] **Step 3: Verify each extension renders correctly**
|
||||
|
||||
- `==highlighted phrase==` → yellow/amber background highlight (matching the active theme's highlight color)
|
||||
- `mc^2^` → `2` as superscript
|
||||
- `H~2~O` → `2` as subscript
|
||||
- `++underlined text++` → underline styling
|
||||
- Content inside code blocks: no transformation, literal syntax visible
|
||||
|
||||
- [ ] **Step 4: Verify toggles work**
|
||||
|
||||
Open Settings → Markdown tab. Toggle Highlight off. Verify the highlighted phrase no longer has a background and shows `==highlighted phrase==` literally. Toggle it back on and verify it re-renders immediately.
|
||||
|
||||
- [ ] **Step 5: Verify highlight color in Theme Editor**
|
||||
|
||||
Open Settings → Themes → select a theme → Edit. Verify "Highlight BG" and "Highlight Text" color pickers appear in the Colors section. Change the highlight color and verify the preview updates immediately.
|
||||
|
||||
- [ ] **Step 6: Verify theme switching**
|
||||
|
||||
Switch between a dark theme and a light theme in the toolbar. Verify the highlight background color changes appropriately with the theme.
|
||||
@@ -0,0 +1,916 @@
|
||||
# Mac App Store Dual-Distribution 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 a sandboxed Mac App Store build variant alongside the existing Developer ID build, from one codebase, with all sandbox-specific behavior isolated behind a `MAS_BUILD` compile flag.
|
||||
|
||||
**Architecture:** A second pair of XcodeGen build configurations (`Debug-MAS`, `Release-MAS`) flips on the sandbox via an entitlements file and defines `MAS_BUILD`. Sparkle is compiled out of the MAS binary. A `FolderAccessManager` acquires a persisted security-scoped bookmark to the document's folder (powering both file-watching and image reads), and local images are inlined as `data:` URLs so the sandboxed WKWebView never needs broad file access. The Developer ID build is unchanged at runtime.
|
||||
|
||||
**Tech Stack:** Swift, SwiftUI, AppKit (NSOpenPanel, security-scoped bookmarks), WKWebView, XcodeGen, xcodebuild.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `project.yml` | Add `configs` map; per-config sandbox/entitlements/flags; move Sparkle Info.plist keys to non-MAS configs | Modify |
|
||||
| `markdownViewr/markdownViewr-MAS.entitlements` | Sandbox entitlements for the MAS build | Create |
|
||||
| `markdownViewr/MarkdownViewrApp.swift` | Guard Sparkle behind `#if !MAS_BUILD` | Modify |
|
||||
| `markdownViewr/HelpView.swift` | Don't advertise Sparkle updates in MAS build | Modify |
|
||||
| `markdownViewr/ImageInliner.swift` | Pure helper: rewrite `<img>` local `src` to `data:` URLs | Create |
|
||||
| `markdownViewrTests/ImageInlinerTests.swift` | Unit tests for the inliner | Create |
|
||||
| `markdownViewr/FolderAccessManager.swift` | Acquire/persist/hold security-scoped folder access; resolve image bytes | Create |
|
||||
| `markdownViewr/ContentView.swift` | Gate watching on access; show grant banner; inline images on render | Modify |
|
||||
| `markdownViewr/MarkdownWebView.swift` | MAS: scope read-access to temp dir, no base tag | Modify |
|
||||
| `ExportOptions-MAS.plist` | App Store export options | Create |
|
||||
| `justfile` | `release-mas` recipe | Modify |
|
||||
|
||||
**Test runner (per user convention):** All tests run via `/tmp/markdownViewr-claude-run-tests.sh` (created in Task 3), which writes output to `/tmp/markdownViewr-claude-test-output.txt`. Builds are run inline with `xcodebuild`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: MAS build configurations + entitlements
|
||||
|
||||
**Files:**
|
||||
- Modify: `project.yml`
|
||||
- Create: `markdownViewr/markdownViewr-MAS.entitlements`
|
||||
|
||||
- [ ] **Step 1: Create the MAS entitlements file**
|
||||
|
||||
Create `markdownViewr/markdownViewr-MAS.entitlements`:
|
||||
|
||||
```xml
|
||||
<?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>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `configs` map to `project.yml`**
|
||||
|
||||
At the top level of `project.yml` (e.g. directly after the `options:` block), add:
|
||||
|
||||
```yaml
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
Debug-MAS: debug
|
||||
Release-MAS: release
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the always-non-sandboxed base setting and the Sparkle Info.plist keys**
|
||||
|
||||
In `project.yml`, in the `markdownViewr` target's `settings.base`, DELETE this line (sandbox is now controlled per-config via the entitlements file):
|
||||
|
||||
```yaml
|
||||
ENABLE_APP_SANDBOX: "NO"
|
||||
```
|
||||
|
||||
In the `markdownViewr` target's `info.properties`, DELETE these two lines (they will be re-injected for non-MAS configs only in the next step):
|
||||
|
||||
```yaml
|
||||
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add per-config settings to the `markdownViewr` target**
|
||||
|
||||
In `project.yml`, under the `markdownViewr` target's `settings:` (which currently only has `base:`), add a `configs:` sibling so it reads:
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: markdownViewr/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
|
||||
MARKETING_VERSION: "1.2.0"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
configs:
|
||||
Debug:
|
||||
INFOPLIST_KEY_SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
INFOPLIST_KEY_SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
Release:
|
||||
INFOPLIST_KEY_SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
INFOPLIST_KEY_SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
Debug-MAS:
|
||||
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MAS_BUILD"
|
||||
Release-MAS:
|
||||
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MAS_BUILD"
|
||||
```
|
||||
|
||||
Note: `ENABLE_APP_SANDBOX` is intentionally NOT set — the entitlements file is the single source of truth for sandboxing, present only in the MAS configs.
|
||||
|
||||
- [ ] **Step 5: Regenerate and verify the configs exist**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `xcodebuild -project markdownViewr.xcodeproj -list`
|
||||
Expected: the `Build Configurations:` list includes `Debug`, `Release`, `Debug-MAS`, and `Release-MAS`.
|
||||
|
||||
- [ ] **Step 6: Verify the Developer ID Info.plist still has Sparkle keys, MAS does not**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release -showBuildSettings 2>/dev/null | grep -i 'INFOPLIST_KEY_SUFeedURL'`
|
||||
Expected: shows the appcast URL.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release-MAS -showBuildSettings 2>/dev/null | grep -ci 'INFOPLIST_KEY_SUFeedURL'`
|
||||
Expected: `0`
|
||||
|
||||
(If `INFOPLIST_KEY_`-prefixed custom keys do not appear in the built `Info.plist` on this Xcode version, fall back to two static Info.plist files: a shared `Info.plist` without Sparkle keys, plus `Info-DevID.plist` with them, and set `INFOPLIST_FILE` per-config. Verify by building and running `plutil -p` on the built app's `Contents/Info.plist`.)
|
||||
|
||||
- [ ] **Step 7: Build both variants to confirm the project is valid**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED` (Sparkle still compiles here — it is removed in Task 2).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add project.yml markdownViewr/markdownViewr-MAS.entitlements
|
||||
git commit -m "build: add Mac App Store build configurations and entitlements"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Compile Sparkle out of the MAS build
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/MarkdownViewrApp.swift:1-31`
|
||||
- Modify: `markdownViewr/HelpView.swift:54`
|
||||
|
||||
- [ ] **Step 1: Guard the Sparkle import**
|
||||
|
||||
In `markdownViewr/MarkdownViewrApp.swift`, replace lines 1-2:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
#if !MAS_BUILD
|
||||
import Sparkle
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Guard the updater controller property**
|
||||
|
||||
In `markdownViewr/MarkdownViewrApp.swift`, wrap the `updaterController` property (currently lines 6-10):
|
||||
|
||||
```swift
|
||||
#if !MAS_BUILD
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Guard the "Check for Updates" command**
|
||||
|
||||
In `markdownViewr/MarkdownViewrApp.swift`, wrap the entire `CommandGroup(after: .appInfo)` block (currently lines 26-31). `#if` is valid inside SwiftUI's `commands` result builder, so in the MAS build the menu item is never added to the menu:
|
||||
|
||||
```swift
|
||||
#if !MAS_BUILD
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
.disabled(!updaterController.updater.canCheckForUpdates)
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Don't advertise Sparkle in the MAS build's Help**
|
||||
|
||||
Read `markdownViewr/HelpView.swift` around line 54 to see the exact surrounding string-array/list context. The line currently reads:
|
||||
|
||||
```swift
|
||||
- Automatic updates via Sparkle
|
||||
```
|
||||
|
||||
Replace it so the bullet is conditional. If it is part of a multi-line Swift string literal, split the literal so only the non-MAS build includes the line, e.g.:
|
||||
|
||||
```swift
|
||||
#if !MAS_BUILD
|
||||
- Automatic updates via Sparkle
|
||||
#endif
|
||||
```
|
||||
|
||||
If `#if` cannot be placed inside that specific literal/view construct, instead remove the Sparkle bullet from the shared text and append it only in the non-MAS build via the surrounding view/string-builder. The requirement: the MAS build's Help must not mention Sparkle or automatic updates.
|
||||
|
||||
- [ ] **Step 5: Build the MAS variant and confirm Sparkle is compiled out**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED` with no reference to `SPUStandardUpdaterController` (the symbol is inside `#if !MAS_BUILD`).
|
||||
|
||||
- [ ] **Step 6: Build the Developer ID variant and confirm it is unchanged**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`; the "Check for Updates…" command and Sparkle updater still compile.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownViewrApp.swift markdownViewr/HelpView.swift
|
||||
git commit -m "build: compile Sparkle out of the Mac App Store build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Image-inlining helper (pure, TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `markdownViewr/ImageInliner.swift`
|
||||
- Test: `markdownViewrTests/ImageInlinerTests.swift`
|
||||
- Create: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
|
||||
- [ ] **Step 1: Create the test-runner script**
|
||||
|
||||
Write `/tmp/markdownViewr-claude-run-tests.sh` (the ONLY way tests are run; always invoked with zero arguments):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -o pipefail
|
||||
cd /Users/dkelkhoff/git/personal/markdownViewr
|
||||
xcodebuild test \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewrTests \
|
||||
-destination 'platform=macOS' \
|
||||
> /tmp/markdownViewr-claude-test-output.txt 2>&1
|
||||
echo "exit=$?" >> /tmp/markdownViewr-claude-test-output.txt
|
||||
```
|
||||
|
||||
Then: `chmod +x /tmp/markdownViewr-claude-run-tests.sh`
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Create `markdownViewrTests/ImageInlinerTests.swift`:
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class ImageInlinerTests: XCTestCase {
|
||||
func testInlinesRelativeImageAsDataURL() {
|
||||
let html = "<p><img src=\"img/a.png\" alt=\"x\"></p>"
|
||||
let bytes = Data([0x01, 0x02, 0x03])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "img/a.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
XCTAssertFalse(out.contains("img/a.png"))
|
||||
XCTAssertTrue(out.contains("alt=\"x\""))
|
||||
}
|
||||
|
||||
func testLeavesRemoteImageUntouched() {
|
||||
let html = "<img src=\"https://example.com/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesAbsolutePathUntouched() {
|
||||
let html = "<img src=\"/Users/x/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesUnresolvableRelativeImageUntouched() {
|
||||
let html = "<img src=\"missing.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in nil }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testDecodesPercentEncodedPathBeforeResolving() {
|
||||
let html = "<img src=\"my%20pic.png\">"
|
||||
let bytes = Data([0xAA])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "my pic.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the new files to the project and run the tests to verify they fail**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
Then read `/tmp/markdownViewr-claude-test-output.txt`.
|
||||
Expected: compilation failure — `ImageInliner` is not defined yet.
|
||||
|
||||
- [ ] **Step 4: Implement the helper**
|
||||
|
||||
Create `markdownViewr/ImageInliner.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
/// Rewrites `<img>` tags whose `src` is a local, relative path into self-contained
|
||||
/// `data:` URLs, using a caller-supplied byte resolver. Remote (`http(s)`), `data:`,
|
||||
/// and absolute-path sources are left untouched. Pure and synchronous so it is unit
|
||||
/// testable without any filesystem or sandbox dependency.
|
||||
enum ImageInliner {
|
||||
static func inlineLocalImages(in html: String, resolve: (String) -> Data?) -> String {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: "<img([^>]*?)src=\"([^\"]*)\"([^>]*)>",
|
||||
options: []
|
||||
) else { return html }
|
||||
|
||||
let ns = html as NSString
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
|
||||
guard !matches.isEmpty else { return html }
|
||||
|
||||
var result = ""
|
||||
var last = 0
|
||||
for match in matches {
|
||||
let full = match.range
|
||||
result += ns.substring(with: NSRange(location: last, length: full.location - last))
|
||||
|
||||
let pre = ns.substring(with: match.range(at: 1))
|
||||
let src = ns.substring(with: match.range(at: 2))
|
||||
let post = ns.substring(with: match.range(at: 3))
|
||||
|
||||
let decoded = src.removingPercentEncoding ?? src
|
||||
if isLocalRelative(src), let data = resolve(decoded) {
|
||||
let mime = mimeType(forExtension: (src as NSString).pathExtension)
|
||||
result += "<img\(pre)src=\"data:\(mime);base64,\(data.base64EncodedString())\"\(post)>"
|
||||
} else {
|
||||
result += ns.substring(with: full)
|
||||
}
|
||||
last = full.location + full.length
|
||||
}
|
||||
result += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
||||
return result
|
||||
}
|
||||
|
||||
static func isLocalRelative(_ src: String) -> Bool {
|
||||
if src.isEmpty { return false }
|
||||
if src.hasPrefix("data:") { return false }
|
||||
if src.contains("://") { return false } // http://, https://, file://
|
||||
if src.hasPrefix("/") { return false } // absolute path — unreadable under sandbox
|
||||
return true
|
||||
}
|
||||
|
||||
static func mimeType(forExtension ext: String) -> String {
|
||||
switch ext.lowercased() {
|
||||
case "png": return "image/png"
|
||||
case "jpg", "jpeg": return "image/jpeg"
|
||||
case "gif": return "image/gif"
|
||||
case "svg": return "image/svg+xml"
|
||||
case "webp": return "image/webp"
|
||||
case "bmp": return "image/bmp"
|
||||
case "tif", "tiff": return "image/tiff"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the file to the project and run the tests to verify they pass**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
Then read `/tmp/markdownViewr-claude-test-output.txt`.
|
||||
Expected: `exit=0`, all `ImageInlinerTests` pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/ImageInliner.swift markdownViewrTests/ImageInlinerTests.swift
|
||||
git commit -m "feat: add ImageInliner for sandbox-safe data-URL image embedding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: FolderAccessManager (security-scoped folder access)
|
||||
|
||||
**Files:**
|
||||
- Create: `markdownViewr/FolderAccessManager.swift`
|
||||
|
||||
This type exists in BOTH builds with a uniform interface. In the non-MAS build it is a no-op that always reports access, so the `ContentView` body needs no `#if`. All sandbox/bookmark logic lives behind `#if MAS_BUILD` inside this file.
|
||||
|
||||
- [ ] **Step 1: Create the manager**
|
||||
|
||||
Create `markdownViewr/FolderAccessManager.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Owns access to the directory containing the currently-open document. In the MAS
|
||||
/// (sandboxed) build it resolves or requests a persisted app-scoped security-scoped
|
||||
/// bookmark for that folder and holds access for the manager's lifetime. In the
|
||||
/// Developer ID build it is an inert pass-through that always reports access.
|
||||
final class FolderAccessManager: ObservableObject {
|
||||
/// True when the document's folder is accessible (always true in the non-MAS build).
|
||||
@Published private(set) var hasAccess: Bool = false
|
||||
|
||||
private var folderURL: URL?
|
||||
private var accessedURL: URL?
|
||||
|
||||
deinit {
|
||||
accessedURL?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
/// Resolves any stored access for the document's folder. Call once when a document opens.
|
||||
func prepare(for fileURL: URL) {
|
||||
let folder = fileURL.deletingLastPathComponent()
|
||||
folderURL = folder
|
||||
#if MAS_BUILD
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.key(for: folder)) else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
var stale = false
|
||||
guard let url = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
), url.startAccessingSecurityScopedResource() else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
accessedURL = url
|
||||
hasAccess = true
|
||||
if stale { storeBookmark(for: url) }
|
||||
#else
|
||||
hasAccess = true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Presents a folder picker (MAS only) to obtain and persist access. Calls back on the main queue.
|
||||
func requestAccess(completion: @escaping (Bool) -> Void) {
|
||||
#if MAS_BUILD
|
||||
guard let folder = folderURL else { completion(false); return }
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = folder
|
||||
panel.prompt = "Allow Access"
|
||||
panel.message = "Allow markdownViewr to access this folder to show images and auto-reload changes."
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url,
|
||||
url.startAccessingSecurityScopedResource() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.accessedURL?.stopAccessingSecurityScopedResource()
|
||||
self.accessedURL = url
|
||||
self.storeBookmark(for: url)
|
||||
self.hasAccess = true
|
||||
completion(true)
|
||||
}
|
||||
#else
|
||||
completion(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reads bytes for a path relative to the document's folder, or nil if unavailable.
|
||||
/// Used by the MAS render path to inline images. Returns nil when access is not held.
|
||||
func imageData(forRelativePath path: String) -> Data? {
|
||||
guard hasAccess, let base = accessedURL ?? folderURL else { return nil }
|
||||
let url = base.appendingPathComponent(path)
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
|
||||
#if MAS_BUILD
|
||||
private func storeBookmark(for url: URL) {
|
||||
guard let data = try? url.bookmarkData(
|
||||
options: [.withSecurityScope],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
) else { return }
|
||||
// Key by the document's folder so the same document re-grants silently next launch.
|
||||
if let folder = folderURL {
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: folder))
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: url))
|
||||
}
|
||||
|
||||
private static func key(for folder: URL) -> String {
|
||||
"folderBookmark:\(folder.standardizedFileURL.path)"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the file and build both variants**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED` (non-MAS: `hasAccess` defaults set in `prepare`, no bookmark code compiled).
|
||||
|
||||
Then: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/FolderAccessManager.swift
|
||||
git commit -m "feat: add FolderAccessManager for sandboxed folder access"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire folder access + grant banner into ContentView
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/ContentView.swift:20-99`
|
||||
|
||||
- [ ] **Step 1: Add the access manager and a render helper**
|
||||
|
||||
In `markdownViewr/ContentView.swift`, add a `@StateObject` for the manager alongside the existing `@StateObject` declarations (after line 30):
|
||||
|
||||
```swift
|
||||
@StateObject private var folderAccess = FolderAccessManager()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make `rerender()` inline images in the MAS build**
|
||||
|
||||
Replace the existing `rerender()` (lines 39-45) with:
|
||||
|
||||
```swift
|
||||
private func rerender() {
|
||||
var html = MarkdownDocument.convertToHTML(
|
||||
currentMarkdown,
|
||||
frontmatterMode: themeManager.frontmatterMode,
|
||||
extensions: themeManager.markdownExtensions
|
||||
)
|
||||
#if MAS_BUILD
|
||||
if folderAccess.hasAccess {
|
||||
html = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
folderAccess.imageData(forRelativePath: path)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
renderedHTML = html
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add a helper to start watching once access is available**
|
||||
|
||||
Add this method to `ContentView` (next to `rerender()`):
|
||||
|
||||
```swift
|
||||
private func beginLiveContent() {
|
||||
guard let fileURL else { return }
|
||||
rerender()
|
||||
liveContent.startWatching(fileURL: fileURL)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `onAppear` to gate watching on access**
|
||||
|
||||
Replace the existing `.onAppear { ... }` block (lines 82-88) with:
|
||||
|
||||
```swift
|
||||
.onAppear {
|
||||
liveContent.rawMarkdown = document.rawMarkdown
|
||||
rerender()
|
||||
if let fileURL {
|
||||
folderAccess.prepare(for: fileURL)
|
||||
if folderAccess.hasAccess {
|
||||
beginLiveContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the Developer ID build `folderAccess.hasAccess` is always true after `prepare`, so this preserves today's behavior (watch immediately).
|
||||
|
||||
- [ ] **Step 5: Add the grant banner**
|
||||
|
||||
Wrap the `MarkdownWebView` in the `body`'s `VStack` so a banner can sit above it. Replace the `VStack(spacing: 0) { ... }` opening through the `MarkdownWebView(...)` call (lines 48-60) with:
|
||||
|
||||
```swift
|
||||
VStack(spacing: 0) {
|
||||
if findBar.isVisible {
|
||||
FindBarView(findBar: findBar)
|
||||
}
|
||||
if !folderAccess.hasAccess, fileURL != nil {
|
||||
FolderAccessBanner {
|
||||
folderAccess.requestAccess { granted in
|
||||
if granted { beginLiveContent() }
|
||||
}
|
||||
}
|
||||
}
|
||||
MarkdownWebView(
|
||||
html: renderedHTML,
|
||||
themeCSS: themeManager.generateCSS(for: themeManager.activeTheme),
|
||||
fileURL: fileURL,
|
||||
findBar: findBar,
|
||||
tocVisible: tocVisible,
|
||||
tocDepth: tocDepth
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Because `folderAccess.hasAccess` is always true in the non-MAS build, the banner never renders there — no `#if` needed in the view body.
|
||||
|
||||
- [ ] **Step 6: Define the banner view**
|
||||
|
||||
At the bottom of `markdownViewr/ContentView.swift` (file scope, after the `ContentView` struct), add:
|
||||
|
||||
```swift
|
||||
private struct FolderAccessBanner: View {
|
||||
let onGrant: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
Text("Allow access to this file's folder to show images and auto-reload on changes.")
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Button("Allow Access…", action: onGrant)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.yellow.opacity(0.18))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build both variants**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
- [ ] **Step 8: Run the unit tests to confirm no regression**
|
||||
|
||||
Run: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
Then read `/tmp/markdownViewr-claude-test-output.txt`.
|
||||
Expected: `exit=0`, all tests pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/ContentView.swift
|
||||
git commit -m "feat: gate file-watching on folder access and add grant banner (MAS)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Scope WKWebView read access for the sandbox
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/MarkdownWebView.swift:65-98`
|
||||
|
||||
In the MAS build the preview HTML is self-contained (images already inlined by `rerender`), so the WebView only needs read access to the container temp dir, and the `<base href>` to the user's folder (unreadable under sandbox) must be omitted.
|
||||
|
||||
- [ ] **Step 1: Make the base tag and read-access scope build-specific**
|
||||
|
||||
In `markdownViewr/MarkdownWebView.swift`, in `loadContent`, replace the base-tag assignment (lines 75-79):
|
||||
|
||||
```swift
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
|
||||
} else {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
#if MAS_BUILD
|
||||
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
|
||||
// both unreadable and unnecessary.
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
#else
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
|
||||
} else {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Scope the read-access URL to the temp dir in the MAS build**
|
||||
|
||||
In `markdownViewr/MarkdownWebView.swift`, replace the temp-file write + load block (lines 83-93):
|
||||
|
||||
```swift
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
let tempDir = Self.previewDirectory
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
|
||||
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
|
||||
// Grant read access to "/" so both the temp file and the document's images are accessible
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||
context.coordinator.tempFileURL = tempHTML
|
||||
} else {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
if fileURL != nil {
|
||||
let tempDir = Self.previewDirectory
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
|
||||
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
|
||||
#if MAS_BUILD
|
||||
// Self-contained HTML (images inlined); only the container temp dir is needed.
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: tempDir)
|
||||
#else
|
||||
// Grant read access to "/" so both the temp file and the document's images are accessible
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||
#endif
|
||||
context.coordinator.tempFileURL = tempHTML
|
||||
} else {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build both variants**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
- [ ] **Step 4: Manual sandbox runtime check**
|
||||
|
||||
Launch the Debug-MAS build:
|
||||
```bash
|
||||
open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" --args -NSDocumentRevisionsDebugMode YES
|
||||
```
|
||||
Then open a markdown file that has a relative local image (e.g. ``).
|
||||
Expected:
|
||||
1. Text renders immediately; the yellow banner appears.
|
||||
2. The image is broken/absent before granting.
|
||||
3. Clicking "Allow Access…" → confirming the folder → the image appears and editing the file on disk live-reloads the view.
|
||||
4. Closing and re-opening a file in the same folder does NOT show the banner again.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownWebView.swift
|
||||
git commit -m "feat: scope WKWebView read access to temp dir in MAS build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: App Store export options + release recipe
|
||||
|
||||
**Files:**
|
||||
- Create: `ExportOptions-MAS.plist`
|
||||
- Modify: `justfile`
|
||||
|
||||
- [ ] **Step 1: Create the App Store export options**
|
||||
|
||||
Create `ExportOptions-MAS.plist`:
|
||||
|
||||
```xml
|
||||
<?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>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>9MG4YT2G93</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `release-mas` recipe**
|
||||
|
||||
Append to `justfile`:
|
||||
|
||||
```just
|
||||
# Archive and export a signed App Store .pkg (then upload via Transporter or altool)
|
||||
release-mas: kill
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "==> Regenerating Xcode project..."
|
||||
xcodegen generate
|
||||
ARCHIVE="/tmp/markdownViewr-mas.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-mas-export"
|
||||
rm -rf "$ARCHIVE" "$EXPORT"
|
||||
echo "==> Archiving v$VERSION (build $BUILD_NUMBER) for the App Store..."
|
||||
xcodebuild archive \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewr \
|
||||
-configuration Release-MAS \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-destination 'generic/platform=macOS' \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
|
||||
echo "==> Exporting App Store package..."
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportPath "$EXPORT" \
|
||||
-exportOptionsPlist ExportOptions-MAS.plist
|
||||
PKG=$(find "$EXPORT" -name '*.pkg' | head -1)
|
||||
echo ""
|
||||
echo "Done! Signed App Store package:"
|
||||
echo " $PKG"
|
||||
echo ""
|
||||
echo "Upload with Transporter.app, or:"
|
||||
echo " xcrun altool --upload-app -f \"$PKG\" -t macos \\"
|
||||
echo " --apiKey <KEY_ID> --apiIssuer <ISSUER_ID>"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the recipe is listed**
|
||||
|
||||
Run: `just --list`
|
||||
Expected: `release-mas` appears with its description.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add ExportOptions-MAS.plist justfile
|
||||
git commit -m "build: add App Store export options and release-mas recipe"
|
||||
```
|
||||
|
||||
Note: actually running `just release-mas` requires an Apple Distribution certificate and provisioning profile that may not exist yet (see Task 8 / out-of-scope account setup). The recipe is verified by listing and by code review here; a full archive+export run happens once the App Store Connect account is set up.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: External-editor sandbox verification
|
||||
|
||||
**Files:**
|
||||
- Possibly modify: `markdownViewr/EditorConfig.swift:60-74`
|
||||
|
||||
This task is a verify-then-decide item, per the design. Do NOT preemptively add bookmarks.
|
||||
|
||||
- [ ] **Step 1: Test launching a configured editor under the sandbox**
|
||||
|
||||
Launch the Debug-MAS build, configure an external editor in Settings (pick an installed app, e.g. an editor in `/Applications`), open a markdown file, and click the editor toolbar button (`ContentView.swift:231` → `EditorManager.openFile`).
|
||||
Expected (to determine): does `NSWorkspace.open(_:withApplicationAt:configuration:)` successfully launch the editor with the document, under sandbox, using the stored path string?
|
||||
|
||||
- [ ] **Step 2: Branch on the result**
|
||||
|
||||
- If it works: no code change. Note the verified behavior in `CLAUDE.md`'s sandbox notes and finish.
|
||||
- If it fails (no access to the stored editor path across launches): store a security-scoped bookmark for the editor app URL when the user selects it (in the editor-picker flow in `SettingsView`/`EditorConfig`), resolve and `startAccessingSecurityScopedResource()` around the launch in `EditorManager.openFile`, keyed like `FolderAccessManager`. Add `com.apple.security.files.user-selected.read-only` is already present; confirm it suffices for launching.
|
||||
|
||||
- [ ] **Step 3: Commit (only if code changed)**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/EditorConfig.swift markdownViewr/SettingsView.swift
|
||||
git commit -m "fix: bookmark external-editor app access under sandbox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Document the new distribution path
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Add a "Distribution" section to CLAUDE.md**
|
||||
|
||||
Add a section documenting: the two build configs (`Release`/`Release-MAS`), what `MAS_BUILD` gates (Sparkle out, sandbox on, folder-access banner, image inlining), the entitlements file, `just release` vs `just release-mas`, and the manual App Store Connect steps that remain (account/cert/profile, app record, screenshots, privacy label, submission). Keep it concise and factual, matching the existing CLAUDE.md style.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: document Mac App Store dual-distribution build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (manual, cannot be automated from the repo)
|
||||
|
||||
- Apple Developer account: register App ID `com.dkelkhoff.markdownViewr`, create **Apple Distribution** + **Mac Installer Distribution** certificates and a provisioning profile.
|
||||
- App Store Connect: create the app record, upload screenshots, write description/keywords, complete the **App Privacy** label (the app collects nothing), answer export-compliance (no non-exempt crypto).
|
||||
- Submit the build and respond to App Review.
|
||||
|
||||
These should be tracked as a manual checklist outside this plan.
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# Themed Scrollbars 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:** Give the main document, TOC, and raw-source pane matching, visible theme-aware scrollbars.
|
||||
|
||||
**Architecture:** Add shared scrollbar CSS to the bundled HTML template. Verify the contract by reading the real bundled template in an XCTest.
|
||||
|
||||
**Tech Stack:** Swift/XCTest, HTML/CSS, WebKit
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared themed scrollbar styling
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html`
|
||||
- Test: `markdownViewrTests/TemplateScrollbarTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add an XCTest that loads `template.html` and asserts shared `::-webkit-scrollbar-track`, `::-webkit-scrollbar-thumb`, and hover selectors use `--bg` and `--text`.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run `xcodebuild test -scheme markdownViewr -destination 'platform=macOS' -only-testing:markdownViewrTests/TemplateScrollbarTests` and expect missing-selector assertion failures.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add shared WebKit scrollbar track and thumb rules near the base CSS in `template.html`, deriving colors from the existing theme variables.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run the focused test, the complete test suite, and visually inspect Nord with a long document.
|
||||
|
||||
### Task 2: Give the rendered pane its own scroll container
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html:18-47,220-225,420-426,876-1112`
|
||||
- Test: `markdownViewrTests/TemplateScrollbarTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `TemplateScrollbarTests` to require `body { height: 100vh; overflow: hidden; }`, `#content { overflow-y: auto; }`, a `_contentScroller()` helper, and content-owned scroll listeners and navigation.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run `xcodebuild test -scheme markdownViewr -destination 'platform=macOS' -only-testing:markdownViewrTests/TemplateScrollbarTests` and expect failures for the missing content-scroller contract.
|
||||
|
||||
- [ ] **Step 3: Implement the pane scroll owner**
|
||||
|
||||
Bound the body and all three panes to the viewport. Make `#content` flexible and vertically scrollable. Add `_contentScroller()` and replace root-document reads, `window.scrollTo`/`scrollBy`, and `window` scroll listeners with equivalent operations on that element.
|
||||
|
||||
- [ ] **Step 4: Verify behavior**
|
||||
|
||||
Run the focused test and full `xcodebuild test -scheme markdownViewr -destination 'platform=macOS'`. Open a long document with TOC and source visible; verify each pane has a scrollbar at its own edge, scroll sync works in both directions, TOC highlighting follows the rendered pane, and Vim/find navigation still scrolls the rendered pane.
|
||||
|
||||
### Task 3: Keep the active TOC heading visible
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html:1000-1025`
|
||||
- Test: `markdownViewrTests/TemplateScrollbarTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add `testActiveTOCLinkIsKeptVisible`, requiring the active link to call `scrollIntoView` with `block: 'nearest'`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run `xcodebuild test -scheme markdownViewr -destination 'platform=macOS' -only-testing:markdownViewrTests/TemplateScrollbarTests/testActiveTOCLinkIsKeptVisible` and expect the missing nearest-scroll assertion to fail.
|
||||
|
||||
- [ ] **Step 3: Implement nearest TOC scrolling**
|
||||
|
||||
After assigning the active class in `highlightActiveTOC`, call `activeLink.scrollIntoView({ block: 'nearest' })` so the TOC moves only when needed.
|
||||
|
||||
- [ ] **Step 4: Verify behavior**
|
||||
|
||||
Run the focused and full suites, then scroll a long rendered document far enough that the active TOC entry would otherwise leave the TOC viewport and confirm it remains visible.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Markdown Extensions Design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Markdown tab to the Settings window that lets users toggle four inline
|
||||
syntax extensions. Enabled extensions are applied via a post-processing pass
|
||||
on the generated HTML.
|
||||
|
||||
## Extensions
|
||||
|
||||
| Syntax | Name | HTML output | Default |
|
||||
|---|---|---|---|
|
||||
| `==text==` | Highlight | `<mark>text</mark>` | on |
|
||||
| `^text^` | Superscript | `<sup>text</sup>` | on |
|
||||
| `~text~` | Subscript | `<sub>text</sub>` | on |
|
||||
| `++text++` | Underline | `<ins>text</ins>` | on |
|
||||
|
||||
Strikethrough (`~~text~~`) is already handled at the parser level (swift-markdown
|
||||
GFM extension) and is not part of this feature.
|
||||
|
||||
## Data Model
|
||||
|
||||
New `MarkdownExtensions` struct in its own file (`MarkdownExtensions.swift`):
|
||||
|
||||
```swift
|
||||
struct MarkdownExtensions: Codable {
|
||||
var highlight: Bool = true
|
||||
var superscript: Bool = true
|
||||
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
|
||||
var underline: Bool = true
|
||||
}
|
||||
```
|
||||
|
||||
`ThemeManager` gets one new `@Published var markdownExtensions: MarkdownExtensions`
|
||||
property. On `didSet` it encodes to JSON and writes to UserDefaults key
|
||||
`markdownExtensions`. On init it reads and decodes from UserDefaults, falling
|
||||
back to `MarkdownExtensions()` (all on) if the key is absent or malformed.
|
||||
|
||||
## Post-Processing
|
||||
|
||||
`MarkdownDocument` gains a private static `applyExtensions(_:extensions:) -> String`
|
||||
helper. It is called as the final step of `convertToHTML`, after `HTMLConverter`
|
||||
produces its output.
|
||||
|
||||
The helper splits the HTML on `<pre>` / `</pre>` boundaries. Segments inside
|
||||
`<pre>` blocks are passed through unchanged. Segments outside have each enabled
|
||||
extension's regex applied using `NSRegularExpression`.
|
||||
|
||||
Patterns (applied in the order listed):
|
||||
|
||||
| Extension | Regex | Replacement |
|
||||
|---|---|---|
|
||||
| Highlight | `==(.+?)==` | `<mark>$1</mark>` |
|
||||
| Superscript | `\^(.+?)\^` | `<sup>$1</sup>` |
|
||||
| Subscript | `~(.+?)~` | `<sub>$1</sub>` |
|
||||
| Underline | `\+\+(.+?)\+\+` | `<ins>$1</ins>` |
|
||||
|
||||
`convertToHTML` signature gains one new parameter:
|
||||
|
||||
```swift
|
||||
static func convertToHTML(
|
||||
_ markdown: String,
|
||||
frontmatterMode: FrontmatterMode = .hide,
|
||||
extensions: MarkdownExtensions = MarkdownExtensions()
|
||||
) -> String
|
||||
```
|
||||
|
||||
All existing callers that omit `extensions` get the all-on defaults.
|
||||
|
||||
## Settings UI
|
||||
|
||||
A fourth tab is added to the `TabView` in `SettingsView`:
|
||||
|
||||
- Label: "Markdown", system image: `doc.plaintext`
|
||||
- New struct `MarkdownSettingsView` in `SettingsView.swift`
|
||||
- One `GroupBox` containing a `VStack` of four rows
|
||||
- Each row: extension name (left), example syntax in secondary monospaced text
|
||||
(center), `Toggle` with `.checkbox` style (right)
|
||||
- Toggling writes through `themeManager.markdownExtensions` immediately
|
||||
|
||||
## Re-render Trigger
|
||||
|
||||
`ContentView` already observes `themeManager` via `@EnvironmentObject` and
|
||||
re-renders on published changes. Adding `onReceive(themeManager.$markdownExtensions)`
|
||||
(same pattern as `frontmatterMode`) triggers a re-render when any toggle changes.
|
||||
No restart required.
|
||||
|
||||
## Theme Color: Highlight
|
||||
|
||||
`<mark>` has a browser-default yellow background that looks wrong on dark themes.
|
||||
`ThemeColors` gets two new fields:
|
||||
|
||||
```swift
|
||||
var highlightBackground: String // default: "#ffd700" (gold — readable on dark)
|
||||
var highlightText: String // default: "#1e1e2e" (dark — pairs with gold bg)
|
||||
```
|
||||
|
||||
`ThemeManager.generateCSS` emits `--highlight-bg` and `--highlight-text` CSS
|
||||
variables. The HTML template adds:
|
||||
|
||||
```css
|
||||
mark {
|
||||
background-color: var(--highlight-bg);
|
||||
color: var(--highlight-text);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
```
|
||||
|
||||
All 20 built-in theme JSON files get the two new color fields with
|
||||
theme-appropriate values. The Theme Editor color list gains a "Highlight"
|
||||
section (background + text), matching the existing "Code" section layout.
|
||||
|
||||
User themes that predate this change omit these fields in their JSON. Swift's
|
||||
auto-synthesized `Decodable` throws on missing keys, so `ThemeColors` needs a
|
||||
custom `init(from decoder:)` that uses `decodeIfPresent` for the two new fields
|
||||
and falls back to the `ThemeColors.init` defaults when absent.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `MarkdownExtensions.swift` | New — struct definition |
|
||||
| `Theme.swift` | Add `highlightBackground`, `highlightText` to `ThemeColors` |
|
||||
| `ThemeManager.swift` | Add `markdownExtensions` published property; emit `--highlight-bg`/`--highlight-text` in `generateCSS` |
|
||||
| `MarkdownDocument.swift` | Add `applyExtensions` helper, update `convertToHTML` signature |
|
||||
| `SettingsView.swift` | Add `MarkdownSettingsView`, add Markdown tab |
|
||||
| `ContentView.swift` | Add `onReceive` for `markdownExtensions` |
|
||||
| `ThemeEditorView.swift` | Add Highlight color pickers |
|
||||
| `Resources/template.html` | Add `mark { … }` CSS rule |
|
||||
| `Resources/themes/*.json` (×20) | Add `highlightBackground` + `highlightText` per theme |
|
||||
@@ -0,0 +1,113 @@
|
||||
# Mac App Store Dual-Distribution — Design
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Branch:** `mas-dual-distribution`
|
||||
**Status:** Approved (design); implementation pending
|
||||
|
||||
## Goal
|
||||
|
||||
Ship markdownViewr through **two** distribution channels from one codebase:
|
||||
|
||||
1. **Developer ID** (existing) — notarized `.app` in a DMG, Sparkle auto-update, Homebrew cask. Unchanged by this work.
|
||||
2. **Mac App Store (MAS)** (new) — sandboxed `.pkg`, updates delivered by the App Store, no Sparkle.
|
||||
|
||||
The two builds differ only where the sandbox forces them to. All sandbox-specific code is isolated behind a `MAS_BUILD` compile-time flag so the Developer ID build remains byte-for-byte what it is today.
|
||||
|
||||
## Constraints discovered in the code
|
||||
|
||||
Three sandbox blockers exist in the current code:
|
||||
|
||||
| # | Blocker | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | Sparkle third-party updater (forbidden on MAS) | `MarkdownViewrApp.swift:2,6`; `Info.plist` `SUFeedURL`/`SUPublicEDKey` |
|
||||
| 2 | File watching opens the doc fd directly and re-reads it | `FileWatcher.swift:20`; `ContentView.swift:10` |
|
||||
| 3 | `loadFileURL(..., allowingReadAccessTo: "/")` + relative images live in the doc's folder, which the sandbox does not grant access to | `MarkdownWebView.swift:89` |
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Build structure
|
||||
|
||||
- Keep the single `markdownViewr` application target.
|
||||
- Add build configurations **`Release-MAS`** and **`Debug-MAS`** in `project.yml` (via the `configs` map and per-config `settings`).
|
||||
- `Release-MAS` / `Debug-MAS` set:
|
||||
- `ENABLE_APP_SANDBOX: YES`
|
||||
- `CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements`
|
||||
- `SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD` (appended to existing conditions for that config)
|
||||
- The existing `Release`/`Debug` configs are untouched. Developer ID path is unchanged.
|
||||
- Bundle ID stays `com.dkelkhoff.markdownViewr` for both — Developer ID and MAS builds may share it.
|
||||
|
||||
### 2. Sparkle (Blocker 1)
|
||||
|
||||
- Wrap the `import Sparkle`, the `updaterController` property, and the "Check for Updates…" command in `#if !MAS_BUILD` in `MarkdownViewrApp.swift`.
|
||||
- Sparkle remains a Swift Package dependency (still linked), but no Sparkle runtime calls compile into the MAS binary.
|
||||
- Move the `SUFeedURL` and `SUPublicEDKey` keys out of the shared `Info.plist` so they are absent from the MAS bundle. (Mechanism: a non-MAS-only Info.plist injection via `project.yml` `info.properties` scoped to the non-MAS configs, or a separate plist for MAS. Exact mechanism resolved during implementation; requirement is: MAS bundle must not contain Sparkle feed keys.)
|
||||
- Update `HelpView.swift:54` ("Automatic updates via Sparkle") to not advertise Sparkle in the MAS build.
|
||||
|
||||
### 3. Folder access subsystem (Blockers 2 + 3) — all behind `#if MAS_BUILD`
|
||||
|
||||
New type `FolderAccessManager`:
|
||||
|
||||
- **Input:** a document URL.
|
||||
- **Lookup:** finds a persisted **app-scoped security-scoped bookmark** for the document's parent folder, keyed by folder path (stored in `UserDefaults`).
|
||||
- **Resolve + hold:** resolves the bookmark and holds `startAccessingSecurityScopedResource()` for the document window's lifetime; releases on close.
|
||||
|
||||
First-time-folder flow (no stored bookmark):
|
||||
|
||||
1. Document renders immediately — the markdown text is already available from DocumentGroup, so **text is never gated** on folder access.
|
||||
2. A dismissible banner appears in the document window: *"Allow access to this file's folder to show images and auto-reload on changes."*
|
||||
3. Button presents an `NSOpenPanel` pre-pointed at the document's folder. User confirms.
|
||||
4. App creates and persists an app-scoped security-scoped bookmark for the folder, then re-renders (images now resolve) and starts file-watching.
|
||||
5. That folder never prompts again on subsequent opens.
|
||||
|
||||
This single grant powers both remaining blockers:
|
||||
|
||||
- **File watching (Blocker 2):** `open(path, O_EVTONLY)` and re-reading the doc succeed while folder access is held. `FileWatcher`/`LiveContent` start only after access is acquired in the MAS build; in the non-MAS build they start immediately as today.
|
||||
- **Image reads (Blocker 3):** see below.
|
||||
|
||||
### 4. Image handling (Blocker 3)
|
||||
|
||||
The "load file vs inline" choice collapses under sandbox: `loadFileURL(_:allowingReadAccessTo:)` grants exactly **one** directory subtree, but the preview HTML lives in the app container temp dir while images live in the user's doc folder — two distinct trees. Writing the preview HTML into the user's folder would litter it and require a read-write grant.
|
||||
|
||||
Therefore:
|
||||
|
||||
- **MAS build:** at render time, resolve relative local image references, read the image bytes **through the held folder bookmark**, and inline them as `data:` URLs in the HTML. The preview HTML becomes self-contained, stays in the container temp dir, and `allowingReadAccessTo` only needs the temp dir. Remote (`http`/`https`) images are left as-is and load via the network-client entitlement.
|
||||
- **Non-MAS build:** unchanged — temp file + `allowingReadAccessTo: "/"`, no inlining, `<base href>` resolves relative images.
|
||||
|
||||
Implementation isolates the divergence in `MarkdownWebView.loadContent` (and a render-time image-inlining helper) behind `#if MAS_BUILD`.
|
||||
|
||||
### 5. Entitlements — `markdownViewr/markdownViewr-MAS.entitlements`
|
||||
|
||||
- `com.apple.security.app-sandbox` = true
|
||||
- `com.apple.security.files.user-selected.read-only` = true
|
||||
- `com.apple.security.files.bookmarks.app-scope` = true
|
||||
- `com.apple.security.network.client` = true (remote images)
|
||||
|
||||
### 6. External editors — verify during implementation
|
||||
|
||||
`EditorManager.openFile` (`EditorConfig.swift:73`) launches a user-configured editor via `NSWorkspace.open(_:withApplicationAt:configuration:)` using a stored **path string**. Under sandbox the stored path may not be accessible across launches without its own security-scoped bookmark.
|
||||
|
||||
Plan: treat as a scoped sub-task. During implementation, test whether Launch Services still permits launching an installed editor from a bare path under sandbox. If not, store a per-editor security-scoped bookmark when the user picks the editor and resolve it at launch time. Do not solve preemptively; confirm the actual sandbox behavior first.
|
||||
|
||||
### 7. Release pipeline
|
||||
|
||||
- New `ExportOptions-MAS.plist` with `method: app-store`.
|
||||
- New `just release-mas` recipe: archive with the `Release-MAS` config + Apple Distribution signing, export a `.pkg`, upload via `notarytool`/Transporter.
|
||||
- Existing `just release` (Developer ID → DMG → notarize → GitHub release → appcast → Homebrew) is untouched.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Manual / App Store Connect portal steps that cannot be done from the repo:
|
||||
|
||||
- Apple Developer account / App ID registration, Apple Distribution + Mac Installer Distribution certificates, provisioning profile.
|
||||
- App Store Connect app record, screenshots, description/keywords, App Privacy nutrition label, export-compliance answers.
|
||||
- The actual submission and App Review.
|
||||
|
||||
These will be documented as a checklist but are not implemented here.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Builds:** both `Release` and `Release-MAS` compile and link; MAS binary contains no Sparkle feed keys and no Sparkle runtime symbols are exercised.
|
||||
- **Sandbox runtime (MAS Debug):** open a doc with relative local images → text renders immediately, banner appears, granting folder access shows images and enables live-reload; second open of a doc in the same folder does not prompt.
|
||||
- **Non-MAS regression:** Developer ID build behavior (images, watching, Sparkle update check) is unchanged.
|
||||
- **External editors:** launch a configured editor from the sandboxed build; confirm it works or that the bookmark fallback works.
|
||||
- Existing unit tests (`markdownViewrTests`) continue to pass under both configs.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Themed Scrollbars — Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make the main document scrollbar as visible and theme-consistent as the table-of-contents scrollbar.
|
||||
|
||||
## Design
|
||||
|
||||
Define one set of WebKit scrollbar rules in `Resources/template.html`. The rules apply to the root document scrollbar and nested scroll containers such as the table of contents and raw-source pane. The track uses `--bg`; the thumb is derived from `--text` and `--bg`, with a stronger hover color. Scrolling behavior, dimensions, and macOS scrollbar preferences remain unchanged.
|
||||
|
||||
When the source pane is visible, the rendered document and source are peer panes. `#content` owns rendered-document scrolling; `#raw-source` owns source scrolling; the page itself does not scroll. This places each scrollbar at its pane's right edge. A single `_contentScroller()` helper is the source of truth for rendered scrolling, including scroll preservation, source synchronization, TOC highlighting, heading navigation, find navigation, and Vim keys.
|
||||
|
||||
The TOC remains independently scrollable. When rendered scrolling changes the active heading, the active TOC link is scrolled with `block: 'nearest'`, moving only enough to keep that link visible rather than recentering the TOC on every update.
|
||||
|
||||
## Validation
|
||||
|
||||
An XCTest verifies that the bundled template contains shared track, thumb, hover, and corner rules using theme variables. It also verifies that `#content` owns vertical overflow and that rendered navigation no longer targets `window` scrolling. Build and unit tests guard template packaging and application behavior; a long document with its source pane open verifies scrollbar placement and synchronized scrolling visually.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Think different
|
||||
|
||||
## Here's to the crazy ones.
|
||||
|
||||
1. The misfits
|
||||
2. The rebels
|
||||
3. The troublemakers
|
||||
|
||||
The round pegs in the square holes. The ones who see things differently. They're not fond of rules.
|
||||
And they have no respect for the status quo. You can quote them, disagree with them, glorify or vilify them. About the only thing you can't do is ignore them. Because they change things.
|
||||
|
||||
***They push the human race forward***.
|
||||
|
||||
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
|
||||
|
||||
[Think different](http://www.apple.com)
|
||||
|
||||
@@ -11,11 +11,37 @@ build:
|
||||
|
||||
# Kill, build, and launch the app
|
||||
run: kill build
|
||||
@open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
|
||||
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
|
||||
|
||||
# Open a specific markdown file
|
||||
open file: build
|
||||
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
|
||||
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
|
||||
|
||||
# Run the test suite
|
||||
test:
|
||||
xcodebuild test -project markdownViewr.xcodeproj -scheme markdownViewr -destination 'platform=macOS'
|
||||
|
||||
# Update the Homebrew cask to match the current release (run after a successful just release)
|
||||
update-cask:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
DMG="/tmp/markdownViewr.dmg"
|
||||
if [[ ! -f "$DMG" ]]; then
|
||||
echo "==> Downloading DMG for v$VERSION..."
|
||||
gh release download "v$VERSION" --repo darinkelkhoff/markdownViewr --pattern "markdownViewr.dmg" --dir /tmp --clobber
|
||||
fi
|
||||
DMG_SHA=$(shasum -a 256 "$DMG" | awk '{print $1}')
|
||||
TAP_DIR="/tmp/markdownViewr-homebrew-tap"
|
||||
rm -rf "$TAP_DIR"
|
||||
git clone git@github.com:darinkelkhoff/homebrew-tap.git "$TAP_DIR"
|
||||
sed -i.bak "s/version \".*\"/version \"$VERSION\"/" "$TAP_DIR/Casks/markdownviewr.rb"
|
||||
sed -i.bak "s/sha256 \".*\"/sha256 \"$DMG_SHA\"/" "$TAP_DIR/Casks/markdownviewr.rb"
|
||||
rm -f "$TAP_DIR/Casks/markdownviewr.rb.bak"
|
||||
git -C "$TAP_DIR" add Casks/markdownviewr.rb
|
||||
git -C "$TAP_DIR" commit -m "markdownViewr: update to v$VERSION"
|
||||
git -C "$TAP_DIR" push origin main
|
||||
echo "Done! brew install --cask darinkelkhoff/tap/markdownviewr"
|
||||
|
||||
# Regenerate the Xcode project from project.yml
|
||||
generate:
|
||||
@@ -30,17 +56,37 @@ release: kill
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "==> Regenerating Xcode project..."
|
||||
xcodegen generate
|
||||
LATEST=$(gh release list --repo darinkelkhoff/markdownViewr --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "none")
|
||||
if [[ "$LATEST" == "v$VERSION" ]]; then
|
||||
echo "Error: v$VERSION is already released. Bump MARKETING_VERSION in project.yml first."
|
||||
exit 1
|
||||
fi
|
||||
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
|
||||
echo "Error: local tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
|
||||
echo "Error: remote tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
|
||||
exit 1
|
||||
fi
|
||||
ARCHIVE="/tmp/markdownViewr.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-export"
|
||||
ZIP="/tmp/markdownViewr-$VERSION.zip"
|
||||
echo "==> Archiving v$VERSION..."
|
||||
echo "==> Archiving v$VERSION (build $BUILD_NUMBER)..."
|
||||
rm -rf "$ARCHIVE" "$EXPORT" "$ZIP"
|
||||
xcodebuild archive \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewr \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-destination 'generic/platform=macOS' \
|
||||
ENABLE_HARDENED_RUNTIME=YES | xcpretty || true
|
||||
ENABLE_HARDENED_RUNTIME=YES \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
|
||||
echo "==> Exporting with Developer ID signing..."
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
@@ -58,11 +104,22 @@ release: kill
|
||||
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 "==> Creating DMG..."
|
||||
DMG="/tmp/markdownViewr.dmg"
|
||||
DMG_STAGING="/tmp/markdownViewr-dmg-staging"
|
||||
rm -f "$DMG"
|
||||
rm -rf "$DMG_STAGING"
|
||||
mkdir "$DMG_STAGING"
|
||||
cp -R "$APP" "$DMG_STAGING/"
|
||||
create-dmg \
|
||||
--volname "markdownViewr" \
|
||||
--window-size 660 400 \
|
||||
--icon-size 128 \
|
||||
--icon "markdownViewr.app" 180 185 \
|
||||
--hide-extension "markdownViewr.app" \
|
||||
--app-drop-link 480 185 \
|
||||
"$DMG" \
|
||||
"$DMG_STAGING/"
|
||||
echo "==> 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
|
||||
@@ -79,6 +136,76 @@ release: kill
|
||||
echo "==> Committing and pushing appcast..."
|
||||
git add appcast.xml
|
||||
git commit -m "release: update appcast for v$VERSION"
|
||||
echo "==> Tagging release v$VERSION..."
|
||||
git tag -a "v$VERSION" -m "markdownViewr v$VERSION"
|
||||
git push origin main
|
||||
git push origin "v$VERSION"
|
||||
echo "==> Creating GitHub release v$VERSION..."
|
||||
gh release create "v$VERSION" "$DMG" "$ZIP" \
|
||||
--repo darinkelkhoff/markdownViewr \
|
||||
--title "markdownViewr v$VERSION" \
|
||||
--generate-notes
|
||||
just update-cask
|
||||
echo ""
|
||||
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
|
||||
echo " brew install --cask darinkelkhoff/tap/markdownviewr"
|
||||
|
||||
# Archive and export a signed App Store .pkg (then upload via Transporter or altool)
|
||||
release-mas: kill
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "==> Regenerating Xcode project..."
|
||||
xcodegen generate
|
||||
ARCHIVE="/tmp/markdownViewr-mas.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-mas-export"
|
||||
rm -rf "$ARCHIVE" "$EXPORT"
|
||||
echo "==> Archiving v$VERSION (build $BUILD_NUMBER) for the App Store..."
|
||||
xcodebuild archive \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewr-MAS \
|
||||
-configuration Release \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-allowProvisioningUpdates \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
|
||||
echo "==> Exporting App Store package..."
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportPath "$EXPORT" \
|
||||
-exportOptionsPlist ExportOptions-MAS.plist \
|
||||
-allowProvisioningUpdates
|
||||
PKG=$(find "$EXPORT" -name '*.pkg' | head -1)
|
||||
echo ""
|
||||
echo "Done! Signed App Store package:"
|
||||
echo " $PKG"
|
||||
echo ""
|
||||
echo "Upload it with: just upload-mas"
|
||||
|
||||
# Key ID is read from the AuthKey_<id>.p8 filename in ~/.appstoreconnect/private_keys/;
|
||||
# issuer id from ~/.appstoreconnect/issuer_id (or the ASC_ISSUER_ID env var).
|
||||
# Validate and upload the exported App Store .pkg to App Store Connect
|
||||
upload-mas:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
KEYS_DIR="$HOME/.appstoreconnect/private_keys"
|
||||
KEY_FILE=$(ls "$KEYS_DIR"/AuthKey_*.p8 2>/dev/null | head -1)
|
||||
[[ -n "$KEY_FILE" ]] || { echo "No AuthKey_*.p8 in $KEYS_DIR"; exit 1; }
|
||||
KEY_ID=$(basename "$KEY_FILE" .p8); KEY_ID=${KEY_ID#AuthKey_}
|
||||
ISSUER_ID="${ASC_ISSUER_ID:-$(cat "$HOME/.appstoreconnect/issuer_id" 2>/dev/null || true)}"
|
||||
[[ -n "$ISSUER_ID" ]] || { echo "Set ASC_ISSUER_ID or write it to ~/.appstoreconnect/issuer_id"; exit 1; }
|
||||
PKG="/tmp/markdownViewr-mas-export/markdownViewr.pkg"
|
||||
[[ -f "$PKG" ]] || { echo "No package at $PKG — run 'just release-mas' first."; exit 1; }
|
||||
echo "==> Validating (key $KEY_ID)..."
|
||||
xcrun altool --validate-app -f "$PKG" -t macos --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
|
||||
echo "==> Uploading..."
|
||||
xcrun altool --upload-app -f "$PKG" -t macos --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
|
||||
echo "Done! Watch App Store Connect -> your app -> Build for processing (a few minutes)."
|
||||
|
||||
# Show App Store Connect build + version processing state (uses the same API key)
|
||||
asc-builds:
|
||||
python3 scripts/asc-builds.py
|
||||
+125
-13
@@ -2,26 +2,53 @@ import SwiftUI
|
||||
|
||||
struct CSSHelpView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("CSS Reference")
|
||||
Text("Custom CSS Guide")
|
||||
.font(.headline)
|
||||
|
||||
Group {
|
||||
Text("Structure")
|
||||
Text("How custom CSS works")
|
||||
.font(.subheadline).bold()
|
||||
Text("Custom CSS is injected after markdownViewr's base styles and theme variables. Use it for small overrides, per-theme polish, or global app-wide styling that the theme editor does not expose.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
codeBlock("""
|
||||
/* Theme variables are available everywhere */
|
||||
h1 { color: var(--h1); }
|
||||
a:hover { color: var(--link); }
|
||||
""")
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Document structure")
|
||||
.font(.subheadline).bold()
|
||||
codeBlock("""
|
||||
body
|
||||
nav#toc
|
||||
a
|
||||
a.active
|
||||
a.toc-h1 .. a.toc-h6
|
||||
div#toc-resizer
|
||||
div#content
|
||||
div.frontmatter > table > tr
|
||||
> td.fm-key, td.fm-value
|
||||
h1, h2, h3, h4, h5, h6
|
||||
> span.collapse-arrow
|
||||
h1.collapsed, h2.collapsed, ...
|
||||
p, ul, ol, blockquote, hr
|
||||
div#content-inner
|
||||
div.frontmatter > table > tr
|
||||
> td.fm-key, td.fm-value
|
||||
h1, h2, h3, h4, h5, h6
|
||||
> span.collapse-arrow
|
||||
h1.collapsed, h2.collapsed, ...
|
||||
p, ul, ol, blockquote, hr
|
||||
pre > code
|
||||
table > thead/tbody > tr > th/td
|
||||
img
|
||||
div#raw-resizer
|
||||
div#raw-source
|
||||
pre > code
|
||||
table > thead/tbody > tr > th/td
|
||||
img
|
||||
.raw-h, .raw-ellipsis
|
||||
""")
|
||||
Text("#content, #toc, and #raw-source are sibling scrollable panes. #content-inner is the centered rendered document body and is the safer target for width, padding, and document-background changes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
@@ -32,6 +59,64 @@ struct CSSHelpView: View {
|
||||
codeBlock("pre code.language-swift { ... }")
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Source Pane")
|
||||
.font(.subheadline).bold()
|
||||
Text("The Markdown source view (toolbar toggle):")
|
||||
.font(.caption)
|
||||
codeBlock("""
|
||||
#raw-source (the whole pane)
|
||||
#raw-source pre (the source block)
|
||||
#raw-source code (the source text)
|
||||
#raw-source .raw-h (heading lines)
|
||||
#raw-source .raw-ellipsis (collapsed marker)
|
||||
#toc-resizer, #raw-resizer (dividers)
|
||||
""")
|
||||
Text("Text color/background live on pre and code — style those, not just #raw-source.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Table of Contents")
|
||||
.font(.subheadline).bold()
|
||||
codeBlock("""
|
||||
#toc (the panel)
|
||||
#toc a (entries)
|
||||
#toc a.active (current entry)
|
||||
#toc .toc-h1 .. .toc-h6 (by level)
|
||||
#toc.bullets .toc-h2::before (markers)
|
||||
""")
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Scrollbars")
|
||||
.font(.subheadline).bold()
|
||||
Text("markdownViewr uses WebKit scrollbar pseudo-elements. Style all panes globally, or scope selectors to one scrollable area.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
codeBlock("""
|
||||
/* All scrollbars */
|
||||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--text); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--link); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg); }
|
||||
|
||||
/* Rendered document only */
|
||||
#content::-webkit-scrollbar-thumb { ... }
|
||||
|
||||
/* Markdown source pane only */
|
||||
#raw-source::-webkit-scrollbar-thumb { ... }
|
||||
|
||||
/* Table of contents only */
|
||||
#toc::-webkit-scrollbar-thumb { ... }
|
||||
""")
|
||||
Text("Use #content for the rendered document scrollbar, not #content-inner. The inner element does not scroll.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("CSS Variables")
|
||||
.font(.subheadline).bold()
|
||||
@@ -56,20 +141,47 @@ struct CSSHelpView: View {
|
||||
Text("Examples")
|
||||
.font(.subheadline).bold()
|
||||
codeBlock("""
|
||||
/* Remove heading underlines */
|
||||
h1, h2 { border-bottom: none; }
|
||||
/* Add heading underlines */
|
||||
h1, h2 {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Custom link hover */
|
||||
a:hover { color: var(--h1); }
|
||||
|
||||
/* Rounded images */
|
||||
img { border-radius: 12px; }
|
||||
|
||||
/* Recolor the Markdown source pane */
|
||||
#raw-source, #raw-source pre,
|
||||
#raw-source code { background: var(--code-bg); }
|
||||
#raw-source code { color: var(--code-text); }
|
||||
#raw-source .raw-h { color: var(--h2); }
|
||||
|
||||
/* Style the table of contents */
|
||||
#toc a.active { color: var(--h1); }
|
||||
#toc { background: var(--code-bg); }
|
||||
|
||||
/* Soft themed scrollbar thumbs */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
border: 3px solid var(--bg);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Make only the source scrollbar louder */
|
||||
#raw-source::-webkit-scrollbar-thumb {
|
||||
background: var(--link);
|
||||
}
|
||||
""")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.textSelection(.enabled)
|
||||
.padding(16)
|
||||
.frame(width: 320)
|
||||
}
|
||||
.frame(width: 460, height: 640)
|
||||
}
|
||||
|
||||
private func codeBlock(_ code: String) -> some View {
|
||||
|
||||
+993
-116
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,24 @@ struct EditorConfig: Codable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExternalEditorMenuState: Equatable {
|
||||
case disabled
|
||||
case single(EditorConfig, title: String)
|
||||
case submenu([EditorConfig])
|
||||
|
||||
init(editors: [EditorConfig]) {
|
||||
switch editors.count {
|
||||
case 0:
|
||||
self = .disabled
|
||||
case 1:
|
||||
let editor = editors[0]
|
||||
self = .single(editor, title: "Open in \(editor.name)")
|
||||
default:
|
||||
self = .submenu(editors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EditorManager: ObservableObject {
|
||||
@Published var editors: [EditorConfig] {
|
||||
didSet {
|
||||
@@ -51,6 +69,21 @@ class EditorManager: ObservableObject {
|
||||
editors.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func moveEditor(id: UUID, by offset: Int) {
|
||||
guard let source = editors.firstIndex(where: { $0.id == id }) else { return }
|
||||
let destination = source + offset
|
||||
guard editors.indices.contains(destination) else { return }
|
||||
|
||||
var reorderedEditors = editors
|
||||
reorderedEditors.swapAt(source, destination)
|
||||
editors = reorderedEditors
|
||||
}
|
||||
|
||||
func setOpensFolder(_ opensFolder: Bool, for id: UUID) {
|
||||
guard let index = editors.firstIndex(where: { $0.id == id }) else { return }
|
||||
editors[index].opensFolder = opensFolder
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(editors) {
|
||||
UserDefaults.standard.set(data, forKey: "configuredEditors")
|
||||
@@ -70,6 +103,8 @@ class EditorManager: ObservableObject {
|
||||
: fileURL
|
||||
|
||||
let config = NSWorkspace.OpenConfiguration()
|
||||
config.activates = true
|
||||
config.allowsRunningApplicationSubstitution = false
|
||||
workspace.open([urlToOpen], withApplicationAt: appURL, configuration: config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
enum FileMenuPruner {
|
||||
private static let removedTitles: Set<String> = ["New", "Save", "Save...", "Revert To"]
|
||||
|
||||
static func removeEditingItems(from menu: NSMenu) {
|
||||
for item in menu.items.reversed() where shouldRemove(item) {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
static func removeEditingItems(from mainMenu: NSMenu?) {
|
||||
guard let fileMenu = fileMenu(in: mainMenu) else { return }
|
||||
removeEditingItems(from: fileMenu)
|
||||
}
|
||||
|
||||
static func fileMenu(in mainMenu: NSMenu?) -> NSMenu? {
|
||||
mainMenu?.item(withTitle: "File")?.submenu
|
||||
}
|
||||
|
||||
private static func shouldRemove(_ item: NSMenuItem) -> Bool {
|
||||
let title = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
|
||||
return removedTitles.contains(title) || title.hasPrefix("Revert To")
|
||||
}
|
||||
}
|
||||
|
||||
final class FileMenuPrunerApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
private var menuObserver: NSObjectProtocol?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
installMenuObserver()
|
||||
DispatchQueue.main.async {
|
||||
self.configureFileMenu()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let menuObserver {
|
||||
NotificationCenter.default.removeObserver(menuObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private func installMenuObserver() {
|
||||
guard menuObserver == nil else { return }
|
||||
menuObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSMenu.didBeginTrackingNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
guard let menu = notification.object as? NSMenu,
|
||||
menu === FileMenuPruner.fileMenu(in: NSApp.mainMenu)
|
||||
else { return }
|
||||
self.updateFileMenu(menu)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureFileMenu() {
|
||||
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApp.mainMenu) else { return }
|
||||
updateFileMenu(fileMenu)
|
||||
}
|
||||
|
||||
private func updateFileMenu(_ fileMenu: NSMenu) {
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
ExternalEditorFileMenuController.shared.update(in: fileMenu)
|
||||
}
|
||||
}
|
||||
|
||||
final class ExternalEditorFileMenuController: NSObject, NSMenuItemValidation {
|
||||
static let shared = ExternalEditorFileMenuController()
|
||||
|
||||
var currentFileURLProvider: () -> URL? = {
|
||||
ExternalEditorFileMenuController.currentOpenDocumentFileURL()
|
||||
}
|
||||
|
||||
weak var editorManager: EditorManager? {
|
||||
didSet {
|
||||
editorSubscription = editorManager?.$editors.sink { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.updateMainMenu()
|
||||
}
|
||||
}
|
||||
updateMainMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private let dividerIdentifier = "markdownViewr.externalEditor.divider"
|
||||
private let itemIdentifier = "markdownViewr.externalEditor.item"
|
||||
private var editorSubscription: AnyCancellable?
|
||||
|
||||
private var dividerMenuIdentifier: NSUserInterfaceItemIdentifier {
|
||||
NSUserInterfaceItemIdentifier(dividerIdentifier)
|
||||
}
|
||||
|
||||
private var itemMenuIdentifier: NSUserInterfaceItemIdentifier {
|
||||
NSUserInterfaceItemIdentifier(itemIdentifier)
|
||||
}
|
||||
|
||||
static func currentOpenDocumentFileURL() -> URL? {
|
||||
let documentController = NSDocumentController.shared
|
||||
return currentOpenDocumentFileURL(in: [documentController.currentDocument] + documentController.documents)
|
||||
}
|
||||
|
||||
static func currentOpenDocumentFileURL(in documents: [NSDocument?]) -> URL? {
|
||||
for document in documents {
|
||||
guard let document else { continue }
|
||||
guard document.windowControllers.contains(where: { $0.window?.isVisible == true }) else { continue }
|
||||
if let fileURL = document.fileURL {
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateMainMenu() {
|
||||
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApplication.shared.mainMenu) else { return }
|
||||
update(in: fileMenu)
|
||||
}
|
||||
|
||||
func update(in menu: NSMenu) {
|
||||
removeExistingItems(from: menu)
|
||||
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.identifier = dividerMenuIdentifier
|
||||
|
||||
let externalEditorItem = makeExternalEditorItem()
|
||||
externalEditorItem.identifier = itemMenuIdentifier
|
||||
|
||||
let insertionIndex = externalEditorInsertionIndex(in: menu)
|
||||
menu.insertItem(separator, at: insertionIndex)
|
||||
menu.insertItem(externalEditorItem, at: insertionIndex + 1)
|
||||
}
|
||||
|
||||
private func makeExternalEditorItem() -> NSMenuItem {
|
||||
let editors = editorManager?.editors ?? []
|
||||
let hasOpenFile = currentFileURLProvider() != nil
|
||||
|
||||
switch ExternalEditorMenuState(editors: editors) {
|
||||
case .disabled:
|
||||
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
return item
|
||||
|
||||
case let .single(editor, title):
|
||||
return makeEditorMenuItem(title: title, editor: editor, keyEquivalent: "e", isEnabled: hasOpenFile)
|
||||
|
||||
case let .submenu(editors):
|
||||
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
|
||||
let submenu = NSMenu(title: "Open in External Editor")
|
||||
for (index, editor) in editors.enumerated() {
|
||||
submenu.addItem(makeEditorMenuItem(
|
||||
title: editor.name,
|
||||
editor: editor,
|
||||
keyEquivalent: index == 0 ? "e" : "",
|
||||
isEnabled: hasOpenFile
|
||||
))
|
||||
}
|
||||
item.submenu = submenu
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private func makeEditorMenuItem(
|
||||
title: String,
|
||||
editor: EditorConfig,
|
||||
keyEquivalent: String = "",
|
||||
isEnabled: Bool = true
|
||||
) -> NSMenuItem {
|
||||
let item = NSMenuItem(
|
||||
title: title,
|
||||
action: isEnabled ? #selector(openInExternalEditor(_:)) : nil,
|
||||
keyEquivalent: keyEquivalent
|
||||
)
|
||||
item.target = isEnabled ? self : nil
|
||||
item.representedObject = editor.id.uuidString
|
||||
item.isEnabled = isEnabled
|
||||
item.keyEquivalentModifierMask = [.command]
|
||||
return item
|
||||
}
|
||||
|
||||
@objc private func openInExternalEditor(_ sender: NSMenuItem) {
|
||||
guard let editorIDString = sender.representedObject as? String,
|
||||
let editorID = UUID(uuidString: editorIDString),
|
||||
let editor = editorManager?.editors.first(where: { $0.id == editorID }),
|
||||
let fileURL = currentFileURLProvider()
|
||||
else { return }
|
||||
|
||||
editorManager?.openFile(fileURL, with: editor)
|
||||
}
|
||||
|
||||
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||
guard menuItem.representedObject as? String != nil else { return true }
|
||||
return currentFileURLProvider() != nil
|
||||
}
|
||||
|
||||
private func removeExistingItems(from menu: NSMenu) {
|
||||
for item in menu.items.reversed() {
|
||||
if item.identifier == dividerMenuIdentifier || item.identifier == itemMenuIdentifier {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func externalEditorInsertionIndex(in menu: NSMenu) -> Int {
|
||||
if let openRecentIndex = menu.items.firstIndex(where: { item in
|
||||
let normalizedTitle = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
|
||||
return normalizedTitle == "Open Recent"
|
||||
}) {
|
||||
return openRecentIndex + 1
|
||||
}
|
||||
|
||||
return min(menu.items.count, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Owns access to the directory containing the currently-open document. In the MAS
|
||||
/// (sandboxed) build it resolves or requests a persisted app-scoped security-scoped
|
||||
/// bookmark for that folder and holds access for the manager's lifetime. In the
|
||||
/// Developer ID build it is an inert pass-through that always reports access.
|
||||
final class FolderAccessManager: ObservableObject {
|
||||
/// True when the document's folder is accessible (always true in the non-MAS build).
|
||||
@Published private(set) var hasAccess: Bool = false
|
||||
|
||||
private var folderURL: URL?
|
||||
private var accessedURL: URL?
|
||||
|
||||
deinit {
|
||||
accessedURL?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
/// Resolves any stored access for the document's folder. Call once when a document opens.
|
||||
func prepare(for fileURL: URL) {
|
||||
let folder = fileURL.deletingLastPathComponent()
|
||||
folderURL = folder
|
||||
#if MAS_BUILD
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.key(for: folder)) else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
var stale = false
|
||||
guard let url = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
), url.startAccessingSecurityScopedResource() else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
accessedURL = url
|
||||
hasAccess = true
|
||||
if stale { storeBookmark(for: url) }
|
||||
#else
|
||||
hasAccess = true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Presents a folder picker (MAS only) to obtain and persist access. Calls back on the main queue.
|
||||
func requestAccess(completion: @escaping (Bool) -> Void) {
|
||||
#if MAS_BUILD
|
||||
guard let folder = folderURL else { completion(false); return }
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = folder
|
||||
panel.prompt = "Allow Access"
|
||||
panel.message = "Allow markdownViewr to access this folder to show images and auto-reload changes."
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url,
|
||||
url.startAccessingSecurityScopedResource() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.accessedURL?.stopAccessingSecurityScopedResource()
|
||||
self.accessedURL = url
|
||||
self.storeBookmark(for: url)
|
||||
self.hasAccess = true
|
||||
completion(true)
|
||||
}
|
||||
#else
|
||||
completion(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reads bytes for a path relative to the document's folder, or nil if unavailable.
|
||||
/// Used by the MAS render path to inline images. Returns nil when access is not held.
|
||||
func imageData(forRelativePath path: String) -> Data? {
|
||||
guard hasAccess, let base = accessedURL ?? folderURL else { return nil }
|
||||
let url = base.appendingPathComponent(path)
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
|
||||
#if MAS_BUILD
|
||||
private func storeBookmark(for url: URL) {
|
||||
guard let data = try? url.bookmarkData(
|
||||
options: [.withSecurityScope],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
) else { return }
|
||||
// Key by the document's folder so the same document re-grants silently next launch.
|
||||
if let folder = folderURL {
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: folder))
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: url))
|
||||
}
|
||||
|
||||
private static func key(for folder: URL) -> String {
|
||||
"folderBookmark:\(folder.standardizedFileURL.path)"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import WebKit
|
||||
private enum HelpTopic: String, CaseIterable, Identifiable {
|
||||
case overview = "Overview"
|
||||
case openingFiles = "Opening Files"
|
||||
case headings = "Headings"
|
||||
case tableOfContents = "Table of Contents"
|
||||
case sourceView = "Source View"
|
||||
case frontmatter = "Frontmatter"
|
||||
case extensions = "Markdown Extensions"
|
||||
case themes = "Themes"
|
||||
case customCSS = "Custom CSS"
|
||||
case editors = "External Editors"
|
||||
case shortcuts = "Keyboard Shortcuts"
|
||||
|
||||
@@ -31,7 +35,7 @@ struct HelpView: View {
|
||||
private func helpMarkdown(for topic: HelpTopic) -> String {
|
||||
switch topic {
|
||||
case .overview:
|
||||
return """
|
||||
var overview = """
|
||||
# Overview
|
||||
|
||||
markdownViewr is a fast, view-only markdown file viewer for macOS. It renders \
|
||||
@@ -47,11 +51,18 @@ struct HelpView: View {
|
||||
- Optional extensions: highlight, superscript, subscript, underline
|
||||
- YAML frontmatter support: hide it, show it as a metadata table, or render it as Markdown
|
||||
- Fully customizable themes with a built-in theme editor and custom CSS
|
||||
- Resizable table of contents with depth markers
|
||||
- Side-by-side Markdown source view, synced to the rendered document
|
||||
- Collapsible outline: click a heading to collapse its section, ⌥-click to anchor to it
|
||||
- Max content width setting to keep line lengths readable on wide monitors
|
||||
- Vim-style keyboard navigation
|
||||
- Find in document with match count
|
||||
- One-click open in any configured external editor
|
||||
- Automatic updates via Sparkle
|
||||
"""
|
||||
#if !MAS_BUILD
|
||||
overview += "\n- Automatic updates via Sparkle"
|
||||
#endif
|
||||
return overview
|
||||
|
||||
case .openingFiles:
|
||||
return """
|
||||
@@ -81,6 +92,72 @@ struct HelpView: View {
|
||||
position is preserved across reloads.
|
||||
"""
|
||||
|
||||
case .headings:
|
||||
return """
|
||||
# Headings
|
||||
|
||||
Headings in the rendered document are interactive:
|
||||
|
||||
- **Click** a heading to collapse or expand its section. Collapsing also \
|
||||
collapses the matching section in the source view (if open), leaving a ⋯ \
|
||||
placeholder.
|
||||
- **⌥-click** (Option-click) a heading to anchor it to the top of the view; \
|
||||
the table of contents and source view follow along.
|
||||
|
||||
You can also jump between headings with `]]` / `[[` (see Keyboard Shortcuts), \
|
||||
or click an entry in the table of contents.
|
||||
"""
|
||||
|
||||
case .tableOfContents:
|
||||
return """
|
||||
# Table of Contents
|
||||
|
||||
Toggle the table of contents with the list button in the toolbar. It lists \
|
||||
the document's headings and highlights the section you're currently reading. \
|
||||
Click an entry to jump to that heading.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Show / hide** — the list-bullet button in the toolbar.
|
||||
- **Depth** — the H1–H6 picker in the toolbar controls how many heading levels to include.
|
||||
- **Resize** — drag the divider on the panel's right edge. The width is \
|
||||
remembered across documents and launches.
|
||||
|
||||
## Display options (Settings > General)
|
||||
|
||||
- **Wrap long entries** — off by default; long headings are truncated with an \
|
||||
ellipsis (hover an entry to see its full text).
|
||||
- **Depth markers** — prefix each entry with a shape that varies by heading \
|
||||
level (●, ○, ◆, ◇, ■, □) so the outline depth is easy to scan.
|
||||
"""
|
||||
|
||||
case .sourceView:
|
||||
return """
|
||||
# Source View
|
||||
|
||||
Toggle the raw Markdown source with the document button in the toolbar to show \
|
||||
it in a pane beside the rendered view.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Resize** — drag the divider on the pane's left edge; the width is remembered.
|
||||
- **Find** — ⌘F searches both panes at once, and Next / Previous move the \
|
||||
rendered and source views together to the same match.
|
||||
- **Scroll-sync** — scrolling either pane keeps the other aligned to the same \
|
||||
heading.
|
||||
- **Collapsing** — collapsing a section in the rendered view also collapses it \
|
||||
in the source, leaving a ⋯ placeholder.
|
||||
|
||||
The source pane is read-only — markdownViewr is a viewer, not an editor. To \
|
||||
edit, use **Open in external editor**.
|
||||
|
||||
## Customizing
|
||||
|
||||
The source pane and table of contents can be restyled with custom CSS \
|
||||
(Settings > General > Global Custom CSS, or a theme's Custom CSS). See the CSS \
|
||||
reference — the **?** button — for selectors such as `#raw-source` and `#toc`.
|
||||
"""
|
||||
|
||||
case .frontmatter:
|
||||
return """
|
||||
# Frontmatter
|
||||
@@ -195,10 +272,12 @@ struct HelpView: View {
|
||||
- Reorder themes by dragging
|
||||
- Delete user themes
|
||||
- Restore deleted built-in themes
|
||||
- Import themes from `.json` files (one or more at a time)
|
||||
- Export the selected theme as a `.json` file to share or back up
|
||||
|
||||
## Creating and editing themes
|
||||
|
||||
Click **New Theme** (or **Edit** on an existing user theme) to open the Theme \
|
||||
Click **New** (or **Edit** on an existing user theme) to open the Theme \
|
||||
Editor. Changes are previewed live against a sample document.
|
||||
|
||||
The editor covers:
|
||||
@@ -212,7 +291,9 @@ struct HelpView: View {
|
||||
## 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:
|
||||
It targets the same HTML the renderer produces, so standard CSS selectors work. \
|
||||
Open the CSS Reference from the Custom CSS editor for a deeper selector guide, \
|
||||
including document layout, source pane, table of contents, and scrollbar selectors.
|
||||
|
||||
```css
|
||||
/* Wider line spacing */
|
||||
@@ -223,13 +304,119 @@ struct HelpView: View {
|
||||
|
||||
/* Larger code font */
|
||||
code { font-size: 0.95em; }
|
||||
|
||||
/* Softer scrollbar thumbs */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
}
|
||||
```
|
||||
|
||||
Useful targets include `#content-inner` for the rendered document body, \
|
||||
`#raw-source` for the source pane, `#toc` for the table of contents, and \
|
||||
`#content::-webkit-scrollbar-thumb` / `#raw-source::-webkit-scrollbar-thumb` \
|
||||
for pane-specific scrollbars.
|
||||
|
||||
## Max Content Width
|
||||
|
||||
**Settings > General > Max Content Width** limits how wide the document content \
|
||||
grows. Enable the checkbox and drag the slider to set a maximum width in pixels. \
|
||||
The width scales with zoom so the proportions stay consistent when you zoom in or out.
|
||||
|
||||
This is useful on wide monitors where full-width text lines become hard to read. \
|
||||
A value around 800–1000 px works well for most documents.
|
||||
|
||||
## Theme storage
|
||||
|
||||
User themes are stored as JSON files in \
|
||||
`~/Library/Application Support/markdownViewr/themes/`. Built-in themes are \
|
||||
bundled with the app and cannot be edited (but can be duplicated).
|
||||
bundled with the app and cannot be edited (but can be copied).
|
||||
"""
|
||||
|
||||
case .customCSS:
|
||||
return """
|
||||
# Custom CSS
|
||||
|
||||
Custom CSS is injected after markdownViewr's built-in styles and theme variables, \
|
||||
so it can override the rendered document, table of contents, source pane, and \
|
||||
scrollbars.
|
||||
|
||||
Add global CSS in **Settings > General > Global Custom CSS**, or add per-theme CSS \
|
||||
from the Theme Editor. The CSS Reference button opens a deeper selector guide with \
|
||||
the app's current HTML structure.
|
||||
|
||||
## Custom CSS Reference
|
||||
|
||||
The main panes are siblings:
|
||||
|
||||
```text
|
||||
body
|
||||
nav#toc
|
||||
div#toc-resizer
|
||||
div#content
|
||||
div#content-inner
|
||||
div#raw-resizer
|
||||
div#raw-source
|
||||
pre > code
|
||||
```
|
||||
|
||||
Use `#content-inner` for rendered document body styles such as padding, background, \
|
||||
or prose layout. Use `#content` only when you mean the rendered document scroll pane.
|
||||
|
||||
## Useful selectors
|
||||
|
||||
| Selector | Target |
|
||||
|---|---|
|
||||
| `#content-inner` | Rendered document body |
|
||||
| `h1` ... `h6` | Rendered headings |
|
||||
| `.collapse-arrow` | Heading disclosure marker |
|
||||
| `.frontmatter` | Rendered frontmatter table |
|
||||
| `#toc` | Table of contents pane |
|
||||
| `#toc a.active` | Current table-of-contents entry |
|
||||
| `#raw-source` | Markdown source pane |
|
||||
| `#raw-source pre` | Source text block |
|
||||
| `#raw-source .raw-h` | Source heading lines |
|
||||
|
||||
## Scrollbars
|
||||
|
||||
markdownViewr uses WebKit scrollbar pseudo-elements. You can style every scrollbar, \
|
||||
or scope styles to one pane:
|
||||
|
||||
```css
|
||||
/* All scrollbars */
|
||||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
border: 3px solid var(--bg);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--link); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg); }
|
||||
|
||||
/* Pane-specific scrollbar thumbs */
|
||||
#content::-webkit-scrollbar-thumb { background: var(--h1); }
|
||||
#toc::-webkit-scrollbar-thumb { background: var(--h2); }
|
||||
#raw-source::-webkit-scrollbar-thumb { background: var(--link); }
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```css
|
||||
/* Add heading underlines */
|
||||
h1, h2 {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Recolor the Markdown source pane */
|
||||
#raw-source, #raw-source pre,
|
||||
#raw-source code { background: var(--code-bg); }
|
||||
#raw-source code { color: var(--code-text); }
|
||||
#raw-source .raw-h { color: var(--h2); }
|
||||
|
||||
/* Style the active table-of-contents entry */
|
||||
#toc a.active { color: var(--h1); }
|
||||
```
|
||||
"""
|
||||
|
||||
case .editors:
|
||||
@@ -248,7 +435,7 @@ struct HelpView: View {
|
||||
|
||||
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 \
|
||||
project-based editors like VS Code, terminal emulators like Terminal \
|
||||
(`/System/Applications/Utilities/Terminal.app`), or Finder \
|
||||
(`/System/Library/CoreServices/Finder.app`).
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
/// Rewrites `<img>` tags whose `src` is a local, relative path into self-contained
|
||||
/// `data:` URLs, using a caller-supplied byte resolver. Remote (`http(s)`), `data:`,
|
||||
/// and absolute-path sources are left untouched. Pure and synchronous so it is unit
|
||||
/// testable without any filesystem or sandbox dependency.
|
||||
enum ImageInliner {
|
||||
static func inlineLocalImages(in html: String, resolve: (String) -> Data?) -> String {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: "<img([^>]*?)src=\"([^\"]*)\"([^>]*)>",
|
||||
options: []
|
||||
) else { return html }
|
||||
|
||||
let ns = html as NSString
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
|
||||
guard !matches.isEmpty else { return html }
|
||||
|
||||
var result = ""
|
||||
var last = 0
|
||||
for match in matches {
|
||||
let full = match.range
|
||||
result += ns.substring(with: NSRange(location: last, length: full.location - last))
|
||||
|
||||
let pre = ns.substring(with: match.range(at: 1))
|
||||
let src = ns.substring(with: match.range(at: 2))
|
||||
let post = ns.substring(with: match.range(at: 3))
|
||||
|
||||
let decoded = src.removingPercentEncoding ?? src
|
||||
if isLocalRelative(src), let data = resolve(decoded) {
|
||||
let mime = mimeType(forExtension: (src as NSString).pathExtension)
|
||||
result += "<img\(pre)src=\"data:\(mime);base64,\(data.base64EncodedString())\"\(post)>"
|
||||
} else {
|
||||
result += ns.substring(with: full)
|
||||
}
|
||||
last = full.location + full.length
|
||||
}
|
||||
result += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
||||
return result
|
||||
}
|
||||
|
||||
/// True if the HTML contains at least one `<img>` whose `src` is a local relative path.
|
||||
/// Used to decide whether folder access (and its grant prompt) is actually needed.
|
||||
static func containsLocalImage(in html: String) -> Bool {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: "<img[^>]*?src=\"([^\"]*)\"[^>]*>",
|
||||
options: []
|
||||
) else { return false }
|
||||
let ns = html as NSString
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
|
||||
for match in matches where isLocalRelative(ns.substring(with: match.range(at: 1))) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func isLocalRelative(_ src: String) -> Bool {
|
||||
if src.isEmpty { return false }
|
||||
if src.hasPrefix("data:") { return false }
|
||||
if src.contains("://") { return false } // http://, https://, file://
|
||||
if src.hasPrefix("/") { return false } // absolute path — unreadable under sandbox
|
||||
return true
|
||||
}
|
||||
|
||||
static func mimeType(forExtension ext: String) -> String {
|
||||
switch ext.lowercased() {
|
||||
case "png": return "image/png"
|
||||
case "jpg", "jpeg": return "image/jpeg"
|
||||
case "gif": return "image/gif"
|
||||
case "svg": return "image/svg+xml"
|
||||
case "webp": return "image/webp"
|
||||
case "bmp": return "image/bmp"
|
||||
case "tif", "tiff": return "image/tiff"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,7 +320,12 @@ private struct HTMLConverter: MarkupWalker {
|
||||
case let list as UnorderedList:
|
||||
result += "<ul>\n"
|
||||
for item in list.listItems {
|
||||
result += "<li>"
|
||||
if let checkbox = item.checkbox {
|
||||
let checked = checkbox == .checked ? " checked" : ""
|
||||
result += "<li class=\"task-list-item\"><input type=\"checkbox\" disabled\(checked)> "
|
||||
} else {
|
||||
result += "<li>"
|
||||
}
|
||||
for child in item.children {
|
||||
if child is Paragraph {
|
||||
for inline in child.children {
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import SwiftUI
|
||||
#if !MAS_BUILD
|
||||
import Sparkle
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct MarkdownViewrApp: App {
|
||||
@NSApplicationDelegateAdaptor(FileMenuPrunerApplicationDelegate.self) private var appDelegate
|
||||
|
||||
#if !MAS_BUILD
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
#endif
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var editorManager = EditorManager()
|
||||
@StateObject private var editorManager: EditorManager
|
||||
@FocusedValue(\.documentViewCommands) private var documentViewCommands
|
||||
|
||||
init() {
|
||||
let editorManager = EditorManager()
|
||||
_editorManager = StateObject(wrappedValue: editorManager)
|
||||
ExternalEditorFileMenuController.shared.editorManager = editorManager
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
DocumentGroup(viewing: MarkdownDocument.self) { file in
|
||||
@@ -21,14 +34,16 @@ struct MarkdownViewrApp: App {
|
||||
.environmentObject(editorManager)
|
||||
.background(DocumentWindowConfigurator(fileURL: file.fileURL))
|
||||
}
|
||||
.defaultSize(width: 700, height: 900)
|
||||
.defaultSize(width: 1100, height: 800)
|
||||
.commands {
|
||||
#if !MAS_BUILD
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
.disabled(!updaterController.updater.canCheckForUpdates)
|
||||
}
|
||||
#endif
|
||||
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings...") {
|
||||
@@ -40,6 +55,14 @@ struct MarkdownViewrApp: App {
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .printItem) {
|
||||
Button("Print...") {
|
||||
documentViewCommands?.printDocument()
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .textEditing) {
|
||||
Button("Find...") {
|
||||
NotificationCenter.default.post(name: .findToggle, object: nil)
|
||||
@@ -64,54 +87,229 @@ struct MarkdownViewrApp: App {
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .toolbar) {
|
||||
Button("Zoom In") {
|
||||
themeManager.zoomIn()
|
||||
}
|
||||
.keyboardShortcut("+", modifiers: .command)
|
||||
|
||||
Button("Zoom Out") {
|
||||
themeManager.zoomOut()
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
|
||||
Button("Actual Size") {
|
||||
themeManager.zoomReset()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
CommandGroup(before: .toolbar) {
|
||||
DocumentViewCommandItems()
|
||||
}
|
||||
|
||||
CommandMenu("Theme") {
|
||||
Button("Next Theme") {
|
||||
themeManager.cycleTheme(direction: 1)
|
||||
documentViewCommands?.nextTheme()
|
||||
}
|
||||
.keyboardShortcut(.downArrow, modifiers: [.command, .shift])
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Button("Previous Theme") {
|
||||
themeManager.cycleTheme(direction: -1)
|
||||
documentViewCommands?.previousTheme()
|
||||
}
|
||||
.keyboardShortcut(.upArrow, modifiers: [.command, .shift])
|
||||
.disabled(documentViewCommands == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentViewCommandItems: View {
|
||||
@FocusedValue(\.documentViewCommands) private var documentViewCommands
|
||||
|
||||
var body: some View {
|
||||
Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))
|
||||
.keyboardShortcut("t", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Menu("Table of Contents Depth") {
|
||||
ForEach(1...6, id: \.self) { depth in
|
||||
Button {
|
||||
documentViewCommands?.setTocDepth(depth)
|
||||
} label: {
|
||||
if documentViewCommands?.tocDepth == depth {
|
||||
Label("H\(depth)", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("H\(depth)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))
|
||||
.keyboardShortcut("u", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Zoom In") {
|
||||
documentViewCommands?.zoomIn()
|
||||
}
|
||||
.keyboardShortcut("+", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Button("Zoom Out") {
|
||||
documentViewCommands?.zoomOut()
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Button("Actual Size") {
|
||||
documentViewCommands?.zoomReset()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
let fileURL: URL?
|
||||
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
|
||||
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
|
||||
|
||||
static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize {
|
||||
let width: CGFloat
|
||||
switch (tocVisible, rawVisible) {
|
||||
case (true, true):
|
||||
width = 1500
|
||||
case (false, true):
|
||||
width = 1350
|
||||
case (true, false):
|
||||
width = 1250
|
||||
case (false, false):
|
||||
width = 1100
|
||||
}
|
||||
return NSSize(width: width, height: 800)
|
||||
}
|
||||
|
||||
private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()
|
||||
|
||||
static func shouldApplyDocumentFrame(isTabbedWindow: Bool, hasTabbedHistory: Bool = false) -> Bool {
|
||||
!isTabbedWindow && !hasTabbedHistory
|
||||
}
|
||||
|
||||
static func autosaveName(for fileURL: URL) -> String {
|
||||
var allowedCharacters = CharacterSet.alphanumerics
|
||||
allowedCharacters.insert(charactersIn: "-_.")
|
||||
let stablePath = fileURL.standardizedFileURL.path
|
||||
let encodedPath = stablePath.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? stablePath
|
||||
return "doc-\(encodedPath)"
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window, let fileURL else { return }
|
||||
let autosaveName = "doc-\(fileURL.path.hashValue)"
|
||||
if !window.setFrameAutosaveName(autosaveName) {
|
||||
window.setFrameUsingName(autosaveName)
|
||||
}
|
||||
configureWindow(for: view, context: context)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
configureWindow(for: nsView, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
private func configureWindow(for view: NSView, context: Context) {
|
||||
guard let window = view.window, let fileURL else { return }
|
||||
window.isRestorable = false
|
||||
let autosaveName = Self.autosaveName(for: fileURL)
|
||||
guard context.coordinator.needsConfiguration(for: window, autosaveName: autosaveName) else { return }
|
||||
|
||||
let isTabbedWindow = Self.isTabbedWindow(window)
|
||||
if isTabbedWindow {
|
||||
Self.markTabGroupHasTabbedHistory(window)
|
||||
context.coordinator.stopSavingFrame(for: window)
|
||||
return
|
||||
}
|
||||
|
||||
let hasTabbedHistory = Self.windowHasTabbedHistory(window)
|
||||
if Self.shouldApplyDocumentFrame(isTabbedWindow: isTabbedWindow, hasTabbedHistory: hasTabbedHistory) {
|
||||
let restoredFrame = window.setFrameUsingName(autosaveName)
|
||||
if !restoredFrame {
|
||||
window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))
|
||||
window.center()
|
||||
}
|
||||
}
|
||||
if !hasTabbedHistory {
|
||||
window.setFrameAutosaveName(autosaveName)
|
||||
}
|
||||
context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)
|
||||
}
|
||||
|
||||
private static func isTabbedWindow(_ window: NSWindow) -> Bool {
|
||||
window.tabbedWindows?.contains { $0 !== window } == true
|
||||
}
|
||||
|
||||
private static func markTabGroupHasTabbedHistory(_ window: NSWindow) {
|
||||
let tabGroupWindows = window.tabbedWindows ?? [window]
|
||||
tabGroupWindows.forEach {
|
||||
windowsWithTabbedHistory.add($0)
|
||||
$0.isRestorable = false
|
||||
$0.setFrameAutosaveName("")
|
||||
}
|
||||
}
|
||||
|
||||
private static func windowHasTabbedHistory(_ window: NSWindow) -> Bool {
|
||||
windowsWithTabbedHistory.contains(window)
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
private weak var configuredWindow: NSWindow?
|
||||
private var configuredAutosaveName: String?
|
||||
private var observerTokens: [NSObjectProtocol] = []
|
||||
|
||||
func needsConfiguration(for window: NSWindow, autosaveName: String) -> Bool {
|
||||
configuredWindow !== window || configuredAutosaveName != autosaveName
|
||||
}
|
||||
|
||||
func configureSavingFrame(for window: NSWindow, autosaveName: String) {
|
||||
removeObservers()
|
||||
configuredWindow = window
|
||||
configuredAutosaveName = autosaveName
|
||||
|
||||
let center = NotificationCenter.default
|
||||
let saveFrame: (Notification) -> Void = { [weak window] _ in
|
||||
window?.saveFrame(usingName: autosaveName)
|
||||
}
|
||||
observerTokens = [
|
||||
center.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: window,
|
||||
queue: .main,
|
||||
using: saveFrame
|
||||
),
|
||||
center.addObserver(
|
||||
forName: NSWindow.didResizeNotification,
|
||||
object: window,
|
||||
queue: .main,
|
||||
using: saveFrame
|
||||
),
|
||||
center.addObserver(
|
||||
forName: NSWindow.didMoveNotification,
|
||||
object: window,
|
||||
queue: .main,
|
||||
using: saveFrame
|
||||
)
|
||||
]
|
||||
window.saveFrame(usingName: autosaveName)
|
||||
}
|
||||
|
||||
func stopSavingFrame(for window: NSWindow) {
|
||||
removeObservers()
|
||||
configuredWindow = nil
|
||||
configuredAutosaveName = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
let center = NotificationCenter.default
|
||||
observerTokens.forEach { center.removeObserver($0) }
|
||||
observerTokens = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HelpWindowController: NSObject, NSWindowDelegate {
|
||||
@@ -164,10 +362,10 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
.environmentObject(editorManager)
|
||||
|
||||
let hostingView = NSHostingView(rootView: settingsView)
|
||||
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)
|
||||
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 600),
|
||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 720),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
|
||||
@@ -9,6 +9,20 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
var findBar: FindBarController?
|
||||
var tocVisible: Bool = false
|
||||
var tocDepth: Int = 3
|
||||
var tocWidth: Double = 220
|
||||
var tocWrap: Bool = false
|
||||
var tocBullets: Bool = false
|
||||
var rawSource: String = ""
|
||||
var rawVisible: Bool = false
|
||||
var rawWidth: Double = 400
|
||||
var onTocWidthChange: ((Double) -> Void)?
|
||||
var onRawWidthChange: ((Double) -> Void)?
|
||||
|
||||
static func jsTemplateEscape(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
.replacingOccurrences(of: "$", with: "\\$")
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
@@ -16,7 +30,11 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
|
||||
let userContent = WKUserContentController()
|
||||
userContent.add(context.coordinator, name: "vim")
|
||||
userContent.add(context.coordinator, name: "tocWidth")
|
||||
userContent.add(context.coordinator, name: "rawWidth")
|
||||
config.userContentController = userContent
|
||||
context.coordinator.onTocWidthChange = onTocWidthChange
|
||||
context.coordinator.onRawWidthChange = onRawWidthChange
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
@@ -27,6 +45,8 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.onTocWidthChange = onTocWidthChange
|
||||
context.coordinator.onRawWidthChange = onRawWidthChange
|
||||
if context.coordinator.lastHTML != html {
|
||||
let previousHTML = context.coordinator.lastHTML
|
||||
context.coordinator.lastHTML = html
|
||||
@@ -42,6 +62,11 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
injectCSS(in: webView)
|
||||
}
|
||||
|
||||
if !context.coordinator.isPageLoaded {
|
||||
queueInitialState(context: context)
|
||||
return
|
||||
}
|
||||
|
||||
if context.coordinator.lastTocVisible != tocVisible {
|
||||
context.coordinator.lastTocVisible = tocVisible
|
||||
webView.evaluateJavaScript("setTOCVisible(\(tocVisible))") { _, _ in }
|
||||
@@ -50,6 +75,30 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
context.coordinator.lastTocDepth = tocDepth
|
||||
webView.evaluateJavaScript("setTOCDepth(\(tocDepth))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastTocWidth != tocWidth {
|
||||
context.coordinator.lastTocWidth = tocWidth
|
||||
webView.evaluateJavaScript("setTOCWidth(\(tocWidth))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastTocWrap != tocWrap {
|
||||
context.coordinator.lastTocWrap = tocWrap
|
||||
webView.evaluateJavaScript("setTOCWrap(\(tocWrap))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastTocBullets != tocBullets {
|
||||
context.coordinator.lastTocBullets = tocBullets
|
||||
webView.evaluateJavaScript("setTOCBullets(\(tocBullets))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastRawSource != rawSource {
|
||||
context.coordinator.lastRawSource = rawSource
|
||||
webView.evaluateJavaScript("setRawSource(`\(Self.jsTemplateEscape(rawSource))`)") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastRawWidth != rawWidth {
|
||||
context.coordinator.lastRawWidth = rawWidth
|
||||
webView.evaluateJavaScript("setRawWidth(\(rawWidth))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastRawVisible != rawVisible {
|
||||
context.coordinator.lastRawVisible = rawVisible
|
||||
webView.evaluateJavaScript("setRawVisible(\(rawVisible))") { _, _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private static let previewDirectory: URL = {
|
||||
@@ -72,29 +121,51 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
|
||||
context.coordinator.lastHTML = html
|
||||
context.coordinator.lastCSS = themeCSS
|
||||
context.coordinator.isPageLoaded = false
|
||||
#if MAS_BUILD
|
||||
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
|
||||
// both unreadable and unnecessary.
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
#else
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
|
||||
} else {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
}
|
||||
#endif
|
||||
template = template.replacingOccurrences(of: "{{THEME_CSS}}", with: themeCSS)
|
||||
template = template.replacingOccurrences(of: "{{CONTENT}}", with: html)
|
||||
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
if fileURL != nil {
|
||||
let tempDir = Self.previewDirectory
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
|
||||
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
|
||||
#if MAS_BUILD
|
||||
// Self-contained HTML (images inlined); only the container temp dir is needed.
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: tempDir)
|
||||
#else
|
||||
// Grant read access to "/" so both the temp file and the document's images are accessible
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||
#endif
|
||||
context.coordinator.tempFileURL = tempHTML
|
||||
} else {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
}
|
||||
|
||||
// Queue TOC state to apply after page loads
|
||||
// Queue document state to apply after page loads.
|
||||
queueInitialState(context: context)
|
||||
}
|
||||
|
||||
private func queueInitialState(context: Context) {
|
||||
context.coordinator.pendingTocVisible = tocVisible
|
||||
context.coordinator.pendingTocDepth = tocDepth
|
||||
context.coordinator.pendingTocWidth = tocWidth
|
||||
context.coordinator.pendingTocWrap = tocWrap
|
||||
context.coordinator.pendingTocBullets = tocBullets
|
||||
context.coordinator.pendingRawSource = rawSource
|
||||
context.coordinator.pendingRawWidth = rawWidth
|
||||
context.coordinator.pendingRawVisible = rawVisible
|
||||
}
|
||||
|
||||
private func updateContent(in webView: WKWebView) {
|
||||
@@ -128,8 +199,23 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
var lastCSS: String = ""
|
||||
var lastTocVisible: Bool = false
|
||||
var lastTocDepth: Int = 3
|
||||
var lastTocWidth: Double = 220
|
||||
var lastTocWrap: Bool = false
|
||||
var lastTocBullets: Bool = false
|
||||
var lastRawSource: String = ""
|
||||
var lastRawVisible: Bool = false
|
||||
var lastRawWidth: Double = 400
|
||||
var isPageLoaded = false
|
||||
var pendingTocVisible: Bool?
|
||||
var pendingTocDepth: Int?
|
||||
var pendingTocWidth: Double?
|
||||
var pendingTocWrap: Bool?
|
||||
var pendingTocBullets: Bool?
|
||||
var pendingRawSource: String?
|
||||
var pendingRawVisible: Bool?
|
||||
var pendingRawWidth: Double?
|
||||
var onTocWidthChange: ((Double) -> Void)?
|
||||
var onRawWidthChange: ((Double) -> Void)?
|
||||
var tempFileURL: URL?
|
||||
private var findBarObservation: Any?
|
||||
private var lastSearchText: String = ""
|
||||
@@ -227,6 +313,22 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
if message.name == "tocWidth" {
|
||||
guard let width = (message.body as? NSNumber)?.doubleValue else { return }
|
||||
lastTocWidth = width
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onTocWidthChange?(width)
|
||||
}
|
||||
return
|
||||
}
|
||||
if message.name == "rawWidth" {
|
||||
guard let width = (message.body as? NSNumber)?.doubleValue else { return }
|
||||
lastRawWidth = width
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onRawWidthChange?(width)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard message.name == "vim", let action = message.body as? String else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -240,6 +342,22 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
isPageLoaded = true
|
||||
if let width = pendingTocWidth {
|
||||
lastTocWidth = width
|
||||
webView.evaluateJavaScript("setTOCWidth(\(width))") { _, _ in }
|
||||
pendingTocWidth = nil
|
||||
}
|
||||
if let wrap = pendingTocWrap {
|
||||
lastTocWrap = wrap
|
||||
webView.evaluateJavaScript("setTOCWrap(\(wrap))") { _, _ in }
|
||||
pendingTocWrap = nil
|
||||
}
|
||||
if let bullets = pendingTocBullets {
|
||||
lastTocBullets = bullets
|
||||
webView.evaluateJavaScript("setTOCBullets(\(bullets))") { _, _ in }
|
||||
pendingTocBullets = nil
|
||||
}
|
||||
if let visible = pendingTocVisible {
|
||||
lastTocVisible = visible
|
||||
webView.evaluateJavaScript("setTOCVisible(\(visible))") { _, _ in }
|
||||
@@ -250,6 +368,23 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
webView.evaluateJavaScript("setTOCDepth(\(depth))") { _, _ in }
|
||||
pendingTocDepth = nil
|
||||
}
|
||||
if let source = pendingRawSource {
|
||||
lastRawSource = source
|
||||
webView.evaluateJavaScript("setRawSource(`\(MarkdownWebView.jsTemplateEscape(source))`)") { _, _ in }
|
||||
pendingRawSource = nil
|
||||
}
|
||||
if let width = pendingRawWidth {
|
||||
lastRawWidth = width
|
||||
webView.evaluateJavaScript("setRawWidth(\(width))") { _, _ in }
|
||||
pendingRawWidth = nil
|
||||
}
|
||||
if let visible = pendingRawVisible {
|
||||
lastRawVisible = visible
|
||||
webView.evaluateJavaScript("setRawVisible(\(visible))") { _, _ in }
|
||||
pendingRawVisible = nil
|
||||
}
|
||||
// Initial TOC state is set; re-enable transitions so toggles animate.
|
||||
webView.evaluateJavaScript("enableTOCTransitions()") { _, _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,22 +23,45 @@ struct SettingsView: View {
|
||||
.tabItem {
|
||||
Label("Markdown", systemImage: "doc.plaintext")
|
||||
}
|
||||
|
||||
PrintingSettingsView()
|
||||
.tabItem {
|
||||
Label("Printing", systemImage: "printer")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsScrollView<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(20)
|
||||
}
|
||||
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
|
||||
@AppStorage("defaultTocDepth") private var defaultTocDepth = 3
|
||||
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
|
||||
@AppStorage("tocWrap") private var tocWrap = false
|
||||
@AppStorage("tocBullets") private var tocBullets = false
|
||||
@State private var isDefault = false
|
||||
@State private var checkPerformed = false
|
||||
@State private var showCSSHelp = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("General")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 16)
|
||||
SettingsScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("General")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox {
|
||||
HStack {
|
||||
@@ -86,6 +109,74 @@ struct GeneralSettingsView: View {
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Max Content Width", isOn: $themeManager.contentWidthEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
if themeManager.contentWidthEnabled {
|
||||
HStack(spacing: 10) {
|
||||
Slider(value: $themeManager.contentWidthPx, in: 600...2400)
|
||||
.onChange(of: themeManager.contentWidthPx) { v in
|
||||
themeManager.contentWidthPx = (v / 10).rounded() * 10
|
||||
}
|
||||
Text(String(format: "%d px", Int(themeManager.contentWidthPx)))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 52, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
HStack {
|
||||
Text("Default Table of Contents depth")
|
||||
Spacer()
|
||||
Picker("Default Table of Contents depth", selection: $defaultTocDepth) {
|
||||
ForEach(1...6, id: \.self) { depth in
|
||||
Text("H\(depth)").tag(depth)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
}
|
||||
|
||||
Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Wrap long entries in the Table of Contents", isOn: $tocWrap)
|
||||
.toggleStyle(.checkbox)
|
||||
Text("When off, long headings are truncated with an ellipsis — hover an entry to see its full text.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Show depth markers in the Table of Contents", isOn: $tocBullets)
|
||||
.toggleStyle(.checkbox)
|
||||
Text("Marks each entry with a shape that varies by heading level (●, ○, ▪, –) to show depth.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
@@ -116,8 +207,8 @@ struct GeneralSettingsView: View {
|
||||
.padding(.top, 12)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.onAppear {
|
||||
checkIfDefault()
|
||||
}
|
||||
@@ -162,6 +253,7 @@ struct GeneralSettingsView: View {
|
||||
struct EditorsSettingsView: View {
|
||||
@EnvironmentObject var editorManager: EditorManager
|
||||
@State private var selectedEditorID: UUID?
|
||||
private let editorActionButtonSize: CGFloat = 28
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -222,7 +314,10 @@ struct EditorsSettingsView: View {
|
||||
addEditor()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
Button {
|
||||
if let id = selectedEditorID {
|
||||
@@ -231,16 +326,45 @@ struct EditorsSettingsView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(selectedEditorID == nil)
|
||||
|
||||
Divider()
|
||||
.frame(height: editorActionButtonSize)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
Button {
|
||||
moveSelectedEditor(by: -1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.up")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Move selected editor up")
|
||||
.disabled(!canMoveSelectedEditorUp)
|
||||
|
||||
Button {
|
||||
moveSelectedEditor(by: 1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Move selected editor down")
|
||||
.disabled(!canMoveSelectedEditorDown)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let id = selectedEditorID,
|
||||
let index = editorManager.editors.firstIndex(where: { $0.id == id }) {
|
||||
Toggle("Opens folder instead of file", isOn: Binding(
|
||||
get: { editorManager.editors[index].opensFolder },
|
||||
set: { editorManager.editors[index].opensFolder = $0 }
|
||||
set: { editorManager.setOpensFolder($0, for: id) }
|
||||
))
|
||||
.font(.caption)
|
||||
}
|
||||
@@ -250,6 +374,24 @@ struct EditorsSettingsView: View {
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private var selectedEditorIndex: Int? {
|
||||
guard let selectedEditorID else { return nil }
|
||||
return editorManager.editors.firstIndex { $0.id == selectedEditorID }
|
||||
}
|
||||
|
||||
private var canMoveSelectedEditorUp: Bool {
|
||||
selectedEditorIndex.map { $0 > 0 } ?? false
|
||||
}
|
||||
|
||||
private var canMoveSelectedEditorDown: Bool {
|
||||
selectedEditorIndex.map { $0 < editorManager.editors.count - 1 } ?? false
|
||||
}
|
||||
|
||||
private func moveSelectedEditor(by offset: Int) {
|
||||
guard let selectedEditorID else { return }
|
||||
editorManager.moveEditor(id: selectedEditorID, by: offset)
|
||||
}
|
||||
|
||||
private func addEditor() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Select Application"
|
||||
@@ -271,15 +413,16 @@ struct MarkdownSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Markdown Extensions")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 8)
|
||||
SettingsScrollView {
|
||||
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)
|
||||
Text("Enable or disable inline syntax extensions applied during rendering.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox {
|
||||
VStack(spacing: 0) {
|
||||
@@ -323,8 +466,8 @@ struct MarkdownSettingsView: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
|
||||
@@ -343,6 +486,144 @@ struct MarkdownSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct PrintingSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@AppStorage("printTheme") private var printTheme = "Clean Printing"
|
||||
@AppStorage("printBackgrounds") private var printBackgrounds = false
|
||||
@AppStorage("printImages") private var printImages = true
|
||||
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
|
||||
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
|
||||
@AppStorage("printBorders") private var printBorders = false
|
||||
@AppStorage("printPagePadding") private var printPagePadding = "0px"
|
||||
|
||||
var body: some View {
|
||||
SettingsScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Printing")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox(label: Text("Print Theme")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Theme")
|
||||
Spacer()
|
||||
Picker("Print Theme", selection: $printTheme) {
|
||||
Text("Active Screen Theme").tag("Active Theme")
|
||||
Text("Plain HTML (Unstyled)").tag("Plain HTML")
|
||||
Text("Clean Printing").tag("Clean Printing")
|
||||
Divider()
|
||||
ForEach(themeManager.themes, id: \.name) { theme in
|
||||
Text(theme.name).tag(theme.name)
|
||||
}
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select the theme to use when printing or exporting to PDF.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
GroupBox(label: Text("Page Layout")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Include Background Colors", isOn: $printBackgrounds)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("When enabled, code block backgrounds, blockquotes, and highlights will print with their theme colors. Disable to save ink.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Toggle("Include Images", isOn: $printImages)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("When enabled, inline and document images will print. Disable to save color ink and space.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Toggle("Include Content Borders", isOn: $printBorders)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("When enabled, adds borders to styled blocks like code blocks, blockquotes, and tables to guarantee contrast with page background.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Text("Page Padding")
|
||||
Spacer()
|
||||
Picker("Page Padding", selection: $printPagePadding) {
|
||||
Text("None (0px)").tag("0px")
|
||||
Text("Small (24px)").tag("24px")
|
||||
Text("Medium (48px)").tag("48px")
|
||||
Text("Large (72px)").tag("72px")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Add margins inside the background color container to keep text away from the page edge.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox(label: Text("Zoom & Scaling")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Font Scaling")
|
||||
Spacer()
|
||||
Picker("Font Scaling", selection: $printZoom) {
|
||||
Text("Compact (75%)").tag("Compact (75%)")
|
||||
Text("Small (85%)").tag("Small (85%)")
|
||||
Text("Standard (100%)").tag("Standard (100%)")
|
||||
Text("Large (115%)").tag("Large (115%)")
|
||||
Divider()
|
||||
Text("Match Screen").tag("Match Screen")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select the scale for printed document fonts, or match current screen zoom.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Text("Content Width")
|
||||
Spacer()
|
||||
Picker("Content Width", selection: $printContentWidth) {
|
||||
Text("Full Page Width").tag("Full Page")
|
||||
Text("Match Screen Width").tag("Match Screen")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select whether page layout uses full printable bounds or conforms to screen width guidelines.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeEditorItem: Identifiable {
|
||||
let id = UUID()
|
||||
let theme: Theme?
|
||||
@@ -369,10 +650,6 @@ struct ThemeSettingsView: View {
|
||||
.padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Text("Drag to reorder. Cmd+Shift+↑/↓ cycles through enabled themes.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
@@ -456,7 +733,7 @@ struct ThemeSettingsView: View {
|
||||
editorItem = ThemeEditorItem(theme: nil)
|
||||
}
|
||||
|
||||
Button("Duplicate") {
|
||||
Button("Copy") {
|
||||
guard let theme = selectedTheme else { return }
|
||||
var copy = theme
|
||||
copy.name = theme.name + " Copy"
|
||||
@@ -496,6 +773,15 @@ struct ThemeSettingsView: View {
|
||||
}
|
||||
.disabled(selectedThemeName == nil || selectedThemeIndex == themeManager.allThemes.count - 1)
|
||||
|
||||
Button("Import...") {
|
||||
importThemes()
|
||||
}
|
||||
|
||||
Button("Export...") {
|
||||
if let theme = selectedTheme { exportTheme(theme) }
|
||||
}
|
||||
.disabled(selectedThemeName == nil)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Restore Built-ins") {
|
||||
@@ -536,6 +822,64 @@ struct ThemeSettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importThemes() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Import Themes"
|
||||
panel.allowedContentTypes = [UTType.json]
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
guard panel.runModal() == .OK else { return }
|
||||
for url in panel.urls {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let theme = try? JSONDecoder().decode(Theme.self, from: data)
|
||||
else { continue }
|
||||
if theme.schemaVersion > Theme.currentSchemaVersion {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "\"\(theme.name)\" was made with a newer version of markdownViewr"
|
||||
alert.informativeText = "It may not display correctly. Import anyway?"
|
||||
alert.addButton(withTitle: "Import")
|
||||
alert.addButton(withTitle: "Skip")
|
||||
if alert.runModal() != .alertFirstButtonReturn { continue }
|
||||
}
|
||||
|
||||
let conflict = themeManager.allThemes.contains { $0.name == theme.name }
|
||||
if conflict {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "\"\(theme.name)\" already exists"
|
||||
alert.informativeText = "Choose how to handle the imported copy."
|
||||
alert.addButton(withTitle: "Overwrite")
|
||||
alert.addButton(withTitle: "Keep Both")
|
||||
alert.addButton(withTitle: "Skip")
|
||||
let resolution: ThemeManager.ImportConflictResolution
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn: resolution = .overwrite
|
||||
case .alertSecondButtonReturn: resolution = .keepBoth
|
||||
default: resolution = .skip
|
||||
}
|
||||
themeManager.importTheme(theme, resolution: resolution)
|
||||
} else {
|
||||
themeManager.importTheme(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportTheme(_ theme: Theme) {
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export Theme"
|
||||
panel.nameFieldStringValue = theme.name
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.lowercased()
|
||||
.appending(".json")
|
||||
panel.allowedContentTypes = [UTType.json]
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(theme) {
|
||||
try? data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeEditorWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
@@ -2,8 +2,11 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Theme: Codable, Identifiable, Hashable {
|
||||
static let currentSchemaVersion = 1
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var schemaVersion: Int = 1
|
||||
var name: String
|
||||
var colors: ThemeColors
|
||||
var fonts: ThemeFonts
|
||||
@@ -14,7 +17,7 @@ struct Theme: Codable, Identifiable, Hashable {
|
||||
var isBuiltIn: Bool = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, colors, fonts, sizes, customCSS
|
||||
case schemaVersion, name, colors, fonts, sizes, customCSS
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -25,6 +28,7 @@ struct Theme: Codable, Identifiable, Hashable {
|
||||
customCSS: String = "",
|
||||
isBuiltIn: Bool = false
|
||||
) {
|
||||
self.schemaVersion = Self.currentSchemaVersion
|
||||
self.name = name
|
||||
self.colors = colors
|
||||
self.fonts = fonts
|
||||
@@ -32,6 +36,16 @@ struct Theme: Codable, Identifiable, Hashable {
|
||||
self.customCSS = customCSS
|
||||
self.isBuiltIn = isBuiltIn
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
colors = try c.decode(ThemeColors.self, forKey: .colors)
|
||||
fonts = try c.decode(ThemeFonts.self, forKey: .fonts)
|
||||
sizes = try c.decode(ThemeSizes.self, forKey: .sizes)
|
||||
customCSS = try c.decodeIfPresent(String.self, forKey: .customCSS) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeColors: Codable, Hashable {
|
||||
@@ -158,3 +172,41 @@ struct ThemeSizes: Codable, Hashable {
|
||||
self.lineHeight = lineHeight
|
||||
}
|
||||
}
|
||||
|
||||
extension Theme {
|
||||
static let cleanPrinting = Theme(
|
||||
name: "Clean Printing",
|
||||
colors: ThemeColors(
|
||||
background: "#ffffff",
|
||||
text: "#24292e",
|
||||
heading1: "#1b1f23",
|
||||
heading2: "#1b1f23",
|
||||
heading3: "#1b1f23",
|
||||
heading4: "#1b1f23",
|
||||
heading5: "#1b1f23",
|
||||
heading6: "#1b1f23",
|
||||
link: "#0366d6",
|
||||
codeBackground: "#f6f8fa",
|
||||
codeText: "#24292e",
|
||||
blockquoteBorder: "#dfe2e5",
|
||||
blockquoteBackground: "#fafbfc",
|
||||
highlightBackground: "#fff5b1",
|
||||
highlightText: "#24292e"
|
||||
),
|
||||
fonts: ThemeFonts(body: "System", heading: "System", code: "SF Mono"),
|
||||
sizes: ThemeSizes(
|
||||
baseFontSize: 11,
|
||||
h1Size: 22,
|
||||
h2Size: 18,
|
||||
h3Size: 15,
|
||||
h4Size: 13.5,
|
||||
h5Size: 12,
|
||||
h6Size: 11,
|
||||
codeFontSize: 10.5,
|
||||
lineHeight: 1.5
|
||||
),
|
||||
customCSS: "",
|
||||
isBuiltIn: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var contentWidthEnabled: Bool {
|
||||
didSet { UserDefaults.standard.set(contentWidthEnabled, forKey: "contentWidthEnabled") }
|
||||
}
|
||||
@Published var contentWidthPx: Double {
|
||||
didSet { UserDefaults.standard.set(contentWidthPx, forKey: "contentWidthPx") }
|
||||
}
|
||||
|
||||
var activeTheme: Theme {
|
||||
themes.first { $0.name == activeThemeName } ?? themes.first ?? Self.fallbackTheme
|
||||
@@ -50,6 +56,11 @@ class ThemeManager: ObservableObject {
|
||||
} else {
|
||||
self.markdownExtensions = MarkdownExtensions()
|
||||
}
|
||||
self.contentWidthEnabled = UserDefaults.standard.object(forKey: "contentWidthEnabled") != nil
|
||||
? UserDefaults.standard.bool(forKey: "contentWidthEnabled")
|
||||
: true
|
||||
let savedWidth = UserDefaults.standard.double(forKey: "contentWidthPx")
|
||||
self.contentWidthPx = savedWidth > 0 ? savedWidth : 1000
|
||||
loadThemes()
|
||||
}
|
||||
|
||||
@@ -81,8 +92,21 @@ class ThemeManager: ObservableObject {
|
||||
func loadThemes() {
|
||||
let deleted = deletedBuiltIns
|
||||
var combined: [Theme] = []
|
||||
combined.append(contentsOf: loadBuiltInThemes().filter { !deleted.contains($0.name) })
|
||||
combined.append(contentsOf: loadUserThemes())
|
||||
let builtIns = loadBuiltInThemes()
|
||||
let builtInNames = Set(builtIns.map(\.name))
|
||||
var userThemes = loadUserThemes()
|
||||
// A user theme sharing a built-in's name is an edit of that built-in (editing
|
||||
// one in place saves a same-named user file). Keep the user's copy so edits
|
||||
// persist, but mark it built-in so the UI still treats it as one, and drop the
|
||||
// now-shadowed bundled copy.
|
||||
for i in userThemes.indices where builtInNames.contains(userThemes[i].name) {
|
||||
userThemes[i].isBuiltIn = true
|
||||
}
|
||||
let overriddenNames = Set(userThemes.map(\.name))
|
||||
combined.append(contentsOf: builtIns.filter {
|
||||
!deleted.contains($0.name) && !overriddenNames.contains($0.name)
|
||||
})
|
||||
combined.append(contentsOf: userThemes)
|
||||
|
||||
let order = themeOrder
|
||||
if !order.isEmpty {
|
||||
@@ -230,18 +254,59 @@ class ThemeManager: ObservableObject {
|
||||
.filter { $0.pathExtension == "json" }
|
||||
.compactMap { url in
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let theme = try? decoder.decode(Theme.self, from: data)
|
||||
var theme = try? decoder.decode(Theme.self, from: data)
|
||||
else { return nil }
|
||||
let versionBefore = theme.schemaVersion
|
||||
migrateIfNeeded(&theme)
|
||||
if theme.schemaVersion != versionBefore {
|
||||
try? writeUserThemeFile(theme)
|
||||
}
|
||||
return theme
|
||||
}
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
func migrateIfNeeded(_ theme: inout Theme) {
|
||||
// Add migration steps here as the schema evolves. Each `if` block
|
||||
// should be a forward migration from one version to the next, in
|
||||
// order, so a theme that is several versions behind migrates fully
|
||||
// in a single load.
|
||||
//
|
||||
// Example (do not remove — shows the pattern):
|
||||
// if theme.schemaVersion < 2 {
|
||||
// theme.someNewField = derivedDefault(from: theme)
|
||||
// theme.schemaVersion = 2
|
||||
// }
|
||||
_ = theme // suppress unused-inout warning until first real migration
|
||||
}
|
||||
|
||||
func saveUserTheme(_ theme: Theme) throws {
|
||||
try writeUserThemeFile(theme)
|
||||
loadThemes()
|
||||
}
|
||||
|
||||
enum ImportConflictResolution { case overwrite, keepBoth, skip }
|
||||
|
||||
func importTheme(_ theme: Theme, resolution: ImportConflictResolution = .keepBoth) {
|
||||
var imported = theme
|
||||
imported.isBuiltIn = false
|
||||
migrateIfNeeded(&imported)
|
||||
let conflict = allThemes.contains { $0.name == imported.name }
|
||||
if conflict {
|
||||
switch resolution {
|
||||
case .overwrite:
|
||||
try? saveUserTheme(imported)
|
||||
case .keepBoth:
|
||||
imported.name = Self.uniqueName(base: imported.name, existing: Set(allThemes.map(\.name)))
|
||||
try? saveUserTheme(imported)
|
||||
case .skip:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
try? saveUserTheme(imported)
|
||||
}
|
||||
}
|
||||
|
||||
func zoomIn() {
|
||||
zoomScale = min(zoomScale * 1.1, 5.0)
|
||||
}
|
||||
@@ -262,15 +327,17 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
|
||||
func deleteTheme(_ theme: Theme) throws {
|
||||
// Remove any user file/override regardless of built-in status (an edited
|
||||
// built-in has both a bundled copy and a user override file).
|
||||
let userFile = Self.userThemesDirectory.appendingPathComponent(Self.userThemeFilename(for: theme.name))
|
||||
if FileManager.default.fileExists(atPath: userFile.path) {
|
||||
try FileManager.default.removeItem(at: userFile)
|
||||
}
|
||||
// Hide the bundled built-in (restorable later via "Restore Built-ins").
|
||||
if theme.isBuiltIn {
|
||||
var deleted = deletedBuiltIns
|
||||
deleted.insert(theme.name)
|
||||
deletedBuiltIns = deleted
|
||||
} else {
|
||||
let userFile = Self.userThemesDirectory.appendingPathComponent(Self.userThemeFilename(for: theme.name))
|
||||
if FileManager.default.fileExists(atPath: userFile.path) {
|
||||
try FileManager.default.removeItem(at: userFile)
|
||||
}
|
||||
}
|
||||
|
||||
disabledThemes.remove(theme.name)
|
||||
@@ -285,6 +352,10 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
|
||||
func generateCSS(for theme: Theme) -> String {
|
||||
generateCSS(for: theme, zoomScale: zoomScale)
|
||||
}
|
||||
|
||||
func generateCSS(for theme: Theme, zoomScale: Double) -> String {
|
||||
let bodyFont = theme.fonts.body == "System"
|
||||
? "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif"
|
||||
: "'\(theme.fonts.body)', -apple-system, sans-serif"
|
||||
@@ -338,6 +409,10 @@ class ThemeManager: ObservableObject {
|
||||
css += "\n\n/* Theme CSS */\n\(theme.customCSS)"
|
||||
}
|
||||
|
||||
if contentWidthEnabled {
|
||||
css += "\n\n#content-inner { max-width: calc(\(Int(contentWidthPx))px * var(--zoom)); margin: auto; }"
|
||||
}
|
||||
|
||||
return css
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,185 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class FileMenuPrunerTests: XCTestCase {
|
||||
func testRemovesOnlyRequestedFileMenuItems() {
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
["New", "Open...", "Open Recent", "Close", "Save", "Duplicate", "Rename", "Save As...", "Revert To", "Page Setup...", "Print..."]
|
||||
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
|
||||
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
|
||||
XCTAssertEqual(
|
||||
fileMenu.items.map(\.title),
|
||||
["Open...", "Open Recent", "Close", "Duplicate", "Rename", "Save As...", "Page Setup...", "Print..."]
|
||||
)
|
||||
}
|
||||
|
||||
func testRemovesSaveWithEllipsisWithoutRemovingSaveAs() {
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
["Save...", "Save As...", "Duplicate", "Rename"]
|
||||
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
|
||||
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
|
||||
XCTAssertEqual(fileMenu.items.map(\.title), ["Save As...", "Duplicate", "Rename"])
|
||||
}
|
||||
|
||||
func testRemovesNestedRevertToSubmenu() {
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open...", action: nil, keyEquivalent: "")
|
||||
let revertItem = NSMenuItem(title: "Revert To", action: nil, keyEquivalent: "")
|
||||
revertItem.submenu = NSMenu(title: "Revert To")
|
||||
fileMenu.addItem(revertItem)
|
||||
fileMenu.addItem(withTitle: "Print...", action: nil, keyEquivalent: "")
|
||||
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
|
||||
XCTAssertEqual(fileMenu.items.map(\.title), ["Open...", "Print..."])
|
||||
}
|
||||
}
|
||||
|
||||
final class ExternalEditorMenuStateTests: XCTestCase {
|
||||
func testDisabledWhenNoEditorsAreAvailable() {
|
||||
let state = ExternalEditorMenuState(editors: [])
|
||||
|
||||
XCTAssertEqual(state, .disabled)
|
||||
}
|
||||
|
||||
func testSingleEditorUsesAppNameInTitle() {
|
||||
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
|
||||
let state = ExternalEditorMenuState(editors: [editor])
|
||||
|
||||
XCTAssertEqual(state, .single(editor, title: "Open in TextEdit"))
|
||||
}
|
||||
|
||||
func testMultipleEditorsUseSubmenu() {
|
||||
let textEdit = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
|
||||
let code = EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
|
||||
let state = ExternalEditorMenuState(editors: [textEdit, code])
|
||||
|
||||
XCTAssertEqual(state, .submenu([textEdit, code]))
|
||||
}
|
||||
|
||||
func testSingleEditorFileMenuItemKeepsEditorIDForAction() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
|
||||
manager.editors = [editor]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
|
||||
XCTAssertEqual(item.representedObject as? String, editor.id.uuidString)
|
||||
XCTAssertTrue(item.target === controller)
|
||||
}
|
||||
|
||||
func testSingleEditorFileMenuItemIsDisabledWithoutOpenFile() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { nil }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
|
||||
XCTAssertFalse(item.isEnabled)
|
||||
XCTAssertNil(item.action)
|
||||
XCTAssertNil(item.target)
|
||||
XCTAssertFalse(controller.validateMenuItem(item))
|
||||
}
|
||||
|
||||
func testExternalEditorSubmenuItemsAreDisabledWithoutOpenFile() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
manager.editors = [
|
||||
EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app"),
|
||||
EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
|
||||
]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { nil }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in External Editor"))
|
||||
let submenu = try XCTUnwrap(item.submenu)
|
||||
XCTAssertTrue(item.isEnabled)
|
||||
XCTAssertTrue(submenu.items.allSatisfy { !$0.isEnabled })
|
||||
XCTAssertTrue(submenu.items.allSatisfy { $0.action == nil })
|
||||
XCTAssertTrue(submenu.items.allSatisfy { $0.target == nil })
|
||||
XCTAssertTrue(submenu.items.allSatisfy { !controller.validateMenuItem($0) })
|
||||
}
|
||||
|
||||
func testExternalEditorMenuItemValidationAllowsOpenFile() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
|
||||
XCTAssertEqual(item.action, NSSelectorFromString("openInExternalEditor:"))
|
||||
XCTAssertTrue(item.target === controller)
|
||||
XCTAssertTrue(controller.validateMenuItem(item))
|
||||
}
|
||||
|
||||
func testCurrentOpenDocumentFileURLIgnoresDocumentsWithoutVisibleWindows() {
|
||||
let document = NSDocument()
|
||||
document.fileURL = URL(fileURLWithPath: "/tmp/closed.md")
|
||||
document.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
|
||||
|
||||
XCTAssertNil(ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [document]))
|
||||
}
|
||||
|
||||
func testCurrentOpenDocumentFileURLUsesVisibleDocumentWindow() {
|
||||
let hiddenDocument = NSDocument()
|
||||
hiddenDocument.fileURL = URL(fileURLWithPath: "/tmp/hidden.md")
|
||||
hiddenDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
|
||||
|
||||
let visibleDocument = NSDocument()
|
||||
let visibleURL = URL(fileURLWithPath: "/tmp/visible.md")
|
||||
visibleDocument.fileURL = visibleURL
|
||||
visibleDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: true)))
|
||||
|
||||
XCTAssertEqual(
|
||||
ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [hiddenDocument, visibleDocument]),
|
||||
visibleURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestWindow: NSWindow {
|
||||
private let isVisibleForTest: Bool
|
||||
|
||||
init(isVisibleForTest: Bool) {
|
||||
self.isVisibleForTest = isVisibleForTest
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 100, height: 100),
|
||||
styleMask: [],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
}
|
||||
|
||||
override var isVisible: Bool {
|
||||
isVisibleForTest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class ImageInlinerTests: XCTestCase {
|
||||
func testInlinesRelativeImageAsDataURL() {
|
||||
let html = "<p><img src=\"img/a.png\" alt=\"x\"></p>"
|
||||
let bytes = Data([0x01, 0x02, 0x03])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "img/a.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
XCTAssertFalse(out.contains("img/a.png"))
|
||||
XCTAssertTrue(out.contains("alt=\"x\""))
|
||||
}
|
||||
|
||||
func testLeavesRemoteImageUntouched() {
|
||||
let html = "<img src=\"https://example.com/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesAbsolutePathUntouched() {
|
||||
let html = "<img src=\"/Users/x/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesUnresolvableRelativeImageUntouched() {
|
||||
let html = "<img src=\"missing.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in nil }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testContainsLocalImageTrueForRelativeSrc() {
|
||||
XCTAssertTrue(ImageInliner.containsLocalImage(in: "<img src=\"images/a.png\">"))
|
||||
}
|
||||
|
||||
func testContainsLocalImageFalseForRemoteOnly() {
|
||||
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<img src=\"https://example.com/a.png\">"))
|
||||
}
|
||||
|
||||
func testContainsLocalImageFalseForNoImages() {
|
||||
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<p>no images here</p>"))
|
||||
}
|
||||
|
||||
func testContainsLocalImageFalseForAbsolutePath() {
|
||||
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<img src=\"/Users/x/a.png\">"))
|
||||
}
|
||||
|
||||
func testDecodesPercentEncodedPathBeforeResolving() {
|
||||
let html = "<img src=\"my%20pic.png\">"
|
||||
let bytes = Data([0xAA])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "my pic.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import XCTest
|
||||
import WebKit
|
||||
@testable import markdownViewr
|
||||
|
||||
final class PrintingTests: XCTestCase {
|
||||
// 1. Unit Test for firstSubview(ofType:)
|
||||
func testNSViewFirstSubviewOfTypeRecurse() {
|
||||
let parent = NSView()
|
||||
let mid = NSView()
|
||||
let target = WKWebView()
|
||||
|
||||
mid.addSubview(target)
|
||||
parent.addSubview(mid)
|
||||
|
||||
// Assert we can find the WKWebView recursively
|
||||
let foundWebView = parent.firstSubview(ofType: WKWebView.self)
|
||||
XCTAssertNotNil(foundWebView)
|
||||
XCTAssertEqual(foundWebView, target)
|
||||
|
||||
// Assert we find nil for a type that doesn't exist
|
||||
class CustomTestView: NSView {}
|
||||
XCTAssertNil(parent.firstSubview(ofType: CustomTestView.self))
|
||||
}
|
||||
|
||||
// 2. Static Assertions on source files
|
||||
func testAppConfiguresReplacementPrintCommand() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
// Verify we replaced the default print command group
|
||||
XCTAssertTrue(appSource.contains("CommandGroup(replacing: .printItem)"))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.printDocument()"))
|
||||
}
|
||||
|
||||
func testContentViewBindsPrintDocumentActionAndCoordinator() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
// Verify printDocument is in DocumentViewCommands struct
|
||||
XCTAssertTrue(contentSource.contains("let printDocument: () -> Void"))
|
||||
// Verify printDocument is instantiated with printActiveDocument
|
||||
XCTAssertTrue(contentSource.contains("printDocument: { printActiveDocument() }"))
|
||||
// Verify printActiveDocument exists
|
||||
XCTAssertTrue(contentSource.contains("func printActiveDocument()"))
|
||||
// Verify recursive traversal is used to find WKWebView
|
||||
XCTAssertTrue(contentSource.contains("window.contentView?.firstSubview(ofType: WKWebView.self)"))
|
||||
|
||||
// Verify print settings storage variables exist
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
|
||||
|
||||
// Verify print coordinator instantiation passing all parameters
|
||||
XCTAssertTrue(contentSource.contains("let coordinator = PrintCoordinator("))
|
||||
XCTAssertTrue(contentSource.contains("stripBackgrounds: !printBackgroundsVal"))
|
||||
XCTAssertTrue(contentSource.contains("stripImages: !printImagesVal"))
|
||||
XCTAssertTrue(contentSource.contains("stripWidth: printContentWidthVal == \"Full Page\""))
|
||||
|
||||
// Verify we run the print operation modally with coordinator pointer
|
||||
XCTAssertTrue(contentSource.contains("printOp.runModal("))
|
||||
XCTAssertTrue(contentSource.contains("didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:))"))
|
||||
XCTAssertTrue(contentSource.contains("contextInfo: coordinatorPointer"))
|
||||
|
||||
// Verify PrintCoordinator class exists, conforms to @MainActor and supports stripWidth
|
||||
XCTAssertTrue(contentSource.contains("@MainActor\nclass PrintCoordinator"))
|
||||
XCTAssertTrue(contentSource.contains("let stripImages: Bool"))
|
||||
XCTAssertTrue(contentSource.contains("let stripWidth: Bool"))
|
||||
XCTAssertTrue(contentSource.contains("func restore()"))
|
||||
}
|
||||
|
||||
func testPrintOperationUsesCopiedPrintInfo() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains("NSPrintInfo.shared.copy() as? NSPrintInfo"))
|
||||
XCTAssertFalse(contentSource.contains("let printInfo = NSPrintInfo.shared\n"))
|
||||
}
|
||||
|
||||
func testPrintToolbarItemIsAvailableForCustomization() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains("printDocument: @escaping () -> Void"))
|
||||
XCTAssertTrue(contentSource.contains("self.printDocument = printDocument"))
|
||||
XCTAssertTrue(contentSource.contains(".printDocument, .space"))
|
||||
XCTAssertFalse(contentSource.contains("return [.toc, .tocDepth, .printDocument"))
|
||||
XCTAssertTrue(contentSource.contains("case .printDocument:\n return makePrintItem()"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Print""#))
|
||||
XCTAssertTrue(contentSource.contains(#"item.paletteLabel = "Print""#))
|
||||
XCTAssertTrue(contentSource.contains(#"NSImage(systemSymbolName: "printer""#))
|
||||
XCTAssertTrue(contentSource.contains("item.action = #selector(printDocumentFromToolbar)"))
|
||||
XCTAssertTrue(contentSource.contains("@objc private func printDocumentFromToolbar()"))
|
||||
XCTAssertTrue(contentSource.contains("printDocument?()"))
|
||||
XCTAssertTrue(contentSource.contains(#"static let printDocument = NSToolbarItem.Identifier("print-document")"#))
|
||||
}
|
||||
|
||||
func testXcodeGenConfigurationIncludesTestSources() throws {
|
||||
let projectSource = try loadSourceFile("project.yml")
|
||||
|
||||
XCTAssertTrue(projectSource.contains("markdownViewrTests:"))
|
||||
XCTAssertTrue(projectSource.contains("- markdownViewrTests"))
|
||||
}
|
||||
|
||||
func testSettingsViewIncludesPrintingTab() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
|
||||
// Verify PrintingSettingsView is declared in TabView
|
||||
XCTAssertTrue(settingsSource.contains("PrintingSettingsView()"))
|
||||
XCTAssertTrue(settingsSource.contains(#"Label("Printing", systemImage: "printer")"#))
|
||||
|
||||
// Verify PrintingSettingsView struct layout
|
||||
XCTAssertTrue(settingsSource.contains("struct PrintingSettingsView: View"))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
|
||||
|
||||
// Verify spacing and alignment matching HIG guidelines
|
||||
XCTAssertTrue(settingsSource.contains("VStack(alignment: .leading, spacing: 0)"))
|
||||
XCTAssertTrue(settingsSource.contains(".padding(.bottom, 16)"))
|
||||
XCTAssertTrue(settingsSource.contains(".padding(.top, 12)"))
|
||||
|
||||
// Verify Settings Layout Components (splits and columns)
|
||||
XCTAssertTrue(settingsSource.contains("Picker("))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Plain HTML (Unstyled)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Clean Printing")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Print Theme"))"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Page Layout"))"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Zoom & Scaling"))"#))
|
||||
XCTAssertTrue(settingsSource.contains("Toggle("))
|
||||
}
|
||||
|
||||
// 3. Template @media print assertion
|
||||
func testTemplateIncludesPrintMediaQueries() throws {
|
||||
let template = try loadTemplate()
|
||||
|
||||
// Verify print media query exists
|
||||
XCTAssertTrue(template.contains("@media print"))
|
||||
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// Verify print color adjust properties are present to preserve theme colors
|
||||
XCTAssertTrue(printBlock.contains("-webkit-print-color-adjust: exact !important"))
|
||||
XCTAssertTrue(printBlock.contains("print-color-adjust: exact !important"))
|
||||
|
||||
// Verify print background toggling override style exists without wiping theme text colors
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds"))
|
||||
XCTAssertTrue(printBlock.contains("print-color-adjust: economy !important"))
|
||||
XCTAssertFalse(printBlock.contains("color: #000000 !important"))
|
||||
|
||||
// Verify ink-saving layout overrides for links, rules, headers, and blockquotes
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds hr"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds blockquote"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds h1"))
|
||||
|
||||
// Verify print image toggling override style targets specific content images
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-images #content-inner img"))
|
||||
|
||||
// Verify collapsed editor expansion rules
|
||||
XCTAssertTrue(printBlock.contains("#content-inner > *"))
|
||||
XCTAssertTrue(printBlock.contains("display: revert !important"))
|
||||
|
||||
// Verify print full width toggling override style exists
|
||||
XCTAssertTrue(printBlock.contains("html.print-full-width #content-inner"))
|
||||
XCTAssertTrue(printBlock.contains("max-width: none !important"))
|
||||
|
||||
// Verify layout overrides
|
||||
XCTAssertTrue(printBlock.contains("html, body"))
|
||||
XCTAssertTrue(printBlock.contains("height: auto !important"))
|
||||
XCTAssertTrue(printBlock.contains("overflow: visible !important"))
|
||||
|
||||
XCTAssertTrue(printBlock.contains("#content"))
|
||||
|
||||
// Verify elements hidden
|
||||
XCTAssertTrue(printBlock.contains("#toc"))
|
||||
XCTAssertTrue(printBlock.contains("#raw-source"))
|
||||
XCTAssertTrue(printBlock.contains(".collapse-arrow"))
|
||||
XCTAssertTrue(printBlock.contains("display: none !important"))
|
||||
}
|
||||
|
||||
func testTemplatePrintStylesForEdgeCases() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// 1. Heading Orphaning: Ensure headings (h1-h6) have CSS break-after avoid rules
|
||||
XCTAssertTrue(printBlock.contains("h1, h2, h3, h4, h5, h6 {"))
|
||||
XCTAssertTrue(printBlock.contains("break-after: avoid"))
|
||||
XCTAssertTrue(printBlock.contains("page-break-after: avoid"))
|
||||
|
||||
// 2. Element Page Splits: Ensure small code blocks (pre/code), blockquotes, table rows, and list items have break-inside/page-break-inside avoid rules
|
||||
XCTAssertTrue(printBlock.contains("pre, blockquote, tr, li {"))
|
||||
XCTAssertTrue(printBlock.contains("break-inside: avoid"))
|
||||
XCTAssertTrue(printBlock.contains("page-break-inside: avoid"))
|
||||
|
||||
// 3. Image Overflow: Ensure images do not exceed page boundaries and break-inside: avoid
|
||||
XCTAssertTrue(printBlock.contains("img {"))
|
||||
XCTAssertTrue(printBlock.contains("max-width: 100%"))
|
||||
|
||||
// 4. Table Formatting: Ensure table headers repeat on page boundaries and headers/cells wrap text nicely
|
||||
XCTAssertTrue(printBlock.contains("thead {"))
|
||||
XCTAssertTrue(printBlock.contains("display: table-header-group"))
|
||||
XCTAssertTrue(printBlock.contains("th, td {"))
|
||||
XCTAssertTrue(printBlock.contains("word-wrap: break-word"))
|
||||
XCTAssertTrue(printBlock.contains("white-space: normal"))
|
||||
}
|
||||
|
||||
func testPrintMediaQueriesPreserveThemeColorsInInkSavingMode() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// Ensure print-no-backgrounds class does not apply universal color: #000000 !important
|
||||
// so that selected theme CSS variables (--h1, --h2, --link, --code-text, --text) apply during print.
|
||||
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds * {\n -webkit-print-color-adjust: economy !important;\n print-color-adjust: economy !important;\n background: transparent !important;\n background-color: transparent !important;\n background-image: none !important;\n /* Avoid light text rendering on white backgrounds */\n color: #000000 !important;\n }"))
|
||||
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds a {\n color: #0969da !important;"))
|
||||
}
|
||||
|
||||
func testPrintMediaQueriesEnforceThemeFontsSizesAndColors() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// Verify fonts are explicitly bound in @media print
|
||||
XCTAssertTrue(printBlock.contains("var(--body-font) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--base-font-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--heading-font) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-font) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-font-size) !important"))
|
||||
|
||||
// Verify heading sizes are explicitly bound in @media print
|
||||
XCTAssertTrue(printBlock.contains("var(--h1-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h2-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h3-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h4-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h5-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h6-size) !important"))
|
||||
|
||||
// Verify colors are explicitly bound in @media print
|
||||
XCTAssertTrue(printBlock.contains("var(--h1) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h2) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h3) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h4) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h5) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h6) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--link) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-text) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-bg) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--blockquote-border) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--blockquote-bg) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--highlight-bg) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--highlight-text) !important"))
|
||||
}
|
||||
|
||||
func testPrintPrepareScriptForcesLayoutPass() {
|
||||
let script = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: "body { color: red; }",
|
||||
stripBackgrounds: false,
|
||||
stripImages: false,
|
||||
stripWidth: false,
|
||||
printBorders: false,
|
||||
pagePadding: "0px"
|
||||
)
|
||||
XCTAssertTrue(script.contains("document.body.offsetHeight;"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testEveryThemeOptionIsRespectedAndGeneratesValidPrintCSS() {
|
||||
let manager = ThemeManager()
|
||||
let themesToTest = manager.allThemes
|
||||
|
||||
XCTAssertFalse(themesToTest.isEmpty, "ThemeManager should load built-in themes")
|
||||
|
||||
for theme in themesToTest {
|
||||
let css = manager.generateCSS(for: theme, zoomScale: 1.0)
|
||||
XCTAssertFalse(css.isEmpty, "Theme '\(theme.name)' should generate non-empty CSS")
|
||||
XCTAssertTrue(css.contains("--bg: \(theme.colors.background)"), "Theme '\(theme.name)' should include background color")
|
||||
XCTAssertTrue(css.contains("--text: \(theme.colors.text)"), "Theme '\(theme.name)' should include text color")
|
||||
XCTAssertTrue(css.contains("--h1: \(theme.colors.heading1)"), "Theme '\(theme.name)' should include h1 color")
|
||||
XCTAssertTrue(css.contains("--link: \(theme.colors.link)"), "Theme '\(theme.name)' should include link color")
|
||||
XCTAssertTrue(css.contains("--code-bg: \(theme.colors.codeBackground)"), "Theme '\(theme.name)' should include code bg color")
|
||||
XCTAssertTrue(css.contains("--code-text: \(theme.colors.codeText)"), "Theme '\(theme.name)' should include code text color")
|
||||
|
||||
let prepareScript = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: css.replacingOccurrences(of: "\n", with: "\\n"),
|
||||
stripBackgrounds: false,
|
||||
stripImages: false,
|
||||
stripWidth: true,
|
||||
printBorders: false,
|
||||
pagePadding: "0px"
|
||||
)
|
||||
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent ="), "Script for '\(theme.name)' should target theme-css element")
|
||||
XCTAssertTrue(prepareScript.contains(theme.colors.background), "Prepared script for '\(theme.name)' should inject background color")
|
||||
}
|
||||
|
||||
// Verify Clean Printing option
|
||||
let cleanCSS = manager.generateCSS(for: Theme.cleanPrinting, zoomScale: 1.0)
|
||||
XCTAssertTrue(cleanCSS.contains("--bg: #ffffff"), "Clean printing should use white background")
|
||||
XCTAssertTrue(cleanCSS.contains("--text: #24292e"), "Clean printing should use dark text")
|
||||
|
||||
// Verify Active Theme option
|
||||
let activeCSS = manager.generateCSS(for: manager.activeTheme, zoomScale: 1.0)
|
||||
XCTAssertFalse(activeCSS.isEmpty, "Active theme CSS should generate non-empty CSS")
|
||||
|
||||
// Verify Plain HTML option
|
||||
let plainScript = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: "",
|
||||
stripBackgrounds: true,
|
||||
stripImages: false,
|
||||
stripWidth: true,
|
||||
printBorders: false,
|
||||
pagePadding: "0px"
|
||||
)
|
||||
XCTAssertTrue(plainScript.contains("document.getElementById('theme-css').textContent = ''"))
|
||||
}
|
||||
|
||||
func testPrintZoomResolver() {
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Compact (75%)", screenZoom: 1.5), 0.75)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Small (85%)", screenZoom: 1.5), 0.85)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Standard (100%)", screenZoom: 1.5), 1.0)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Large (115%)", screenZoom: 1.5), 1.15)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Match Screen", screenZoom: 1.5), 1.5)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Unknown", screenZoom: 1.5), 1.0)
|
||||
}
|
||||
|
||||
func testCleanPrintingThemeUsesPrintProportionalFontSizes() {
|
||||
let sizes = Theme.cleanPrinting.sizes
|
||||
XCTAssertLessThanOrEqual(sizes.baseFontSize, 12.0)
|
||||
XCTAssertLessThanOrEqual(sizes.h1Size, 22.0)
|
||||
XCTAssertLessThanOrEqual(sizes.h2Size, 18.0)
|
||||
XCTAssertLessThanOrEqual(sizes.codeFontSize, 11.0)
|
||||
}
|
||||
|
||||
func testSettingsViewIncludesExpandedFontScalingOptions() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Compact (75%)").tag("Compact (75%)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Small (85%)").tag("Small (85%)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Standard (100%)").tag("Standard (100%)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Large (115%)").tag("Large (115%)")"#))
|
||||
}
|
||||
|
||||
// 4. Functional test for PrintCoordinator script building and memory cycles
|
||||
@MainActor
|
||||
func testPrintCoordinatorMemoryManagementAndRestore() {
|
||||
class MockWebView: WKWebView {
|
||||
var lastEvaluatedJavaScript: String?
|
||||
|
||||
override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) {
|
||||
self.lastEvaluatedJavaScript = javaScriptString
|
||||
completionHandler?(nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
let webView = MockWebView()
|
||||
var coordinator: PrintCoordinator? = PrintCoordinator(
|
||||
webView: webView,
|
||||
restoreCSS: "body { background: 'white'; }",
|
||||
stripBackgrounds: true,
|
||||
stripImages: true,
|
||||
stripWidth: true,
|
||||
printBorders: true,
|
||||
pagePadding: "48px"
|
||||
)
|
||||
|
||||
weak var weakCoordinator = coordinator
|
||||
XCTAssertNotNil(weakCoordinator)
|
||||
|
||||
// Pass retained pointer simulating print runModal contextInfo
|
||||
let pointer = Unmanaged.passRetained(coordinator!).toOpaque()
|
||||
|
||||
// Release our strong local reference (ref count is now held only by the unmanaged pointer)
|
||||
coordinator = nil
|
||||
XCTAssertNotNil(weakCoordinator)
|
||||
|
||||
// Simulate AppKit delegate execution finishing
|
||||
let dummyOperation = NSPrintOperation()
|
||||
weakCoordinator?.printOperationDidRun(dummyOperation, success: true, contextInfo: pointer)
|
||||
|
||||
// Assert the unmanaged pointer is released and coordinator is deallocated
|
||||
XCTAssertNil(weakCoordinator)
|
||||
|
||||
// Assert Javascript script execution occurred and escaped CSS correctly
|
||||
XCTAssertNotNil(webView.lastEvaluatedJavaScript)
|
||||
let js = webView.lastEvaluatedJavaScript!
|
||||
XCTAssertTrue(js.contains("document.getElementById('theme-css').textContent = 'body { background: \\'white\\'; }';"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-images');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-full-width');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-borders');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.style.removeProperty('--print-padding');"))
|
||||
}
|
||||
|
||||
func testCleanPrintingThemeIsBuiltInAndIndependent() {
|
||||
let theme = Theme.cleanPrinting
|
||||
XCTAssertEqual(theme.name, "Clean Printing")
|
||||
XCTAssertTrue(theme.isBuiltIn)
|
||||
XCTAssertEqual(theme.colors.background, "#ffffff")
|
||||
XCTAssertEqual(theme.colors.text, "#24292e")
|
||||
}
|
||||
|
||||
// 5. Test that template print query stylesheet contains the print-borders rules
|
||||
func testTemplatePrintStylesForBorders() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders pre"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders .frontmatter"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders blockquote"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders code"))
|
||||
}
|
||||
|
||||
// 6. Test that PrintScriptGenerator outputs correct prepare and restore scripts
|
||||
func testPrintScriptGeneratorScriptOutputs() {
|
||||
let prepareScript = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: "body { background: 'white'; }",
|
||||
stripBackgrounds: true,
|
||||
stripImages: true,
|
||||
stripWidth: true,
|
||||
printBorders: true,
|
||||
pagePadding: "48px"
|
||||
)
|
||||
|
||||
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'white\'; }';"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-backgrounds');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-images');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-full-width');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-borders');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.style.setProperty('--print-padding', '48px');"))
|
||||
|
||||
let restoreScript = PrintScriptGenerator.restoreScript(
|
||||
escapedCSS: "body { background: 'dark'; }",
|
||||
stripBackgrounds: true,
|
||||
stripImages: true,
|
||||
stripWidth: true,
|
||||
printBorders: true,
|
||||
pagePadding: "48px"
|
||||
)
|
||||
|
||||
XCTAssertTrue(restoreScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'dark\'; }';"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-images');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-full-width');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-borders');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.style.removeProperty('--print-padding');"))
|
||||
}
|
||||
|
||||
// Helper function to read source files
|
||||
private func loadSourceFile(_ relativePath: String) throws -> String {
|
||||
let testFileURL = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = testFileURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceURL = repoRoot.appendingPathComponent(relativePath)
|
||||
return try String(contentsOf: sourceURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
// Helper function to read HTML template
|
||||
private func loadTemplate() throws -> String {
|
||||
let testFileURL = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = testFileURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let templateURL = repoRoot.appendingPathComponent("markdownViewr/Resources/template.html")
|
||||
return try String(contentsOf: templateURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
// Helper function to extract @media print block
|
||||
private func printMediaQueryBlock(in template: String) throws -> String {
|
||||
let start = try XCTUnwrap(template.range(of: "@media print {"))
|
||||
let remainder = template[start.upperBound...]
|
||||
|
||||
var bracketCount = 1
|
||||
var index = remainder.startIndex
|
||||
|
||||
while bracketCount > 0 && index < remainder.endIndex {
|
||||
let char = remainder[index]
|
||||
if char == "{" {
|
||||
bracketCount += 1
|
||||
} else if char == "}" {
|
||||
bracketCount -= 1
|
||||
}
|
||||
if bracketCount > 0 {
|
||||
index = remainder.index(after: index)
|
||||
}
|
||||
}
|
||||
|
||||
return String(remainder[..<index])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class TemplateScrollbarTests: XCTestCase {
|
||||
func testTemplateStylesAllScrollbarsWithThemeColors() throws {
|
||||
let template = try loadTemplate()
|
||||
let rawSourcePreRule = try cssRule("#raw-source pre", in: template)
|
||||
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-track"))
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-thumb:hover"))
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-corner"))
|
||||
XCTAssertTrue(template.contains("background: var(--bg)"))
|
||||
XCTAssertTrue(template.contains("var(--text)"))
|
||||
XCTAssertTrue(rawSourcePreRule.contains("border-radius: 0"))
|
||||
}
|
||||
|
||||
func testRenderedContentOwnsScrolling() throws {
|
||||
let template = try loadTemplate()
|
||||
let bodyRule = try cssRule("body", in: template)
|
||||
let contentRule = try cssRule("#content", in: template)
|
||||
let contentInnerRule = try cssRule("#content-inner", in: template)
|
||||
|
||||
XCTAssertTrue(bodyRule.contains("height: 100vh"))
|
||||
XCTAssertTrue(bodyRule.contains("overflow: hidden"))
|
||||
XCTAssertTrue(contentRule.contains("height: 100vh"))
|
||||
XCTAssertTrue(contentRule.contains("overflow-y: auto"))
|
||||
XCTAssertFalse(contentRule.contains("padding:"))
|
||||
XCTAssertTrue(contentInnerRule.contains("padding: 32px 48px"))
|
||||
XCTAssertTrue(template.contains(#"<div id="content">"#))
|
||||
XCTAssertTrue(template.contains(#"<div id="content-inner">"#))
|
||||
XCTAssertTrue(template.contains("function _contentScroller()"))
|
||||
XCTAssertTrue(template.contains("function _contentContainer()"))
|
||||
XCTAssertTrue(template.contains("content.addEventListener('scroll'"))
|
||||
XCTAssertFalse(template.contains("window.addEventListener('scroll'"))
|
||||
XCTAssertFalse(template.contains("window.scrollTo"))
|
||||
XCTAssertFalse(template.contains("window.scrollBy"))
|
||||
XCTAssertFalse(template.contains("window.scrollY"))
|
||||
XCTAssertFalse(template.contains("document.documentElement.scrollTop"))
|
||||
}
|
||||
|
||||
func testActiveTOCLinkIsKeptVisible() throws {
|
||||
let template = try loadTemplate()
|
||||
|
||||
XCTAssertTrue(template.contains("function _scrollTOCLinkIntoView(link)"))
|
||||
XCTAssertTrue(template.contains("var tocFollowPadding = 24"))
|
||||
XCTAssertTrue(template.contains("activeTop < tocTop + tocFollowPadding"))
|
||||
XCTAssertTrue(template.contains("activeBottom > tocBottom - tocFollowPadding"))
|
||||
XCTAssertTrue(template.contains("toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' })"))
|
||||
XCTAssertTrue(template.contains("_scrollTOCLinkIntoView(activeLink)"))
|
||||
XCTAssertFalse(template.contains("activeLink.scrollIntoView"))
|
||||
}
|
||||
|
||||
func testCommandArrowKeysScrollDocumentToTopAndBottom() throws {
|
||||
let template = try loadTemplate()
|
||||
|
||||
XCTAssertTrue(template.contains("function _handleCommandArrowScroll(e)"))
|
||||
XCTAssertTrue(template.contains("if (!e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) return false"))
|
||||
XCTAssertTrue(template.contains("if (e.key === 'ArrowUp')"))
|
||||
XCTAssertTrue(template.contains("_contentScroller().scrollTo({ top: 0, behavior: 'smooth' })"))
|
||||
XCTAssertTrue(template.contains("if (e.key === 'ArrowDown')"))
|
||||
XCTAssertTrue(template.contains("content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })"))
|
||||
XCTAssertTrue(template.contains("if (_handleCommandArrowScroll(e)) return"))
|
||||
}
|
||||
|
||||
func testTOCHighlightFollowsHoveredRenderedSection() throws {
|
||||
let template = try loadTemplate()
|
||||
let updateStart = try XCTUnwrap(template.range(of: "function updateContent(html)"))
|
||||
let updateRemainder = template[updateStart.lowerBound...]
|
||||
let updateEnd = try XCTUnwrap(updateRemainder.range(of: "var _findPairs"))
|
||||
let updateContentFunction = String(updateRemainder[..<updateEnd.lowerBound])
|
||||
let activeTOCRule = try cssRule("#toc a.active", in: template)
|
||||
|
||||
XCTAssertTrue(template.contains("var _tocHoverHeadingId = null"))
|
||||
XCTAssertTrue(template.contains("function setupTOCHoverHighlight()"))
|
||||
XCTAssertTrue(template.contains("content.addEventListener('mousemove'"))
|
||||
XCTAssertTrue(template.contains("content.addEventListener('mouseleave'"))
|
||||
XCTAssertTrue(template.contains("raw.addEventListener('mousemove'"))
|
||||
XCTAssertTrue(template.contains("raw.addEventListener('mouseleave'"))
|
||||
XCTAssertTrue(template.contains("function _headingForHoverPosition(clientY)"))
|
||||
XCTAssertTrue(template.contains("_headingForHoverPosition(event.clientY)"))
|
||||
XCTAssertTrue(template.contains("function _headingIdForRawHoverPosition(clientY)"))
|
||||
XCTAssertTrue(template.contains("_headingIdForRawHoverPosition(event.clientY)"))
|
||||
XCTAssertTrue(template.contains("heading.getBoundingClientRect().top <= clientY"))
|
||||
XCTAssertTrue(template.contains("function _tocHeadingIdForHover(heading)"))
|
||||
XCTAssertTrue(template.contains("_applyTOCHoverHeadingId(_tocHeadingIdForHover(heading))"))
|
||||
XCTAssertTrue(template.contains("function _tocLinkForHeadingId(headingId)"))
|
||||
XCTAssertTrue(template.contains("links[i].dataset.headingId === headingId"))
|
||||
XCTAssertTrue(template.contains("if (_tocHoverHeadingId) return"))
|
||||
XCTAssertTrue(template.contains("highlightTOCHeading(_tocHoverHeadingId, false)"))
|
||||
XCTAssertTrue(template.contains("highlightActiveTOC()"))
|
||||
XCTAssertTrue(updateContentFunction.contains("_tocHoverHeadingId = null"))
|
||||
XCTAssertFalse(activeTOCRule.contains("font-weight"))
|
||||
}
|
||||
|
||||
func testCSSHelpDocumentsScrollbarSelectors() throws {
|
||||
let cssHelp = try loadSourceFile("markdownViewr/CSSHelpView.swift")
|
||||
let appHelp = try loadSourceFile("markdownViewr/HelpView.swift")
|
||||
|
||||
XCTAssertTrue(cssHelp.contains("Custom CSS Guide"))
|
||||
XCTAssertTrue(cssHelp.contains("#content::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(cssHelp.contains("#raw-source::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(cssHelp.contains("#toc::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(cssHelp.contains("::-webkit-scrollbar-corner"))
|
||||
XCTAssertTrue(cssHelp.contains("#content-inner"))
|
||||
XCTAssertTrue(cssHelp.contains("nav#toc"))
|
||||
XCTAssertTrue(cssHelp.contains("div#raw-source"))
|
||||
XCTAssertFalse(cssHelp.contains("Keep readable text centered on wide screens"))
|
||||
XCTAssertFalse(cssHelp.contains("Remove heading underlines"))
|
||||
XCTAssertTrue(appHelp.contains("Open the CSS Reference"))
|
||||
XCTAssertTrue(appHelp.contains("case customCSS"))
|
||||
XCTAssertTrue(appHelp.contains("Custom CSS Reference"))
|
||||
XCTAssertTrue(appHelp.contains("scrollbar thumbs"))
|
||||
}
|
||||
|
||||
func testFileMenuKeepsDefaultCommandGroupsForNonEditingItems() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .newItem)"))
|
||||
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .saveItem)"))
|
||||
XCTAssertTrue(appSource.contains("FileMenuPrunerApplicationDelegate"))
|
||||
|
||||
let prunerSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
|
||||
XCTAssertTrue(prunerSource.contains("NSMenu.didBeginTrackingNotification"))
|
||||
XCTAssertFalse(prunerSource.contains("NSMenuDelegate"))
|
||||
XCTAssertFalse(prunerSource.contains(".delegate = self"))
|
||||
XCTAssertFalse(prunerSource.contains("func menuNeedsUpdate"))
|
||||
XCTAssertFalse(prunerSource.contains("func applicationWillUpdate"))
|
||||
XCTAssertFalse(prunerSource.contains("func menuWillOpen"))
|
||||
}
|
||||
|
||||
func testExternalEditorCommandIsSeparatedBelowOpenRecent() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains("ExternalEditorFileMenuController.shared.editorManager = editorManager"))
|
||||
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.shared.update(in: fileMenu)"))
|
||||
XCTAssertTrue(menuSource.contains(#"normalizedTitle == "Open Recent""#))
|
||||
XCTAssertTrue(menuSource.contains("NSMenuItem.separator()"))
|
||||
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.currentOpenDocumentFileURL()"))
|
||||
XCTAssertTrue(menuSource.contains("window?.isVisible == true"))
|
||||
XCTAssertFalse(appSource.contains(".disabled(fileURL == nil)"))
|
||||
}
|
||||
|
||||
func testExternalEditorAddRemoveButtonsUseMatchingSize() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains("private let editorActionButtonSize"))
|
||||
XCTAssertTrue(settingsSource.contains(".frame(width: editorActionButtonSize, height: editorActionButtonSize)"))
|
||||
}
|
||||
|
||||
func testExternalEditorSettingsCanReorderConfiguredEditors() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
let editorSource = try loadSourceFile("markdownViewr/EditorConfig.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.up")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.down")"#))
|
||||
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: -1)"))
|
||||
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: 1)"))
|
||||
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorUp"))
|
||||
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorDown"))
|
||||
XCTAssertTrue(settingsSource.contains("editorManager.moveEditor(id: selectedEditorID, by: offset)"))
|
||||
XCTAssertTrue(editorSource.contains("func moveEditor(id: UUID, by offset: Int)"))
|
||||
XCTAssertTrue(editorSource.contains("reorderedEditors.swapAt(source, destination)"))
|
||||
XCTAssertTrue(editorSource.contains("editors = reorderedEditors"))
|
||||
}
|
||||
|
||||
func testViewMenuIncludesTogglesForTableOfContentsAndMarkdownSource() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains(#"@AppStorage("tocVisible")"#))
|
||||
XCTAssertFalse(appSource.contains(#"@AppStorage("rawVisible")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocVisible")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawVisible")"#))
|
||||
XCTAssertTrue(contentSource.contains("@State private var tocVisible = false"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var rawVisible = false"))
|
||||
XCTAssertTrue(contentSource.contains(".focusedSceneValue(\\.documentViewCommands, documentViewCommands)"))
|
||||
XCTAssertFalse(contentSource.contains(".focusedValue(\\.documentViewCommands, documentViewCommands)"))
|
||||
XCTAssertTrue(appSource.contains("@FocusedValue(\\.documentViewCommands) private var documentViewCommands"))
|
||||
XCTAssertTrue(appSource.contains(#"Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))"#))
|
||||
XCTAssertTrue(appSource.contains(#"Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))"#))
|
||||
}
|
||||
|
||||
func testViewMenuIncludesTableOfContentsDepthSubmenu() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains(#"@AppStorage("tocDepth")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocDepth")"#))
|
||||
XCTAssertTrue(contentSource.contains("@State private var tocDepth = 3"))
|
||||
XCTAssertTrue(appSource.contains(#"Menu("Table of Contents Depth")"#))
|
||||
XCTAssertTrue(appSource.contains(#"ForEach(1...6, id: \.self)"#))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.setTocDepth(depth)"))
|
||||
XCTAssertTrue(appSource.contains(#"Label("H\(depth)", systemImage: "checkmark")"#))
|
||||
XCTAssertFalse(appSource.contains(#"Picker("Table of Contents Depth", selection: $tocDepth)"#))
|
||||
}
|
||||
|
||||
func testDocumentViewStateAndZoomAreWindowLocal() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
let themeSource = try loadSourceFile("markdownViewr/ThemeManager.swift")
|
||||
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocWidth")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawWidth")"#))
|
||||
XCTAssertTrue(contentSource.contains("@State private var tocWidth: Double = 220"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var rawWidth: Double = 400"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var zoomScale = 1.0"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var activeThemeName: String?"))
|
||||
XCTAssertTrue(contentSource.contains("private var activeTheme: Theme"))
|
||||
XCTAssertTrue(contentSource.contains("themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale)"))
|
||||
XCTAssertTrue(contentSource.contains("activeThemeName = themeManager.activeThemeName"))
|
||||
XCTAssertTrue(contentSource.contains("setZoomScale: { zoomScale = $0 }"))
|
||||
XCTAssertTrue(contentSource.contains("setActiveThemeName: { activeThemeName = $0 }"))
|
||||
XCTAssertTrue(contentSource.contains("zoomScale: Binding("))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomIn()"))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomOut()"))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomReset()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.zoomIn()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.zoomOut()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.zoomReset()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.cycleTheme(direction:"))
|
||||
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = title"))
|
||||
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = sender.title"))
|
||||
XCTAssertTrue(themeSource.contains("func generateCSS(for theme: Theme, zoomScale: Double) -> String"))
|
||||
}
|
||||
|
||||
func testSettingsIncludeNewWindowViewDefaults() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
let webViewSource = try loadSourceFile("markdownViewr/MarkdownWebView.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Picker("Default Table of Contents depth", selection: $defaultTocDepth)"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
|
||||
XCTAssertTrue(contentSource.contains("initializeWindowViewStateIfNeeded(windowWidth: Double(window.contentView?.bounds.width ?? 0))"))
|
||||
XCTAssertFalse(contentSource.contains(".onAppear {\n initializeWindowViewStateIfNeeded()"))
|
||||
XCTAssertFalse(contentSource.contains("guard windowWidth > 0 else { return }"))
|
||||
XCTAssertTrue(contentSource.contains("tocVisible = defaultTocVisible"))
|
||||
XCTAssertTrue(contentSource.contains("tocDepth = defaultTocDepth"))
|
||||
XCTAssertTrue(contentSource.contains("rawVisible = defaultRawVisible"))
|
||||
XCTAssertTrue(contentSource.contains("sizeInitialRawSourceIfNeeded(windowWidth: windowWidth)"))
|
||||
XCTAssertTrue(contentSource.contains("guard rawVisible && !hasActivatedRawSource && windowWidth > 0 else { return }"))
|
||||
XCTAssertTrue(contentSource.contains("windowWidth: windowWidth"))
|
||||
XCTAssertTrue(webViewSource.contains("var isPageLoaded = false"))
|
||||
XCTAssertTrue(webViewSource.contains("context.coordinator.isPageLoaded = false"))
|
||||
XCTAssertTrue(webViewSource.contains("if !context.coordinator.isPageLoaded {"))
|
||||
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingTocVisible = tocVisible"))
|
||||
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingRawVisible = rawVisible"))
|
||||
XCTAssertTrue(webViewSource.contains("isPageLoaded = true"))
|
||||
}
|
||||
|
||||
func testSettingsTabsScrollWhenWindowIsSmall() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains("private struct SettingsScrollView<Content: View>: View"))
|
||||
XCTAssertTrue(settingsSource.contains("ScrollView(.vertical)"))
|
||||
XCTAssertTrue(settingsSource.contains("content\n .frame(maxWidth: .infinity, alignment: .topLeading)\n .padding(20)"))
|
||||
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"General\")"))
|
||||
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"Markdown Extensions\")"))
|
||||
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n .onAppear {\n checkIfDefault()"))
|
||||
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n }\n\n private func extensionRow"))
|
||||
XCTAssertTrue(settingsSource.contains("}\n .padding(20)\n }\n\n private var selectedEditorIndex"))
|
||||
XCTAssertFalse(settingsSource.contains("struct EditorsSettingsView: View {\n var body: some View {\n SettingsScrollView"))
|
||||
XCTAssertFalse(settingsSource.contains("struct ThemeSettingsView: View {\n var body: some View {\n SettingsScrollView"))
|
||||
XCTAssertTrue(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)"))
|
||||
XCTAssertTrue(appSource.contains("contentRect: NSRect(x: 0, y: 0, width: 600, height: 720)"))
|
||||
XCTAssertFalse(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)"))
|
||||
}
|
||||
|
||||
func testDocumentWindowsUseLandscapeDefaultSize() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains(".defaultSize(width: 1100, height: 800)"))
|
||||
XCTAssertTrue(appSource.contains("static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize"))
|
||||
XCTAssertTrue(appSource.contains("let autosaveName = Self.autosaveName(for: fileURL)"))
|
||||
XCTAssertTrue(appSource.contains("let restoredFrame = window.setFrameUsingName(autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))"))
|
||||
XCTAssertTrue(appSource.contains("window.center()"))
|
||||
XCTAssertTrue(appSource.contains("window.setFrameAutosaveName(autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("func updateNSView(_ nsView: NSView, context: Context)"))
|
||||
XCTAssertTrue(appSource.contains("window.saveFrame(usingName: autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("NSWindow.willCloseNotification"))
|
||||
XCTAssertTrue(appSource.contains("NSWindow.didResizeNotification"))
|
||||
XCTAssertTrue(appSource.contains("NSWindow.didMoveNotification"))
|
||||
XCTAssertFalse(appSource.contains(".defaultSize(width: 700, height: 900)"))
|
||||
XCTAssertFalse(appSource.contains("if !window.setFrameAutosaveName(autosaveName)"))
|
||||
XCTAssertFalse(appSource.contains("fileURL.path.hashValue"))
|
||||
}
|
||||
|
||||
func testDocumentWindowAutosaveNameIsStableAcrossLaunches() {
|
||||
let fileURL = URL(fileURLWithPath: "/tmp/My File.md")
|
||||
|
||||
XCTAssertEqual(
|
||||
DocumentWindowConfigurator.autosaveName(for: fileURL),
|
||||
"doc-%2Ftmp%2FMy%20File.md"
|
||||
)
|
||||
}
|
||||
|
||||
func testDocumentWindowDefaultWidthAccountsForDefaultSidePanes() {
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: false), NSSize(width: 1100, height: 800))
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: false), NSSize(width: 1250, height: 800))
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: true), NSSize(width: 1350, height: 800))
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: true), NSSize(width: 1500, height: 800))
|
||||
}
|
||||
|
||||
func testDocumentWindowFrameConfigurationSkipsTabbedWindows() {
|
||||
XCTAssertTrue(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false))
|
||||
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false, hasTabbedHistory: true))
|
||||
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: true))
|
||||
}
|
||||
|
||||
func testDocumentWindowFrameAutosaveSkipsTabbedWindows() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains("let isTabbedWindow = Self.isTabbedWindow(window)"))
|
||||
XCTAssertTrue(appSource.contains("window.isRestorable = false"))
|
||||
XCTAssertTrue(appSource.contains("if isTabbedWindow {\n Self.markTabGroupHasTabbedHistory(window)\n context.coordinator.stopSavingFrame(for: window)\n return\n }"))
|
||||
XCTAssertTrue(appSource.contains("Self.markTabGroupHasTabbedHistory(window)"))
|
||||
XCTAssertTrue(appSource.contains("tabGroupWindows.forEach {\n windowsWithTabbedHistory.add($0)\n $0.isRestorable = false\n $0.setFrameAutosaveName(\"\")\n }"))
|
||||
XCTAssertTrue(appSource.contains("let hasTabbedHistory = Self.windowHasTabbedHistory(window)"))
|
||||
XCTAssertTrue(appSource.contains("if !hasTabbedHistory {\n window.setFrameAutosaveName(autosaveName)\n }\n context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()"))
|
||||
XCTAssertTrue(appSource.contains("func stopSavingFrame(for window: NSWindow)"))
|
||||
XCTAssertFalse(appSource.contains("func consumeRecentlyTabbedWindow(_ window: NSWindow) -> Bool"))
|
||||
}
|
||||
|
||||
func testInitialSourceWidthUsesHalfAvailableWindowWidth() {
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: false, tocWidth: 300),
|
||||
600
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: true, tocWidth: 300),
|
||||
450
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 380, tocVisible: true, tocWidth: 260),
|
||||
220
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 1500, tocVisible: true, tocWidth: 220),
|
||||
640
|
||||
)
|
||||
}
|
||||
|
||||
func testSourceWidthOnlyAutoSizesOnFirstActivation() {
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.rawWidthWhenActivating(
|
||||
currentRawWidth: 360,
|
||||
hasActivatedRawSource: false,
|
||||
windowWidth: 1200,
|
||||
tocVisible: true,
|
||||
tocWidth: 300
|
||||
),
|
||||
450
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.rawWidthWhenActivating(
|
||||
currentRawWidth: 360,
|
||||
hasActivatedRawSource: true,
|
||||
windowWidth: 1200,
|
||||
tocVisible: true,
|
||||
tocWidth: 300
|
||||
),
|
||||
360
|
||||
)
|
||||
}
|
||||
|
||||
func testViewAndExternalEditorCommandsHaveKeyboardShortcuts() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains(#".keyboardShortcut("t", modifiers: .command)"#))
|
||||
XCTAssertTrue(appSource.contains(#".keyboardShortcut("u", modifiers: .command)"#))
|
||||
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: .command)"#))
|
||||
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: [.command, .shift])"#))
|
||||
XCTAssertTrue(menuSource.contains(#"keyEquivalent: "e""#))
|
||||
XCTAssertTrue(menuSource.contains("makeEditorMenuItem(title: title, editor: editor, keyEquivalent: \"e\", isEnabled: hasOpenFile)"))
|
||||
XCTAssertTrue(menuSource.contains("keyEquivalent: index == 0 ? \"e\" : \"\""))
|
||||
XCTAssertTrue(menuSource.contains("let hasOpenFile = currentFileURLProvider() != nil"))
|
||||
XCTAssertTrue(menuSource.contains("item.keyEquivalentModifierMask = [.command]"))
|
||||
}
|
||||
|
||||
func testToolbarControlsExposeStableTextLabels() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains("DocumentToolbarController"))
|
||||
XCTAssertTrue(contentSource.contains("static let toc = NSToolbarItem.Identifier(\"toc\")"))
|
||||
XCTAssertFalse(contentSource.contains("ToolbarItemGroup(placement: .automatic)"))
|
||||
XCTAssertFalse(contentSource.contains(#".toolbar(id: "document-toolbar")"#))
|
||||
XCTAssertFalse(contentSource.contains("ToolbarSpacer"))
|
||||
XCTAssertFalse(contentSource.contains("tocControls"))
|
||||
XCTAssertFalse(contentSource.contains("zoomControls"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "TOC""#))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "TOC Depth""#))
|
||||
XCTAssertFalse(contentSource.contains(#"item.label = "Table of Contents Depth""#))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Markdown Source""#))
|
||||
XCTAssertFalse(contentSource.contains("ToolbarDisplayModeReader"))
|
||||
XCTAssertFalse(contentSource.contains("toolbarDisplayMode"))
|
||||
XCTAssertFalse(contentSource.contains("zoomTextMenu"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Zoom""#))
|
||||
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = zoomMenuItem()"))
|
||||
XCTAssertTrue(contentSource.contains(#"NSMenuItem(title: "Zoom", action: nil, keyEquivalent: "")"#))
|
||||
XCTAssertTrue(contentSource.contains("item.view = zoomControlView"))
|
||||
XCTAssertTrue(contentSource.contains("item.target = self"))
|
||||
XCTAssertTrue(contentSource.contains("item.action = #selector(showZoomMenuFromToolbar(_:))"))
|
||||
XCTAssertTrue(contentSource.contains("NSSegmentedControl(labels:"))
|
||||
XCTAssertTrue(contentSource.contains("final class ZoomToolbarControlView"))
|
||||
XCTAssertTrue(contentSource.contains("control.setWidth(56, forSegment: 1)"))
|
||||
XCTAssertTrue(contentSource.contains("private func zoomMenu() -> NSMenu"))
|
||||
XCTAssertTrue(contentSource.contains("menu.popUp(positioning: nil"))
|
||||
XCTAssertTrue(contentSource.contains("showZoomMenuFromToolbar"))
|
||||
XCTAssertTrue(contentSource.contains("zoomSegmentSelected"))
|
||||
XCTAssertFalse(contentSource.contains("dumpToolbar"))
|
||||
XCTAssertFalse(contentSource.contains("toolbar-debug"))
|
||||
XCTAssertFalse(contentSource.contains("ControlGroup"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Theme""#))
|
||||
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = themeMenuItem()"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = externalEditorLabel"#))
|
||||
XCTAssertTrue(contentSource.contains("item.action = #selector(openExternalEditorFromToolbar(_:))"))
|
||||
XCTAssertTrue(contentSource.contains("item.autovalidates = false"))
|
||||
XCTAssertTrue(contentSource.contains("item.view = externalEditorButton(isEnabled: !validEditors.isEmpty)"))
|
||||
XCTAssertTrue(contentSource.contains("private func externalEditorButton(isEnabled: Bool) -> NSButton"))
|
||||
XCTAssertTrue(contentSource.contains("button.showsBorderOnlyWhileMouseInside = true"))
|
||||
XCTAssertFalse(contentSource.contains("button.isBordered = false"))
|
||||
XCTAssertTrue(contentSource.contains("func validateToolbarItem(_ item: NSToolbarItem) -> Bool"))
|
||||
XCTAssertTrue(contentSource.contains("return hasValidExternalEditor"))
|
||||
XCTAssertTrue(contentSource.contains("showExternalEditorMenu(from: sender)"))
|
||||
XCTAssertFalse(contentSource.contains("externalEditorPopup"))
|
||||
}
|
||||
|
||||
func testToolbarIsNativelyCustomizable() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .toolbar)"))
|
||||
XCTAssertTrue(appSource.contains("CommandGroup(before: .toolbar)"))
|
||||
XCTAssertTrue(contentSource.contains("final class DocumentToolbarController: NSObject"))
|
||||
XCTAssertTrue(contentSource.contains("ObservableObject"))
|
||||
XCTAssertTrue(contentSource.contains("NSToolbarDelegate"))
|
||||
XCTAssertTrue(contentSource.contains("window.toolbar = toolbar"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.delegate = self"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.allowsUserCustomization = true"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.autosavesConfiguration = true"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.displayMode = NSToolbar.DisplayMode.default"))
|
||||
XCTAssertTrue(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v6")"#))
|
||||
XCTAssertTrue(contentSource.contains("removeLegacySavedToolbarConfigurations()"))
|
||||
XCTAssertTrue(contentSource.contains(#""NSToolbar Configuration \(identifier)""#))
|
||||
XCTAssertTrue(contentSource.contains(#""document-toolbar-v5""#))
|
||||
XCTAssertTrue(contentSource.contains(#""document-toolbar-v4""#))
|
||||
XCTAssertFalse(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v5")"#))
|
||||
XCTAssertTrue(contentSource.contains("} else if window.toolbar?.delegate !== self {\n window.toolbar?.delegate = self\n }"))
|
||||
XCTAssertTrue(contentSource.contains("item.target = self"))
|
||||
XCTAssertTrue(contentSource.contains("toolbarAllowedItemIdentifiers"))
|
||||
XCTAssertTrue(contentSource.contains("toolbarDefaultItemIdentifiers"))
|
||||
}
|
||||
|
||||
func testDocumentToolbarUsesNativeRepeatableSpaceItem() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains(".space"))
|
||||
XCTAssertFalse(contentSource.contains(".flexibleSpace"))
|
||||
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]"))
|
||||
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .space, .markdownSource, .zoom, .theme, .externalEditor]"))
|
||||
XCTAssertFalse(contentSource.contains("case .fixedSpace:"))
|
||||
XCTAssertFalse(contentSource.contains("makeFixedSpaceItem"))
|
||||
XCTAssertFalse(contentSource.contains(#"NSToolbarItem.Identifier("document-fixed-space")"#))
|
||||
XCTAssertFalse(contentSource.contains("Small Space"))
|
||||
}
|
||||
|
||||
private func loadTemplate() throws -> String {
|
||||
let templateURL = try XCTUnwrap(
|
||||
Bundle.main.url(forResource: "template", withExtension: "html")
|
||||
)
|
||||
return try String(contentsOf: templateURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func loadSourceFile(_ relativePath: String) throws -> String {
|
||||
let testFileURL = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = testFileURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceURL = repoRoot.appendingPathComponent(relativePath)
|
||||
return try String(contentsOf: sourceURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func cssRule(_ selector: String, in template: String) throws -> String {
|
||||
let start = try XCTUnwrap(template.range(of: "\(selector) {"))
|
||||
let remainder = template[start.upperBound...]
|
||||
let end = try XCTUnwrap(remainder.firstIndex(of: "}"))
|
||||
return String(remainder[..<end])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class ThemeMigrationTests: XCTestCase {
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
func testDecodesCurrentVersionTheme() throws {
|
||||
let json = minimalThemeJSON(schemaVersion: Theme.currentSchemaVersion)
|
||||
let theme = try JSONDecoder().decode(Theme.self, from: json)
|
||||
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
|
||||
}
|
||||
|
||||
func testMissingSchemaVersionDefaultsToOne() throws {
|
||||
// Themes written before versioning was added have no schemaVersion field.
|
||||
let json = minimalThemeJSON(schemaVersion: nil)
|
||||
let theme = try JSONDecoder().decode(Theme.self, from: json)
|
||||
XCTAssertEqual(theme.schemaVersion, 1)
|
||||
}
|
||||
|
||||
func testUnknownFieldsAreIgnored() throws {
|
||||
// A theme from a future app version may have fields this version doesn't know about.
|
||||
// Decoding should succeed — unknown keys are silently ignored by Codable.
|
||||
var dict = minimalThemeDict(schemaVersion: Theme.currentSchemaVersion + 1)
|
||||
dict["futureField"] = "someValue"
|
||||
let json = try JSONSerialization.data(withJSONObject: dict)
|
||||
XCTAssertNoThrow(try JSONDecoder().decode(Theme.self, from: json))
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
func testMigrateNoOpAtCurrentVersion() throws {
|
||||
var theme = Theme(name: "Test")
|
||||
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
|
||||
let manager = ThemeManager()
|
||||
let versionBefore = theme.schemaVersion
|
||||
manager.migrateIfNeeded(&theme)
|
||||
XCTAssertEqual(theme.schemaVersion, versionBefore, "migrateIfNeeded should not change version for a current-version theme")
|
||||
}
|
||||
|
||||
func testNewThemeWrittenAtCurrentSchemaVersion() {
|
||||
let theme = Theme(name: "Fresh")
|
||||
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
|
||||
}
|
||||
|
||||
func testContentWidthConstrainsInnerContentNotScrollContainer() {
|
||||
let defaults = UserDefaults.standard
|
||||
let previousEnabled = defaults.object(forKey: "contentWidthEnabled")
|
||||
let previousWidth = defaults.object(forKey: "contentWidthPx")
|
||||
defer {
|
||||
restore(previousEnabled, forKey: "contentWidthEnabled")
|
||||
restore(previousWidth, forKey: "contentWidthPx")
|
||||
}
|
||||
|
||||
let manager = ThemeManager()
|
||||
manager.contentWidthEnabled = true
|
||||
manager.contentWidthPx = 1230
|
||||
|
||||
let css = manager.generateCSS(for: Theme(name: "Content Width"))
|
||||
|
||||
XCTAssertTrue(css.contains("#content-inner { max-width: calc(1230px * var(--zoom)); margin: auto; }"))
|
||||
XCTAssertFalse(css.contains("#content { max-width:"))
|
||||
}
|
||||
|
||||
// MARK: - Round-trip
|
||||
|
||||
func testEncodesSchemaVersion() throws {
|
||||
let theme = Theme(name: "RoundTrip")
|
||||
let data = try JSONEncoder().encode(theme)
|
||||
let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let encoded = try XCTUnwrap(dict["schemaVersion"] as? Int)
|
||||
XCTAssertEqual(encoded, Theme.currentSchemaVersion)
|
||||
}
|
||||
|
||||
func testRoundTripPreservesAllFields() throws {
|
||||
let original = Theme(
|
||||
name: "Round Trip",
|
||||
colors: ThemeColors(background: "#ff0000", text: "#00ff00"),
|
||||
fonts: ThemeFonts(body: "Georgia", heading: "Helvetica", code: "Menlo"),
|
||||
sizes: ThemeSizes(baseFontSize: 16, lineHeight: 1.8),
|
||||
customCSS: "body { margin: 0; }"
|
||||
)
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(Theme.self, from: data)
|
||||
XCTAssertEqual(decoded.name, original.name)
|
||||
XCTAssertEqual(decoded.schemaVersion, original.schemaVersion)
|
||||
XCTAssertEqual(decoded.colors.background, original.colors.background)
|
||||
XCTAssertEqual(decoded.colors.text, original.colors.text)
|
||||
XCTAssertEqual(decoded.fonts.body, original.fonts.body)
|
||||
XCTAssertEqual(decoded.sizes.baseFontSize, original.sizes.baseFontSize)
|
||||
XCTAssertEqual(decoded.sizes.lineHeight, original.sizes.lineHeight)
|
||||
XCTAssertEqual(decoded.customCSS, original.customCSS)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func minimalThemeJSON(schemaVersion: Int?) -> Data {
|
||||
let dict = minimalThemeDict(schemaVersion: schemaVersion)
|
||||
return try! JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
private func minimalThemeDict(schemaVersion: Int?) -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"name": "Minimal",
|
||||
"colors": [
|
||||
"background": "#000000", "text": "#ffffff",
|
||||
"heading1": "#ffffff", "heading2": "#ffffff",
|
||||
"heading3": "#ffffff", "heading4": "#ffffff",
|
||||
"heading5": "#ffffff", "heading6": "#ffffff",
|
||||
"link": "#0000ff", "codeBackground": "#111111",
|
||||
"codeText": "#ffffff", "blockquoteBorder": "#888888",
|
||||
"blockquoteBackground": "#222222",
|
||||
"highlightBackground": "#ffff00", "highlightText": "#000000"
|
||||
],
|
||||
"fonts": ["body": "System", "heading": "System", "code": "SF Mono"],
|
||||
"sizes": [
|
||||
"baseFontSize": 14, "h1Size": 28, "h2Size": 23, "h3Size": 20,
|
||||
"h4Size": 18, "h5Size": 16, "h6Size": 15,
|
||||
"codeFontSize": 13, "lineHeight": 1.7
|
||||
],
|
||||
"customCSS": ""
|
||||
]
|
||||
if let v = schemaVersion {
|
||||
dict["schemaVersion"] = v
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private func restore(_ value: Any?, forKey key: String) {
|
||||
if let value {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
-7
@@ -14,8 +14,11 @@ packages:
|
||||
url: https://github.com/sparkle-project/Sparkle.git
|
||||
from: "2.0.0"
|
||||
|
||||
targets:
|
||||
markdownViewr:
|
||||
# Shared base for both distribution targets. The two targets differ ONLY in their
|
||||
# Sparkle dependency (Developer ID has it, App Store must not — Sparkle's helpers
|
||||
# can't be sandboxed), the sandbox entitlements, and the MAS_BUILD flag.
|
||||
targetTemplates:
|
||||
AppBase:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
@@ -25,17 +28,37 @@ 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.1.3"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
PRODUCT_NAME: markdownViewr
|
||||
MARKETING_VERSION: "1.5.0"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ENABLE_APP_SANDBOX: "NO"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
|
||||
targets:
|
||||
markdownViewrTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
sources:
|
||||
- markdownViewrTests
|
||||
dependencies:
|
||||
- target: markdownViewr
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewrTests
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
|
||||
# Developer ID distribution (notarized DMG + Sparkle auto-update). `just release`.
|
||||
markdownViewr:
|
||||
templates:
|
||||
- AppBase
|
||||
dependencies:
|
||||
- package: sparkle
|
||||
product: Sparkle
|
||||
info:
|
||||
path: markdownViewr/Info.plist
|
||||
properties:
|
||||
@@ -67,3 +90,50 @@ targets:
|
||||
LSApplicationCategoryType: "public.app-category.productivity"
|
||||
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
|
||||
# Mac App Store distribution (sandboxed, no Sparkle). `just release-mas`.
|
||||
# Consumes the Info.plist generated by the markdownViewr target above and strips
|
||||
# the (now-irrelevant) Sparkle feed keys from its own bundle copy.
|
||||
markdownViewr-MAS:
|
||||
templates:
|
||||
- AppBase
|
||||
settings:
|
||||
base:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD
|
||||
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
|
||||
postBuildScripts:
|
||||
- name: Strip Sparkle keys from MAS Info.plist
|
||||
script: |
|
||||
PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST" 2>/dev/null || true
|
||||
|
||||
schemes:
|
||||
markdownViewr:
|
||||
build:
|
||||
targets:
|
||||
markdownViewr: all
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- markdownViewrTests
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
markdownViewr-MAS:
|
||||
build:
|
||||
targets:
|
||||
markdownViewr-MAS: all
|
||||
run:
|
||||
config: Debug
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show App Store Connect build + version state for markdownViewr.
|
||||
|
||||
Credential-free: the API Key ID is auto-discovered from the AuthKey_<id>.p8
|
||||
filename in ~/.appstoreconnect/private_keys/, and the issuer id is read from
|
||||
~/.appstoreconnect/issuer_id (or the ASC_ISSUER_ID env var). No secrets live in
|
||||
this (public) repo. Stdlib only — ES256 JWT is signed via openssl.
|
||||
"""
|
||||
import json, base64, time, subprocess, urllib.request, urllib.error, os, glob, sys
|
||||
|
||||
BUNDLE_ID = "com.dkelkhoff.markdownViewr"
|
||||
KEYS_DIR = os.path.expanduser("~/.appstoreconnect/private_keys")
|
||||
|
||||
key_files = glob.glob(os.path.join(KEYS_DIR, "AuthKey_*.p8"))
|
||||
if not key_files:
|
||||
sys.exit(f"No AuthKey_*.p8 in {KEYS_DIR}")
|
||||
KEY_PATH = key_files[0]
|
||||
KEY_ID = os.path.basename(KEY_PATH)[len("AuthKey_"):-len(".p8")]
|
||||
ISSUER = os.environ.get("ASC_ISSUER_ID") or \
|
||||
open(os.path.expanduser("~/.appstoreconnect/issuer_id")).read().strip()
|
||||
|
||||
def b64url(b):
|
||||
return base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
|
||||
now = int(time.time())
|
||||
seg = b64url(json.dumps({"alg": "ES256", "kid": KEY_ID, "typ": "JWT"}, separators=(',', ':')).encode()) + b"." + \
|
||||
b64url(json.dumps({"iss": ISSUER, "iat": now, "exp": now + 1200, "aud": "appstoreconnect-v1"}, separators=(',', ':')).encode())
|
||||
der = subprocess.run(["openssl", "dgst", "-sha256", "-sign", KEY_PATH], input=seg, capture_output=True).stdout
|
||||
def read_int(d, i):
|
||||
ln = d[i+1]; return d[i+2:i+2+ln], i+2+ln
|
||||
i = 2 if der[1] < 0x80 else 2 + (der[1] & 0x7f)
|
||||
r, i = read_int(der, i); s, i = read_int(der, i)
|
||||
pad = lambda v: b'\x00' * (32 - len(v.lstrip(b'\x00'))) + v.lstrip(b'\x00')
|
||||
token = (seg + b"." + b64url(pad(r) + pad(s))).decode()
|
||||
|
||||
def get(url):
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
try:
|
||||
apps = get(f"https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]={BUNDLE_ID}")
|
||||
if not apps["data"]:
|
||||
sys.exit(f"No App Store Connect app found for bundle id {BUNDLE_ID}")
|
||||
app_id = apps["data"][0]["id"]
|
||||
print(f"App: {BUNDLE_ID} (id {app_id})\n")
|
||||
|
||||
b = get(f"https://api.appstoreconnect.apple.com/v1/builds?filter[app]={app_id}"
|
||||
"&limit=5&sort=-uploadedDate&include=preReleaseVersion,appStoreVersion")
|
||||
inc = {(x["type"], x["id"]): x for x in b.get("included", [])}
|
||||
print("=== Builds ===")
|
||||
for bd in b["data"]:
|
||||
at, rels = bd["attributes"], bd.get("relationships", {})
|
||||
pv = rels.get("preReleaseVersion", {}).get("data")
|
||||
train = inc.get((pv["type"], pv["id"]), {}).get("attributes", {}).get("version", "?") if pv else "-"
|
||||
asv = rels.get("appStoreVersion", {}).get("data")
|
||||
att = "not attached to a version"
|
||||
if asv:
|
||||
a = inc.get((asv["type"], asv["id"]), {}).get("attributes", {})
|
||||
att = f"attached to {a.get('versionString')} [{a.get('appStoreState')}]"
|
||||
enc = at.get('usesNonExemptEncryption')
|
||||
comp = {None: "NOT ANSWERED", True: "uses non-exempt encryption", False: "exempt (answered)"}.get(enc, enc)
|
||||
print(f" build {at.get('version')} train {train} state={at.get('processingState')} ({att})")
|
||||
print(f" export compliance: {comp}")
|
||||
|
||||
print("\n=== App Store versions ===")
|
||||
v = get(f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/appStoreVersions?limit=10")
|
||||
for vv in v["data"]:
|
||||
a = vv["attributes"]
|
||||
print(f" {a.get('versionString')} {a.get('platform')} [{a.get('appStoreState')}]")
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"HTTP {e.code}\n{e.read().decode()[:800]}")
|
||||
Reference in new issue
Block a user