41 Commits
Author SHA1 Message Date
Darin Kelkhoff a3d91f50f4 Add release notes page for 1.3.0 2026-06-19 15:42:23 -05:00
Darin Kelkhoff 88c983b51f release: update appcast for v1.3.0 2026-06-19 14:47:15 -05:00
Darin Kelkhoff 562462714a MARKETING_VERSION 1.3.0 2026-06-19 14:45:05 -05:00
Darin Kelkhoff 63cd94886c Update feature documentation 2026-06-19 14:44:16 -05:00
Darin Kelkhoff 50a9d2a01c Improve document panes and CSS help 2026-06-19 11:43:14 -05:00
Darin KelkhoffandClaude Opus 4.8 0bf40661bf feat: Markdown source pane + task-list checkboxes
Add a toggleable raw-Markdown source pane (toolbar button) shown beside the
rendered view in the WKWebView:
- Resizable divider with persisted width (@AppStorage), dynamic max width.
- Find spans both panes, pairing the k-th rendered hit with the k-th source hit
  so next/prev moves both.
- Header-anchored scroll-sync both directions (interpolated between headings).
- Option-click a rendered header to anchor/jump (both panes follow).
- Collapsing a section mirrors into the source with a collapsed-section ellipsis.
- Slides open/closed like the TOC (suppressed on load and during drag).

Also render GFM task lists ([ ]/[x]) as real disabled checkboxes, and document
the source-pane and TOC custom-CSS selectors in the shared CSS help (now
scrollable + selectable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:41 -05:00
Darin KelkhoffandClaude Opus 4.8 442a6ebae9 fix: user theme override of a built-in is no longer shadowed
Editing a built-in theme in place saves a same-named user file, but loadThemes
appended both the bundled and user copies, so the built-in (added first) shadowed
the override in every name-based lookup — the editor reopened the bundled copy
(losing edits/customCSS) and rendering used it too.

Drop the shadowed bundled copy when a user theme shares its name, and mark that
override built-in so the UI still shows it as one. deleteTheme now also removes
the override file (so deleting an edited built-in actually clears it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:40 -05:00
Darin KelkhoffandClaude Opus 4.8 6f595b9370 feat: TOC wrap toggle, depth markers, full-height divider, slide animation
- Settings → General toggle to wrap long TOC entries; default is no-wrap with
  ellipsis (hover shows full text via the title attribute).
- 'Depth markers' toggle prefixing each entry with a shape that varies by
  heading level (●○◆◇■□), with hanging-indent so wrapped lines align past
  the marker.
- Move the divider onto the full-height sticky resizer so it spans the whole
  window regardless of TOC content length.
- Animate TOC open/close (width/padding/opacity), suppressed on initial load
  and during drag-resize so only deliberate toggles animate.
- Fix the Settings TOC GroupBox not spanning full width.

New settings persist via @AppStorage and inject into the web view on load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:30:16 -05:00
Darin KelkhoffandClaude Opus 4.8 7f23bb9393 feat: resizable TOC panel with persisted width, visibility, and depth
Add a drag handle on the TOC's right edge (CSS/JS in template.html); on
drag-end the width is posted to Swift via a new tocWidth message handler and
saved to @AppStorage, then injected into every document on load. Also persist
tocVisible and tocDepth (previously per-window @State) so the whole TOC state
survives across documents and launches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:47:24 -05:00
Darin KelkhoffandClaude Opus 4.8 fd0df14e95 docs: correct inaccurate syntax-highlighting claim
The app does not tokenize/highlight code; code blocks are monospaced and
themed. Fix the claim in the README and landing page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:02:19 -05:00
Darin KelkhoffandClaude Opus 4.8 4edb7f3bd7 build: show export-compliance state in asc-builds output
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:18:55 -05:00
Darin KelkhoffandClaude Opus 4.8 82e12674b4 docs: add privacy policy page and link it from the landing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:04:52 -05:00
Darin KelkhoffandClaude Opus 4.8 df8eeab4ad build: add asc-builds recipe to check App Store Connect build state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:02:16 -05:00
Darin KelkhoffandClaude Opus 4.8 8d76a4bdca build: split App Store build into its own Sparkle-free target
A single target can't exclude Sparkle from the MAS build: SwiftPM auto-links
a declared package-product dependency regardless of #if guards, so the
framework is always embedded/linked. App Store rejects Sparkle's non-sandboxed
helpers (error 90296), and the framework can't be stripped post-build (the
binary links it -> dyld launch crash).

Replace the single target + Debug-MAS/Release-MAS configs with two app targets
sharing a targetTemplates base:
- markdownViewr (Developer ID, depends on Sparkle)
- markdownViewr-MAS (App Store, no Sparkle dependency, sandboxed, MAS_BUILD)

Both use plain Debug/Release. Update release-mas, test, and the test runner to
the new scheme names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:07:09 -05:00
Darin KelkhoffandClaude Opus 4.8 b07a9f87a1 build: fix upload-mas just --list description ordering
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:44:59 -05:00
Darin KelkhoffandClaude Opus 4.8 e870838a73 build: add upload-mas recipe for App Store Connect delivery
Auto-discovers the API Key ID from the AuthKey_<id>.p8 filename and reads
the issuer id from ~/.appstoreconnect/issuer_id (or ASC_ISSUER_ID), so no
credentials live in this public repo and no per-run input is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:44:40 -05:00
Darin KelkhoffandClaude Opus 4.8 f10dd1fa48 build: allow provisioning updates and use app-store-connect export method
-allowProvisioningUpdates lets xcodebuild create the Mac App Store
provisioning profile for com.dkelkhoff.markdownViewr on first export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:32:52 -05:00
Darin KelkhoffandClaude Opus 4.8 f1e96f2913 docs: add Apple-platform portability note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:11:24 -05:00
Darin KelkhoffandClaude Opus 4.8 27447e6ca1 feat: only prompt for folder access when the doc has local images
Decouple file-watching from the folder grant so live-reload works for
every document with no prompt; the bookmark is needed only for sibling
images. Show the grant banner solely when the document references a local
relative image (ImageInliner.containsLocalImage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:07:30 -05:00
Darin KelkhoffandClaude Opus 4.8 0822792868 docs: document Mac App Store dual-distribution build
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:51:22 -05:00
Darin KelkhoffandClaude Opus 4.8 a061eb3644 build: add App Store export options and release-mas recipe
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:47:52 -05:00
Darin KelkhoffandClaude Opus 4.8 03259ccf14 feat: scope WKWebView read access to temp dir in MAS build
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:24:07 -05:00
Darin KelkhoffandClaude Opus 4.8 ac930b116a feat: gate file-watching on folder access and add grant banner (MAS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:23:10 -05:00
Darin KelkhoffandClaude Opus 4.8 f5d5ea7bdc feat: add FolderAccessManager for sandboxed folder access
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:21:42 -05:00
Darin KelkhoffandClaude Opus 4.8 3edfd7bedb feat: add ImageInliner for sandbox-safe data-URL image embedding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:20:52 -05:00
Darin KelkhoffandClaude Opus 4.8 2ef3e0b32c build: compile Sparkle out of the Mac App Store build
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:19:31 -05:00
Darin KelkhoffandClaude Opus 4.8 03f883af6b build: add Mac App Store build configurations and entitlements
Use a post-build script to strip Sparkle Info.plist keys from the MAS
bundle; INFOPLIST_KEY_ injection does not work for non-Apple custom keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:17:33 -05:00
Darin KelkhoffandClaude Opus 4.8 4dbda768af docs: implementation plan for Mac App Store dual-distribution
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:12:28 -05:00
Darin KelkhoffandClaude Opus 4.8 3d0c030fed docs: design for Mac App Store dual-distribution
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:51:46 -05:00
Darin KelkhoffandClaude Sonnet 4.6 a86203bd8f release: delegate cask update to update-cask recipe
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:04:54 -05:00
Darin KelkhoffandClaude Sonnet 4.6 177d805d9e fix: use sed -i.bak for macOS compatibility in Homebrew cask update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:01:55 -05:00
Darin Kelkhoff 516946fec4 release: update appcast for v1.2.0 2026-06-09 13:58:19 -05:00
Darin KelkhoffandClaude Sonnet 4.6 2631fc5ff2 add theme import/export, schema versioning, migration, unit tests, Homebrew cask
- Theme import/export with conflict resolution (overwrite/keep both/skip)
- Theme schema versioning: schemaVersion field, migrateIfNeeded on load with write-back
- Unit tests for theme decoding, migration, and round-trip (markdownViewrTests target)
- Max content width setting in General tab (checkbox + slider, default 1000px enabled)
- Homebrew cask: brew install --cask darinkelkhoff/tap/markdownviewr
- Copy button replaces Duplicate in Themes settings
- Settings min-width bumped to 560px
- Help updated for new features; CLAUDE.md documents migration process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:55:34 -05:00
Darin KelkhoffandClaude Sonnet 4.6 f2109f49ba add MIT license and public README; move GitHub link inline on landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:43:28 -05:00
Darin Kelkhoff 000f2699cc release: update appcast for v1.1.6 2026-06-08 12:01:59 -05:00
Darin Kelkhoff 823b8f5ecc release 1.1.6 (fix version in about dialog) 2026-06-08 11:59:56 -05:00
Darin KelkhoffandClaude Sonnet 4.6 a0f1f88a4f release: run xcodegen generate before archiving
Ensures the .xcodeproj reflects the current project.yml (MARKETING_VERSION,
etc.) before the archive step, so the built app's version strings match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:59:02 -05:00
Darin Kelkhoff ca644efc4e release: update appcast for v1.1.5 2026-06-08 11:56:45 -05:00
Darin Kelkhoff be5995bb55 release 1.1.5 (adding dmg) 2026-06-08 11:54:51 -05:00
Darin KelkhoffandClaude Sonnet 4.6 3a3884155f release: derive CURRENT_PROJECT_VERSION from MARKETING_VERSION, fix DMG staging
Build number is computed as major*10000+minor*100+patch so Sparkle always
sees a strictly-increasing CFBundleVersion without a separate manual bump.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:54:27 -05:00
Darin Kelkhoff 97f398276a release: update appcast for v1.1.4 2026-06-08 11:38:07 -05:00
31 changed files with 3632 additions and 117 deletions

No files matched your search

+56
View File
@@ -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`)
+12
View File
@@ -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>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Darin Kelkhoff
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+61
View File
@@ -0,0 +1,61 @@
# markdownViewr
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
[![Download](https://img.shields.io/badge/Download-macOS-blue?style=flat-square&logo=apple)](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
![macOS 13+](https://img.shields.io/badge/macOS-13%2B-lightgrey?style=flat-square)
![Free](https://img.shields.io/badge/price-free-brightgreen?style=flat-square)
![markdownViewr screenshot](docs/screenshot.png)
## Download
**[Download for Mac](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)** — macOS 13 Ventura or later, free.
Open the DMG, drag markdownViewr to Applications, and you're done.
Or install via Homebrew:
```bash
brew install --cask darinkelkhoff/tap/markdownviewr
```
## Features
- **GitHub Flavored Markdown** — tables, task lists, strikethrough, fenced code blocks
- **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.
+20 -11
View File
@@ -6,20 +6,29 @@
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>1.1.3</title>
<pubDate>Mon, 08 Jun 2026 10:07:17 -0500</pubDate>
<sparkle:version>3</sparkle:version>
<sparkle:shortVersionString>1.1.3</sparkle:shortVersionString>
<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.1.3/markdownViewr-1.1.3.zip" length="4202338" type="application/octet-stream" sparkle:edSignature="5aZaZZhadc5Vf0b7q9NXFES0++6j72n5mzkwAUr4vtZcxLktwMoJ72kNuXtDnovcL0Rjol5U/+/8aLMQFvIIDQ=="/>
<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>
<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.2.0</title>
<pubDate>Tue, 09 Jun 2026 13:58:18 -0500</pubDate>
<sparkle:version>10200</sparkle:version>
<sparkle:shortVersionString>1.2.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.2/markdownViewr-1.1.2.zip" length="4202333" type="application/octet-stream" sparkle:edSignature="FZ19CRV6Ib9Dbpt7Nr5PK4tCcaLJ8rHPlBOr2eGLjV1GnJxBETSnCp0FCEiOsvgWqpgsOlJc/eEQHDbIK/8UBw=="/>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.2.0/markdownViewr-1.2.0.zip" length="4220100" type="application/octet-stream" sparkle:edSignature="To0Yh+F2nAaYfLCwd/nxOR+bMkEwTIS01T1YA0tJNto6VYXc5haNeOU+uX2e4nx3JAHvknGFKWQRunkS1aCSBw=="/>
</item>
<item>
<title>1.1.6</title>
<pubDate>Mon, 08 Jun 2026 12:01:59 -0500</pubDate>
<sparkle:version>10106</sparkle:version>
<sparkle:shortVersionString>1.1.6</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.6/markdownViewr-1.1.6.zip" length="4202331" type="application/octet-stream" sparkle:edSignature="PJHorFzsVMKacxkjrVMNfRYoUfoHrBvy0okZ7NO7+0fFSOqFEi50D0dMlog8DlCvOxeXEfkMXQoPjmugDArcCg=="/>
</item>
</channel>
</rss>
</rss>
+9 -3
View File
@@ -141,12 +141,16 @@
Download for Mac
</a>
<p class="req">macOS 13 Ventura or later &nbsp;·&nbsp; Free</p>
<p class="req">macOS 13 Ventura or later &nbsp;·&nbsp; Free &nbsp;·&nbsp; <a href="https://github.com/darinkelkhoff/markdownViewr" style="color:inherit">View on GitHub</a></p>
<p class="req" style="margin-top:8px">Also available via Homebrew: <code style="font-size:0.82rem">brew install --cask darinkelkhoff/tap/markdownviewr</code></p>
<div class="features">
<span class="feature">GitHub Flavored Markdown</span>
<span class="feature">Syntax-highlighted code blocks</span>
<span class="feature">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>
@@ -161,7 +165,9 @@
</div>
<footer>
<a href="https://github.com/darinkelkhoff/markdownViewr">View on GitHub</a>
<a href="privacy.html">Privacy Policy</a> &nbsp;·&nbsp;
<a href="https://github.com/darinkelkhoff/markdownViewr">GitHub</a>
</footer>
</body>
</html>
+167
View File
@@ -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 &nbsp;·&nbsp; 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&#8209;App&#8209;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> &nbsp;·&nbsp;
<a href="https://github.com/darinkelkhoff/markdownViewr">GitHub</a>
</footer>
</body>
</html>
+92
View File
@@ -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,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. `![](images/x.png)`).
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,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.
+102 -3
View File
@@ -17,6 +17,32 @@ run: kill build
open file: build
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
# Run the test suite
test:
xcodebuild test -project markdownViewr.xcodeproj -scheme 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:
xcodegen generate
@@ -30,6 +56,12 @@ 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."
@@ -38,14 +70,15 @@ release: kill
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" \
@@ -65,7 +98,11 @@ release: kill
ditto -c -k --keepParent "$APP" "$ZIP"
echo "==> Creating DMG..."
DMG="/tmp/markdownViewr.dmg"
DMG_STAGING="/tmp/markdownViewr-dmg-staging"
rm -f "$DMG"
rm -rf "$DMG_STAGING"
mkdir "$DMG_STAGING"
cp -R "$APP" "$DMG_STAGING/"
create-dmg \
--volname "markdownViewr" \
--window-size 660 400 \
@@ -74,7 +111,7 @@ release: kill
--hide-extension "markdownViewr.app" \
--app-drop-link 480 185 \
"$DMG" \
"$EXPORT/"
"$DMG_STAGING/"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$DMG" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
@@ -97,5 +134,67 @@ release: kill
git add appcast.xml
git commit -m "release: update appcast for v$VERSION"
git push origin main
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
View File
@@ -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 {
+71 -5
View File
@@ -28,20 +28,41 @@ struct ContentView: View {
@State private var missingEditorName = ""
@StateObject private var liveContent = LiveContent()
@StateObject private var findBar = FindBarController()
@StateObject private var folderAccess = FolderAccessManager()
@State private var renderedHTML = ""
@State private var tocVisible = false
@State private var tocDepth = 3
@State private var docNeedsImageAccess = false
@AppStorage("tocVisible") private var tocVisible = false
@AppStorage("tocDepth") private var tocDepth = 3
@AppStorage("tocWidth") private var tocWidth: Double = 220
@AppStorage("tocWrap") private var tocWrap = false
@AppStorage("tocBullets") private var tocBullets = false
@AppStorage("rawVisible") private var rawVisible = false
@AppStorage("rawWidth") private var rawWidth: Double = 400
private var currentMarkdown: String {
liveContent.rawMarkdown.isEmpty ? document.rawMarkdown : liveContent.rawMarkdown
}
private func rerender() {
renderedHTML = MarkdownDocument.convertToHTML(
let html = MarkdownDocument.convertToHTML(
currentMarkdown,
frontmatterMode: themeManager.frontmatterMode,
extensions: themeManager.markdownExtensions
)
#if MAS_BUILD
if folderAccess.hasAccess {
renderedHTML = ImageInliner.inlineLocalImages(in: html) { path in
folderAccess.imageData(forRelativePath: path)
}
docNeedsImageAccess = false
} else {
renderedHTML = html
// Only prompt for folder access when the document actually has local images.
docNeedsImageAccess = ImageInliner.containsLocalImage(in: html)
}
#else
renderedHTML = html
#endif
}
var body: some View {
@@ -49,13 +70,28 @@ struct ContentView: View {
if findBar.isVisible {
FindBarView(findBar: findBar)
}
if docNeedsImageAccess {
FolderAccessBanner {
folderAccess.requestAccess { granted in
if granted { rerender() }
}
}
}
MarkdownWebView(
html: renderedHTML,
themeCSS: themeManager.generateCSS(for: themeManager.activeTheme),
fileURL: fileURL,
findBar: findBar,
tocVisible: tocVisible,
tocDepth: tocDepth
tocDepth: tocDepth,
tocWidth: tocWidth,
tocWrap: tocWrap,
tocBullets: tocBullets,
rawSource: currentMarkdown,
rawVisible: rawVisible,
rawWidth: rawWidth,
onTocWidthChange: { tocWidth = $0 },
onRawWidthChange: { rawWidth = $0 }
)
}
.onReceive(themeManager.$frontmatterMode) { _ in
@@ -81,10 +117,13 @@ struct ContentView: View {
})
.onAppear {
liveContent.rawMarkdown = document.rawMarkdown
rerender()
if let fileURL {
// Watching the opened document does not require the folder grant —
// the document itself is accessible via the document architecture.
folderAccess.prepare(for: fileURL)
liveContent.startWatching(fileURL: fileURL)
}
rerender()
}
.onDisappear {
liveContent.fileWatcher = nil
@@ -92,6 +131,7 @@ struct ContentView: View {
.toolbar {
ToolbarItemGroup(placement: .automatic) {
tocControls
rawButton
zoomControls
palettePicker
editorButton
@@ -130,6 +170,15 @@ struct ContentView: View {
}
}
private var rawButton: some View {
Button {
rawVisible.toggle()
} label: {
Image(systemName: rawVisible ? "doc.plaintext.fill" : "doc.plaintext")
}
.help(rawVisible ? "Hide Markdown Source" : "Show Markdown Source")
}
private var zoomControls: some View {
HStack(spacing: 2) {
Button {
@@ -232,6 +281,23 @@ struct ContentView: View {
}
}
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))
}
}
struct FindBarView: View {
@ObservedObject var findBar: FindBarController
+100
View File
@@ -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
}
+192 -5
View File
@@ -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:
+76
View File
@@ -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"
}
}
}
+6 -1
View File
@@ -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 {
+6
View File
@@ -1,13 +1,17 @@
import SwiftUI
#if !MAS_BUILD
import Sparkle
#endif
@main
struct MarkdownViewrApp: App {
#if !MAS_BUILD
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
#endif
@StateObject private var themeManager = ThemeManager()
@StateObject private var editorManager = EditorManager()
@@ -23,12 +27,14 @@ struct MarkdownViewrApp: App {
}
.defaultSize(width: 700, height: 900)
.commands {
#if !MAS_BUILD
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterController.checkForUpdates(nil)
}
.disabled(!updaterController.updater.canCheckForUpdates)
}
#endif
CommandGroup(replacing: .appSettings) {
Button("Settings...") {
+124 -1
View File
@@ -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
@@ -50,6 +70,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,21 +116,32 @@ struct MarkdownWebView: NSViewRepresentable {
context.coordinator.lastHTML = html
context.coordinator.lastCSS = themeCSS
#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)
@@ -95,6 +150,12 @@ struct MarkdownWebView: NSViewRepresentable {
// Queue TOC state to apply after page loads
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 +189,22 @@ 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 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 +302,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 +331,21 @@ struct MarkdownWebView: NSViewRepresentable {
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
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 +356,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 }
}
}
}
+611 -58
View File
@@ -11,8 +11,32 @@
box-sizing: border-box;
}
::-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: color-mix(in srgb, var(--text) 45%, var(--bg));
}
::-webkit-scrollbar-corner {
background: var(--bg);
}
html {
height: 100%;
overflow: hidden;
}
body {
@@ -25,27 +49,104 @@ body {
-webkit-font-smoothing: antialiased;
display: flex;
align-items: flex-start;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
#toc {
position: sticky;
top: 0;
align-self: flex-start;
width: 220px;
min-width: 220px;
flex: 0 0 auto;
width: 0;
max-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
padding: 24px 12px 24px 16px;
border-right: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
padding: 24px 0;
font-size: 1em;
display: none;
opacity: 0;
transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
}
#toc.visible {
padding: 24px 12px 24px 16px;
opacity: 1;
}
#toc.no-transition {
transition: none;
}
.resizer {
position: sticky;
top: 0;
align-self: flex-start;
flex: 0 0 6px;
height: 100vh;
margin-left: -3px;
cursor: col-resize;
z-index: 5;
display: none;
}
.resizer.visible {
display: block;
}
.resizer::before {
content: "";
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 1px;
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.resizer:hover::before,
.resizer.dragging::before {
width: 2px;
background: color-mix(in srgb, var(--link) 45%, transparent);
}
#raw-source {
position: sticky;
top: 0;
align-self: flex-start;
flex: 0 0 auto;
width: 0;
max-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
opacity: 0;
transition: width 0.3s ease, opacity 0.3s ease;
}
#raw-source.visible {
opacity: 1;
}
#raw-source.no-transition {
transition: none;
}
#raw-source pre {
margin: 0;
padding: 32px 20px;
font-family: var(--code-font);
font-size: var(--code-font-size);
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
tab-size: 4;
border-radius: 0;
}
#raw-source .raw-ellipsis {
display: none;
opacity: 0.4;
}
#toc ul {
list-style: none;
padding: 0;
@@ -64,8 +165,44 @@ body {
padding: 2px 4px;
border-radius: 4px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#toc.wrap a {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
#toc.bullets a::before {
display: inline-block;
flex: 0 0 auto;
width: 1.8em;
margin-right: 1px;
font-size: 0.5em;
line-height: 1;
vertical-align: middle;
position: relative;
top: -0.15em;
text-align: center;
opacity: 0.6;
-webkit-text-stroke: 0.4px currentColor;
}
/* When wrapping is on, hang-indent wrapped lines so text aligns past the marker. */
#toc.wrap.bullets a {
display: flex;
align-items: baseline;
}
#toc.bullets .toc-h1::before { content: "\25CF"; } /* ● filled circle */
#toc.bullets .toc-h2::before { content: "\25CB"; } /* ○ hollow circle */
#toc.bullets .toc-h3::before { content: "\25C6"; } /* ◆ filled diamond */
#toc.bullets .toc-h4::before { content: "\25C7"; } /* ◇ hollow diamond */
#toc.bullets .toc-h5::before { content: "\25A0"; } /* ■ filled square */
#toc.bullets .toc-h6::before { content: "\25A1"; } /* □ hollow square */
#toc a:hover {
opacity: 1;
background: color-mix(in srgb, var(--text) 8%, transparent);
@@ -86,7 +223,12 @@ body {
#content {
flex: 1;
min-width: 0;
min-height: 100vh;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
}
#content-inner {
padding: 32px 48px;
}
@@ -186,6 +328,15 @@ li {
margin-bottom: 4px;
}
li.task-list-item {
list-style: none;
}
li.task-list-item > input[type="checkbox"] {
margin: 0 0.4em 0.15em -1.4em;
vertical-align: middle;
}
li > ul, li > ol {
margin-top: 4px;
margin-bottom: 4px;
@@ -263,34 +414,63 @@ mark {
</style>
</head>
<body>
<nav id="toc"></nav>
<nav id="toc" class="no-transition"></nav>
<div id="toc-resizer" class="resizer"></div>
<div id="content">
<div id="content-inner">
{{CONTENT}}
</div>
</div>
<div id="raw-resizer" class="resizer"></div>
<div id="raw-source" class="no-transition"><pre><code></code></pre></div>
<script>
function _contentScroller() {
return document.getElementById('content');
}
function _contentContainer() {
return document.getElementById('content-inner');
}
function updateThemeCSS(css) {
document.getElementById('theme-css').textContent = css;
}
function updateContent(html) {
var scrollRatio = document.documentElement.scrollTop /
Math.max(1, document.documentElement.scrollHeight - document.documentElement.clientHeight);
document.getElementById('content').innerHTML = html;
var newMax = document.documentElement.scrollHeight - document.documentElement.clientHeight;
document.documentElement.scrollTop = scrollRatio * newMax;
var content = _contentScroller();
var scrollRatio = content.scrollTop /
Math.max(1, content.scrollHeight - content.clientHeight);
_contentContainer().innerHTML = html;
var newMax = content.scrollHeight - content.clientHeight;
content.scrollTop = scrollRatio * newMax;
setupCollapsibleHeadings();
if (_tocVisible) buildTOC();
_rebuildSyncAnchors();
}
var _findMatches = [];
var _findPairs = [];
var _findIndex = -1;
var _allFindMarks = [];
function findAll(query) {
clearFind();
if (!query) return JSON.stringify({current: 0, total: 0});
function _findStatus() {
return JSON.stringify({ current: _findIndex + 1, total: _findPairs.length });
}
var content = document.getElementById('content');
var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, null);
function _styleMark(mark, active) {
if (active) {
mark.style.background = 'color-mix(in srgb, var(--text) 40%, transparent)';
mark.style.outline = '2px solid color-mix(in srgb, var(--link) 60%, transparent)';
} else {
mark.style.background = 'color-mix(in srgb, var(--text) 25%, transparent)';
mark.style.outline = 'none';
}
}
// Wrap every occurrence of query within root in a <mark>; returns the marks in order.
function _markMatchesIn(root, query) {
var marks = [];
if (!root) return marks;
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
var textNodes = [];
var node;
while (node = walker.nextNode()) textNodes.push(node);
@@ -298,8 +478,7 @@ function findAll(query) {
var lowerQuery = query.toLowerCase();
for (var i = 0; i < textNodes.length; i++) {
var n = textNodes[i];
var text = n.textContent;
var lower = text.toLowerCase();
var lower = n.textContent.toLowerCase();
var idx = 0;
while ((idx = lower.indexOf(lowerQuery, idx)) !== -1) {
var range = document.createRange();
@@ -307,66 +486,100 @@ function findAll(query) {
range.setEnd(n, idx + query.length);
var mark = document.createElement('mark');
mark.className = 'find-match';
mark.style.background = 'color-mix(in srgb, var(--text) 25%, transparent)';
mark.style.color = 'inherit';
mark.style.borderRadius = '2px';
range.surroundContents(mark);
_findMatches.push(mark);
// After surroundContents, the text node is split; re-walk from mark's next sibling
_styleMark(mark, false);
marks.push(mark);
_allFindMarks.push(mark);
n = mark.nextSibling;
if (!n) break;
lower = n.textContent.toLowerCase();
idx = 0;
}
}
return marks;
}
if (_findMatches.length > 0) {
function findAll(query) {
clearFind();
if (!query) return _findStatus();
var contentMarks = _markMatchesIn(document.getElementById('content'), query);
var rawMarks = _rawVisible ? _markMatchesIn(document.getElementById('raw-source'), query) : [];
// Pair the k-th rendered hit with the k-th source hit so next/prev moves both panes.
var n = Math.max(contentMarks.length, rawMarks.length);
for (var k = 0; k < n; k++) {
_findPairs.push({ c: contentMarks[k] || null, r: rawMarks[k] || null });
}
if (_findPairs.length > 0) {
_findIndex = 0;
highlightCurrent();
}
return JSON.stringify({current: _findIndex + 1, total: _findMatches.length});
return _findStatus();
}
function findNext() {
if (_findMatches.length === 0) return JSON.stringify({current: 0, total: 0});
if (_findPairs.length === 0) return _findStatus();
unhighlightCurrent();
_findIndex = (_findIndex + 1) % _findMatches.length;
_findIndex = (_findIndex + 1) % _findPairs.length;
highlightCurrent();
return JSON.stringify({current: _findIndex + 1, total: _findMatches.length});
return _findStatus();
}
function findPrev() {
if (_findMatches.length === 0) return JSON.stringify({current: 0, total: 0});
if (_findPairs.length === 0) return _findStatus();
unhighlightCurrent();
_findIndex = (_findIndex - 1 + _findMatches.length) % _findMatches.length;
_findIndex = (_findIndex - 1 + _findPairs.length) % _findPairs.length;
highlightCurrent();
return JSON.stringify({current: _findIndex + 1, total: _findMatches.length});
return _findStatus();
}
function highlightCurrent() {
if (_findIndex >= 0 && _findIndex < _findMatches.length) {
_findMatches[_findIndex].style.background = 'color-mix(in srgb, var(--text) 40%, transparent)';
_findMatches[_findIndex].style.outline = '2px solid color-mix(in srgb, var(--link) 60%, transparent)';
_findMatches[_findIndex].scrollIntoView({block: 'center', behavior: 'smooth'});
var pair = _findPairs[_findIndex];
if (!pair) return;
// Pause scroll-sync so find's explicit dual-scroll isn't overridden.
_findScrolling = true;
clearTimeout(_findScrollTimer);
_findScrollTimer = setTimeout(function() { _findScrolling = false; }, 600);
if (pair.c) {
_styleMark(pair.c, true);
pair.c.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
if (pair.r) {
_styleMark(pair.r, true);
_scrollRawCenter(pair.r);
}
}
function unhighlightCurrent() {
if (_findIndex >= 0 && _findIndex < _findMatches.length) {
_findMatches[_findIndex].style.background = 'color-mix(in srgb, var(--text) 25%, transparent)';
_findMatches[_findIndex].style.outline = 'none';
}
var pair = _findPairs[_findIndex];
if (!pair) return;
if (pair.c) _styleMark(pair.c, false);
if (pair.r) _styleMark(pair.r, false);
}
// Scroll only the raw pane (not the window) to center a mark within it.
function _scrollRawCenter(mark) {
var raw = document.getElementById('raw-source');
if (!raw) return;
var top = mark.getBoundingClientRect().top - raw.getBoundingClientRect().top + raw.scrollTop;
raw.scrollTo({ top: top - raw.clientHeight / 2, behavior: 'smooth' });
}
function clearFind() {
for (var i = 0; i < _findMatches.length; i++) {
var mark = _findMatches[i];
for (var i = 0; i < _allFindMarks.length; i++) {
var mark = _allFindMarks[i];
var parent = mark.parentNode;
if (!parent) continue;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
parent.normalize();
}
_findMatches = [];
_allFindMarks = [];
_findPairs = [];
_findIndex = -1;
}
@@ -391,6 +604,11 @@ function setupCollapsibleHeadings() {
function toggleCollapse(e) {
var h = e.currentTarget;
if (e.altKey) {
// Option-click: anchor/jump to this header (both panes follow via scroll-sync).
h.scrollIntoView({ block: 'start', behavior: 'smooth' });
return;
}
var level = headingLevel(h);
var collapsed = h.classList.toggle('collapsed');
var sibling = h.nextElementSibling;
@@ -424,10 +642,26 @@ function toggleCollapse(e) {
}
sibling = sibling.nextElementSibling;
}
applyRawCollapse();
}
var _tocMaxDepth = 3;
var _tocVisible = false;
var _tocWidth = 220;
var _tocWrap = false;
var _tocBullets = false;
var _rawVisible = false;
var _rawWidth = 400;
var _syncContentHeadings = [];
var _syncRawHeadings = [];
var _suppressContentSync = false;
var _suppressRawSync = false;
var _supContentTimer = null;
var _supRawTimer = null;
var _findScrolling = false;
var _findScrollTimer = null;
var _syncRafC = null;
var _syncRafR = null;
function buildTOC() {
var toc = document.getElementById('toc');
@@ -471,45 +705,361 @@ function buildTOC() {
function setTOCVisible(visible) {
_tocVisible = visible;
var toc = document.getElementById('toc');
var resizer = document.getElementById('toc-resizer');
if (visible) {
toc.classList.add('visible');
buildTOC();
setupTOCScrollHighlight();
toc.classList.add('visible');
toc.style.width = _tocWidth + 'px';
if (resizer) resizer.classList.add('visible');
} else {
toc.classList.remove('visible');
toc.style.width = '0px';
// Keep the divider through the collapse animation, then hide it.
if (resizer) {
setTimeout(function() {
if (!_tocVisible) resizer.classList.remove('visible');
}, 300);
}
}
}
// Removes the no-transition guard after initial state is applied, so the first
// document render doesn't animate but later toggles do.
function enableTOCTransitions() {
requestAnimationFrame(function() {
document.getElementById('toc').classList.remove('no-transition');
var raw = document.getElementById('raw-source');
if (raw) raw.classList.remove('no-transition');
});
}
function setTOCDepth(depth) {
_tocMaxDepth = depth;
if (_tocVisible) buildTOC();
}
function setTOCWidth(px) {
_tocWidth = Math.max(150, Math.min(500, px));
if (_tocVisible) document.getElementById('toc').style.width = _tocWidth + 'px';
}
function setTOCWrap(wrap) {
_tocWrap = wrap;
var toc = document.getElementById('toc');
if (wrap) toc.classList.add('wrap'); else toc.classList.remove('wrap');
}
function setTOCBullets(on) {
_tocBullets = on;
var toc = document.getElementById('toc');
if (on) toc.classList.add('bullets'); else toc.classList.remove('bullets');
}
function setRawVisible(visible) {
_rawVisible = visible;
var raw = document.getElementById('raw-source');
var resizer = document.getElementById('raw-resizer');
if (visible) {
raw.classList.add('visible');
raw.style.width = _rawWidth + 'px';
if (resizer) resizer.classList.add('visible');
_rebuildSyncAnchors();
applyRawCollapse();
syncRawFromContent();
} else {
raw.classList.remove('visible');
raw.style.width = '0px';
// Keep the divider through the collapse animation, then hide it.
if (resizer) {
setTimeout(function() {
if (!_rawVisible) resizer.classList.remove('visible');
}, 300);
}
}
}
function setRawSource(text) {
var code = document.querySelector('#raw-source code');
if (!code) return;
code.textContent = '';
var lines = text.split('\n');
var inFence = false, fenceChar = '', fenceLen = 0;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var stripped = line.replace(/^[ \t]+/, '');
var fm = stripped.match(/^(`{3,}|~{3,})/);
var hl = 0;
if (!inFence && fm) {
inFence = true; fenceChar = fm[1].charAt(0); fenceLen = fm[1].length;
} else if (inFence && fm && fm[1].charAt(0) === fenceChar && fm[1].length >= fenceLen && stripped.slice(fm[1].length).trim() === '') {
inFence = false;
} else if (!inFence) {
var hm = stripped.match(/^(#{1,6})\s/);
if (hm) hl = hm[1].length;
}
var span = document.createElement('span');
span.className = hl ? 'raw-line raw-h' : 'raw-line';
if (hl) span.setAttribute('data-level', hl);
span.textContent = line + (i < lines.length - 1 ? '\n' : '');
code.appendChild(span);
if (hl) {
var ell = document.createElement('span');
ell.className = 'raw-ellipsis';
ell.textContent = '⋯\n';
code.appendChild(ell);
}
}
_rebuildSyncAnchors();
applyRawCollapse();
}
// Mirror the rendered collapse state into the source pane: hide each collapsed
// section's body lines and reveal its ellipsis placeholder.
function applyRawCollapse() {
var code = document.querySelector('#raw-source code');
if (!code) return;
var lines = code.querySelectorAll('.raw-line');
var headingIdx = -1;
var hideUntil = 0;
for (var i = 0; i < lines.length; i++) {
var el = lines[i];
var isHeading = el.classList.contains('raw-h');
var ellipsis = (isHeading && el.nextElementSibling && el.nextElementSibling.classList.contains('raw-ellipsis'))
? el.nextElementSibling : null;
if (isHeading) {
headingIdx++;
var level = parseInt(el.getAttribute('data-level'), 10) || 1;
if (hideUntil > 0 && level <= hideUntil) hideUntil = 0;
if (hideUntil > 0) {
el.style.display = 'none';
if (ellipsis) ellipsis.style.display = 'none';
} else {
el.style.display = '';
var rendered = _syncContentHeadings[headingIdx];
if (rendered && rendered.classList.contains('collapsed')) {
if (ellipsis) ellipsis.style.display = 'inline';
hideUntil = level;
} else if (ellipsis) {
ellipsis.style.display = 'none';
}
}
} else {
el.style.display = (hideUntil > 0) ? 'none' : '';
}
}
}
function setRawWidth(px) {
// Cap at the viewport minus a reserve so the rendered pane never fully disappears.
var maxW = Math.max(220, document.documentElement.clientWidth - 200);
_rawWidth = Math.max(220, Math.min(maxW, px));
if (_rawVisible) document.getElementById('raw-source').style.width = _rawWidth + 'px';
}
// Drag-to-resize the raw-source pane (anchored to the right edge).
(function() {
var resizer = document.getElementById('raw-resizer');
var raw = document.getElementById('raw-source');
if (!resizer || !raw) return;
var dragging = false;
resizer.addEventListener('mousedown', function(e) {
dragging = true;
resizer.classList.add('dragging');
raw.classList.add('no-transition');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!dragging) return;
setRawWidth(document.documentElement.clientWidth - e.clientX);
});
document.addEventListener('mouseup', function() {
if (!dragging) return;
dragging = false;
resizer.classList.remove('dragging');
raw.classList.remove('no-transition');
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.rawWidth) {
window.webkit.messageHandlers.rawWidth.postMessage(_rawWidth);
}
});
})();
// ---- Header-anchored scroll-sync between the rendered view and the raw pane ----
function _rebuildSyncAnchors() {
_syncContentHeadings = Array.prototype.slice.call(
document.querySelectorAll('#content h1, #content h2, #content h3, #content h4, #content h5, #content h6'));
_syncRawHeadings = Array.prototype.slice.call(
document.querySelectorAll('#raw-source .raw-h'));
}
function syncRawFromContent() {
if (!_rawVisible || _findScrolling) return;
var ch = _syncContentHeadings, rh = _syncRawHeadings;
var n = Math.min(ch.length, rh.length);
if (n === 0) return;
var content = _contentScroller();
var contentTopVp = content.getBoundingClientRect().top, contentScroll = content.scrollTop;
var raw = document.getElementById('raw-source');
var rawTopVp = raw.getBoundingClientRect().top, rawScroll = raw.scrollTop;
var y = contentScroll;
function cTop(i) { return ch[i].getBoundingClientRect().top - contentTopVp + contentScroll; }
function rTop(i) { return rh[i].getBoundingClientRect().top - rawTopVp + rawScroll; }
var k = -1;
for (var i = 0; i < n; i++) { if (cTop(i) <= y + 4) k = i; else break; }
var target;
if (k < 0) target = 0;
else if (k >= n - 1) target = rTop(k);
else {
var c0 = cTop(k), c1 = cTop(k + 1);
var frac = (y - c0) / Math.max(1, c1 - c0);
target = rTop(k) + frac * (rTop(k + 1) - rTop(k));
}
_suppressRawSync = true;
clearTimeout(_supRawTimer);
_supRawTimer = setTimeout(function() { _suppressRawSync = false; }, 120);
raw.scrollTop = target;
}
function syncContentFromRaw() {
if (!_rawVisible || _findScrolling) return;
var ch = _syncContentHeadings, rh = _syncRawHeadings;
var n = Math.min(ch.length, rh.length);
if (n === 0) return;
var content = _contentScroller();
var contentTopVp = content.getBoundingClientRect().top, contentScroll = content.scrollTop;
var raw = document.getElementById('raw-source');
var rawTopVp = raw.getBoundingClientRect().top, rawScroll = raw.scrollTop;
var ry = rawScroll;
function cTop(i) { return ch[i].getBoundingClientRect().top - contentTopVp + contentScroll; }
function rTop(i) { return rh[i].getBoundingClientRect().top - rawTopVp + rawScroll; }
var k = -1;
for (var i = 0; i < n; i++) { if (rTop(i) <= ry + 4) k = i; else break; }
var target;
if (k < 0) target = 0;
else if (k >= n - 1) target = cTop(k);
else {
var r0 = rTop(k), r1 = rTop(k + 1);
var frac = (ry - r0) / Math.max(1, r1 - r0);
target = cTop(k) + frac * (cTop(k + 1) - cTop(k));
}
_suppressContentSync = true;
clearTimeout(_supContentTimer);
_supContentTimer = setTimeout(function() { _suppressContentSync = false; }, 120);
content.scrollTop = target;
}
var content = _contentScroller();
content.addEventListener('scroll', function() {
if (!_rawVisible || _findScrolling) return;
if (_suppressContentSync) { _suppressContentSync = false; return; }
if (_syncRafC) return;
_syncRafC = requestAnimationFrame(function() { _syncRafC = null; syncRawFromContent(); });
});
(function() {
var raw = document.getElementById('raw-source');
if (!raw) return;
raw.addEventListener('scroll', function() {
if (!_rawVisible || _findScrolling) return;
if (_suppressRawSync) { _suppressRawSync = false; return; }
if (_syncRafR) return;
_syncRafR = requestAnimationFrame(function() { _syncRafR = null; syncContentFromRaw(); });
});
})();
// Drag-to-resize the TOC; reports the final width back to the app to persist.
(function() {
var resizer = document.getElementById('toc-resizer');
var toc = document.getElementById('toc');
if (!resizer || !toc) return;
var dragging = false;
resizer.addEventListener('mousedown', function(e) {
dragging = true;
resizer.classList.add('dragging');
toc.classList.add('no-transition');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!dragging) return;
setTOCWidth(e.clientX - toc.getBoundingClientRect().left);
});
document.addEventListener('mouseup', function() {
if (!dragging) return;
dragging = false;
resizer.classList.remove('dragging');
toc.classList.remove('no-transition');
document.body.style.userSelect = '';
document.body.style.cursor = '';
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.tocWidth) {
window.webkit.messageHandlers.tocWidth.postMessage(_tocWidth);
}
});
})();
function setupTOCScrollHighlight() {
// Debounced scroll handler to highlight active TOC entry
var timeout;
window.addEventListener('scroll', function() {
var content = _contentScroller();
content.addEventListener('scroll', function() {
clearTimeout(timeout);
timeout = setTimeout(highlightActiveTOC, 50);
});
}
function _scrollTOCLinkIntoView(link) {
var toc = document.getElementById('toc');
var tocFollowPadding = 24;
var linkRect = link.getBoundingClientRect();
var tocRect = toc.getBoundingClientRect();
var activeTop = linkRect.top;
var activeBottom = linkRect.bottom;
var tocTop = tocRect.top;
var tocBottom = tocRect.bottom;
var targetScrollTop = toc.scrollTop;
if (activeTop < tocTop + tocFollowPadding) {
targetScrollTop = toc.scrollTop + activeTop - tocTop - tocFollowPadding;
} else if (activeBottom > tocBottom - tocFollowPadding) {
targetScrollTop = toc.scrollTop + activeBottom - tocBottom + tocFollowPadding;
} else {
return;
}
targetScrollTop = Math.max(0, Math.min(targetScrollTop, toc.scrollHeight - toc.clientHeight));
toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
function highlightActiveTOC() {
var toc = document.getElementById('toc');
if (!toc.classList.contains('visible')) return;
var content = _contentScroller();
var links = toc.querySelectorAll('a');
var scrollTop = window.scrollY || document.documentElement.scrollTop;
var scrollTop = content.scrollTop;
var contentTopVp = content.getBoundingClientRect().top;
var activeLink = null;
for (var i = 0; i < links.length; i++) {
var target = document.getElementById(links[i].dataset.headingId);
if (target && target.offsetTop <= scrollTop + 60) {
var targetTop = target
? target.getBoundingClientRect().top - contentTopVp + scrollTop
: Infinity;
if (targetTop <= scrollTop + 60) {
activeLink = links[i];
}
links[i].classList.remove('active');
}
if (activeLink) activeLink.classList.add('active');
if (activeLink) {
activeLink.classList.add('active');
_scrollTOCLinkIntoView(activeLink);
}
}
document.addEventListener('DOMContentLoaded', function() {
@@ -547,7 +1097,8 @@ function _vimJumpHeading(dir) {
for (var i = 0; i < all.length; i++) {
if (all[i].offsetParent !== null) visible.push(all[i]);
}
var y = window.scrollY;
var content = _contentScroller();
var y = content.scrollTop;
var threshold = 2;
var target = null;
if (dir > 0) {
@@ -578,7 +1129,7 @@ window.addEventListener('keydown', function(e) {
if (_vimPending === 'g' && key === 'g' && !ctrl) {
_vimClearPending();
window.scrollTo({ top: 0, behavior: 'smooth' });
_contentScroller().scrollTo({ top: 0, behavior: 'smooth' });
e.preventDefault();
return;
}
@@ -597,25 +1148,27 @@ window.addEventListener('keydown', function(e) {
if (_vimPending) _vimClearPending();
if (ctrl) {
var vh = window.innerHeight;
if (key === 'd') { window.scrollBy({ top: vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
if (key === 'u') { window.scrollBy({ top: -vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
if (key === 'f') { window.scrollBy({ top: vh, behavior: 'smooth' }); e.preventDefault(); return; }
if (key === 'b') { window.scrollBy({ top: -vh, behavior: 'smooth' }); e.preventDefault(); return; }
var content = _contentScroller();
var vh = content.clientHeight;
if (key === 'd') { content.scrollBy({ top: vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
if (key === 'u') { content.scrollBy({ top: -vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
if (key === 'f') { content.scrollBy({ top: vh, behavior: 'smooth' }); e.preventDefault(); return; }
if (key === 'b') { content.scrollBy({ top: -vh, behavior: 'smooth' }); e.preventDefault(); return; }
return;
}
switch (key) {
case 'j':
window.scrollBy({ top: _vimLineHeight(), behavior: 'smooth' });
_contentScroller().scrollBy({ top: _vimLineHeight(), behavior: 'smooth' });
e.preventDefault();
break;
case 'k':
window.scrollBy({ top: -_vimLineHeight(), behavior: 'smooth' });
_contentScroller().scrollBy({ top: -_vimLineHeight(), behavior: 'smooth' });
e.preventDefault();
break;
case 'G':
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
var content = _contentScroller();
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' });
e.preventDefault();
break;
case 'g':
+114 -2
View File
@@ -24,12 +24,14 @@ struct SettingsView: View {
Label("Markdown", systemImage: "doc.plaintext")
}
}
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
}
struct GeneralSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
@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
@@ -86,6 +88,49 @@ 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) {
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 {
@@ -456,7 +501,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 +541,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 +590,64 @@ struct ThemeSettingsView: View {
}
}
}
private func importThemes() {
let panel = NSOpenPanel()
panel.title = "Import Themes"
panel.allowedContentTypes = [UTType.json]
panel.allowsMultipleSelection = true
panel.canChooseFiles = true
panel.canChooseDirectories = false
guard panel.runModal() == .OK else { return }
for url in panel.urls {
guard let data = try? Data(contentsOf: url),
let theme = try? JSONDecoder().decode(Theme.self, from: data)
else { continue }
if theme.schemaVersion > Theme.currentSchemaVersion {
let alert = NSAlert()
alert.messageText = "\"\(theme.name)\" was made with a newer version of markdownViewr"
alert.informativeText = "It may not display correctly. Import anyway?"
alert.addButton(withTitle: "Import")
alert.addButton(withTitle: "Skip")
if alert.runModal() != .alertFirstButtonReturn { continue }
}
let conflict = themeManager.allThemes.contains { $0.name == theme.name }
if conflict {
let alert = NSAlert()
alert.messageText = "\"\(theme.name)\" already exists"
alert.informativeText = "Choose how to handle the imported copy."
alert.addButton(withTitle: "Overwrite")
alert.addButton(withTitle: "Keep Both")
alert.addButton(withTitle: "Skip")
let resolution: ThemeManager.ImportConflictResolution
switch alert.runModal() {
case .alertFirstButtonReturn: resolution = .overwrite
case .alertSecondButtonReturn: resolution = .keepBoth
default: resolution = .skip
}
themeManager.importTheme(theme, resolution: resolution)
} else {
themeManager.importTheme(theme)
}
}
}
private func exportTheme(_ theme: Theme) {
let panel = NSSavePanel()
panel.title = "Export Theme"
panel.nameFieldStringValue = theme.name
.replacingOccurrences(of: " ", with: "-")
.lowercased()
.appending(".json")
panel.allowedContentTypes = [UTType.json]
guard panel.runModal() == .OK, let url = panel.url else { return }
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(theme) {
try? data.write(to: url)
}
}
}
class ThemeEditorWindowController: NSObject, NSWindowDelegate {
+15 -1
View File
@@ -2,8 +2,11 @@ import Foundation
import SwiftUI
struct Theme: Codable, Identifiable, Hashable {
static let currentSchemaVersion = 1
var id: String { name }
var schemaVersion: Int = 1
var name: String
var colors: ThemeColors
var fonts: ThemeFonts
@@ -14,7 +17,7 @@ struct Theme: Codable, Identifiable, Hashable {
var isBuiltIn: Bool = false
enum CodingKeys: String, CodingKey {
case name, colors, fonts, sizes, customCSS
case schemaVersion, name, colors, fonts, sizes, customCSS
}
init(
@@ -25,6 +28,7 @@ struct Theme: Codable, Identifiable, Hashable {
customCSS: String = "",
isBuiltIn: Bool = false
) {
self.schemaVersion = Self.currentSchemaVersion
self.name = name
self.colors = colors
self.fonts = fonts
@@ -32,6 +36,16 @@ struct Theme: Codable, Identifiable, Hashable {
self.customCSS = customCSS
self.isBuiltIn = isBuiltIn
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
name = try c.decode(String.self, forKey: .name)
colors = try c.decode(ThemeColors.self, forKey: .colors)
fonts = try c.decode(ThemeFonts.self, forKey: .fonts)
sizes = try c.decode(ThemeSizes.self, forKey: .sizes)
customCSS = try c.decodeIfPresent(String.self, forKey: .customCSS) ?? ""
}
}
struct ThemeColors: Codable, Hashable {
+79 -8
View File
@@ -30,6 +30,12 @@ class ThemeManager: ObservableObject {
}
}
}
@Published var contentWidthEnabled: Bool {
didSet { UserDefaults.standard.set(contentWidthEnabled, forKey: "contentWidthEnabled") }
}
@Published var contentWidthPx: Double {
didSet { UserDefaults.standard.set(contentWidthPx, forKey: "contentWidthPx") }
}
var activeTheme: Theme {
themes.first { $0.name == activeThemeName } ?? themes.first ?? Self.fallbackTheme
@@ -50,6 +56,11 @@ class ThemeManager: ObservableObject {
} else {
self.markdownExtensions = MarkdownExtensions()
}
self.contentWidthEnabled = UserDefaults.standard.object(forKey: "contentWidthEnabled") != nil
? UserDefaults.standard.bool(forKey: "contentWidthEnabled")
: true
let savedWidth = UserDefaults.standard.double(forKey: "contentWidthPx")
self.contentWidthPx = savedWidth > 0 ? savedWidth : 1000
loadThemes()
}
@@ -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)
@@ -338,6 +405,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,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,95 @@
import XCTest
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 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"))
}
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)
}
}
}
+76 -6
View File
@@ -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.4"
PRODUCT_NAME: markdownViewr
MARKETING_VERSION: "1.3.0"
CURRENT_PROJECT_VERSION: "3"
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
+71
View File
@@ -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]}")