commit 2849854794d343554da36b3665a0452be1502542
parent 988a5c21767c58880e6cbdb7d5b6e1e3c657e6a6
Author: David Eisinger <[email protected]>
Date: Sat, 10 Oct 2026 15:48:39 -0400
Cleanup and reorg
Diffstat:
26 files changed, 430 insertions(+), 547 deletions(-)
diff --git a/.build.yml b/.build.yml
@@ -30,10 +30,7 @@ tasks:
# Diff RSS template against Hugo default, make sure only differences are intentional
- rss: |
- curl -s https://raw.githubusercontent.com/gohugoio/hugo/refs/tags/v$HUGO_VERSION/tpl/tplimpl/embedded/templates/rss.xml \
- | diff - ./themes/v2/layouts/_default/rss.xml \
- | wc -l \
- | grep 7
+ bin/check-rss-template
# Start Ruby server for dithering images
- dither: |
diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml
@@ -1,51 +0,0 @@
-name: Deploy
-
-on:
- push:
- branches:
- - main
- workflow_dispatch:
-
-env:
- HUGO_VERSION: 0.160.1
- DEPLOY_DIR: /var/www/davideisinger.com
- DITHER_CACHE_DIR: /workspace-cache/dither
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - name: Install packages
- run: |
- apt-get update
- apt-get install -y curl imagemagick rsync ruby-full wget
- wget --quiet https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
- apt-get install -y ./hugo_extended_${HUGO_VERSION}_linux-amd64.deb
- gem install bundler
-
- - name: Restore secret key
- env:
- SECRET_KEY: ${{ secrets.SECRET_KEY }}
- run: |
- printf '%s' "$SECRET_KEY" > secret.key
-
- - name: Verify RSS template diff
- run: ./bin/check-rss-template
-
- - name: Spellcheck Markdown
- run: npx -y cspell "content/**/*.md"
-
- - name: Build site
- run: ./bin/build
-
- - name: Deploy site
- run: ./bin/deploy-local "${DEPLOY_DIR}"
diff --git a/bin/check-dispatch-dates b/.githooks/lib/check-dispatch-dates
diff --git a/bin/check-staged-spelling b/.githooks/lib/check-staged-spelling
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
@@ -2,4 +2,4 @@
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
-exec bin/check-staged-spelling
+exec .githooks/lib/check-staged-spelling
diff --git a/.githooks/pre-push b/.githooks/pre-push
@@ -2,4 +2,4 @@
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
-exec bin/check-dispatch-dates "$@"
+exec .githooks/lib/check-dispatch-dates "$@"
diff --git a/Gemfile b/Gemfile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gem "ferrum"
+gem "httparty"
+gem "nokogiri"
+gem "pandoc-ruby"
diff --git a/Gemfile.lock b/Gemfile.lock
@@ -0,0 +1,42 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ base64 (0.3.0)
+ bigdecimal (4.1.3)
+ concurrent-ruby (1.3.8)
+ csv (3.3.6)
+ ferrum (0.18.0)
+ addressable (~> 2.5)
+ base64 (~> 0.2)
+ concurrent-ruby (~> 1.1)
+ websocket-driver (~> 0.7)
+ httparty (0.24.3)
+ csv
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
+ mini_mime (1.1.5)
+ multi_xml (0.9.1)
+ bigdecimal (>= 3.1, < 5)
+ nokogiri (1.19.4-arm64-darwin)
+ racc (~> 1.4)
+ pandoc-ruby (2.1.10)
+ public_suffix (7.0.5)
+ racc (1.8.1)
+ websocket-driver (0.8.2)
+ base64
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+
+PLATFORMS
+ arm64-darwin-22
+
+DEPENDENCIES
+ ferrum
+ httparty
+ nokogiri
+ pandoc-ruby
+
+BUNDLED WITH
+ 2.4.8
diff --git a/README.md b/README.md
@@ -2,6 +2,25 @@
[1]: https://davideisinger.com
+## Local tools
+
+Run `bundle install` at the repository root to install the Ruby dependencies for
+`bin/archive`, `bin/elsewhere`, and `bin/newsletter`. Run these commands from the
+repository root. `bin/elsewhere URL ...` imports third-party articles;
+`bin/newsletter` creates a newsletter campaign from the latest published post.
+The image service in `bin/dither` retains its own Gemfile and bundle.
+
+Run `bin/test` for the local test suite (requires Python 3, Ruby, Git, and Hugo).
+Individual tests live in `test/`; Git hook helpers live in `.githooks/lib/`.
+`bin/refresh-dither-alt` remains available for image maintenance.
+
+SourceHut runs `bin/check-rss-template` during deployment. To run it locally, set
+`HUGO_VERSION` to the version in `.build.yml`:
+
+```sh
+HUGO_VERSION=0.167.0 bin/check-rss-template
+```
+
## Image alt text
`bin/encrypt IMAGE` encrypts an image and uses Codex to write plain, descriptive
@@ -33,7 +52,7 @@ Enable the repository's Git hooks once per clone:
git config core.hooksPath .githooks
```
-Before each commit, `bin/check-staged-spelling` runs `npx cspell` on the staged
+Before each commit, `.githooks/lib/check-staged-spelling` runs `npx cspell` on the staged
contents of added, changed, or renamed `.md` and `.markdown` files throughout the
repository. Unstaged edits and deleted files are skipped. This requires Python 3,
Node.js, and npm (`npx`).
@@ -66,9 +85,9 @@ the clipboard. For intentional backdating, `git push --no-verify origin main`
bypasses local push hooks for that push. The hook must be installed in each clone;
it does not enforce dates on server-side merges or pushes from other clones.
-Run `bin/check-dispatch-hook` to exercise the workflow with temporary local Git
+Run `test/dispatch-hook` to exercise the workflow with temporary local Git
repositories, without contacting the publishing remote.
-Run `bin/check-spelling-hook` to test the commit hook offline in a disposable
+Run `test/spelling-hook` to test the commit hook offline in a disposable
repository with a simulated spellchecker.
---
diff --git a/bin/archive b/bin/archive
@@ -1,5 +1,6 @@
#!/usr/bin/env ruby
+require "bundler/setup"
require "uri"
require "digest"
require "time"
diff --git a/bin/audio b/bin/audio
@@ -1,47 +0,0 @@
-#!/usr/bin/env ruby
-
-require "optparse"
-
-ARTIST = "David Eisinger"
-
-Config = Struct.new(:file, :title, :directory, :artist)
-
-config = Config.new(artist: ARTIST)
-
-parser = OptionParser.new do |opts|
- opts.banner = "Usage: bin/audio [options]"
-
- opts.on("-f", "--file FILE", "input file") do |file|
- config.file = file
- end
-
- opts.on("-t", "--title TITLE", "title of the track") do |title|
- config.title = title
- end
-
- opts.on("-d", "--directory DIRECTORY", "directory to put the output file") do |directory|
- config.directory = directory
- end
-
- opts.on("-a", "--artist ARTIST", "artist (default: #{ARTIST})") do |artist|
- config.artist = artist
- end
-end
-
-begin
- parser.parse!(ARGV)
- missing = config.to_h.filter_map { |k, v| k if v.nil? }
- raise OptionParser::MissingArgument.new(missing * ", ") unless missing.empty?
-rescue
- puts $!.to_s
- puts parser
- exit
-end
-
-dest = [config.directory, "#{config.title}.mp3"].join("/").squeeze("/")
-
-# `ffmpeg -i "#{config.file}" -af "pan=mono|FC=FL" -y "#{dest}"`
-`ffmpeg -i "#{config.file}" -y "#{dest}"`
-`id3tag --artist="#{config.artist}" --song="#{config.title}" "#{dest}"`
-
-puts `id3info "#{dest}"`
diff --git a/bin/build b/bin/build
@@ -1,44 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
-dither_dir="${repo_root}/bin/dither"
-cache_dir="${DITHER_CACHE_DIR:-${repo_root}/.cache/dither}"
-
-mkdir -p "${cache_dir}"
-
-pushd "${dither_dir}" >/dev/null
-bundle install
-ROOT="${repo_root}/content" KEY="${repo_root}/secret.key" DITHER_CACHE_DIR="${cache_dir}" bundle exec rackup -p 4567 &
-dither_pid=$!
-popd >/dev/null
-
-cleanup() {
- if kill -0 "${dither_pid}" 2>/dev/null; then
- kill "${dither_pid}" 2>/dev/null || true
- wait "${dither_pid}" 2>/dev/null || true
- fi
-}
-trap cleanup EXIT INT TERM
-
-for _ in {1..50}; do
- if (echo >/dev/tcp/localhost/4567) >/dev/null 2>&1; then
- ready=1
- break
- fi
- sleep 0.1
-done
-
-if [[ "${ready:-0}" -ne 1 ]]; then
- echo "dither server did not start on port 4567" >&2
- exit 1
-fi
-
-pushd "${repo_root}" >/dev/null
-rm -rf public
-HUGO_PARAMS_GIT_COMMIT="$(git rev-parse HEAD)" \
- DITHER_SERVER=http://localhost:4567 \
- hugo --minify
-find public -name "*.enc" -type f -delete
-find public/elsewhere -name "*.gmi" -type f -delete
-popd >/dev/null
diff --git a/bin/check-dispatch-hook b/bin/check-dispatch-hook
@@ -1,130 +0,0 @@
-#!/usr/bin/env python3
-"""Test the publication guard using disposable repositories and local pushes."""
-
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-import os
-import shutil
-import subprocess
-import tempfile
-
-
-repo = Path(__file__).resolve().parent.parent
-env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
-
-with tempfile.TemporaryDirectory(prefix="dispatch-hook-") as directory:
- root = Path(directory)
- work = root / "work"
- work.mkdir()
- # Pushing must no longer invoke spellcheck.
- commands = root / "commands"
- commands.mkdir()
- npx = commands / "npx"
- npx.write_text('#!/usr/bin/env python3\n'
- 'import os, sys\n'
- 'sys.stderr.write("Unexpected npx invocation during push\\n")\n'
- 'sys.exit(1)\n')
- npx.chmod(0o755)
- env.update(PATH=f"{commands}{os.pathsep}{env['PATH']}", HOOK_TEST_WORK=str(work))
-
- def git(*args):
- result = subprocess.run(["git", "-C", str(work), *args], env=env,
- text=True, capture_output=True)
- assert result.returncode == 0, result.stderr
- return result.stdout.strip()
-
- def post(name, hours=192, draft=False, date=True):
- path = work / f"content/journal/dispatch-{name}/index.md"
- path.parent.mkdir(parents=True, exist_ok=True)
- timestamp = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
- field = f"date: {timestamp}\n" if date else ""
- path.write_text(f"---\ntitle: Test\n{field}draft: {str(draft).lower()}\n"
- "references:\n- date: 2020-01-01\n---\n\nBody.\ndate: Leave this alone.\n")
- return path
-
- def commit():
- git("add", ".")
- git("commit", "-m", "Test change")
-
- def push(allowed, remote="origin", ref="main", message=None):
- before = git("--git-dir", str(root / "origin.git"), "rev-parse", "main")
- result = subprocess.run(["git", "-C", str(work), "push", remote, ref],
- env=env, text=True, capture_output=True)
- assert (result.returncode == 0) == allowed, result.stderr
- if message:
- assert message in result.stderr, result.stderr
- if not allowed:
- assert git("--git-dir", str(root / "origin.git"), "rev-parse", "main") == before
-
- git("init", "-b", "main")
- git("config", "user.name", "Hook Test")
- git("config", "user.email", "[email protected]")
- git("config", "commit.gpgsign", "false")
- for remote in ("origin", "mirror"):
- git("init", "--bare", str(root / f"{remote}.git"))
- git("remote", "add", remote, str(root / f"{remote}.git"))
- for file in (".githooks/pre-push", "bin/check-dispatch-dates", "bin/timestamp"):
- target = work / file
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(repo / file, target)
- published = post("published")
- post("draft", draft=True)
- commit()
- git("push", "origin", "main")
- git("config", "core.hooksPath", ".githooks")
-
- # Old posts remain editable and renameable without changing their dates.
- published.write_text(published.read_text() + "Correction.\n")
- commit()
- push(True)
- git("mv", "content/journal/dispatch-published", "content/journal/dispatch-renamed")
- commit()
- push(True)
-
- # Drafts may be old; publishing them must use a recent committed timestamp.
- post("new-draft", draft=True)
- commit()
- push(True)
- draft = post("draft", draft=False)
- commit()
- push(False, message="publication date is")
- original = draft.read_text()
- result = subprocess.run([str(work / "bin/timestamp"), str(draft)],
- env=env, text=True, capture_output=True)
- assert result.returncode == 0, result.stderr
- assert draft.read_text() != original
- assert "- date: 2020-01-01" in draft.read_text()
- assert "date: Leave this alone." in draft.read_text()
- push(False, message="publication date is") # Working-tree changes don't count.
- commit()
- push(True)
-
- # Accept under 24 hours; block over 24 hours and missing dates.
- post("recent", hours=23)
- commit()
- push(True)
- post("stale", hours=25)
- commit()
- push(False, message="maximum: 24 hours")
- push(True, ref="main:refs/heads/writing")
- push(True, remote="mirror")
- git("reset", "--hard", "origin/main")
- post("missing-date", date=False)
- commit()
- push(False, message="publication date is missing or invalid")
- git("reset", "--hard", "origin/main")
-
- # A differently named local branch still gets checked when publishing main.
- git("checkout", "-b", "publishing")
- post("from-branch")
- commit()
- push(False, ref="publishing:main", message="publication date is")
-
- # No known remote baseline must fail clearly, rather than checking the archive.
- sha = git("rev-parse", "HEAD")
- result = subprocess.run([str(work / "bin/check-dispatch-dates"), "origin"],
- cwd=work, env=env, text=True, capture_output=True,
- input=f"refs/heads/main {sha} refs/heads/main {'0' * 40}\n")
- assert result.returncode != 0 and "does not exist yet" in result.stderr
-
-print("Dispatch hook checks passed: real local pushes, draft publication, timestamps, renames, and remote/branch scope.")
diff --git a/bin/check-spelling-hook b/bin/check-spelling-hook
@@ -1,138 +0,0 @@
-#!/usr/bin/env python3
-"""Test real commits offline, substituting a predictable spellchecker."""
-
-from pathlib import Path
-import os
-import shutil
-import subprocess
-import tempfile
-
-repo = Path(__file__).resolve().parent.parent
-env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
-
-with tempfile.TemporaryDirectory(prefix="spelling-hook-") as directory:
- root = Path(directory)
- work = root / "work"
- work.mkdir()
- commands = root / "commands"
- commands.mkdir()
- log = root / "calls"
- npx = commands / "npx"
- npx.write_text('''#!/usr/bin/env python3
-import os, sys
-from pathlib import Path
-assert sys.argv[1:-1] == ['--yes', 'cspell', '--words-only', '--unique', '--no-progress',
- '--no-summary', '--config', '.cspell.json']
-assert sys.argv[-1].startswith('stdin://')
-with open(os.environ['SPELL_LOG'], 'a') as log:
- log.write(sys.argv[-1] + '\\n')
-if os.environ.get('SPELL_FAIL'):
- print('simulated tool failure', file=sys.stderr)
- sys.exit(1)
-if os.environ.get('SPELL_NOISE'):
- print('Need to install the following packages:')
- print('Ok to proceed? (y) jammies')
- sys.exit(int(os.environ['SPELL_NOISE']))
-known = set(Path('.dictionary').read_text().splitlines())
-issues = False
-for word in sys.stdin.read().split():
- if word.startswith('unknown') and word not in known:
- print(word)
- issues = True
-sys.exit(1 if issues else 0)
-''')
- npx.chmod(0o755)
- env.update(PATH=f"{commands}{os.pathsep}{env['PATH']}", SPELL_LOG=str(log))
-
- def git(*args, ok=True):
- result = subprocess.run(["git", "-C", str(work), *args], env=env,
- capture_output=True, text=True)
- assert (result.returncode == 0) == ok, result.stderr
- return result
-
- def commit(ok=True):
- return git("commit", "-m", "Test change", ok=ok)
-
- git("init", "-b", "main")
- git("config", "user.name", "Hook Test")
- git("config", "user.email", "[email protected]")
- git("config", "commit.gpgsign", "false")
- for name in (".githooks/pre-commit", "bin/check-staged-spelling"):
- target = work / name
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(repo / name, target)
- dictionary = work / ".dictionary"
- dictionary.write_text("existing\n")
- (work / ".cspell.json").write_text("{}")
- git("add", ".")
- commit()
- git("config", "core.hooksPath", ".githooks")
-
- # Paths with spaces, duplicate words, and a partially staged file.
- post = work / "post with spaces.md"
- post.write_text("unknownz unknowna unknownz\n")
- git("add", post.name)
- post.write_text("unknownunstaged\n")
- before = git("rev-parse", "HEAD").stdout
- commit(ok=False)
- assert git("rev-parse", "HEAD").stdout == before
- assert dictionary.read_text() == "existing\nunknowna\nunknownz\n"
- assert git("show", ":.dictionary").stdout == "existing\n"
- commit(ok=False) # An immediate retry must not accept unstaged words.
- git("add", ".dictionary")
- commit()
- assert post.read_text() == "unknownunstaged\n"
- assert git("show", "HEAD:post with spaces.md").stdout == "unknownz unknowna unknownz\n"
-
- # Fix a typo instead of accepting it into the dictionary.
- post.write_text("unknowntypo\n")
- git("add", post.name)
- commit(ok=False)
- dictionary.write_text(dictionary.read_text().replace("unknowntypo\n", ""))
- post.write_text("Correct spelling.\n")
- git("add", post.name)
- commit()
-
- # Operational failure must block without modifying the dictionary.
- post.write_text("unknownfailure\n")
- git("add", post.name)
- env['SPELL_FAIL'] = '1'
- saved = dictionary.read_bytes()
- assert 'Spellcheck failed' in commit(ok=False).stderr
- assert dictionary.read_bytes() == saved
- del env['SPELL_FAIL']
- # Installation prompts or other stdout diagnostics must never become words.
- for status in ('0', '1'):
- env['SPELL_NOISE'] = status
- assert 'unexpected output' in commit(ok=False).stderr
- assert dictionary.read_bytes() == saved
- del env['SPELL_NOISE']
- post.write_text("Correct revised spelling.\n")
- git("add", post.name)
- commit()
-
- # Accented words follow system sort under the invoking shell's locale.
- env['LC_ALL'] = 'en_US.UTF-8'
- dictionary.write_text(dictionary.read_text() + "Ørsted\nrésumé\nresume\nzebra\n")
- expected = subprocess.check_output(['sort', '-u', str(dictionary)], env=env)
- git('add', '.dictionary')
- post.write_text('Another spelling check.\n')
- git('add', post.name)
- commit(ok=False)
- assert dictionary.read_bytes() == expected
- git('add', '.dictionary')
- commit()
-
- # Renames to .markdown are checked, deletions and non-Markdown are skipped.
- git("mv", post.name, "renamed.markdown")
- log.write_text("")
- commit()
- assert log.read_text() == "stdin://renamed.markdown\n"
- git("rm", "renamed.markdown")
- log.write_text("")
- (work / "script.txt").write_text("unknownignored\n")
- git("add", "script.txt")
- commit()
- assert log.read_text() == ""
-
-print("Spelling hook checks passed: staged content, dictionary review, retries, failures, renames, and deletions.")
diff --git a/bin/deploy b/bin/deploy
@@ -1,15 +0,0 @@
-set -e
-
-rm -rf public
-
-source bin/get-last-commit.sh
-
-hugo --ignoreCache
-
-rsync \
- -v \
- -r \
- --delete \
- -e "ssh -o PubkeyAcceptedKeyTypes=+ssh-rsa" \
- public/* \
- www-data@$SERVER_IP:/var/www/davideisinger.com
diff --git a/bin/deploy-local b/bin/deploy-local
@@ -1,15 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
-target_dir="${1:-/var/www/davideisinger.com}"
-
-pushd "${repo_root}" >/dev/null
-mkdir -p "${target_dir}"
-rsync \
- -v \
- -r \
- --delete \
- public/ \
- "${target_dir}/"
-popd >/dev/null
diff --git a/bin/elsewhere b/bin/elsewhere
@@ -0,0 +1,74 @@
+#!/usr/bin/env ruby
+
+require "bundler/setup"
+require "date"
+require "net/http"
+require "nokogiri"
+require "pandoc-ruby"
+require "fileutils"
+require "uri"
+
+BASEDIR = "content/elsewhere"
+
+def download(uri, filename)
+ if File.exist?(filename)
+ puts "#{filename} already exists"
+ else
+ puts "Downloading #{uri}..."
+ `curl -s "#{uri}" > #{filename}`
+ end
+end
+
+*urls = ARGV
+
+unless urls.any?
+ warn "Please supply one or more URLs"
+ exit 1
+end
+
+FileUtils.mkdir_p "tmp"
+
+urls.each do |url|
+ uri = URI.parse(url)
+ name = File.basename(uri.path)
+ filename = "tmp/#{name}.html"
+
+ download(uri, filename)
+
+ article = Nokogiri::HTML(File.open(filename))
+ content = article.css("div.page-blocks").first
+
+ FileUtils.mkdir_p "#{BASEDIR}/#{name}"
+
+ md = PandocRuby.convert(content.to_html, from: :html, to: :markdown)
+
+ # strip weird ::: directives
+ md.gsub!(/^:{3}.*\n/, "")
+
+ # strip trailing "\"
+ md.gsub!(/\\$/, "")
+
+ md.gsub!(/^!\[image\]\((.*?)\)\{.*?\}/m) do
+ img_uri = URI.parse($1)
+ img_filename = File.basename(img_uri.path)
+
+ download(img_uri, "#{BASEDIR}/#{name}/#{img_filename}")
+
+ ""
+ end
+
+ title = article.css("title").text.gsub(" | Viget", "").gsub('"', '\"')
+ date = Date.parse(article.css("time").attr("datetime").value).to_datetime.to_s
+
+ File.open("#{BASEDIR}/#{name}/index.md", "w") do |f|
+ f.write "---\n"
+ f.write "title: \"#{title}\"\n"
+ f.write "date: #{date}\n"
+ f.write "draft: false\n"
+ f.write "needs_review: true\n"
+ f.write "canonical_url: #{uri}\n"
+ f.write "---\n"
+ f.write "\n"
+ f.write md
+ end
+end
diff --git a/bin/elsewhere/Gemfile b/bin/elsewhere/Gemfile
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-source "https://rubygems.org"
-
-# gem "rails"
-gem "nokogiri"
-gem "pandoc-ruby"
-\ No newline at end of file
diff --git a/bin/elsewhere/Gemfile.lock b/bin/elsewhere/Gemfile.lock
@@ -1,17 +0,0 @@
-GEM
- remote: https://rubygems.org/
- specs:
- nokogiri (1.16.5-arm64-darwin)
- racc (~> 1.4)
- pandoc-ruby (2.1.10)
- racc (1.8.0)
-
-PLATFORMS
- arm64-darwin-22
-
-DEPENDENCIES
- nokogiri
- pandoc-ruby
-
-BUNDLED WITH
- 2.4.8
diff --git a/bin/elsewhere/elsewhere b/bin/elsewhere/elsewhere
@@ -1,73 +0,0 @@
-#!/usr/bin/env ruby
-
-require "date"
-require "net/http"
-require "nokogiri"
-require "pandoc-ruby"
-require "pry"
-require "uri"
-
-BASEDIR = "content/elsewhere"
-
-def download(uri, filename)
- if File.exist?(filename)
- puts "#{filename} already exists"
- else
- puts "Downloading #{uri}..."
- `curl -s "#{uri}" > #{filename}`
- end
-end
-
-*urls = ARGV
-
-unless urls.any?
- warn "Please supply one or more URLs"
- exit 1
-end
-
-FileUtils.mkdir_p "tmp"
-
-urls.each do |url|
- uri = URI.parse(url)
- name = File.basename(uri.path)
- filename = "tmp/#{name}.html"
-
- download(uri, filename)
-
- article = Nokogiri::HTML(File.open(filename))
- content = article.css("div.page-blocks").first
-
- FileUtils.mkdir_p "#{BASEDIR}/#{name}"
-
- md = PandocRuby.convert(content.to_html, from: :html, to: :markdown)
-
- # strip weird ::: directives
- md.gsub!(/^:{3}.*\n/, "")
-
- # strip trailing "\"
- md.gsub!(/\\$/, "")
-
- md.gsub!(/^!\[image\]\((.*?)\)\{.*?\}/m) do
- img_uri = URI.parse($1)
- img_filename = File.basename(img_uri.path)
-
- download(img_uri, "#{BASEDIR}/#{name}/#{img_filename}")
-
- ""
- end
-
- title = article.css("title").text.gsub(" | Viget", "").gsub('"', '\"')
- date = Date.parse(article.css("time").attr("datetime").value).to_datetime.to_s
-
- File.open("#{BASEDIR}/#{name}/index.md", "w") do |f|
- f.write "---\n"
- f.write "title: \"#{title}\"\n"
- f.write "date: #{date}\n"
- f.write "draft: false\n"
- f.write "needs_review: true\n"
- f.write "canonical_url: #{uri}\n"
- f.write "---\n"
- f.write "\n"
- f.write md
- end
-end
diff --git a/bin/newsletter b/bin/newsletter
@@ -1,5 +1,6 @@
#!/usr/bin/env ruby
+require "bundler/setup"
require "httparty"
require "nokogiri"
require "open-uri"
diff --git a/bin/test b/bin/test
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+
+for test in test/*; do
+ if [[ -f "$test" && -x "$test" ]]; then
+ echo "Running $test"
+ "$test"
+ fi
+done
diff --git a/test/dispatch-hook b/test/dispatch-hook
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Test the publication guard using disposable repositories and local pushes."""
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import tempfile
+
+
+repo = Path(__file__).resolve().parent.parent
+env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
+
+with tempfile.TemporaryDirectory(prefix="dispatch-hook-") as directory:
+ root = Path(directory)
+ work = root / "work"
+ work.mkdir()
+ # Pushing must no longer invoke spellcheck.
+ commands = root / "commands"
+ commands.mkdir()
+ npx = commands / "npx"
+ npx.write_text('#!/usr/bin/env python3\n'
+ 'import os, sys\n'
+ 'sys.stderr.write("Unexpected npx invocation during push\\n")\n'
+ 'sys.exit(1)\n')
+ npx.chmod(0o755)
+ env.update(PATH=f"{commands}{os.pathsep}{env['PATH']}", HOOK_TEST_WORK=str(work))
+
+ def git(*args):
+ result = subprocess.run(["git", "-C", str(work), *args], env=env,
+ text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+ return result.stdout.strip()
+
+ def post(name, hours=192, draft=False, date=True):
+ path = work / f"content/journal/dispatch-{name}/index.md"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ timestamp = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
+ field = f"date: {timestamp}\n" if date else ""
+ path.write_text(f"---\ntitle: Test\n{field}draft: {str(draft).lower()}\n"
+ "references:\n- date: 2020-01-01\n---\n\nBody.\ndate: Leave this alone.\n")
+ return path
+
+ def commit():
+ git("add", ".")
+ git("commit", "-m", "Test change")
+
+ def push(allowed, remote="origin", ref="main", message=None):
+ before = git("--git-dir", str(root / "origin.git"), "rev-parse", "main")
+ result = subprocess.run(["git", "-C", str(work), "push", remote, ref],
+ env=env, text=True, capture_output=True)
+ assert (result.returncode == 0) == allowed, result.stderr
+ if message:
+ assert message in result.stderr, result.stderr
+ if not allowed:
+ assert git("--git-dir", str(root / "origin.git"), "rev-parse", "main") == before
+
+ git("init", "-b", "main")
+ git("config", "user.name", "Hook Test")
+ git("config", "user.email", "[email protected]")
+ git("config", "commit.gpgsign", "false")
+ for remote in ("origin", "mirror"):
+ git("init", "--bare", str(root / f"{remote}.git"))
+ git("remote", "add", remote, str(root / f"{remote}.git"))
+ for file in (".githooks/pre-push", ".githooks/lib/check-dispatch-dates", "bin/timestamp"):
+ target = work / file
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(repo / file, target)
+ published = post("published")
+ post("draft", draft=True)
+ commit()
+ git("push", "origin", "main")
+ git("config", "core.hooksPath", ".githooks")
+
+ # Old posts remain editable and renameable without changing their dates.
+ published.write_text(published.read_text() + "Correction.\n")
+ commit()
+ push(True)
+ git("mv", "content/journal/dispatch-published", "content/journal/dispatch-renamed")
+ commit()
+ push(True)
+
+ # Drafts may be old; publishing them must use a recent committed timestamp.
+ post("new-draft", draft=True)
+ commit()
+ push(True)
+ draft = post("draft", draft=False)
+ commit()
+ push(False, message="publication date is")
+ original = draft.read_text()
+ result = subprocess.run([str(work / "bin/timestamp"), str(draft)],
+ env=env, text=True, capture_output=True)
+ assert result.returncode == 0, result.stderr
+ assert draft.read_text() != original
+ assert "- date: 2020-01-01" in draft.read_text()
+ assert "date: Leave this alone." in draft.read_text()
+ push(False, message="publication date is") # Working-tree changes don't count.
+ commit()
+ push(True)
+
+ # Accept under 24 hours; block over 24 hours and missing dates.
+ post("recent", hours=23)
+ commit()
+ push(True)
+ post("stale", hours=25)
+ commit()
+ push(False, message="maximum: 24 hours")
+ push(True, ref="main:refs/heads/writing")
+ push(True, remote="mirror")
+ git("reset", "--hard", "origin/main")
+ post("missing-date", date=False)
+ commit()
+ push(False, message="publication date is missing or invalid")
+ git("reset", "--hard", "origin/main")
+
+ # A differently named local branch still gets checked when publishing main.
+ git("checkout", "-b", "publishing")
+ post("from-branch")
+ commit()
+ push(False, ref="publishing:main", message="publication date is")
+
+ # No known remote baseline must fail clearly, rather than checking the archive.
+ sha = git("rev-parse", "HEAD")
+ result = subprocess.run([str(work / ".githooks/lib/check-dispatch-dates"), "origin"],
+ cwd=work, env=env, text=True, capture_output=True,
+ input=f"refs/heads/main {sha} refs/heads/main {'0' * 40}\n")
+ assert result.returncode != 0 and "does not exist yet" in result.stderr
+
+print("Dispatch hook checks passed: real local pushes, draft publication, timestamps, renames, and remote/branch scope.")
diff --git a/bin/check-dither-alt b/test/dither-alt
diff --git a/bin/check-gemtext b/test/gemtext
diff --git a/test/spelling-hook b/test/spelling-hook
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Test real commits offline, substituting a predictable spellchecker."""
+
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import tempfile
+
+repo = Path(__file__).resolve().parent.parent
+env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
+
+with tempfile.TemporaryDirectory(prefix="spelling-hook-") as directory:
+ root = Path(directory)
+ work = root / "work"
+ work.mkdir()
+ commands = root / "commands"
+ commands.mkdir()
+ log = root / "calls"
+ npx = commands / "npx"
+ npx.write_text('''#!/usr/bin/env python3
+import os, sys
+from pathlib import Path
+assert sys.argv[1:-1] == ['--yes', 'cspell', '--words-only', '--unique', '--no-progress',
+ '--no-summary', '--config', '.cspell.json']
+assert sys.argv[-1].startswith('stdin://')
+with open(os.environ['SPELL_LOG'], 'a') as log:
+ log.write(sys.argv[-1] + '\\n')
+if os.environ.get('SPELL_FAIL'):
+ print('simulated tool failure', file=sys.stderr)
+ sys.exit(1)
+if os.environ.get('SPELL_NOISE'):
+ print('Need to install the following packages:')
+ print('Ok to proceed? (y) jammies')
+ sys.exit(int(os.environ['SPELL_NOISE']))
+known = set(Path('.dictionary').read_text().splitlines())
+issues = False
+for word in sys.stdin.read().split():
+ if word.startswith('unknown') and word not in known:
+ print(word)
+ issues = True
+sys.exit(1 if issues else 0)
+''')
+ npx.chmod(0o755)
+ env.update(PATH=f"{commands}{os.pathsep}{env['PATH']}", SPELL_LOG=str(log))
+
+ def git(*args, ok=True):
+ result = subprocess.run(["git", "-C", str(work), *args], env=env,
+ capture_output=True, text=True)
+ assert (result.returncode == 0) == ok, result.stderr
+ return result
+
+ def commit(ok=True):
+ return git("commit", "-m", "Test change", ok=ok)
+
+ git("init", "-b", "main")
+ git("config", "user.name", "Hook Test")
+ git("config", "user.email", "[email protected]")
+ git("config", "commit.gpgsign", "false")
+ for name in (".githooks/pre-commit", ".githooks/lib/check-staged-spelling"):
+ target = work / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(repo / name, target)
+ dictionary = work / ".dictionary"
+ dictionary.write_text("existing\n")
+ (work / ".cspell.json").write_text("{}")
+ git("add", ".")
+ commit()
+ git("config", "core.hooksPath", ".githooks")
+
+ # Paths with spaces, duplicate words, and a partially staged file.
+ post = work / "post with spaces.md"
+ post.write_text("unknownz unknowna unknownz\n")
+ git("add", post.name)
+ post.write_text("unknownunstaged\n")
+ before = git("rev-parse", "HEAD").stdout
+ commit(ok=False)
+ assert git("rev-parse", "HEAD").stdout == before
+ assert dictionary.read_text() == "existing\nunknowna\nunknownz\n"
+ assert git("show", ":.dictionary").stdout == "existing\n"
+ commit(ok=False) # An immediate retry must not accept unstaged words.
+ git("add", ".dictionary")
+ commit()
+ assert post.read_text() == "unknownunstaged\n"
+ assert git("show", "HEAD:post with spaces.md").stdout == "unknownz unknowna unknownz\n"
+
+ # Fix a typo instead of accepting it into the dictionary.
+ post.write_text("unknowntypo\n")
+ git("add", post.name)
+ commit(ok=False)
+ dictionary.write_text(dictionary.read_text().replace("unknowntypo\n", ""))
+ post.write_text("Correct spelling.\n")
+ git("add", post.name)
+ commit()
+
+ # Operational failure must block without modifying the dictionary.
+ post.write_text("unknownfailure\n")
+ git("add", post.name)
+ env['SPELL_FAIL'] = '1'
+ saved = dictionary.read_bytes()
+ assert 'Spellcheck failed' in commit(ok=False).stderr
+ assert dictionary.read_bytes() == saved
+ del env['SPELL_FAIL']
+ # Installation prompts or other stdout diagnostics must never become words.
+ for status in ('0', '1'):
+ env['SPELL_NOISE'] = status
+ assert 'unexpected output' in commit(ok=False).stderr
+ assert dictionary.read_bytes() == saved
+ del env['SPELL_NOISE']
+ post.write_text("Correct revised spelling.\n")
+ git("add", post.name)
+ commit()
+
+ # Accented words follow system sort under the invoking shell's locale.
+ env['LC_ALL'] = 'en_US.UTF-8'
+ dictionary.write_text(dictionary.read_text() + "Ørsted\nrésumé\nresume\nzebra\n")
+ expected = subprocess.check_output(['sort', '-u', str(dictionary)], env=env)
+ git('add', '.dictionary')
+ post.write_text('Another spelling check.\n')
+ git('add', post.name)
+ commit(ok=False)
+ assert dictionary.read_bytes() == expected
+ git('add', '.dictionary')
+ commit()
+
+ # Renames to .markdown are checked, deletions and non-Markdown are skipped.
+ git("mv", post.name, "renamed.markdown")
+ log.write_text("")
+ commit()
+ assert log.read_text() == "stdin://renamed.markdown\n"
+ git("rm", "renamed.markdown")
+ log.write_text("")
+ (work / "script.txt").write_text("unknownignored\n")
+ git("add", "script.txt")
+ commit()
+ assert log.read_text() == ""
+
+print("Spelling hook checks passed: staged content, dictionary review, retries, failures, renames, and deletions.")