89 Commits
Author SHA1 Message Date
james.maes 466a1e655c Merge pull request 'ci: shared ci-workflows macOS workflow; fix the Xcode 27 Document ambiguity (blocked: runner has no GUI session)' (#3) from ci/shared-macos into main
macos / macos (push) Successful in 14s
build / macos (push) Successful in 14s
2026-11-08 23:28:56 +00:00
James MaesandClaude Opus 5.5 be68c9f1bb ci: build only on CI; run the app-hosted tests locally
macos / macos (push) Successful in 16s
build / macos (push) Successful in 16s
The grogu runner has no GUI session, so xcodebuild test cannot reach
testmanagerd. CI compiles the app and the test bundle unsigned
(build-for-testing); the README documents running just test locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:24:40 -06:00
James MaesandClaude Opus 5.5 77b9274434 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:46:19 -06:00
James MaesandClaude Opus 5.5 ab3731e0df fix: qualify Markdown.Document for the Xcode 27 SDK
macos / macos (push) Failing after 14s
build / macos (push) Failing after 14s
SwiftUI now declares a Document protocol, so the unqualified name in the
plain-text visitor is ambiguous and the build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:37:16 -06:00
James MaesandClaude Opus 5.5 4536db3f09 ci: build on the shared ci-workflows macOS workflow
macos / macos (push) Failing after 16s
build / macos (push) Failing after 16s
Runs the repository's build (and tests where it has them) on the KOF macOS
host runner through ci-workflows macos.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:34:06 -06:00
james.maes 2bfdf91f77 Merge pull request #1 from KofTwentyTwo/docs/fork-notice
docs: mark this repository as a contribution fork of the upstream app
2026-10-13 06:26:49 -05:00
james.maesandClaude Opus 5.5 6e25ec7963 docs: mark this repository as a contribution fork of the upstream app
The README was the upstream README word for word, so it read as if
KofTwentyTwo shipped the macOS app. Every download, cask and update link
points at darinkelkhoff/markdownViewr, and this fork publishes no
releases. Say so, and point to the upstream printing PRs (#2 merged,
#3 open) and to the Windows port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:23:23 -05:00
james.maes 1ab16bf4ed feat(printing): add print font scaling options and document-proportional clean print theme sizing 2026-07-24 08:28:58 -05:00
james.maes 0d1133aafd test(printing): add unit test validating every theme option generates non-empty CSS and script injection 2026-07-24 08:26:49 -05:00
james.maes fbb313ccb9 test(printing): expand test assertions for all heading sizes, colors, blockquotes, and highlights 2026-07-24 08:26:15 -05:00
james.maes 53a6ae7896 fix(printing): enforce theme fonts, sizes, and colors in print media queries 2026-07-24 08:23:49 -05:00
Darin Kelkhoff 76ed62618c release: add 1.5.0 release notes link to appcast 2026-07-23 08:35:09 -05:00
Darin Kelkhoff cb29d64a1d release: update appcast for v1.5.0 2026-07-23 08:33:56 -05:00
Darin Kelkhoff cde9351f11 Prepare release 1.5.0 2026-07-23 08:31:48 -05:00
Darin Kelkhoff ad2cc7e55f Merge pull request #2 from KofTwentyTwo/feature/mac-printing
feat: Add native macOS printing and custom printing configuration preferences
2026-07-23 08:22:17 -05:00
Darin Kelkhoff 05d65cf0c7 Clean up printing PR whitespace 2026-07-23 08:13:50 -05:00
james.maes ff7b942d46 fix(printing): update test assertions for clean print theme and space item 2026-07-22 12:05:47 -05:00
james.maes 01ff200f8d merge: incorporate native toolbar space item from fix/toolbar-spacer 2026-07-22 12:05:02 -05:00
james.maes c9e8867ec3 fix(printing): use built-in Theme.cleanPrinting for clean print option 2026-07-22 12:04:37 -05:00
Darin Kelkhoff 2c2f153299 Polish printing toolbar integration 2026-07-22 10:00:18 -05:00
Darin Kelkhoff f6f4ac723c Use native toolbar space item 2026-07-22 08:46:00 -05:00
Darin Kelkhoff 484d9af0ea Disable frame autosave for tabbed document windows 2026-07-22 08:26:57 -05:00
james.maes 87f8318f4d feat: Add native macOS printing and custom printing configuration preferences 2026-07-21 16:21:56 -05:00
james.maes 4199c8b375 fix: Remove label and border container from fixed space toolbar item 2026-07-21 16:21:47 -05:00
Darin Kelkhoff 6d9b5a32f5 Improve TOC hover and document navigation 2026-07-17 09:59:20 -05:00
Darin Kelkhoff baac7e71a8 fix toolbar persistence, menu pruning, and tabbed window frame handling 2026-07-13 14:45:56 -05:00
Darin Kelkhoff 1abc51eb25 release: add appcast release notes for v1.4.0 2026-06-26 09:00:55 -05:00
Darin Kelkhoff 91985c3fbc release: update appcast for v1.4.0 2026-06-26 09:00:06 -05:00
Darin Kelkhoff 39a08ae42e Prepare release 1.4.0 2026-06-26 08:57:44 -05:00
Darin Kelkhoff 8373722174 Make theme selection per-window 2026-06-26 08:43:04 -05:00
Darin Kelkhoff e06facc915 Refine document window and toolbar behavior 2026-06-25 13:10:33 -05:00
Darin Kelkhoff 71d71df7dd Add customizable document toolbar 2026-06-22 15:51:59 -05:00
Darin Kelkhoff 842d6bf025 Add native toolbar customization 2026-06-22 12:40:59 -05:00
Darin Kelkhoff 28458b0892 Polish menus and toolbar controls 2026-06-22 11:38:34 -05:00
Darin Kelkhoff ad76a0c8e1 Prune editing commands from File menu 2026-06-19 16:40:24 -05:00
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
Darin Kelkhoff f60bb0aa53 release 1.1.4 2026-06-08 11:29:14 -05:00
Darin Kelkhoff 654a134753 widen hero and features max-width to 1200px 2026-06-08 11:28:28 -05:00
Darin Kelkhoff e8ad868277 add .nojekyll to prevent Jekyll from interfering with static assets 2026-06-08 11:27:12 -05:00
Darin KelkhoffandClaude Sonnet 4.6 3fbbe57c6f add app icon and screenshot to landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:25:41 -05:00
Darin KelkhoffandClaude Sonnet 4.6 6e71826787 add DMG creation to release recipe and GitHub Pages landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:23:47 -05:00
Darin KelkhoffandClaude Sonnet 4.6 e2a6aeafb2 guard release recipe against re-releasing an already-published version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:10:39 -05:00
Darin Kelkhoff 2ed08c1e86 release: update appcast for v1.1.3 2026-06-08 10:07:18 -05:00
Darin KelkhoffandClaude Sonnet 4.6 e09b6713cc bump to 1.1.3; set CURRENT_PROJECT_VERSION=3 so Sparkle detects updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:54:00 -05:00
Darin Kelkhoff a793a26977 release: update appcast for v1.1.2 2026-06-08 09:50:34 -05:00
Darin KelkhoffandClaude Sonnet 4.6 9efc6b1518 bump to 1.1.2; fix CFBundleShortVersionString in project.yml to survive xcodegen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:48:53 -05:00
Darin KelkhoffandClaude Sonnet 4.6 d57ca37e86 fix CFBundleShortVersionString to use MARKETING_VERSION build setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:47:41 -05:00
Darin KelkhoffandClaude Sonnet 4.6 b451f36d71 add LSApplicationCategoryType to suppress archive warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:46:27 -05:00
Darin Kelkhoff d0e74cc99a release: update appcast for v1.1.1 2026-06-08 09:39:31 -05:00
45 changed files with 8036 additions and 281 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
macos:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/macos.yaml@v1
secrets: inherit
with:
# Build only: the app and the test bundle are compiled unsigned, the tests are not run. They are
# app-hosted XCTests and the grogu runner has no GUI session for testmanagerd; run `just test`
# locally before merging (README, Building from Source). Package.swift has no test target.
commands: |
xcodegen generate
xcodebuild -quiet -project markdownViewr.xcodeproj -scheme markdownViewr -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build-for-testing
+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.
+72
View File
@@ -0,0 +1,72 @@
# markdownViewr
> **This is KofTwentyTwo's contribution fork of [darinkelkhoff/markdownViewr](https://github.com/darinkelkhoff/markdownViewr).** It publishes no builds. The download, Homebrew cask and Sparkle updates below all come from the upstream project. The fork carries printing work that is offered upstream: native printing merged as [darinkelkhoff/markdownViewr#2](https://github.com/darinkelkhoff/markdownViewr/pull/2), and the print-styling fixes are open as [#3](https://github.com/darinkelkhoff/markdownViewr/pull/3). Report app issues upstream. The Windows port is [KofTwentyTwo/MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
[![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.
### Tests
The tests are app-hosted XCTests (`markdownViewrTests`). CI builds the app and the test bundle unsigned
but does not run them (the CI macOS runner has no GUI session), so run them locally before merging:
```bash
just test # xcodegen generate first if the project is stale
```
+25 -6
View File
@@ -6,12 +6,31 @@
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>1.0</title>
<pubDate>Mon, 08 Jun 2026 08:18:43 -0500</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0</sparkle:shortVersionString>
<title>1.5.0</title>
<pubDate>Thu, 23 Jul 2026 08:33:55 -0500</pubDate>
<sparkle:version>10500</sparkle:version>
<sparkle:shortVersionString>1.5.0</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.5.0.html</sparkle:releaseNotesLink>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.0/markdownViewr-1.1.0.zip" length="4188045" type="application/octet-stream" sparkle:edSignature="70LiSpW246aGNT9FYU7bZ1yEkqOPuDRTF3eKFZ7tz3wPkTz5aHrO36nUnqhATLg3ikq0A4hTBMXjvJx2ydV6Bg=="/>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.5.0/markdownViewr-1.5.0.zip" length="4426474" type="application/octet-stream" sparkle:edSignature="sIjjKAMzb8Fv9zjr0vkfbpxeriD+lmkdB/L+wcs/Ay0mcvYu+WIr4UkIpeYBLsLevOl2aYfM3+KOhHdTjAnXDA=="/>
</item>
<item>
<title>1.4.0</title>
<pubDate>Fri, 26 Jun 2026 09:00:05 -0500</pubDate>
<sparkle:version>10400</sparkle:version>
<sparkle:shortVersionString>1.4.0</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.4.0.html</sparkle:releaseNotesLink>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.4.0/markdownViewr-1.4.0.zip" length="4385892" type="application/octet-stream" sparkle:edSignature="7OtkBh/Qd6/BB5MYr38LVGJA3C24+hzFfd4OOe88gim2LEKShRoMkz8EXuuCCIhcVbDIDrtcU6XzckshCKKJBg=="/>
</item>
<item>
<title>1.3.0</title>
<pubDate>Fri, 19 Jun 2026 14:47:14 -0500</pubDate>
<sparkle:version>10300</sparkle:version>
<sparkle:shortVersionString>1.3.0</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.3.0.html</sparkle:releaseNotesLink>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.3.0/markdownViewr-1.3.0.zip" length="4256339" type="application/octet-stream" sparkle:edSignature="TNbtuUw4oI/YHBwiHqtfy4CFTu/pay+R/fGjF/usHw9DWVfKh+tRvgu8UyHVe0SbR7KFX6PGg1aVzeONMicqDw=="/>
</item>
</channel>
</rss>
</rss>
View File
Whitespace-only changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>markdownViewr</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f7;
color: #1d1d1f;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 1200px;
}
.icon {
width: 128px;
height: 128px;
margin-bottom: 24px;
border-radius: 22%;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
}
.screenshot-wrap {
margin-top: 48px;
width: 100%;
max-width: 780px;
}
.screenshot-wrap img {
width: 100%;
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0,0,0,0.18);
}
h1 {
font-size: 2.4rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.tagline {
font-size: 1.15rem;
color: #555;
line-height: 1.5;
margin-bottom: 32px;
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: #0071e3;
color: #fff;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
padding: 14px 28px;
border-radius: 980px;
transition: background 0.15s;
}
.download-btn:hover { background: #0077ed; }
.download-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.req {
margin-top: 12px;
font-size: 0.82rem;
color: #888;
}
.features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 40px;
max-width: 1200px;
}
.feature {
background: #fff;
border-radius: 12px;
padding: 10px 16px;
font-size: 0.88rem;
color: #444;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
footer {
margin-top: 56px;
font-size: 0.8rem;
color: #aaa;
}
footer a { color: #aaa; }
@media (prefers-color-scheme: dark) {
body { background: #1c1c1e; color: #f5f5f7; }
.tagline { color: #aaa; }
.feature { background: #2c2c2e; color: #ccc; box-shadow: none; }
.req { color: #666; }
footer, footer a { color: #555; }
}
</style>
</head>
<body>
<div class="hero">
<img class="icon" src="icon.png" alt="markdownViewr icon">
<h1>markdownViewr</h1>
<p class="tagline">
A fast, beautiful markdown viewer for macOS.<br>
Open any markdown file. Live-reloads as you edit.
</p>
<a class="download-btn"
href="https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg">
<svg viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v9.586l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V3a1 1 0 011-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"/></svg>
Download for Mac
</a>
<p class="req">macOS 13 Ventura or later &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">Themed code blocks</span>
<span class="feature">Fully customizable themes</span>
<span class="feature">Table of contents</span>
<span class="feature">Markdown source view</span>
<span class="feature">Live file watching</span>
<span class="feature">Vim navigation keys</span>
<span class="feature">Find in document</span>
<span class="feature">YAML frontmatter support</span>
<span class="feature">Open in external editor</span>
<span class="feature">Automatic updates</span>
</div>
</div>
<div class="screenshot-wrap">
<img src="screenshot.png" alt="markdownViewr showing a markdown document">
</div>
<footer>
<a href="privacy.html">Privacy Policy</a> &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>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>markdownViewr 1.4.0 Release Notes</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 28px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1d1d1f;
background: #fff;
line-height: 1.5;
}
.release-notes {
max-width: 680px;
margin: 0 auto;
}
h1 {
margin: 0 0 6px;
font-size: 1.8rem;
letter-spacing: -0.02em;
}
.date {
margin: 0 0 24px;
color: #6e6e73;
font-size: 0.92rem;
}
h2 {
margin: 24px 0 8px;
font-size: 1.1rem;
}
ul {
margin: 8px 0 0;
padding-left: 1.35rem;
}
li {
margin: 8px 0;
}
code {
padding: 1px 5px;
border-radius: 5px;
background: #f5f5f7;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
}
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
.date { color: #a1a1a6; }
code { background: #2c2c2e; }
}
</style>
</head>
<body>
<div class="release-notes">
<h1>markdownViewr 1.4.0</h1>
<p class="date">Released June 26, 2026</p>
<h2>Native macOS toolbar customization</h2>
<ul>
<li>Added native macOS toolbar customization for document windows.</li>
</ul>
<h2>View settings now apply per window</h2>
<ul>
<li>TOC, TOC depth, Markdown Source, zoom, and active theme are now per-window settings.</li>
<li>Added Settings options to define default settings for new windows.</li>
</ul>
<h2>Menu and shortcut improvements</h2>
<ul>
<li>Cleaned up the File menu by removing New, Save, and Revert, and adding Open in External Editor.</li>
<li>Added View menu commands for TOC, Markdown Source, and TOC depth.</li>
<li>Added <code>Cmd-T</code> to toggle TOC, <code>Cmd-U</code> to toggle Markdown Source, and <code>Cmd-E</code> to open in the external editor.</li>
</ul>
<h2>Window and settings polish</h2>
<ul>
<li>Improved default window sizing and side pane widths.</li>
<li>Added per-file window size restoration.</li>
<li>Added the ability to reorder external editors.</li>
</ul>
</div>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>markdownViewr 1.5.0 Release Notes</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 28px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1d1d1f;
background: #fff;
line-height: 1.5;
}
.release-notes {
max-width: 680px;
margin: 0 auto;
}
h1 {
margin: 0 0 6px;
font-size: 1.8rem;
letter-spacing: -0.02em;
}
.date {
margin: 0 0 24px;
color: #6e6e73;
font-size: 0.92rem;
}
h2 {
margin: 24px 0 8px;
font-size: 1.1rem;
}
ul {
margin: 8px 0 0;
padding-left: 1.35rem;
}
li {
margin: 8px 0;
}
code {
padding: 1px 5px;
border-radius: 5px;
background: #f5f5f7;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
}
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
.date { color: #a1a1a6; }
code { background: #2c2c2e; }
}
</style>
</head>
<body>
<div class="release-notes">
<h1>markdownViewr 1.5.0</h1>
<p class="date">Released July 23, 2026</p>
<h2>Native printing</h2>
<ul>
<li>Added native macOS printing for rendered Markdown documents.</li>
<li>Added a Printing tab in Settings for print theme, images, backgrounds, borders, page padding, zoom, and content width.</li>
<li>Added a built-in Clean Printing theme for predictable print output.</li>
</ul>
<h2>Toolbar improvements</h2>
<ul>
<li>Added an optional Print toolbar item that can be added from Customize Toolbar.</li>
<li>Replaced the custom small-space toolbar item with AppKit's native repeatable space item.</li>
</ul>
<h2>Window and navigation polish</h2>
<ul>
<li>Updated table-of-contents highlighting to follow the mouse cursor instead of just the top-of-window scroll position.</li>
<li>Improved tabbed document window behavior by avoiding frame autosave on tab groups.</li>
</ul>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

@@ -0,0 +1,732 @@
# Markdown Extensions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add four toggleable inline markdown extensions (highlight, superscript, subscript, underline) with a Markdown settings tab, a theme-aware highlight color, and a post-processing HTML pass.
**Architecture:** A new `MarkdownExtensions` struct holds four booleans stored in UserDefaults via `ThemeManager`. `MarkdownDocument.convertToHTML` runs a post-processing pass (skipping `<pre>` blocks) after the swift-markdown AST walk. `ThemeColors` gains `highlightBackground`/`highlightText` fields (with a custom decoder for backward compatibility) that feed CSS variables used by `mark {}` in the HTML template.
**Tech Stack:** SwiftUI, AppKit (NSWindow), swift-markdown 0.7.3, NSRegularExpression, UserDefaults, XcodeGen (sources glob — new Swift files auto-included, no regeneration needed).
---
### Task 1: MarkdownExtensions struct + ThemeColors highlight fields
**Files:**
- Create: `markdownViewr/MarkdownExtensions.swift`
- Modify: `markdownViewr/Theme.swift`
- [ ] **Step 1: Create `markdownViewr/MarkdownExtensions.swift`**
```swift
import Foundation
struct MarkdownExtensions: Codable {
var highlight: Bool = true
var superscript: Bool = true
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
var underline: Bool = true
}
```
- [ ] **Step 2: Add `highlightBackground` and `highlightText` to `ThemeColors` in `Theme.swift`**
Add the two new properties after `blockquoteBackground`:
```swift
var blockquoteBackground: String
var highlightBackground: String
var highlightText: String
```
Update the `init(...)` signature and body to include them after `blockquoteBackground`:
```swift
init(
background: String = "#1e1e2e",
text: String = "#cdd6f4",
heading1: String = "#cba6f7",
heading2: String = "#89b4fa",
heading3: String = "#89b4fa",
heading4: String = "#74c7ec",
heading5: String = "#74c7ec",
heading6: String = "#74c7ec",
link: String = "#89dceb",
codeBackground: String = "#181825",
codeText: String = "#a6e3a1",
blockquoteBorder: String = "#cba6f7",
blockquoteBackground: String = "#252535",
highlightBackground: String = "#ffd700",
highlightText: String = "#1a1a1a"
) {
self.background = background
self.text = text
self.heading1 = heading1
self.heading2 = heading2
self.heading3 = heading3
self.heading4 = heading4
self.heading5 = heading5
self.heading6 = heading6
self.link = link
self.codeBackground = codeBackground
self.codeText = codeText
self.blockquoteBorder = blockquoteBorder
self.blockquoteBackground = blockquoteBackground
self.highlightBackground = highlightBackground
self.highlightText = highlightText
}
```
- [ ] **Step 3: Add explicit `CodingKeys` and custom `init(from:)` to `ThemeColors` for backward compatibility**
Existing user theme JSONs don't have these fields. Swift's auto-synthesized decoder throws on missing keys, so add the following inside `struct ThemeColors`:
```swift
enum CodingKeys: String, CodingKey {
case background, text
case heading1, heading2, heading3, heading4, heading5, heading6
case link, codeBackground, codeText
case blockquoteBorder, blockquoteBackground
case highlightBackground, highlightText
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
background = try c.decode(String.self, forKey: .background)
text = try c.decode(String.self, forKey: .text)
heading1 = try c.decode(String.self, forKey: .heading1)
heading2 = try c.decode(String.self, forKey: .heading2)
heading3 = try c.decode(String.self, forKey: .heading3)
heading4 = try c.decode(String.self, forKey: .heading4)
heading5 = try c.decode(String.self, forKey: .heading5)
heading6 = try c.decode(String.self, forKey: .heading6)
link = try c.decode(String.self, forKey: .link)
codeBackground = try c.decode(String.self, forKey: .codeBackground)
codeText = try c.decode(String.self, forKey: .codeText)
blockquoteBorder = try c.decode(String.self, forKey: .blockquoteBorder)
blockquoteBackground = try c.decode(String.self, forKey: .blockquoteBackground)
highlightBackground = try c.decodeIfPresent(String.self, forKey: .highlightBackground) ?? "#ffd700"
highlightText = try c.decodeIfPresent(String.self, forKey: .highlightText) ?? "#1a1a1a"
}
```
- [ ] **Step 4: Build to verify**
```bash
just build
```
Expected: build succeeds with no errors.
- [ ] **Step 5: Commit**
```bash
git add markdownViewr/MarkdownExtensions.swift markdownViewr/Theme.swift
git commit -m "add MarkdownExtensions struct and highlight color fields to ThemeColors"
```
---
### Task 2: ThemeManager — extensions property and CSS variables
**Files:**
- Modify: `markdownViewr/ThemeManager.swift`
- [ ] **Step 1: Add `markdownExtensions` as the last `@Published` property in `ThemeManager`**
Add after the `frontmatterMode` property block (around line 21–26):
```swift
@Published var markdownExtensions: MarkdownExtensions {
didSet {
if let data = try? JSONEncoder().encode(markdownExtensions) {
UserDefaults.standard.set(data, forKey: "markdownExtensions")
}
}
}
```
- [ ] **Step 2: Initialize `markdownExtensions` in `ThemeManager.init()`**
In the `init()` body, after the `frontmatterMode` initialization, add:
```swift
if let data = UserDefaults.standard.data(forKey: "markdownExtensions"),
let ext = try? JSONDecoder().decode(MarkdownExtensions.self, from: data) {
self.markdownExtensions = ext
} else {
self.markdownExtensions = MarkdownExtensions()
}
```
- [ ] **Step 3: Add `--highlight-bg` and `--highlight-text` to `generateCSS`**
In `generateCSS(for:)`, inside the `:root {` block, add these two lines after `--blockquote-bg`:
```swift
--highlight-bg: \(theme.colors.highlightBackground);
--highlight-text: \(theme.colors.highlightText);
```
- [ ] **Step 4: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 3: HTML template — `mark` CSS rule
**Files:**
- Modify: `markdownViewr/Resources/template.html`
- [ ] **Step 1: Add `mark` rule to `base-css` in `template.html`**
After the `del { opacity: 0.6; }` rule (around line 223–225), add:
```css
mark {
background-color: var(--highlight-bg);
color: var(--highlight-text);
border-radius: 2px;
padding: 0 2px;
}
```
Note: find marks created by the in-page find feature use inline styles that override this rule, so there is no conflict.
- [ ] **Step 2: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 4: Update built-in theme JSON files
**Files:**
- Modify: all 20 files in `markdownViewr/Resources/themes/`
Each file needs `"highlightBackground"` and `"highlightText"` added inside the `"colors"` object, after `"blockquoteBackground"`.
- [ ] **Step 1: Update `catppuccin-mocha.json`** (dark)
Add after `"blockquoteBackground"`:
```json
"highlightBackground": "#f9e2af",
"highlightText": "#1e1e2e"
```
- [ ] **Step 2: Update `catppuccin-latte.json`** (light)
```json
"highlightBackground": "#f9e2af",
"highlightText": "#4c4f69"
```
- [ ] **Step 3: Update `dracula.json`** (dark)
```json
"highlightBackground": "#f1fa8c",
"highlightText": "#282a36"
```
- [ ] **Step 4: Update `everforest-dark.json`** (dark)
```json
"highlightBackground": "#dbbc7f",
"highlightText": "#2d353b"
```
- [ ] **Step 5: Update `github-dark.json`** (dark)
```json
"highlightBackground": "#e3b341",
"highlightText": "#0d1117"
```
- [ ] **Step 6: Update `github-light.json`** (light)
```json
"highlightBackground": "#fff8c5",
"highlightText": "#1f2328"
```
- [ ] **Step 7: Update `gruvbox-dark.json`** (dark)
```json
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
```
- [ ] **Step 8: Update `gruvbox-light.json`** (light)
```json
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
```
- [ ] **Step 9: Update `kanagawa.json`** (dark)
```json
"highlightBackground": "#ff9e3b",
"highlightText": "#1f1f28"
```
- [ ] **Step 10: Update `monokai.json`** (dark)
```json
"highlightBackground": "#e6db74",
"highlightText": "#272822"
```
- [ ] **Step 11: Update `nord.json`** (dark)
```json
"highlightBackground": "#ebcb8b",
"highlightText": "#2e3440"
```
- [ ] **Step 12: Update `one-dark.json`** (dark)
```json
"highlightBackground": "#e5c07b",
"highlightText": "#282c34"
```
- [ ] **Step 13: Update `one-light.json`** (light)
```json
"highlightBackground": "#ffd700",
"highlightText": "#383a42"
```
- [ ] **Step 14: Update `rose-pine.json`** (dark)
```json
"highlightBackground": "#f6c177",
"highlightText": "#191724"
```
- [ ] **Step 15: Update `rose-pine-dawn.json`** (light)
```json
"highlightBackground": "#f6c177",
"highlightText": "#575279"
```
- [ ] **Step 16: Update `solarized-dark.json`** (dark)
```json
"highlightBackground": "#b58900",
"highlightText": "#002b36"
```
- [ ] **Step 17: Update `solarized-light.json`** (light)
```json
"highlightBackground": "#b58900",
"highlightText": "#fdf6e3"
```
- [ ] **Step 18: Update `tokyo-night.json`** (dark)
```json
"highlightBackground": "#e0af68",
"highlightText": "#1a1b26"
```
- [ ] **Step 19: Update `tokyo-night-light.json`** (light)
```json
"highlightBackground": "#f9c859",
"highlightText": "#343b58"
```
- [ ] **Step 20: Update `zenburn.json`** (dark)
```json
"highlightBackground": "#f0dfaf",
"highlightText": "#3f3f3f"
```
- [ ] **Step 21: Build to verify**
```bash
just build
```
Expected: build succeeds.
- [ ] **Step 22: Commit**
```bash
git add markdownViewr/ThemeManager.swift markdownViewr/Resources/template.html markdownViewr/Resources/themes/
git commit -m "add highlight CSS variable, mark rule, and highlight colors to all built-in themes"
```
---
### Task 5: MarkdownDocument — post-processing pass
**Files:**
- Modify: `markdownViewr/MarkdownDocument.swift`
- [ ] **Step 1: Update `convertToHTML` signature to accept `extensions`**
Change the function signature from:
```swift
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide) -> String {
```
to:
```swift
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide, extensions: MarkdownExtensions = MarkdownExtensions()) -> String {
```
- [ ] **Step 2: Call `applyExtensions` as the last step in `convertToHTML`**
At the end of `convertToHTML`, change the final two lines from:
```swift
var htmlVisitor = HTMLConverter()
html += htmlVisitor.visit(document)
return html
```
to:
```swift
var htmlVisitor = HTMLConverter()
html += htmlVisitor.visit(document)
return applyExtensions(html, extensions: extensions)
```
- [ ] **Step 3: Add the three private static helpers at the bottom of `MarkdownDocument`** (before the closing brace, after `escapeHTML`)
```swift
private static func applyExtensions(_ html: String, extensions: MarkdownExtensions) -> String {
// Split on <pre>...</pre> blocks so code blocks are never transformed.
var segments = html.components(separatedBy: "<pre>")
for i in 0..<segments.count {
if i == 0 {
segments[i] = applyExtensionRegexes(segments[i], extensions: extensions)
} else {
// Each segment begins with the pre block content up to </pre>,
// followed by regular content.
if let endRange = segments[i].range(of: "</pre>") {
let preContent = String(segments[i][..<endRange.upperBound])
let afterPre = applyExtensionRegexes(
String(segments[i][endRange.upperBound...]),
extensions: extensions
)
segments[i] = preContent + afterPre
}
}
}
return segments.joined(separator: "<pre>")
}
private static func applyExtensionRegexes(_ text: String, extensions: MarkdownExtensions) -> String {
var result = text
if extensions.highlight {
result = applyRegex(result, pattern: "==(.+?)==", template: "<mark>$1</mark>")
}
if extensions.superscript {
result = applyRegex(result, pattern: "\\^(.+?)\\^", template: "<sup>$1</sup>")
}
if extensions.subscript_ {
result = applyRegex(result, pattern: "~(.+?)~", template: "<sub>$1</sub>")
}
if extensions.underline {
result = applyRegex(result, pattern: "\\+\\+(.+?)\\+\\+", template: "<ins>$1</ins>")
}
return result
}
private static func applyRegex(_ text: String, pattern: String, template: String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return text }
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: template)
}
```
- [ ] **Step 4: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 6: ContentView — wire extensions into rerender
**Files:**
- Modify: `markdownViewr/ContentView.swift`
- [ ] **Step 1: Update `rerender()` to pass `extensions`**
Change:
```swift
private func rerender() {
renderedHTML = MarkdownDocument.convertToHTML(currentMarkdown, frontmatterMode: themeManager.frontmatterMode)
}
```
to:
```swift
private func rerender() {
renderedHTML = MarkdownDocument.convertToHTML(
currentMarkdown,
frontmatterMode: themeManager.frontmatterMode,
extensions: themeManager.markdownExtensions
)
}
```
- [ ] **Step 2: Add `onReceive` for `markdownExtensions`**
After the existing `.onReceive(themeManager.$frontmatterMode)` block, add:
```swift
.onReceive(themeManager.$markdownExtensions) { _ in
DispatchQueue.main.async { rerender() }
}
```
- [ ] **Step 3: Build to verify**
```bash
just build
```
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add markdownViewr/MarkdownDocument.swift markdownViewr/ContentView.swift
git commit -m "add post-processing pass for markdown extensions (highlight, superscript, subscript, underline)"
```
---
### Task 7: Settings UI — Markdown tab
**Files:**
- Modify: `markdownViewr/SettingsView.swift`
- [ ] **Step 1: Add `MarkdownSettingsView` struct to `SettingsView.swift`**
Add this new struct before the `ThemeEditorItem` struct (before line 265):
```swift
struct MarkdownSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
GroupBox {
VStack(spacing: 0) {
extensionRow(
name: "Highlight",
example: "==text==",
isOn: Binding(
get: { themeManager.markdownExtensions.highlight },
set: { themeManager.markdownExtensions.highlight = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Superscript",
example: "^text^",
isOn: Binding(
get: { themeManager.markdownExtensions.superscript },
set: { themeManager.markdownExtensions.superscript = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Subscript",
example: "~text~",
isOn: Binding(
get: { themeManager.markdownExtensions.subscript_ },
set: { themeManager.markdownExtensions.subscript_ = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Underline",
example: "++text++",
isOn: Binding(
get: { themeManager.markdownExtensions.underline },
set: { themeManager.markdownExtensions.underline = $0 }
)
)
}
.padding(4)
}
Spacer()
}
.padding(20)
}
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
HStack {
Text(name)
.frame(width: 100, alignment: .leading)
Text(example)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Toggle("", isOn: isOn)
.toggleStyle(.checkbox)
.labelsHidden()
}
.padding(.vertical, 2)
}
}
```
- [ ] **Step 2: Add the Markdown tab to `SettingsView`**
In `SettingsView.body`, add the new tab after the Themes tab:
```swift
MarkdownSettingsView()
.tabItem {
Label("Markdown", systemImage: "doc.plaintext")
}
```
- [ ] **Step 3: Build to verify**
```bash
just build
```
Expected: build succeeds.
---
### Task 8: Theme Editor — highlight color pickers
**Files:**
- Modify: `markdownViewr/ThemeEditorView.swift`
- Modify: `markdownViewr/SampleMarkdown.swift`
- [ ] **Step 1: Add highlight color rows to `colorsSection` in `ThemeEditorView.swift`**
In the `LazyVGrid` inside `colorsSection`, after `colorRow("Quote BG", $colors.blockquoteBackground)`, add:
```swift
colorRow("Highlight BG", $colors.highlightBackground)
colorRow("Highlight Text", $colors.highlightText)
```
- [ ] **Step 2: Add highlight/extension examples to `sampleMarkdown` in `SampleMarkdown.swift`**
Find the sample markdown string and add a new section so the Theme Editor preview shows the highlight color in action. Add this block to the sample (a sensible place is near the inline formatting examples):
```swift
## Extensions
This sentence uses ==highlighted text==, H~2~O for subscript, E=mc^2^ for superscript, and ++underlined text++.
```
- [ ] **Step 3: Build to verify**
```bash
just build
```
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add markdownViewr/SettingsView.swift markdownViewr/ThemeEditorView.swift markdownViewr/SampleMarkdown.swift
git commit -m "add Markdown settings tab and highlight color pickers to theme editor"
```
---
### Task 9: Manual test
- [ ] **Step 1: Launch the app**
```bash
just run
```
- [ ] **Step 2: Open a test file with all four extension types**
Create `/tmp/extensions-test.md` with the following content:
```markdown
# Extension Test
Regular text with ==highlighted phrase== inline.
E=mc^2^ uses superscript.
H~2~O uses subscript.
This is ++underlined text++ using plus markers.
Code blocks must NOT be transformed:
==not highlighted==
^not superscript^
~not subscript~
++not underlined++
Fenced code blocks must also be skipped:
```text
==still not highlighted==
```
Open this file in the app.
- [ ] **Step 3: Verify each extension renders correctly**
- `==highlighted phrase==` → yellow/amber background highlight (matching the active theme's highlight color)
- `mc^2^` → `2` as superscript
- `H~2~O` → `2` as subscript
- `++underlined text++` → underline styling
- Content inside code blocks: no transformation, literal syntax visible
- [ ] **Step 4: Verify toggles work**
Open Settings → Markdown tab. Toggle Highlight off. Verify the highlighted phrase no longer has a background and shows `==highlighted phrase==` literally. Toggle it back on and verify it re-renders immediately.
- [ ] **Step 5: Verify highlight color in Theme Editor**
Open Settings → Themes → select a theme → Edit. Verify "Highlight BG" and "Highlight Text" color pickers appear in the Colors section. Change the highlight color and verify the preview updates immediately.
- [ ] **Step 6: Verify theme switching**
Switch between a dark theme and a light theme in the toolbar. Verify the highlight background color changes appropriately with the theme.
@@ -0,0 +1,916 @@
# Mac App Store Dual-Distribution Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a sandboxed Mac App Store build variant alongside the existing Developer ID build, from one codebase, with all sandbox-specific behavior isolated behind a `MAS_BUILD` compile flag.
**Architecture:** A second pair of XcodeGen build configurations (`Debug-MAS`, `Release-MAS`) flips on the sandbox via an entitlements file and defines `MAS_BUILD`. Sparkle is compiled out of the MAS binary. A `FolderAccessManager` acquires a persisted security-scoped bookmark to the document's folder (powering both file-watching and image reads), and local images are inlined as `data:` URLs so the sandboxed WKWebView never needs broad file access. The Developer ID build is unchanged at runtime.
**Tech Stack:** Swift, SwiftUI, AppKit (NSOpenPanel, security-scoped bookmarks), WKWebView, XcodeGen, xcodebuild.
---
## File Structure
| File | Responsibility | Action |
|---|---|---|
| `project.yml` | Add `configs` map; per-config sandbox/entitlements/flags; move Sparkle Info.plist keys to non-MAS configs | Modify |
| `markdownViewr/markdownViewr-MAS.entitlements` | Sandbox entitlements for the MAS build | Create |
| `markdownViewr/MarkdownViewrApp.swift` | Guard Sparkle behind `#if !MAS_BUILD` | Modify |
| `markdownViewr/HelpView.swift` | Don't advertise Sparkle updates in MAS build | Modify |
| `markdownViewr/ImageInliner.swift` | Pure helper: rewrite `<img>` local `src` to `data:` URLs | Create |
| `markdownViewrTests/ImageInlinerTests.swift` | Unit tests for the inliner | Create |
| `markdownViewr/FolderAccessManager.swift` | Acquire/persist/hold security-scoped folder access; resolve image bytes | Create |
| `markdownViewr/ContentView.swift` | Gate watching on access; show grant banner; inline images on render | Modify |
| `markdownViewr/MarkdownWebView.swift` | MAS: scope read-access to temp dir, no base tag | Modify |
| `ExportOptions-MAS.plist` | App Store export options | Create |
| `justfile` | `release-mas` recipe | Modify |
**Test runner (per user convention):** All tests run via `/tmp/markdownViewr-claude-run-tests.sh` (created in Task 3), which writes output to `/tmp/markdownViewr-claude-test-output.txt`. Builds are run inline with `xcodebuild`.
---
## Task 1: MAS build configurations + entitlements
**Files:**
- Modify: `project.yml`
- Create: `markdownViewr/markdownViewr-MAS.entitlements`
- [ ] **Step 1: Create the MAS entitlements file**
Create `markdownViewr/markdownViewr-MAS.entitlements`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
```
- [ ] **Step 2: Add the `configs` map to `project.yml`**
At the top level of `project.yml` (e.g. directly after the `options:` block), add:
```yaml
configs:
Debug: debug
Release: release
Debug-MAS: debug
Release-MAS: release
```
- [ ] **Step 3: Remove the always-non-sandboxed base setting and the Sparkle Info.plist keys**
In `project.yml`, in the `markdownViewr` target's `settings.base`, DELETE this line (sandbox is now controlled per-config via the entitlements file):
```yaml
ENABLE_APP_SANDBOX: "NO"
```
In the `markdownViewr` target's `info.properties`, DELETE these two lines (they will be re-injected for non-MAS configs only in the next step):
```yaml
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
```
- [ ] **Step 4: Add per-config settings to the `markdownViewr` target**
In `project.yml`, under the `markdownViewr` target's `settings:` (which currently only has `base:`), add a `configs:` sibling so it reads:
```yaml
settings:
base:
INFOPLIST_FILE: markdownViewr/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
MARKETING_VERSION: "1.2.0"
CURRENT_PROJECT_VERSION: "3"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
configs:
Debug:
INFOPLIST_KEY_SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
INFOPLIST_KEY_SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
Release:
INFOPLIST_KEY_SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
INFOPLIST_KEY_SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
Debug-MAS:
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MAS_BUILD"
Release-MAS:
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MAS_BUILD"
```
Note: `ENABLE_APP_SANDBOX` is intentionally NOT set — the entitlements file is the single source of truth for sandboxing, present only in the MAS configs.
- [ ] **Step 5: Regenerate and verify the configs exist**
Run: `xcodegen generate`
Then: `xcodebuild -project markdownViewr.xcodeproj -list`
Expected: the `Build Configurations:` list includes `Debug`, `Release`, `Debug-MAS`, and `Release-MAS`.
- [ ] **Step 6: Verify the Developer ID Info.plist still has Sparkle keys, MAS does not**
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release -showBuildSettings 2>/dev/null | grep -i 'INFOPLIST_KEY_SUFeedURL'`
Expected: shows the appcast URL.
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release-MAS -showBuildSettings 2>/dev/null | grep -ci 'INFOPLIST_KEY_SUFeedURL'`
Expected: `0`
(If `INFOPLIST_KEY_`-prefixed custom keys do not appear in the built `Info.plist` on this Xcode version, fall back to two static Info.plist files: a shared `Info.plist` without Sparkle keys, plus `Info-DevID.plist` with them, and set `INFOPLIST_FILE` per-config. Verify by building and running `plutil -p` on the built app's `Contents/Info.plist`.)
- [ ] **Step 7: Build both variants to confirm the project is valid**
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
Expected: `BUILD SUCCEEDED`.
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
Expected: `BUILD SUCCEEDED` (Sparkle still compiles here — it is removed in Task 2).
- [ ] **Step 8: Commit**
```bash
git add project.yml markdownViewr/markdownViewr-MAS.entitlements
git commit -m "build: add Mac App Store build configurations and entitlements"
```
---
## Task 2: Compile Sparkle out of the MAS build
**Files:**
- Modify: `markdownViewr/MarkdownViewrApp.swift:1-31`
- Modify: `markdownViewr/HelpView.swift:54`
- [ ] **Step 1: Guard the Sparkle import**
In `markdownViewr/MarkdownViewrApp.swift`, replace lines 1-2:
```swift
import SwiftUI
import Sparkle
```
with:
```swift
import SwiftUI
#if !MAS_BUILD
import Sparkle
#endif
```
- [ ] **Step 2: Guard the updater controller property**
In `markdownViewr/MarkdownViewrApp.swift`, wrap the `updaterController` property (currently lines 6-10):
```swift
#if !MAS_BUILD
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
#endif
```
- [ ] **Step 3: Guard the "Check for Updates" command**
In `markdownViewr/MarkdownViewrApp.swift`, wrap the entire `CommandGroup(after: .appInfo)` block (currently lines 26-31). `#if` is valid inside SwiftUI's `commands` result builder, so in the MAS build the menu item is never added to the menu:
```swift
#if !MAS_BUILD
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterController.checkForUpdates(nil)
}
.disabled(!updaterController.updater.canCheckForUpdates)
}
#endif
```
- [ ] **Step 4: Don't advertise Sparkle in the MAS build's Help**
Read `markdownViewr/HelpView.swift` around line 54 to see the exact surrounding string-array/list context. The line currently reads:
```swift
- Automatic updates via Sparkle
```
Replace it so the bullet is conditional. If it is part of a multi-line Swift string literal, split the literal so only the non-MAS build includes the line, e.g.:
```swift
#if !MAS_BUILD
- Automatic updates via Sparkle
#endif
```
If `#if` cannot be placed inside that specific literal/view construct, instead remove the Sparkle bullet from the shared text and append it only in the non-MAS build via the surrounding view/string-builder. The requirement: the MAS build's Help must not mention Sparkle or automatic updates.
- [ ] **Step 5: Build the MAS variant and confirm Sparkle is compiled out**
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
Expected: `BUILD SUCCEEDED` with no reference to `SPUStandardUpdaterController` (the symbol is inside `#if !MAS_BUILD`).
- [ ] **Step 6: Build the Developer ID variant and confirm it is unchanged**
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
Expected: `BUILD SUCCEEDED`; the "Check for Updates…" command and Sparkle updater still compile.
- [ ] **Step 7: Commit**
```bash
git add markdownViewr/MarkdownViewrApp.swift markdownViewr/HelpView.swift
git commit -m "build: compile Sparkle out of the Mac App Store build"
```
---
## Task 3: Image-inlining helper (pure, TDD)
**Files:**
- Create: `markdownViewr/ImageInliner.swift`
- Test: `markdownViewrTests/ImageInlinerTests.swift`
- Create: `/tmp/markdownViewr-claude-run-tests.sh`
- [ ] **Step 1: Create the test-runner script**
Write `/tmp/markdownViewr-claude-run-tests.sh` (the ONLY way tests are run; always invoked with zero arguments):
```bash
#!/usr/bin/env bash
set -o pipefail
cd /Users/dkelkhoff/git/personal/markdownViewr
xcodebuild test \
-project markdownViewr.xcodeproj \
-scheme markdownViewrTests \
-destination 'platform=macOS' \
> /tmp/markdownViewr-claude-test-output.txt 2>&1
echo "exit=$?" >> /tmp/markdownViewr-claude-test-output.txt
```
Then: `chmod +x /tmp/markdownViewr-claude-run-tests.sh`
- [ ] **Step 2: Write the failing tests**
Create `markdownViewrTests/ImageInlinerTests.swift`:
```swift
import XCTest
@testable import markdownViewr
final class ImageInlinerTests: XCTestCase {
func testInlinesRelativeImageAsDataURL() {
let html = "<p><img src=\"img/a.png\" alt=\"x\"></p>"
let bytes = Data([0x01, 0x02, 0x03])
let out = ImageInliner.inlineLocalImages(in: html) { path in
path == "img/a.png" ? bytes : nil
}
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
XCTAssertFalse(out.contains("img/a.png"))
XCTAssertTrue(out.contains("alt=\"x\""))
}
func testLeavesRemoteImageUntouched() {
let html = "<img src=\"https://example.com/a.png\">"
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
XCTAssertEqual(out, html)
}
func testLeavesAbsolutePathUntouched() {
let html = "<img src=\"/Users/x/a.png\">"
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
XCTAssertEqual(out, html)
}
func testLeavesUnresolvableRelativeImageUntouched() {
let html = "<img src=\"missing.png\">"
let out = ImageInliner.inlineLocalImages(in: html) { _ in nil }
XCTAssertEqual(out, html)
}
func testDecodesPercentEncodedPathBeforeResolving() {
let html = "<img src=\"my%20pic.png\">"
let bytes = Data([0xAA])
let out = ImageInliner.inlineLocalImages(in: html) { path in
path == "my pic.png" ? bytes : nil
}
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
}
}
```
- [ ] **Step 3: Add the new files to the project and run the tests to verify they fail**
Run: `xcodegen generate`
Then: `/tmp/markdownViewr-claude-run-tests.sh`
Then read `/tmp/markdownViewr-claude-test-output.txt`.
Expected: compilation failure — `ImageInliner` is not defined yet.
- [ ] **Step 4: Implement the helper**
Create `markdownViewr/ImageInliner.swift`:
```swift
import Foundation
/// Rewrites `<img>` tags whose `src` is a local, relative path into self-contained
/// `data:` URLs, using a caller-supplied byte resolver. Remote (`http(s)`), `data:`,
/// and absolute-path sources are left untouched. Pure and synchronous so it is unit
/// testable without any filesystem or sandbox dependency.
enum ImageInliner {
static func inlineLocalImages(in html: String, resolve: (String) -> Data?) -> String {
guard let regex = try? NSRegularExpression(
pattern: "<img([^>]*?)src=\"([^\"]*)\"([^>]*)>",
options: []
) else { return html }
let ns = html as NSString
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
guard !matches.isEmpty else { return html }
var result = ""
var last = 0
for match in matches {
let full = match.range
result += ns.substring(with: NSRange(location: last, length: full.location - last))
let pre = ns.substring(with: match.range(at: 1))
let src = ns.substring(with: match.range(at: 2))
let post = ns.substring(with: match.range(at: 3))
let decoded = src.removingPercentEncoding ?? src
if isLocalRelative(src), let data = resolve(decoded) {
let mime = mimeType(forExtension: (src as NSString).pathExtension)
result += "<img\(pre)src=\"data:\(mime);base64,\(data.base64EncodedString())\"\(post)>"
} else {
result += ns.substring(with: full)
}
last = full.location + full.length
}
result += ns.substring(with: NSRange(location: last, length: ns.length - last))
return result
}
static func isLocalRelative(_ src: String) -> Bool {
if src.isEmpty { return false }
if src.hasPrefix("data:") { return false }
if src.contains("://") { return false } // http://, https://, file://
if src.hasPrefix("/") { return false } // absolute path — unreadable under sandbox
return true
}
static func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "svg": return "image/svg+xml"
case "webp": return "image/webp"
case "bmp": return "image/bmp"
case "tif", "tiff": return "image/tiff"
default: return "application/octet-stream"
}
}
}
```
- [ ] **Step 5: Add the file to the project and run the tests to verify they pass**
Run: `xcodegen generate`
Then: `/tmp/markdownViewr-claude-run-tests.sh`
Then read `/tmp/markdownViewr-claude-test-output.txt`.
Expected: `exit=0`, all `ImageInlinerTests` pass.
- [ ] **Step 6: Commit**
```bash
git add markdownViewr/ImageInliner.swift markdownViewrTests/ImageInlinerTests.swift
git commit -m "feat: add ImageInliner for sandbox-safe data-URL image embedding"
```
---
## Task 4: FolderAccessManager (security-scoped folder access)
**Files:**
- Create: `markdownViewr/FolderAccessManager.swift`
This type exists in BOTH builds with a uniform interface. In the non-MAS build it is a no-op that always reports access, so the `ContentView` body needs no `#if`. All sandbox/bookmark logic lives behind `#if MAS_BUILD` inside this file.
- [ ] **Step 1: Create the manager**
Create `markdownViewr/FolderAccessManager.swift`:
```swift
import Foundation
import AppKit
/// Owns access to the directory containing the currently-open document. In the MAS
/// (sandboxed) build it resolves or requests a persisted app-scoped security-scoped
/// bookmark for that folder and holds access for the manager's lifetime. In the
/// Developer ID build it is an inert pass-through that always reports access.
final class FolderAccessManager: ObservableObject {
/// True when the document's folder is accessible (always true in the non-MAS build).
@Published private(set) var hasAccess: Bool = false
private var folderURL: URL?
private var accessedURL: URL?
deinit {
accessedURL?.stopAccessingSecurityScopedResource()
}
/// Resolves any stored access for the document's folder. Call once when a document opens.
func prepare(for fileURL: URL) {
let folder = fileURL.deletingLastPathComponent()
folderURL = folder
#if MAS_BUILD
guard let data = UserDefaults.standard.data(forKey: Self.key(for: folder)) else {
hasAccess = false
return
}
var stale = false
guard let url = try? URL(
resolvingBookmarkData: data,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &stale
), url.startAccessingSecurityScopedResource() else {
hasAccess = false
return
}
accessedURL = url
hasAccess = true
if stale { storeBookmark(for: url) }
#else
hasAccess = true
#endif
}
/// Presents a folder picker (MAS only) to obtain and persist access. Calls back on the main queue.
func requestAccess(completion: @escaping (Bool) -> Void) {
#if MAS_BUILD
guard let folder = folderURL else { completion(false); return }
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = folder
panel.prompt = "Allow Access"
panel.message = "Allow markdownViewr to access this folder to show images and auto-reload changes."
panel.begin { response in
guard response == .OK, let url = panel.url,
url.startAccessingSecurityScopedResource() else {
completion(false)
return
}
self.accessedURL?.stopAccessingSecurityScopedResource()
self.accessedURL = url
self.storeBookmark(for: url)
self.hasAccess = true
completion(true)
}
#else
completion(true)
#endif
}
/// Reads bytes for a path relative to the document's folder, or nil if unavailable.
/// Used by the MAS render path to inline images. Returns nil when access is not held.
func imageData(forRelativePath path: String) -> Data? {
guard hasAccess, let base = accessedURL ?? folderURL else { return nil }
let url = base.appendingPathComponent(path)
return try? Data(contentsOf: url)
}
#if MAS_BUILD
private func storeBookmark(for url: URL) {
guard let data = try? url.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil
) else { return }
// Key by the document's folder so the same document re-grants silently next launch.
if let folder = folderURL {
UserDefaults.standard.set(data, forKey: Self.key(for: folder))
}
UserDefaults.standard.set(data, forKey: Self.key(for: url))
}
private static func key(for folder: URL) -> String {
"folderBookmark:\(folder.standardizedFileURL.path)"
}
#endif
}
```
- [ ] **Step 2: Add the file and build both variants**
Run: `xcodegen generate`
Then: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
Expected: `BUILD SUCCEEDED` (non-MAS: `hasAccess` defaults set in `prepare`, no bookmark code compiled).
Then: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
Expected: `BUILD SUCCEEDED`.
- [ ] **Step 3: Commit**
```bash
git add markdownViewr/FolderAccessManager.swift
git commit -m "feat: add FolderAccessManager for sandboxed folder access"
```
---
## Task 5: Wire folder access + grant banner into ContentView
**Files:**
- Modify: `markdownViewr/ContentView.swift:20-99`
- [ ] **Step 1: Add the access manager and a render helper**
In `markdownViewr/ContentView.swift`, add a `@StateObject` for the manager alongside the existing `@StateObject` declarations (after line 30):
```swift
@StateObject private var folderAccess = FolderAccessManager()
```
- [ ] **Step 2: Make `rerender()` inline images in the MAS build**
Replace the existing `rerender()` (lines 39-45) with:
```swift
private func rerender() {
var html = MarkdownDocument.convertToHTML(
currentMarkdown,
frontmatterMode: themeManager.frontmatterMode,
extensions: themeManager.markdownExtensions
)
#if MAS_BUILD
if folderAccess.hasAccess {
html = ImageInliner.inlineLocalImages(in: html) { path in
folderAccess.imageData(forRelativePath: path)
}
}
#endif
renderedHTML = html
}
```
- [ ] **Step 3: Add a helper to start watching once access is available**
Add this method to `ContentView` (next to `rerender()`):
```swift
private func beginLiveContent() {
guard let fileURL else { return }
rerender()
liveContent.startWatching(fileURL: fileURL)
}
```
- [ ] **Step 4: Replace `onAppear` to gate watching on access**
Replace the existing `.onAppear { ... }` block (lines 82-88) with:
```swift
.onAppear {
liveContent.rawMarkdown = document.rawMarkdown
rerender()
if let fileURL {
folderAccess.prepare(for: fileURL)
if folderAccess.hasAccess {
beginLiveContent()
}
}
}
```
In the Developer ID build `folderAccess.hasAccess` is always true after `prepare`, so this preserves today's behavior (watch immediately).
- [ ] **Step 5: Add the grant banner**
Wrap the `MarkdownWebView` in the `body`'s `VStack` so a banner can sit above it. Replace the `VStack(spacing: 0) { ... }` opening through the `MarkdownWebView(...)` call (lines 48-60) with:
```swift
VStack(spacing: 0) {
if findBar.isVisible {
FindBarView(findBar: findBar)
}
if !folderAccess.hasAccess, fileURL != nil {
FolderAccessBanner {
folderAccess.requestAccess { granted in
if granted { beginLiveContent() }
}
}
}
MarkdownWebView(
html: renderedHTML,
themeCSS: themeManager.generateCSS(for: themeManager.activeTheme),
fileURL: fileURL,
findBar: findBar,
tocVisible: tocVisible,
tocDepth: tocDepth
)
}
```
Because `folderAccess.hasAccess` is always true in the non-MAS build, the banner never renders there — no `#if` needed in the view body.
- [ ] **Step 6: Define the banner view**
At the bottom of `markdownViewr/ContentView.swift` (file scope, after the `ContentView` struct), add:
```swift
private struct FolderAccessBanner: View {
let onGrant: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "photo.on.rectangle.angled")
Text("Allow access to this file's folder to show images and auto-reload on changes.")
.font(.callout)
Spacer()
Button("Allow Access…", action: onGrant)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.yellow.opacity(0.18))
}
}
```
- [ ] **Step 7: Build both variants**
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
Expected: `BUILD SUCCEEDED`.
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
Expected: `BUILD SUCCEEDED`.
- [ ] **Step 8: Run the unit tests to confirm no regression**
Run: `/tmp/markdownViewr-claude-run-tests.sh`
Then read `/tmp/markdownViewr-claude-test-output.txt`.
Expected: `exit=0`, all tests pass.
- [ ] **Step 9: Commit**
```bash
git add markdownViewr/ContentView.swift
git commit -m "feat: gate file-watching on folder access and add grant banner (MAS)"
```
---
## Task 6: Scope WKWebView read access for the sandbox
**Files:**
- Modify: `markdownViewr/MarkdownWebView.swift:65-98`
In the MAS build the preview HTML is self-contained (images already inlined by `rerender`), so the WebView only needs read access to the container temp dir, and the `<base href>` to the user's folder (unreadable under sandbox) must be omitted.
- [ ] **Step 1: Make the base tag and read-access scope build-specific**
In `markdownViewr/MarkdownWebView.swift`, in `loadContent`, replace the base-tag assignment (lines 75-79):
```swift
if let fileDir = fileURL?.deletingLastPathComponent() {
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
} else {
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
}
```
with:
```swift
#if MAS_BUILD
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
// both unreadable and unnecessary.
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
#else
if let fileDir = fileURL?.deletingLastPathComponent() {
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
} else {
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
}
#endif
```
- [ ] **Step 2: Scope the read-access URL to the temp dir in the MAS build**
In `markdownViewr/MarkdownWebView.swift`, replace the temp-file write + load block (lines 83-93):
```swift
if let fileDir = fileURL?.deletingLastPathComponent() {
let tempDir = Self.previewDirectory
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
// Grant read access to "/" so both the temp file and the document's images are accessible
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
context.coordinator.tempFileURL = tempHTML
} else {
webView.loadHTMLString(template, baseURL: nil)
}
```
with:
```swift
if fileURL != nil {
let tempDir = Self.previewDirectory
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
#if MAS_BUILD
// Self-contained HTML (images inlined); only the container temp dir is needed.
webView.loadFileURL(tempHTML, allowingReadAccessTo: tempDir)
#else
// Grant read access to "/" so both the temp file and the document's images are accessible
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
#endif
context.coordinator.tempFileURL = tempHTML
} else {
webView.loadHTMLString(template, baseURL: nil)
}
```
- [ ] **Step 3: Build both variants**
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
Expected: `BUILD SUCCEEDED`.
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
Expected: `BUILD SUCCEEDED`.
- [ ] **Step 4: Manual sandbox runtime check**
Launch the Debug-MAS build:
```bash
open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" --args -NSDocumentRevisionsDebugMode YES
```
Then open a markdown file that has a relative local image (e.g. `![](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,134 @@
# Markdown Extensions Design
**Date:** 2026-06-04
**Status:** Approved
## Overview
Add a Markdown tab to the Settings window that lets users toggle four inline
syntax extensions. Enabled extensions are applied via a post-processing pass
on the generated HTML.
## Extensions
| Syntax | Name | HTML output | Default |
|---|---|---|---|
| `==text==` | Highlight | `<mark>text</mark>` | on |
| `^text^` | Superscript | `<sup>text</sup>` | on |
| `~text~` | Subscript | `<sub>text</sub>` | on |
| `++text++` | Underline | `<ins>text</ins>` | on |
Strikethrough (`~~text~~`) is already handled at the parser level (swift-markdown
GFM extension) and is not part of this feature.
## Data Model
New `MarkdownExtensions` struct in its own file (`MarkdownExtensions.swift`):
```swift
struct MarkdownExtensions: Codable {
var highlight: Bool = true
var superscript: Bool = true
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
var underline: Bool = true
}
```
`ThemeManager` gets one new `@Published var markdownExtensions: MarkdownExtensions`
property. On `didSet` it encodes to JSON and writes to UserDefaults key
`markdownExtensions`. On init it reads and decodes from UserDefaults, falling
back to `MarkdownExtensions()` (all on) if the key is absent or malformed.
## Post-Processing
`MarkdownDocument` gains a private static `applyExtensions(_:extensions:) -> String`
helper. It is called as the final step of `convertToHTML`, after `HTMLConverter`
produces its output.
The helper splits the HTML on `<pre>` / `</pre>` boundaries. Segments inside
`<pre>` blocks are passed through unchanged. Segments outside have each enabled
extension's regex applied using `NSRegularExpression`.
Patterns (applied in the order listed):
| Extension | Regex | Replacement |
|---|---|---|
| Highlight | `==(.+?)==` | `<mark>$1</mark>` |
| Superscript | `\^(.+?)\^` | `<sup>$1</sup>` |
| Subscript | `~(.+?)~` | `<sub>$1</sub>` |
| Underline | `\+\+(.+?)\+\+` | `<ins>$1</ins>` |
`convertToHTML` signature gains one new parameter:
```swift
static func convertToHTML(
_ markdown: String,
frontmatterMode: FrontmatterMode = .hide,
extensions: MarkdownExtensions = MarkdownExtensions()
) -> String
```
All existing callers that omit `extensions` get the all-on defaults.
## Settings UI
A fourth tab is added to the `TabView` in `SettingsView`:
- Label: "Markdown", system image: `doc.plaintext`
- New struct `MarkdownSettingsView` in `SettingsView.swift`
- One `GroupBox` containing a `VStack` of four rows
- Each row: extension name (left), example syntax in secondary monospaced text
(center), `Toggle` with `.checkbox` style (right)
- Toggling writes through `themeManager.markdownExtensions` immediately
## Re-render Trigger
`ContentView` already observes `themeManager` via `@EnvironmentObject` and
re-renders on published changes. Adding `onReceive(themeManager.$markdownExtensions)`
(same pattern as `frontmatterMode`) triggers a re-render when any toggle changes.
No restart required.
## Theme Color: Highlight
`<mark>` has a browser-default yellow background that looks wrong on dark themes.
`ThemeColors` gets two new fields:
```swift
var highlightBackground: String // default: "#ffd700" (gold — readable on dark)
var highlightText: String // default: "#1e1e2e" (dark — pairs with gold bg)
```
`ThemeManager.generateCSS` emits `--highlight-bg` and `--highlight-text` CSS
variables. The HTML template adds:
```css
mark {
background-color: var(--highlight-bg);
color: var(--highlight-text);
border-radius: 2px;
padding: 0 2px;
}
```
All 20 built-in theme JSON files get the two new color fields with
theme-appropriate values. The Theme Editor color list gains a "Highlight"
section (background + text), matching the existing "Code" section layout.
User themes that predate this change omit these fields in their JSON. Swift's
auto-synthesized `Decodable` throws on missing keys, so `ThemeColors` needs a
custom `init(from decoder:)` that uses `decodeIfPresent` for the two new fields
and falls back to the `ThemeColors.init` defaults when absent.
## Files Changed
| File | Change |
|---|---|
| `MarkdownExtensions.swift` | New — struct definition |
| `Theme.swift` | Add `highlightBackground`, `highlightText` to `ThemeColors` |
| `ThemeManager.swift` | Add `markdownExtensions` published property; emit `--highlight-bg`/`--highlight-text` in `generateCSS` |
| `MarkdownDocument.swift` | Add `applyExtensions` helper, update `convertToHTML` signature |
| `SettingsView.swift` | Add `MarkdownSettingsView`, add Markdown tab |
| `ContentView.swift` | Add `onReceive` for `markdownExtensions` |
| `ThemeEditorView.swift` | Add Highlight color pickers |
| `Resources/template.html` | Add `mark { … }` CSS rule |
| `Resources/themes/*.json` (×20) | Add `highlightBackground` + `highlightText` per theme |
@@ -0,0 +1,113 @@
# Mac App Store Dual-Distribution — Design
**Date:** 2026-06-11
**Branch:** `mas-dual-distribution`
**Status:** Approved (design); implementation pending
## Goal
Ship markdownViewr through **two** distribution channels from one codebase:
1. **Developer ID** (existing) — notarized `.app` in a DMG, Sparkle auto-update, Homebrew cask. Unchanged by this work.
2. **Mac App Store (MAS)** (new) — sandboxed `.pkg`, updates delivered by the App Store, no Sparkle.
The two builds differ only where the sandbox forces them to. All sandbox-specific code is isolated behind a `MAS_BUILD` compile-time flag so the Developer ID build remains byte-for-byte what it is today.
## Constraints discovered in the code
Three sandbox blockers exist in the current code:
| # | Blocker | Location |
|---|---------|----------|
| 1 | Sparkle third-party updater (forbidden on MAS) | `MarkdownViewrApp.swift:2,6`; `Info.plist` `SUFeedURL`/`SUPublicEDKey` |
| 2 | File watching opens the doc fd directly and re-reads it | `FileWatcher.swift:20`; `ContentView.swift:10` |
| 3 | `loadFileURL(..., allowingReadAccessTo: "/")` + relative images live in the doc's folder, which the sandbox does not grant access to | `MarkdownWebView.swift:89` |
## Architecture
### 1. Build structure
- Keep the single `markdownViewr` application target.
- Add build configurations **`Release-MAS`** and **`Debug-MAS`** in `project.yml` (via the `configs` map and per-config `settings`).
- `Release-MAS` / `Debug-MAS` set:
- `ENABLE_APP_SANDBOX: YES`
- `CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements`
- `SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD` (appended to existing conditions for that config)
- The existing `Release`/`Debug` configs are untouched. Developer ID path is unchanged.
- Bundle ID stays `com.dkelkhoff.markdownViewr` for both — Developer ID and MAS builds may share it.
### 2. Sparkle (Blocker 1)
- Wrap the `import Sparkle`, the `updaterController` property, and the "Check for Updates…" command in `#if !MAS_BUILD` in `MarkdownViewrApp.swift`.
- Sparkle remains a Swift Package dependency (still linked), but no Sparkle runtime calls compile into the MAS binary.
- Move the `SUFeedURL` and `SUPublicEDKey` keys out of the shared `Info.plist` so they are absent from the MAS bundle. (Mechanism: a non-MAS-only Info.plist injection via `project.yml` `info.properties` scoped to the non-MAS configs, or a separate plist for MAS. Exact mechanism resolved during implementation; requirement is: MAS bundle must not contain Sparkle feed keys.)
- Update `HelpView.swift:54` ("Automatic updates via Sparkle") to not advertise Sparkle in the MAS build.
### 3. Folder access subsystem (Blockers 2 + 3) — all behind `#if MAS_BUILD`
New type `FolderAccessManager`:
- **Input:** a document URL.
- **Lookup:** finds a persisted **app-scoped security-scoped bookmark** for the document's parent folder, keyed by folder path (stored in `UserDefaults`).
- **Resolve + hold:** resolves the bookmark and holds `startAccessingSecurityScopedResource()` for the document window's lifetime; releases on close.
First-time-folder flow (no stored bookmark):
1. Document renders immediately — the markdown text is already available from DocumentGroup, so **text is never gated** on folder access.
2. A dismissible banner appears in the document window: *"Allow access to this file's folder to show images and auto-reload on changes."*
3. Button presents an `NSOpenPanel` pre-pointed at the document's folder. User confirms.
4. App creates and persists an app-scoped security-scoped bookmark for the folder, then re-renders (images now resolve) and starts file-watching.
5. That folder never prompts again on subsequent opens.
This single grant powers both remaining blockers:
- **File watching (Blocker 2):** `open(path, O_EVTONLY)` and re-reading the doc succeed while folder access is held. `FileWatcher`/`LiveContent` start only after access is acquired in the MAS build; in the non-MAS build they start immediately as today.
- **Image reads (Blocker 3):** see below.
### 4. Image handling (Blocker 3)
The "load file vs inline" choice collapses under sandbox: `loadFileURL(_:allowingReadAccessTo:)` grants exactly **one** directory subtree, but the preview HTML lives in the app container temp dir while images live in the user's doc folder — two distinct trees. Writing the preview HTML into the user's folder would litter it and require a read-write grant.
Therefore:
- **MAS build:** at render time, resolve relative local image references, read the image bytes **through the held folder bookmark**, and inline them as `data:` URLs in the HTML. The preview HTML becomes self-contained, stays in the container temp dir, and `allowingReadAccessTo` only needs the temp dir. Remote (`http`/`https`) images are left as-is and load via the network-client entitlement.
- **Non-MAS build:** unchanged — temp file + `allowingReadAccessTo: "/"`, no inlining, `<base href>` resolves relative images.
Implementation isolates the divergence in `MarkdownWebView.loadContent` (and a render-time image-inlining helper) behind `#if MAS_BUILD`.
### 5. Entitlements — `markdownViewr/markdownViewr-MAS.entitlements`
- `com.apple.security.app-sandbox` = true
- `com.apple.security.files.user-selected.read-only` = true
- `com.apple.security.files.bookmarks.app-scope` = true
- `com.apple.security.network.client` = true (remote images)
### 6. External editors — verify during implementation
`EditorManager.openFile` (`EditorConfig.swift:73`) launches a user-configured editor via `NSWorkspace.open(_:withApplicationAt:configuration:)` using a stored **path string**. Under sandbox the stored path may not be accessible across launches without its own security-scoped bookmark.
Plan: treat as a scoped sub-task. During implementation, test whether Launch Services still permits launching an installed editor from a bare path under sandbox. If not, store a per-editor security-scoped bookmark when the user picks the editor and resolve it at launch time. Do not solve preemptively; confirm the actual sandbox behavior first.
### 7. Release pipeline
- New `ExportOptions-MAS.plist` with `method: app-store`.
- New `just release-mas` recipe: archive with the `Release-MAS` config + Apple Distribution signing, export a `.pkg`, upload via `notarytool`/Transporter.
- Existing `just release` (Developer ID → DMG → notarize → GitHub release → appcast → Homebrew) is untouched.
## Out of scope
Manual / App Store Connect portal steps that cannot be done from the repo:
- Apple Developer account / App ID registration, Apple Distribution + Mac Installer Distribution certificates, provisioning profile.
- App Store Connect app record, screenshots, description/keywords, App Privacy nutrition label, export-compliance answers.
- The actual submission and App Review.
These will be documented as a checklist but are not implemented here.
## Testing
- **Builds:** both `Release` and `Release-MAS` compile and link; MAS binary contains no Sparkle feed keys and no Sparkle runtime symbols are exercised.
- **Sandbox runtime (MAS Debug):** open a doc with relative local images → text renders immediately, banner appears, granting folder access shows images and enables live-reload; second open of a doc in the same folder does not prompt.
- **Non-MAS regression:** Developer ID build behavior (images, watching, Sparkle update check) is unchanged.
- **External editors:** launch a configured editor from the sandboxed build; confirm it works or that the bookmark fallback works.
- Existing unit tests (`markdownViewrTests`) continue to pass under both configs.
@@ -0,0 +1,17 @@
# Themed Scrollbars — Design
## Goal
Make the main document scrollbar as visible and theme-consistent as the table-of-contents scrollbar.
## Design
Define one set of WebKit scrollbar rules in `Resources/template.html`. The rules apply to the root document scrollbar and nested scroll containers such as the table of contents and raw-source pane. The track uses `--bg`; the thumb is derived from `--text` and `--bg`, with a stronger hover color. Scrolling behavior, dimensions, and macOS scrollbar preferences remain unchanged.
When the source pane is visible, the rendered document and source are peer panes. `#content` owns rendered-document scrolling; `#raw-source` owns source scrolling; the page itself does not scroll. This places each scrollbar at its pane's right edge. A single `_contentScroller()` helper is the source of truth for rendered scrolling, including scroll preservation, source synchronization, TOC highlighting, heading navigation, find navigation, and Vim keys.
The TOC remains independently scrollable. When rendered scrolling changes the active heading, the active TOC link is scrolled with `block: 'nearest'`, moving only enough to keep that link visible rather than recentering the TOC on every update.
## Validation
An XCTest verifies that the bundled template contains shared track, thumb, hover, and corner rules using theme variables. It also verifies that `#content` owns vertical overflow and that rendered navigation no longer targets `window` scrolling. Build and unit tests guard template packaging and application behavior; a long document with its source pane open verifies scrollbar placement and synchronized scrolling visually.
+17
View File
@@ -0,0 +1,17 @@
# Think different
## Here's to the crazy ones.
1. The misfits
2. The rebels
3. The troublemakers
The round pegs in the square holes. The ones who see things differently. They're not fond of rules.
And they have no respect for the status quo. You can quote them, disagree with them, glorify or vilify them. About the only thing you can't do is ignore them. Because they change things.
***They push the human race forward***.
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
[Think different](http://www.apple.com)
+136 -9
View File
@@ -11,11 +11,37 @@ build:
# Kill, build, and launch the app
run: kill build
@open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
# Open a specific markdown file
open file: build
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
# Run the test suite
test:
xcodebuild test -project markdownViewr.xcodeproj -scheme markdownViewr -destination 'platform=macOS'
# Update the Homebrew cask to match the current release (run after a successful just release)
update-cask:
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
DMG="/tmp/markdownViewr.dmg"
if [[ ! -f "$DMG" ]]; then
echo "==> Downloading DMG for v$VERSION..."
gh release download "v$VERSION" --repo darinkelkhoff/markdownViewr --pattern "markdownViewr.dmg" --dir /tmp --clobber
fi
DMG_SHA=$(shasum -a 256 "$DMG" | awk '{print $1}')
TAP_DIR="/tmp/markdownViewr-homebrew-tap"
rm -rf "$TAP_DIR"
git clone git@github.com:darinkelkhoff/homebrew-tap.git "$TAP_DIR"
sed -i.bak "s/version \".*\"/version \"$VERSION\"/" "$TAP_DIR/Casks/markdownviewr.rb"
sed -i.bak "s/sha256 \".*\"/sha256 \"$DMG_SHA\"/" "$TAP_DIR/Casks/markdownviewr.rb"
rm -f "$TAP_DIR/Casks/markdownviewr.rb.bak"
git -C "$TAP_DIR" add Casks/markdownviewr.rb
git -C "$TAP_DIR" commit -m "markdownViewr: update to v$VERSION"
git -C "$TAP_DIR" push origin main
echo "Done! brew install --cask darinkelkhoff/tap/markdownviewr"
# Regenerate the Xcode project from project.yml
generate:
@@ -30,17 +56,37 @@ release: kill
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> Regenerating Xcode project..."
xcodegen generate
LATEST=$(gh release list --repo darinkelkhoff/markdownViewr --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "none")
if [[ "$LATEST" == "v$VERSION" ]]; then
echo "Error: v$VERSION is already released. Bump MARKETING_VERSION in project.yml first."
exit 1
fi
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
echo "Error: local tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
echo "Error: remote tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
exit 1
fi
ARCHIVE="/tmp/markdownViewr.xcarchive"
EXPORT="/tmp/markdownViewr-export"
ZIP="/tmp/markdownViewr-$VERSION.zip"
echo "==> Archiving v$VERSION..."
echo "==> Archiving v$VERSION (build $BUILD_NUMBER)..."
rm -rf "$ARCHIVE" "$EXPORT" "$ZIP"
xcodebuild archive \
-project markdownViewr.xcodeproj \
-scheme markdownViewr \
-archivePath "$ARCHIVE" \
-destination 'generic/platform=macOS' \
ENABLE_HARDENED_RUNTIME=YES | xcpretty || true
ENABLE_HARDENED_RUNTIME=YES \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
echo "==> Exporting with Developer ID signing..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
@@ -58,11 +104,22 @@ release: kill
xcrun stapler staple "$APP"
echo "==> Packaging final release..."
ditto -c -k --keepParent "$APP" "$ZIP"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
--title "markdownViewr v$VERSION" \
--generate-notes
echo "==> Creating DMG..."
DMG="/tmp/markdownViewr.dmg"
DMG_STAGING="/tmp/markdownViewr-dmg-staging"
rm -f "$DMG"
rm -rf "$DMG_STAGING"
mkdir "$DMG_STAGING"
cp -R "$APP" "$DMG_STAGING/"
create-dmg \
--volname "markdownViewr" \
--window-size 660 400 \
--icon-size 128 \
--icon "markdownViewr.app" 180 185 \
--hide-extension "markdownViewr.app" \
--app-drop-link 480 185 \
"$DMG" \
"$DMG_STAGING/"
echo "==> Updating appcast..."
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
if [[ -z "$SPARKLE_BIN" ]]; then
@@ -79,6 +136,76 @@ release: kill
echo "==> Committing and pushing appcast..."
git add appcast.xml
git commit -m "release: update appcast for v$VERSION"
echo "==> Tagging release v$VERSION..."
git tag -a "v$VERSION" -m "markdownViewr v$VERSION"
git push origin main
git push origin "v$VERSION"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$DMG" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
--title "markdownViewr v$VERSION" \
--generate-notes
just update-cask
echo ""
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
echo " brew install --cask darinkelkhoff/tap/markdownviewr"
# Archive and export a signed App Store .pkg (then upload via Transporter or altool)
release-mas: kill
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> Regenerating Xcode project..."
xcodegen generate
ARCHIVE="/tmp/markdownViewr-mas.xcarchive"
EXPORT="/tmp/markdownViewr-mas-export"
rm -rf "$ARCHIVE" "$EXPORT"
echo "==> Archiving v$VERSION (build $BUILD_NUMBER) for the App Store..."
xcodebuild archive \
-project markdownViewr.xcodeproj \
-scheme markdownViewr-MAS \
-configuration Release \
-archivePath "$ARCHIVE" \
-destination 'generic/platform=macOS' \
-allowProvisioningUpdates \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
echo "==> Exporting App Store package..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT" \
-exportOptionsPlist ExportOptions-MAS.plist \
-allowProvisioningUpdates
PKG=$(find "$EXPORT" -name '*.pkg' | head -1)
echo ""
echo "Done! Signed App Store package:"
echo " $PKG"
echo ""
echo "Upload it with: just upload-mas"
# Key ID is read from the AuthKey_<id>.p8 filename in ~/.appstoreconnect/private_keys/;
# issuer id from ~/.appstoreconnect/issuer_id (or the ASC_ISSUER_ID env var).
# Validate and upload the exported App Store .pkg to App Store Connect
upload-mas:
#!/usr/bin/env bash
set -euo pipefail
KEYS_DIR="$HOME/.appstoreconnect/private_keys"
KEY_FILE=$(ls "$KEYS_DIR"/AuthKey_*.p8 2>/dev/null | head -1)
[[ -n "$KEY_FILE" ]] || { echo "No AuthKey_*.p8 in $KEYS_DIR"; exit 1; }
KEY_ID=$(basename "$KEY_FILE" .p8); KEY_ID=${KEY_ID#AuthKey_}
ISSUER_ID="${ASC_ISSUER_ID:-$(cat "$HOME/.appstoreconnect/issuer_id" 2>/dev/null || true)}"
[[ -n "$ISSUER_ID" ]] || { echo "Set ASC_ISSUER_ID or write it to ~/.appstoreconnect/issuer_id"; exit 1; }
PKG="/tmp/markdownViewr-mas-export/markdownViewr.pkg"
[[ -f "$PKG" ]] || { echo "No package at $PKG — run 'just release-mas' first."; exit 1; }
echo "==> Validating (key $KEY_ID)..."
xcrun altool --validate-app -f "$PKG" -t macos --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
echo "==> Uploading..."
xcrun altool --upload-app -f "$PKG" -t macos --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
echo "Done! Watch App Store Connect -> your app -> Build for processing (a few minutes)."
# Show App Store Connect build + version processing state (uses the same API key)
asc-builds:
python3 scripts/asc-builds.py
+125 -13
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 {
File diff suppressed because it is too large. Load diff
+35
View File
@@ -23,6 +23,24 @@ struct EditorConfig: Codable, Identifiable, Hashable {
}
}
enum ExternalEditorMenuState: Equatable {
case disabled
case single(EditorConfig, title: String)
case submenu([EditorConfig])
init(editors: [EditorConfig]) {
switch editors.count {
case 0:
self = .disabled
case 1:
let editor = editors[0]
self = .single(editor, title: "Open in \(editor.name)")
default:
self = .submenu(editors)
}
}
}
class EditorManager: ObservableObject {
@Published var editors: [EditorConfig] {
didSet {
@@ -51,6 +69,21 @@ class EditorManager: ObservableObject {
editors.removeAll { $0.id == id }
}
func moveEditor(id: UUID, by offset: Int) {
guard let source = editors.firstIndex(where: { $0.id == id }) else { return }
let destination = source + offset
guard editors.indices.contains(destination) else { return }
var reorderedEditors = editors
reorderedEditors.swapAt(source, destination)
editors = reorderedEditors
}
func setOpensFolder(_ opensFolder: Bool, for id: UUID) {
guard let index = editors.firstIndex(where: { $0.id == id }) else { return }
editors[index].opensFolder = opensFolder
}
private func save() {
if let data = try? JSONEncoder().encode(editors) {
UserDefaults.standard.set(data, forKey: "configuredEditors")
@@ -70,6 +103,8 @@ class EditorManager: ObservableObject {
: fileURL
let config = NSWorkspace.OpenConfiguration()
config.activates = true
config.allowsRunningApplicationSubstitution = false
workspace.open([urlToOpen], withApplicationAt: appURL, configuration: config)
}
}
+215
View File
@@ -0,0 +1,215 @@
import AppKit
import Combine
enum FileMenuPruner {
private static let removedTitles: Set<String> = ["New", "Save", "Save...", "Revert To"]
static func removeEditingItems(from menu: NSMenu) {
for item in menu.items.reversed() where shouldRemove(item) {
menu.removeItem(item)
}
}
static func removeEditingItems(from mainMenu: NSMenu?) {
guard let fileMenu = fileMenu(in: mainMenu) else { return }
removeEditingItems(from: fileMenu)
}
static func fileMenu(in mainMenu: NSMenu?) -> NSMenu? {
mainMenu?.item(withTitle: "File")?.submenu
}
private static func shouldRemove(_ item: NSMenuItem) -> Bool {
let title = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
return removedTitles.contains(title) || title.hasPrefix("Revert To")
}
}
final class FileMenuPrunerApplicationDelegate: NSObject, NSApplicationDelegate {
private var menuObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) {
installMenuObserver()
DispatchQueue.main.async {
self.configureFileMenu()
}
}
deinit {
if let menuObserver {
NotificationCenter.default.removeObserver(menuObserver)
}
}
private func installMenuObserver() {
guard menuObserver == nil else { return }
menuObserver = NotificationCenter.default.addObserver(
forName: NSMenu.didBeginTrackingNotification,
object: nil,
queue: .main
) { notification in
guard let menu = notification.object as? NSMenu,
menu === FileMenuPruner.fileMenu(in: NSApp.mainMenu)
else { return }
self.updateFileMenu(menu)
}
}
private func configureFileMenu() {
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApp.mainMenu) else { return }
updateFileMenu(fileMenu)
}
private func updateFileMenu(_ fileMenu: NSMenu) {
FileMenuPruner.removeEditingItems(from: fileMenu)
ExternalEditorFileMenuController.shared.update(in: fileMenu)
}
}
final class ExternalEditorFileMenuController: NSObject, NSMenuItemValidation {
static let shared = ExternalEditorFileMenuController()
var currentFileURLProvider: () -> URL? = {
ExternalEditorFileMenuController.currentOpenDocumentFileURL()
}
weak var editorManager: EditorManager? {
didSet {
editorSubscription = editorManager?.$editors.sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateMainMenu()
}
}
updateMainMenu()
}
}
private let dividerIdentifier = "markdownViewr.externalEditor.divider"
private let itemIdentifier = "markdownViewr.externalEditor.item"
private var editorSubscription: AnyCancellable?
private var dividerMenuIdentifier: NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier(dividerIdentifier)
}
private var itemMenuIdentifier: NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier(itemIdentifier)
}
static func currentOpenDocumentFileURL() -> URL? {
let documentController = NSDocumentController.shared
return currentOpenDocumentFileURL(in: [documentController.currentDocument] + documentController.documents)
}
static func currentOpenDocumentFileURL(in documents: [NSDocument?]) -> URL? {
for document in documents {
guard let document else { continue }
guard document.windowControllers.contains(where: { $0.window?.isVisible == true }) else { continue }
if let fileURL = document.fileURL {
return fileURL
}
}
return nil
}
func updateMainMenu() {
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApplication.shared.mainMenu) else { return }
update(in: fileMenu)
}
func update(in menu: NSMenu) {
removeExistingItems(from: menu)
let separator = NSMenuItem.separator()
separator.identifier = dividerMenuIdentifier
let externalEditorItem = makeExternalEditorItem()
externalEditorItem.identifier = itemMenuIdentifier
let insertionIndex = externalEditorInsertionIndex(in: menu)
menu.insertItem(separator, at: insertionIndex)
menu.insertItem(externalEditorItem, at: insertionIndex + 1)
}
private func makeExternalEditorItem() -> NSMenuItem {
let editors = editorManager?.editors ?? []
let hasOpenFile = currentFileURLProvider() != nil
switch ExternalEditorMenuState(editors: editors) {
case .disabled:
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
item.isEnabled = false
return item
case let .single(editor, title):
return makeEditorMenuItem(title: title, editor: editor, keyEquivalent: "e", isEnabled: hasOpenFile)
case let .submenu(editors):
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
let submenu = NSMenu(title: "Open in External Editor")
for (index, editor) in editors.enumerated() {
submenu.addItem(makeEditorMenuItem(
title: editor.name,
editor: editor,
keyEquivalent: index == 0 ? "e" : "",
isEnabled: hasOpenFile
))
}
item.submenu = submenu
return item
}
}
private func makeEditorMenuItem(
title: String,
editor: EditorConfig,
keyEquivalent: String = "",
isEnabled: Bool = true
) -> NSMenuItem {
let item = NSMenuItem(
title: title,
action: isEnabled ? #selector(openInExternalEditor(_:)) : nil,
keyEquivalent: keyEquivalent
)
item.target = isEnabled ? self : nil
item.representedObject = editor.id.uuidString
item.isEnabled = isEnabled
item.keyEquivalentModifierMask = [.command]
return item
}
@objc private func openInExternalEditor(_ sender: NSMenuItem) {
guard let editorIDString = sender.representedObject as? String,
let editorID = UUID(uuidString: editorIDString),
let editor = editorManager?.editors.first(where: { $0.id == editorID }),
let fileURL = currentFileURLProvider()
else { return }
editorManager?.openFile(fileURL, with: editor)
}
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
guard menuItem.representedObject as? String != nil else { return true }
return currentFileURLProvider() != nil
}
private func removeExistingItems(from menu: NSMenu) {
for item in menu.items.reversed() {
if item.identifier == dividerMenuIdentifier || item.identifier == itemMenuIdentifier {
menu.removeItem(item)
}
}
}
private func externalEditorInsertionIndex(in menu: NSMenu) -> Int {
if let openRecentIndex = menu.items.firstIndex(where: { item in
let normalizedTitle = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
return normalizedTitle == "Open Recent"
}) {
return openRecentIndex + 1
}
return min(menu.items.count, 1)
}
}
+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
}
+193 -6
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:
@@ -248,7 +435,7 @@ struct HelpView: View {
Enable **Opens folder instead of file** if the editor works better when opened \
to the containing directory rather than the file itself — useful for \
project-based editors like VS Code or Nova, terminal emulators like Terminal \
project-based editors like VS Code, terminal emulators like Terminal \
(`/System/Applications/Utilities/Terminal.app`), or Finder \
(`/System/Library/CoreServices/Finder.app`).
+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"
}
}
}
+4 -2
View File
@@ -38,9 +38,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml</string>
<key>SUPublicEDKey</key>
+7 -2
View File
@@ -275,7 +275,7 @@ struct MarkdownDocument: FileDocument {
private struct HTMLConverter: MarkupWalker {
private var result = ""
mutating func visit(_ document: Document) -> String {
mutating func visit(_ document: Markdown.Document) -> String {
result = ""
for child in document.children {
visitChild(child)
@@ -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 {
+225 -27
View File
@@ -1,15 +1,28 @@
import SwiftUI
#if !MAS_BUILD
import Sparkle
#endif
@main
struct MarkdownViewrApp: App {
@NSApplicationDelegateAdaptor(FileMenuPrunerApplicationDelegate.self) private var appDelegate
#if !MAS_BUILD
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
#endif
@StateObject private var themeManager = ThemeManager()
@StateObject private var editorManager = EditorManager()
@StateObject private var editorManager: EditorManager
@FocusedValue(\.documentViewCommands) private var documentViewCommands
init() {
let editorManager = EditorManager()
_editorManager = StateObject(wrappedValue: editorManager)
ExternalEditorFileMenuController.shared.editorManager = editorManager
}
var body: some Scene {
DocumentGroup(viewing: MarkdownDocument.self) { file in
@@ -21,14 +34,16 @@ struct MarkdownViewrApp: App {
.environmentObject(editorManager)
.background(DocumentWindowConfigurator(fileURL: file.fileURL))
}
.defaultSize(width: 700, height: 900)
.defaultSize(width: 1100, height: 800)
.commands {
#if !MAS_BUILD
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterController.checkForUpdates(nil)
}
.disabled(!updaterController.updater.canCheckForUpdates)
}
#endif
CommandGroup(replacing: .appSettings) {
Button("Settings...") {
@@ -40,6 +55,14 @@ struct MarkdownViewrApp: App {
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .printItem) {
Button("Print...") {
documentViewCommands?.printDocument()
}
.keyboardShortcut("p", modifiers: .command)
.disabled(documentViewCommands == nil)
}
CommandGroup(replacing: .textEditing) {
Button("Find...") {
NotificationCenter.default.post(name: .findToggle, object: nil)
@@ -64,54 +87,229 @@ struct MarkdownViewrApp: App {
.keyboardShortcut("?", modifiers: .command)
}
CommandGroup(replacing: .toolbar) {
Button("Zoom In") {
themeManager.zoomIn()
}
.keyboardShortcut("+", modifiers: .command)
Button("Zoom Out") {
themeManager.zoomOut()
}
.keyboardShortcut("-", modifiers: .command)
Button("Actual Size") {
themeManager.zoomReset()
}
.keyboardShortcut("0", modifiers: .command)
CommandGroup(before: .toolbar) {
DocumentViewCommandItems()
}
CommandMenu("Theme") {
Button("Next Theme") {
themeManager.cycleTheme(direction: 1)
documentViewCommands?.nextTheme()
}
.keyboardShortcut(.downArrow, modifiers: [.command, .shift])
.disabled(documentViewCommands == nil)
Button("Previous Theme") {
themeManager.cycleTheme(direction: -1)
documentViewCommands?.previousTheme()
}
.keyboardShortcut(.upArrow, modifiers: [.command, .shift])
.disabled(documentViewCommands == nil)
}
}
}
}
struct DocumentViewCommandItems: View {
@FocusedValue(\.documentViewCommands) private var documentViewCommands
var body: some View {
Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))
.keyboardShortcut("t", modifiers: .command)
.disabled(documentViewCommands == nil)
Menu("Table of Contents Depth") {
ForEach(1...6, id: \.self) { depth in
Button {
documentViewCommands?.setTocDepth(depth)
} label: {
if documentViewCommands?.tocDepth == depth {
Label("H\(depth)", systemImage: "checkmark")
} else {
Text("H\(depth)")
}
}
}
}
.disabled(documentViewCommands == nil)
Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))
.keyboardShortcut("u", modifiers: .command)
.disabled(documentViewCommands == nil)
Divider()
Button("Zoom In") {
documentViewCommands?.zoomIn()
}
.keyboardShortcut("+", modifiers: .command)
.disabled(documentViewCommands == nil)
Button("Zoom Out") {
documentViewCommands?.zoomOut()
}
.keyboardShortcut("-", modifiers: .command)
.disabled(documentViewCommands == nil)
Button("Actual Size") {
documentViewCommands?.zoomReset()
}
.keyboardShortcut("0", modifiers: .command)
.disabled(documentViewCommands == nil)
}
}
struct DocumentWindowConfigurator: NSViewRepresentable {
let fileURL: URL?
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize {
let width: CGFloat
switch (tocVisible, rawVisible) {
case (true, true):
width = 1500
case (false, true):
width = 1350
case (true, false):
width = 1250
case (false, false):
width = 1100
}
return NSSize(width: width, height: 800)
}
private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()
static func shouldApplyDocumentFrame(isTabbedWindow: Bool, hasTabbedHistory: Bool = false) -> Bool {
!isTabbedWindow && !hasTabbedHistory
}
static func autosaveName(for fileURL: URL) -> String {
var allowedCharacters = CharacterSet.alphanumerics
allowedCharacters.insert(charactersIn: "-_.")
let stablePath = fileURL.standardizedFileURL.path
let encodedPath = stablePath.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? stablePath
return "doc-\(encodedPath)"
}
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window, let fileURL else { return }
let autosaveName = "doc-\(fileURL.path.hashValue)"
if !window.setFrameAutosaveName(autosaveName) {
window.setFrameUsingName(autosaveName)
}
configureWindow(for: view, context: context)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
configureWindow(for: nsView, context: context)
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
private func configureWindow(for view: NSView, context: Context) {
guard let window = view.window, let fileURL else { return }
window.isRestorable = false
let autosaveName = Self.autosaveName(for: fileURL)
guard context.coordinator.needsConfiguration(for: window, autosaveName: autosaveName) else { return }
let isTabbedWindow = Self.isTabbedWindow(window)
if isTabbedWindow {
Self.markTabGroupHasTabbedHistory(window)
context.coordinator.stopSavingFrame(for: window)
return
}
let hasTabbedHistory = Self.windowHasTabbedHistory(window)
if Self.shouldApplyDocumentFrame(isTabbedWindow: isTabbedWindow, hasTabbedHistory: hasTabbedHistory) {
let restoredFrame = window.setFrameUsingName(autosaveName)
if !restoredFrame {
window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))
window.center()
}
}
if !hasTabbedHistory {
window.setFrameAutosaveName(autosaveName)
}
context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)
}
private static func isTabbedWindow(_ window: NSWindow) -> Bool {
window.tabbedWindows?.contains { $0 !== window } == true
}
private static func markTabGroupHasTabbedHistory(_ window: NSWindow) {
let tabGroupWindows = window.tabbedWindows ?? [window]
tabGroupWindows.forEach {
windowsWithTabbedHistory.add($0)
$0.isRestorable = false
$0.setFrameAutosaveName("")
}
}
private static func windowHasTabbedHistory(_ window: NSWindow) -> Bool {
windowsWithTabbedHistory.contains(window)
}
class Coordinator {
private weak var configuredWindow: NSWindow?
private var configuredAutosaveName: String?
private var observerTokens: [NSObjectProtocol] = []
func needsConfiguration(for window: NSWindow, autosaveName: String) -> Bool {
configuredWindow !== window || configuredAutosaveName != autosaveName
}
func configureSavingFrame(for window: NSWindow, autosaveName: String) {
removeObservers()
configuredWindow = window
configuredAutosaveName = autosaveName
let center = NotificationCenter.default
let saveFrame: (Notification) -> Void = { [weak window] _ in
window?.saveFrame(usingName: autosaveName)
}
observerTokens = [
center.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main,
using: saveFrame
),
center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main,
using: saveFrame
),
center.addObserver(
forName: NSWindow.didMoveNotification,
object: window,
queue: .main,
using: saveFrame
)
]
window.saveFrame(usingName: autosaveName)
}
func stopSavingFrame(for window: NSWindow) {
removeObservers()
configuredWindow = nil
configuredAutosaveName = nil
}
deinit {
removeObservers()
}
private func removeObservers() {
let center = NotificationCenter.default
observerTokens.forEach { center.removeObserver($0) }
observerTokens = []
}
}
}
class HelpWindowController: NSObject, NSWindowDelegate {
@@ -164,10 +362,10 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
.environmentObject(editorManager)
let hostingView = NSHostingView(rootView: settingsView)
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 600),
contentRect: NSRect(x: 0, y: 0, width: 600, height: 720),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
+137 -2
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
@@ -42,6 +62,11 @@ struct MarkdownWebView: NSViewRepresentable {
injectCSS(in: webView)
}
if !context.coordinator.isPageLoaded {
queueInitialState(context: context)
return
}
if context.coordinator.lastTocVisible != tocVisible {
context.coordinator.lastTocVisible = tocVisible
webView.evaluateJavaScript("setTOCVisible(\(tocVisible))") { _, _ in }
@@ -50,6 +75,30 @@ struct MarkdownWebView: NSViewRepresentable {
context.coordinator.lastTocDepth = tocDepth
webView.evaluateJavaScript("setTOCDepth(\(tocDepth))") { _, _ in }
}
if context.coordinator.lastTocWidth != tocWidth {
context.coordinator.lastTocWidth = tocWidth
webView.evaluateJavaScript("setTOCWidth(\(tocWidth))") { _, _ in }
}
if context.coordinator.lastTocWrap != tocWrap {
context.coordinator.lastTocWrap = tocWrap
webView.evaluateJavaScript("setTOCWrap(\(tocWrap))") { _, _ in }
}
if context.coordinator.lastTocBullets != tocBullets {
context.coordinator.lastTocBullets = tocBullets
webView.evaluateJavaScript("setTOCBullets(\(tocBullets))") { _, _ in }
}
if context.coordinator.lastRawSource != rawSource {
context.coordinator.lastRawSource = rawSource
webView.evaluateJavaScript("setRawSource(`\(Self.jsTemplateEscape(rawSource))`)") { _, _ in }
}
if context.coordinator.lastRawWidth != rawWidth {
context.coordinator.lastRawWidth = rawWidth
webView.evaluateJavaScript("setRawWidth(\(rawWidth))") { _, _ in }
}
if context.coordinator.lastRawVisible != rawVisible {
context.coordinator.lastRawVisible = rawVisible
webView.evaluateJavaScript("setRawVisible(\(rawVisible))") { _, _ in }
}
}
private static let previewDirectory: URL = {
@@ -72,29 +121,51 @@ struct MarkdownWebView: NSViewRepresentable {
context.coordinator.lastHTML = html
context.coordinator.lastCSS = themeCSS
context.coordinator.isPageLoaded = false
#if MAS_BUILD
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
// both unreadable and unnecessary.
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
#else
if let fileDir = fileURL?.deletingLastPathComponent() {
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
} else {
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
}
#endif
template = template.replacingOccurrences(of: "{{THEME_CSS}}", with: themeCSS)
template = template.replacingOccurrences(of: "{{CONTENT}}", with: html)
if let fileDir = fileURL?.deletingLastPathComponent() {
if fileURL != nil {
let tempDir = Self.previewDirectory
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
#if MAS_BUILD
// Self-contained HTML (images inlined); only the container temp dir is needed.
webView.loadFileURL(tempHTML, allowingReadAccessTo: tempDir)
#else
// Grant read access to "/" so both the temp file and the document's images are accessible
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
#endif
context.coordinator.tempFileURL = tempHTML
} else {
webView.loadHTMLString(template, baseURL: nil)
}
// Queue TOC state to apply after page loads
// Queue document state to apply after page loads.
queueInitialState(context: context)
}
private func queueInitialState(context: Context) {
context.coordinator.pendingTocVisible = tocVisible
context.coordinator.pendingTocDepth = tocDepth
context.coordinator.pendingTocWidth = tocWidth
context.coordinator.pendingTocWrap = tocWrap
context.coordinator.pendingTocBullets = tocBullets
context.coordinator.pendingRawSource = rawSource
context.coordinator.pendingRawWidth = rawWidth
context.coordinator.pendingRawVisible = rawVisible
}
private func updateContent(in webView: WKWebView) {
@@ -128,8 +199,23 @@ struct MarkdownWebView: NSViewRepresentable {
var lastCSS: String = ""
var lastTocVisible: Bool = false
var lastTocDepth: Int = 3
var lastTocWidth: Double = 220
var lastTocWrap: Bool = false
var lastTocBullets: Bool = false
var lastRawSource: String = ""
var lastRawVisible: Bool = false
var lastRawWidth: Double = 400
var isPageLoaded = false
var pendingTocVisible: Bool?
var pendingTocDepth: Int?
var pendingTocWidth: Double?
var pendingTocWrap: Bool?
var pendingTocBullets: Bool?
var pendingRawSource: String?
var pendingRawVisible: Bool?
var pendingRawWidth: Double?
var onTocWidthChange: ((Double) -> Void)?
var onRawWidthChange: ((Double) -> Void)?
var tempFileURL: URL?
private var findBarObservation: Any?
private var lastSearchText: String = ""
@@ -227,6 +313,22 @@ struct MarkdownWebView: NSViewRepresentable {
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
if message.name == "tocWidth" {
guard let width = (message.body as? NSNumber)?.doubleValue else { return }
lastTocWidth = width
DispatchQueue.main.async { [weak self] in
self?.onTocWidthChange?(width)
}
return
}
if message.name == "rawWidth" {
guard let width = (message.body as? NSNumber)?.doubleValue else { return }
lastRawWidth = width
DispatchQueue.main.async { [weak self] in
self?.onRawWidthChange?(width)
}
return
}
guard message.name == "vim", let action = message.body as? String else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
@@ -240,6 +342,22 @@ struct MarkdownWebView: NSViewRepresentable {
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
isPageLoaded = true
if let width = pendingTocWidth {
lastTocWidth = width
webView.evaluateJavaScript("setTOCWidth(\(width))") { _, _ in }
pendingTocWidth = nil
}
if let wrap = pendingTocWrap {
lastTocWrap = wrap
webView.evaluateJavaScript("setTOCWrap(\(wrap))") { _, _ in }
pendingTocWrap = nil
}
if let bullets = pendingTocBullets {
lastTocBullets = bullets
webView.evaluateJavaScript("setTOCBullets(\(bullets))") { _, _ in }
pendingTocBullets = nil
}
if let visible = pendingTocVisible {
lastTocVisible = visible
webView.evaluateJavaScript("setTOCVisible(\(visible))") { _, _ in }
@@ -250,6 +368,23 @@ struct MarkdownWebView: NSViewRepresentable {
webView.evaluateJavaScript("setTOCDepth(\(depth))") { _, _ in }
pendingTocDepth = nil
}
if let source = pendingRawSource {
lastRawSource = source
webView.evaluateJavaScript("setRawSource(`\(MarkdownWebView.jsTemplateEscape(source))`)") { _, _ in }
pendingRawSource = nil
}
if let width = pendingRawWidth {
lastRawWidth = width
webView.evaluateJavaScript("setRawWidth(\(width))") { _, _ in }
pendingRawWidth = nil
}
if let visible = pendingRawVisible {
lastRawVisible = visible
webView.evaluateJavaScript("setRawVisible(\(visible))") { _, _ in }
pendingRawVisible = nil
}
// Initial TOC state is set; re-enable transitions so toggles animate.
webView.evaluateJavaScript("enableTOCTransitions()") { _, _ in }
}
}
}
File diff suppressed because it is too large. Load diff
+365 -21
View File
@@ -23,22 +23,45 @@ struct SettingsView: View {
.tabItem {
Label("Markdown", systemImage: "doc.plaintext")
}
PrintingSettingsView()
.tabItem {
Label("Printing", systemImage: "printer")
}
}
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
}
private struct SettingsScrollView<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView(.vertical) {
content
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(20)
}
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
}
struct GeneralSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
@AppStorage("defaultTocDepth") private var defaultTocDepth = 3
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
@AppStorage("tocWrap") private var tocWrap = false
@AppStorage("tocBullets") private var tocBullets = false
@State private var isDefault = false
@State private var checkPerformed = false
@State private var showCSSHelp = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("General")
.font(.headline)
.padding(.bottom, 16)
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("General")
.font(.headline)
.padding(.bottom, 16)
GroupBox {
HStack {
@@ -86,6 +109,74 @@ struct GeneralSettingsView: View {
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 8) {
Toggle("Max Content Width", isOn: $themeManager.contentWidthEnabled)
.toggleStyle(.checkbox)
if themeManager.contentWidthEnabled {
HStack(spacing: 10) {
Slider(value: $themeManager.contentWidthPx, in: 600...2400)
.onChange(of: themeManager.contentWidthPx) { v in
themeManager.contentWidthPx = (v / 10).rounded() * 10
}
Text(String(format: "%d px", Int(themeManager.contentWidthPx)))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 52, alignment: .trailing)
}
}
}
.padding(4)
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 10) {
Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)
.toggleStyle(.checkbox)
HStack {
Text("Default Table of Contents depth")
Spacer()
Picker("Default Table of Contents depth", selection: $defaultTocDepth) {
ForEach(1...6, id: \.self) { depth in
Text("H\(depth)").tag(depth)
}
}
.labelsHidden()
.frame(width: 90)
}
Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)
.toggleStyle(.checkbox)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(4)
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 6) {
Toggle("Wrap long entries in the Table of Contents", isOn: $tocWrap)
.toggleStyle(.checkbox)
Text("When off, long headings are truncated with an ellipsis — hover an entry to see its full text.")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
Toggle("Show depth markers in the Table of Contents", isOn: $tocBullets)
.toggleStyle(.checkbox)
Text("Marks each entry with a shape that varies by heading level (●, ○, ▪, –) to show depth.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(4)
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 6) {
HStack {
@@ -116,8 +207,8 @@ struct GeneralSettingsView: View {
.padding(.top, 12)
Spacer()
}
}
.padding(20)
.onAppear {
checkIfDefault()
}
@@ -162,6 +253,7 @@ struct GeneralSettingsView: View {
struct EditorsSettingsView: View {
@EnvironmentObject var editorManager: EditorManager
@State private var selectedEditorID: UUID?
private let editorActionButtonSize: CGFloat = 28
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -222,7 +314,10 @@ struct EditorsSettingsView: View {
addEditor()
} label: {
Image(systemName: "plus")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
Button {
if let id = selectedEditorID {
@@ -231,16 +326,45 @@ struct EditorsSettingsView: View {
}
} label: {
Image(systemName: "minus")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(selectedEditorID == nil)
Divider()
.frame(height: editorActionButtonSize)
.padding(.horizontal, 2)
Button {
moveSelectedEditor(by: -1)
} label: {
Image(systemName: "chevron.up")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Move selected editor up")
.disabled(!canMoveSelectedEditorUp)
Button {
moveSelectedEditor(by: 1)
} label: {
Image(systemName: "chevron.down")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Move selected editor down")
.disabled(!canMoveSelectedEditorDown)
Spacer()
if let id = selectedEditorID,
let index = editorManager.editors.firstIndex(where: { $0.id == id }) {
Toggle("Opens folder instead of file", isOn: Binding(
get: { editorManager.editors[index].opensFolder },
set: { editorManager.editors[index].opensFolder = $0 }
set: { editorManager.setOpensFolder($0, for: id) }
))
.font(.caption)
}
@@ -250,6 +374,24 @@ struct EditorsSettingsView: View {
.padding(20)
}
private var selectedEditorIndex: Int? {
guard let selectedEditorID else { return nil }
return editorManager.editors.firstIndex { $0.id == selectedEditorID }
}
private var canMoveSelectedEditorUp: Bool {
selectedEditorIndex.map { $0 > 0 } ?? false
}
private var canMoveSelectedEditorDown: Bool {
selectedEditorIndex.map { $0 < editorManager.editors.count - 1 } ?? false
}
private func moveSelectedEditor(by offset: Int) {
guard let selectedEditorID else { return }
editorManager.moveEditor(id: selectedEditorID, by: offset)
}
private func addEditor() {
let panel = NSOpenPanel()
panel.title = "Select Application"
@@ -271,15 +413,16 @@ struct MarkdownSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
GroupBox {
VStack(spacing: 0) {
@@ -323,8 +466,8 @@ struct MarkdownSettingsView: View {
}
Spacer()
}
}
.padding(20)
}
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
@@ -343,6 +486,144 @@ struct MarkdownSettingsView: View {
}
}
struct PrintingSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
@AppStorage("printTheme") private var printTheme = "Clean Printing"
@AppStorage("printBackgrounds") private var printBackgrounds = false
@AppStorage("printImages") private var printImages = true
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
@AppStorage("printBorders") private var printBorders = false
@AppStorage("printPagePadding") private var printPagePadding = "0px"
var body: some View {
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Printing")
.font(.headline)
.padding(.bottom, 16)
GroupBox(label: Text("Print Theme")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Theme")
Spacer()
Picker("Print Theme", selection: $printTheme) {
Text("Active Screen Theme").tag("Active Theme")
Text("Plain HTML (Unstyled)").tag("Plain HTML")
Text("Clean Printing").tag("Clean Printing")
Divider()
ForEach(themeManager.themes, id: \.name) { theme in
Text(theme.name).tag(theme.name)
}
}
.frame(width: 200)
.labelsHidden()
}
Text("Select the theme to use when printing or exporting to PDF.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
GroupBox(label: Text("Page Layout")) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Include Background Colors", isOn: $printBackgrounds)
.toggleStyle(.checkbox)
Text("When enabled, code block backgrounds, blockquotes, and highlights will print with their theme colors. Disable to save ink.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
Toggle("Include Images", isOn: $printImages)
.toggleStyle(.checkbox)
Text("When enabled, inline and document images will print. Disable to save color ink and space.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
Toggle("Include Content Borders", isOn: $printBorders)
.toggleStyle(.checkbox)
Text("When enabled, adds borders to styled blocks like code blocks, blockquotes, and tables to guarantee contrast with page background.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
HStack {
Text("Page Padding")
Spacer()
Picker("Page Padding", selection: $printPagePadding) {
Text("None (0px)").tag("0px")
Text("Small (24px)").tag("24px")
Text("Medium (48px)").tag("48px")
Text("Large (72px)").tag("72px")
}
.frame(width: 200)
.labelsHidden()
}
Text("Add margins inside the background color container to keep text away from the page edge.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
.padding(.top, 12)
GroupBox(label: Text("Zoom & Scaling")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Font Scaling")
Spacer()
Picker("Font Scaling", selection: $printZoom) {
Text("Compact (75%)").tag("Compact (75%)")
Text("Small (85%)").tag("Small (85%)")
Text("Standard (100%)").tag("Standard (100%)")
Text("Large (115%)").tag("Large (115%)")
Divider()
Text("Match Screen").tag("Match Screen")
}
.frame(width: 200)
.labelsHidden()
}
Text("Select the scale for printed document fonts, or match current screen zoom.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
HStack {
Text("Content Width")
Spacer()
Picker("Content Width", selection: $printContentWidth) {
Text("Full Page Width").tag("Full Page")
Text("Match Screen Width").tag("Match Screen")
}
.frame(width: 200)
.labelsHidden()
}
Text("Select whether page layout uses full printable bounds or conforms to screen width guidelines.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
.padding(.top, 12)
}
}
}
}
struct ThemeEditorItem: Identifiable {
let id = UUID()
let theme: Theme?
@@ -369,10 +650,6 @@ struct ThemeSettingsView: View {
.padding(.bottom, 8)
HStack {
Text("Drag to reorder. Cmd+Shift+↑/↓ cycles through enabled themes.")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
HStack(spacing: 0) {
@@ -456,7 +733,7 @@ struct ThemeSettingsView: View {
editorItem = ThemeEditorItem(theme: nil)
}
Button("Duplicate") {
Button("Copy") {
guard let theme = selectedTheme else { return }
var copy = theme
copy.name = theme.name + " Copy"
@@ -496,6 +773,15 @@ struct ThemeSettingsView: View {
}
.disabled(selectedThemeName == nil || selectedThemeIndex == themeManager.allThemes.count - 1)
Button("Import...") {
importThemes()
}
Button("Export...") {
if let theme = selectedTheme { exportTheme(theme) }
}
.disabled(selectedThemeName == nil)
Spacer()
Button("Restore Built-ins") {
@@ -536,6 +822,64 @@ struct ThemeSettingsView: View {
}
}
}
private func importThemes() {
let panel = NSOpenPanel()
panel.title = "Import Themes"
panel.allowedContentTypes = [UTType.json]
panel.allowsMultipleSelection = true
panel.canChooseFiles = true
panel.canChooseDirectories = false
guard panel.runModal() == .OK else { return }
for url in panel.urls {
guard let data = try? Data(contentsOf: url),
let theme = try? JSONDecoder().decode(Theme.self, from: data)
else { continue }
if theme.schemaVersion > Theme.currentSchemaVersion {
let alert = NSAlert()
alert.messageText = "\"\(theme.name)\" was made with a newer version of markdownViewr"
alert.informativeText = "It may not display correctly. Import anyway?"
alert.addButton(withTitle: "Import")
alert.addButton(withTitle: "Skip")
if alert.runModal() != .alertFirstButtonReturn { continue }
}
let conflict = themeManager.allThemes.contains { $0.name == theme.name }
if conflict {
let alert = NSAlert()
alert.messageText = "\"\(theme.name)\" already exists"
alert.informativeText = "Choose how to handle the imported copy."
alert.addButton(withTitle: "Overwrite")
alert.addButton(withTitle: "Keep Both")
alert.addButton(withTitle: "Skip")
let resolution: ThemeManager.ImportConflictResolution
switch alert.runModal() {
case .alertFirstButtonReturn: resolution = .overwrite
case .alertSecondButtonReturn: resolution = .keepBoth
default: resolution = .skip
}
themeManager.importTheme(theme, resolution: resolution)
} else {
themeManager.importTheme(theme)
}
}
}
private func exportTheme(_ theme: Theme) {
let panel = NSSavePanel()
panel.title = "Export Theme"
panel.nameFieldStringValue = theme.name
.replacingOccurrences(of: " ", with: "-")
.lowercased()
.appending(".json")
panel.allowedContentTypes = [UTType.json]
guard panel.runModal() == .OK, let url = panel.url else { return }
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(theme) {
try? data.write(to: url)
}
}
}
class ThemeEditorWindowController: NSObject, NSWindowDelegate {
+53 -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 {
@@ -158,3 +172,41 @@ struct ThemeSizes: Codable, Hashable {
self.lineHeight = lineHeight
}
}
extension Theme {
static let cleanPrinting = Theme(
name: "Clean Printing",
colors: ThemeColors(
background: "#ffffff",
text: "#24292e",
heading1: "#1b1f23",
heading2: "#1b1f23",
heading3: "#1b1f23",
heading4: "#1b1f23",
heading5: "#1b1f23",
heading6: "#1b1f23",
link: "#0366d6",
codeBackground: "#f6f8fa",
codeText: "#24292e",
blockquoteBorder: "#dfe2e5",
blockquoteBackground: "#fafbfc",
highlightBackground: "#fff5b1",
highlightText: "#24292e"
),
fonts: ThemeFonts(body: "System", heading: "System", code: "SF Mono"),
sizes: ThemeSizes(
baseFontSize: 11,
h1Size: 22,
h2Size: 18,
h3Size: 15,
h4Size: 13.5,
h5Size: 12,
h6Size: 11,
codeFontSize: 10.5,
lineHeight: 1.5
),
customCSS: "",
isBuiltIn: true
)
}
+83 -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)
@@ -285,6 +352,10 @@ class ThemeManager: ObservableObject {
}
func generateCSS(for theme: Theme) -> String {
generateCSS(for: theme, zoomScale: zoomScale)
}
func generateCSS(for theme: Theme, zoomScale: Double) -> String {
let bodyFont = theme.fonts.body == "System"
? "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif"
: "'\(theme.fonts.body)', -apple-system, sans-serif"
@@ -338,6 +409,10 @@ class ThemeManager: ObservableObject {
css += "\n\n/* Theme CSS */\n\(theme.customCSS)"
}
if contentWidthEnabled {
css += "\n\n#content-inner { max-width: calc(\(Int(contentWidthPx))px * var(--zoom)); margin: auto; }"
}
return css
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,185 @@
import AppKit
import XCTest
@testable import markdownViewr
final class FileMenuPrunerTests: XCTestCase {
func testRemovesOnlyRequestedFileMenuItems() {
let fileMenu = NSMenu(title: "File")
["New", "Open...", "Open Recent", "Close", "Save", "Duplicate", "Rename", "Save As...", "Revert To", "Page Setup...", "Print..."]
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
FileMenuPruner.removeEditingItems(from: fileMenu)
XCTAssertEqual(
fileMenu.items.map(\.title),
["Open...", "Open Recent", "Close", "Duplicate", "Rename", "Save As...", "Page Setup...", "Print..."]
)
}
func testRemovesSaveWithEllipsisWithoutRemovingSaveAs() {
let fileMenu = NSMenu(title: "File")
["Save...", "Save As...", "Duplicate", "Rename"]
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
FileMenuPruner.removeEditingItems(from: fileMenu)
XCTAssertEqual(fileMenu.items.map(\.title), ["Save As...", "Duplicate", "Rename"])
}
func testRemovesNestedRevertToSubmenu() {
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open...", action: nil, keyEquivalent: "")
let revertItem = NSMenuItem(title: "Revert To", action: nil, keyEquivalent: "")
revertItem.submenu = NSMenu(title: "Revert To")
fileMenu.addItem(revertItem)
fileMenu.addItem(withTitle: "Print...", action: nil, keyEquivalent: "")
FileMenuPruner.removeEditingItems(from: fileMenu)
XCTAssertEqual(fileMenu.items.map(\.title), ["Open...", "Print..."])
}
}
final class ExternalEditorMenuStateTests: XCTestCase {
func testDisabledWhenNoEditorsAreAvailable() {
let state = ExternalEditorMenuState(editors: [])
XCTAssertEqual(state, .disabled)
}
func testSingleEditorUsesAppNameInTitle() {
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
let state = ExternalEditorMenuState(editors: [editor])
XCTAssertEqual(state, .single(editor, title: "Open in TextEdit"))
}
func testMultipleEditorsUseSubmenu() {
let textEdit = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
let code = EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
let state = ExternalEditorMenuState(editors: [textEdit, code])
XCTAssertEqual(state, .submenu([textEdit, code]))
}
func testSingleEditorFileMenuItemKeepsEditorIDForAction() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
manager.editors = [editor]
controller.editorManager = manager
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
XCTAssertEqual(item.representedObject as? String, editor.id.uuidString)
XCTAssertTrue(item.target === controller)
}
func testSingleEditorFileMenuItemIsDisabledWithoutOpenFile() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
controller.editorManager = manager
controller.currentFileURLProvider = { nil }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
XCTAssertFalse(item.isEnabled)
XCTAssertNil(item.action)
XCTAssertNil(item.target)
XCTAssertFalse(controller.validateMenuItem(item))
}
func testExternalEditorSubmenuItemsAreDisabledWithoutOpenFile() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
manager.editors = [
EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app"),
EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
]
controller.editorManager = manager
controller.currentFileURLProvider = { nil }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in External Editor"))
let submenu = try XCTUnwrap(item.submenu)
XCTAssertTrue(item.isEnabled)
XCTAssertTrue(submenu.items.allSatisfy { !$0.isEnabled })
XCTAssertTrue(submenu.items.allSatisfy { $0.action == nil })
XCTAssertTrue(submenu.items.allSatisfy { $0.target == nil })
XCTAssertTrue(submenu.items.allSatisfy { !controller.validateMenuItem($0) })
}
func testExternalEditorMenuItemValidationAllowsOpenFile() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
controller.editorManager = manager
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
XCTAssertEqual(item.action, NSSelectorFromString("openInExternalEditor:"))
XCTAssertTrue(item.target === controller)
XCTAssertTrue(controller.validateMenuItem(item))
}
func testCurrentOpenDocumentFileURLIgnoresDocumentsWithoutVisibleWindows() {
let document = NSDocument()
document.fileURL = URL(fileURLWithPath: "/tmp/closed.md")
document.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
XCTAssertNil(ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [document]))
}
func testCurrentOpenDocumentFileURLUsesVisibleDocumentWindow() {
let hiddenDocument = NSDocument()
hiddenDocument.fileURL = URL(fileURLWithPath: "/tmp/hidden.md")
hiddenDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
let visibleDocument = NSDocument()
let visibleURL = URL(fileURLWithPath: "/tmp/visible.md")
visibleDocument.fileURL = visibleURL
visibleDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: true)))
XCTAssertEqual(
ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [hiddenDocument, visibleDocument]),
visibleURL
)
}
}
private final class TestWindow: NSWindow {
private let isVisibleForTest: Bool
init(isVisibleForTest: Bool) {
self.isVisibleForTest = isVisibleForTest
super.init(
contentRect: NSRect(x: 0, y: 0, width: 100, height: 100),
styleMask: [],
backing: .buffered,
defer: false
)
}
override var isVisible: Bool {
isVisibleForTest
}
}
@@ -0,0 +1,58 @@
import XCTest
@testable import markdownViewr
final class ImageInlinerTests: XCTestCase {
func testInlinesRelativeImageAsDataURL() {
let html = "<p><img src=\"img/a.png\" alt=\"x\"></p>"
let bytes = Data([0x01, 0x02, 0x03])
let out = ImageInliner.inlineLocalImages(in: html) { path in
path == "img/a.png" ? bytes : nil
}
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
XCTAssertFalse(out.contains("img/a.png"))
XCTAssertTrue(out.contains("alt=\"x\""))
}
func testLeavesRemoteImageUntouched() {
let html = "<img src=\"https://example.com/a.png\">"
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
XCTAssertEqual(out, html)
}
func testLeavesAbsolutePathUntouched() {
let html = "<img src=\"/Users/x/a.png\">"
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
XCTAssertEqual(out, html)
}
func testLeavesUnresolvableRelativeImageUntouched() {
let html = "<img src=\"missing.png\">"
let out = ImageInliner.inlineLocalImages(in: html) { _ in nil }
XCTAssertEqual(out, html)
}
func testContainsLocalImageTrueForRelativeSrc() {
XCTAssertTrue(ImageInliner.containsLocalImage(in: "<img src=\"images/a.png\">"))
}
func testContainsLocalImageFalseForRemoteOnly() {
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<img src=\"https://example.com/a.png\">"))
}
func testContainsLocalImageFalseForNoImages() {
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<p>no images here</p>"))
}
func testContainsLocalImageFalseForAbsolutePath() {
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<img src=\"/Users/x/a.png\">"))
}
func testDecodesPercentEncodedPathBeforeResolving() {
let html = "<img src=\"my%20pic.png\">"
let bytes = Data([0xAA])
let out = ImageInliner.inlineLocalImages(in: html) { path in
path == "my pic.png" ? bytes : nil
}
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
}
}
+485
View File
@@ -0,0 +1,485 @@
import XCTest
import WebKit
@testable import markdownViewr
final class PrintingTests: XCTestCase {
// 1. Unit Test for firstSubview(ofType:)
func testNSViewFirstSubviewOfTypeRecurse() {
let parent = NSView()
let mid = NSView()
let target = WKWebView()
mid.addSubview(target)
parent.addSubview(mid)
// Assert we can find the WKWebView recursively
let foundWebView = parent.firstSubview(ofType: WKWebView.self)
XCTAssertNotNil(foundWebView)
XCTAssertEqual(foundWebView, target)
// Assert we find nil for a type that doesn't exist
class CustomTestView: NSView {}
XCTAssertNil(parent.firstSubview(ofType: CustomTestView.self))
}
// 2. Static Assertions on source files
func testAppConfiguresReplacementPrintCommand() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
// Verify we replaced the default print command group
XCTAssertTrue(appSource.contains("CommandGroup(replacing: .printItem)"))
XCTAssertTrue(appSource.contains("documentViewCommands?.printDocument()"))
}
func testContentViewBindsPrintDocumentActionAndCoordinator() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
// Verify printDocument is in DocumentViewCommands struct
XCTAssertTrue(contentSource.contains("let printDocument: () -> Void"))
// Verify printDocument is instantiated with printActiveDocument
XCTAssertTrue(contentSource.contains("printDocument: { printActiveDocument() }"))
// Verify printActiveDocument exists
XCTAssertTrue(contentSource.contains("func printActiveDocument()"))
// Verify recursive traversal is used to find WKWebView
XCTAssertTrue(contentSource.contains("window.contentView?.firstSubview(ofType: WKWebView.self)"))
// Verify print settings storage variables exist
XCTAssertTrue(contentSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
// Verify print coordinator instantiation passing all parameters
XCTAssertTrue(contentSource.contains("let coordinator = PrintCoordinator("))
XCTAssertTrue(contentSource.contains("stripBackgrounds: !printBackgroundsVal"))
XCTAssertTrue(contentSource.contains("stripImages: !printImagesVal"))
XCTAssertTrue(contentSource.contains("stripWidth: printContentWidthVal == \"Full Page\""))
// Verify we run the print operation modally with coordinator pointer
XCTAssertTrue(contentSource.contains("printOp.runModal("))
XCTAssertTrue(contentSource.contains("didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:))"))
XCTAssertTrue(contentSource.contains("contextInfo: coordinatorPointer"))
// Verify PrintCoordinator class exists, conforms to @MainActor and supports stripWidth
XCTAssertTrue(contentSource.contains("@MainActor\nclass PrintCoordinator"))
XCTAssertTrue(contentSource.contains("let stripImages: Bool"))
XCTAssertTrue(contentSource.contains("let stripWidth: Bool"))
XCTAssertTrue(contentSource.contains("func restore()"))
}
func testPrintOperationUsesCopiedPrintInfo() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("NSPrintInfo.shared.copy() as? NSPrintInfo"))
XCTAssertFalse(contentSource.contains("let printInfo = NSPrintInfo.shared\n"))
}
func testPrintToolbarItemIsAvailableForCustomization() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("printDocument: @escaping () -> Void"))
XCTAssertTrue(contentSource.contains("self.printDocument = printDocument"))
XCTAssertTrue(contentSource.contains(".printDocument, .space"))
XCTAssertFalse(contentSource.contains("return [.toc, .tocDepth, .printDocument"))
XCTAssertTrue(contentSource.contains("case .printDocument:\n return makePrintItem()"))
XCTAssertTrue(contentSource.contains(#"item.label = "Print""#))
XCTAssertTrue(contentSource.contains(#"item.paletteLabel = "Print""#))
XCTAssertTrue(contentSource.contains(#"NSImage(systemSymbolName: "printer""#))
XCTAssertTrue(contentSource.contains("item.action = #selector(printDocumentFromToolbar)"))
XCTAssertTrue(contentSource.contains("@objc private func printDocumentFromToolbar()"))
XCTAssertTrue(contentSource.contains("printDocument?()"))
XCTAssertTrue(contentSource.contains(#"static let printDocument = NSToolbarItem.Identifier("print-document")"#))
}
func testXcodeGenConfigurationIncludesTestSources() throws {
let projectSource = try loadSourceFile("project.yml")
XCTAssertTrue(projectSource.contains("markdownViewrTests:"))
XCTAssertTrue(projectSource.contains("- markdownViewrTests"))
}
func testSettingsViewIncludesPrintingTab() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
// Verify PrintingSettingsView is declared in TabView
XCTAssertTrue(settingsSource.contains("PrintingSettingsView()"))
XCTAssertTrue(settingsSource.contains(#"Label("Printing", systemImage: "printer")"#))
// Verify PrintingSettingsView struct layout
XCTAssertTrue(settingsSource.contains("struct PrintingSettingsView: View"))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
// Verify spacing and alignment matching HIG guidelines
XCTAssertTrue(settingsSource.contains("VStack(alignment: .leading, spacing: 0)"))
XCTAssertTrue(settingsSource.contains(".padding(.bottom, 16)"))
XCTAssertTrue(settingsSource.contains(".padding(.top, 12)"))
// Verify Settings Layout Components (splits and columns)
XCTAssertTrue(settingsSource.contains("Picker("))
XCTAssertTrue(settingsSource.contains(#"Text("Plain HTML (Unstyled)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Clean Printing")"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Print Theme"))"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Page Layout"))"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Zoom & Scaling"))"#))
XCTAssertTrue(settingsSource.contains("Toggle("))
}
// 3. Template @media print assertion
func testTemplateIncludesPrintMediaQueries() throws {
let template = try loadTemplate()
// Verify print media query exists
XCTAssertTrue(template.contains("@media print"))
let printBlock = try printMediaQueryBlock(in: template)
// Verify print color adjust properties are present to preserve theme colors
XCTAssertTrue(printBlock.contains("-webkit-print-color-adjust: exact !important"))
XCTAssertTrue(printBlock.contains("print-color-adjust: exact !important"))
// Verify print background toggling override style exists without wiping theme text colors
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds"))
XCTAssertTrue(printBlock.contains("print-color-adjust: economy !important"))
XCTAssertFalse(printBlock.contains("color: #000000 !important"))
// Verify ink-saving layout overrides for links, rules, headers, and blockquotes
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds hr"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds blockquote"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds h1"))
// Verify print image toggling override style targets specific content images
XCTAssertTrue(printBlock.contains("html.print-no-images #content-inner img"))
// Verify collapsed editor expansion rules
XCTAssertTrue(printBlock.contains("#content-inner > *"))
XCTAssertTrue(printBlock.contains("display: revert !important"))
// Verify print full width toggling override style exists
XCTAssertTrue(printBlock.contains("html.print-full-width #content-inner"))
XCTAssertTrue(printBlock.contains("max-width: none !important"))
// Verify layout overrides
XCTAssertTrue(printBlock.contains("html, body"))
XCTAssertTrue(printBlock.contains("height: auto !important"))
XCTAssertTrue(printBlock.contains("overflow: visible !important"))
XCTAssertTrue(printBlock.contains("#content"))
// Verify elements hidden
XCTAssertTrue(printBlock.contains("#toc"))
XCTAssertTrue(printBlock.contains("#raw-source"))
XCTAssertTrue(printBlock.contains(".collapse-arrow"))
XCTAssertTrue(printBlock.contains("display: none !important"))
}
func testTemplatePrintStylesForEdgeCases() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// 1. Heading Orphaning: Ensure headings (h1-h6) have CSS break-after avoid rules
XCTAssertTrue(printBlock.contains("h1, h2, h3, h4, h5, h6 {"))
XCTAssertTrue(printBlock.contains("break-after: avoid"))
XCTAssertTrue(printBlock.contains("page-break-after: avoid"))
// 2. Element Page Splits: Ensure small code blocks (pre/code), blockquotes, table rows, and list items have break-inside/page-break-inside avoid rules
XCTAssertTrue(printBlock.contains("pre, blockquote, tr, li {"))
XCTAssertTrue(printBlock.contains("break-inside: avoid"))
XCTAssertTrue(printBlock.contains("page-break-inside: avoid"))
// 3. Image Overflow: Ensure images do not exceed page boundaries and break-inside: avoid
XCTAssertTrue(printBlock.contains("img {"))
XCTAssertTrue(printBlock.contains("max-width: 100%"))
// 4. Table Formatting: Ensure table headers repeat on page boundaries and headers/cells wrap text nicely
XCTAssertTrue(printBlock.contains("thead {"))
XCTAssertTrue(printBlock.contains("display: table-header-group"))
XCTAssertTrue(printBlock.contains("th, td {"))
XCTAssertTrue(printBlock.contains("word-wrap: break-word"))
XCTAssertTrue(printBlock.contains("white-space: normal"))
}
func testPrintMediaQueriesPreserveThemeColorsInInkSavingMode() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// Ensure print-no-backgrounds class does not apply universal color: #000000 !important
// so that selected theme CSS variables (--h1, --h2, --link, --code-text, --text) apply during print.
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds * {\n -webkit-print-color-adjust: economy !important;\n print-color-adjust: economy !important;\n background: transparent !important;\n background-color: transparent !important;\n background-image: none !important;\n /* Avoid light text rendering on white backgrounds */\n color: #000000 !important;\n }"))
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds a {\n color: #0969da !important;"))
}
func testPrintMediaQueriesEnforceThemeFontsSizesAndColors() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// Verify fonts are explicitly bound in @media print
XCTAssertTrue(printBlock.contains("var(--body-font) !important"))
XCTAssertTrue(printBlock.contains("var(--base-font-size) !important"))
XCTAssertTrue(printBlock.contains("var(--heading-font) !important"))
XCTAssertTrue(printBlock.contains("var(--code-font) !important"))
XCTAssertTrue(printBlock.contains("var(--code-font-size) !important"))
// Verify heading sizes are explicitly bound in @media print
XCTAssertTrue(printBlock.contains("var(--h1-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h2-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h3-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h4-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h5-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h6-size) !important"))
// Verify colors are explicitly bound in @media print
XCTAssertTrue(printBlock.contains("var(--h1) !important"))
XCTAssertTrue(printBlock.contains("var(--h2) !important"))
XCTAssertTrue(printBlock.contains("var(--h3) !important"))
XCTAssertTrue(printBlock.contains("var(--h4) !important"))
XCTAssertTrue(printBlock.contains("var(--h5) !important"))
XCTAssertTrue(printBlock.contains("var(--h6) !important"))
XCTAssertTrue(printBlock.contains("var(--link) !important"))
XCTAssertTrue(printBlock.contains("var(--code-text) !important"))
XCTAssertTrue(printBlock.contains("var(--code-bg) !important"))
XCTAssertTrue(printBlock.contains("var(--blockquote-border) !important"))
XCTAssertTrue(printBlock.contains("var(--blockquote-bg) !important"))
XCTAssertTrue(printBlock.contains("var(--highlight-bg) !important"))
XCTAssertTrue(printBlock.contains("var(--highlight-text) !important"))
}
func testPrintPrepareScriptForcesLayoutPass() {
let script = PrintScriptGenerator.prepareScript(
escapedCSS: "body { color: red; }",
stripBackgrounds: false,
stripImages: false,
stripWidth: false,
printBorders: false,
pagePadding: "0px"
)
XCTAssertTrue(script.contains("document.body.offsetHeight;"))
}
@MainActor
func testEveryThemeOptionIsRespectedAndGeneratesValidPrintCSS() {
let manager = ThemeManager()
let themesToTest = manager.allThemes
XCTAssertFalse(themesToTest.isEmpty, "ThemeManager should load built-in themes")
for theme in themesToTest {
let css = manager.generateCSS(for: theme, zoomScale: 1.0)
XCTAssertFalse(css.isEmpty, "Theme '\(theme.name)' should generate non-empty CSS")
XCTAssertTrue(css.contains("--bg: \(theme.colors.background)"), "Theme '\(theme.name)' should include background color")
XCTAssertTrue(css.contains("--text: \(theme.colors.text)"), "Theme '\(theme.name)' should include text color")
XCTAssertTrue(css.contains("--h1: \(theme.colors.heading1)"), "Theme '\(theme.name)' should include h1 color")
XCTAssertTrue(css.contains("--link: \(theme.colors.link)"), "Theme '\(theme.name)' should include link color")
XCTAssertTrue(css.contains("--code-bg: \(theme.colors.codeBackground)"), "Theme '\(theme.name)' should include code bg color")
XCTAssertTrue(css.contains("--code-text: \(theme.colors.codeText)"), "Theme '\(theme.name)' should include code text color")
let prepareScript = PrintScriptGenerator.prepareScript(
escapedCSS: css.replacingOccurrences(of: "\n", with: "\\n"),
stripBackgrounds: false,
stripImages: false,
stripWidth: true,
printBorders: false,
pagePadding: "0px"
)
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent ="), "Script for '\(theme.name)' should target theme-css element")
XCTAssertTrue(prepareScript.contains(theme.colors.background), "Prepared script for '\(theme.name)' should inject background color")
}
// Verify Clean Printing option
let cleanCSS = manager.generateCSS(for: Theme.cleanPrinting, zoomScale: 1.0)
XCTAssertTrue(cleanCSS.contains("--bg: #ffffff"), "Clean printing should use white background")
XCTAssertTrue(cleanCSS.contains("--text: #24292e"), "Clean printing should use dark text")
// Verify Active Theme option
let activeCSS = manager.generateCSS(for: manager.activeTheme, zoomScale: 1.0)
XCTAssertFalse(activeCSS.isEmpty, "Active theme CSS should generate non-empty CSS")
// Verify Plain HTML option
let plainScript = PrintScriptGenerator.prepareScript(
escapedCSS: "",
stripBackgrounds: true,
stripImages: false,
stripWidth: true,
printBorders: false,
pagePadding: "0px"
)
XCTAssertTrue(plainScript.contains("document.getElementById('theme-css').textContent = ''"))
}
func testPrintZoomResolver() {
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Compact (75%)", screenZoom: 1.5), 0.75)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Small (85%)", screenZoom: 1.5), 0.85)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Standard (100%)", screenZoom: 1.5), 1.0)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Large (115%)", screenZoom: 1.5), 1.15)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Match Screen", screenZoom: 1.5), 1.5)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Unknown", screenZoom: 1.5), 1.0)
}
func testCleanPrintingThemeUsesPrintProportionalFontSizes() {
let sizes = Theme.cleanPrinting.sizes
XCTAssertLessThanOrEqual(sizes.baseFontSize, 12.0)
XCTAssertLessThanOrEqual(sizes.h1Size, 22.0)
XCTAssertLessThanOrEqual(sizes.h2Size, 18.0)
XCTAssertLessThanOrEqual(sizes.codeFontSize, 11.0)
}
func testSettingsViewIncludesExpandedFontScalingOptions() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
XCTAssertTrue(settingsSource.contains(#"Text("Compact (75%)").tag("Compact (75%)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Small (85%)").tag("Small (85%)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Standard (100%)").tag("Standard (100%)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Large (115%)").tag("Large (115%)")"#))
}
// 4. Functional test for PrintCoordinator script building and memory cycles
@MainActor
func testPrintCoordinatorMemoryManagementAndRestore() {
class MockWebView: WKWebView {
var lastEvaluatedJavaScript: String?
override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) {
self.lastEvaluatedJavaScript = javaScriptString
completionHandler?(nil, nil)
}
}
let webView = MockWebView()
var coordinator: PrintCoordinator? = PrintCoordinator(
webView: webView,
restoreCSS: "body { background: 'white'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
weak var weakCoordinator = coordinator
XCTAssertNotNil(weakCoordinator)
// Pass retained pointer simulating print runModal contextInfo
let pointer = Unmanaged.passRetained(coordinator!).toOpaque()
// Release our strong local reference (ref count is now held only by the unmanaged pointer)
coordinator = nil
XCTAssertNotNil(weakCoordinator)
// Simulate AppKit delegate execution finishing
let dummyOperation = NSPrintOperation()
weakCoordinator?.printOperationDidRun(dummyOperation, success: true, contextInfo: pointer)
// Assert the unmanaged pointer is released and coordinator is deallocated
XCTAssertNil(weakCoordinator)
// Assert Javascript script execution occurred and escaped CSS correctly
XCTAssertNotNil(webView.lastEvaluatedJavaScript)
let js = webView.lastEvaluatedJavaScript!
XCTAssertTrue(js.contains("document.getElementById('theme-css').textContent = 'body { background: \\'white\\'; }';"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-images');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-full-width');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-borders');"))
XCTAssertTrue(js.contains("document.documentElement.style.removeProperty('--print-padding');"))
}
func testCleanPrintingThemeIsBuiltInAndIndependent() {
let theme = Theme.cleanPrinting
XCTAssertEqual(theme.name, "Clean Printing")
XCTAssertTrue(theme.isBuiltIn)
XCTAssertEqual(theme.colors.background, "#ffffff")
XCTAssertEqual(theme.colors.text, "#24292e")
}
// 5. Test that template print query stylesheet contains the print-borders rules
func testTemplatePrintStylesForBorders() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
XCTAssertTrue(printBlock.contains("html.print-borders pre"))
XCTAssertTrue(printBlock.contains("html.print-borders .frontmatter"))
XCTAssertTrue(printBlock.contains("html.print-borders blockquote"))
XCTAssertTrue(printBlock.contains("html.print-borders code"))
}
// 6. Test that PrintScriptGenerator outputs correct prepare and restore scripts
func testPrintScriptGeneratorScriptOutputs() {
let prepareScript = PrintScriptGenerator.prepareScript(
escapedCSS: "body { background: 'white'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'white\'; }';"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-backgrounds');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-images');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-full-width');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-borders');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.style.setProperty('--print-padding', '48px');"))
let restoreScript = PrintScriptGenerator.restoreScript(
escapedCSS: "body { background: 'dark'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
XCTAssertTrue(restoreScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'dark\'; }';"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-images');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-full-width');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-borders');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.style.removeProperty('--print-padding');"))
}
// Helper function to read source files
private func loadSourceFile(_ relativePath: String) throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceURL = repoRoot.appendingPathComponent(relativePath)
return try String(contentsOf: sourceURL, encoding: .utf8)
}
// Helper function to read HTML template
private func loadTemplate() throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let templateURL = repoRoot.appendingPathComponent("markdownViewr/Resources/template.html")
return try String(contentsOf: templateURL, encoding: .utf8)
}
// Helper function to extract @media print block
private func printMediaQueryBlock(in template: String) throws -> String {
let start = try XCTUnwrap(template.range(of: "@media print {"))
let remainder = template[start.upperBound...]
var bracketCount = 1
var index = remainder.startIndex
while bracketCount > 0 && index < remainder.endIndex {
let char = remainder[index]
if char == "{" {
bracketCount += 1
} else if char == "}" {
bracketCount -= 1
}
if bracketCount > 0 {
index = remainder.index(after: index)
}
}
return String(remainder[..<index])
}
}
@@ -0,0 +1,502 @@
import XCTest
@testable import markdownViewr
final class TemplateScrollbarTests: XCTestCase {
func testTemplateStylesAllScrollbarsWithThemeColors() throws {
let template = try loadTemplate()
let rawSourcePreRule = try cssRule("#raw-source pre", in: template)
XCTAssertTrue(template.contains("::-webkit-scrollbar-track"))
XCTAssertTrue(template.contains("::-webkit-scrollbar-thumb"))
XCTAssertTrue(template.contains("::-webkit-scrollbar-thumb:hover"))
XCTAssertTrue(template.contains("::-webkit-scrollbar-corner"))
XCTAssertTrue(template.contains("background: var(--bg)"))
XCTAssertTrue(template.contains("var(--text)"))
XCTAssertTrue(rawSourcePreRule.contains("border-radius: 0"))
}
func testRenderedContentOwnsScrolling() throws {
let template = try loadTemplate()
let bodyRule = try cssRule("body", in: template)
let contentRule = try cssRule("#content", in: template)
let contentInnerRule = try cssRule("#content-inner", in: template)
XCTAssertTrue(bodyRule.contains("height: 100vh"))
XCTAssertTrue(bodyRule.contains("overflow: hidden"))
XCTAssertTrue(contentRule.contains("height: 100vh"))
XCTAssertTrue(contentRule.contains("overflow-y: auto"))
XCTAssertFalse(contentRule.contains("padding:"))
XCTAssertTrue(contentInnerRule.contains("padding: 32px 48px"))
XCTAssertTrue(template.contains(#"<div id="content">"#))
XCTAssertTrue(template.contains(#"<div id="content-inner">"#))
XCTAssertTrue(template.contains("function _contentScroller()"))
XCTAssertTrue(template.contains("function _contentContainer()"))
XCTAssertTrue(template.contains("content.addEventListener('scroll'"))
XCTAssertFalse(template.contains("window.addEventListener('scroll'"))
XCTAssertFalse(template.contains("window.scrollTo"))
XCTAssertFalse(template.contains("window.scrollBy"))
XCTAssertFalse(template.contains("window.scrollY"))
XCTAssertFalse(template.contains("document.documentElement.scrollTop"))
}
func testActiveTOCLinkIsKeptVisible() throws {
let template = try loadTemplate()
XCTAssertTrue(template.contains("function _scrollTOCLinkIntoView(link)"))
XCTAssertTrue(template.contains("var tocFollowPadding = 24"))
XCTAssertTrue(template.contains("activeTop < tocTop + tocFollowPadding"))
XCTAssertTrue(template.contains("activeBottom > tocBottom - tocFollowPadding"))
XCTAssertTrue(template.contains("toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' })"))
XCTAssertTrue(template.contains("_scrollTOCLinkIntoView(activeLink)"))
XCTAssertFalse(template.contains("activeLink.scrollIntoView"))
}
func testCommandArrowKeysScrollDocumentToTopAndBottom() throws {
let template = try loadTemplate()
XCTAssertTrue(template.contains("function _handleCommandArrowScroll(e)"))
XCTAssertTrue(template.contains("if (!e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) return false"))
XCTAssertTrue(template.contains("if (e.key === 'ArrowUp')"))
XCTAssertTrue(template.contains("_contentScroller().scrollTo({ top: 0, behavior: 'smooth' })"))
XCTAssertTrue(template.contains("if (e.key === 'ArrowDown')"))
XCTAssertTrue(template.contains("content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })"))
XCTAssertTrue(template.contains("if (_handleCommandArrowScroll(e)) return"))
}
func testTOCHighlightFollowsHoveredRenderedSection() throws {
let template = try loadTemplate()
let updateStart = try XCTUnwrap(template.range(of: "function updateContent(html)"))
let updateRemainder = template[updateStart.lowerBound...]
let updateEnd = try XCTUnwrap(updateRemainder.range(of: "var _findPairs"))
let updateContentFunction = String(updateRemainder[..<updateEnd.lowerBound])
let activeTOCRule = try cssRule("#toc a.active", in: template)
XCTAssertTrue(template.contains("var _tocHoverHeadingId = null"))
XCTAssertTrue(template.contains("function setupTOCHoverHighlight()"))
XCTAssertTrue(template.contains("content.addEventListener('mousemove'"))
XCTAssertTrue(template.contains("content.addEventListener('mouseleave'"))
XCTAssertTrue(template.contains("raw.addEventListener('mousemove'"))
XCTAssertTrue(template.contains("raw.addEventListener('mouseleave'"))
XCTAssertTrue(template.contains("function _headingForHoverPosition(clientY)"))
XCTAssertTrue(template.contains("_headingForHoverPosition(event.clientY)"))
XCTAssertTrue(template.contains("function _headingIdForRawHoverPosition(clientY)"))
XCTAssertTrue(template.contains("_headingIdForRawHoverPosition(event.clientY)"))
XCTAssertTrue(template.contains("heading.getBoundingClientRect().top <= clientY"))
XCTAssertTrue(template.contains("function _tocHeadingIdForHover(heading)"))
XCTAssertTrue(template.contains("_applyTOCHoverHeadingId(_tocHeadingIdForHover(heading))"))
XCTAssertTrue(template.contains("function _tocLinkForHeadingId(headingId)"))
XCTAssertTrue(template.contains("links[i].dataset.headingId === headingId"))
XCTAssertTrue(template.contains("if (_tocHoverHeadingId) return"))
XCTAssertTrue(template.contains("highlightTOCHeading(_tocHoverHeadingId, false)"))
XCTAssertTrue(template.contains("highlightActiveTOC()"))
XCTAssertTrue(updateContentFunction.contains("_tocHoverHeadingId = null"))
XCTAssertFalse(activeTOCRule.contains("font-weight"))
}
func testCSSHelpDocumentsScrollbarSelectors() throws {
let cssHelp = try loadSourceFile("markdownViewr/CSSHelpView.swift")
let appHelp = try loadSourceFile("markdownViewr/HelpView.swift")
XCTAssertTrue(cssHelp.contains("Custom CSS Guide"))
XCTAssertTrue(cssHelp.contains("#content::-webkit-scrollbar-thumb"))
XCTAssertTrue(cssHelp.contains("#raw-source::-webkit-scrollbar-thumb"))
XCTAssertTrue(cssHelp.contains("#toc::-webkit-scrollbar-thumb"))
XCTAssertTrue(cssHelp.contains("::-webkit-scrollbar-corner"))
XCTAssertTrue(cssHelp.contains("#content-inner"))
XCTAssertTrue(cssHelp.contains("nav#toc"))
XCTAssertTrue(cssHelp.contains("div#raw-source"))
XCTAssertFalse(cssHelp.contains("Keep readable text centered on wide screens"))
XCTAssertFalse(cssHelp.contains("Remove heading underlines"))
XCTAssertTrue(appHelp.contains("Open the CSS Reference"))
XCTAssertTrue(appHelp.contains("case customCSS"))
XCTAssertTrue(appHelp.contains("Custom CSS Reference"))
XCTAssertTrue(appHelp.contains("scrollbar thumbs"))
}
func testFileMenuKeepsDefaultCommandGroupsForNonEditingItems() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .newItem)"))
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .saveItem)"))
XCTAssertTrue(appSource.contains("FileMenuPrunerApplicationDelegate"))
let prunerSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
XCTAssertTrue(prunerSource.contains("NSMenu.didBeginTrackingNotification"))
XCTAssertFalse(prunerSource.contains("NSMenuDelegate"))
XCTAssertFalse(prunerSource.contains(".delegate = self"))
XCTAssertFalse(prunerSource.contains("func menuNeedsUpdate"))
XCTAssertFalse(prunerSource.contains("func applicationWillUpdate"))
XCTAssertFalse(prunerSource.contains("func menuWillOpen"))
}
func testExternalEditorCommandIsSeparatedBelowOpenRecent() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
XCTAssertTrue(appSource.contains("ExternalEditorFileMenuController.shared.editorManager = editorManager"))
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.shared.update(in: fileMenu)"))
XCTAssertTrue(menuSource.contains(#"normalizedTitle == "Open Recent""#))
XCTAssertTrue(menuSource.contains("NSMenuItem.separator()"))
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.currentOpenDocumentFileURL()"))
XCTAssertTrue(menuSource.contains("window?.isVisible == true"))
XCTAssertFalse(appSource.contains(".disabled(fileURL == nil)"))
}
func testExternalEditorAddRemoveButtonsUseMatchingSize() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
XCTAssertTrue(settingsSource.contains("private let editorActionButtonSize"))
XCTAssertTrue(settingsSource.contains(".frame(width: editorActionButtonSize, height: editorActionButtonSize)"))
}
func testExternalEditorSettingsCanReorderConfiguredEditors() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
let editorSource = try loadSourceFile("markdownViewr/EditorConfig.swift")
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.up")"#))
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.down")"#))
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: -1)"))
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: 1)"))
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorUp"))
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorDown"))
XCTAssertTrue(settingsSource.contains("editorManager.moveEditor(id: selectedEditorID, by: offset)"))
XCTAssertTrue(editorSource.contains("func moveEditor(id: UUID, by offset: Int)"))
XCTAssertTrue(editorSource.contains("reorderedEditors.swapAt(source, destination)"))
XCTAssertTrue(editorSource.contains("editors = reorderedEditors"))
}
func testViewMenuIncludesTogglesForTableOfContentsAndMarkdownSource() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertFalse(appSource.contains(#"@AppStorage("tocVisible")"#))
XCTAssertFalse(appSource.contains(#"@AppStorage("rawVisible")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocVisible")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawVisible")"#))
XCTAssertTrue(contentSource.contains("@State private var tocVisible = false"))
XCTAssertTrue(contentSource.contains("@State private var rawVisible = false"))
XCTAssertTrue(contentSource.contains(".focusedSceneValue(\\.documentViewCommands, documentViewCommands)"))
XCTAssertFalse(contentSource.contains(".focusedValue(\\.documentViewCommands, documentViewCommands)"))
XCTAssertTrue(appSource.contains("@FocusedValue(\\.documentViewCommands) private var documentViewCommands"))
XCTAssertTrue(appSource.contains(#"Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))"#))
XCTAssertTrue(appSource.contains(#"Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))"#))
}
func testViewMenuIncludesTableOfContentsDepthSubmenu() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertFalse(appSource.contains(#"@AppStorage("tocDepth")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocDepth")"#))
XCTAssertTrue(contentSource.contains("@State private var tocDepth = 3"))
XCTAssertTrue(appSource.contains(#"Menu("Table of Contents Depth")"#))
XCTAssertTrue(appSource.contains(#"ForEach(1...6, id: \.self)"#))
XCTAssertTrue(appSource.contains("documentViewCommands?.setTocDepth(depth)"))
XCTAssertTrue(appSource.contains(#"Label("H\(depth)", systemImage: "checkmark")"#))
XCTAssertFalse(appSource.contains(#"Picker("Table of Contents Depth", selection: $tocDepth)"#))
}
func testDocumentViewStateAndZoomAreWindowLocal() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
let themeSource = try loadSourceFile("markdownViewr/ThemeManager.swift")
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocWidth")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawWidth")"#))
XCTAssertTrue(contentSource.contains("@State private var tocWidth: Double = 220"))
XCTAssertTrue(contentSource.contains("@State private var rawWidth: Double = 400"))
XCTAssertTrue(contentSource.contains("@State private var zoomScale = 1.0"))
XCTAssertTrue(contentSource.contains("@State private var activeThemeName: String?"))
XCTAssertTrue(contentSource.contains("private var activeTheme: Theme"))
XCTAssertTrue(contentSource.contains("themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale)"))
XCTAssertTrue(contentSource.contains("activeThemeName = themeManager.activeThemeName"))
XCTAssertTrue(contentSource.contains("setZoomScale: { zoomScale = $0 }"))
XCTAssertTrue(contentSource.contains("setActiveThemeName: { activeThemeName = $0 }"))
XCTAssertTrue(contentSource.contains("zoomScale: Binding("))
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomIn()"))
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomOut()"))
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomReset()"))
XCTAssertFalse(appSource.contains("themeManager.zoomIn()"))
XCTAssertFalse(appSource.contains("themeManager.zoomOut()"))
XCTAssertFalse(appSource.contains("themeManager.zoomReset()"))
XCTAssertFalse(appSource.contains("themeManager.cycleTheme(direction:"))
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = title"))
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = sender.title"))
XCTAssertTrue(themeSource.contains("func generateCSS(for theme: Theme, zoomScale: Double) -> String"))
}
func testSettingsIncludeNewWindowViewDefaults() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
let webViewSource = try loadSourceFile("markdownViewr/MarkdownWebView.swift")
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)"#))
XCTAssertTrue(settingsSource.contains(#"Picker("Default Table of Contents depth", selection: $defaultTocDepth)"#))
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
XCTAssertTrue(contentSource.contains("initializeWindowViewStateIfNeeded(windowWidth: Double(window.contentView?.bounds.width ?? 0))"))
XCTAssertFalse(contentSource.contains(".onAppear {\n initializeWindowViewStateIfNeeded()"))
XCTAssertFalse(contentSource.contains("guard windowWidth > 0 else { return }"))
XCTAssertTrue(contentSource.contains("tocVisible = defaultTocVisible"))
XCTAssertTrue(contentSource.contains("tocDepth = defaultTocDepth"))
XCTAssertTrue(contentSource.contains("rawVisible = defaultRawVisible"))
XCTAssertTrue(contentSource.contains("sizeInitialRawSourceIfNeeded(windowWidth: windowWidth)"))
XCTAssertTrue(contentSource.contains("guard rawVisible && !hasActivatedRawSource && windowWidth > 0 else { return }"))
XCTAssertTrue(contentSource.contains("windowWidth: windowWidth"))
XCTAssertTrue(webViewSource.contains("var isPageLoaded = false"))
XCTAssertTrue(webViewSource.contains("context.coordinator.isPageLoaded = false"))
XCTAssertTrue(webViewSource.contains("if !context.coordinator.isPageLoaded {"))
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingTocVisible = tocVisible"))
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingRawVisible = rawVisible"))
XCTAssertTrue(webViewSource.contains("isPageLoaded = true"))
}
func testSettingsTabsScrollWhenWindowIsSmall() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertTrue(settingsSource.contains("private struct SettingsScrollView<Content: View>: View"))
XCTAssertTrue(settingsSource.contains("ScrollView(.vertical)"))
XCTAssertTrue(settingsSource.contains("content\n .frame(maxWidth: .infinity, alignment: .topLeading)\n .padding(20)"))
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"General\")"))
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"Markdown Extensions\")"))
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n .onAppear {\n checkIfDefault()"))
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n }\n\n private func extensionRow"))
XCTAssertTrue(settingsSource.contains("}\n .padding(20)\n }\n\n private var selectedEditorIndex"))
XCTAssertFalse(settingsSource.contains("struct EditorsSettingsView: View {\n var body: some View {\n SettingsScrollView"))
XCTAssertFalse(settingsSource.contains("struct ThemeSettingsView: View {\n var body: some View {\n SettingsScrollView"))
XCTAssertTrue(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)"))
XCTAssertTrue(appSource.contains("contentRect: NSRect(x: 0, y: 0, width: 600, height: 720)"))
XCTAssertFalse(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)"))
}
func testDocumentWindowsUseLandscapeDefaultSize() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertTrue(appSource.contains(".defaultSize(width: 1100, height: 800)"))
XCTAssertTrue(appSource.contains("static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize"))
XCTAssertTrue(appSource.contains("let autosaveName = Self.autosaveName(for: fileURL)"))
XCTAssertTrue(appSource.contains("let restoredFrame = window.setFrameUsingName(autosaveName)"))
XCTAssertTrue(appSource.contains("window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))"))
XCTAssertTrue(appSource.contains("window.center()"))
XCTAssertTrue(appSource.contains("window.setFrameAutosaveName(autosaveName)"))
XCTAssertTrue(appSource.contains("context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
XCTAssertTrue(appSource.contains("func updateNSView(_ nsView: NSView, context: Context)"))
XCTAssertTrue(appSource.contains("window.saveFrame(usingName: autosaveName)"))
XCTAssertTrue(appSource.contains("NSWindow.willCloseNotification"))
XCTAssertTrue(appSource.contains("NSWindow.didResizeNotification"))
XCTAssertTrue(appSource.contains("NSWindow.didMoveNotification"))
XCTAssertFalse(appSource.contains(".defaultSize(width: 700, height: 900)"))
XCTAssertFalse(appSource.contains("if !window.setFrameAutosaveName(autosaveName)"))
XCTAssertFalse(appSource.contains("fileURL.path.hashValue"))
}
func testDocumentWindowAutosaveNameIsStableAcrossLaunches() {
let fileURL = URL(fileURLWithPath: "/tmp/My File.md")
XCTAssertEqual(
DocumentWindowConfigurator.autosaveName(for: fileURL),
"doc-%2Ftmp%2FMy%20File.md"
)
}
func testDocumentWindowDefaultWidthAccountsForDefaultSidePanes() {
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: false), NSSize(width: 1100, height: 800))
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: false), NSSize(width: 1250, height: 800))
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: true), NSSize(width: 1350, height: 800))
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: true), NSSize(width: 1500, height: 800))
}
func testDocumentWindowFrameConfigurationSkipsTabbedWindows() {
XCTAssertTrue(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false))
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false, hasTabbedHistory: true))
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: true))
}
func testDocumentWindowFrameAutosaveSkipsTabbedWindows() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertTrue(appSource.contains("let isTabbedWindow = Self.isTabbedWindow(window)"))
XCTAssertTrue(appSource.contains("window.isRestorable = false"))
XCTAssertTrue(appSource.contains("if isTabbedWindow {\n Self.markTabGroupHasTabbedHistory(window)\n context.coordinator.stopSavingFrame(for: window)\n return\n }"))
XCTAssertTrue(appSource.contains("Self.markTabGroupHasTabbedHistory(window)"))
XCTAssertTrue(appSource.contains("tabGroupWindows.forEach {\n windowsWithTabbedHistory.add($0)\n $0.isRestorable = false\n $0.setFrameAutosaveName(\"\")\n }"))
XCTAssertTrue(appSource.contains("let hasTabbedHistory = Self.windowHasTabbedHistory(window)"))
XCTAssertTrue(appSource.contains("if !hasTabbedHistory {\n window.setFrameAutosaveName(autosaveName)\n }\n context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
XCTAssertTrue(appSource.contains("private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()"))
XCTAssertTrue(appSource.contains("func stopSavingFrame(for window: NSWindow)"))
XCTAssertFalse(appSource.contains("func consumeRecentlyTabbedWindow(_ window: NSWindow) -> Bool"))
}
func testInitialSourceWidthUsesHalfAvailableWindowWidth() {
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: false, tocWidth: 300),
600
)
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: true, tocWidth: 300),
450
)
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 380, tocVisible: true, tocWidth: 260),
220
)
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 1500, tocVisible: true, tocWidth: 220),
640
)
}
func testSourceWidthOnlyAutoSizesOnFirstActivation() {
XCTAssertEqual(
DocumentViewLayout.rawWidthWhenActivating(
currentRawWidth: 360,
hasActivatedRawSource: false,
windowWidth: 1200,
tocVisible: true,
tocWidth: 300
),
450
)
XCTAssertEqual(
DocumentViewLayout.rawWidthWhenActivating(
currentRawWidth: 360,
hasActivatedRawSource: true,
windowWidth: 1200,
tocVisible: true,
tocWidth: 300
),
360
)
}
func testViewAndExternalEditorCommandsHaveKeyboardShortcuts() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
XCTAssertTrue(appSource.contains(#".keyboardShortcut("t", modifiers: .command)"#))
XCTAssertTrue(appSource.contains(#".keyboardShortcut("u", modifiers: .command)"#))
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: .command)"#))
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: [.command, .shift])"#))
XCTAssertTrue(menuSource.contains(#"keyEquivalent: "e""#))
XCTAssertTrue(menuSource.contains("makeEditorMenuItem(title: title, editor: editor, keyEquivalent: \"e\", isEnabled: hasOpenFile)"))
XCTAssertTrue(menuSource.contains("keyEquivalent: index == 0 ? \"e\" : \"\""))
XCTAssertTrue(menuSource.contains("let hasOpenFile = currentFileURLProvider() != nil"))
XCTAssertTrue(menuSource.contains("item.keyEquivalentModifierMask = [.command]"))
}
func testToolbarControlsExposeStableTextLabels() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("DocumentToolbarController"))
XCTAssertTrue(contentSource.contains("static let toc = NSToolbarItem.Identifier(\"toc\")"))
XCTAssertFalse(contentSource.contains("ToolbarItemGroup(placement: .automatic)"))
XCTAssertFalse(contentSource.contains(#".toolbar(id: "document-toolbar")"#))
XCTAssertFalse(contentSource.contains("ToolbarSpacer"))
XCTAssertFalse(contentSource.contains("tocControls"))
XCTAssertFalse(contentSource.contains("zoomControls"))
XCTAssertTrue(contentSource.contains(#"item.label = "TOC""#))
XCTAssertTrue(contentSource.contains(#"item.label = "TOC Depth""#))
XCTAssertFalse(contentSource.contains(#"item.label = "Table of Contents Depth""#))
XCTAssertTrue(contentSource.contains(#"item.label = "Markdown Source""#))
XCTAssertFalse(contentSource.contains("ToolbarDisplayModeReader"))
XCTAssertFalse(contentSource.contains("toolbarDisplayMode"))
XCTAssertFalse(contentSource.contains("zoomTextMenu"))
XCTAssertTrue(contentSource.contains(#"item.label = "Zoom""#))
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = zoomMenuItem()"))
XCTAssertTrue(contentSource.contains(#"NSMenuItem(title: "Zoom", action: nil, keyEquivalent: "")"#))
XCTAssertTrue(contentSource.contains("item.view = zoomControlView"))
XCTAssertTrue(contentSource.contains("item.target = self"))
XCTAssertTrue(contentSource.contains("item.action = #selector(showZoomMenuFromToolbar(_:))"))
XCTAssertTrue(contentSource.contains("NSSegmentedControl(labels:"))
XCTAssertTrue(contentSource.contains("final class ZoomToolbarControlView"))
XCTAssertTrue(contentSource.contains("control.setWidth(56, forSegment: 1)"))
XCTAssertTrue(contentSource.contains("private func zoomMenu() -> NSMenu"))
XCTAssertTrue(contentSource.contains("menu.popUp(positioning: nil"))
XCTAssertTrue(contentSource.contains("showZoomMenuFromToolbar"))
XCTAssertTrue(contentSource.contains("zoomSegmentSelected"))
XCTAssertFalse(contentSource.contains("dumpToolbar"))
XCTAssertFalse(contentSource.contains("toolbar-debug"))
XCTAssertFalse(contentSource.contains("ControlGroup"))
XCTAssertTrue(contentSource.contains(#"item.label = "Theme""#))
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = themeMenuItem()"))
XCTAssertTrue(contentSource.contains(#"item.label = externalEditorLabel"#))
XCTAssertTrue(contentSource.contains("item.action = #selector(openExternalEditorFromToolbar(_:))"))
XCTAssertTrue(contentSource.contains("item.autovalidates = false"))
XCTAssertTrue(contentSource.contains("item.view = externalEditorButton(isEnabled: !validEditors.isEmpty)"))
XCTAssertTrue(contentSource.contains("private func externalEditorButton(isEnabled: Bool) -> NSButton"))
XCTAssertTrue(contentSource.contains("button.showsBorderOnlyWhileMouseInside = true"))
XCTAssertFalse(contentSource.contains("button.isBordered = false"))
XCTAssertTrue(contentSource.contains("func validateToolbarItem(_ item: NSToolbarItem) -> Bool"))
XCTAssertTrue(contentSource.contains("return hasValidExternalEditor"))
XCTAssertTrue(contentSource.contains("showExternalEditorMenu(from: sender)"))
XCTAssertFalse(contentSource.contains("externalEditorPopup"))
}
func testToolbarIsNativelyCustomizable() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .toolbar)"))
XCTAssertTrue(appSource.contains("CommandGroup(before: .toolbar)"))
XCTAssertTrue(contentSource.contains("final class DocumentToolbarController: NSObject"))
XCTAssertTrue(contentSource.contains("ObservableObject"))
XCTAssertTrue(contentSource.contains("NSToolbarDelegate"))
XCTAssertTrue(contentSource.contains("window.toolbar = toolbar"))
XCTAssertTrue(contentSource.contains("toolbar.delegate = self"))
XCTAssertTrue(contentSource.contains("toolbar.allowsUserCustomization = true"))
XCTAssertTrue(contentSource.contains("toolbar.autosavesConfiguration = true"))
XCTAssertTrue(contentSource.contains("toolbar.displayMode = NSToolbar.DisplayMode.default"))
XCTAssertTrue(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v6")"#))
XCTAssertTrue(contentSource.contains("removeLegacySavedToolbarConfigurations()"))
XCTAssertTrue(contentSource.contains(#""NSToolbar Configuration \(identifier)""#))
XCTAssertTrue(contentSource.contains(#""document-toolbar-v5""#))
XCTAssertTrue(contentSource.contains(#""document-toolbar-v4""#))
XCTAssertFalse(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v5")"#))
XCTAssertTrue(contentSource.contains("} else if window.toolbar?.delegate !== self {\n window.toolbar?.delegate = self\n }"))
XCTAssertTrue(contentSource.contains("item.target = self"))
XCTAssertTrue(contentSource.contains("toolbarAllowedItemIdentifiers"))
XCTAssertTrue(contentSource.contains("toolbarDefaultItemIdentifiers"))
}
func testDocumentToolbarUsesNativeRepeatableSpaceItem() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains(".space"))
XCTAssertFalse(contentSource.contains(".flexibleSpace"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .space, .markdownSource, .zoom, .theme, .externalEditor]"))
XCTAssertFalse(contentSource.contains("case .fixedSpace:"))
XCTAssertFalse(contentSource.contains("makeFixedSpaceItem"))
XCTAssertFalse(contentSource.contains(#"NSToolbarItem.Identifier("document-fixed-space")"#))
XCTAssertFalse(contentSource.contains("Small Space"))
}
private func loadTemplate() throws -> String {
let templateURL = try XCTUnwrap(
Bundle.main.url(forResource: "template", withExtension: "html")
)
return try String(contentsOf: templateURL, encoding: .utf8)
}
private func loadSourceFile(_ relativePath: String) throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceURL = repoRoot.appendingPathComponent(relativePath)
return try String(contentsOf: sourceURL, encoding: .utf8)
}
private func cssRule(_ selector: String, in template: String) throws -> String {
let start = try XCTUnwrap(template.range(of: "\(selector) {"))
let remainder = template[start.upperBound...]
let end = try XCTUnwrap(remainder.firstIndex(of: "}"))
return String(remainder[..<end])
}
}
@@ -0,0 +1,136 @@
import XCTest
@testable import markdownViewr
final class ThemeMigrationTests: XCTestCase {
// MARK: - Decoding
func testDecodesCurrentVersionTheme() throws {
let json = minimalThemeJSON(schemaVersion: Theme.currentSchemaVersion)
let theme = try JSONDecoder().decode(Theme.self, from: json)
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
}
func testMissingSchemaVersionDefaultsToOne() throws {
// Themes written before versioning was added have no schemaVersion field.
let json = minimalThemeJSON(schemaVersion: nil)
let theme = try JSONDecoder().decode(Theme.self, from: json)
XCTAssertEqual(theme.schemaVersion, 1)
}
func testUnknownFieldsAreIgnored() throws {
// A theme from a future app version may have fields this version doesn't know about.
// Decoding should succeed — unknown keys are silently ignored by Codable.
var dict = minimalThemeDict(schemaVersion: Theme.currentSchemaVersion + 1)
dict["futureField"] = "someValue"
let json = try JSONSerialization.data(withJSONObject: dict)
XCTAssertNoThrow(try JSONDecoder().decode(Theme.self, from: json))
}
// MARK: - Migration
func testMigrateNoOpAtCurrentVersion() throws {
var theme = Theme(name: "Test")
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
let manager = ThemeManager()
let versionBefore = theme.schemaVersion
manager.migrateIfNeeded(&theme)
XCTAssertEqual(theme.schemaVersion, versionBefore, "migrateIfNeeded should not change version for a current-version theme")
}
func testNewThemeWrittenAtCurrentSchemaVersion() {
let theme = Theme(name: "Fresh")
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
}
func testContentWidthConstrainsInnerContentNotScrollContainer() {
let defaults = UserDefaults.standard
let previousEnabled = defaults.object(forKey: "contentWidthEnabled")
let previousWidth = defaults.object(forKey: "contentWidthPx")
defer {
restore(previousEnabled, forKey: "contentWidthEnabled")
restore(previousWidth, forKey: "contentWidthPx")
}
let manager = ThemeManager()
manager.contentWidthEnabled = true
manager.contentWidthPx = 1230
let css = manager.generateCSS(for: Theme(name: "Content Width"))
XCTAssertTrue(css.contains("#content-inner { max-width: calc(1230px * var(--zoom)); margin: auto; }"))
XCTAssertFalse(css.contains("#content { max-width:"))
}
// MARK: - Round-trip
func testEncodesSchemaVersion() throws {
let theme = Theme(name: "RoundTrip")
let data = try JSONEncoder().encode(theme)
let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
let encoded = try XCTUnwrap(dict["schemaVersion"] as? Int)
XCTAssertEqual(encoded, Theme.currentSchemaVersion)
}
func testRoundTripPreservesAllFields() throws {
let original = Theme(
name: "Round Trip",
colors: ThemeColors(background: "#ff0000", text: "#00ff00"),
fonts: ThemeFonts(body: "Georgia", heading: "Helvetica", code: "Menlo"),
sizes: ThemeSizes(baseFontSize: 16, lineHeight: 1.8),
customCSS: "body { margin: 0; }"
)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(Theme.self, from: data)
XCTAssertEqual(decoded.name, original.name)
XCTAssertEqual(decoded.schemaVersion, original.schemaVersion)
XCTAssertEqual(decoded.colors.background, original.colors.background)
XCTAssertEqual(decoded.colors.text, original.colors.text)
XCTAssertEqual(decoded.fonts.body, original.fonts.body)
XCTAssertEqual(decoded.sizes.baseFontSize, original.sizes.baseFontSize)
XCTAssertEqual(decoded.sizes.lineHeight, original.sizes.lineHeight)
XCTAssertEqual(decoded.customCSS, original.customCSS)
}
// MARK: - Helpers
private func minimalThemeJSON(schemaVersion: Int?) -> Data {
let dict = minimalThemeDict(schemaVersion: schemaVersion)
return try! JSONSerialization.data(withJSONObject: dict)
}
private func minimalThemeDict(schemaVersion: Int?) -> [String: Any] {
var dict: [String: Any] = [
"name": "Minimal",
"colors": [
"background": "#000000", "text": "#ffffff",
"heading1": "#ffffff", "heading2": "#ffffff",
"heading3": "#ffffff", "heading4": "#ffffff",
"heading5": "#ffffff", "heading6": "#ffffff",
"link": "#0000ff", "codeBackground": "#111111",
"codeText": "#ffffff", "blockquoteBorder": "#888888",
"blockquoteBackground": "#222222",
"highlightBackground": "#ffff00", "highlightText": "#000000"
],
"fonts": ["body": "System", "heading": "System", "code": "SF Mono"],
"sizes": [
"baseFontSize": 14, "h1Size": 28, "h2Size": 23, "h3Size": 20,
"h4Size": 18, "h5Size": 16, "h6Size": 15,
"codeFontSize": 13, "lineHeight": 1.7
],
"customCSS": ""
]
if let v = schemaVersion {
dict["schemaVersion"] = v
}
return dict
}
private func restore(_ value: Any?, forKey key: String) {
if let value {
UserDefaults.standard.set(value, forKey: key)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
}
}
+80 -7
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.1"
CURRENT_PROJECT_VERSION: "1"
PRODUCT_NAME: markdownViewr
MARKETING_VERSION: "1.5.0"
CURRENT_PROJECT_VERSION: "5"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ENABLE_APP_SANDBOX: "NO"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
targets:
markdownViewrTests:
type: bundle.unit-test
platform: macOS
sources:
- markdownViewrTests
dependencies:
- target: markdownViewr
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewrTests
MACOSX_DEPLOYMENT_TARGET: "13.0"
GENERATE_INFOPLIST_FILE: YES
# Developer ID distribution (notarized DMG + Sparkle auto-update). `just release`.
markdownViewr:
templates:
- AppBase
dependencies:
- package: sparkle
product: Sparkle
info:
path: markdownViewr/Info.plist
properties:
@@ -62,5 +85,55 @@ targets:
- markdown
- mdown
- mkd
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
LSApplicationCategoryType: "public.app-category.productivity"
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
# 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]}")