Compare commits
11
Commits
ce9810d09e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea7652f36 | ||
|
|
2d7c724a48 | ||
|
|
8e8958274a | ||
|
|
f90e694c3e | ||
|
|
be9c92f6d2 | ||
|
|
cfaab13e45 | ||
|
|
19e6208a22 | ||
|
|
dd06c50374 | ||
|
|
0d24df31ad | ||
|
|
2d8f79b7fd | ||
|
|
99b7546fd1 |
@@ -1 +1,6 @@
|
||||
**/public/
|
||||
linkcheck/
|
||||
resources/_gen/
|
||||
__pycache__/
|
||||
.hermes/
|
||||
.tools/
|
||||
|
||||
@@ -1,3 +1,95 @@
|
||||
run:
|
||||
hugo server &
|
||||
npx -y pagefind --site public --serve
|
||||
HUGO := hugo
|
||||
PAGEFIND := npx -y pagefind
|
||||
LYCHEE := lychee
|
||||
OUT := public
|
||||
|
||||
# --- Pinned toolchain (repo-local .tools/; gitignored) ---
|
||||
# `make bootstrap` installs these exact versions into .tools/bin. The PATH
|
||||
# prepend below applies to every recipe, so make targets always use the pinned
|
||||
# tools; your system PATH is never modified. `make tool-versions` shows both.
|
||||
HUGO_VERSION := 0.167.0
|
||||
NODE_VERSION := 22.11.0
|
||||
LYCHEE_VERSION := 0.24.2
|
||||
TOOLS_BIN := $(CURDIR)/.tools/bin
|
||||
export PATH := $(TOOLS_BIN):$(PATH)
|
||||
|
||||
.PHONY: all build index serve run link-check link-fix external-check external-fix external-fix-apply serve-lan clean bootstrap tool-versions preflight
|
||||
|
||||
all: index
|
||||
|
||||
# ONE-TIME per machine: install the pinned hugo/node/lychee into .tools/bin.
|
||||
# Idempotent (present-at-pin tools are skipped); touches nothing outside .tools/.
|
||||
bootstrap:
|
||||
scripts/bootstrap.sh
|
||||
|
||||
# Diagnostic: pinned (.tools/bin) vs PATH-first (system) tool versions.
|
||||
tool-versions:
|
||||
scripts/tool_versions.sh
|
||||
|
||||
# Strict tool check; runs automatically before all/serve/serve-lan and
|
||||
# fails with a `make bootstrap` hint instead of a bare "error 127".
|
||||
preflight:
|
||||
scripts/tool_versions.sh --strict
|
||||
|
||||
# Build Hugo content -> public/ (preflight first so a missing tool fails fast
|
||||
# and in order even under `make -j`).
|
||||
build: preflight
|
||||
$(HUGO) --gc
|
||||
|
||||
# (Re)build the Pagefind search index from public/ -> public/pagefind/
|
||||
index: build
|
||||
$(PAGEFIND) --site $(OUT)
|
||||
|
||||
# Build content + index, then serve the built site WITH search on :1414 (dev preview).
|
||||
# Uses pagefind's own server. Local only (binds 127.0.0.1) -- for LAN use `make serve-lan`.
|
||||
serve: index
|
||||
$(PAGEFIND) --site $(OUT) --serve
|
||||
|
||||
run: serve
|
||||
|
||||
# DEAD LINKS -- internal (offline, fast): scan the SOURCE markdown; internal links
|
||||
# resolve to file:// under public/. External links are skipped by --scheme file.
|
||||
# Output: linkcheck/internal.json (exit != 0 when dead links exist; that is fine).
|
||||
link-check: build
|
||||
mkdir -p linkcheck
|
||||
$(LYCHEE) --root-dir $(OUT) --default-extension html --scheme file \
|
||||
--format json --timeout 10 \
|
||||
$(wildcard content/*.md) $(wildcard content/*/*.md) > linkcheck/internal.json
|
||||
|
||||
# Comment out confirmed dead INTERNAL page links, then rebuild content + index so they
|
||||
# leave the live page AND the search index. (External failures -> manual_review.txt.)
|
||||
link-fix:
|
||||
python3 scripts/fix_dead_links.py --report linkcheck/internal.json --apply
|
||||
$(HUGO) --gc
|
||||
$(PAGEFIND) --site $(OUT)
|
||||
|
||||
# DEAD LINKS -- external (network): scan all http/https links in content. Report-only.
|
||||
# Always exits 0; the report is the point. Also saves the full lychee JSON to
|
||||
# linkcheck/external.json so external-fix can act without re-pinging the web.
|
||||
external-check:
|
||||
python3 scripts/check_external_links.py
|
||||
|
||||
# Preview which EXTERNAL links are truly dead (domain/DNS no longer exists) and
|
||||
# would be commented out. Reports only; changes nothing. Run external-check first.
|
||||
external-fix:
|
||||
python3 scripts/fix_dead_external_links.py --report linkcheck/external.json
|
||||
|
||||
# Comment out the truly-dead EXTERNAL links (DNS failed / host unreachable), then
|
||||
# rebuild content + index so they leave the live page AND the search index.
|
||||
# 404s, SSL errors, 5xx, redirects, refused/reset connections are NOT auto-commented
|
||||
# (domain is still alive / ambiguous) -- they go to linkcheck/manual_review_external.txt.
|
||||
external-fix-apply:
|
||||
python3 scripts/fix_dead_external_links.py --report linkcheck/external.json --apply
|
||||
$(HUGO) --gc
|
||||
$(PAGEFIND) --site $(OUT)
|
||||
|
||||
# Serve the BUILT site on all interfaces so another LAN machine can open it:
|
||||
# http://<this-machine-LAN-IP>:8090/ (IP printed at startup; also `hostname -I | head -1`)
|
||||
serve-lan: preflight
|
||||
python3 scripts/serve_lan.py
|
||||
|
||||
# NOTE: clean does NOT remove .tools/ -- the pinned toolchain is expensive to
|
||||
# reinstall and independent of build output. Remove it manually if ever needed.
|
||||
|
||||
clean:
|
||||
rm -rf $(OUT) linkcheck
|
||||
|
||||
@@ -1,90 +1,476 @@
|
||||
# Building The Website
|
||||
## Clone Repository
|
||||
`git clone https://dev.libertytools.io/adminuser/libertytoolsio.git`
|
||||
# Liberty Tools (libertytools.io) — Hugo static site
|
||||
|
||||
A Hugo site built on the [hugo-book](https://github.com/alex-shpak/hugo-book) theme,
|
||||
with **Pagefind** client-side search and **lychee**-based dead-link checking. This
|
||||
document is the single reference for understanding the architecture, building,
|
||||
previewing, searching, and maintaining the site.
|
||||
|
||||
> If you only need to build and preview, jump to
|
||||
> [Build & preview (§4)](#4-build--preview-make-commands). Everything you normally
|
||||
> type is a `make` target.
|
||||
|
||||
---
|
||||
|
||||
## Fresh git pull? Rebuild before serving
|
||||
|
||||
`public/` (the built site) is **never in git** — it's regenerated locally and
|
||||
gitignored (§8). A `git pull` brings you source only: content, theme, Makefile,
|
||||
scripts. Until you rebuild, there is nothing to serve — and a stale `public/`
|
||||
left over from an older workflow (e.g. `hugo server`) will render pages with a
|
||||
broken search box and spurious `pagefind/*` / `livereload.js` 404s in the server
|
||||
log.
|
||||
|
||||
From the project root:
|
||||
|
||||
## Run Makefile
|
||||
The `Makefile` in the root of the directory will run the following two commands:
|
||||
``` bash
|
||||
hugo server &
|
||||
npx -y pagefind --site public --serve
|
||||
```
|
||||
The first command will run and compile the `hugo` website and any changes made. The second command will build and re-index the search bar index.
|
||||
|
||||
To build the website, simply run
|
||||
``` bash
|
||||
make run
|
||||
```
|
||||
in the project root to run those two commands found in the `Makefile`.
|
||||
|
||||
|
||||
# Viewing Changes to Website Locally
|
||||
After running the `Makefile` you should get an output like this"
|
||||
|
||||
``` shell
|
||||
hugo server &
|
||||
npx -y pagefind --site public --serve
|
||||
Watching for changes in /path/to/libertytools_sites/dev_lt/libertytoolsio/{archetypes,content,data,layouts,static,themes}
|
||||
Watching for config changes in /path/to/libertytools_sites/dev_lt/libertytoolsio/config.toml, /path/to/libertytools_sites/dev_lt/libertytoolsio/themes/hugo-book/hugo.toml
|
||||
Start building sites …
|
||||
hugo v0.151.0-c70ab27ceb841fc9404eab5d2c985ff7595034b7+extended linux/amd64 BuildDate=2025-10-02T13:30:36Z VendorInfo=gohugoio
|
||||
|
||||
|
||||
│ EN
|
||||
──────────────────┼─────
|
||||
Pages │ 27
|
||||
Paginator pages │ 0
|
||||
Non-page files │ 65
|
||||
Static files │ 106
|
||||
Processed images │ 0
|
||||
Aliases │ 0
|
||||
Cleaned │ 0
|
||||
|
||||
Built in 100 ms
|
||||
Environment: "development"
|
||||
Serving pages from disk
|
||||
Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender
|
||||
Web Server is available at http://localhost:1313/ (bind address 127.0.0.1)
|
||||
Press Ctrl+C to stop
|
||||
|
||||
Running Pagefind v1.5.2 (Extended)
|
||||
Running from: "/path/to/libertytools_sites/dev_lt/libertytoolsio"
|
||||
Source: "public"
|
||||
Output: "public/pagefind"
|
||||
|
||||
[Walking source directory]
|
||||
Found 50 files matching **/*.{html}
|
||||
|
||||
[Parsing files]
|
||||
Did not find a data-pagefind-body element on the site.
|
||||
↳ Indexing all <body> elements on the site.
|
||||
|
||||
[Reading languages]
|
||||
Discovered 2 languages: en-us, en
|
||||
|
||||
[Building search indexes]
|
||||
Total:
|
||||
Indexed 2 languages
|
||||
Indexed 48 pages
|
||||
Indexed 6934 words
|
||||
Indexed 0 filters
|
||||
Indexed 0 sorts
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Pagefind found references to the Default UI (pagefind-ui.js) │
|
||||
│ on your site. The Default UI is supported and will continue │
|
||||
│ to work. │
|
||||
│ │
|
||||
│ As of 1.5.0, if you are setting up a new integration, use the │
|
||||
│ Component UI instead. It includes a search modal, better │
|
||||
│ accessibility and customization: https://pagefind.app/docs/search-ui/ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Finished in 0.067 seconds
|
||||
|
||||
Serving the Pagefind Playground at http://localhost:1414/pagefind/playground/
|
||||
Serving "public" at http://localhost:1414
|
||||
make bootstrap # ONCE per machine: pinned Hugo/Node/lychee into .tools/ (no system changes)
|
||||
make tool-versions # diagnostic: pinned vs system tool versions
|
||||
make clean && make all # wipe stale output, then hugo --gc + pagefind index
|
||||
make serve-lan # serve on http://<LAN-IP>:8090/ (or: make serve, local :1414)
|
||||
```
|
||||
|
||||
Copy `http://localhost:1414` into your web browser and you should then see the website as it is with all the changes made, but only served from your local machine. This version of the site is not public facing.
|
||||
Success check: the `make all` log prints `Indexed N pages` (N > 0) and
|
||||
`public/pagefind/pagefind-ui.js` exists. `error 127` / "command not found"
|
||||
means a tool is missing: `make tool-versions` shows which, `make bootstrap`
|
||||
fixes it. Full details: [Prerequisites (§2)](#2-prerequisites-install-once),
|
||||
[Build & preview (§4)](#4-build--preview-make-commands).
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Fresh git pull? Rebuild before serving](#fresh-git-pull-rebuild-before-serving)
|
||||
- [Architecture](#architecture)
|
||||
- [1. Clone](#1-clone)
|
||||
- [2. Prerequisites (install once)](#2-prerequisites-install-once)
|
||||
- [3. Configuration](#3-configuration)
|
||||
- [4. Build & preview (make commands)](#4-build--preview-make-commands)
|
||||
- [5. Search (Pagefind)](#5-search-pagefind)
|
||||
- [6. Dead links](#6-dead-links)
|
||||
- [7. Troubleshooting](#7-troubleshooting)
|
||||
- [8. Do not commit](#8-do-not-commit)
|
||||
- [Repository layout](#repository-layout)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The site is a **fully static** output: no server-side rendering at request time, no
|
||||
database, no runtime. Hugo renders the markdown to plain HTML files *at build time*;
|
||||
a search index is generated from those files; and a trivial static file server
|
||||
(delivering the built directory) is all that runs in front of a browser.
|
||||
|
||||
### The three moving parts
|
||||
|
||||
| Component | Role |
|
||||
|--------------|---------------------------------------------------------------------------------------|
|
||||
| **Hugo** | The static site generator. Reads `content/*.md` + the theme, emits `public/`. |
|
||||
| **hugo-book** | The theme (vendored under `themes/hugo-book/`). Layouts, SCSS styling, menu/TOC. |
|
||||
| **Pagefind** | Client-side full-text search. Indexes the built `public/` into `public/pagefind/`. |
|
||||
| **lychee** | Dead-link checker (used only by the `link-check`/`link-fix`/`external-check` targets).|
|
||||
|
||||
Only Hugo and Node (for Pagefind's `npx` wrapper) are required to build. `lychee` is
|
||||
needed only if you run the dead-link targets.
|
||||
|
||||
### Content → build → serve, data flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ content/ │
|
||||
│ *.md pages + images (the source) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│ make build (hugo --gc)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ public/ │
|
||||
│ rendered static HTML + fingerprinted CSS/JS │
|
||||
│ (hugo-book SCSS compiled by the extended │
|
||||
│ build; each asset hash-named + SRI-checked)│
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│ make index (pagefind --site public)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ public/pagefind/ │
|
||||
│ Pagefind search index (WASM + JSON) │
|
||||
│ — ships inside public/, so any static │
|
||||
│ server can serve a working search │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│ make serve / make serve-lan
|
||||
▼
|
||||
browser (any machine on the network)
|
||||
```
|
||||
|
||||
Two consequences worth internalizing:
|
||||
|
||||
1. **`public/` is the single source of truth for what is served.** Both the search
|
||||
index and every preview server read from it. If the site "isn't showing your
|
||||
change," the build hasn't run — re-run `make index`.
|
||||
2. **The Pagefind bundle lives inside `public/`.** That's why `make serve-lan` (a plain
|
||||
Python static server) serves working search with zero extra setup — there is no
|
||||
special search server required.
|
||||
|
||||
### The theme SCSS pipeline
|
||||
|
||||
`html-head.html` compiles the theme's stylesheet on every build:
|
||||
|
||||
```
|
||||
book.scss → resources.ExecuteAsTemplate → css.Sass → resources.Minify → resources.Fingerprint
|
||||
```
|
||||
|
||||
`css.Sass` is why the **Hugo *extended*** edition is mandatory (the standard build has
|
||||
no SCSS compiler). `resources.Fingerprint` hashes the output into the filename
|
||||
(`book.min.<sha>.css`) and embeds an SRI `integrity` attribute on the `<link>`; a
|
||||
single byte change in the CSS produces a new filename, which is why old cached
|
||||
stylesheets never collide with new ones.
|
||||
|
||||
### Where your customizations live
|
||||
|
||||
Two files hold all site-level customization — you rarely need to touch anything else:
|
||||
|
||||
- **`themes/hugo-book/layouts/_partials/docs/search.html`** — the entire search UI:
|
||||
the Pagefind `new PagefindUI({...})` config, the `#search`-scoped `<style>` block
|
||||
that fits the UI to the sidebar, and the keyboard-navigation script. (See §5.)
|
||||
- **`themes/hugo-book/assets/_variables.scss`** — site-wide dimensions and font.
|
||||
This file is imported **after** the theme's `_defaults.scss`, so the values here
|
||||
*override* the theme defaults with plain (non-`!default`) assignments.
|
||||
|
||||
| Variable | Current value | Effect |
|
||||
|---------------------------|---------------|-----------------------------------------------------|
|
||||
| `:root { --font-size }` | `17px` | Site-wide base font (theme default is 16px). All `rem` sizes scale off this. |
|
||||
| `$container-max-width` | `100rem` | Max width of the centered content area (~1700px at 17px root). |
|
||||
| `$menu-width` | `18.75rem` | Width of the **left sidebar** — the category list *and* the search box *and* the search-results pane are all this one column. |
|
||||
| `$mobile-breakpoint` | *(recomputed)*| **Must be recomputed whenever you change `$menu-width`/`$body-min-width`/`$toc-width`** — it is derived as `$menu-width + $body-min-width * 1.2 + $toc-width`. |
|
||||
|
||||
### Search index scoping (`data-pagefind-body`)
|
||||
|
||||
By default Pagefind indexes the *entire* `<body>` of each page — including the
|
||||
sidebar, menu, and footer — which pollutes results. Instead, the article element in
|
||||
each layout is tagged with `data-pagefind-body`:
|
||||
|
||||
```html
|
||||
<article class="markdown book-article" data-pagefind-body>
|
||||
```
|
||||
|
||||
Pagefind then indexes **only** that element and *skips any page that lacks the
|
||||
attribute*. The attribute is present in `layouts/baseof.html` (default single + home),
|
||||
`layouts/term.html` (list pages), and `layouts/posts/{single,list}.html`.
|
||||
`404.html` is intentionally left untagged (it has its own standalone `<html>`).
|
||||
**Do not remove these attributes** or the menu text will start appearing in search
|
||||
results. After any change, confirm the indexed page count in the `make index` log —
|
||||
a sharp drop means a page lost its tag (usually the home page).
|
||||
|
||||
### The `#search` CSS cascade trick
|
||||
|
||||
`pagefind-ui.css` is loaded in `<body>` (by the search partial) — **after** the theme's
|
||||
head stylesheet. A CSS rule in the head at equal specificity therefore *loses* the
|
||||
cascade. All search overrides live in a `#search`-scoped `<style>` block in
|
||||
`search.html`; the `#search` id + class gives them higher specificity than
|
||||
Pagefind's class-only rules regardless of load order. Keep this pattern when you tune
|
||||
the search UI.
|
||||
|
||||
### Pagefind 1.5.2 quirks we work around (all in `search.html`)
|
||||
|
||||
These are non-obvious behaviors of the Default UI in Pagefind **1.5.2** (the version
|
||||
`npx -y pagefind` resolves to). They cost real debugging time; they are documented
|
||||
here so the next person doesn't rediscover them:
|
||||
|
||||
- **The results drawer is an in-flow child of the form.** Once results render,
|
||||
`.pagefind-ui__form` stretches to the full height of the results list (measured
|
||||
~1800px for a dozen rows). The fixed-height sidebar (`.book-menu-content`,
|
||||
`position:fixed; overflow-y:auto`) simply scrolls it — that's *intended*. Consequence:
|
||||
any `%`-based height on the absolutely-positioned clear button would resolve against
|
||||
that stretched form, so the clear button uses **fixed** pixel dimensions keyed to the
|
||||
input.
|
||||
- **There is no keyboard navigation, and stock Enter is a silent no-op.** The built
|
||||
`pagefind-ui.js` handles `Enter` with only `preventDefault()` — typing a query and
|
||||
pressing Enter navigates nowhere. A custom **capture-phase** `keydown` listener on
|
||||
`#search` implements ↑/↓ highlighting and Enter-to-open (see §5).
|
||||
- **An empty thumbnail occupies the left of every row.** Even with zero images in the
|
||||
index, Pagefind renders an ~81px `.pagefind-ui__result-thumb` placeholder, which
|
||||
pushes result text ~90px right (a "right-justified" look). We hide it
|
||||
(`display:none`) because this index has no images.
|
||||
- **Matched terms render in harsh UA-yellow.** Pagefind's CSS reset does
|
||||
`mark { all: revert }`, so `<mark>` falls back to the browser default (yellow). We
|
||||
restyle `#search .pagefind-ui__result mark` to a subdued slate.
|
||||
|
||||
### Dead-link automation
|
||||
|
||||
Two independent checks, deliberately split by scope and cost:
|
||||
|
||||
- **Internal** (`make link-check` / `make link-fix`): *offline*. `lychee` checks the
|
||||
source markdown against the built site (`--root-dir public`, `--scheme file`), so no
|
||||
network is involved. Confirmed-dead *internal page* links are auto-commented out by
|
||||
`scripts/fix_dead_links.py`; external/bare-domain/empty links are routed to a manual
|
||||
review file, never auto-edited.
|
||||
- **External** (`make external-check`): *network*. `scripts/check_external_links.py`
|
||||
pings every `http(s)` link in `content/`. Report-only, always exits 0. A daily cron
|
||||
job runs it (see §6).
|
||||
|
||||
The removal mechanism for internal links is **commenting, not deleting**
|
||||
(`[Old](/gone/)` → `<!-- [Old](/gone/) -->`). An HTML comment survives the Hugo build
|
||||
verbatim, is invisible on the rendered page, is excluded from the search index, and is
|
||||
no longer detected by `lychee` — which makes `make link-fix` **idempotent** (safe to
|
||||
re-run; it never loops).
|
||||
|
||||
---
|
||||
|
||||
## 1. Clone
|
||||
|
||||
```
|
||||
git clone https://dev.libertytools.io/adminuser/libertytoolsio.git
|
||||
cd libertytoolsio
|
||||
```
|
||||
|
||||
## 2. Prerequisites (install once)
|
||||
|
||||
**Recommended: `make bootstrap`.** It installs pinned versions of Hugo extended
|
||||
(0.167.0), Node (22.11.0 — provides `npx` for Pagefind), and lychee (0.24.2)
|
||||
into the repo-local `.tools/bin` (gitignored). Make targets always use these
|
||||
pinned tools via a recipe-scoped PATH; your system PATH and any system-installed
|
||||
tools (e.g. an older Hugo) are never touched. Run `make tool-versions` to see
|
||||
pinned vs system versions side by side. The manual per-tool installs below are
|
||||
the fallback (they install into `~/.local/bin`).
|
||||
All tools install into `~/.local/bin` (no sudo). Make sure `~/.local/bin` is on your
|
||||
`PATH` (it usually is; if not: `export PATH="$HOME/.local/bin:$PATH"`).
|
||||
|
||||
- **Hugo extended (v0.167.0 or newer).** The theme compiles SCSS with `css.Sass`, so
|
||||
the *extended* build is required. From the repo root:
|
||||
```
|
||||
cd "$(mktemp -d)"
|
||||
curl -sL -o h.tgz https://github.com/gohugoio/hugo/releases/download/v0.167.0/hugo_extended_0.167.0_linux-amd64.tar.gz
|
||||
echo "0163f5c3deddac1f494a1629ddc40c65d18de9d5794facd98f7f96ac2c7d8957 h.tgz" | sha256sum -c -
|
||||
tar xzf h.tgz
|
||||
install -m 0755 hugo "$HOME/.local/bin/hugo"
|
||||
hugo version # must print ...+extended...
|
||||
```
|
||||
- **Node (v18+).** Pagefind runs through `npx -y pagefind`, which auto-installs the
|
||||
Pagefind CLI the first time (resolves to **1.5.x**; the UI quirks in
|
||||
[Architecture](#pagefind-152-quirks-we-work-around-all-in-searchhtml) are for 1.5.2).
|
||||
Verify: `node --version && npx --version`.
|
||||
- **lychee (v0.24.2)** — link checker, needed only for the dead-link targets:
|
||||
```
|
||||
cd "$(mktemp -d)"
|
||||
curl -sL -o ly.tgz https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz
|
||||
tar xzf ly.tgz
|
||||
install -m 0755 lychee-x86_64-unknown-linux-musl/lychee "$HOME/.local/bin/lychee"
|
||||
lychee --version # lychee 0.24.2
|
||||
```
|
||||
|
||||
## 3. Configuration
|
||||
|
||||
- **`config.toml`** — site-level settings: `theme = "hugo-book"`, `BookSearch = true`,
|
||||
`BookTheme = 'dark'`, `plausibleDomain`, and `markup.goldmark.renderer.unsafe = true`
|
||||
(required so raw HTML — including the commented-out dead links and inline `<style>`
|
||||
blocks — passes through the markdown renderer unharmed).
|
||||
- **`themes/hugo-book/assets/_variables.scss`** — site dimensions + font (see the
|
||||
table in [Architecture](#where-your-customizations-live)). This is the file to edit
|
||||
to change the base font size, page width, or sidebar width.
|
||||
- **`themes/hugo-book/layouts/_partials/docs/search.html`** — the search UI (see §5).
|
||||
|
||||
## 4. Build & preview (make commands)
|
||||
|
||||
Run from the project root. `public/`, `linkcheck/`, `resources/_gen/`, and
|
||||
`.tools/` are generated and gitignored.
|
||||
|
||||
| Target | What it does |
|
||||
|-----------------------|--------------|
|
||||
| `make bootstrap` | **Once per machine.** Install the pinned toolchain (Hugo 0.167.0 extended, Node 22.11.0, lychee 0.24.2) into repo-local `.tools/bin`. Idempotent; never touches your system. |
|
||||
| `make tool-versions` | Diagnostic table: pinned (`.tools/bin`) vs PATH-first (system) tool versions. |
|
||||
| `make preflight` | Strict tool check (runs automatically before `all`/`serve`/`serve-lan`); on failure it says `make bootstrap` instead of a bare `error 127`. |
|
||||
| `make build` | Build Hugo content into `public/` (`hugo --gc`). |
|
||||
| `make index` | `build` + build the Pagefind search index into `public/pagefind/`. **This is the normal "I edited content, re-make it" command.** |
|
||||
| `make serve` / `make run` | `index` + serve the built site **with search** at `http://localhost:1414/` (local only). Uses Pagefind's own server, which binds 127.0.0.1. |
|
||||
| `make serve-lan` | Serve the built site on **all interfaces** so another machine on the network can open it: `http://<LAN-IP>:8090/`. Python stdlib only; build first with `make index`. |
|
||||
| `make link-check` | **Offline** scan of *internal* links in `content/` → `linkcheck/internal.json`. Fast, no network. |
|
||||
| `make link-fix` | Comment out confirmed dead *internal* links, then rebuild content + index. |
|
||||
| `make external-check` | **Network** scan of all *external* links in `content/` → console report + `linkcheck/external_dead.txt`. Report-only; always exits 0. |
|
||||
| `make clean` | Remove `public/` and `linkcheck/`. |
|
||||
| `make all` | `build` + `index` (the default target). |
|
||||
|
||||
Notes on the targets:
|
||||
|
||||
- **`build` runs `hugo --gc`** — `--gc` garbage-collects orphaned resources from the
|
||||
asset cache.
|
||||
- **`serve` / `serve-lan` do not rebuild.** They serve whatever is already in
|
||||
`public/`. Always run `make index` first (or use `make serve`, which chains `index`),
|
||||
or you'll be serving a stale build.
|
||||
- **Why `serve` uses `pagefind --serve`, not `hugo server`:** `hugo server` renders in
|
||||
memory and may not write `public/`, leaving Pagefind with nothing to index.
|
||||
Serving the *built* `public/` through Pagefind's own static server is what makes the
|
||||
preview match production.
|
||||
- **`serve-lan` is a plain static file server** (`scripts/serve_lan.py`, stdlib only)
|
||||
rooted at `public/` on `0.0.0.0:8090`. It exists specifically because `make serve`
|
||||
binds to 127.0.0.1 and is unreachable from other machines.
|
||||
|
||||
### Viewing changes locally
|
||||
|
||||
```
|
||||
make serve
|
||||
```
|
||||
Open `http://localhost:1414/`. There is no live reload — re-run `make serve` after
|
||||
editing content.
|
||||
|
||||
### Viewing from another machine on the same network
|
||||
|
||||
```
|
||||
make index # build first so public/pagefind/ exists
|
||||
make serve-lan
|
||||
```
|
||||
`serve-lan` prints this machine's LAN IP at startup (also: `hostname -I | head -1`).
|
||||
On the **other** machine, open `http://<that-printed-IP>:8090/`. The IP is dynamic
|
||||
(DHCP); re-check it if it changes. Search works identically under this plain static
|
||||
server as under `make serve` (the Pagefind bundle is part of `public/`, so no special
|
||||
server is needed for cross-machine access).
|
||||
|
||||
## 5. Search (Pagefind)
|
||||
|
||||
The search box lives in the **left sidebar** on every page (injected via
|
||||
`themes/hugo-book/layouts/_partials/docs/menu.html` → `docs/search.html`).
|
||||
|
||||
### Using it
|
||||
|
||||
- Press **`/`** anywhere on a page to jump to the search box (`focusOnSlash`).
|
||||
- Type a query; results appear below the box, scoped to page content (see
|
||||
`data-pagefind-body` in [Architecture](#search-index-scoping-data-pagefind-body)).
|
||||
- **Navigate with the keyboard:**
|
||||
- **`↓` / `↑`** move a highlight through the results, in visual order. Top-level
|
||||
hits *and* their sub-topics (Pagefind "sub-results") each get their own highlight —
|
||||
e.g. a query under "Hacking" can step through Hacking, then its Anonymity / OSINT /
|
||||
Security Courses sub-entries, one at a time. Wraps around at the ends.
|
||||
- **`Enter`** opens the highlighted result. If nothing is highlighted, it opens the
|
||||
top result. (Stock Pagefind does neither — this is the custom listener described in
|
||||
[Architecture](#pagefind-152-quirks-we-work-around-all-in-searchhtml).)
|
||||
- The matched terms inside each result are shown in a subdued slate (not the browser's
|
||||
default yellow).
|
||||
|
||||
### Customizing the look
|
||||
|
||||
Everything is in the `#search`-scoped `<style>` block and the `new PagefindUI({...})`
|
||||
call in `themes/hugo-book/layouts/_partials/docs/search.html`. **Always keep the
|
||||
`#search` prefix** on overrides — see the [cascade trick](#the-search-css-cascade-trick).
|
||||
Current values (verified against the built page):
|
||||
|
||||
| Element | Current value |
|
||||
|--------------------------------------|---------------|
|
||||
| Input font / height / padding | `font-size: 0.85em; height: 2.4em; min-height: 2.4em; padding: 0 2.6em 0 2.4em;` |
|
||||
| Magnifier icon (`.pagefind-ui__form:before`) | `top: 7px; left: 12px; width/height: 14px;` — fades to `opacity: 0` while the input has text |
|
||||
| Clear button (`.pagefind-ui__search-clear`) | `top: 3px; right: 3px; height: 23px; font-size: 11px; padding: 0 6px;` (fixed px, *not* `%` — see form-stretch quirk) |
|
||||
| Results layout (`.pagefind-ui__drawer`) | `flex-direction: column; gap: 0.4em;` (stack, don't wrap) |
|
||||
| Shrink guards (`.results-area`, `.result-excerpt`) | `min-width: 0;` |
|
||||
| Empty thumbnail (`.result-thumb`) | `display: none;` (no images in this index) |
|
||||
| Result title / link | `font-size: 1em; overflow-wrap: anywhere;` |
|
||||
| Result excerpt | `font-size: 0.88em; overflow-wrap: anywhere; word-break: break-word;` |
|
||||
| "N results for …" message | `font-size: 0.8em;` |
|
||||
| Keyboard-selected row (`.pf-kbd-selected`) | `background: rgba(100,116,139,0.5); outline: 2px solid #cbd5e1; outline-offset: -2px; color: #fff;` |
|
||||
| Matched term (`mark`) | `background: rgba(100,116,139,0.45); color: #fff; font-weight: 600; border-radius: 2px;` |
|
||||
| Excerpt length (words) | `excerptLength: 20` in the `new PagefindUI({...})` call |
|
||||
|
||||
The legacy Fuse.js search was **removed** (it double-loaded with Pagefind). A backup of
|
||||
the old head partial is at `themes/hugo-book/layouts/_partials/docs/html-head.html.bkup`.
|
||||
|
||||
## 6. Dead links
|
||||
|
||||
### Internal links (fast, offline)
|
||||
|
||||
```
|
||||
make link-check # writes linkcheck/internal.json
|
||||
make link-fix # comments out confirmed dead internal page links, then rebuilds
|
||||
```
|
||||
|
||||
How it works: `lychee` checks the source markdown against the built site
|
||||
(`--root-dir public`). A link whose target page does not exist is a *dead internal
|
||||
link* and `scripts/fix_dead_links.py` wraps it in an HTML comment:
|
||||
`[Old](/gone/)` → `<!-- [Old](/gone/) -->`. A commented link disappears from both the
|
||||
rendered page and the search index, and is **not** re-detected, so `make link-fix` is
|
||||
idempotent and safe to run repeatedly.
|
||||
|
||||
**Safety rule:** only *internal page* links are auto-commented. External `https://`
|
||||
failures, bare-domain links, and empty `href`s are written to
|
||||
`linkcheck/manual_review.txt` and left for you to fix by hand (a bare domain may be a
|
||||
typo to fix, not a link to delete).
|
||||
|
||||
### External links (network, daily)
|
||||
|
||||
```
|
||||
make external-check # pings every http/https link in content; report-only
|
||||
```
|
||||
|
||||
It lists each dead external link with its `file:line` and writes
|
||||
`linkcheck/external_dead.txt`. It does **not** edit anything. A scheduled job runs this
|
||||
once a day (below).
|
||||
|
||||
### The daily external-link job
|
||||
|
||||
A cron job named **`libertytools-external-link-check`** runs
|
||||
`scripts/check_external_links.py` **every day at 6:00am**. It is report-only and always
|
||||
exits 0. Its output is saved and viewable with:
|
||||
|
||||
```
|
||||
hermes cronjob list # confirm it exists and is enabled
|
||||
```
|
||||
- To run it manually right now: `hermes cronjob run <job_id>` (from `hermes cronjob list`).
|
||||
- To pause it: `hermes cronjob pause <job_id>`; to resume: `hermes cronjob resume <job_id>`.
|
||||
- To change the time, edit the job's schedule in `config.yaml` (or ask the agent).
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
- **Server log shows 404s for `/pagefind/*` or `/livereload.js`** → stale or
|
||||
unindexed `public/`: run `make clean && make all` and restart the server (see
|
||||
[Fresh git pull? Rebuild before serving](#fresh-git-pull-rebuild-before-serving)).
|
||||
- **`error 127` / "command not found"** → a build tool is missing. Run
|
||||
`make tool-versions` to see which, then `make bootstrap` to install the
|
||||
pinned toolchain into `.tools/`.
|
||||
- **`hugo: command not found`** → you're using the non-extended build or it isn't on
|
||||
PATH. Re-run the Hugo install above and confirm `hugo version` says `+extended`.
|
||||
- **Search shows no results** → the index wasn't built. Run `make index`, confirm the
|
||||
log prints `Indexed N pages` and `public/pagefind/pagefind-ui.js` exists, then reload.
|
||||
If the page count looks low, a layout lost its `data-pagefind-body` attribute.
|
||||
- **`pagefind` / `lychee` not found** → re-run the relevant install in §2; both live in
|
||||
`~/.local/bin`.
|
||||
- **`make serve` says port 1414 in use** → a previous server is still running. Stop it
|
||||
(Ctrl+C in its terminal) or find it: `ss -ltnp | grep 1414`.
|
||||
- **Other machine can't reach `http://<LAN-IP>:8090/`** → confirm you're on the same
|
||||
network and using the IP `serve-lan` printed (`hostname -I | head -1` on the host).
|
||||
The host's firewall may need to allow inbound 8090.
|
||||
- **Search is too big/small for the sidebar** → tune the `#search` block in
|
||||
`themes/hugo-book/layouts/_partials/docs/search.html` (see §5).
|
||||
- **Search results look right-justified / have a big left gap** → the empty thumbnail
|
||||
isn't hidden; ensure `#search .pagefind-ui__result-thumb { display: none; }` is
|
||||
present (see the quirk in [Architecture](#pagefind-152-quirks-we-work-around-all-in-searchhtml)).
|
||||
- **Up/down arrows don't highlight results** → the keyboard-nav script in
|
||||
`search.html` is missing or its `rowFor()` no longer matches the result DOM. Sub-results
|
||||
are nested inside the top-level `<li>` (see the quirk notes).
|
||||
|
||||
## 8. Do not commit
|
||||
|
||||
All changes from a maintenance pass are left in the working tree for review. Commit
|
||||
**only after you have validated the result** (`make build` exits 0 and you've checked
|
||||
the rendered output). The following are generated and gitignored — never commit them:
|
||||
|
||||
- `public/` — built site output; a fresh clone/pull contains none of it (rebuild
|
||||
per [Fresh git pull? Rebuild before serving](#fresh-git-pull-rebuild-before-serving))
|
||||
- `linkcheck/` — link-check reports
|
||||
- `resources/_gen/` — Hugo asset-generation cache
|
||||
- `.tools/` — pinned local toolchain installed by `make bootstrap`
|
||||
- `.hermes/` — agent working files
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
content/ # site pages (markdown + images)
|
||||
themes/hugo-book/ # the theme (vendored). Do not edit themes/hugo-book.backup/.
|
||||
assets/_variables.scss # SITE overrides: font, page width, sidebar width, breakpoint
|
||||
layouts/baseof.html # root layout; <article data-pagefind-body>
|
||||
layouts/term.html # list/term pages (data-pagefind-body)
|
||||
layouts/posts/{single,list}.html # blog post pages (data-pagefind-body)
|
||||
layouts/_partials/docs/search.html # Pagefind UI + sidebar-fit CSS + keyboard nav
|
||||
layouts/_partials/docs/menu.html # injects search.html into the sidebar
|
||||
layouts/_partials/docs/html-head.html # (legacy fuse search removed; .bkup kept)
|
||||
layouts/_partials/docs/html-head.html.bkup # backup of the pre-Pagefind head
|
||||
scripts/
|
||||
fix_dead_links.py # internal dead-link comment-out (idempotent)
|
||||
check_external_links.py # external dead-link report (report-only, daily cron)
|
||||
serve_lan.py # static LAN preview server (serves public/ on 0.0.0.0:8090)
|
||||
Makefile # build / index / serve / run / serve-lan / link-check / link-fix / external-check / clean
|
||||
config.toml # theme + BookSearch + BookTheme + goldmark.unsafe
|
||||
```
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
baseURL = 'http://example.org/'
|
||||
baseURL = 'https://libertytools.io/'
|
||||
languageCode = 'en-us'
|
||||
title = ''
|
||||
title = 'Liberty Tools'
|
||||
theme = "hugo-book"
|
||||
|
||||
[params]
|
||||
|
||||
@@ -38,7 +38,7 @@ Death Athletes refers to a concept going back to Ancient Greece and popularized
|
||||
|
||||
**[Ian Freemen](https://www.freeiannow.org/)** - Freeman, known for advocating freedom and financial privacy through bitcoin, found himself targeted by federal prosecutors. Initially facing charges including bank fraud and wire fraud, these allegations were eventually dropped due to lack of evidence. The core of the case revolved around money laundering and conspiracy charges. Ultimately, Freeman received a severe sentence of eight years in prison, a $40,000 fine, and forfeiture of $5 million in assets, despite not being convicted of the fraud charges prosecutors initially highlighted.
|
||||
|
||||
[**Cody Wilson**](defcad.com) - The creator of the Death Athletic concept as well as the founder of Defcad.com and first person to create a 3d printed Gun. Cody has been at the forefront of fighting for 2nd amendment and 1st amendment as they pertain to code and digital rights for over a decade. Cody has been involved in many lawsuits with the federal government as a result and suffered charges for sex crimes in 2018. He is currently free and working for Defcad.
|
||||
[**Cody Wilson**](https://defcad.com) - The creator of the Death Athletic concept as well as the founder of Defcad.com and first person to create a 3d printed Gun. Cody has been at the forefront of fighting for 2nd amendment and 1st amendment as they pertain to code and digital rights for over a decade. Cody has been involved in many lawsuits with the federal government as a result and suffered charges for sex crimes in 2018. He is currently free and working for Defcad.
|
||||
|
||||
[**Jay Stark**](https://en.wikipedia.org/wiki/FGC-9) - The anonymous creator of the FGC-9, which stands for "Fuck Gun Control." The FGC was the first project to enable the creation of a fully 3d printed gun that did not require any actual gun parts, instead it uses items that can be purchased from conventional stores all over the world. Jay Stark was arrested in April 2021 on suspicions of owning illegal weapons, however none of these weapons were found and he was not taken into custody. Two days later he was found dead sitting in his car in front of his parents house. While the offical cause of death ruled out "foul play" it is difficult for most to believe that this was simply a coincidence.
|
||||
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ Documentaries, talks, and other media.
|
||||
- [**The Unshackled Englishman**](https://www.youtube.com/watch?v=rB6oK_FS8JA)
|
||||
|
||||
## Resources
|
||||
<!-- - [**GatGPT**](https://gatgpt.defcad.com/) - A deneutered version of ChatGPT focused on firearms. -->
|
||||
- [**GatGPT**](https://gatgpt.defcad.com/) - A deneutered version of ChatGPT focused on firearms.
|
||||
- [**Shootingbot**](https://www.shootingbot.com/) - Monitors price and availability of ammunition and reloading components.
|
||||
|
||||
## Training
|
||||
|
||||
@@ -75,7 +75,7 @@ Privacy Front ends are interfaces that interact with well known services that do
|
||||
|
||||
- [**Lingva Translate**](https://github.com/TheDavidDelta/lingva-translate) - Alternative front-end for Google Translate, serving as a free and open-source translator with over a hundred languages available.
|
||||
|
||||
- [**SimplyTranslate**](simplytranslate.org) - Provide fast and private translations to the user without wasting much overhead for extensive styling or JavaScript. Supports Google Translate, DeepL, ICIBA, and LibreTranslate.
|
||||
- [**SimplyTranslate**](https://simplytranslate.org) - Provide fast and private translations to the user without wasting much overhead for extensive styling or JavaScript. Supports Google Translate, DeepL, ICIBA, and LibreTranslate.
|
||||
|
||||
- [**SimplyTranslate Mobile**](https://github.com/ManeraKai/simplytranslate_mobile) - Unofficial Android client of SimplyTranslate.
|
||||
|
||||
|
||||
@@ -43,5 +43,6 @@ Why work for a company you hate? If you believe in liberty many of these compani
|
||||
- [**Vaccinefreejobs.org**](http://vaccinefreejobs.org/) - This site is for the people, the employees and candidates, who believe that your access and free movement in society should not be determined by your vaccination status.
|
||||
|
||||
|
||||
<!--- [https://www.jabfreejobs.in](https://www.jabfreejobs.in)fo/
|
||||
http://www.jobsnotjabs.com/ http://novaxjobsusa.com www.provisionjobs.com --->
|
||||
- [**JabFreeJobs**](https://www.jabfreejobs.in/) - No jab job board.
|
||||
|
||||
<!--- http://www.jobsnotjabs.com/ http://novaxjobsusa.com www.provisionjobs.com --->
|
||||
+1
-1
@@ -113,7 +113,7 @@ Most exchanges are custodial, meaning you are required to leave crypto on their
|
||||
|
||||
### Business Directories
|
||||
|
||||
- [**AirBTC**](airbtc.online) - AirBNB for Bitcoiners.
|
||||
- [**AirBTC**](https://airbtc.online) - AirBNB for Bitcoiners.
|
||||
|
||||
- [**AgoristAcers**](https://agoristacres.com/) - Purchase Seeds with Monero.
|
||||
|
||||
|
||||
+1
-1
@@ -525,7 +525,7 @@ Password managers are one of the few security tools that makes your life easier.
|
||||
|
||||
- [**Privacy Security and Osint**](https://inteltechniques.com/podcast.html) - Hosted by the king of privacy, Micahel Bazzell, covers a wide range of privacy, security and Osint. Currently on a hiatus.
|
||||
|
||||
- [**Surveillance Report**](https://ttps://surveillancereport.tech/) - Joint hosted by Privacy advocates Henry from Techlore and Nate from the New Oil. Break down of weekly data leaks and privacy incursions.
|
||||
- [**Surveillance Report**](https://surveillancereport.tech/) - Joint hosted by Privacy advocates Henry from Techlore and Nate from The New Oil. Break down of weekly data leaks and privacy incursions.
|
||||
|
||||
- [**WatchMan Privacy**](https://watchmanprivacy.com/) - Hosted by Gabriel Custodiet, a principled privacy and freedom advocate.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ While privacy tools are fantastic, they sometimes miss the true root of the prob
|
||||
|
||||
- [**Disroot**](https://disroot.org/en) - Privacy-focused ecosystem. Provides multiple cloud services. Including Email, Nextcloud, Cryptpad etc…
|
||||
|
||||
- [**Federated.Computer**](https://www.federated.computer/?via=liberty&ref=libertyontherocks.org) - Great open-source replacements for Dropbox, Google Workplace, Zoom, Slack, Airtable, Mailchimp, 1Password, Netlify, Github, Calendy, your spying VPN and much more for the low price of $39 a month. As many users as you want. Only pay for more storage when/if you need it.Federated includes Human Support and full Backups. The benefits of self-hosting without the day-to-day high-stakes drudgery. [**Affiliate Link**]((https://www.federated.computer/?via=liberty&ref=libertyontherocks.org))
|
||||
- [**Federated.Computer**](https://www.federated.computer/?via=liberty&ref=libertyontherocks.org) - Great open-source replacements for Dropbox, Google Workplace, Zoom, Slack, Airtable, Mailchimp, 1Password, Netlify, Github, Calendy, your spying VPN and much more for the low price of $39 a month. As many users as you want. Only pay for more storage when/if you need it.Federated includes Human Support and full Backups. The benefits of self-hosting without the day-to-day high-stakes drudgery. [**Affiliate Link**](https://www.federated.computer/?via=liberty&ref=libertyontherocks.org)
|
||||
|
||||
- [**Koofr**](https://koofr.eu/) - Allows for importing of Drive, Dropbox etc. Privacy-focused cloud storage free up to 10 Gb’s. ^1,2^
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"Target":"book.min.0edbfa12be0756ec13c02ec8218ae882d32e82be873db98b790ade0a47d58b55.css","MediaType":"text/css","Data":{"Integrity":"sha256-Dtv6Er4HVuwTwC7IIYrogtMugr6HPbmLeQreCkfVi1U="}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"Target":"book.min.bd0c2ca7caf6a870ed62ce6a734b0bcdf2e612df263b13b2c9c55e48ab315ad3.css","MediaType":"text/css","Data":{"Integrity":"sha256-vQwsp8r2qHDtYs5qc0sLzfLmEt8mOxOyycVeSKsxWtM="}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"Target":"book.min.4b35fed0bea034bbc19c89c71e14b73fb9c68cfcc586b9382adfb9b7b103ba06.css","MediaType":"text/css","Data":{"Integrity":"sha256-SzX+0L6gNLvBnInHHhS3P7nGjPzFhrk4Kt+5t7EDugY="}}
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
# Install the pinned toolchain into the repo-local .tools/bin (gitignored):
|
||||
# hugo extended 0.167.0, node 22.11.0 (provides npx/npm), lychee 0.24.2
|
||||
# Idempotent: a tool already present at the pinned version is skipped, so
|
||||
# re-running with everything installed is an instant no-op. Touches nothing
|
||||
# outside .tools/ and never modifies your system PATH.
|
||||
# Requires: curl, tar, sha256sum, and network on first run.
|
||||
set -eu
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
BIN="$ROOT/.tools/bin"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
HUGO_VERSION=0.167.0
|
||||
HUGO_SHA256=0163f5c3deddac1f494a1629ddc40c65d18de9d5794facd98f7f96ac2c7d8957
|
||||
NODE_VERSION=22.11.0
|
||||
LYCHEE_VERSION=0.24.2
|
||||
|
||||
case "$(uname -s)/$(uname -m)" in
|
||||
Linux/x86_64) HARCH=amd64; NARCH=x64; RARCH=x86_64 ;;
|
||||
Linux/aarch64) HARCH=arm64; NARCH=arm64; RARCH=aarch64 ;;
|
||||
*) echo "bootstrap: unsupported platform $(uname -s) $(uname -m) (Linux x86_64/aarch64 only)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "bootstrap: curl is required (downloads the pinned tarballs)" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$BIN"
|
||||
|
||||
# --- hugo extended ---
|
||||
# Release builds report "v<ver>-<40-hex-commit>+extended"; strip the commit so
|
||||
# the pinned comparison is exact.
|
||||
hugo_norm() { "$1" version 2>/dev/null | awk '{print $2}' | sed 's/-[0-9a-f]\{40,\}//'; }
|
||||
if [ -x "$BIN/hugo" ] && [ "$(hugo_norm "$BIN/hugo")" = "v$HUGO_VERSION+extended" ]; then
|
||||
echo "hugo v$HUGO_VERSION+extended: present, skipping"
|
||||
else
|
||||
echo "hugo v$HUGO_VERSION+extended: downloading..."
|
||||
curl -fsSL -o "$TMP/h.tgz" \
|
||||
"https://github.com/gohugoio/hugo/releases/download/v$HUGO_VERSION/hugo_extended_${HUGO_VERSION}_linux-${HARCH}.tar.gz"
|
||||
echo "$HUGO_SHA256 $TMP/h.tgz" | sha256sum -c -
|
||||
tar xzf "$TMP/h.tgz" -C "$TMP"
|
||||
install -m 0755 "$TMP/hugo" "$BIN/hugo"
|
||||
fi
|
||||
|
||||
# --- node (+ npm/npx) ---
|
||||
if [ -x "$BIN/node" ] && [ "$("$BIN/node" --version 2>/dev/null)" = "v$NODE_VERSION" ]; then
|
||||
echo "node v$NODE_VERSION: present, skipping"
|
||||
else
|
||||
echo "node v$NODE_VERSION: downloading..."
|
||||
curl -fsSL -o "$TMP/n.tar.xz" \
|
||||
"https://nodejs.org/dist/v$NODE_VERSION/node-v${NODE_VERSION}-linux-${NARCH}.tar.xz"
|
||||
rm -rf "$ROOT"/.tools/node-v*-linux-*
|
||||
tar xJf "$TMP/n.tar.xz" -C "$ROOT/.tools"
|
||||
for t in node npm npx; do
|
||||
ln -sfn "../node-v${NODE_VERSION}-linux-${NARCH}/bin/$t" "$BIN/$t"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- lychee ---
|
||||
if [ -x "$BIN/lychee" ] && "$BIN/lychee" --version 2>/dev/null | grep -q "$LYCHEE_VERSION"; then
|
||||
echo "lychee $LYCHEE_VERSION: present, skipping"
|
||||
else
|
||||
echo "lychee $LYCHEE_VERSION: downloading..."
|
||||
curl -fsSL -o "$TMP/l.tgz" \
|
||||
"https://github.com/lycheeverse/lychee/releases/download/lychee-v$LYCHEE_VERSION/lychee-${RARCH}-unknown-linux-musl.tar.gz"
|
||||
tar xzf "$TMP/l.tgz" -C "$TMP"
|
||||
install -m 0755 "$TMP/lychee-${RARCH}-unknown-linux-musl/lychee" "$BIN/lychee"
|
||||
fi
|
||||
|
||||
echo "OK: pinned toolchain ready in $BIN (verify with: make tool-versions)"
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daily external-link liveness check.
|
||||
|
||||
Runs lychee over every http/https link in the site's content/*.md and prints a
|
||||
concise report of the ones that failed (dead / 4xx / 5xx). Designed for a
|
||||
scheduled job: read-only (HTTP GET only), always exits 0 so the job never
|
||||
"fails" — the report is the output.
|
||||
|
||||
Defaults are absolute to the repo, so it runs from ANY working directory
|
||||
(including a cron scheduler). Override with flags if the repo lives elsewhere.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_external_links.py
|
||||
python3 scripts/check_external_links.py --timeout 20 --retries 1
|
||||
|
||||
Report goes to stdout and to linkcheck/external_dead.txt (one dead URL per line
|
||||
with its source file:line).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = Path("/home/noether/libertytoolsio")
|
||||
LYCHEE_CANDIDATES = ["lychee", str(Path.home() / ".local/bin/lychee")]
|
||||
|
||||
|
||||
def find_lychee() -> str:
|
||||
for c in LYCHEE_CANDIDATES:
|
||||
p = shutil.which(c) if "/" not in c else (c if Path(c).exists() else None)
|
||||
if p:
|
||||
return p
|
||||
raise SystemExit("lychee not found. Install it first (plan Task 7a).")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repo", default=str(DEFAULT_REPO))
|
||||
ap.add_argument("--timeout", type=int, default=20)
|
||||
ap.add_argument("--retries", type=int, default=1)
|
||||
ap.add_argument("--threads", type=int, default="8")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
content = repo / "content"
|
||||
md_files = sorted(str(p) for p in content.rglob("*.md"))
|
||||
if not md_files:
|
||||
print(f"No markdown files under {content}/")
|
||||
return
|
||||
|
||||
lychee = find_lychee()
|
||||
cmd = [
|
||||
lychee, "--format", "json",
|
||||
"--scheme", "http", "--scheme", "https", # web links only
|
||||
"--exclude", "mailto:", "--exclude", "tel:",
|
||||
"--timeout", str(args.timeout), "--max-retries", str(args.retries),
|
||||
"--threads", str(args.threads),
|
||||
] + md_files
|
||||
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
# lychee exits non-zero when it finds dead links; we still parse the JSON.
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError:
|
||||
print("Could not parse lychee output. stderr:\n", (proc.stderr or "")[-2000:])
|
||||
return
|
||||
|
||||
dead = []
|
||||
for src, items in data.get("error_map", {}).items():
|
||||
for it in items:
|
||||
url = it.get("url", "")
|
||||
if url.startswith(("http://", "https://")):
|
||||
status = it.get("status", {}).get("text", "")
|
||||
span = it.get("span", {})
|
||||
# report the source path relative to the repo for readability
|
||||
try:
|
||||
rel = Path(src).resolve().relative_to(repo)
|
||||
except ValueError:
|
||||
rel = src
|
||||
dead.append((str(rel), span.get("line"), url, status))
|
||||
|
||||
total_checked = data.get("total", 0)
|
||||
ok = data.get("successful", 0)
|
||||
|
||||
outdir = repo / "linkcheck"
|
||||
outdir.mkdir(exist_ok=True)
|
||||
with (outdir / "external_dead.txt").open("w") as f:
|
||||
for src, line, url, status in sorted(dead):
|
||||
f.write(f"{src}:{line}\t{url}\t[{status}]\n")
|
||||
|
||||
# Save the full lychee JSON so `make external-fix` can act on this run's data
|
||||
# without re-pinging the web (see scripts/fix_dead_external_links.py).
|
||||
(outdir / "external.json").write_text(json.dumps(data, indent=2))
|
||||
|
||||
print(f"External link check: {total_checked} links checked, {ok} ok, {len(dead)} dead.")
|
||||
if dead:
|
||||
print("\nDead external links:")
|
||||
for src, line, url, status in sorted(dead):
|
||||
print(f" {src}:{line} {url} [{status}]")
|
||||
print(f"\nFull list written to {outdir/'external_dead.txt'}")
|
||||
else:
|
||||
print("No dead external links found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Comment out EXTERNAL links that are truly dead — i.e. the host/domain no longer
|
||||
exists (DNS does not resolve / host unreachable / connection failed).
|
||||
|
||||
This is the external analogue of fix_dead_links.py (which does the same for internal
|
||||
page links). It is deliberately conservative:
|
||||
|
||||
* ONLY links whose lychee reason matches the "domain-gone" class are auto-commented:
|
||||
- "Connection failed" (lychee's umbrella for "couldn't even connect" —
|
||||
the exact reason a dead domain like jabfreejobs.in
|
||||
produces)
|
||||
- "Could not resolve host" (explicit DNS failure)
|
||||
- "HostUnreachable" (host unreachable)
|
||||
* Everything else is LEFT ALONE and written to a manual-review file:
|
||||
- HTTP status rejections (404 / 429 / 500 / redirects) -> the domain is alive
|
||||
and the server responded; a 404 is "page gone", not "domain gone".
|
||||
- SSL / TLS / certificate / HTTP2 errors -> the site is up.
|
||||
- Connection refused / reset / closed -> weaker, possibly
|
||||
transient; not the "domain doesn't exist" class.
|
||||
- "Error (cached)" -> a stale lychee cache
|
||||
entry, not a fresh signal.
|
||||
|
||||
Commenting (NOT deleting) wraps the markdown link in an HTML comment:
|
||||
[Old](/gone/) -> <!-- [Old](/gone/) -->
|
||||
so it disappears from the rendered page and the Pagefind index, and is no longer
|
||||
detected by lychee on the next run (idempotent).
|
||||
|
||||
Usage:
|
||||
python3 scripts/fix_dead_external_links.py --report linkcheck/external.json # DRY RUN (default)
|
||||
python3 scripts/fix_dead_external_links.py --report linkcheck/external.json --apply # actually edit
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# Case-insensitive substrings that mark a link as "the domain/host no longer exists".
|
||||
DOMAIN_GONE_PATTERNS = [
|
||||
"connection failed",
|
||||
"could not resolve host",
|
||||
"hostunreachable",
|
||||
]
|
||||
|
||||
|
||||
def is_domain_gone(status_text: str) -> bool:
|
||||
s = status_text.lower()
|
||||
return any(p in s for p in DOMAIN_GONE_PATTERNS)
|
||||
|
||||
|
||||
def is_onion(url: str) -> bool:
|
||||
"""True for .onion hosts — only reachable through Tor. This checker runs
|
||||
off-Tor, so every .onion link 'fails' here by construction; they must
|
||||
never be auto-commented (manual: verify via Tor)."""
|
||||
host = urlsplit(url).hostname or ""
|
||||
return host.endswith(".onion")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--report", required=True,
|
||||
help="lychee JSON report (linkcheck/external.json)")
|
||||
ap.add_argument("--apply", action="store_true",
|
||||
help="actually edit the files (default is a dry-run preview)")
|
||||
ap.add_argument("--repo", default=".",
|
||||
help="repo root the error_map file paths are relative to")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
data = json.loads(Path(args.report).read_text())
|
||||
err_map = data.get("error_map", {})
|
||||
|
||||
to_comment = [] # (rel_md_file, line, url)
|
||||
manual = [] # (rel_md_file, line, url, status_text)
|
||||
for md_file, items in err_map.items():
|
||||
rel = _rel(md_file, repo)
|
||||
for it in items:
|
||||
url = it.get("url", "")
|
||||
status = it.get("status", {}).get("text", "")
|
||||
line = it.get("span", {}).get("line")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
continue
|
||||
if is_onion(url):
|
||||
manual.append((rel, line, url,
|
||||
"(.onion — only reachable via Tor; not routable from "
|
||||
"this network. Verify in Tor; do NOT auto-comment.)"))
|
||||
continue
|
||||
if is_domain_gone(status):
|
||||
to_comment.append((rel, line, url))
|
||||
else:
|
||||
manual.append((rel, line, url, status))
|
||||
|
||||
# Group edits by file, apply bottom-up so line numbers stay valid.
|
||||
by_file = {}
|
||||
for rel, line, url in to_comment:
|
||||
by_file.setdefault(rel, []).append((line, url))
|
||||
for rel in by_file:
|
||||
by_file[rel].sort(reverse=True)
|
||||
|
||||
plan = 0
|
||||
for rel in sorted(by_file):
|
||||
p = repo / rel
|
||||
if not p.exists():
|
||||
print(f" ! missing source, skipping: {rel}")
|
||||
continue
|
||||
lines = p.read_text().splitlines(keepends=True)
|
||||
changed = False
|
||||
for line_no, url in by_file[rel]:
|
||||
if not line_no:
|
||||
continue
|
||||
idx = line_no - 1
|
||||
if idx < 0 or idx >= len(lines):
|
||||
continue
|
||||
cur = lines[idx]
|
||||
# Find the markdown link on this line whose href matches the dead URL
|
||||
# (allow a trailing-slash mismatch).
|
||||
link = _find_link(cur, url)
|
||||
if not link:
|
||||
print(f" ? could not locate link on {rel}:{line_no} for {url}")
|
||||
continue
|
||||
link_txt = link.group(0)
|
||||
# Skip if it is already inside an HTML comment (idempotency guard).
|
||||
if _already_commented(cur, link.start()):
|
||||
continue
|
||||
lines[idx] = cur[:link.start()] + "!-- " + link_txt + " --" + cur[link.end():]
|
||||
changed = True
|
||||
plan += 1
|
||||
print(f" {'APPLY' if args.apply else 'DRY '} {rel}:{line_no} {link_txt}")
|
||||
if changed and args.apply:
|
||||
p.write_text("".join(lines))
|
||||
|
||||
# Manual-review report for everything we deliberately did NOT touch.
|
||||
outdir = repo / "linkcheck"
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
mr = outdir / "manual_review_external.txt"
|
||||
with mr.open("w") as f:
|
||||
f.write("External links NOT auto-commented (domain still alive / ambiguous).\n")
|
||||
f.write("Auto-comment only covers the domain-gone class (DNS failed / host\n")
|
||||
f.write("unreachable). Review these by hand.\n\n")
|
||||
for rel, line, url, status in sorted(manual):
|
||||
f.write(f"{rel}:{line}\t{url}\t[{status}]\n")
|
||||
|
||||
print(f"\nAuto-comment {'applied' if args.apply else 'planned'}: {plan}")
|
||||
print(f"Manual review entries: {len(manual)} -> {mr}")
|
||||
if not args.apply:
|
||||
print("\nDRY RUN — nothing was changed. Re-run with --apply to comment these out.")
|
||||
|
||||
|
||||
def _rel(path: str, repo: Path) -> str:
|
||||
try:
|
||||
return str(Path(path).resolve().relative_to(repo))
|
||||
except ValueError:
|
||||
return path
|
||||
|
||||
|
||||
def _find_link(line: str, url: str):
|
||||
"""Return a match for the markdown link [text](url) whose href equals url
|
||||
(ignoring a trailing-slash difference)."""
|
||||
# Escape the url for regex, then allow an optional trailing slash on either side.
|
||||
esc = re.escape(url)
|
||||
patterns = [
|
||||
r"\[[^\]]*\]\(" + esc + r"\s*\)",
|
||||
r"\[[^\]]*\]\(" + esc.rstrip("/") + r"/?\s*\)",
|
||||
]
|
||||
for pat in patterns:
|
||||
m = re.search(pat, line)
|
||||
if m:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _already_commented(line: str, link_start: int) -> bool:
|
||||
"""True if an opening <!-- appears before the link with no matching --> between
|
||||
them (i.e. the link sits inside an existing HTML comment)."""
|
||||
pre = line[:link_start]
|
||||
opens = pre.count("<!--")
|
||||
closes = pre.count("-->")
|
||||
return opens > closes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Comment out confirmed dead INTERNAL page links, report external/bare-domain failures.
|
||||
|
||||
Reads lychee's JSON report (linkcheck/internal.json). A link is auto-commented ONLY
|
||||
if lychee flagged its file:// path as a missing file under the built site (public/).
|
||||
External https:// failures and bare-domain failures are written to a manual-review
|
||||
report and left untouched. Idempotent: already-commented links are not detected by
|
||||
lychee, so re-runs are safe.
|
||||
|
||||
IMPORTANT: run lychee with the SOURCE markdown as input (e.g. `lychee --root-dir public
|
||||
--default-extension html content/*.md`) so the error_map keys are the markdown files
|
||||
we actually edit. They are relative to the repo root.
|
||||
|
||||
Usage:
|
||||
python3 scripts/fix_dead_links.py --report linkcheck/internal.json # dry-run
|
||||
python3 scripts/fix_dead_links.py --report linkcheck/internal.json --apply # edit
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--report", required=True)
|
||||
ap.add_argument("--apply", action="store_true")
|
||||
ap.add_argument("--public-dir", default="public",
|
||||
help="built site dir that lychee's --root-dir pointed at")
|
||||
args = ap.parse_args()
|
||||
|
||||
data = json.loads(Path(args.report).read_text())
|
||||
err_map = data.get("error_map", {})
|
||||
|
||||
to_comment = [] # (md_file_relative, line, dest)
|
||||
manual = [] # (md_file_relative, line, url, status_text)
|
||||
for md_file, items in err_map.items():
|
||||
for it in items:
|
||||
url = it.get("url", "")
|
||||
status = it.get("status", {}).get("text", "")
|
||||
span = it.get("span", {})
|
||||
if url.startswith("file://"):
|
||||
after = url[len("file://"):]
|
||||
m = re.search(r"/" + re.escape(args.public_dir) + r"/(.*)$", after)
|
||||
if m:
|
||||
to_comment.append((md_file, span.get("line"), "/" + m.group(1)))
|
||||
continue
|
||||
manual.append((md_file, span.get("line"), url, status))
|
||||
|
||||
# Group by source file (error_map keys are relative to repo root).
|
||||
by_file = {}
|
||||
for md_file, line, dest in to_comment:
|
||||
by_file.setdefault(md_file, []).append((line, dest))
|
||||
for md_file in by_file:
|
||||
by_file[md_file].sort(reverse=True) # edit bottom-up; keep line count stable
|
||||
|
||||
plan = 0
|
||||
for md_file, edits in sorted(by_file.items()):
|
||||
p = Path(md_file)
|
||||
if not p.exists():
|
||||
print(f" ! missing source, skipping: {p}")
|
||||
continue
|
||||
lines = p.read_text().splitlines(keepends=True)
|
||||
changed = False
|
||||
for line_no, dest in edits:
|
||||
if not line_no:
|
||||
continue
|
||||
idx = line_no - 1
|
||||
if idx < 0 or idx >= len(lines):
|
||||
continue
|
||||
cur = lines[idx]
|
||||
# lychee reports the resolved file path (trailing slash stripped); the
|
||||
# markdown link may keep a trailing slash, so allow an optional "/".
|
||||
m = re.search(r"\[([^\]]*)\]\(" + re.escape(dest) + r"/?\s*\)", cur)
|
||||
if not m:
|
||||
print(f" ? could not locate link on {md_file}:{line_no} for {dest}")
|
||||
continue
|
||||
link_txt = m.group(0)
|
||||
if link_txt.count("<!--") or (cur.count("<!--") > cur.count("-->")):
|
||||
continue # already commented
|
||||
lines[idx] = cur[:m.start()] + "<!-- " + link_txt + " -->" + cur[m.end():]
|
||||
changed = True
|
||||
plan += 1
|
||||
print(f" {'APPLY ' if args.apply else 'DRY '} {md_file}:{line_no} {link_txt}")
|
||||
if changed and args.apply:
|
||||
p.write_text("".join(lines))
|
||||
|
||||
outdir = Path("linkcheck")
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
with (outdir / "manual_review.txt").open("w") as f:
|
||||
f.write("Manual review (NOT auto-commented). External/bare-domain failures.\n\n")
|
||||
for md_file, line, url, status in sorted(manual):
|
||||
f.write(f"{md_file}:{line}\t{url}\t[{status}]\n")
|
||||
|
||||
print(f"\nAuto-comment planned: {plan} (apply={args.apply})")
|
||||
print(f"Manual review entries: {len(manual)} -> {outdir/'manual_review.txt'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve the built site (public/) on all interfaces so other machines on the
|
||||
same LAN can reach it. Usage: python3 serve_lan.py [port] (default 8090)
|
||||
|
||||
The site must be built first: make build index
|
||||
Then start this: python3 serve_lan.py
|
||||
Address to open from ANOTHER machine on the same network:
|
||||
http://<THIS-MACHINE-LAN-IP>:8090/
|
||||
where the LAN IP is printed at startup (and is also hostname -I | head -1).
|
||||
"""
|
||||
import functools
|
||||
import http.server
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
|
||||
|
||||
# Serve the built site (public/), NOT the repo root.
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory="public")
|
||||
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), handler)
|
||||
|
||||
|
||||
def lan_ip() -> str:
|
||||
try:
|
||||
return subprocess.check_output(["hostname", "-I"], text=True).split()[0]
|
||||
except Exception:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
ip = lan_ip()
|
||||
print(f"Serving public/ -> http://0.0.0.0:{PORT}/", flush=True)
|
||||
print(f"LAN address -> http://{ip}:{PORT}/ (open this from another machine)", flush=True)
|
||||
print("Press Ctrl+C to stop.", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/sh
|
||||
# Print a table of tool versions: pinned (.tools/bin -- what make targets use,
|
||||
# because the Makefile prepends it to recipe PATH) vs PATH-first (what your own
|
||||
# shell resolves outside make).
|
||||
#
|
||||
# scripts/tool_versions.sh informational; always exits 0
|
||||
# scripts/tool_versions.sh --strict exit 1 if a pinned tool is missing or
|
||||
# has the wrong version (used by `make
|
||||
# preflight`, which runs before
|
||||
# all/serve/serve-lan)
|
||||
set -u
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
BIN="$ROOT/.tools/bin"
|
||||
MODE=${1:-}
|
||||
|
||||
HUGO_PIN="v0.167.0+extended"
|
||||
NODE_PIN="v22.11.0"
|
||||
LYCHEE_PIN="0.24.2"
|
||||
|
||||
# PATH with .tools/bin stripped, so "PATH-first" shows what the user's shell
|
||||
# would resolve, even though make exported .tools/bin into our PATH.
|
||||
SYSPATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vx "$BIN" | tr '\n' ':' | sed 's/:$//')
|
||||
|
||||
pinned_ver() {
|
||||
case "$1" in
|
||||
# hugo release builds report "v<ver>-<40-hex-commit>+extended"; strip the commit
|
||||
hugo) [ -x "$BIN/hugo" ] && "$BIN/hugo" version 2>/dev/null | awk '{print $2}' | sed 's/-[0-9a-f]\{40,\}//' ;;
|
||||
node) [ -x "$BIN/node" ] && "$BIN/node" --version 2>/dev/null ;;
|
||||
npx) [ -x "$BIN/npx" ] && "$BIN/npx" --version 2>/dev/null ;;
|
||||
lychee) [ -x "$BIN/lychee" ] && "$BIN/lychee" --version 2>/dev/null | awk '{print $2}' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
first_path() { PATH="$SYSPATH" command -v "$1" 2>/dev/null; }
|
||||
|
||||
first_ver() { # first_ver <tool> <path>
|
||||
[ -n "${2:-}" ] || return 0
|
||||
case "$1" in
|
||||
hugo) "$2" version 2>/dev/null | awk '{print $2}' | sed 's/-[0-9a-f]\{40,\}//' ;;
|
||||
node) "$2" --version 2>/dev/null ;;
|
||||
npx) "$2" --version 2>/dev/null ;;
|
||||
lychee) "$2" --version 2>/dev/null | awk '{print $2}' ;;
|
||||
python3) "$2" --version 2>/dev/null | awk '{print $2}' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$MODE" = "--strict" ]; then
|
||||
fail=0
|
||||
[ "$(pinned_ver hugo)" = "$HUGO_PIN" ] || { echo "preflight: pinned hugo $HUGO_PIN missing/wrong in $BIN" >&2; fail=1; }
|
||||
[ "$(pinned_ver node)" = "$NODE_PIN" ] || { echo "preflight: pinned node $NODE_PIN missing/wrong in $BIN" >&2; fail=1; }
|
||||
[ -n "$(pinned_ver npx)" ] || { echo "preflight: pinned npx missing in $BIN" >&2; fail=1; }
|
||||
[ "$(pinned_ver lychee)" = "$LYCHEE_PIN" ] || { echo "preflight: pinned lychee $LYCHEE_PIN missing/wrong in $BIN" >&2; fail=1; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "preflight: python3 not found on PATH (needed by serve-lan)" >&2; fail=1; }
|
||||
if [ "$fail" = 1 ]; then
|
||||
echo "preflight: fix with: make bootstrap" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "preflight: OK (pinned toolchain present in $BIN)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
row() { # row <tool> <pinned disp> <first disp> <first path>
|
||||
printf '%-8s %-27s %-27s %s\n' "$1" "$2" "$3" "${4:--}"
|
||||
}
|
||||
|
||||
echo "tool pinned (.tools/bin) PATH-first (your shell)"
|
||||
for t in hugo node npx lychee; do
|
||||
pv=$(pinned_ver "$t")
|
||||
fp=$(first_path "$t")
|
||||
fv=$(first_ver "$t" "$fp")
|
||||
[ -n "$pv" ] || pv="(missing)"
|
||||
[ -n "$fv" ] || fv="(not installed)"
|
||||
row "$t" "$pv" "$fv" "$fp"
|
||||
done
|
||||
fp=$(first_path python3); fv=$(first_ver python3 "$fp")
|
||||
[ -n "$fv" ] || fv="(not installed)"
|
||||
row "python3" "(not pinned)" "$fv" "$fp"
|
||||
echo
|
||||
echo "make targets always use the pinned column. 'make bootstrap' installs it."
|
||||
@@ -3,3 +3,20 @@
|
||||
// --pagefind-ui-background: #84DE02;
|
||||
// }
|
||||
// $pagefind-ui-background: #84DE02;
|
||||
|
||||
// Site-wide font: +1pt (16px -> 17px). Every rem-based theme size scales with this.
|
||||
:root {
|
||||
--font-size: 17px;
|
||||
}
|
||||
|
||||
// Widen the page so wide screens don't leave ~310px dead gutters on each side.
|
||||
// (1280px cap -> 1700px; on a 1920px display the gutters drop 313px -> ~103px.)
|
||||
$container-max-width: 100rem;
|
||||
|
||||
// Widen the left sidebar (the category list + search bar + search-results pane
|
||||
// are all this one column). 12.5rem -> 18.75rem (a 50% increase, per request).
|
||||
$menu-width: 18.75rem;
|
||||
|
||||
// $mobile-breakpoint in _defaults.scss is derived from the values above; recompute it
|
||||
// so the mobile layout still collapses at the right width.
|
||||
$mobile-breakpoint: $menu-width + $body-min-width * 1.2 + $toc-width;
|
||||
|
||||
@@ -32,13 +32,8 @@ https://github.com/alex-shpak/hugo-book
|
||||
{{- $styles := resources.Get "book.scss" | resources.ExecuteAsTemplate "book.scss" . | css.Sass | resources.Minify | resources.Fingerprint }}
|
||||
<link rel="stylesheet" href="{{ partial "docs/links/resource-precache" $styles }}" {{ template "integrity" $styles }}>
|
||||
|
||||
<!-- Search -->
|
||||
{{- if default true .Site.Params.BookSearch -}}
|
||||
{{- $searchJSFile := printf "%s.search.js" .Language.Lang }}
|
||||
{{- $searchJS := resources.Get "search.js" | resources.ExecuteAsTemplate $searchJSFile . | resources.Minify | resources.Fingerprint }}
|
||||
<script defer src="{{ "fuse.min.js" | relURL }}"></script>
|
||||
<script defer src="{{ partial "docs/links/resource-precache" $searchJS }}" {{ template "integrity" $searchJS }}></script>
|
||||
{{ end -}}
|
||||
<!-- Search: handled by Pagefind (see _partials/docs/search.html, injected into the
|
||||
sidebar via menu.html). The legacy Fuse.js search was removed to avoid double-loading. -->
|
||||
|
||||
<!-- Service Worker -->
|
||||
{{- if .Site.Params.BookServiceWorker -}}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{{ "<!--" | safeHTML }}
|
||||
Made with Book Theme
|
||||
https://github.com/alex-shpak/hugo-book
|
||||
{{ "-->" | safeHTML }}
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{ or .Description .Summary | plainify | htmlUnescape }}">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#343a40">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
|
||||
{{- with .Page.Params.BookHref -}}
|
||||
<meta http-equiv="Refresh" content="0; url='{{ . }}'" />
|
||||
{{- end -}}
|
||||
|
||||
{{ partial "opengraph.html" . }}
|
||||
{{ partial "schema.html" . }}
|
||||
|
||||
<title>{{ partial "docs/html-head-title" . }}</title>
|
||||
{{ partial "docs/html-head-favicon" . }}
|
||||
|
||||
{{- $manifest := resources.Get "manifest.json" | resources.ExecuteAsTemplate "manifest.json" . }}
|
||||
<link rel="manifest" href="{{ $manifest.RelPermalink }}">
|
||||
<link rel="canonical" href="{{ .Permalink }}">
|
||||
|
||||
{{- range .Translations }}
|
||||
<link rel="alternate" hreflang="{{ default .Site.LanguageCode .Language.Lang }}" href="{{ .Permalink }}" title="{{ partial "docs/title" . }}">
|
||||
{{- end -}}
|
||||
|
||||
<!-- Theme stylesheet, you can customize scss by creating `assets/custom.scss` in your website -->
|
||||
{{- $styles := resources.Get "book.scss" | resources.ExecuteAsTemplate "book.scss" . | css.Sass | resources.Minify | resources.Fingerprint }}
|
||||
<link rel="stylesheet" href="{{ partial "docs/links/resource-precache" $styles }}" {{ template "integrity" $styles }}>
|
||||
|
||||
<!-- Search -->
|
||||
{{- if default true .Site.Params.BookSearch -}}
|
||||
{{- $searchJSFile := printf "%s.search.js" .Language.Lang }}
|
||||
{{- $searchJS := resources.Get "search.js" | resources.ExecuteAsTemplate $searchJSFile . | resources.Minify | resources.Fingerprint }}
|
||||
<script defer src="{{ "fuse.min.js" | relURL }}"></script>
|
||||
<script defer src="{{ partial "docs/links/resource-precache" $searchJS }}" {{ template "integrity" $searchJS }}></script>
|
||||
{{ end -}}
|
||||
|
||||
<!-- Service Worker -->
|
||||
{{- if .Site.Params.BookServiceWorker -}}
|
||||
{{- $swJS := resources.Get "sw-register.js" | resources.ExecuteAsTemplate "sw.js" . | resources.Minify | resources.Fingerprint }}
|
||||
<script defer src="{{ $swJS.RelPermalink }}" {{ template "integrity" $swJS }}></script>
|
||||
{{ end -}}
|
||||
|
||||
{{ partial "google_analytics.html" . }}
|
||||
|
||||
<!-- RSS -->
|
||||
{{- with .OutputFormats.Get "rss" -}}
|
||||
{{ printf `<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
|
||||
{{ end -}}
|
||||
|
||||
{{- define "integrity" -}}
|
||||
{{- if (urls.Parse .Permalink).Host -}}
|
||||
integrity="{{ .Data.Integrity }}" crossorigin="anonymous"
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -1,10 +1,184 @@
|
||||
{{ if default true .Site.Params.BookSearch }}
|
||||
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
|
||||
<script src="/pagefind/pagefind-ui.js"></script>
|
||||
<style>
|
||||
/* Pagefind Default UI -> fit the sidebar. The #search id prefix gives id+class
|
||||
specificity that beats pagefind-ui.css's class-only rules no matter where that
|
||||
stylesheet loads (it loads in <body>, after the head CSS). */
|
||||
#search .pagefind-ui__search-input { font-size: 0.85em; height: 2.4em; min-height: 2.4em; padding: 0 2.6em 0 2.4em; }
|
||||
/* Magnifier: re-center it in the shrunken input; input padding-left 2.4em clears
|
||||
the icon's right edge. */
|
||||
#search .pagefind-ui__form:before { top: 7px; left: 12px; width: 14px; height: 14px; }
|
||||
/* Fade the magnifier out while the input has content (no :has() -> icon stays). */
|
||||
#search .pagefind-ui__form:has(.pagefind-ui__search-input:not(:placeholder-shown)):before { opacity: 0; }
|
||||
/* Clear button: the form is the absolute containing block and in 1.5.2 the results
|
||||
drawer is an in-flow child of it, so the form grows to the full results height —
|
||||
use fixed dimensions keyed to the INPUT (form top == input top). */
|
||||
#search .pagefind-ui__search-clear { top: 3px; right: 3px; height: 23px; font-size: 11px; padding: 0 6px; }
|
||||
/* Results clipping: stack the drawer, allow shrink in the narrow sidebar. */
|
||||
#search .pagefind-ui__drawer { flex-direction: column; gap: 0.4em; }
|
||||
#search .pagefind-ui__results-area { min-width: 0; }
|
||||
#search .pagefind-ui__result-excerpt { min-width: 0; }
|
||||
/* No images exist in this index, but Pagefind still renders an 81px empty
|
||||
.result-thumb on the left of every row, pushing the text ~90px right (the
|
||||
"right-justified" look). Hide it so results fill the pane from the left. */
|
||||
#search .pagefind-ui__result-thumb { display: none; }
|
||||
#search .pagefind-ui__result-title,
|
||||
#search .pagefind-ui__result-link { font-size: 1em; overflow-wrap: anywhere; }
|
||||
#search .pagefind-ui__result-excerpt{ font-size: 0.88em; overflow-wrap: anywhere; word-break: break-word; }
|
||||
#search .pagefind-ui__message { font-size: 0.8em; }
|
||||
/* Keyboard-navigation highlight. Must be unmistakable on the dark theme
|
||||
(bg #000, text #EEE): a solid slate fill + a bright 2px inset outline + white,
|
||||
bold text. The class is applied to ONE node per selection: a sub's
|
||||
.result-nested wrapper, or a top hit's .result-title line (see rowFor()). */
|
||||
#search .pf-kbd-selected {
|
||||
background: rgba(100,116,139,0.5);
|
||||
outline: 2px solid #cbd5e1;
|
||||
outline-offset: -2px;
|
||||
color: #ffffff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#search .pf-kbd-selected,
|
||||
#search .pf-kbd-selected .pagefind-ui__result-title,
|
||||
#search .pf-kbd-selected .pagefind-ui__result-link {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Matched-term <mark>: pagefind-ui.css resets `mark { all: revert }`, so matches
|
||||
otherwise render the UA default harsh yellow. Restyle to a subdued slate on the
|
||||
dark theme, scoped to the search results so page-content marks are untouched. */
|
||||
#search .pagefind-ui__result mark {
|
||||
background: rgba(100,116,139,0.45);
|
||||
color: #ffffff;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<div id="search"></div>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', (event) => {
|
||||
new PagefindUI({ element: "#search", showSubResults: true });
|
||||
new PagefindUI({
|
||||
element: "#search",
|
||||
showSubResults: true,
|
||||
focusOnSlash: true, // press "/" anywhere to focus the search box
|
||||
excerptLength: 20 // shorter excerpts fit the narrow sidebar
|
||||
});
|
||||
|
||||
// --- Keyboard navigation (Pagefind Default UI 1.5.2 has none) ---
|
||||
// ArrowDown/ArrowUp move a highlight over the result rows (wraparound,
|
||||
// sub-result rows included — DOM order equals visual order); Enter opens the
|
||||
// highlighted row, or the top result when nothing is highlighted. (Stock
|
||||
// 1.5.2 Enter is a silent no-op — verified in the built pagefind-ui.js.)
|
||||
//
|
||||
// Two fixes over the naive version, both needed for a PHYSICAL keyboard:
|
||||
// (1) The link set is re-read from the LIVE DOM on every key press. Caching
|
||||
// a NodeList that Pagefind's async re-render later detaches means the
|
||||
// highlight/Enter act on a dead node and nothing appears.
|
||||
// (2) The MutationObserver reset is fingerprinted. Pagefind patches the
|
||||
// results subtree (excerpts, Svelte reactivity) right after a key press;
|
||||
// an unguarded childList observer would fire and clear the selection the
|
||||
// user just made. We only reset when the actual set of result hrefs
|
||||
// changes (a new query / rebuilt list), and otherwise re-assert the
|
||||
// highlight on the live row so a rebuilt <li> never loses it.
|
||||
(function () {
|
||||
var searchEl = document.getElementById('search');
|
||||
if (!searchEl) return;
|
||||
var SELECTED = 'pf-kbd-selected';
|
||||
var index = -1;
|
||||
var selFp = null; // fingerprint of the link set when we last applied a selection
|
||||
|
||||
function liveLinks() {
|
||||
var resultsEl = searchEl.querySelector('.pagefind-ui__results');
|
||||
if (!resultsEl) return [];
|
||||
// Only interactive when actually rendered/visible. offsetParent is
|
||||
// non-null here (it resolves to the form), but also accept a visible
|
||||
// bounding box so a position:fixed sidebar can never null it out.
|
||||
var visible = (resultsEl.offsetParent !== null) ||
|
||||
(resultsEl.getBoundingClientRect().height > 0);
|
||||
if (!visible) return [];
|
||||
return Array.from(searchEl.querySelectorAll('.pagefind-ui__results .pagefind-ui__result-link'));
|
||||
}
|
||||
function fp(links) {
|
||||
return links.map(function (l) { return l.getAttribute('href'); }).join('\u0001');
|
||||
}
|
||||
function rowFor(link) {
|
||||
if (!link.closest) return null;
|
||||
// Sub-results: highlight this sub's OWN wrapper block, so each
|
||||
// sub-topic (e.g. hacking -> anonymity / osint / courses) gets its
|
||||
// own row rather than all of them re-lighting the parent <li>.
|
||||
var nested = link.closest('.pagefind-ui__result-nested');
|
||||
if (nested) return nested;
|
||||
// Top-level hit: highlight just its title line. Highlighting the
|
||||
// whole .pagefind-ui__result would also swallow its nested subs.
|
||||
return link.closest('.pagefind-ui__result-title') ||
|
||||
link.closest('.pagefind-ui__result');
|
||||
}
|
||||
function clearAll() {
|
||||
// The class can sit on a .result-nested, a .result-title, or a
|
||||
// .result — clear it wherever it landed.
|
||||
searchEl.querySelectorAll('.' + SELECTED)
|
||||
.forEach(function (r) { r.classList.remove(SELECTED); });
|
||||
}
|
||||
function deselect() {
|
||||
clearAll();
|
||||
index = -1;
|
||||
selFp = null;
|
||||
}
|
||||
function move(dir) {
|
||||
var links = liveLinks();
|
||||
var n = links.length;
|
||||
if (!n) return;
|
||||
index = index < 0 ? (dir > 0 ? 0 : n - 1) : (index + dir + n) % n;
|
||||
clearAll();
|
||||
var row = rowFor(links[index]);
|
||||
if (row) {
|
||||
row.classList.add(SELECTED);
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
selFp = fp(links);
|
||||
}
|
||||
|
||||
// Capture phase on #search so this runs BEFORE Svelte's bubbling
|
||||
// keydown/submit handlers on the input.
|
||||
searchEl.addEventListener('keydown', function (e) {
|
||||
var input = searchEl.querySelector('.pagefind-ui__search-input');
|
||||
if (e.target !== input) return;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
if (!liveLinks().length) return; // no results yet: leave default alone
|
||||
e.preventDefault();
|
||||
move(e.key === 'ArrowDown' ? 1 : -1);
|
||||
} else if (e.key === 'Enter') {
|
||||
var links = liveLinks();
|
||||
if (!links.length) return;
|
||||
// Use the highlighted row, unless the selection went stale (the
|
||||
// result set changed under us and the MO hasn't caught up yet).
|
||||
var stale = (index < 0 || index >= links.length) ||
|
||||
(selFp !== null && selFp !== fp(links));
|
||||
var href = stale
|
||||
? links[0].getAttribute('href')
|
||||
: links[index].getAttribute('href');
|
||||
if (href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
location.href = href;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Keep the selection in sync with Pagefind re-renders without nuking it.
|
||||
var mo = new MutationObserver(function () {
|
||||
if (index < 0) return; // nothing selected -> nothing to keep in sync
|
||||
var links = liveLinks();
|
||||
if (fp(links) !== selFp) { deselect(); return; } // result set changed
|
||||
// Same result set, but Svelte may have rebuilt the <li> nodes:
|
||||
// re-assert the highlight on the live row at the current index.
|
||||
clearAll();
|
||||
var row = rowFor(links[index]);
|
||||
if (row) row.classList.add(SELECTED);
|
||||
});
|
||||
mo.observe(searchEl, { childList: true, subtree: true });
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ end }}
|
||||
|
||||
{{ define "main" }}
|
||||
<article class="markdown book-article">
|
||||
<article class="markdown book-article" data-pagefind-body>
|
||||
{{- .Content -}}
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{ define "main" }}
|
||||
{{ range sort .Paginator.Pages }}
|
||||
<article class="book-post markdown">
|
||||
<article class="book-post markdown" data-pagefind-body>
|
||||
<h2>
|
||||
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{ define "main" }}
|
||||
<article class="markdown book-post">
|
||||
<article class="markdown book-post" data-pagefind-body>
|
||||
<h1>
|
||||
{{ partial "docs/title" . }}
|
||||
</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{ define "main" }}
|
||||
{{ range sort .Paginator.Pages }}
|
||||
<article class="markdown book-post">
|
||||
<article class="markdown book-post" data-pagefind-body>
|
||||
<h2>
|
||||
<a href="{{ .RelPermalink }}">{{ partial "docs/title" . }}</a>
|
||||
</h2>
|
||||
|
||||
Reference in New Issue
Block a user