@commte/mdbrowse 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -59,17 +59,32 @@ mdb
59
59
 
60
60
  プレビュー用のタブが開き、小さな背景プロセスが動きだす。以後どの Markdown を保存しても、どのプロジェクトのものでも、そのタブに出る。止めるときは `mdb --stop`、再開はまた `mdb`
61
61
 
62
+ ログアウトや再起動で消える。macOS なら、勝手に戻ってくるようにもできる
63
+
64
+ ```sh
65
+ mdb --install-agent
66
+ ```
67
+
68
+ `~/Library/LaunchAgents` に LaunchAgent を1枚置くだけで、ログインした時点から追従が動いている。外すときは `mdb --uninstall-agent`。macOS のログイン項目には「開発元を識別できない項目」として出る。スクリプトに署名していないため
69
+
62
70
  ## 使い方
63
71
 
64
72
  ```sh
65
73
  mdb # タブを開いて、以後ずっと追従させる
66
74
  mdb --stop # 背景の追従を止める
67
75
  mdb --status # いま何が出ているかを表示する
76
+ mdb --install-agent # ログイン時に自動で始めるようにする(macOS)
77
+ mdb --uninstall-agent # それを外す
68
78
  mdb file.md # そのファイルを1回だけ変換する
69
79
  mdb -w file.md # 1つのファイルだけを前面で監視する(Ctrl-C で終了)
70
80
  mdb --path # 出力先の HTML のパスを表示する
71
81
  ```
72
82
 
83
+ | 環境変数 | 既定値 | |
84
+ |---|---|---|
85
+ | `MDBROWSE_OUT` | `/tmp/mdbrowse.html` | 出力先の HTML |
86
+ | `MDBROWSE_HEAD` | `~/.config/mdbrowse/head.html` | スタイルとブラウザ側のスクリプト |
87
+
73
88
  ### どうやって保存したファイルを見つけているか
74
89
 
75
90
  Spotlight(`mdfind`)に「直近に保存された Markdown」を尋ねて、それを変換している。ディスクを歩き回らないので軽い。隠しディレクトリ、`node_modules`、`~/Library` の下は無視する。Spotlight が使えない環境では、`mdb` を実行したディレクトリを見る動きに落ちる
package/README.md CHANGED
@@ -59,17 +59,32 @@ mdb
59
59
 
60
60
  That opens the preview tab and starts a small background process. Save any Markdown file from here on and it shows up in that tab, whichever project it lives in. `mdb --stop` ends it; `mdb` starts it again.
61
61
 
62
+ The process is gone after a logout or a reboot. On macOS you can have it come back on its own:
63
+
64
+ ```sh
65
+ mdb --install-agent
66
+ ```
67
+
68
+ That writes a LaunchAgent under `~/Library/LaunchAgents` and nothing else, so the sync is running from the moment you log in. `mdb --uninstall-agent` removes it. macOS lists it under Login Items as an unidentified developer — the script is not code-signed.
69
+
62
70
  ## Usage
63
71
 
64
72
  ```sh
65
73
  mdb # open the tab and keep it in sync
66
74
  mdb --stop # stop the background sync
67
75
  mdb --status # show what is being previewed
76
+ mdb --install-agent # start the sync at every login (macOS)
77
+ mdb --uninstall-agent # remove it
68
78
  mdb file.md # render one file, once
69
79
  mdb -w file.md # follow one file in the foreground (Ctrl-C to stop)
70
80
  mdb --path # print the output HTML path
71
81
  ```
72
82
 
83
+ | Variable | Default | |
84
+ |---|---|---|
85
+ | `MDBROWSE_OUT` | `/tmp/mdbrowse.html` | output HTML path |
86
+ | `MDBROWSE_HEAD` | `~/.config/mdbrowse/head.html` | stylesheet and browser script |
87
+
73
88
  ### How the sync finds your file
74
89
 
75
90
  It asks Spotlight (`mdfind`) which Markdown file was saved most recently, twice a second-and-a-bit, and renders that one. Walking your disk is never involved, so it stays cheap. Files under hidden directories, `node_modules` and `~/Library` are ignored. Without Spotlight it falls back to watching the directory you started `mdb` in.
package/assets/head.html CHANGED
@@ -184,8 +184,17 @@
184
184
  var THEMES = ['light', 'dark'];
185
185
  var WIDTHS = [800, 680, 960, 1200];
186
186
  var printing = false;
187
- addEventListener('beforeprint', function () { printing = true; });
188
- addEventListener('afterprint', function () { printing = false; });
187
+ var printTimer = null;
188
+ addEventListener('beforeprint', function () {
189
+ printing = true;
190
+ // afterprint が来ないブラウザや、途中で閉じられた場合に止まったままにならないようにする
191
+ clearTimeout(printTimer);
192
+ printTimer = setTimeout(function () { printing = false; }, 60000);
193
+ });
194
+ addEventListener('afterprint', function () {
195
+ printing = false;
196
+ clearTimeout(printTimer);
197
+ });
189
198
 
190
199
  function save() { try { localStorage.setItem('mdbrowse', JSON.stringify(s)); } catch (e) {} }
191
200
  function fontSize() { return s.fontSize || parseInt(getComputedStyle(r).getPropertyValue('--font-size')) || 16; }
@@ -255,7 +264,8 @@
255
264
  }
256
265
  heads = heads.filter(function (h) { return lv(h) <= top + 1; });
257
266
  }
258
- if (heads.length >= 3) {
267
+ try {
268
+ if (heads.length >= 3 && typeof IntersectionObserver === 'function') {
259
269
  var topLevel = Math.min.apply(null, heads.map(function (h) { return +h.tagName.slice(1); }));
260
270
  var toc = document.createElement('nav');
261
271
  toc.id = 'mdtoc';
@@ -292,6 +302,7 @@
292
302
  }, { rootMargin: '0px 0px -75% 0px' });
293
303
  heads.forEach(function (h) { io.observe(h); });
294
304
  }
305
+ } catch (e) { /* 目次が組めなくても、更新の監視は動かす */ }
295
306
 
296
307
  // スクロール位置はブラウザ標準の復元に任せる(手で保存するとカクつく)
297
308
  var lastScroll = 0;
@@ -316,6 +327,7 @@
316
327
  var stampUrl = location.pathname.replace(/\.html$/, '-stamp.js');
317
328
  var known = null;
318
329
  function checkStamp() {
330
+ if (printing) return;
319
331
  var el = document.createElement('script');
320
332
  el.src = stampUrl + '?t=' + Date.now();
321
333
  el.onload = function () {
@@ -333,8 +345,10 @@
333
345
  addEventListener('focus', function () { checkStamp(); });
334
346
  // バーを操作している間は再読み込みを止める。状態を持たずにその場で見る
335
347
  // (持つと、バーの上にポインタを置いたままウィンドウを離れたときに止まったままになる)
348
+ var hoverable = typeof window.matchMedia !== 'function'
349
+ || window.matchMedia('(hover: hover)').matches;
336
350
  setInterval(function () {
337
- if (printing || bar.matches(':hover')) return;
351
+ if (printing || (hoverable && bar.matches(':hover'))) return;
338
352
  if (Date.now() - lastScroll < 900) return;
339
353
  checkStamp();
340
354
  }, 1000);
package/bin/mdbrowse CHANGED
@@ -3,9 +3,14 @@
3
3
  # browser tab can stay open and just swap its contents.
4
4
  set -euo pipefail
5
5
 
6
- VERSION="0.2.0"
6
+ VERSION="0.3.0"
7
7
  PROG="$(basename "$0")"
8
8
  OUT="${MDBROWSE_OUT:-/tmp/mdbrowse.html}"
9
+ case "$OUT" in
10
+ *.html) ;;
11
+ *) echo "$(basename "$0"): MDBROWSE_OUT must end with .html (got: $OUT)" >&2; exit 2 ;;
12
+ esac
13
+ STAMP="${OUT%.html}-stamp.js"
9
14
  CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/mdbrowse"
10
15
 
11
16
  # スタイルシートの探索順
@@ -40,6 +45,8 @@ Usage:
40
45
  mdb <file.md> render that file once
41
46
  mdb --stop stop the background sync
42
47
  mdb --status show what is being previewed
48
+ mdb --install-agent start the sync automatically at every login (macOS)
49
+ mdb --uninstall-agent remove it
43
50
  mdb -w <file|dir> watch in the foreground instead (Ctrl-C to stop)
44
51
  mdb --eject copy the bundled stylesheet into your config directory
45
52
  mdb --path print the output HTML path
@@ -47,7 +54,12 @@ Usage:
47
54
 
48
55
  With no arguments, mdb opens the preview tab and starts a small background
49
56
  process. From then on, whatever Markdown file you save — in any project, from
50
- any editor — shows up in that tab. Nothing else to set up. `mdb --stop` ends it.
57
+ any editor — shows up in that tab. Nothing else to set up. `mdb --stop` ends it,
58
+ and it is gone after a logout or a reboot.
59
+
60
+ `mdb --install-agent` makes it start on its own at every login, so you never
61
+ have to think about starting or stopping it. It writes a LaunchAgent under
62
+ ~/Library/LaunchAgents and nothing else; `mdb --uninstall-agent` removes it.
51
63
 
52
64
  Finding the file you just saved uses Spotlight (mdfind). Files under hidden
53
65
  directories, node_modules and ~/Library are ignored. Without Spotlight, mdb
@@ -72,32 +84,61 @@ open_url() {
72
84
  fi
73
85
  }
74
86
 
87
+ # まだ何も変換していないときの仮ページ。スタイルシートごと入れるので、
88
+ # 更新の監視も目次もバーも、ふつうのページと同じように動く
89
+ placeholder() {
90
+ printf '%s\n' '<!doctype html><meta charset="utf-8"><title>mdbrowse</title>'
91
+ [ -f "$HEAD" ] && cat "$HEAD"
92
+ printf '%s\n' '<p>No preview yet. Save a Markdown file.'
93
+ }
94
+
75
95
  open_tab() {
76
- [ -f "$OUT" ] || printf '<!doctype html><meta charset="utf-8"><p>No preview yet.' > "$OUT"
77
- [ -f "${OUT%.html}-stamp.js" ] || printf 'window.__mdbrowseStamp="0";\n' > "${OUT%.html}-stamp.js"
96
+ [ -f "$OUT" ] || placeholder > "$OUT"
97
+ [ -f "$STAMP" ] || printf 'window.__mdbrowseStamp="0";\n' > "$STAMP"
78
98
  open_url
79
99
  }
80
100
 
81
- PIDFILE="${OUT%.html}-sync.pid"
101
+ # PID・ログ・ロックは自分だけが書ける場所に置く。共有の /tmp に固定名で置くと、
102
+ # 先に symlink を作られたときに書き込み先を差し替えられる
103
+ RUNTIME_DIR="${TMPDIR:-/tmp}/mdbrowse-$(id -u)"
104
+ ( umask 077; mkdir -p "$RUNTIME_DIR" ) 2>/dev/null || true
105
+ LOGFILE="$RUNTIME_DIR/sync.log"
106
+ LOCKDIR="$RUNTIME_DIR/sync.lock"
107
+ PIDFILE="$LOCKDIR/pid"
108
+ LEGACY_PIDFILE="${OUT%.html}-sync.pid"
82
109
 
83
110
  running_pid() {
84
111
  [ -f "$PIDFILE" ] || return 1
85
112
  local pid; pid="$(cat "$PIDFILE" 2>/dev/null)"
86
113
  [ -n "$pid" ] || return 1
87
114
  kill -0 "$pid" 2>/dev/null || return 1
115
+ # PID は使い回される。中身が自分のプロセスか確かめてから扱う
116
+ ps -p "$pid" -o command= 2>/dev/null | grep -q -- '--sync-daemon' || return 1
88
117
  echo "$pid"
89
118
  }
90
119
 
91
120
  stop_sync() {
92
- local pid
121
+ local pid stopped=0 i=0
93
122
  if pid="$(running_pid)"; then
94
123
  kill "$pid" 2>/dev/null || true
95
- rm -f "$PIDFILE"
96
- echo "$PROG: stopped"
97
- else
98
- rm -f "$PIDFILE"
99
- echo "$PROG: not running"
124
+ while [ $i -lt 30 ]; do
125
+ kill -0 "$pid" 2>/dev/null || break
126
+ i=$(( i + 1 ))
127
+ sleep 0.1
128
+ done
129
+ stopped=1
100
130
  fi
131
+ # 0.2.x が置いた古い場所のものも拾う
132
+ if [ -f "$LEGACY_PIDFILE" ]; then
133
+ local old; old="$(cat "$LEGACY_PIDFILE" 2>/dev/null || true)"
134
+ if [ -n "$old" ] && kill -0 "$old" 2>/dev/null; then
135
+ kill "$old" 2>/dev/null || true
136
+ stopped=1
137
+ fi
138
+ rm -f "$LEGACY_PIDFILE"
139
+ fi
140
+ rm -rf "$LOCKDIR"
141
+ if [ "$stopped" -eq 1 ]; then echo "$PROG: stopped"; else echo "$PROG: not running"; fi
101
142
  }
102
143
 
103
144
  start_sync() {
@@ -105,13 +146,90 @@ start_sync() {
105
146
  echo "$PROG: already in sync (mdb --stop to end it)"
106
147
  return 0
107
148
  fi
149
+ # PID は起動した本人に書かせる。すぐ落ちた場合に死んだ PID を残さないため
108
150
  nohup "$0" --sync-daemon "$PWD" >/dev/null 2>&1 &
109
- echo $! > "$PIDFILE"
110
- echo "$PROG: in sync save any .md and it shows up here (mdb --stop to end it)"
151
+ local i=0
152
+ while [ $i -lt 20 ]; do
153
+ running_pid >/dev/null && break
154
+ i=$(( i + 1 ))
155
+ sleep 0.1
156
+ done
157
+ if running_pid >/dev/null; then
158
+ echo "$PROG: in sync — save any .md and it shows up here (mdb --stop to end it)"
159
+ else
160
+ echo "$PROG: could not start the background sync (see $LOGFILE)" >&2
161
+ return 1
162
+ fi
163
+ }
164
+
165
+ AGENT_LABEL="com.commte.mdbrowse"
166
+ AGENT_PLIST="$HOME/Library/LaunchAgents/$AGENT_LABEL.plist"
167
+
168
+ agent_installed() { [ -f "$AGENT_PLIST" ]; }
169
+
170
+ install_agent() {
171
+ if [ "$(uname -s)" != "Darwin" ]; then
172
+ echo "$PROG: --install-agent is macOS only" >&2
173
+ return 1
174
+ fi
175
+ local self; self="$(resolve_dir "$0")/$(basename "$(readlink "$0" 2>/dev/null || echo "$0")")"
176
+ [ -x "$self" ] || self="$0"
177
+
178
+ # launchd の PATH は最小限なので、pandoc と node の場所を書き込んでおく
179
+ local paths="/usr/bin:/bin:/usr/sbin:/sbin"
180
+ local d
181
+ for d in "$(command -v pandoc)" "$(command -v node)" "$(command -v mdfind)"; do
182
+ [ -n "$d" ] && paths="$(dirname "$d"):$paths"
183
+ done
184
+
185
+ mkdir -p "$HOME/Library/LaunchAgents"
186
+ {
187
+ printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
188
+ printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
189
+ printf '%s\n' '<plist version="1.0">'
190
+ printf '%s\n' '<dict>'
191
+ printf ' <key>Label</key><string>%s</string>\n' "$AGENT_LABEL"
192
+ printf '%s\n' ' <key>ProgramArguments</key>'
193
+ printf '%s\n' ' <array>'
194
+ printf ' <string>%s</string>\n' "$self"
195
+ printf '%s\n' ' <string>--sync-daemon</string>'
196
+ printf ' <string>%s</string>\n' "$HOME"
197
+ printf '%s\n' ' </array>'
198
+ printf '%s\n' ' <key>RunAtLoad</key><true/>'
199
+ printf '%s\n' ' <key>KeepAlive</key><false/>'
200
+ printf ' <key>StandardErrorPath</key><string>%s</string>\n' "$LOGFILE"
201
+ printf '%s\n' ' <key>EnvironmentVariables</key>'
202
+ printf '%s\n' ' <dict>'
203
+ printf ' <key>PATH</key><string>%s</string>\n' "$paths"
204
+ [ -n "${MDBROWSE_OUT:-}" ] && printf ' <key>MDBROWSE_OUT</key><string>%s</string>\n' "$MDBROWSE_OUT"
205
+ [ -n "${MDBROWSE_HEAD:-}" ] && printf ' <key>MDBROWSE_HEAD</key><string>%s</string>\n' "$MDBROWSE_HEAD"
206
+ printf '%s\n' ' </dict>'
207
+ printf '%s\n' '</dict>'
208
+ printf '%s\n' '</plist>'
209
+ } > "$AGENT_PLIST"
210
+
211
+ launchctl bootout "gui/$(id -u)/$AGENT_LABEL" 2>/dev/null || true
212
+ if ! launchctl bootstrap "gui/$(id -u)" "$AGENT_PLIST" 2>/dev/null; then
213
+ launchctl load -w "$AGENT_PLIST" 2>/dev/null || true
214
+ fi
215
+ echo "$PROG: starts at login now ($AGENT_PLIST)"
216
+ }
217
+
218
+ uninstall_agent() {
219
+ launchctl bootout "gui/$(id -u)/$AGENT_LABEL" 2>/dev/null \
220
+ || launchctl unload -w "$AGENT_PLIST" 2>/dev/null || true
221
+ if [ -f "$AGENT_PLIST" ]; then
222
+ rm -f "$AGENT_PLIST"
223
+ echo "$PROG: will not start at login any more"
224
+ else
225
+ echo "$PROG: not installed"
226
+ fi
111
227
  }
112
228
 
113
229
  case "${1:-}" in
114
230
  -h|--help) usage; exit 0 ;;
231
+ --install-agent) install_agent; exit $? ;;
232
+ --uninstall-agent) uninstall_agent; exit 0 ;;
115
233
  -v|--version) echo "mdbrowse $VERSION"; exit 0 ;;
116
234
  --path) echo "$OUT"; exit 0 ;;
117
235
  --stop) stop_sync; exit 0 ;;
@@ -121,6 +239,11 @@ case "${1:-}" in
121
239
  else
122
240
  echo "$PROG: not running"
123
241
  fi
242
+ if agent_installed; then
243
+ echo "starts at login: yes ($AGENT_PLIST)"
244
+ else
245
+ echo "starts at login: no (mdb --install-agent to change that)"
246
+ fi
124
247
  [ -f "$OUT" ] && echo "showing: $(grep -o '<title>[^<]*' "$OUT" 2>/dev/null | sed 's/<title>//')"
125
248
  exit 0 ;;
126
249
  --eject)
@@ -147,13 +270,23 @@ while [ $# -gt 0 ]; do
147
270
  --sync-daemon) daemon=1; shift; sync_cwd="${1:-$PWD}" ;;
148
271
  --) shift; [ $# -gt 0 ] && src="$1"; break ;;
149
272
  -*) echo "$PROG: unknown option: $1" >&2; exit 2 ;;
150
- *) src="$1" ;;
273
+ *)
274
+ if [ -n "$src" ]; then
275
+ echo "$PROG: too many files: $1" >&2
276
+ exit 2
277
+ fi
278
+ src="$1" ;;
151
279
  esac
152
- shift
280
+ shift || break
153
281
  done
154
282
 
155
283
  # 引数なし(または --open だけ)が既定の使い方。タブを開いて、以後は勝手に追従する
156
284
  if [ -z "$src" ] && [ "$watch" -eq 0 ] && [ "$daemon" -eq 0 ]; then
285
+ # --open だけを渡された場合はタブを開くだけ(エディタのタスクから呼ばれる経路)
286
+ if [ "$do_open" -eq 1 ]; then
287
+ open_tab
288
+ exit 0
289
+ fi
157
290
  if ! command -v pandoc >/dev/null 2>&1; then
158
291
  echo "$PROG: pandoc not found. Install it first (macOS: brew install pandoc)" >&2
159
292
  exit 127
@@ -192,34 +325,47 @@ if command -v node >/dev/null 2>&1 && [ -f "$HIGHLIGHTER" ]; then
192
325
  use_shiki=1
193
326
  fi
194
327
 
195
- # シンタックスハイライトの指定は pandoc 3.9 で名前が変わった
328
+ # シンタックスハイライトの指定は pandoc 3.9 で名前が変わった。
329
+ # sort -V は POSIX に無いので、major と minor を数として比べる
196
330
  pv="$(pandoc --version | head -1 | awk '{print $2}')"
197
- if [ "$(printf '%s\n3.9\n' "$pv" | sort -V | head -1)" = "3.9" ]; then
331
+ pv_major="${pv%%.*}"; pv_rest="${pv#*.}"; pv_minor="${pv_rest%%.*}"
332
+ case "$pv_major$pv_minor" in *[!0-9]*|'') pv_major=0; pv_minor=0 ;; esac
333
+ if [ "$pv_major" -gt 3 ] || { [ "$pv_major" -eq 3 ] && [ "$pv_minor" -ge 9 ]; }; then
198
334
  if [ "$use_shiki" -eq 1 ]; then hl=(--syntax-highlighting none); else hl=(--syntax-highlighting breezedark); fi
199
335
  else
200
336
  if [ "$use_shiki" -eq 1 ]; then hl=(--no-highlight); else hl=(--highlight-style breezedark); fi
201
337
  fi
202
338
 
203
339
  render() {
204
- local args=(--standalone --from gfm "${hl[@]}" --variable "pagetitle=$(basename "$src")" --output "$OUT")
340
+ # 途中経過をタブに見せないため、別名で組み立ててから置き換える。
341
+ # 名前は毎回変える。固定名だと、同時に走ったときに互いの中間結果を踏む
342
+ local tmp
343
+ tmp="$(mktemp "$OUT.part.XXXXXX")" || return 1
344
+
345
+ # 出力先が .part なので、拡張子から形式を推測させず明示する
346
+ local args=(--standalone --from gfm --to html5 "${hl[@]}" --variable "pagetitle=$(basename "$src")" --output "$tmp")
205
347
  [ -f "$HEAD" ] && args+=(--include-in-header "$HEAD")
206
348
 
207
- pandoc "$src" "${args[@]}"
349
+ # 途中で失敗したら、そこで止めて古い表示を残す。
350
+ # 関数を `render || ...` の形で呼ぶと関数の中では set -e が効かないので、各段で判定する
351
+ pandoc "$src" "${args[@]}" || { rm -f "$tmp"; return 1; }
208
352
 
209
353
  # コードブロックを Shiki で色付けし直す。失敗しても変換自体は成立させる
210
354
  if [ "$use_shiki" -eq 1 ]; then
211
- node "$HIGHLIGHTER" "$OUT" || true
355
+ node "$HIGHLIGHTER" "$tmp" || true
212
356
  fi
213
357
 
214
358
  # frontmatter の title を pandoc が拾うと本文の先頭に見出しが増えるので落とす。
215
359
  # ブラウザのタブ名には pagetitle を使っている
216
- perl -0pi -e 's#<header id="title-block-header">.*?</header>\n?##s' "$OUT"
360
+ perl -0pi -e 's#<header id="title-block-header">.*?</header>\n?##s' "$tmp" \
361
+ || { rm -f "$tmp"; return 1; }
217
362
 
218
363
  # 出力先が /tmp なので、元ファイルからの相対パス(画像・ローカルリンク)が
219
364
  # そのままでは解決できない。ソースのあるディレクトリ基準の file:// に書き換える。
220
365
  # 書き換えるのはタグの属性だけ。本文中の src="..." のようなインラインコードは触らない。
221
366
  # スキーム付き(http(s) data: mailto: など)/絶対パス/ページ内アンカーはそのまま残す
222
- local src_dir="$(cd "$(dirname "$src")" && pwd)"
367
+ local src_dir
368
+ src_dir="$(cd "$(dirname "$src")" && pwd)" || { rm -f "$tmp"; return 1; }
223
369
  MDBROWSE_SRC_DIR="$src_dir" perl -0pi -e '
224
370
  BEGIN {
225
371
  $base = $ENV{MDBROWSE_SRC_DIR};
@@ -227,70 +373,121 @@ render() {
227
373
  $base =~ s/%/%25/g;
228
374
  $base =~ s/#/%23/g;
229
375
  $base =~ s/\?/%3F/g;
376
+ $base =~ s/&/%26/g;
377
+ $base =~ s/"/%22/g;
230
378
  $base =~ s/ /%20/g;
231
379
  }
232
- s{(<(?:img|a|source|video|audio|embed|object|track)\b)([^>]*?)(/?>)}{
380
+ # 属性値の中の > でタグを切らないよう、引用符の中はまとめて食う
381
+ s{(<(?:img|a|source|video|audio|embed|object|track)\b)((?:[^>"\x27]|"[^"]*"|\x27[^\x27]*\x27)*)(>)}{
233
382
  my ($tag, $attrs, $close) = ($1, $2, $3);
234
383
  $attrs =~ s{((?:src|href|poster|data)=")(?![a-zA-Z][a-zA-Z0-9+.\-]*:|//|/|\#|")([^"]*)"}
235
384
  {$1 . "file://" . $base . "/" . $2 . "\""}ge;
385
+ $attrs =~ s{((?:src|href|poster|data)=\x27)(?![a-zA-Z][a-zA-Z0-9+.\-]*:|//|/|\#|\x27)([^\x27]*)\x27}
386
+ {$1 . "file://" . $base . "/" . $2 . "\x27"}ge;
236
387
  $tag . $attrs . $close;
237
388
  }gse;
238
- ' "$OUT"
389
+ ' "$tmp" || { rm -f "$tmp"; return 1; }
239
390
 
240
- # 更新の目印。ブラウザはこれを見て、中身が変わったときだけ読み直す
241
- printf 'window.__mdbrowseStamp="%s";\n' "$(date +%s)-$RANDOM" > "${OUT%.html}-stamp.js"
391
+ mv -f "$tmp" "$OUT" || { rm -f "$tmp"; return 1; }
392
+
393
+ # 更新の目印。ブラウザはこれを見て、中身が変わったときだけ読み直す。
394
+ # 本体の差し替えが済んでから書く
395
+ local stamp_tmp
396
+ stamp_tmp="$(mktemp "$STAMP.part.XXXXXX")" || return 1
397
+ printf 'window.__mdbrowseStamp="%s";\n' "$(date +%s)-$RANDOM" > "$stamp_tmp" \
398
+ || { rm -f "$stamp_tmp"; return 1; }
399
+ mv -f "$stamp_tmp" "$STAMP" || { rm -f "$stamp_tmp"; return 1; }
242
400
  }
243
401
 
244
402
  # ファイルの更新時刻とサイズ。同じ秒内の2回目の保存も拾えるようにサイズも見る
245
403
  if stat -f %m . >/dev/null 2>&1; then
246
- fingerprint() { stat -f '%m %z' "$1" 2>/dev/null; }
247
- stat_time_name() { xargs -0 stat -f '%m %N' 2>/dev/null; }
404
+ fingerprint() { stat -f '%Fm %z %i' "$1" 2>/dev/null || true; }
405
+ stat_time_name() { xargs -0 stat -f '%m %N' 2>/dev/null || true; }
248
406
  else
249
- fingerprint() { stat -c '%Y %s' "$1" 2>/dev/null; }
250
- stat_time_name() { xargs -0 stat -c '%Y %n' 2>/dev/null; }
407
+ fingerprint() { stat -c '%.9Y %s %i' "$1" 2>/dev/null || true; }
408
+ stat_time_name() { xargs -0 stat -c '%Y %n' 2>/dev/null || true; }
251
409
  fi
252
410
 
411
+ # 「更新時刻 パス」の行から、いちばん新しい1件を返す。
412
+ # sort と head の組み合わせは、件数が多いと head の SIGPIPE で pipefail に引っかかる
413
+ pick_newest() {
414
+ awk '$1 > m { m = $1; sub(/^[0-9]+ /, ""); f = $0 } END { if (f != "") print f }'
415
+ }
416
+
253
417
  # ディレクトリの中でいちばん最近保存された Markdown
254
418
  newest_md() {
255
419
  find "$1" \( -name .git -o -name node_modules -o -name .venv \) -prune \
256
420
  -o -type f -name '*.md' -print0 2>/dev/null \
257
- | stat_time_name | sort -rn | head -1 | cut -d' ' -f2-
421
+ | stat_time_name | pick_newest || true
258
422
  }
259
423
 
260
424
  # Spotlight で「家じゅうで直近に保存された Markdown」を引く。木を歩かないので速い
425
+ # 該当が無いときは grep が 1 を返す。pipefail に拾わせると背景プロセスが即死するので握る
261
426
  newest_global() {
262
427
  mdfind 'kMDItemFSName == "*.md"c && kMDItemContentModificationDate >= $time.now(-1800)' 2>/dev/null \
263
428
  | grep -v -e '/\.' -e '/node_modules/' -e '/Library/' \
264
- | tr '\n' '\0' | stat_time_name | sort -rn | head -1 | cut -d' ' -f2-
429
+ | tr '\n' '\0' | stat_time_name | pick_newest || true
265
430
  }
266
431
 
267
432
  # 背景で動く本体。保存された Markdown を追いかけて変換し続ける
268
433
  if [ "$daemon" -eq 1 ]; then
269
- trap 'rm -f "$PIDFILE"' EXIT
434
+ # 確認と取得を1操作にする。mkdir は原子的なので、同時に叩かれても1つしか通らない
435
+ if ! mkdir "$LOCKDIR" 2>/dev/null; then
436
+ # 取れなかった側は、持ち主が名乗るのを少し待つ。
437
+ # すぐ「持ち主なし」と判断すると、書き込み前の相手からロックを奪ってしまう
438
+ i=0
439
+ while [ $i -lt 30 ]; do
440
+ running_pid >/dev/null && exit 0
441
+ i=$(( i + 1 ))
442
+ sleep 0.1
443
+ done
444
+ # 3秒待っても名乗らないなら、置き去りのロックとして回収する
445
+ rm -rf "$LOCKDIR"
446
+ mkdir "$LOCKDIR" 2>/dev/null || exit 0
447
+ fi
448
+ echo $$ > "$PIDFILE"
449
+ # 自分が持っているときだけ外す。あとから起きたものの分を消さないため
450
+ trap 'if [ "$(cat "$PIDFILE" 2>/dev/null)" = "$$" ]; then rm -rf "$LOCKDIR"; fi' EXIT
270
451
  use_spotlight=0
271
452
  command -v mdfind >/dev/null 2>&1 && use_spotlight=1
272
- [ "$use_spotlight" -eq 0 ] && root="$sync_cwd"
453
+ if [ "$use_spotlight" -eq 0 ]; then root="$sync_cwd"; fi
273
454
 
274
455
  pick() {
275
456
  if [ "$use_spotlight" -eq 1 ]; then newest_global; else newest_md "$root"; fi
276
457
  }
277
458
 
459
+ # 変換の失敗は画面に出ないので、ログに残す
460
+ log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$LOGFILE"; }
461
+
462
+ # Spotlight を使えない場合は木を歩く。大きな木では間隔を伸ばす
463
+ scan_gap=2
464
+ if [ "$use_spotlight" -eq 0 ]; then
465
+ t0="$(perl -MTime::HiRes=time -e 'printf "%d", time*1000')"
466
+ newest_md "$root" >/dev/null
467
+ t1="$(perl -MTime::HiRes=time -e 'printf "%d", time*1000')"
468
+ scan_gap=$(( ( (t1 - t0) * 5 + 999) / 1000 ))
469
+ [ "$scan_gap" -lt 2 ] && scan_gap=2
470
+ [ "$scan_gap" -gt 10 ] && scan_gap=10
471
+ log "watching $root every ${scan_gap}s (no Spotlight)"
472
+ fi
473
+
278
474
  src="$(pick)"
279
- if [ -n "$src" ] && [ -f "$src" ]; then render || true; fi
475
+ if [ -n "$src" ] && [ -f "$src" ]; then render || log "render failed: $src"; fi
280
476
  last="$src $(fingerprint "$src") $(fingerprint "$HEAD")"
281
477
  # 開いているファイルの保存は毎秒見る。別ファイルへの切り替えの検出は2秒ごと
282
478
  tick=0
283
479
  while sleep 1; do
284
480
  tick=$(( tick + 1 ))
285
- if [ $(( tick % 2 )) -eq 0 ]; then
481
+ if [ $(( tick % scan_gap )) -eq 0 ]; then
286
482
  cand="$(pick)"
287
- [ -n "$cand" ] && src="$cand"
483
+ if [ -n "$cand" ]; then src="$cand"; fi
288
484
  fi
485
+ [ -n "$src" ] || continue
289
486
  [ -f "$src" ] || continue
290
487
  now="$src $(fingerprint "$src") $(fingerprint "$HEAD")"
291
488
  [ "$now" = "$last" ] && continue
292
489
  last="$now"
293
- render || true
490
+ render || log "render failed: $src"
294
491
  done
295
492
  exit 0
296
493
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commte/mdbrowse",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Preview the Markdown file you are editing in a real browser, with your own CSS, from any editor that can run a shell command. No server, no port.",
5
5
  "bin": {
6
6
  "mdbrowse": "bin/mdbrowse",