diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b207d6f..ce11d52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: Release -# Triggered by pushing a tag like v0.2.0. Builds Markee.app from the tagged -# commit, zips it, and publishes a GitHub Release with the zip attached and -# the matching CHANGELOG.md section as the release body. +# Triggered by pushing a version tag (v*). Builds Markee.app from the tagged +# commit, Developer-ID-signs and notarizes it, zips it, and publishes a GitHub +# Release with the zip attached and the matching CHANGELOG.md section as body. on: push: @@ -14,7 +14,7 @@ permissions: jobs: release: - name: Build + publish Markee.app + name: Build + notarize + publish Markee.app runs-on: macos-14 steps: - uses: actions/checkout@v4 @@ -37,7 +37,40 @@ jobs: - name: Test run: make test - - name: Build app bundle + - name: Import Developer ID certificate + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + run: | + if [ -z "$MACOS_CERT_P12" ]; then + echo "::error::MACOS_CERT_P12 secret is not set"; exit 1 + fi + CERT_PATH="$RUNNER_TEMP/markee-cert.p12" + trap 'rm -f "$CERT_PATH"' EXIT # remove the decoded cert on any exit + KEYCHAIN_PATH="$RUNNER_TEMP/markee-signing.keychain-db" + KEYCHAIN_PW=$(openssl rand -base64 24) + + echo "$MACOS_CERT_P12" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" \ + -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + # Prepend our keychain to the search list, keeping the existing + # entries (System.keychain holds the Apple CA chain codesign needs). + # Read into an array so each path stays a single argument. + OLD_KEYCHAINS=() + while IFS= read -r kc; do + [ -n "$kc" ] && OLD_KEYCHAINS+=("$kc") + done < <(security list-keychains -d user | sed 's/[[:space:]]*"//; s/"[[:space:]]*$//') + security list-keychains -d user -s "$KEYCHAIN_PATH" "${OLD_KEYCHAINS[@]}" + + echo "MARKEE_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Build app bundle (Developer ID signed) run: make app - name: Verify bundle layout @@ -45,28 +78,55 @@ jobs: test -x Markee.app/Contents/MacOS/Markee test -f Markee.app/Contents/Resources/LICENSE test -f Markee.app/Contents/Resources/THIRD-PARTY-NOTICES.md + test -d Markee.app/Contents/PlugIns/QuickLookPreview.appex + test -d Markee.app/Contents/PlugIns/QuickLookThumbnail.appex + + - name: Verify Developer ID signature + run: | + if codesign -dvv Markee.app 2>&1 | grep -q "Signature=adhoc"; then + echo "::error::Markee.app is ad-hoc signed — Developer ID certificate import failed" + exit 1 + fi + echo "Markee.app carries a non-ad-hoc signature." - name: Verify Info.plist version matches tag run: | TAG_VERSION="${GITHUB_REF_NAME#v}" + # Strip any pre-release suffix so a tag like v1.0.0-rc.1 still + # validates against Info.plist 1.0.0 (lets you dry-run this workflow + # with an -rc tag before the real release). + BASE_VERSION="${TAG_VERSION%%-*}" PLIST_VERSION=$(defaults read "$PWD/Markee.app/Contents/Info" CFBundleShortVersionString) - echo "Tag: $TAG_VERSION, Info.plist: $PLIST_VERSION" - if [ "$TAG_VERSION" != "$PLIST_VERSION" ]; then + echo "Tag: $TAG_VERSION (base $BASE_VERSION), Info.plist: $PLIST_VERSION" + if [ "$BASE_VERSION" != "$PLIST_VERSION" ]; then echo "::error::Tag $TAG_VERSION does not match Info.plist version $PLIST_VERSION. Bump Resources/Info.plist before tagging." exit 1 fi + - name: Notarize and staple + env: + NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + run: | + if [ -z "$NOTARY_KEY_P8" ]; then + echo "::error::NOTARY_KEY_P8 secret is not set"; exit 1 + fi + KEY_PATH="$RUNNER_TEMP/notary-key.p8" + trap 'rm -f "$KEY_PATH"' EXIT # remove the decoded API key on any exit + echo "$NOTARY_KEY_P8" | base64 --decode > "$KEY_PATH" + export NOTARY_KEY_P8_PATH="$KEY_PATH" + ./scripts/notarize-app.sh Markee.app + - name: Zip Markee.app run: | - zip -r -y Markee.app.zip Markee.app + ditto -c -k --keepParent Markee.app Markee.app.zip ls -la Markee.app.zip - name: Extract changelog notes for this version id: changelog run: | VERSION="${GITHUB_REF_NAME#v}" - # awk through CHANGELOG.md: when we hit `## [VERSION]`, start - # printing; when we hit the next `## [`, stop. NOTES=$(awk -v ver="$VERSION" ' $0 ~ "^## \\[" ver "\\]" { flag=1; next } flag && /^## \[/ { exit } @@ -78,10 +138,6 @@ jobs: { echo "notes<> "$GITHUB_OUTPUT" @@ -95,3 +151,10 @@ jobs: draft: false prerelease: ${{ contains(github.ref_name, '-') }} fail_on_unmatched_files: true + + - name: Clean up signing keychain + if: always() + run: | + if [ -n "${MARKEE_KEYCHAIN:-}" ]; then + security delete-keychain "$MARKEE_KEYCHAIN" 2>/dev/null || true + fi diff --git a/.gitignore b/.gitignore index e08a436..6b13a52 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Markee.app/ # Generated icon (built from AppIcon.svg by scripts/build-icon.sh) Resources/AppIcon.icns +Resources/DocIcon.icns # Vendored JS/CSS libraries (fetched by scripts/fetch-vendor.sh) Resources/web/vendor/ @@ -23,9 +24,4 @@ Resources/web/vendor/ # Internal design specs / AI-collaboration notes — kept private docs/superpowers/ -# Failed self-signing experiment from May 2026 — kept local for reference. -# See CLAUDE.md "Known issues blocking public release" for why this doesn't -# work (Gatekeeper requires Apple's CA chain, not a trusted self-signed cert). -scripts/make-signing-cert.sh - .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 540c041..711699b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to Markee are documented here. Format roughly follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] — 2026-05-19 + +The first publicly distributable release — Developer ID signed and notarized, +so Markee installs and opens without Gatekeeper warnings. + +### Added +- **Quick Look preview** — press Space on a Markdown file in Finder to see it + fully rendered instead of as raw source. A sandboxed Quick Look extension + that reuses the app's renderer. +- **Per-file Finder thumbnails** — `.md` files show a thumbnail of their + rendered content in Finder's icon views, falling back to the document icon. +- **Markdown document icon** — `.md` files now carry a branded Markee document + icon in place of the generic plain-text page. +- **Developer ID signing & notarization** — the app and both Quick Look + extensions are signed with a Developer ID certificate and notarized by Apple, + so they install and open without Gatekeeper warnings. `make notarize` and the + release workflow handle it end to end. + +### Changed +- Mermaid now loads lazily — only documents that contain a diagram pay its + ~2.5 MB parse cost, so cold-start render is faster for everything else. +- Bundle version bumped to `1.0.0` (CFBundleShortVersionString) / `6` + (CFBundleVersion). + ## [0.5.0] — 2026-05-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ae5f88b..02fc064 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,8 @@ make test # swift test + node --test Tests/util.test.js (both green at - `Resources/AppIcon.svg` — source; `Resources/AppIcon.icns` is built (gitignored) - `scripts/build-icon.sh` — `sips` + `iconutil` → AppIcon.icns - `scripts/fetch-vendor.sh` — pinned downloads from jsdelivr +- `scripts/sign-app.sh` — Developer ID / ad-hoc bundle signing (used by `make app`) +- `scripts/notarize-app.sh` — `notarytool` submit + staple (used by `make notarize`) - `Tests/MarkeeTests/` — Swift unit tests (`@testable import Markee`) - `Tests/util.test.js` — Node `--test` runner over `util.js` - `fixtures/sample.md` — exercises every feature @@ -73,9 +75,17 @@ Watch + rerender, atomic-save aware, scroll preservation, GFM + footnotes + defl Visual identity redesign. Integrated window chrome (no system titlebar divider, custom 44pt gradient bar, traffic lights kept). Outline sidebar redesigned with H1/H2/H3 indent + live active-section highlight (driven by `currentHeadingID` published from JS IntersectionObserver). Full `theme.css` rewrite with new token palette (`--surface`, `--accent`, etc.), Soft Modern typography, custom task-list checkboxes, faded `
`, lede paragraph after H1. Dark + light themes, system-following via `prefers-color-scheme`. See `docs/superpowers/specs/2026-05-11-ui-ux-redesign-design.md`. +## What's done (v1.0 — Finder integration & notarization) + +Quick Look preview + per-file thumbnail extensions — a shared `MarkeeKit` +renderer behind two `.appex` bundles in `Contents/PlugIns/`. Branded Markdown +document icon. Mermaid now lazy-loaded (only diagram-bearing documents pay its +cost). Developer ID code signing + notarization for the app and both +extensions — see "Signing & notarization". The first publicly distributable +release. + ## Not done -- Apple Developer ID signing / notarization (needed for distribution beyond your machine) — see "Known issues blocking public release" below - DMG / Homebrew cask - PreviewController test coverage: `toggleTask` drift bailout, line-ending preservation, export-HTML write - Print stylesheet (uses screen CSS; usually fine, breaks near page boundaries can be ugly) @@ -85,8 +95,12 @@ Visual identity redesign. Integrated window chrome (no system titlebar divider, ## Known issues blocking public release +*Issues #1 and #4 are resolved by Developer ID notarization (see "Signing & notarization" below). #2 and #3 remain.* + ### 1. Open With picker grays out Markee; not in "Recommended Applications" +**Status: RESOLVED — the app is now Developer ID signed and notarized (see "Signing & notarization" below), so `spctl -a` passes and the picker enables Markee normally. The diagnosis below is kept for historical context.** + **Symptom.** Right-click .md → Open With → Other… shows Markee grayed out under "Recommended Applications" *and* under "All Applications". The bundle is registered, the binding is correct, and `open -Ra Markee` works — but the picker UI refuses to let users select it. **Why.** macOS (Sonoma+) gates the Open With picker on a Gatekeeper assessment (`spctl -a`). Ad-hoc signed apps fail this assessment. Self-signed certs — even trusted system-wide via `security add-trusted-cert -p codeSign` — *also* fail, because `spctl -a` specifically requires Apple's CA chain (Developer ID Application or notarized). There is no purely-local workaround. Note also: every file in the bundle carries `com.apple.provenance` xattr that `xattr -cr` cannot remove on Sonoma+; this is kernel-managed and Apple intends for it to stay. @@ -95,7 +109,7 @@ Visual identity redesign. Integrated window chrome (no system titlebar divider, **Workaround we used during development.** Get Info → Open with → "Other…" → switch dropdown to "All Applications" → navigate to /Applications/Markee.app → it's grayed but still clickable in the file dialog → click Open → Change All. Once. Then it sticks. -**Real fix for public release.** Either: +**Real fix (done — see "Signing & notarization" below).** It was: - Enroll in the Apple Developer Program ($99/yr), get a Developer ID Application cert, codesign with that, notarize. After notarization, `spctl -a` passes and the picker enables Markee normally. - Or: ship a tiny first-run helper that writes the LSHandler entries directly to the user's `launchservices.secure.plist` and runs `lsregister`. Avoids the picker entirely but doesn't help discoverability for users who didn't go through the helper. @@ -117,6 +131,37 @@ Reproduce + capture logs before public release. Do **not** symlink /Applications/Markee.app → repo path. Finder's "Other…" picker won't let users select symlinked apps even in "All Applications" mode (we confirmed this in the May 2026 session). +### 4. Quick Look extensions need a notarized app — RESOLVED + +The Quick Look preview/thumbnail extensions (`Contents/PlugIns/*.appex`) only register with `pkd` when the host app passes Gatekeeper. Ad-hoc signing does **not** work — `pkd` silently refuses to register the extensions and `pluginkit -a` no-ops. This is resolved by Developer ID signing + notarization (see "Signing & notarization" below): a notarized build passes `spctl -a`, `pkd` registers the extensions, and Quick Look + the document icon work. + +## Signing & notarization + +Markee is distributed as a Developer-ID-signed, notarized app. Both the app and +its two Quick Look `.appex` extensions are signed with the **Developer ID +Application** certificate and the Hardened Runtime, then the bundle is notarized +by Apple and the ticket stapled. The app itself is **not** sandboxed — it watches and writes Markdown files at arbitrary paths. The two Quick Look `.appex` extensions, however, **are** sandboxed (`Resources/QuickLookExtension.entitlements`): `pkd` refuses to register an unsandboxed Quick Look extension. Their entitlements are `com.apple.security.app-sandbox`, `com.apple.security.files.user-selected.read-only` (to read the previewed file), and `com.apple.security.network.client` — the last is **load-bearing**: without it `WKWebView`'s helper processes crash inside the sandboxed extension and the renderer hangs forever (preview shows an endless spinner). `sign-app.sh` signs the extensions with those entitlements and the app without. + +- `scripts/sign-app.sh` signs the bundle inside-out. With a Developer ID + identity in the keychain it signs Developer ID + Hardened Runtime; with none + it falls back to ad-hoc (so CI build-test and contributors still build). +- `scripts/notarize-app.sh` zips, submits to `notarytool`, and staples. +- `make app` signs; `make notarize` builds + signs + notarizes + staples. + +**Local setup (one-time):** +- Install the "Developer ID Application" certificate in your login keychain + (Xcode → Settings → Accounts → Manage Certificates, or the developer portal). +- Store the App Store Connect API key as a notarytool keychain profile: + `xcrun notarytool store-credentials markee-notary --key --key-id --issuer ` +- Then `NOTARY_KEYCHAIN_PROFILE=markee-notary make notarize` produces a + notarized, stapled bundle. + +**CI:** `release.yml` (tag-triggered) imports the cert and notarizes +automatically. It needs five repo secrets: `MACOS_CERT_P12` (base64 of the +Developer ID `.p12`), `MACOS_CERT_PASSWORD`, `NOTARY_KEY_P8` (base64 of the API +key `.p8`), `NOTARY_KEY_ID`, `NOTARY_ISSUER_ID`. `ci.yml` (push/PR) stays +ad-hoc — signing secrets must never reach PR builds. + ## Useful one-liners ```sh diff --git a/Makefile b/Makefile index d2db2a9..a54b49f 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ APP_NAME := Markee APP_BUNDLE := $(APP_NAME).app BIN := .build/release/$(APP_NAME) +PREVIEW_BIN := .build/release/MarkeeQuickLookPreview +THUMBNAIL_BIN := .build/release/MarkeeQuickLookThumbnail CONFIG := release INSTALLED_BUNDLE := /Applications/$(APP_BUNDLE) VENDOR_SENTINEL := Resources/web/vendor/.fetched -.PHONY: all build app run clean fetch-vendor install install-cli icon test test-swift test-js +.PHONY: all build app run notarize clean fetch-vendor install install-cli icon test test-swift test-js all: app @@ -31,12 +33,25 @@ app: $(VENDOR_SENTINEL) build icon cp $(BIN) $(APP_BUNDLE)/Contents/MacOS/$(APP_NAME) cp Resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist cp Resources/AppIcon.icns $(APP_BUNDLE)/Contents/Resources/AppIcon.icns + cp Resources/DocIcon.icns $(APP_BUNDLE)/Contents/Resources/DocIcon.icns cp -R Resources/web $(APP_BUNDLE)/Contents/Resources/ cp -R Resources/cli $(APP_BUNDLE)/Contents/Resources/ cp LICENSE $(APP_BUNDLE)/Contents/Resources/LICENSE cp THIRD-PARTY-NOTICES.md $(APP_BUNDLE)/Contents/Resources/THIRD-PARTY-NOTICES.md - # Ad-hoc codesign so WKWebView and TCC don't barf - codesign --force --deep --sign - $(APP_BUNDLE) 2>/dev/null || true + # Assemble the Quick Look extensions into Contents/PlugIns/ + mkdir -p $(APP_BUNDLE)/Contents/PlugIns/QuickLookPreview.appex/Contents/MacOS + mkdir -p $(APP_BUNDLE)/Contents/PlugIns/QuickLookPreview.appex/Contents/Resources + cp $(PREVIEW_BIN) $(APP_BUNDLE)/Contents/PlugIns/QuickLookPreview.appex/Contents/MacOS/MarkeeQuickLookPreview + cp Resources/QuickLookPreview-Info.plist $(APP_BUNDLE)/Contents/PlugIns/QuickLookPreview.appex/Contents/Info.plist + cp -R Resources/web $(APP_BUNDLE)/Contents/PlugIns/QuickLookPreview.appex/Contents/Resources/ + mkdir -p $(APP_BUNDLE)/Contents/PlugIns/QuickLookThumbnail.appex/Contents/MacOS + mkdir -p $(APP_BUNDLE)/Contents/PlugIns/QuickLookThumbnail.appex/Contents/Resources + cp $(THUMBNAIL_BIN) $(APP_BUNDLE)/Contents/PlugIns/QuickLookThumbnail.appex/Contents/MacOS/MarkeeQuickLookThumbnail + cp Resources/QuickLookThumbnail-Info.plist $(APP_BUNDLE)/Contents/PlugIns/QuickLookThumbnail.appex/Contents/Info.plist + cp -R Resources/web $(APP_BUNDLE)/Contents/PlugIns/QuickLookThumbnail.appex/Contents/Resources/ + # Sign the bundle: Developer ID + Hardened Runtime when an identity is + # available, ad-hoc otherwise. See scripts/sign-app.sh. + ./scripts/sign-app.sh $(APP_BUNDLE) @echo "Built $(APP_BUNDLE)" @if [ -L "$(INSTALLED_BUNDLE)" ] || [ -d "$(INSTALLED_BUNDLE)" ]; then \ echo "Syncing to $(INSTALLED_BUNDLE)..."; \ @@ -55,6 +70,9 @@ install: app run: app open $(APP_BUNDLE) +notarize: app + ./scripts/notarize-app.sh $(APP_BUNDLE) + install-cli: app @if [ -w /usr/local/bin ]; then \ ln -sf "$$(pwd)/$(APP_BUNDLE)/Contents/Resources/cli/markee" /usr/local/bin/markee; \ diff --git a/Package.swift b/Package.swift index 2c3a5cd..b8df43a 100644 --- a/Package.swift +++ b/Package.swift @@ -6,12 +6,34 @@ let package = Package( platforms: [.macOS(.v13)], products: [ .executable(name: "Markee", targets: ["Markee"]), + .executable(name: "MarkeeQuickLookPreview", targets: ["MarkeeQuickLookPreview"]), + .executable(name: "MarkeeQuickLookThumbnail", targets: ["MarkeeQuickLookThumbnail"]), ], targets: [ + .target( + name: "MarkeeKit", + path: "Sources/MarkeeKit" + ), .executableTarget( name: "Markee", + dependencies: ["MarkeeKit"], path: "Sources/Markee" ), + .executableTarget( + name: "MarkeeQuickLookPreview", + dependencies: ["MarkeeKit"], + path: "Sources/MarkeeQuickLookPreview" + ), + .executableTarget( + name: "MarkeeQuickLookThumbnail", + dependencies: ["MarkeeKit"], + path: "Sources/MarkeeQuickLookThumbnail" + ), + .testTarget( + name: "MarkeeKitTests", + dependencies: ["MarkeeKit"], + path: "Tests/MarkeeKitTests" + ), .testTarget( name: "MarkeeTests", dependencies: ["Markee"], diff --git a/README.md b/README.md index 4245152..98f12d5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ in Editor**, and your editor opens at that line. - KaTeX math (inline `$…$` and display `$$…$$`). - Syntax highlighting via highlight.js. - Mermaid diagrams. +- **Quick Look** — press Space on a `.md` file in Finder for a fully rendered + preview instead of raw text. +- **Finder thumbnails & document icon** — `.md` files show a thumbnail of + their rendered content, and carry a branded Markee document icon. - **Interactive task-list checkboxes** that write back to the source file. - **Open in Editor at Current Heading** — ⌥⌘E or right-click an outline row to jump to that heading's source line in your editor. @@ -55,17 +59,8 @@ Download `Markee.app.zip` from the latest [GitHub Release](https://github.com/sethbangert/markee/releases), unzip, drag into `/Applications`. -**First launch — Gatekeeper warning.** Markee is ad-hoc codesigned, not -notarized with an Apple Developer ID yet. The first time you open it, macOS -will say *"`Markee` can't be opened because Apple cannot check it for -malicious software."* Two ways past it: - -- **Right-click** `Markee.app` in Finder → **Open** → **Open** in the dialog. - You only need to do this once; subsequent launches go straight through. -- Or, in Terminal: `xattr -dr com.apple.quarantine /Applications/Markee.app`, - then double-click as normal. - -Notarization is on the roadmap. +Release builds are Developer ID signed and notarized, so they open normally +with a double-click — no Gatekeeper warning, no right-click workaround needed. ### From source @@ -145,7 +140,7 @@ Resources/web/ HTML/JS/CSS shipped into the bundle vendor/ Fetched at build time, not committed Resources/cli/markee Shell launcher Resources/AppIcon.svg Source for the app icon -scripts/ build-icon.sh, fetch-vendor.sh +scripts/ build-icon.sh, fetch-vendor.sh, sign-app.sh, notarize-app.sh Tests/ Swift + JS tests fixtures/sample.md Exercises every feature docs/demo.md README hero document @@ -170,9 +165,8 @@ inside the WebView; Swift just streams the file's source into ## Status -v0.4.0 — Zoom (⌘+/⌘-/⌘0), Find Next/Previous (⌘G/⌘⇧G), and Reload (⌘R). -See [CHANGELOG.md](CHANGELOG.md) for the full release history. Not yet signed -with a Developer ID or notarized. +v1.0.0 — see [CHANGELOG.md](CHANGELOG.md) for the full release history. +Developer ID signed and notarized. ## Contributing diff --git a/Resources/DocIcon.svg b/Resources/DocIcon.svg new file mode 100644 index 0000000..dc24f8f --- /dev/null +++ b/Resources/DocIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/Info.plist b/Resources/Info.plist index ed7d054..166f8c5 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.5.0 + 1.0.0 CFBundleVersion - 5 + 6 CFBundleIconFile AppIcon LSMinimumSystemVersion @@ -37,6 +37,8 @@ CFBundleTypeName Markdown Document + CFBundleTypeIconFile + DocIcon CFBundleTypeRole Editor LSHandlerRank diff --git a/Resources/QuickLookExtension.entitlements b/Resources/QuickLookExtension.entitlements new file mode 100644 index 0000000..625af03 --- /dev/null +++ b/Resources/QuickLookExtension.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + diff --git a/Resources/QuickLookPreview-Info.plist b/Resources/QuickLookPreview-Info.plist new file mode 100644 index 0000000..965b93d --- /dev/null +++ b/Resources/QuickLookPreview-Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + MarkeeQuickLookPreview + CFBundleIdentifier + com.markee.preview.QuickLookPreview + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Markee Quick Look Preview + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 6 + LSMinimumSystemVersion + 13.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + MarkeeQuickLookPreview.PreviewViewController + NSExtensionAttributes + + QLSupportedContentTypes + + net.daringfireball.markdown + + QLSupportsSearchableItems + + + + + diff --git a/Resources/QuickLookThumbnail-Info.plist b/Resources/QuickLookThumbnail-Info.plist new file mode 100644 index 0000000..e21c22e --- /dev/null +++ b/Resources/QuickLookThumbnail-Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + MarkeeQuickLookThumbnail + CFBundleIdentifier + com.markee.preview.QuickLookThumbnail + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Markee Quick Look Thumbnail + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 6 + LSMinimumSystemVersion + 13.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.quicklook.thumbnail + NSExtensionPrincipalClass + MarkeeQuickLookThumbnail.ThumbnailProvider + NSExtensionAttributes + + QLSupportedContentTypes + + net.daringfireball.markdown + + + + + diff --git a/Resources/web/app.js b/Resources/web/app.js index c8bf7aa..3d0942b 100644 --- a/Resources/web/app.js +++ b/Resources/web/app.js @@ -221,14 +221,20 @@ // Source-line indices are computed against the ORIGINAL source so they // match what's on disk (Swift reads the file fresh before toggling). const taskLines = collectTaskLineNumbers(String(payload.source || "")); + // readOnly (Quick Look) renders checkboxes non-interactive — a click + // there cannot write back to the file, so don't pretend it can. + const readOnly = !!payload.readOnly; const taskItems = article.querySelectorAll("li.task-list-item"); taskItems.forEach((li, i) => { if (i >= taskLines.length) return; li.dataset.line = String(taskLines[i]); const cb = li.querySelector('input[type="checkbox"]'); if (cb) { - cb.disabled = false; - cb.addEventListener("click", onTaskToggle); + if (readOnly) { + cb.disabled = true; + } else { + cb.addEventListener("click", onTaskToggle); + } } }); @@ -247,15 +253,11 @@ } catch (e) { /* non-fatal */ } } - // Mermaid - if (window.mermaid) { - try { - // Reset processed flag on existing diagrams so re-render works - article.querySelectorAll("pre.mermaid").forEach((el) => { - el.removeAttribute("data-processed"); - }); - window.mermaid.run({ querySelector: "#content pre.mermaid" }).catch(() => {}); - } catch (e) { /* non-fatal */ } + // Mermaid — the 2.5 MB bundle is loaded on demand only when the + // document actually contains a diagram. Diagram-free renders never + // pay the parse cost (matters most for fresh Quick Look processes). + if (article.querySelector("pre.mermaid")) { + ensureMermaid(() => runMermaid(article)); } // Restore scroll @@ -276,6 +278,46 @@ if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); } + // ---- lazy Mermaid ------------------------------------------------------- + // "unloaded" | "loading" | "ready" + let mermaidState = "unloaded"; + let mermaidWaiters = []; + + // Load mermaid.min.js (UMD bundle — not the ESM split build) once. On load, + // dispatch markee:mermaid-ready (the existing listener runs mermaid.initialize + // synchronously during dispatch), THEN invoke each queued `then`. On failure + // the queue is dropped — diagrams stay as code — and a later render retries. + function ensureMermaid(then) { + if (mermaidState === "ready" && window.mermaid) { then(); return; } + mermaidWaiters.push(then); + if (mermaidState === "loading") return; + mermaidState = "loading"; + const s = document.createElement("script"); + s.src = "markee-app://app/vendor/mermaid/mermaid.min.js"; + s.onload = () => { + mermaidState = "ready"; + window.dispatchEvent(new Event("markee:mermaid-ready")); + const waiters = mermaidWaiters; + mermaidWaiters = []; + waiters.forEach((fn) => fn()); + }; + s.onerror = () => { + mermaidState = "unloaded"; + mermaidWaiters = []; + }; + document.head.appendChild(s); + } + + function runMermaid(article) { + if (!window.mermaid) return; + try { + article.querySelectorAll("pre.mermaid").forEach((el) => { + el.removeAttribute("data-processed"); + }); + window.mermaid.run({ querySelector: "#content pre.mermaid" }).catch(() => {}); + } catch (e) { /* non-fatal */ } + } + // ---- export standalone HTML -------------------------------------------- async function exportStandalone() { const article = document.getElementById("content"); diff --git a/Resources/web/template.html b/Resources/web/template.html index bcbf95f..41073a5 100644 --- a/Resources/web/template.html +++ b/Resources/web/template.html @@ -18,12 +18,6 @@ - -
diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index c0902c8..eb2ebc6 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -1,5 +1,6 @@ import SwiftUI import WebKit +import MarkeeKit /// Discrete zoom rungs, browser-style. Zoom commands only ever land the /// page on one of these values. @@ -155,7 +156,7 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle private func loadFromDisk(reason: String) { let source: String do { - source = try Self.readFileWithFallback(at: fileURL) + source = try readFileWithFallback(at: fileURL) self.errorBanner = nil } catch { self.errorBanner = "Couldn't read \(fileURL.lastPathComponent): \(error.localizedDescription)" @@ -165,15 +166,6 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle render(source: source) } - static func readFileWithFallback(at url: URL) throws -> String { - let data = try Data(contentsOf: url) - if let s = String(data: data, encoding: .utf8) { return s } - // Try utf16 with BOM detection - if let s = String(data: data, encoding: .utf16) { return s } - if let s = String(data: data, encoding: .isoLatin1) { return s } - return "" - } - // MARK: - Render private func render(source: String) { @@ -249,7 +241,7 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle func copyMarkdownSource() { let source: String do { - source = try Self.readFileWithFallback(at: fileURL) + source = try readFileWithFallback(at: fileURL) } catch { self.errorBanner = "Couldn't read \(fileURL.lastPathComponent): \(error.localizedDescription)" return diff --git a/Sources/MarkeeKit/FileReader.swift b/Sources/MarkeeKit/FileReader.swift new file mode 100644 index 0000000..e07fcfe --- /dev/null +++ b/Sources/MarkeeKit/FileReader.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Read a text file, tolerating non-UTF-8 encodings. Tries UTF-8, then UTF-16 +/// (BOM-detected), then ISO Latin-1. Returns "" only if the file has no bytes +/// decodable by any of those — never throws for an encoding miss; it throws +/// only if the file itself cannot be read. +public func readFileWithFallback(at url: URL) throws -> String { + let data = try Data(contentsOf: url) + if let s = String(data: data, encoding: .utf8) { return s } + if let s = String(data: data, encoding: .utf16) { return s } + if let s = String(data: data, encoding: .isoLatin1) { return s } + return "" +} diff --git a/Sources/Markee/SchemeHandlers.swift b/Sources/MarkeeKit/SchemeHandlers.swift similarity index 88% rename from Sources/Markee/SchemeHandlers.swift rename to Sources/MarkeeKit/SchemeHandlers.swift index 4243316..3d843e7 100644 --- a/Sources/Markee/SchemeHandlers.swift +++ b/Sources/MarkeeKit/SchemeHandlers.swift @@ -3,17 +3,17 @@ import WebKit /// Serves files from the app bundle's Resources/web/ directory. /// URLs look like: markee-app://app/template.html, markee-app://app/vendor/katex/katex.min.css -final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { - static let scheme = "markee-app" +public final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { + public static let scheme = "markee-app" private let webRoot: URL - override init() { + public override init() { let resources = Bundle.main.resourceURL ?? Bundle.main.bundleURL self.webRoot = resources.appendingPathComponent("web", isDirectory: true) super.init() } - func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { guard let url = urlSchemeTask.request.url else { urlSchemeTask.didFailWithError(URLError(.badURL)); return } @@ -23,7 +23,7 @@ final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { serve(fileURL: candidate, task: urlSchemeTask) } - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} + public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} private func serve(fileURL: URL, task: WKURLSchemeTask) { guard let requestURL = task.request.url else { @@ -80,18 +80,18 @@ final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { /// Serves files from a specific document directory. One instance per WebView. /// URLs look like: markee-doc://doc/image.png → /path/to/doc-dir/image.png -final class DocSchemeHandler: NSObject, WKURLSchemeHandler { - static let scheme = "markee-doc" - private(set) var docRoot: URL +public final class DocSchemeHandler: NSObject, WKURLSchemeHandler { + public static let scheme = "markee-doc" + public private(set) var docRoot: URL - init(docRoot: URL) { + public init(docRoot: URL) { self.docRoot = docRoot super.init() } - func setDocRoot(_ url: URL) { self.docRoot = url } + public func setDocRoot(_ url: URL) { self.docRoot = url } - func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { guard let url = urlSchemeTask.request.url else { urlSchemeTask.didFailWithError(URLError(.badURL)); return } @@ -119,7 +119,7 @@ final class DocSchemeHandler: NSObject, WKURLSchemeHandler { } } - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} + public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} private func fail(task: WKURLSchemeTask, status: Int, message: String) { guard let requestURL = task.request.url, @@ -146,7 +146,7 @@ final class DocSchemeHandler: NSObject, WKURLSchemeHandler { /// both sides. The boundary check uses a trailing slash so the sibling-dir /// attack is blocked. The exact-equal allowance covers the root-itself case /// (rare but possible if a request asks for the root directory). -func resolveSandboxed(root: URL, requestPath: String) -> URL? { +public func resolveSandboxed(root: URL, requestPath: String) -> URL? { var path = requestPath while path.hasPrefix("/") { path.removeFirst() } let decoded = path.removingPercentEncoding ?? path @@ -158,7 +158,7 @@ func resolveSandboxed(root: URL, requestPath: String) -> URL? { return nil } -func mimeType(for ext: String) -> String { +public func mimeType(for ext: String) -> String { switch ext.lowercased() { case "html", "htm": return "text/html; charset=utf-8" case "js", "mjs": return "application/javascript; charset=utf-8" diff --git a/Sources/MarkeeKit/ThumbnailLayout.swift b/Sources/MarkeeKit/ThumbnailLayout.swift new file mode 100644 index 0000000..8f47466 --- /dev/null +++ b/Sources/MarkeeKit/ThumbnailLayout.swift @@ -0,0 +1,19 @@ +import CoreGraphics + +/// Largest page-shaped rectangle (width / height == `pageAspect`) that fits +/// inside `maximumSize`. Returned size never exceeds the budget in either +/// dimension. +public func thumbnailContextSize(maximumSize: CGSize, pageAspect: CGFloat) -> CGSize { + guard maximumSize.width > 0, maximumSize.height > 0, pageAspect > 0 else { + return .zero + } + // Start by pinning height, derive width from the aspect. + var width = maximumSize.height * pageAspect + var height = maximumSize.height + // If that overflows the width budget, pin width instead. + if width > maximumSize.width { + width = maximumSize.width + height = width / pageAspect + } + return CGSize(width: width, height: height) +} diff --git a/Sources/MarkeeKit/Timeout.swift b/Sources/MarkeeKit/Timeout.swift new file mode 100644 index 0000000..89408f3 --- /dev/null +++ b/Sources/MarkeeKit/Timeout.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Thrown by `withTimeout` when the operation does not finish in time. +public struct TimeoutError: Error { + public init() {} +} + +/// Run `operation`, racing it against a deadline. If `operation` finishes +/// first its value is returned; if the deadline wins, the operation task is +/// cancelled and `TimeoutError` is thrown. +public func withTimeout( + seconds: Double, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw TimeoutError() + } + // First task to finish wins; cancel the loser before returning. + for try await result in group { + group.cancelAll() + return result + } + throw TimeoutError() // unreachable: the group always holds two tasks + } +} diff --git a/Sources/MarkeeKit/WebRenderer.swift b/Sources/MarkeeKit/WebRenderer.swift new file mode 100644 index 0000000..71c598b --- /dev/null +++ b/Sources/MarkeeKit/WebRenderer.swift @@ -0,0 +1,102 @@ +import WebKit + +/// Forwards `WKScriptMessage` callbacks to a weakly-held target. +/// `WKUserContentController` retains its message handler strongly; without +/// this trampoline the chain WebRenderer → webView → configuration → +/// userContentController → WebRenderer would be a retain cycle and the +/// WebRenderer would never deallocate. +@MainActor +private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + weak var target: WKScriptMessageHandler? + + func userContentController(_ controller: WKUserContentController, + didReceive message: WKScriptMessage) { + target?.userContentController(controller, didReceive: message) + } +} + +/// Headless Markdown renderer. Owns a `WKWebView` wired with Markee's scheme +/// handlers, loads `template.html`, and exposes async `waitUntilReady()` / +/// `render(...)`. This is the extension-side equivalent of the render path +/// inside the app's `PreviewController` — without file watching, the outline, +/// task write-back, zoom, find, or any app chrome. +@MainActor +public final class WebRenderer: NSObject, WKScriptMessageHandler { + public let webView: WKWebView + + private let bundleHandler = BundleSchemeHandler() + private let docHandler: DocSchemeHandler + private var isReady = false + + /// - Parameter docRoot: directory the document lives in; relative + /// `markee-doc://` URLs resolve against it (sandbox permitting). + public init(docRoot: URL) { + self.docHandler = DocSchemeHandler(docRoot: docRoot) + + let config = WKWebViewConfiguration() + let pagePrefs = WKWebpagePreferences() + pagePrefs.allowsContentJavaScript = true + config.defaultWebpagePreferences = pagePrefs + let userContent = WKUserContentController() + config.userContentController = userContent + config.setURLSchemeHandler(bundleHandler, forURLScheme: BundleSchemeHandler.scheme) + config.setURLSchemeHandler(docHandler, forURLScheme: DocSchemeHandler.scheme) + + self.webView = WKWebView(frame: .zero, configuration: config) + super.init() + + // Weak trampoline: see WeakScriptMessageHandler above. + let proxy = WeakScriptMessageHandler() + proxy.target = self + userContent.add(proxy, name: "markee") + } + + /// Begin loading `template.html`. Call once, before `waitUntilReady()`. + public func loadTemplate() { + var components = URLComponents() + components.scheme = BundleSchemeHandler.scheme + components.host = "app" + components.path = "/template.html" + guard let url = components.url else { return } + webView.load(URLRequest(url: url)) + } + + /// Suspend until `app.js` has posted `{kind:"ready"}`. Polls rather than + /// parking a continuation: `template.html` may never signal ready if it + /// fails to load, and the poll's `Task.sleep` makes a cancelled caller + /// (e.g. the thumbnail extension's timeout) unwind cleanly instead of + /// leaking. Throws `CancellationError` if the calling task is cancelled. + public func waitUntilReady() async throws { + while !isReady { + try await Task.sleep(for: .milliseconds(20)) + } + } + + /// Render `source`. Resolves once the synchronous render call returns + /// (text + layout in the DOM); asynchronous Mermaid may still finish after. + public func render(source: String, fileName: String, readOnly: Bool) async { + let payload: [String: Any] = [ + "source": source, + "fileName": fileName, + "docBase": "\(DocSchemeHandler.scheme)://doc/", + "readOnly": readOnly, + ] + // `payload` always serializes to a JSON *object* literal, which is + // also a valid JS expression — safe to interpolate as the argument. + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + await withCheckedContinuation { continuation in + webView.evaluateJavaScript("window.markee && window.markee.render(\(json));") { _, _ in + continuation.resume() + } + } + } + + public func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + guard message.name == "markee", + let body = message.body as? [String: Any], + (body["kind"] as? String) == "ready" else { return } + isReady = true + } +} diff --git a/Sources/MarkeeQuickLookPreview/PreviewViewController.swift b/Sources/MarkeeQuickLookPreview/PreviewViewController.swift new file mode 100644 index 0000000..bc4cab3 --- /dev/null +++ b/Sources/MarkeeQuickLookPreview/PreviewViewController.swift @@ -0,0 +1,40 @@ +import Cocoa +import Quartz +import MarkeeKit + +/// Quick Look preview principal class. Hosts a `MarkeeKit.WebRenderer`'s +/// WKWebView and renders the previewed file into it. +final class PreviewViewController: NSViewController, QLPreviewingController { + private var renderer: WebRenderer? + + override func loadView() { + self.view = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + } + + func preparePreviewOfFile(at url: URL, + completionHandler handler: @escaping (Error?) -> Void) { + Task { @MainActor in + // The controller may be reused for successive previews; drop the + // previous renderer's view before mounting the new one. + self.renderer?.webView.removeFromSuperview() + let renderer = WebRenderer(docRoot: url.deletingLastPathComponent()) + self.renderer = renderer + + renderer.webView.frame = self.view.bounds + renderer.webView.autoresizingMask = [.width, .height] + self.view.addSubview(renderer.webView) + + renderer.loadTemplate() + do { + try await renderer.waitUntilReady() + let source = try readFileWithFallback(at: url) + await renderer.render(source: source, + fileName: url.lastPathComponent, + readOnly: true) + handler(nil) + } catch { + handler(error) + } + } + } +} diff --git a/Sources/MarkeeQuickLookPreview/main.swift b/Sources/MarkeeQuickLookPreview/main.swift new file mode 100644 index 0000000..72a3504 --- /dev/null +++ b/Sources/MarkeeQuickLookPreview/main.swift @@ -0,0 +1,8 @@ +import Foundation + +// App extensions have no normal main(): the system entry point is +// NSExtensionMain, which runs the extension's XPC service loop. +@_silgen_name("NSExtensionMain") +func NSExtensionMain() -> Int32 + +exit(NSExtensionMain()) diff --git a/Sources/MarkeeQuickLookThumbnail/ThumbnailProvider.swift b/Sources/MarkeeQuickLookThumbnail/ThumbnailProvider.swift new file mode 100644 index 0000000..83ede00 --- /dev/null +++ b/Sources/MarkeeQuickLookThumbnail/ThumbnailProvider.swift @@ -0,0 +1,124 @@ +import Cocoa +import QuickLookThumbnailing +import WebKit +import MarkeeKit + +/// Carries an `NSImage` across the `withTimeout` task boundary. `NSImage` is +/// not `Sendable`; this is safe because the snapshot is never mutated after +/// capture. +private struct SnapshotBox: @unchecked Sendable { + let image: NSImage +} + +/// Quick Look thumbnail principal class. Renders the file in an offscreen +/// WebView, snapshots the top of the page, and draws that onto a white +/// document card. A timeout falls back to the system's generic doc icon. +final class ThumbnailProvider: QLThumbnailProvider { + /// US-letter-ish page proportions (width / height). + private static let pageAspect: CGFloat = 8.5 / 11.0 + + override func provideThumbnail( + for request: QLFileThumbnailRequest, + _ handler: @escaping (QLThumbnailReply?, Error?) -> Void + ) { + Task { + do { + let snapshot = try await withTimeout(seconds: 2.5) { [fileURL = request.fileURL] in + try await Self.renderSnapshot(fileURL: fileURL) + } + let contextSize = thumbnailContextSize( + maximumSize: request.maximumSize, + pageAspect: Self.pageAspect) + guard contextSize.width > 0, contextSize.height > 0 else { + handler(nil, nil); return + } + let reply = QLThumbnailReply(contextSize: contextSize) { + Self.drawCard(image: snapshot.image, + in: CGRect(origin: .zero, size: contextSize)) + return true + } + handler(reply, nil) + } catch { + // Timeout or render failure: no thumbnail → system shows DocIcon. + handler(nil, nil) + } + } + } + + /// Render the file in an offscreen WebView and snapshot the top of the page. + @MainActor + private static func renderSnapshot(fileURL: URL) async throws -> SnapshotBox { + let pageWidth: CGFloat = 850 + let pageHeight: CGFloat = pageWidth / pageAspect + let pageRect = NSRect(x: 0, y: 0, width: pageWidth, height: pageHeight) + + let renderer = WebRenderer(docRoot: fileURL.deletingLastPathComponent()) + renderer.webView.frame = pageRect + + // WKWebView snapshots reliably only when hosted in a window. + let window = NSWindow(contentRect: pageRect, + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.contentView = renderer.webView + + renderer.loadTemplate() + try await renderer.waitUntilReady() + + let source = try readFileWithFallback(at: fileURL) + await renderer.render(source: source, + fileName: fileURL.lastPathComponent, + readOnly: true) + try Task.checkCancellation() + + // Fixed settle delay: render() resolves when the synchronous JS call + // returns, but layout — and async Mermaid — may still be in flight. + // A diagram-heavy document may therefore thumbnail with diagrams only + // partially drawn; an accepted limitation within the 2.5s budget. + try await Task.sleep(nanoseconds: 200_000_000) + try Task.checkCancellation() + + let config = WKSnapshotConfiguration() + config.rect = pageRect + let image: NSImage = try await withCheckedThrowingContinuation { continuation in + renderer.webView.takeSnapshot(with: config) { image, error in + if let image { + continuation.resume(returning: image) + } else { + continuation.resume(throwing: error ?? TimeoutError()) + } + } + } + // Keep the offscreen window (which owns the webView's backing store) + // alive until the async snapshot above has completed. + withExtendedLifetime(window) {} + return SnapshotBox(image: image) + } + + /// Draw a white rounded "document card" with the rendered snapshot clipped + /// to its top, scaled to fill the card width. + private static func drawCard(image: NSImage, in rect: CGRect) { + let radius = min(rect.width, rect.height) * 0.05 + let card = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + NSColor.white.setFill() + card.fill() + + card.addClip() + // Scale the snapshot to the card width; pin it to the top of the card. + let scale = rect.width / max(image.size.width, 1) + let drawnHeight = image.size.height * scale + let drawRect = CGRect(x: rect.minX, + y: rect.maxY - drawnHeight, + width: rect.width, + height: drawnHeight) + image.draw(in: drawRect, + from: .zero, + operation: .sourceOver, + fraction: 1.0) + + NSColor(white: 0.0, alpha: 0.12).setStroke() + let border = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + border.lineWidth = 1 + border.stroke() + } +} diff --git a/Sources/MarkeeQuickLookThumbnail/main.swift b/Sources/MarkeeQuickLookThumbnail/main.swift new file mode 100644 index 0000000..72a3504 --- /dev/null +++ b/Sources/MarkeeQuickLookThumbnail/main.swift @@ -0,0 +1,8 @@ +import Foundation + +// App extensions have no normal main(): the system entry point is +// NSExtensionMain, which runs the extension's XPC service loop. +@_silgen_name("NSExtensionMain") +func NSExtensionMain() -> Int32 + +exit(NSExtensionMain()) diff --git a/Tests/MarkeeTests/SchemeHandlerTests.swift b/Tests/MarkeeKitTests/SchemeHandlerTests.swift similarity index 99% rename from Tests/MarkeeTests/SchemeHandlerTests.swift rename to Tests/MarkeeKitTests/SchemeHandlerTests.swift index 06071b7..f4fb0c2 100644 --- a/Tests/MarkeeTests/SchemeHandlerTests.swift +++ b/Tests/MarkeeKitTests/SchemeHandlerTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import Markee +@testable import MarkeeKit final class SchemeHandlerTests: XCTestCase { private var tempDir: URL! diff --git a/Tests/MarkeeKitTests/ThumbnailLayoutTests.swift b/Tests/MarkeeKitTests/ThumbnailLayoutTests.swift new file mode 100644 index 0000000..19da39c --- /dev/null +++ b/Tests/MarkeeKitTests/ThumbnailLayoutTests.swift @@ -0,0 +1,35 @@ +import XCTest +import CoreGraphics +@testable import MarkeeKit + +final class ThumbnailLayoutTests: XCTestCase { + // pageAspect = width / height. A US-letter-ish page is 0.773 (8.5/11). + func test_fitsPageInsideASquareBudget_heightIsLimiting() { + let size = thumbnailContextSize(maximumSize: CGSize(width: 100, height: 100), + pageAspect: 0.773) + // Tall page: height pins to 100, width follows aspect. + XCTAssertEqual(size.height, 100, accuracy: 0.001) + XCTAssertEqual(size.width, 77.3, accuracy: 0.1) + } + + func test_fitsPageInsideAWideBudget_widthIsLimiting() { + let size = thumbnailContextSize(maximumSize: CGSize(width: 40, height: 100), + pageAspect: 0.773) + // Narrow budget: width pins to 40, height follows aspect. + XCTAssertEqual(size.width, 40, accuracy: 0.001) + XCTAssertEqual(size.height, 51.74, accuracy: 0.1) + } + + func test_neverExceedsTheBudgetInEitherDimension() { + let size = thumbnailContextSize(maximumSize: CGSize(width: 256, height: 256), + pageAspect: 0.773) + XCTAssertLessThanOrEqual(size.width, 256) + XCTAssertLessThanOrEqual(size.height, 256) + } + + func test_zeroBudgetReturnsZero() { + let size = thumbnailContextSize(maximumSize: .zero, pageAspect: 0.773) + XCTAssertEqual(size.width, 0, accuracy: 0.001) + XCTAssertEqual(size.height, 0, accuracy: 0.001) + } +} diff --git a/Tests/MarkeeKitTests/TimeoutTests.swift b/Tests/MarkeeKitTests/TimeoutTests.swift new file mode 100644 index 0000000..680956e --- /dev/null +++ b/Tests/MarkeeKitTests/TimeoutTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import MarkeeKit + +final class TimeoutTests: XCTestCase { + func test_returnsValue_whenOperationFinishesInTime() async throws { + let value = try await withTimeout(seconds: 1.0) { + try await Task.sleep(nanoseconds: 10_000_000) // 10 ms + return 42 + } + XCTAssertEqual(value, 42) + } + + func test_throwsTimeoutError_whenOperationIsTooSlow() async { + do { + _ = try await withTimeout(seconds: 0.2) { + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 s + return 1 + } + XCTFail("expected a timeout error") + } catch is TimeoutError { + // expected + } catch { + XCTFail("expected TimeoutError, got \(error)") + } + } +} diff --git a/Tests/MarkeeKitTests/WebRendererTests.swift b/Tests/MarkeeKitTests/WebRendererTests.swift new file mode 100644 index 0000000..bd78b07 --- /dev/null +++ b/Tests/MarkeeKitTests/WebRendererTests.swift @@ -0,0 +1,30 @@ +import XCTest +import WebKit +@testable import MarkeeKit + +@MainActor +final class WebRendererTests: XCTestCase { + func test_constructsWithAWebView() { + let renderer = WebRenderer(docRoot: URL(fileURLWithPath: "/tmp")) + XCTAssertNotNil(renderer.webView) + } + + func test_loadTemplateDoesNotCrash() { + let renderer = WebRenderer(docRoot: URL(fileURLWithPath: "/tmp")) + renderer.loadTemplate() + XCTAssertNotNil(renderer.webView.configuration) + } + + func test_deallocatesAfterLastReference() { + weak var weakRenderer: WebRenderer? + autoreleasepool { + let renderer = WebRenderer(docRoot: URL(fileURLWithPath: "/tmp")) + weakRenderer = renderer + XCTAssertNotNil(weakRenderer) + } + // Drain autorelease pools / let WebKit settle. + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + XCTAssertNil(weakRenderer, + "WebRenderer must not be retained by its own WKWebView's message handler") + } +} diff --git a/docs/screenshots/thumbnail-demo/API Reference.md b/docs/screenshots/thumbnail-demo/API Reference.md new file mode 100644 index 0000000..243c5f9 --- /dev/null +++ b/docs/screenshots/thumbnail-demo/API Reference.md @@ -0,0 +1,51 @@ +# Pancake HTTP API + +Version 2 of the Pancake API. Every request is JSON over HTTPS, and every +response carries a `request-id` header so you can trace it in the logs. + +## Authentication + +Pass your key as a bearer token on each request: + +```sh +curl https://api.pancake.dev/v2/notes \ + -H "Authorization: Bearer $PANCAKE_KEY" \ + -H "Accept: application/json" +``` + +## List notes + +```http +GET /v2/notes?limit=20&cursor=eyJpZCI6OTB9 +``` + +Returns a page of notes, newest first, with an opaque cursor for the next +page: + +```json +{ + "data": [ + { "id": "n_8f2a", "title": "Roadmap", "updated": 1716100000 }, + { "id": "n_8f29", "title": "Sprint 14", "updated": 1716090000 } + ], + "next_cursor": "eyJpZCI6IDg5fQ" +} +``` + +## Create a note + +```json +POST /v2/notes +{ + "title": "Sprint 15", + "body": "## Goals\n- Ship search\n- Cut render time" +} +``` + +## Errors + +A failed request returns a typed error object and an appropriate status code: + +```json +{ "error": { "code": "rate_limited", "retry_after": 30 } } +``` diff --git a/docs/screenshots/thumbnail-demo/Benchmarks.md b/docs/screenshots/thumbnail-demo/Benchmarks.md new file mode 100644 index 0000000..117c50b --- /dev/null +++ b/docs/screenshots/thumbnail-demo/Benchmarks.md @@ -0,0 +1,29 @@ +# Renderer Benchmarks + +Cold-start render time — from opening a file to the first painted preview — +measured across document sizes on an Apple M3 Pro. Each figure is the median +of 30 runs. + +## By document size + +| Document | Lines | Parse (ms) | Render (ms) | Total (ms) | +|----------------|--------|------------|-------------|------------| +| Short note | 40 | 1.2 | 8.4 | 9.6 | +| Typical README | 220 | 3.1 | 14.7 | 17.8 | +| Design spec | 900 | 9.8 | 41.2 | 51.0 | +| Book chapter | 3,400 | 32.6 | 138.9 | 171.5 | +| Generated dump | 12,000 | 121.4 | 503.7 | 625.1 | + +Render time scales close to linearly with document length — there is no +cliff as files grow. + +## By feature + +| Feature | Off (ms) | On (ms) | Delta | +|-------------------|----------|---------|--------| +| Syntax highlight | 14.7 | 19.3 | +4.6 | +| KaTeX math | 14.7 | 28.1 | +13.4 | +| Mermaid diagrams | 14.7 | 96.5 | +81.8 | + +Mermaid dominates the cost, which is why it now loads lazily: only documents +that actually contain a diagram pay for it. diff --git a/docs/screenshots/thumbnail-demo/Calculus Notes.md b/docs/screenshots/thumbnail-demo/Calculus Notes.md new file mode 100644 index 0000000..97d32f1 --- /dev/null +++ b/docs/screenshots/thumbnail-demo/Calculus Notes.md @@ -0,0 +1,41 @@ +# Calculus — Key Results + +A condensed reference for the results that come up most often, with just +enough context to remember why each one matters. + +## The derivative + +The derivative measures the instantaneous rate of change of a function: + +$$ +f'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h} +$$ + +## The fundamental theorem + +Differentiation and integration are inverse operations — this is what ties +the whole subject together: + +$$ +\int_a^b f'(x)\,dx = f(b) - f(a) +$$ + +## Taylor series + +Any sufficiently smooth function can be expanded as a power series about a +point $a$: + +$$ +f(x) = \sum_{n=0}^{\infty} \frac{f^{(n)}(a)}{n!}\,(x - a)^n +$$ + +## The Gaussian integral + +A result that appears throughout probability and physics, and which has no +elementary antiderivative: + +$$ +\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} +$$ + +Keep these four within reach and most first-year problems become bookkeeping. diff --git a/docs/screenshots/thumbnail-demo/Onboarding Guide.md b/docs/screenshots/thumbnail-demo/Onboarding Guide.md new file mode 100644 index 0000000..cd3dacc --- /dev/null +++ b/docs/screenshots/thumbnail-demo/Onboarding Guide.md @@ -0,0 +1,32 @@ +# Team Onboarding + +Welcome aboard. This guide takes you from a fresh laptop to your first merged +pull request, without anyone having to tap you on the shoulder. + +## Before day one + +> Ask IT for your hardware a week ahead. Provisioning a Mac takes time, and +> you do not want to spend your first morning watching a progress bar. + +## Day one + +1. Sign in to email, chat, and the shared calendar. +2. Clone the main repository and run `./scripts/setup`. +3. Build the app and confirm the test suite is green. +4. Post a short hello in the team channel. + +## Your first week + +1. Pick a *good first issue* from the tracker. +2. Pair with your onboarding buddy on the fix. +3. Open a pull request and walk through the review together. +4. Once it merges, pick a slightly larger issue and repeat. + +> The goal of week one is not output — it is to learn how work flows here. +> A small, well-understood change beats a large, rushed one every time. + +## Where to ask + +Questions are expected, not tolerated. The team channel is the front door; +nothing is too small to ask there, and someone has almost certainly hit the +same wall before you. diff --git a/docs/screenshots/thumbnail-demo/Project Roadmap.md b/docs/screenshots/thumbnail-demo/Project Roadmap.md new file mode 100644 index 0000000..4f98eb1 --- /dev/null +++ b/docs/screenshots/thumbnail-demo/Project Roadmap.md @@ -0,0 +1,31 @@ +# Product Roadmap + +Where Markee is headed. Items move up the list as they are picked up, and +checked items ship in the next release. + +## Shipping now + +- [x] Live re-render on save, scroll position preserved +- [x] Quick Look preview extension +- [x] Per-file Finder thumbnails +- [x] Markdown document icon +- [x] Developer ID signing and notarization +- [x] Lazy-loaded Mermaid for faster cold starts + +## Next up + +- [ ] DMG installer with a drag-to-Applications layout +- [ ] Homebrew cask — `brew install --cask markee` +- [ ] In-app theme picker with custom CSS support +- [ ] Remember window size and position per document +- [ ] A print stylesheet tuned for page breaks + +## Under consideration + +- [ ] Multi-file project sidebar +- [ ] Presenter mode for slide-style Markdown +- [ ] Direct PDF export without the print dialog +- [ ] Live word count and reading-time estimate +- [ ] Configurable Markdown dialect per document + +Nothing here is a promise — it is a snapshot of intent, revised every sprint. diff --git a/docs/screenshots/thumbnail-demo/Welcome.md b/docs/screenshots/thumbnail-demo/Welcome.md new file mode 100644 index 0000000..80b0bc7 --- /dev/null +++ b/docs/screenshots/thumbnail-demo/Welcome.md @@ -0,0 +1,36 @@ +# Welcome to Markee + +The Markdown previewer that gets out of your way. + +Markee watches a file on disk and re-renders the moment you save — so you keep +editing in the tool you already love, and the preview simply keeps up. No +browser tab, no plugin, no copy-and-paste. + +--- + +## What it does + +- Live re-render on save, with your scroll position preserved +- GitHub-flavored Markdown — footnotes, tables, task lists, front matter +- KaTeX math, Mermaid diagrams, and syntax highlighting +- Interactive task-list checkboxes that write back to the file +- Quick Look previews and per-file Finder thumbnails +- Light and dark themes that follow the system appearance + +## Getting started + +Open any `.md` file in Markee, or launch it from the terminal: + +```sh +markee notes/ideas.md +``` + +Then save in your editor and watch the preview update. That is the entire +workflow — there is nothing else to learn, and nothing to configure before +you see your first rendered page. + +## Going further + +Press space on a Markdown file in Finder for a quick rendered look, toggle +the outline with ⌘⌥\\ to navigate long documents, and export a standalone +HTML copy with ⌘E when you need to share one. diff --git a/scripts/build-icon.sh b/scripts/build-icon.sh index 3de30a3..a6fda34 100755 --- a/scripts/build-icon.sh +++ b/scripts/build-icon.sh @@ -1,25 +1,10 @@ #!/usr/bin/env bash -# Generate AppIcon.icns from Resources/AppIcon.svg using built-in macOS tooling. +# Generate .icns files from SVG sources using built-in macOS tooling. +# Builds the app icon and the Markdown document icon. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SRC="$ROOT/Resources/AppIcon.svg" -OUT_ICNS="$ROOT/Resources/AppIcon.icns" -ICONSET="$ROOT/build/AppIcon.iconset" - -if [ ! -f "$SRC" ]; then - echo "build-icon: missing $SRC" >&2; exit 1 -fi - -# Skip if .icns is newer than the source SVG. -if [ -f "$OUT_ICNS" ] && [ "$OUT_ICNS" -nt "$SRC" ]; then - echo "build-icon: AppIcon.icns is up to date." - exit 0 -fi - -rm -rf "$ICONSET" -mkdir -p "$ICONSET" # Required sizes (logical @ 1x and @ 2x). Pairs of "filename:pixel-size". PAIRS=( @@ -35,11 +20,29 @@ PAIRS=( "icon_512x512@2x.png:1024" ) -for pair in "${PAIRS[@]}"; do - name="${pair%%:*}" - size="${pair##*:}" - sips -s format png -z "$size" "$size" "$SRC" --out "$ICONSET/$name" >/dev/null -done - -iconutil -c icns -o "$OUT_ICNS" "$ICONSET" -echo "build-icon: wrote $OUT_ICNS" +build_icns() { + local src="$1" + local out_icns="$2" + local iconset="$3" + + if [ ! -f "$src" ]; then + echo "build-icon: missing $src" >&2; exit 1 + fi + if [ -f "$out_icns" ] && [ "$out_icns" -nt "$src" ]; then + echo "build-icon: $(basename "$out_icns") is up to date." + return + fi + + rm -rf "$iconset" + mkdir -p "$iconset" + for pair in "${PAIRS[@]}"; do + local name="${pair%%:*}" + local size="${pair##*:}" + sips -s format png -z "$size" "$size" "$src" --out "$iconset/$name" >/dev/null + done + iconutil -c icns -o "$out_icns" "$iconset" + echo "build-icon: wrote $out_icns" +} + +build_icns "$ROOT/Resources/AppIcon.svg" "$ROOT/Resources/AppIcon.icns" "$ROOT/build/AppIcon.iconset" +build_icns "$ROOT/Resources/DocIcon.svg" "$ROOT/Resources/DocIcon.icns" "$ROOT/build/DocIcon.iconset" diff --git a/scripts/notarize-app.sh b/scripts/notarize-app.sh new file mode 100755 index 0000000..8f3ec36 --- /dev/null +++ b/scripts/notarize-app.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Notarize a Developer-ID-signed Markee.app and staple the ticket. +# +# Credentials (one of): +# - NOTARY_KEYCHAIN_PROFILE: name of a profile stored via +# `xcrun notarytool store-credentials` (local use). +# - NOTARY_KEY_P8_PATH + NOTARY_KEY_ID + NOTARY_ISSUER_ID: an App Store +# Connect API key file and its identifiers (CI use). +# +# Usage: scripts/notarize-app.sh [path/to/Markee.app] (default: Markee.app) + +set -euo pipefail + +APP="${1:-Markee.app}" + +if [ ! -d "$APP" ]; then + echo "notarize-app: no app bundle at '$APP'" >&2 + exit 1 +fi + +# Refuse an ad-hoc bundle — notarization needs a Developer ID signature. +if codesign -dvv "$APP" 2>&1 | grep -q "Signature=adhoc"; then + echo "notarize-app: '$APP' is ad-hoc signed — run a Developer ID build first" >&2 + exit 1 +fi + +# Assemble notarytool credential arguments. +if [ -n "${NOTARY_KEYCHAIN_PROFILE:-}" ]; then + CRED_ARGS=(--keychain-profile "$NOTARY_KEYCHAIN_PROFILE") +elif [ -n "${NOTARY_KEY_P8_PATH:-}" ] \ + && [ -n "${NOTARY_KEY_ID:-}" ] \ + && [ -n "${NOTARY_ISSUER_ID:-}" ]; then + CRED_ARGS=(--key "$NOTARY_KEY_P8_PATH" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID") +else + echo "notarize-app: no credentials — set NOTARY_KEYCHAIN_PROFILE, or" >&2 + echo " NOTARY_KEY_P8_PATH + NOTARY_KEY_ID + NOTARY_ISSUER_ID" >&2 + exit 1 +fi + +ZIP="${APP%.app}-notarize.zip" +trap 'rm -f "$ZIP"' EXIT # clean the submission zip on any exit, incl. Ctrl-C + +echo "notarize-app: zipping $APP -> $ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" + +echo "notarize-app: submitting to Apple (this can take a few minutes)..." +# Capture output and exit code without `set -e` aborting on a non-zero rc: +# success is decided by the reported status, not notarytool's exit code +# (its exit semantics for a rejected submission are not reliable). +set +e +SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP" "${CRED_ARGS[@]}" --wait 2>&1) +SUBMIT_RC=$? +set -e +echo "$SUBMIT_OUTPUT" +SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | awk -F': *' '/^ *id:/{print $2; exit}') + +if ! echo "$SUBMIT_OUTPUT" | grep -qE '^[[:space:]]*status: Accepted[[:space:]]*$'; then + if [ -n "$SUBMISSION_ID" ]; then + echo "notarize-app: notarization was not Accepted — fetching the log" >&2 + xcrun notarytool log "$SUBMISSION_ID" "${CRED_ARGS[@]}" >&2 || true + else + echo "notarize-app: notarytool submit failed before a submission was created" >&2 + echo "notarize-app: (rc=$SUBMIT_RC) — check credentials and network" >&2 + fi + exit 1 +fi + +echo "notarize-app: stapling ticket" +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" + +if ! spctl -a -vvv -t exec "$APP"; then + echo "notarize-app: Gatekeeper assessment failed — see spctl output above" >&2 + exit 1 +fi +echo "notarize-app: done — $APP is notarized and stapled" diff --git a/scripts/sign-app.sh b/scripts/sign-app.sh new file mode 100755 index 0000000..16715c7 --- /dev/null +++ b/scripts/sign-app.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Sign Markee.app and its Quick Look extensions. +# +# Uses a Developer ID Application identity when one is available — taken from +# $CODESIGN_IDENTITY, else auto-detected from the keychain — and applies the +# Hardened Runtime plus a secure timestamp (both required for notarization). +# When no Developer ID identity is found it falls back to ad-hoc signing, so CI +# build-test and contributors without the certificate can still build. +# +# Usage: scripts/sign-app.sh [path/to/Markee.app] (default: Markee.app) + +set -euo pipefail + +APP="${1:-Markee.app}" + +if [ ! -d "$APP" ]; then + echo "sign-app: no app bundle at '$APP'" >&2 + exit 1 +fi + +# Resolve the signing identity: explicit override ($CODESIGN_IDENTITY, a name +# or a SHA-1 hash), else the first Developer ID Application identity in the +# keychain. Auto-detection matches by SHA-1 hash (field 2) rather than name: +# a keychain may hold more than one cert with the same name, and codesign +# rejects an ambiguous name. +IDENTITY="${CODESIGN_IDENTITY:-}" +if [ -z "$IDENTITY" ]; then + # Match by SHA-1 hash (field 2), not name: a keychain may hold more than + # one cert with the same name, and codesign rejects an ambiguous name. + # A single awk (no grep) prints nothing and exits 0 on no match, so the + # ad-hoc fallback below stays reachable under `set -euo pipefail`. + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk '/Developer ID Application/ { print $2; exit }') +fi + +PREVIEW_APPEX="$APP/Contents/PlugIns/QuickLookPreview.appex" +THUMBNAIL_APPEX="$APP/Contents/PlugIns/QuickLookThumbnail.appex" + +if [ -n "$IDENTITY" ]; then + echo "sign-app: Developer ID signing as: $IDENTITY" + SIGN_ARGS=(--force --options runtime --timestamp --sign "$IDENTITY") +else + echo "sign-app: no Developer ID identity found — ad-hoc signing" + SIGN_ARGS=(--force --sign -) +fi + +# Sign inside-out: nested extensions first, then the app. `codesign --deep` is +# deprecated and seals nested code unreliably — sign each item explicitly. +# +# The two Quick Look extensions MUST be App-Sandboxed — pkd refuses to register +# an unsandboxed Quick Look extension. The app itself is NOT sandboxed (it +# watches and writes Markdown files at arbitrary paths). +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +EXT_ENTITLEMENTS="$SCRIPT_DIR/../Resources/QuickLookExtension.entitlements" +if [ ! -f "$EXT_ENTITLEMENTS" ]; then + echo "sign-app: missing $EXT_ENTITLEMENTS" >&2 + exit 1 +fi + +for appex in "$PREVIEW_APPEX" "$THUMBNAIL_APPEX"; do + if [ -d "$appex" ]; then + echo "sign-app: signing $appex (sandboxed extension)" + codesign "${SIGN_ARGS[@]}" --entitlements "$EXT_ENTITLEMENTS" "$appex" + fi +done + +if [ -d "$APP" ]; then + echo "sign-app: signing $APP" + codesign "${SIGN_ARGS[@]}" "$APP" +fi + +codesign --verify --strict --verbose=2 "$APP" +echo "sign-app: done ($APP)" diff --git a/site/img/icon-dark.png b/site/img/icon-dark.png new file mode 100644 index 0000000..102dde9 Binary files /dev/null and b/site/img/icon-dark.png differ diff --git a/site/img/icon-light.png b/site/img/icon-light.png new file mode 100644 index 0000000..b292184 Binary files /dev/null and b/site/img/icon-light.png differ diff --git a/site/img/preview-dark.png b/site/img/preview-dark.png new file mode 100644 index 0000000..af510f3 Binary files /dev/null and b/site/img/preview-dark.png differ diff --git a/site/img/preview-light.png b/site/img/preview-light.png new file mode 100644 index 0000000..6689bd3 Binary files /dev/null and b/site/img/preview-light.png differ diff --git a/site/img/thumbnails-dark.png b/site/img/thumbnails-dark.png new file mode 100644 index 0000000..cf8d1cd Binary files /dev/null and b/site/img/thumbnails-dark.png differ diff --git a/site/img/thumbnails-light.png b/site/img/thumbnails-light.png new file mode 100644 index 0000000..9a0180f Binary files /dev/null and b/site/img/thumbnails-light.png differ diff --git a/site/index.html b/site/index.html index d9d5b22..5e15a21 100644 --- a/site/index.html +++ b/site/index.html @@ -113,6 +113,42 @@

Open at any heading

Built for the way developers actually read Markdown.

+
+
+ + + + A macOS Quick Look window showing a Markdown file rendered by Markee — headings, text, and code — instead of raw source. + +
+
+

Quick Look, fully rendered

+

Press Space on any Markdown file in Finder and Markee renders it in place — headings, tables, code, math, and diagrams — instead of showing raw source. It's the same renderer as the app, running in a sandboxed Quick Look extension.

+
+
+ +
+
+ + + + A Finder window in icon view showing several Markdown files, each with a per-file thumbnail of its rendered content. + +
+
+

At home in Finder

+

Every .md file gets a per-file thumbnail of its actual rendered content, so a folder of notes is scannable at a glance. Where a thumbnail isn't available, files fall back to a clean, branded Markdown document icon.

+
+ + + + The Markee Markdown document icon shown at small size — a white page carrying the Markee M mark. + +
The document icon — legible down to 16 px.
+
+
+
+
@@ -163,7 +199,7 @@

Open at any heading, in your editor

Install

-

Two steps. No Homebrew tap, no notarization wait.

+

Download, drag, open — no Homebrew tap, no Gatekeeper detour.

    @@ -178,13 +214,8 @@

    Download Markee.app.zip from the latest release

  1. 2
    -

    First launch — clear the quarantine flag

    -

    Markee is ad-hoc signed, not yet notarized with an Apple Developer ID. On first launch macOS will warn you. Two ways past it:

    -
      -
    • Right-click → Open in Finder, then click Open in the dialog. Once is enough.
    • -
    • Or, in Terminal:
    • -
    -
    xattr -dr com.apple.quarantine /Applications/Markee.app
    +

    Open it

    +

    Markee is signed with an Apple Developer ID and notarized by Apple, so it opens with a normal double-click — no Gatekeeper warning, no right-click trick, no Terminal.

diff --git a/site/style.css b/site/style.css index 3c4ca8d..3d4e6ce 100644 --- a/site/style.css +++ b/site/style.css @@ -450,6 +450,23 @@ kbd { margin: 0; } +.feature-inline { + margin: 22px 0 0; + max-width: 300px; +} +.feature-inline img { + width: 100%; + height: auto; + border-radius: var(--radius-card); + border: 1px solid var(--hairline); + background: var(--surface-3); +} +.feature-inline figcaption { + margin-top: 8px; + font-size: 0.86rem; + color: var(--fg-muted); +} + /* ---- Install ------------------------------------------------------------- */ .install { background: var(--surface-2);