Skip to content

Instantly share code, notes, and snippets.

@heathdutton
Last active September 3, 2026 17:12
Show Gist options
  • Select an option

  • Save heathdutton/b0a8149f1a159efac23fe97df7768f47 to your computer and use it in GitHub Desktop.

Select an option

Save heathdutton/b0a8149f1a159efac23fe97df7768f47 to your computer and use it in GitHub Desktop.
Install MarkEdit and prime extensions for macOS: curl -L markedit.install.id|sh
#!/bin/sh
#
# Install MarkEdit and its extensions on macOS.
# Location: https://gist.github.com/heathdutton/b0a8149f1a159efac23fe97df7768f47
# Run this using: curl -L markedit.install.id|sh
#
# Needs macOS 15+ and nothing else. Homebrew is used when it is already installed, otherwise
# the app is downloaded straight from GitHub releases and verified before it is installed.
# Extensions come from the official registry, pinned and checked by SHA-256.
#
# Safe to rerun: it upgrades what is there, and it asks before quitting a running MarkEdit.
#
# Env:
# MARKEDIT_DEMO=1 always write and open the demo document
# MARKEDIT_QUIT=1 pre-approve quitting MarkEdit for an upgrade
# MARKEDIT_THEMING=1 also clone the theme-development library
# MARKEDIT_APP_DIR=<dir> install the app somewhere other than /Applications
# MARKEDIT_REGISTRY_BASE=<url> use a registry mirror
set -eu
# pipefail is not POSIX. macOS /bin/sh is bash and has it, so take it when it is on offer.
(set -o pipefail) 2>/dev/null && set -o pipefail || true
BUNDLE_ID="app.cyan.markedit"
APP_REPO="MarkEdit-app/MarkEdit"
APP_DIR="${MARKEDIT_APP_DIR:-/Applications}"
CONTAINER="${MARKEDIT_CONTAINER:-$HOME/Library/Containers/$BUNDLE_ID/Data/Documents}"
THEMING_DIR="$HOME/.local/share/markedit-theming"
REGISTRY_BASE="${MARKEDIT_REGISTRY_BASE:-https://raw.githubusercontent.com/MarkEdit-app/extensions/main}"
DEMO_NAME="MarkEdit Demo.md"
DEMO_DIR="$HOME/Documents"
DEMO_URL="https://gist.githubusercontent.com/heathdutton/b0a8149f1a159efac23fe97df7768f47/raw/markedit-demo.md"
REGISTRY_INDEX="$REGISTRY_BASE/index.json"
SCHEMA_URL="https://raw.githubusercontent.com/MarkEdit-app/schemas/main/extensions.json"
NL='
'
# Registry ids to install. Each one is also the installed filename, which is the convention
# MarkEdit's extension manager expects.
#
# language-data is not optional in practice: 1.34.0 moved code-block syntax highlighting out
# of the app and into this extension, so without it fenced code blocks render flat.
EXTENSIONS="markedit-preview markedit-language-data markedit-ai-writer"
say() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# Fail on HTTP errors rather than writing an error page to disk, and retry a flaky network.
fetch() {
curl -fsSL --connect-timeout 10 --max-time 300 \
--retry 3 --retry-delay 1 --retry-connrefused "$@"
}
# With `curl | bash`, stdin is the script itself, so prompts have to use the terminal.
have_tty() { [ -c /dev/tty ] && (: < /dev/tty) 2>/dev/null; }
ask() {
reply=""
have_tty || return 1
printf '%s ' "$1" > /dev/tty
read -r reply < /dev/tty || return 1
case "$reply" in
[yY] | [yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
# Quit gracefully so an unsaved document gets its save prompt instead of being killed.
# Returns non-zero if it is still running afterwards, which happens when a dialog is waiting
# or when automation permission was denied, and the caller then leaves the app alone.
quit_markedit() {
osascript -e 'quit app "MarkEdit"' >/dev/null 2>&1 || return 1
waited=0
while pgrep -x MarkEdit >/dev/null 2>&1 && [ "$waited" -lt 15 ]; do
sleep 1
waited=$((waited + 1))
done
! pgrep -x MarkEdit >/dev/null 2>&1
}
# True when $1 is an older version than $2. Field-wise numeric sort, because BSD sort has no -V.
version_lt() {
if [ "$1" = "$2" ]; then
return 1
fi
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "$1" ]
}
# First "<key>": "<value>" in a JSON file. The registry lists versions newest-first, so the
# first hit for version/url/sha256 is the current release.
json_first() {
LC_ALL=C sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
sha_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
app_path() { [ -d "$APP_DIR/MarkEdit.app" ] && printf '%s' "$APP_DIR/MarkEdit.app"; }
app_version() {
path="$(app_path)" || return 0
defaults read "$path/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || true
}
# Newest release tag, read off the redirect rather than the API, so no token is needed.
release_tag() {
curl -fsSLI -o /dev/null -w '%{url_effective}' --connect-timeout 10 --max-time 60 \
"https://github.com/$1/releases/latest" 2>/dev/null | sed 's#.*/tag/v\{0,1\}##'
}
# Ask (once) to close a running MarkEdit so its bundle can be replaced. Sets relaunch_after.
close_for_upgrade() {
pgrep -x MarkEdit >/dev/null 2>&1 || return 0
say "MarkEdit is running and an upgrade is waiting."
if [ "${MARKEDIT_QUIT:-}" = "1" ] || ask "Quit MarkEdit and upgrade it? Unsaved work will prompt. [y/N]"; then
if quit_markedit; then
relaunch_after=1
return 0
fi
say " -> MarkEdit is still running, so the upgrade was skipped."
say " Answer its save prompt, or allow automation for this terminal, then rerun."
return 1
fi
say " -> left running, upgrade skipped. Rerun once it's closed."
return 1
}
# --- Preflight ---
[ "$(uname -s)" = "Darwin" ] || die "macOS only, this looks like $(uname -s)."
[ "$(id -u)" -ne 0 ] || die "don't run this as root, the app and its container belong to your user."
command -v curl >/dev/null 2>&1 || die "curl is missing, which should not be possible on macOS."
os_version="$(sw_vers -productVersion 2>/dev/null || echo 0)"
case "${os_version%%.*}" in
'' | *[!0-9]*) die "could not read the macOS version." ;;
*) [ "${os_version%%.*}" -ge 15 ] || die "MarkEdit needs macOS 15 or later, this is $os_version." ;;
esac
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/markedit-install.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
relaunch_after=0
fresh_install=0
# --- Homebrew, used only when it is already here ---
# Never installed on demand: the direct path below needs nothing macOS doesn't already ship,
# so there is no reason to drag in a package manager or ask for an admin password.
#
# Detection has to be generous. A `curl | bash` from fish, or any shell that never sourced
# shellenv, can have brew installed and absent from PATH, and mistaking that for "no Homebrew"
# is how a machine ends up being asked to install a Homebrew it already has.
if ! command -v brew >/dev/null 2>&1; then
for candidate in "${HOMEBREW_PREFIX:-/nonexistent}/bin/brew" /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [ -x "$candidate" ]; then
eval "$("$candidate" shellenv)"
break
fi
done
fi
# --- MarkEdit, through Homebrew when it manages the app ---
install_app_brew() {
if ! brew list --cask markedit >/dev/null 2>&1; then
say "Installing MarkEdit..."
fresh_install=1
brew install --cask markedit
return 0
fi
# Captured rather than piped: this must not depend on brew's exit code. Deliberately not
# --greedy, because 1.34.0 updates itself, so brew's receipt goes stale and greedy would
# claim an upgrade on every run.
outdated="$(brew outdated --cask markedit 2>/dev/null || true)"
if [ -z "$outdated" ]; then
say "MarkEdit is up to date."
return 0
fi
close_for_upgrade || return 0
say "Upgrading MarkEdit..."
brew upgrade --cask markedit
}
# --- MarkEdit, straight from GitHub releases when Homebrew is not in the picture ---
install_app_direct() {
# A Caskroom entry with no working brew means Homebrew owns this app and we simply cannot
# see it. Replacing the bundle here would leave brew's receipt pointing at a version that
# is no longer on disk, so stop and let the user run it where brew works.
for caskroom in /opt/homebrew/Caskroom/markedit /usr/local/Caskroom/markedit; do
if [ -d "$caskroom" ]; then
die "Homebrew installed MarkEdit ($caskroom) but brew isn't usable here, so this run would desync it. Open a terminal where 'brew --version' works and rerun."
fi
done
latest="$(release_tag "$APP_REPO")"
[ -n "$latest" ] || die "could not work out the latest MarkEdit release."
installed="$(app_version)"
if [ -n "$installed" ]; then
if [ "$installed" = "$latest" ] || ! version_lt "$installed" "$latest"; then
say "MarkEdit is up to date ($installed)."
return 0
fi
close_for_upgrade || return 0
else
fresh_install=1
fi
arch="$(uname -m)"
case "$arch" in
arm64) asset="UpdateArchive-arm64.zip" ;;
x86_64) asset="UpdateArchive.zip" ;;
*) die "unsupported architecture: $arch" ;;
esac
say "Downloading MarkEdit $latest ($asset)..."
archive="$WORK_DIR/$asset"
fetch -o "$archive" "https://github.com/$APP_REPO/releases/latest/download/$asset" \
|| die "could not download $asset."
stage="$WORK_DIR/stage"
mkdir -p "$stage"
ditto -x -k "$archive" "$stage" 2>/dev/null || die "could not expand $asset."
staged="$stage/MarkEdit.app"
[ -d "$staged" ] || die "the archive did not contain MarkEdit.app."
if [ -n "$(find "$stage" -type l 2>/dev/null | head -1)" ]; then
die "the archive contains symlinks, refusing to install it."
fi
staged_id="$(defaults read "$staged/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || true)"
[ "$staged_id" = "$BUNDLE_ID" ] || die "staged app claims bundle id '$staged_id', expected $BUNDLE_ID."
staged_version="$(defaults read "$staged/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || true)"
[ "$staged_version" = "$latest" ] || die "staged app is $staged_version but the release is $latest."
# `file` rather than `lipo`, which is a Command Line Tools stub on a bare Mac.
file "$staged/Contents/MacOS/MarkEdit" 2>/dev/null | grep -q "$arch" \
|| die "staged app has no $arch binary."
codesign --verify --strict "$staged" >/dev/null 2>&1 || die "staged app failed signature verification."
spctl --assess --type execute "$staged" >/dev/null 2>&1 || die "Gatekeeper rejected the staged app."
# A signing-team change means it is not the same publisher, whatever the bundle id says.
current="$(app_path || true)"
if [ -n "$current" ]; then
old_team="$(codesign -dv "$current" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
new_team="$(codesign -dv "$staged" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
if [ -n "$old_team" ] && [ "$old_team" != "$new_team" ]; then
die "signing team changed ($old_team -> $new_team), refusing to replace the app."
fi
fi
if [ ! -d "$APP_DIR" ] || [ ! -w "$APP_DIR" ]; then
APP_DIR="$HOME/Applications"
mkdir -p "$APP_DIR"
say " -> /Applications isn't writable, installing to $APP_DIR"
fi
backup=""
if [ -d "$APP_DIR/MarkEdit.app" ]; then
backup="$WORK_DIR/MarkEdit.app.backup"
mv "$APP_DIR/MarkEdit.app" "$backup" || die "could not move the installed app aside."
fi
if ! mv "$staged" "$APP_DIR/MarkEdit.app"; then
if [ -n "$backup" ]; then
mv "$backup" "$APP_DIR/MarkEdit.app" || true
die "could not install into $APP_DIR. The previous app was put back."
fi
die "could not install into $APP_DIR."
fi
say " -> installed $latest into $APP_DIR"
}
if command -v brew >/dev/null 2>&1; then
install_app_brew
else
install_app_direct
fi
installed_version="$(app_version)"
if [ -n "$installed_version" ]; then
say " -> MarkEdit $installed_version"
fi
# --- Extensions ---
# The sandbox container is created by macOS on first launch. Making it by hand invites the
# container manager to move it aside, so launch the app once and wait for it instead.
if [ ! -d "$CONTAINER" ]; then
say "Waiting for MarkEdit's container (first launch)..."
open -g -a MarkEdit >/dev/null 2>&1 || true
waited=0
while [ ! -d "$CONTAINER" ] && [ "$waited" -lt 30 ]; do
sleep 1
waited=$((waited + 1))
done
fi
[ -d "$CONTAINER" ] || die "MarkEdit's container never showed up. Launch MarkEdit once, then rerun."
SCRIPTS_DIR="$CONTAINER/scripts"
mkdir -p "$SCRIPTS_DIR"
records=""
for id in $EXTENSIONS; do
entry="$WORK_DIR/$id.json"
fetch -o "$entry" "$REGISTRY_BASE/extensions/$id.json" \
|| die "$id is not in the registry at $REGISTRY_BASE."
version="$(json_first "$entry" version)"
url="$(json_first "$entry" url)"
sha="$(json_first "$entry" sha256)"
min_app="$(json_first "$entry" minAppVersion)"
[ -n "$version" ] && [ -n "$url" ] && [ -n "$sha" ] \
|| die "registry entry for $id is missing version, url, or sha256."
case "$url" in
https://*) ;;
*) die "registry entry for $id has a non-HTTPS url." ;;
esac
if [ -n "$min_app" ] && [ -n "$installed_version" ] && version_lt "$installed_version" "$min_app"; then
die "$id $version needs MarkEdit $min_app, but $installed_version is installed."
fi
say "Installing $id $version..."
staged="$WORK_DIR/$id.js"
fetch -o "$staged" "$url" || die "could not download $id from $url."
got="$(sha_of "$staged")"
[ "$got" = "$sha" ] || die "$id failed its checksum (registry $sha, downloaded $got). Nothing was changed."
mv "$staged" "$SCRIPTS_DIR/$id.js"
say " -> $SCRIPTS_DIR/$id.js"
record=" {$NL \"id\": \"$id\",$NL \"version\": \"$version\",$NL \"url\": \"$url\",$NL \"sha256\": \"$sha\",$NL \"file\": \"$id.js\",$NL \"enabled\": true,$NL \"installDate\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"$NL }"
if [ -n "$records" ]; then
records="$records,$NL$record"
else
records="$record"
fi
done
# --- extensions.json ---
# Recording version, url, and sha256 is what lets MarkEdit's own updater take over from here.
# Without it the app adopts the files as untracked, and never offers an update for them.
#
# Only written when this script owns every record, since merging JSON without a parser would
# be a good way to eat somebody's manager-installed extension.
EXTENSIONS_JSON="$CONTAINER/extensions.json"
write_json=1
if [ -f "$EXTENSIONS_JSON" ]; then
for found in $(LC_ALL=C sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$EXTENSIONS_JSON"); do
case " $EXTENSIONS " in
*" $found "*) ;;
*) write_json=0 ;;
esac
done
fi
if [ "$write_json" = "1" ]; then
printf '{\n "$schema": "%s",\n "registry.url": "%s",\n "registry.updateBehavior": "quiet",\n "installed": [\n%s\n ]\n}\n' \
"$SCHEMA_URL" "$REGISTRY_INDEX" "$records" > "$EXTENSIONS_JSON.tmp"
mv "$EXTENSIONS_JSON.tmp" "$EXTENSIONS_JSON"
say " -> recorded versions in extensions.json, so the app can update them itself"
else
say " -> left extensions.json alone, it tracks extensions this script didn't install"
fi
# --- MarkEdit-theming (opt-in) ---
# A library for building themes, not an extension. Skipped on a bare Mac on purpose: cloning
# needs git, and /usr/bin/git is a stub that pops the Command Line Tools installer.
if [ -d "$THEMING_DIR" ] || [ "${MARKEDIT_THEMING:-}" = "1" ]; then
if xcode-select -p >/dev/null 2>&1; then
say "Installing MarkEdit-theming (library)..."
if [ -d "$THEMING_DIR" ]; then
git -C "$THEMING_DIR" pull --ff-only >/dev/null 2>&1 || say " -> pull skipped (local changes?)"
else
git clone --depth 1 https://github.com/MarkEdit-app/MarkEdit-theming.git "$THEMING_DIR" >/dev/null 2>&1 \
|| say " -> clone failed, skipping"
fi
if [ -d "$THEMING_DIR" ]; then
say " -> $THEMING_DIR"
fi
else
say "Skipping MarkEdit-theming, no command line tools for git."
fi
fi
# --- Default editor for Markdown ---
# Installing the app is what associates .md: MarkEdit declares net.daringfireball.markdown in
# its own bundle. duti's role writes do not persist on current macOS (they exit 0 and change
# nothing), so only attempt them when duti is already here, and trust LaunchServices for the
# answer rather than claiming success.
handler=""
if command -v duti >/dev/null 2>&1; then
for uti in net.daringfireball.markdown app.markedit.md app.markedit.markdown; do
duti -s "$BUNDLE_ID" "$uti" all >/dev/null 2>&1 || true
done
handler="$(duti -x md 2>/dev/null | head -1 || true)"
fi
case "$handler" in
MarkEdit.app) say "Markdown: .md opens in MarkEdit." ;;
'') say "Markdown: .md should open in MarkEdit now. If it doesn't, Get Info on any .md file in Finder, then Open With, MarkEdit, Change All." ;;
*) say "Markdown: .md currently opens in $handler. To switch, Get Info on any .md file in Finder, then Open With, MarkEdit, Change All." ;;
esac
# --- Demo document ---
# A first run has nothing to look at, and MarkEdit ships no sample of its own, so leave one
# behind: it doubles as the shortcut reference and a tour of what the preview renders.
open_demo=""
demo_path="$DEMO_DIR/$DEMO_NAME"
if [ "$fresh_install" = "1" ] || [ "${MARKEDIT_DEMO:-}" = "1" ]; then
if [ ! -f "$demo_path" ]; then
mkdir -p "$DEMO_DIR"
if fetch -o "$demo_path.tmp" "$DEMO_URL"; then
mv "$demo_path.tmp" "$demo_path"
else
rm -f "$demo_path.tmp"
say " -> couldn't fetch the demo document, skipping it"
fi
fi
if [ -f "$demo_path" ]; then
open_demo="$demo_path"
fi
fi
# Bring the app back if we closed it, and hand over the demo when there is one.
if [ -n "$open_demo" ]; then
open -a MarkEdit "$open_demo" >/dev/null 2>&1 || true
elif [ "$relaunch_after" = "1" ]; then
open -a MarkEdit >/dev/null 2>&1 || true
fi
say ""
if [ -n "$open_demo" ]; then
say "Done. Opened \"$DEMO_NAME\": the shortcuts, the view modes, and what the preview renders."
say " $demo_path"
elif [ "$relaunch_after" = "1" ]; then
say "Done. MarkEdit is back up, with the extensions loaded."
elif pgrep -x MarkEdit >/dev/null 2>&1; then
say "Done. Restart MarkEdit to load the extensions:"
say " osascript -e 'quit app \"MarkEdit\"' -e 'delay 1' -e 'launch app \"MarkEdit\"'"
else
say "Done. The extensions load the next time MarkEdit starts."
fi
say " Extensions: $SCRIPTS_DIR"
say ""
say "Notes:"
say " - markedit-ai-writer needs Apple Intelligence enabled."
say " - More extensions and themes: https://markedit-app.github.io/extensions"

You just installed MarkEdit

This is an example of a markdown doc

View modes

⇧⌘V cycles them. Or pick one from Extensions > View Mode:

  • Markdown Source, the plain editor
  • Preview (Side-by-Side), rendered next to the source
  • Preview (Overlay), rendered instead of the source
  • Mixed (Syntax Hidden), ** and # marks hidden while you type

Shortcuts

  • Format: ⌘B bold, ⌘I italic, ⌃⌘S strike, ⌘K link, ⌃⌘K image
  • Blocks: ⌃⌘C code, ⇧⌘C code block, ⌃⌘M math, ⇧⌘M math block
  • Lists: ⌃⌘L bullet, ⌃⌘O numbered, ⌃⌘T todo
  • Find: ⌘F, ⌥⌘F replace, ⌘G next, ⇧⌘G previous, ⌘D next occurrence, ⌥⌘E all
  • Files: ⌘N new, ⌃⌘N new from clipboard, ⌘T tab, ⇧⌘T reopen tab, ⌥⌘R reveal in Finder
  • Rest: ⌘L go to line, ⇧⌘I statistics, ⇧⌘D typewriter, ⇧⌘R read only

The manual adds ⇧⌘O for the table of contents and ⌥⌘↑ / ⌥⌘↓ between sections. Roll your own in System Settings > Keyboard > Keyboard Shortcuts > App Shortcuts.

Text

Italic, bold, both, struck, code, and a link. Bare URLs autolink: https://markedit-app.github.io/extensions

Lists

  1. Ordered items keep their numbers
  2. Nesting works
    • As deep as you like
  • Task lists are real checkboxes
  • Tick them in the preview
  • Just - [ ] in the source

Quotes and alerts

A plain blockquote.

Note

Worth noticing.

Tip

Makes life easier.

Warning

Will bite.

Table

Mode Shows Good for
Markdown Source Raw text Editing
Side-by-Side Both Checking as you go
Overlay Rendered only Reading
Mixed Text without the marks Drafting

Code

Code coloring comes from the Language Data extension. 1.34 moved it out of the app, so if it's missing, these blocks render flat.

def word_count(path):
    """Words in a file, split on whitespace."""
    with open(path) as f:
        return len(f.read().split())
for f in *.md
    echo "$f: "(wc -w < $f)" words"
end

Math

Inline $E = mc^2$ reads as $E = mc^2$. Blocks get their own line:

$$ \int_{0}^{\infty} e^{-x^2},dx = \frac{\sqrt{\pi}}{2} $$

Diagram

graph LR
    A[Write .md] --> B{Preview mode}
    B -->|Side-by-Side| C[See both]
    B -->|Overlay| D[Read it]
    C --> E[Ship it]
    D --> E
Loading

Image

Grapefruit slice

Local files work the same, ![alt](picture.png) next to the document.

Video

Raw HTML passes through, so <video> plays inline like it does on GitHub.

Any http or https URL works. For a file next to this document, use MarkEdit's own scheme:

<video src="image-loader://video.mp4" controls preload="metadata" width="320"></video>

Footnotes

Footnotes1 collect at the bottom.

Extensions

  • Preview, the view modes plus Save and Copy HTML
  • Language Data, colors for code blocks
  • AI Writer, on-device help through Apple Intelligence

More under Extensions > Manage Extensions…, or https://markedit-app.github.io/extensions

Files

Extensions > Open Documents Folder:

  • scripts/ installed extensions
  • extensions.json versions and enabled state
  • settings.json advanced settings
  • editor.css and editor.js your own styling and scripting

Footnotes

  1. Written [^1] up there, [^1]: down here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment