davideisinger.com

My personal website
Log | Files | Refs | README

commit 2849854794d343554da36b3665a0452be1502542
parent 988a5c21767c58880e6cbdb7d5b6e1e3c657e6a6
Author: David Eisinger <[email protected]>
Date:   Sat, 10 Oct 2026 15:48:39 -0400

Cleanup and reorg

Diffstat:
M.build.yml | 5+----
D.gitea/workflows/deploy.yaml | 51---------------------------------------------------
Rbin/check-dispatch-dates -> .githooks/lib/check-dispatch-dates | 0
Rbin/check-staged-spelling -> .githooks/lib/check-staged-spelling | 0
M.githooks/pre-commit | 2+-
M.githooks/pre-push | 2+-
AGemfile | 8++++++++
AGemfile.lock | 42++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 25++++++++++++++++++++++---
Mbin/archive | 1+
Dbin/audio | 47-----------------------------------------------
Dbin/build | 44--------------------------------------------
Dbin/check-dispatch-hook | 130-------------------------------------------------------------------------------
Dbin/check-spelling-hook | 138-------------------------------------------------------------------------------
Dbin/deploy | 15---------------
Dbin/deploy-local | 15---------------
Abin/elsewhere | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dbin/elsewhere/Gemfile | 8--------
Dbin/elsewhere/Gemfile.lock | 17-----------------
Dbin/elsewhere/elsewhere | 73-------------------------------------------------------------------------
Mbin/newsletter | 1+
Abin/test | 11+++++++++++
Atest/dispatch-hook | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rbin/check-dither-alt -> test/dither-alt | 0
Rbin/check-gemtext -> test/gemtext | 0
Atest/spelling-hook | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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}") + + "![image](#{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}") - - "![image](#{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.")