Compare commits
55
Commits
5a9106b61e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f9162872 | ||
|
|
611f0887c4 | ||
|
|
e38afb523c | ||
|
|
7fda0455c1 | ||
|
|
0d344a4f57 | ||
|
|
84539ec40f | ||
|
|
97fc024a38 | ||
|
|
7215cd6db0 | ||
|
|
8fa342e80d | ||
|
|
9029ead53a | ||
|
|
1709cb78a1 | ||
|
|
e257d58541 | ||
|
|
df9597682c | ||
|
|
f5faa8a3af | ||
|
|
edba66239e | ||
|
|
9c0f33e6ca | ||
|
|
b2bd6bc3aa | ||
|
|
ef8407b272 | ||
|
|
13e3186f58 | ||
|
|
3ef38bc8fc | ||
|
|
09da15c1df | ||
|
|
e292b4ebfb | ||
|
|
b72446b80e | ||
|
|
44a343ae72 | ||
|
|
cc03cefe8d | ||
|
|
c04d8c856c | ||
|
|
4e7798c906 | ||
|
|
a6a7b3a8c7 | ||
|
|
a72d73f21c | ||
|
|
2ccbe28774 | ||
|
|
2ba0b7bc48 | ||
|
|
3eae3afcbb | ||
|
|
a0692056bc | ||
|
|
be80c89548 | ||
|
|
e3fc734862 | ||
|
|
4b4d0c491c | ||
|
|
9730987846 | ||
|
|
f54ce2fab7 | ||
|
|
cb13ccecab | ||
|
|
abb712f8a2 | ||
|
|
754c8fa1b9 | ||
|
|
a9a02aa9ba | ||
|
|
96278e0d02 | ||
|
|
5db2814c2e | ||
|
|
068911b4e8 | ||
|
|
0b815dc66a | ||
|
|
ea588bd670 | ||
|
|
888aa8b00b | ||
|
|
cbbdaae53d | ||
|
|
cee64b9f74 | ||
|
|
e16513f195 | ||
|
|
792516919a | ||
|
|
a1ff6dbbac | ||
|
|
9fdb003521 | ||
|
|
d8cf81aeec |
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# pre-push: the Lens pass. Runs `make verify` (cheap gate + heavy sensors + both
|
||||
# test suites) and blocks the push if anything fails.
|
||||
#
|
||||
# Install with `make hooks` (symlinks this file into this clone's git hooks
|
||||
# directory). Deliberate bypass: `git push --no-verify`.
|
||||
#
|
||||
# Why a symlink and not `git config core.hooksPath .githooks`: bd keeps its own
|
||||
# managed hooks in .git/hooks (pre-commit flushes .beads/issues.jsonl, post-merge
|
||||
# imports it). Pointing core.hooksPath elsewhere would silently retire them, and
|
||||
# bd would still report its hooks as installed. Installing one symlink per hook
|
||||
# in .githooks/ keeps every hook manager's files where they expect them.
|
||||
#
|
||||
# stdin carries "<local ref> <local sha> <remote ref> <remote sha>" per ref being
|
||||
# pushed; the Lens pass is repo-wide, so it is deliberately not read.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if ! cd "$(git rev-parse --show-toplevel)"; then
|
||||
echo "pre-push: cannot find the repository root; refusing to let this push through" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v make >/dev/null 2>&1; then
|
||||
echo "pre-push: make not found on PATH — the Lens pass cannot run, so the push is blocked" >&2
|
||||
echo "pre-push: install make, or bypass deliberately with: git push --no-verify" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log=$(git rev-parse --git-dir)/lens-pass.log
|
||||
echo "pre-push: running the Lens pass (make verify) — full log: $log"
|
||||
|
||||
set -o pipefail
|
||||
if make verify 2>&1 | tee "$log"; then
|
||||
echo "pre-push: Lens pass passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
last_step=$(grep -E '^==> ' "$log" | tail -n 1)
|
||||
{
|
||||
echo
|
||||
echo "=================================================================="
|
||||
echo "pre-push: LENS PASS FAILED — push blocked"
|
||||
echo " last step started: ${last_step:-unknown (see log)}"
|
||||
echo " full log: $log"
|
||||
echo " triage the failure (real bug / environment-dependent test /"
|
||||
echo " design nit — see AGENTS.md), then re-run: make verify"
|
||||
echo " deliberate bypass: git push --no-verify"
|
||||
echo "=================================================================="
|
||||
} >&2
|
||||
exit 1
|
||||
@@ -25,3 +25,4 @@ Screenshot*.png
|
||||
|
||||
# nono sandbox scratch space
|
||||
nono/
|
||||
/.DS_Store
|
||||
|
||||
+58
-23
@@ -46,7 +46,7 @@ Build an understanding of what to demo from these sources (in priority order):
|
||||
Explicit scenario (--scenario): If provided, use as primary guide.
|
||||
Plan file: Read the plan's Goal, Acceptance Criteria, and completed Steps.
|
||||
Recent commits: Run git log --oneline -15 and git diff main...HEAD --stat to identify changed files and commit messages.
|
||||
Route map: Cross-reference changed files against known LiveView routes in the router (lib/hub_web/router.ex).
|
||||
Route map: Find the router yourself - do not assume a path. Search the project for its router (`grep -rn "use Phoenix.Router" lib/`; conventionally lib/<app>_web/router.ex), then read the scopes, pipelines and live_sessions to learn which routes the feature touches, which are public, and which require a logged-in user.
|
||||
|
||||
From these sources, produce a demo outline:
|
||||
|
||||
@@ -56,42 +56,77 @@ From these sources, produce a demo outline:
|
||||
Interactions: any clicks, form fills, or navigation sequences to perform
|
||||
|
||||
If no plan exists and commits are ambiguous, ask the user for a brief description of what to demo.
|
||||
3. Check dev server
|
||||
3. Resolve the dev port, then check the server
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ 2>/dev/null
|
||||
Never assume a port. Read it from the project config, from the repo root:
|
||||
|
||||
DEMO_PORT=$(cd app && mix run --no-start -e \
|
||||
'IO.puts(Application.get_env(:firehose, FirehoseWeb.Endpoint)[:http][:port])')
|
||||
DEMO_BASE_URL="http://localhost:${DEMO_PORT}"
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" "$DEMO_BASE_URL/"
|
||||
|
||||
Adapt that command to the project: find the app/endpoint pair in `config/*.exs`
|
||||
(`grep -rn "Endpoint," config/`) and use the same names. If the config reads the
|
||||
port from the environment, the value above already reflects it. Do not resolve
|
||||
the port with `<Endpoint>.config(:http)`: with `--no-start` the endpoint's ETS
|
||||
table does not exist and it raises.
|
||||
|
||||
If the project pins a toolchain or wraps builds, use its wrapper for every
|
||||
command below (`mise exec -- mix ...`, asdf shims, or its `make` targets - check
|
||||
AGENTS.md/README first). Bare `mix` is not a safe assumption.
|
||||
|
||||
If the server is not reachable:
|
||||
|
||||
The Phoenix dev server is not running. Start it now?
|
||||
|
||||
mix phx.server &
|
||||
mkdir -p .tmp && (cd app && PORT=$DEMO_PORT mix phx.server \
|
||||
> ../.tmp/phx-demo.log 2>&1 &)
|
||||
|
||||
After starting, wait up to 10 seconds and verify connectivity. If it still fails, proceed with backend-only evidence (skip all browser screenshots) and note the limitation in the demo document.
|
||||
4. Handle authentication
|
||||
Then poll `$DEMO_BASE_URL` for up to 10 seconds. If it still fails, read
|
||||
`.tmp/phx-demo.log`: an error about an unresolvable JS module means the
|
||||
project's JS packages are not installed - run its assets install step (in this
|
||||
repository `make -C app assets`, which root `make deps` also runs). Only when
|
||||
the server really cannot come up, proceed with backend-only evidence (skip all
|
||||
browser screenshots) and note the limitation in the demo document.
|
||||
|
||||
The app requires authentication for LiveView routes. Before capturing UI screenshots:
|
||||
Use `$DEMO_BASE_URL` for every URL below - there is no default port to fall
|
||||
back on.
|
||||
4. Handle authentication (only if the route needs it)
|
||||
|
||||
Ensure a demo user exists:
|
||||
Decide this from the project, not from this file. If every page you need is
|
||||
public, skip this step. A route needs a session when the router found in step 2
|
||||
puts it inside a `pipe_through` that requires a logged-in user, or inside a
|
||||
`live_session` guarded that way.
|
||||
|
||||
mix run -e "
|
||||
alias Hub.Accounts
|
||||
case Accounts.get_user_by_email(\"demo@example.com\") do
|
||||
nil -> Accounts.register_user(%{email: \"demo@example.com\", password: \"demodemo1234\"})
|
||||
user -> {:ok, user}
|
||||
end
|
||||
"
|
||||
When you do need a session, read each part from the project instead of assuming
|
||||
it:
|
||||
|
||||
Log in via rodney:
|
||||
- Credentials: check README, AGENTS.md, docs and the project's seeds
|
||||
(`priv/repo/seeds.exs`, a `mix demo`-style task). Use documented demo
|
||||
credentials if they exist - never invent them.
|
||||
- Demo user: if nothing is documented, create one through the project's own
|
||||
path, in this order: run its seeds; use its registration route; call the
|
||||
accounts/context function you find by searching for it (`grep -rn
|
||||
"get_user_by_email\|register_user\|create_user" lib/`). Copy the exact module,
|
||||
function and field names from the source; do not guess an alias.
|
||||
- Login route, form fields and submit button: read them off the router and out of
|
||||
the rendered form (`rodney html form` or `rodney ax-tree`) rather than
|
||||
assuming a path or an input name.
|
||||
|
||||
Then drive the form with the selectors you just read:
|
||||
|
||||
rodney start
|
||||
rodney open http://localhost:4000/users/log-in
|
||||
rodney wait "input[name='user[email]']"
|
||||
rodney input "input[name='user[email]']" "demo@example.com"
|
||||
rodney input "input[name='user[password]']" "demodemo1234"
|
||||
rodney click "button[type='submit']"
|
||||
rodney open "$DEMO_BASE_URL<login-route>"
|
||||
rodney wait "<email-input-selector>"
|
||||
rodney input "<email-input-selector>" "<email>"
|
||||
rodney input "<password-input-selector>" "<password>"
|
||||
rodney click "<submit-selector>"
|
||||
rodney waitidle
|
||||
|
||||
If login fails, warn and proceed with backend-only evidence.
|
||||
Confirm the session took - `rodney url` should have left the login route, and
|
||||
the page should show the user (name, avatar, menu). If login fails, warn, note it
|
||||
in the demo document, and proceed with backend-only evidence.
|
||||
5. Initialize the demo document
|
||||
|
||||
Slugify the feature name (lowercase, hyphens, no special chars). Then:
|
||||
@@ -136,7 +171,7 @@ For each UI page identified in step 2, navigate with rodney, screenshot, and emb
|
||||
|
||||
Static page capture:
|
||||
|
||||
rodney open http://localhost:4000/<route>
|
||||
rodney open "$DEMO_BASE_URL/<route>"
|
||||
rodney waitidle
|
||||
rodney screenshot demos/screenshots/<feature>-<page-name>.png
|
||||
|
||||
|
||||
@@ -26,11 +26,24 @@ firehose/
|
||||
│ ├── lib/
|
||||
│ └── mix.exs
|
||||
├── mise.toml # Runtime versions (Elixir, Erlang, Node)
|
||||
└── Makefile # make (test, check)
|
||||
├── Dockerfile # Deploy build; ARG pins must match mise.toml
|
||||
├── scripts/check-pins.sh # Verifies the pin sync; runs in `make check`
|
||||
├── .githooks/pre-push # Lens pass on push; install once with `make hooks`
|
||||
└── Makefile # make (check, verify, test, hooks)
|
||||
```
|
||||
|
||||
Two blogs are configured:
|
||||
|
||||
# Toolchain pins
|
||||
|
||||
`mise.toml` and the `Dockerfile` ARG pins must agree; `scripts/check-pins.sh`
|
||||
(runs as part of `make check`) enforces this. When bumping versions, change
|
||||
both files in one commit. Upstream drift (newer Elixir/OTP/Debian releases)
|
||||
is reported by `dokku-updates firehose check` (dokku-maintenance repo).
|
||||
Upgrading pins or mix packages is largely unscripted: for such tasks, a Pi
|
||||
sub agent can be started in this directory — it reads this AGENTS.md and
|
||||
follows the zero-defects policy above.
|
||||
|
||||
| Blog | Route | Description |
|
||||
|------|-------|-------------|
|
||||
| Engineering | `/blog/engineering` | Main blog |
|
||||
@@ -57,9 +70,143 @@ This is a Phoenix Liveview monorepo, the blogging library is in blogex, the appl
|
||||
|
||||
Main make targets:
|
||||
|
||||
- make test
|
||||
- make check - runs credo static analysis and suggests refactorings
|
||||
- `make setup` - one-time clone setup (and after any mix.exs gains a dep):
|
||||
fetches Hex deps for both apps, the app's JS packages, and compiles deps
|
||||
(some deps fetch artifacts at compile time, e.g. the mdex_native NIF).
|
||||
Needs network - run it yourself; agent sandboxes refuse installs.
|
||||
check/test/verify never touch the network: they run a local `deps.check`
|
||||
guard that fails with a pointer here when deps are missing.
|
||||
- `make check` - cheap static gate for both apps (no DB): app `credo --strict` +
|
||||
format **check** (`mix format --check-formatted`), blogex compile (warnings
|
||||
fatal) + format **check**, then `scripts/check-pins.sh`. It never rewrites
|
||||
files.
|
||||
- `make precommit` - alias of `check`; fast, safe to run on every commit
|
||||
- `make test` - both suites (app needs PostgreSQL on localhost:5432)
|
||||
- `make verify` - the Lens pass: `check`, then per-app heavy sensors and both suites
|
||||
- `make hooks` - install `.githooks/*` into this clone (once per clone)
|
||||
|
||||
# Verification: fast loop vs Lens pass
|
||||
|
||||
Two loops, deliberately split so commits stay cheap and pushes stay honest.
|
||||
|
||||
**Fast loop (every commit).** `make check` is static analysis only: no database,
|
||||
no coverage, about a second. `make precommit` is the same target. Per app,
|
||||
`mix precommit` (run inside `app/` or `blogex/`) is the fuller loop: compile with
|
||||
warnings fatal, format, test. Run these as often as you like while working.
|
||||
|
||||
`make check` never writes files. A formatting failure means run `make format`
|
||||
(both apps). The mutating loops are the explicit ones: `make format` and the
|
||||
per-app `mix precommit`, which still formats as you work.
|
||||
|
||||
**Lens pass (every push, and before handing off).** `make verify` runs `check`
|
||||
first (cheapest), then per app: one suite run that exports its coverage, then
|
||||
that app's sensors (sobelow, coverage, CRAP; mutation as that yak lands), which
|
||||
run cheapest-first and read the export rather than re-running the suite. Measured
|
||||
2026-11-01: ~11s wall (app 178 tests + blogex 156 tests + coverage floor).
|
||||
|
||||
`.githooks/pre-push` runs `make verify` and blocks the push when anything fails.
|
||||
Install it once per clone:
|
||||
|
||||
make hooks
|
||||
|
||||
It symlinks `.githooks/*` into the clone's hooks directory; `core.hooksPath` is
|
||||
deliberately left alone so bd's own hooks (pre-commit JSONL flush, post-merge
|
||||
import) keep firing. On failure the hook prints one summary - last step started,
|
||||
log path, bypass - and exits non-zero; the full output is in `.git/lens-pass.log`.
|
||||
Deliberate override (e.g. pushing a branch you know is red): `git push --no-verify`.
|
||||
|
||||
## Lens practice
|
||||
|
||||
The Lens pass is a *separate pass with its own context*. It is not a way to find
|
||||
work mid-feature: finish and commit what you are building, then run it.
|
||||
|
||||
- Do not chase hook or `credo` output inline while building a feature. If
|
||||
something interesting surfaces, note it and file a yak; take it up in a Lens
|
||||
session.
|
||||
- A failing Lens pass blocks the push, and therefore the hand-off. Triage it;
|
||||
never re-run until green and never ignore it.
|
||||
- Adding a sensor: implement it as its own make target, add it to that app's
|
||||
`VERIFY_SENSORS` in `app/Makefile` / `blogex/Makefile` (cheapest first), and
|
||||
update the slot comments there. A gate that reports a check it did not run is
|
||||
worse than no gate - the `VERIFY_SENSORS` list is what gets echoed. Sensors run
|
||||
*after* the app's `test.coverage` step, because the coverage export they read is
|
||||
produced by that suite run; if a check can pass without its input, make it fail
|
||||
loudly (see the `coverage` target's guard).
|
||||
|
||||
### Triage classes
|
||||
|
||||
| Class | Looks like | What to do |
|
||||
|-------|-----------|------------|
|
||||
| *Real bug* | behavior is wrong; a test fails for a real reason; a sensor reports a real risk | fix inline, with a test that pins the behavior |
|
||||
| *Environment-dependent test* | passes alone, fails in the pass; depends on ambient state (rows left behind, wall clock, ordering, network, locale) | the test is the bug: make it deterministic by construction - own fixtures, explicit clock, no ambient state as fixtures. Never retry-until-green |
|
||||
| *Style or design nit* | credo or format suggestion, naming, structure, "this could be smaller" | do not refactor mid-build: note it and file a yak; the Lens session decides |
|
||||
|
||||
Reconciling this with the zero-defects policy: "never continue when there are
|
||||
errors" governs *what you do with a failure* - report, triage, decide - not
|
||||
*when* you fix it. Never silently absorb a finding, and never let a pre-existing
|
||||
failure ride along: if it predates your change, stop and discuss it with the user
|
||||
rather than working around it.
|
||||
|
||||
Browser tests (`make playwright*`) are *external* verification and are **not**
|
||||
part of the Lens pass. Decided 2026-10-28 (human, yak "ratchets, deferred
|
||||
decisions, handoff practice"): it stays that way - Playwright is not wired into
|
||||
`make verify` or into either `VERIFY_SENSORS`. The targets need a running
|
||||
Phoenix server or the Docker Chrome container, and a gate that only runs with
|
||||
docker or sudo is not a gate the Lens pass can rely on. Run them as their own
|
||||
loop, by hand, when a change touches the browser surface.
|
||||
|
||||
### Landing the plane
|
||||
|
||||
The Lens pass is what you land with, so it is the last step of every session,
|
||||
not a thing that only happens on push:
|
||||
|
||||
- After any session that changed code, run `make precommit`. It is `make check`
|
||||
under its session-ending name and costs about a second; there is no session
|
||||
where it is not worth running.
|
||||
- Before handing off - to a human, a reviewer, or the next agent - run
|
||||
`make verify`, or push, which fires `.githooks/pre-push` and runs the same
|
||||
pass on the same tree. A hand-off note that says "tests pass" without a Lens
|
||||
pass behind it is a guess, not a hand-off.
|
||||
- A failing Lens pass is triaged into the classes above (see *Triage classes*)
|
||||
and **reported** in the hand-off note, with the class it fell in. A nit gets a
|
||||
yak; it does not get fixed inline mid-build. Never re-run until green.
|
||||
- `git push --no-verify` stays the deliberate override for a branch you know is
|
||||
red. It is never how a session lands.
|
||||
|
||||
## Ratchets
|
||||
|
||||
The coverage threshold in `app/mix.exs` and `blogex/mix.exs` (the `threshold:`
|
||||
inside each `test_coverage/0`) is a **floor**, not a target:
|
||||
|
||||
- After each behavior-test yak lands, re-measure both apps - `make test.coverage`
|
||||
then `make coverage` inside each app - and raise the floor to the new total.
|
||||
The convention is integer truncation of the measurement: a measured 82.61%
|
||||
means a floor of 82. Never above the measurement: a floor that cannot pass is
|
||||
not a ratchet, it is a red gate.
|
||||
- Never lower a floor. A legitimate drop is a conversation, not a config edit.
|
||||
Lowering the number, or adding an `ignore_modules` entry to prop it up, hides
|
||||
the behavior gap the floor exists to surface.
|
||||
- The record of the last measurement and its date is the comment above
|
||||
`test_coverage/0` in that app's `mix.exs`. Update it there, in the same commit
|
||||
as the number. Do not copy the totals into this file: one record, in the
|
||||
place the gate reads, is the whole point.
|
||||
|
||||
Two further health numbers are baselines-to-be; neither is `make verify`'s
|
||||
business yet, and neither is to be invented:
|
||||
|
||||
- **CRAP offender count.** `make crap` in one app is the sensor: a passing run
|
||||
prints `Summary: ... worst_score=...`, a failing run prints `High scores: N`
|
||||
and the offending functions. Both apps sit under the default `--max-score
|
||||
(30)`, and the decision (2026-10-28) not to lower it, plus the baseline
|
||||
offender count, live in the comment above the `crap` target in
|
||||
`app/Makefile` / `blogex/Makefile`.
|
||||
- **Mutation survivor count.** Mutation is a slot comment only in
|
||||
`app/Makefile` and `blogex/Makefile` ("yak: mutation testing with muex - a
|
||||
sensor, not a gate"), and `muex` is a dependency of neither app. Nothing has
|
||||
been mutated, so there is no survivor baseline; do not conjure one from a run
|
||||
that did not happen. When the mutation yak lands, its survivors become the
|
||||
baseline the same way the coverage floor did: measured, dated, recorded where
|
||||
the sensor lives.
|
||||
|
||||
# Development
|
||||
Always fix failing tests, credo issues and format isseus.
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
# Dockerfile for Dokku deployment
|
||||
# Multi-stage build for Phoenix/Elixir app with monorepo layout
|
||||
|
||||
ARG ELIXIR_VERSION=1.18.3
|
||||
ARG OTP_VERSION=27.2.4
|
||||
ARG DEBIAN_VERSION=bookworm-20260316-slim
|
||||
ARG ELIXIR_VERSION=1.20.4
|
||||
ARG OTP_VERSION=27.3.4.19
|
||||
ARG DEBIAN_VERSION=bookworm-20261020-slim
|
||||
|
||||
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
# Makefile for Firehose monorepo
|
||||
|
||||
.PHONY: check precommit deps compile test test-elisp format
|
||||
MISE_BIN ?= mise
|
||||
|
||||
# Common check target that runs all static analysis
|
||||
.PHONY: check precommit verify hooks deps setup compile test test-elisp format
|
||||
|
||||
# Cheap static gate over both apps, cheapest first. No database, no coverage:
|
||||
# this is the loop you run freely while working and before every commit.
|
||||
check:
|
||||
@echo "Running static analysis..."
|
||||
@make -C app MISE_BIN=mise check
|
||||
@echo "==> check: app (credo --strict, format)"
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) check
|
||||
@echo "==> check: blogex (compile, format)"
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) check
|
||||
@echo "==> check: toolchain pins"
|
||||
@bash scripts/check-pins.sh
|
||||
@echo "==> check: Lens sensor wiring"
|
||||
@bash scripts/check-lens-sensors.sh
|
||||
|
||||
# Precommit target for CI/pre-commit hooks
|
||||
precommit: check
|
||||
|
||||
# Sync dependencies
|
||||
deps:
|
||||
@make -C app deps
|
||||
# The Lens pass: everything `check` does, plus, per app, one suite run (which
|
||||
# exports its coverage) and that app's sensors reading the export. Sensors run
|
||||
# cheapest-first among themselves. `.githooks/pre-push` runs this before every
|
||||
# push (see `make hooks`); run it yourself before handing off. Bypass
|
||||
# deliberately with `git push --no-verify`.
|
||||
verify: check
|
||||
@echo "==> verify: app"
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) verify
|
||||
@echo "==> verify: blogex"
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) verify
|
||||
|
||||
# Install the versioned hooks in .githooks/ into this clone's hooks directory as
|
||||
# symlinks (edits take effect immediately, no copy to drift). Idempotent; re-run
|
||||
# after moving the clone, since the links are absolute.
|
||||
#
|
||||
# core.hooksPath is deliberately NOT changed: bd manages its own hooks inside
|
||||
# .git/hooks (pre-commit flushes .beads/issues.jsonl, post-merge imports it) and
|
||||
# moving the hooks path would silently retire them.
|
||||
hooks:
|
||||
@hooks_dir=$$(git rev-parse --git-path hooks); \
|
||||
root=$$(git rev-parse --show-toplevel); \
|
||||
mkdir -p "$$hooks_dir"; \
|
||||
for hook in .githooks/*; do \
|
||||
name=$$(basename "$$hook"); \
|
||||
ln -sfn "$$root/$$hook" "$$hooks_dir/$$name"; \
|
||||
echo "installed $$hooks_dir/$$name -> $$hook"; \
|
||||
done
|
||||
|
||||
# One-time setup for this clone (and after any mix.exs gains a dependency):
|
||||
# fetches Hex deps for both apps and the app's JS packages. Needs network, and
|
||||
# is deliberately NOT wired into check/test/verify: `mix deps.get` contacts
|
||||
# hex.pm even when everything is already installed (the advisory check), which
|
||||
# fails offline/sandboxed. The everyday loops run the per-app `deps.check`
|
||||
# guard instead and point here when deps are missing.
|
||||
setup:
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) deps
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) deps
|
||||
|
||||
# Kept for muscle memory; `setup` is the canonical entry point.
|
||||
deps: setup
|
||||
|
||||
# Compile the project
|
||||
compile:
|
||||
@@ -23,13 +69,11 @@ test:
|
||||
@make -C app test
|
||||
@make -C blogex test
|
||||
|
||||
# Run Emacs Lisp tests for roam-export-blog (requires emacs on PATH)
|
||||
test-elisp:
|
||||
emacs --batch -Q -l org -l ox-md -l scripts/roam-export-blog.el -l scripts/roam-export-blog-test.el -f ert-run-tests-batch-and-exit
|
||||
|
||||
# Format code
|
||||
# Format code in both apps. Mutating convenience target: `check` (and so
|
||||
# `verify`) never writes. If a gate fails on formatting, run this.
|
||||
format:
|
||||
@make -C app format
|
||||
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) format
|
||||
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) format
|
||||
|
||||
# Serve the app
|
||||
serve:
|
||||
|
||||
@@ -25,7 +25,9 @@ firehose/
|
||||
├── blogex/ # Blogex library (multi-blog engine)
|
||||
│ ├── lib/
|
||||
│ └── mix.exs
|
||||
└── mise.toml # Runtime versions (Elixir, Erlang, Node)
|
||||
├── Dockerfile # Deploy build; ARG pins must match mise.toml
|
||||
├── mise.toml # Runtime versions (Elixir, Erlang, Node)
|
||||
└── scripts/check-pins.sh # Verifies the pin sync; runs in `make check`
|
||||
```
|
||||
|
||||
Two blogs are configured:
|
||||
@@ -51,6 +53,15 @@ mise install
|
||||
|
||||
This installs Elixir, Erlang, and Node at the versions specified in `mise.toml`.
|
||||
|
||||
## Toolchain pins
|
||||
|
||||
`mise.toml` (local dev toolchain) and the `Dockerfile` `ARG` pins (deploy
|
||||
builder image) must agree — local builds and deployed images should run the
|
||||
same Elixir/OTP. `make check` runs `scripts/check-pins.sh` to verify this;
|
||||
when it reports a MISMATCH, fix the file it names. Upstream drift of these
|
||||
pins (newer Elixir/OTP/Debian releases) is reported separately by
|
||||
`dokku-updates firehose check` from the dokku-maintenance repo.
|
||||
|
||||
### 2. Start PostgreSQL
|
||||
|
||||
You need PostgreSQL running locally with a `postgres` user (password: `postgres`).
|
||||
|
||||
+24
-1
@@ -4,6 +4,13 @@
|
||||
# If you find anything wrong or unclear in this file, please report an
|
||||
# issue on GitHub: https://github.com/rrrene/credo/issues
|
||||
#
|
||||
# The gate for this app is `mix credo --strict` on the command line (the
|
||||
# `credo` target in Makefile, a prerequisite of `check`). The command line, not
|
||||
# this file, decides: `--strict` overrides the inert `strict: false` key below.
|
||||
# Editing `strict` here to silence a finding would change nothing - fix the
|
||||
# finding, or write down why the check is wrong for this codebase (see
|
||||
# `disabled:`).
|
||||
#
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
@@ -41,6 +48,11 @@
|
||||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
# This is the ONLY load path for lib_dev/ checks: the directory is kept out
|
||||
# of elixirc_paths in mix.exs on purpose, so the module is not also loaded
|
||||
# from a compiled .beam (that double load produced
|
||||
# "warning: redefining module Firehose.Checks.NoConnShadowing").
|
||||
#
|
||||
requires: ["lib_dev/firehose/checks/"],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
@@ -121,7 +133,6 @@
|
||||
#
|
||||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
@@ -169,6 +180,18 @@
|
||||
{Firehose.Checks.NoConnShadowing, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Our one deliberate deviation from credo's defaults. CRAP (`ex_crap`,
|
||||
# threshold 30, wired into the Lens pass in Makefile) is this app's
|
||||
# complexity sensor, and it scores complexity against real coverage: a
|
||||
# wide `case` that the tests exercise is not a problem - that was the
|
||||
# itch behind ExCrap. A second, coverage-blind complexity sensor would
|
||||
# just argue with the first, so complexity questions are settled by
|
||||
# the CRAP target. Re-enabling this check without retiring the CRAP
|
||||
# target is a policy change, not a cleanup.
|
||||
#
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Sobelow per-finding skips. Enabled by `--skip` in `app/Makefile`.
|
||||
#
|
||||
# Rule: every entry is preceded by a rationale comment saying what the check
|
||||
# cannot see and why the protection is real (or why the finding is a false
|
||||
# positive). An unexplained skip is a defect; `scripts/check-lens-sensors.sh`
|
||||
# enforces this. Fingerprints are sobelow's own; a code change that moves a
|
||||
# skip's line invalidates it and the sensor fails loudly rather than staying
|
||||
# quiet.
|
||||
#
|
||||
# Baseline 2026-10-28, sobelow 0.17.0: 3 findings (2 High, 1 Low), all skipped.
|
||||
|
||||
# Config.CSP - High - lib/firehose_web/router.ex:13 (pipeline :browser)
|
||||
# No CSP exists. put_secure_browser_headers does not set one and the root
|
||||
# layout has an inline theme script with no nonce, so choosing a policy is a
|
||||
# design decision rather than a mechanical fix.
|
||||
# Follow-up yak: app: add a Content-Security-Policy (sobelow Config.CSP)
|
||||
Config.CSP: Missing Content-Security-Policy,lib/firehose_web/router.ex:13,486EC13
|
||||
|
||||
# Config.HTTPS - High - config/prod.exs
|
||||
# No force_ssl: production TLS is terminated by the Dokku proxy. Whether the
|
||||
# endpoint should also force SSL/HSTS (and trust X-Forwarded-Proto) is a
|
||||
# deployment decision, not a mechanical fix.
|
||||
# Follow-up yak: app: enforce HTTPS in production (sobelow Config.HTTPS)
|
||||
Config.HTTPS: HTTPS Not Enabled,config/prod.exs:0,2B5C077
|
||||
|
||||
# Traversal.FileModule - Low - lib/firehose_web/live/microprints_live.ex:331
|
||||
# False positive: the client path is only read after Enum.find matches it
|
||||
# against the server-scanned microprint list, and source_dir comes from
|
||||
# @source_dirs. Both Path.join inputs are server-computed, never client text.
|
||||
Traversal.FileModule: Directory Traversal in `File.read`,lib/firehose_web/live/microprints_live.ex:331,7ED2FE5
|
||||
+126
-7
@@ -3,17 +3,52 @@
|
||||
MISE_BIN ?= mise
|
||||
MISE_EXEC = $(MISE_BIN) exec --
|
||||
|
||||
.PHONY: check precommit deps compile test format credo
|
||||
.PHONY: check precommit deps deps.check assets compile test test.coverage sobelow coverage crap format format.check credo verify
|
||||
|
||||
# Run all static analysis checks (no database required)
|
||||
check: credo format
|
||||
# Run all static analysis checks (no database, no network required). The
|
||||
# deps.check guard fails loudly with a pointer at `make setup` when Hex deps
|
||||
# are missing. `format.check` only reports: a gate that rewrites the files it
|
||||
# judges can never fail. Fix formatting with `make format`.
|
||||
check: deps.check credo format.check
|
||||
|
||||
# Precommit target for CI/pre-commit hooks
|
||||
precommit: check compile
|
||||
|
||||
# Sync dependencies
|
||||
deps:
|
||||
# Install JS dependencies (esbuild bundle, playwright). `mix phx.server` and
|
||||
# `mix assets.deploy` need them: without `assets/node_modules` the bundle fails
|
||||
# to resolve highlight.js and LiveView never loads.
|
||||
#
|
||||
# Guarded on npm's own install marker, so a clone pays for it once and later
|
||||
# runs never touch the network: `make test` keeps working offline.
|
||||
assets:
|
||||
@test -f assets/node_modules/.package-lock.json || \
|
||||
(echo "==> assets: npm install (first run)"; cd assets && npm install)
|
||||
|
||||
# Sync dependencies (Hex packages + JS packages) and compile them. The compile
|
||||
# step is part of setup on purpose: some deps download artifacts at compile
|
||||
# time - rustler_precompiled fetches the mdex_native NIF from GitHub during
|
||||
# its first compile, and NimblePublisher pulls mdex_native into this app. Keep
|
||||
# that traffic in this explicit, user-run step so check/test/verify stay
|
||||
# offline even on a cold clone. (rustler_precompiled caches artifacts globally
|
||||
# in ~/Library/Caches/rustler_precompiled, so later test-env compiles find
|
||||
# them without network.)
|
||||
deps: assets
|
||||
$(MISE_EXEC) mix deps.get
|
||||
$(MISE_EXEC) mix deps.compile
|
||||
|
||||
# Local, network-free guard: every package pinned in mix.lock must be present
|
||||
# in deps/. check, test and test.coverage depend on this instead of `deps`, so
|
||||
# the everyday loops never touch the network: `mix deps.get` contacts hex.pm
|
||||
# even when everything is already installed (the advisory check), which fails
|
||||
# in offline/sandboxed environments. Fetching is `make setup`'s explicit job,
|
||||
# run from the repo root.
|
||||
deps.check:
|
||||
@test -f mix.lock || { echo "deps.check: no mix.lock - run 'make setup' at the repo root" >&2; exit 1; }
|
||||
@missing=""; \
|
||||
for dep in $$(sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' mix.lock); do \
|
||||
[ -d "deps/$$dep" ] || missing="$$missing $$dep"; \
|
||||
done; \
|
||||
test -z "$$missing" || { echo "deps.check: missing:$$missing - run 'make setup' at the repo root" >&2; exit 1; }
|
||||
|
||||
# Compile the project
|
||||
compile:
|
||||
@@ -21,13 +56,55 @@ compile:
|
||||
|
||||
# Run tests (requires PostgreSQL running on localhost:5432)
|
||||
# Note: If you don't have PostgreSQL, you can skip tests with `make check`
|
||||
test: deps compile
|
||||
test: deps.check compile
|
||||
$(MISE_EXEC) mix test
|
||||
|
||||
# Format code
|
||||
# Run the suite once with coverage exported, so the coverage floor check (and,
|
||||
# later, CRAP) reads the export instead of running the suite a second time.
|
||||
# Stale exports are removed first: the floor must never report an older run.
|
||||
test.coverage: deps.check compile
|
||||
rm -f cover/*.coverdata
|
||||
$(MISE_EXEC) mix test --cover --export-coverage default
|
||||
|
||||
# Enforce the coverage floor (mix.exs -> test_coverage) from that export. No
|
||||
# test run. `mix test.coverage` exits 0 when it finds no export at all, so the
|
||||
# guard below is load-bearing: a floor check that did not run must not pass.
|
||||
coverage:
|
||||
@test -f cover/default.coverdata || { echo "coverage: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
|
||||
$(MISE_EXEC) mix test.coverage
|
||||
|
||||
# Enforce CRAP scores (complexity x coverage) from the same export. No test run.
|
||||
# Runs in MIX_ENV=test because that is the environment the export came from:
|
||||
# running in dev would compile a second build and score against a different set
|
||||
# of modules. The guard is load-bearing for the same reason as `coverage`:
|
||||
# ex_crap falls back to 0% coverage when the export is missing, which would
|
||||
# report scores that are pessimistic instead of absent - a false failure is
|
||||
# less bad than a false pass, but neither is acceptable in the Lens pass.
|
||||
#
|
||||
# Threshold is ex_crap's default (30), kept deliberately: decision 2026-10-28
|
||||
# (human, yak "ratchets, deferred decisions, handoff practice") is not to
|
||||
# ratchet --max-score down yet - below 30 is a policy change, not a cleanup.
|
||||
# Baseline at that threshold, same date: 0 offenders above 30, worst function
|
||||
# FirehoseWeb.CoreComponents.table/1 at 20.00 ("Summary: files=23 functions=140
|
||||
# scored=140 worst_score=20.00"). A failing run prints "High scores: N" plus the
|
||||
# offending functions; on a passing run read N=0 off the worst score rather than
|
||||
# inventing a count.
|
||||
crap:
|
||||
@test -f cover/default.coverdata || { echo "crap: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
|
||||
MIX_ENV=test $(MISE_EXEC) mix crap
|
||||
|
||||
# Format code. MUTATES the tree; deliberately not part of `check` (nor of the
|
||||
# Lens pass), which must never write: a gate that fixes what it judges can never
|
||||
# fail, and would dirty the tree a push is about to send. `make check` uses
|
||||
# `format.check` below instead.
|
||||
format:
|
||||
$(MISE_EXEC) mix format
|
||||
|
||||
# Verify formatting without writing. Fails with the offending files, so the fix
|
||||
# is explicit: run `make format` (or `make -C app format`).
|
||||
format.check:
|
||||
$(MISE_EXEC) mix format --check-formatted
|
||||
|
||||
# =============================================================================
|
||||
# Playwright e2e browser tests
|
||||
# =============================================================================
|
||||
@@ -95,3 +172,45 @@ playwright-full: playwright-docker-start
|
||||
# Run Credo static analysis
|
||||
credo:
|
||||
$(MISE_EXEC) mix credo --strict
|
||||
|
||||
# =============================================================================
|
||||
# Lens pass (heavy checks; root `make verify` runs this before every push)
|
||||
# =============================================================================
|
||||
#
|
||||
# Sensor slots, cheapest first. Credo is cheap enough that it lives in
|
||||
# `check`, not here. Add a sensor as its yak lands, both as a target and to
|
||||
# VERIFY_SENSORS — that variable is the single source of truth for what this
|
||||
# target reports as wired.
|
||||
# - sobelow (yak: sobelow in the Lens pass)
|
||||
# WIRED below (sobelow, ~1s). Fails loudly on findings and
|
||||
# when the task is missing — no fallback of any kind.
|
||||
# Per-finding skips exist only in .sobelow-skips, each with a
|
||||
# written rationale (pinned by scripts/check-lens-sensors.sh).
|
||||
# - coverage (yak: coverage floors + framework-glue exclusions policy)
|
||||
# WIRED below (test.coverage + coverage).
|
||||
# - crap (yak: CRAP scores with ex_crap (both apps))
|
||||
# WIRED below (crap); reuses the coverage export.
|
||||
# - mutation (yak: mutation testing with muex — a sensor, not a gate)
|
||||
#
|
||||
# Runtime note: the coverage sensor exports from a single suite run
|
||||
# (`test.coverage` -> `mix test --cover --export-coverage default`) that CRAP
|
||||
# reuses, instead of running the suite twice.
|
||||
#
|
||||
# The suite runs FIRST, because it is what produces the coverage export the
|
||||
# sensors read; sensors still run cheapest-first among themselves.
|
||||
VERIFY_SENSORS := sobelow coverage crap
|
||||
|
||||
# Sobelow security scan. `--exit` is load-bearing: without it sobelow prints
|
||||
# findings and exits 0 (verified 2026-10-28, sobelow 0.17.0: 3 findings -> 0,
|
||||
# `--exit` -> 1; it defaults to the `low` confidence threshold). `--skip`
|
||||
# applies only the individually rationalised entries in .sobelow-skips.
|
||||
# There is deliberately no guard or fallback: if the dep is absent, `mix
|
||||
# sobelow` is a hard Mix error ("The task could not be found") and the
|
||||
# verify loop's `|| exit 1` fails the pass.
|
||||
sobelow:
|
||||
$(MISE_EXEC) mix sobelow --skip --exit
|
||||
|
||||
verify:
|
||||
@echo "==> verify(app): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]"
|
||||
@$(MAKE) test.coverage
|
||||
@for sensor in $(VERIFY_SENSORS); do echo "==> verify(app): sensor $$sensor"; $(MAKE) $$sensor || exit 1; done
|
||||
|
||||
+44
-14
@@ -27,7 +27,11 @@
|
||||
--color-base-200: oklch(94% 0.012 75);
|
||||
--color-base-300: oklch(90% 0.016 75);
|
||||
--color-base-content: oklch(25% 0.015 50);
|
||||
--color-primary: oklch(58% 0.16 45);
|
||||
/* 54% lightness, not 58%: primary is also *text* (pagination links, body
|
||||
links, the post-title hover). At 58% it measured 4.18:1 on base-100, below
|
||||
the 4.5:1 floor; 54% is 4.93:1 there and 4.52:1 on base-200. Same hue and
|
||||
chroma - this is a contrast fix, not a re-colour. */
|
||||
--color-primary: oklch(54% 0.16 45);
|
||||
--color-primary-content: oklch(98% 0.01 75);
|
||||
--color-secondary: oklch(55% 0.12 30);
|
||||
--color-secondary-content: oklch(98% 0.008 30);
|
||||
@@ -53,11 +57,18 @@
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Warm dark theme */
|
||||
/* Warm dark theme.
|
||||
`prefersdark: false` deliberately: daisyUI emits an *unscoped*
|
||||
`@media (prefers-color-scheme: dark) { :root { … } }` for a prefersdark
|
||||
theme, which lands after the light block and beats `[data-theme="light"]` on
|
||||
equal specificity - an explicit light choice rendered dark on a dark-OS
|
||||
browser. The inline script in components/layouts/root.html.heex resolves
|
||||
"system" to an explicit data-theme instead, so no rule here may follow the
|
||||
OS preference on its own. */
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(24% 0.015 50);
|
||||
--color-base-200: oklch(20% 0.012 50);
|
||||
@@ -144,12 +155,12 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
}
|
||||
|
||||
.blogex-post-preview header h2 a {
|
||||
color: oklch(var(--color-base-content));
|
||||
color: var(--color-base-content);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blogex-post-preview header h2 a:hover {
|
||||
color: oklch(var(--color-primary));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.blogex-post-description {
|
||||
@@ -163,7 +174,10 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.6;
|
||||
/* 0.6 measured 4.16:1 (base-content over base-100, light theme) - below the
|
||||
4.5:1 floor. 0.625 is what the floor needs; 0.65 keeps a margin without
|
||||
making the meta line as loud as the description (0.7 -> 5.66:1). */
|
||||
opacity: 0.65;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -172,7 +186,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
background: oklch(var(--color-base-200));
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.blogex-post-header h1 {
|
||||
@@ -193,9 +207,9 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
.blogex-post-body p { margin-top: 1rem; }
|
||||
.blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; }
|
||||
.blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; }
|
||||
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
|
||||
.blogex-post-body pre { background: var(--color-base-200); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
|
||||
.blogex-post-body code { font-size: 0.875em; }
|
||||
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; }
|
||||
.blogex-post-body a { color: var(--color-primary); text-decoration: underline; }
|
||||
|
||||
/* External link marker on links rewritten by Blogex.ExternalLinks (class
|
||||
`blogex-external`, added at compile time together with target="_blank").
|
||||
@@ -219,7 +233,23 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
-webkit-mask-size: contain;
|
||||
}
|
||||
|
||||
.blogex-post-body blockquote { border-left: 3px solid oklch(var(--color-primary)); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
|
||||
/* Screen-reader hint injected by Blogex.ExternalLinks at the end of every
|
||||
external link's text. Visually hidden (the icon above is the visual cue),
|
||||
still announced: "Wikipedia, link, opens in a new tab". */
|
||||
.blogex-post-body .blogex-external-hint {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.blogex-post-body blockquote { border-left: 3px solid var(--color-primary); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
|
||||
|
||||
.blogex-tag-list {
|
||||
display: flex;
|
||||
@@ -232,13 +262,13 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
background: oklch(var(--color-base-200));
|
||||
background: var(--color-base-200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blogex-tag-link:hover, .blogex-tag-active {
|
||||
background: oklch(var(--color-primary));
|
||||
color: oklch(var(--color-primary-content));
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
.blogex-pagination {
|
||||
@@ -251,7 +281,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
|
||||
}
|
||||
|
||||
.blogex-pagination a {
|
||||
color: oklch(var(--color-primary));
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
defmodule Firehose do
|
||||
use Boundary,
|
||||
deps: [Firehose.Repo],
|
||||
exports: :all
|
||||
|
||||
@moduledoc """
|
||||
Firehose keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
@@ -2,6 +2,16 @@ defmodule Firehose.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
# `redact: true` below makes Ecto derive an `Inspect` impl for us. We write
|
||||
# that impl out by hand instead (see the `defimpl` after the schema):
|
||||
# Elixir 1.20.4 emits the derived code entirely on the `schema` line, and
|
||||
# OTP 27's coverage instrumentation turns that single line into invalid
|
||||
# Erlang ("variable '_2' unsafe in 'case'"), which aborts `mix test --cover`
|
||||
# for the whole app. Output and redaction are identical, and the redaction is
|
||||
# pinned by test/firehose/accounts_test.exs. Drop this and go back to the
|
||||
# derive when the Elixir/OTP pins no longer reproduce the crash.
|
||||
@derive_inspect_for_redacted_fields false
|
||||
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
@@ -12,6 +22,27 @@ defmodule Firehose.Accounts.User do
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
defimpl Inspect do
|
||||
alias Firehose.Accounts.User
|
||||
|
||||
# Same set as the `redact: true` field options above; keep them in sync.
|
||||
@redacted_fields [:password, :hashed_password]
|
||||
|
||||
def inspect(%User{} = user, opts) do
|
||||
infos =
|
||||
for %{field: field} = info <- User.__info__(:struct),
|
||||
field not in @redacted_fields,
|
||||
do: info
|
||||
|
||||
Inspect.Any.inspect_as_struct(
|
||||
user,
|
||||
Macro.inspect_atom(:literal, User),
|
||||
infos,
|
||||
opts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registering or changing the email.
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@ defmodule Firehose.Application do
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
# Booting the app is the one job that must reach into the web tier (it starts
|
||||
# FirehoseWeb.Telemetry and FirehoseWeb.Endpoint) while the web tier reaches
|
||||
# back into the contexts. Declaring the Application a top-level boundary of
|
||||
# its own is the layout boundary's README prescribes for `mix phx.new`
|
||||
# projects, and it keeps the graph acyclic:
|
||||
# Firehose.Application -> {Firehose, FirehoseWeb} -> Firehose -> Firehose.Repo.
|
||||
# Putting FirehoseWeb in Firehose's deps instead is a cycle, which boundary
|
||||
# rejects ("dependency cycle found: Firehose -> FirehoseWeb -> Firehose").
|
||||
use Boundary, top_level?: true, deps: [Firehose, FirehoseWeb]
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
defmodule Firehose.Repo do
|
||||
use Boundary, top_level?: true, deps: [], exports: []
|
||||
|
||||
use Ecto.Repo,
|
||||
otp_app: :firehose,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
defmodule FirehoseWeb do
|
||||
use Boundary,
|
||||
deps: [Firehose],
|
||||
exports: [Endpoint, Telemetry]
|
||||
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, components, channels, and so on.
|
||||
|
||||
@@ -303,6 +303,16 @@ defmodule FirehoseWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
# `table/1` and `list/1` below are part of the upstream Phoenix generator's
|
||||
# core_components.ex and are kept available for when we need them — they are
|
||||
# genuinely useful, not leftover scaffolding to be deleted.
|
||||
#
|
||||
# Nothing renders them yet: the collections that exist in the UI are hand-rolled
|
||||
# instead (editor_dashboard_live.ex builds its drafts/scheduled rows out of
|
||||
# bordered divs; Blogex.Components.post_index renders post previews and tag
|
||||
# lists as articles/links). If you build a new data or admin screen, prefer
|
||||
# these two over more bespoke markup — and if you retrofit an existing screen,
|
||||
# drop the note here.
|
||||
@doc """
|
||||
Renders a table with generic styling.
|
||||
|
||||
@@ -366,6 +376,7 @@ defmodule FirehoseWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
# Upstream scaffold, unused in-app today — see the note above `table/1`.
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@ defmodule FirehoseWeb.Layouts do
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
||||
<%!-- The knob follows the visitor's choice, not the resolved palette: in
|
||||
system mode data-theme may be light or dark, but the system icon stays
|
||||
selected. --%>
|
||||
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme-choice=light]_&]:left-1/3 [[data-theme-choice=dark]_&]:left-2/3 transition-[left]" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
|
||||
@@ -14,20 +14,37 @@
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
// Theme selection is always explicit: the document always carries a
|
||||
// data-theme attribute, resolved here before first paint. An explicit
|
||||
// choice therefore always wins in CSS, whatever the OS preference is;
|
||||
// no stylesheet rule follows the OS on its own.
|
||||
//
|
||||
// data-theme-choice records what the visitor actually chose (unset,
|
||||
// system, light, dark) and is what the toggle's knob follows: in
|
||||
// "system" mode the palette may resolve to light or dark, but the
|
||||
// system icon must stay selected.
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const stored = () => localStorage.getItem("phx:theme");
|
||||
const resolve = (theme) =>
|
||||
theme === "system" ? (prefersDark.matches ? "dark" : "light") : theme;
|
||||
const apply = (theme) => {
|
||||
document.documentElement.setAttribute("data-theme", resolve(theme));
|
||||
document.documentElement.setAttribute("data-theme-choice", theme);
|
||||
};
|
||||
const setTheme = (theme) => {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
apply(theme);
|
||||
};
|
||||
// Light is the default: no stored preference means light, not "follow
|
||||
// the OS". "System" is an explicit choice and is stored as such.
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
apply(stored() || "light");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
// Follow the OS only while the visitor's recorded choice is "system".
|
||||
prefersDark.addEventListener("change", () => {
|
||||
if (stored() === "system") apply("system");
|
||||
});
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "light"));
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -14,9 +14,8 @@ defmodule FirehoseWeb.Router do
|
||||
plug :fetch_current_scope_for_user
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
# No :api pipeline: the JSON blog API is served by
|
||||
# forward "/api/blog/...", Blogex.Router below, which sets its own content types.
|
||||
|
||||
scope "/", FirehoseWeb do
|
||||
pipe_through :browser
|
||||
|
||||
@@ -171,9 +171,15 @@ defmodule FirehoseWeb.UserAuth do
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
put_session(conn, :user_token, token)
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, user_session_topic(token))
|
||||
end
|
||||
|
||||
# Must match what Phoenix.LiveView.Socket.id/1 reads out of the socket's
|
||||
# connect_info session, so a "disconnect" broadcast reaches open sockets.
|
||||
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
|
||||
|
||||
@doc """
|
||||
Plug for routes that require sudo mode.
|
||||
"""
|
||||
|
||||
+179
-6
@@ -10,7 +10,8 @@ defmodule Firehose.MixProject do
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
test_coverage: test_coverage(),
|
||||
compilers: extra_compilers(Mix.env()) ++ [:phoenix_live_view] ++ Mix.compilers(),
|
||||
listeners: [Phoenix.CodeReloader]
|
||||
]
|
||||
end
|
||||
@@ -32,10 +33,25 @@ defmodule Firehose.MixProject do
|
||||
end
|
||||
|
||||
# Specifies which paths to compile per environment.
|
||||
defp elixirc_paths(:test), do: ["lib", "lib_dev", "test/support"]
|
||||
defp elixirc_paths(:dev), do: ["lib", "lib_dev"]
|
||||
#
|
||||
# `lib_dev/` is deliberately NOT compiled in any environment: it holds
|
||||
# credo-only checks (Firehose.Checks.NoConnShadowing), and credo loads them
|
||||
# itself from source via `requires:` in .credo.exs. Compiling that path as
|
||||
# well gave every run two load paths for the same module, which surfaces as
|
||||
# "warning: redefining module Firehose.Checks.NoConnShadowing" on a warm
|
||||
# `_build/dev`. Keep the single source load: if you add code under lib_dev/
|
||||
# that is NOT a credo check, give it its own compiled path instead.
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
# The boundary compiler must be listed *before* `:elixir`: it enforces by
|
||||
# registering an `after_compiler(:elixir, ...)` callback, so a later position
|
||||
# would look wired and report nothing. It stays out of `:prod` on boundary's
|
||||
# own advice (a violation must not be able to fail a release build); `:dev`
|
||||
# and `:test` are where code is written.
|
||||
defp extra_compilers(env) when env in [:dev, :test], do: [:boundary]
|
||||
defp extra_compilers(_), do: []
|
||||
|
||||
# Specifies your project dependencies.
|
||||
#
|
||||
# Type `mix help deps` for examples and options.
|
||||
@@ -50,7 +66,14 @@ defmodule Firehose.MixProject do
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_view, "~> 1.2"},
|
||||
{:lazy_html, ">= 0.1.0", only: :test},
|
||||
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||
{:phoenix_live_dashboard, "~> 0.9.1"},
|
||||
# LiveDashboard's Ecto Stats page loads the Postgres extras module at
|
||||
# runtime, and declaring it also puts `postgrex` ahead of the dashboard
|
||||
# in the dep graph. That edge matters: the dashboard references
|
||||
# `Postgrex.Interval` in an `in` guard, and without it a parallel dep
|
||||
# compile warns the module is "not available or yet to be defined"
|
||||
# (upstream's `no_warn_undefined` list omits `Postgrex.Interval`).
|
||||
{:ecto_psql_extras, "~> 0.8"},
|
||||
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
||||
{:heroicons,
|
||||
@@ -73,7 +96,15 @@ defmodule Firehose.MixProject do
|
||||
{:microprints,
|
||||
git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git",
|
||||
branch: "main"},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
|
||||
# Compile-time boundary enforcement (Firehose / FirehoseWeb / Firehose.Repo).
|
||||
# Declared directly even though `ex_crap` already pulls it in transitively:
|
||||
# dropping a test-only metric tool must not silently take the boundary
|
||||
# compiler with it.
|
||||
{:boundary, "~> 0.11", runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
|
||||
{:muex, "~> 0.12", only: [:dev, :test], runtime: false},
|
||||
{:sobelow, "~> 0.16", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -83,6 +114,14 @@ defmodule Firehose.MixProject do
|
||||
# $ mix setup
|
||||
#
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
#
|
||||
# `mutate` / `mutate.fast` are a sensor, not a gate: they are deliberately
|
||||
# absent from Makefile's VERIFY_SENSORS and always pass `--fail-at 0`, so a
|
||||
# survivor is reported rather than fatal. The scope lives in the plain-text
|
||||
# files beside this one; the aliases read them at run time, so the text file
|
||||
# stays the single source of truth. Both are empty today because the app's
|
||||
# candidate modules are only tested through the database-backed DataCase;
|
||||
# an empty scope raises instead of silently running over nothing.
|
||||
defp aliases do
|
||||
[
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
@@ -96,7 +135,141 @@ defmodule Firehose.MixProject do
|
||||
"esbuild firehose --minify",
|
||||
"phx.digest"
|
||||
],
|
||||
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
|
||||
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"],
|
||||
mutate: &mutate/1,
|
||||
"mutate.fast": &mutate_fast/1
|
||||
]
|
||||
end
|
||||
|
||||
# No macro/`use` mutators: the web layer (where the DSLs live) is out of
|
||||
# scope, and an empty scope never reaches muex at all. Spellings are muex
|
||||
# 0.12's CLI names.
|
||||
@mutation_mutators "arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
|
||||
|
||||
defp mutate(args), do: run_mutation("mutation_scope.txt", args)
|
||||
|
||||
defp mutate_fast(args), do: run_mutation("mutation_scope_fast.txt", args)
|
||||
|
||||
defp run_mutation(scope_file, args) do
|
||||
Mix.Task.run("muex", mutation_args(scope_file, args))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def mutation_args(scope_file, extra_args \\ []) do
|
||||
files = read_mutation_scope(scope_file)
|
||||
|
||||
# `--fail-at 0` comes first so an ad-hoc override cannot turn the sensor
|
||||
# into a gate. Extra args come before our defaults, and OptionParser keeps
|
||||
# the first value, so `mix mutate --files lib/foo.ex` targets one file.
|
||||
["--fail-at", "0"] ++
|
||||
extra_args ++
|
||||
["--files", Enum.join(files, ","), "--mutators", @mutation_mutators]
|
||||
end
|
||||
|
||||
@doc false
|
||||
def read_mutation_scope(scope_file) do
|
||||
path = Path.expand(scope_file, Path.dirname(Mix.Project.project_file()))
|
||||
|
||||
files =
|
||||
case File.read(path) do
|
||||
{:ok, contents} ->
|
||||
contents
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
{:error, reason} ->
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is unreadable (#{:file.format_error(reason)}); " <>
|
||||
"refusing to run the mutation sensor over an unknown scope"
|
||||
)
|
||||
end
|
||||
|
||||
if files == [] do
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is empty; refusing to run the mutation sensor over nothing"
|
||||
)
|
||||
end
|
||||
|
||||
files
|
||||
end
|
||||
|
||||
# The coverage floor the Lens pass enforces (`make verify` -> `make coverage`
|
||||
# in app/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks
|
||||
# land; never lower it. A drop means the change (or an exclusion) is wrong,
|
||||
# not the floor.
|
||||
#
|
||||
# Measured 2026-10-29 (PostgreSQL on localhost:5432, exclusions below):
|
||||
# 94.01% on `main` = 9c0f33e, with boundary enforcement, credo, the mutation
|
||||
# scope and the core-components behavior tests all landed (88.19% after the
|
||||
# web-controller/auth edge-case tests, 87.56% after the editorial-LiveView
|
||||
# ones, 86.97% before them, and 86.79% / 86.17% at earlier exclusion
|
||||
# revisions), identical via `mix test --cover` and via
|
||||
# `mix test --cover --export-coverage default` + `mix test.coverage`. Floor is
|
||||
# the integer below that — so a floor of 94 means "at least 94.00%".
|
||||
#
|
||||
# `test.coverage` deletes stale exports before sampling: `mix test.coverage`
|
||||
# imports *every* .coverdata it finds, so leftover exports from earlier runs
|
||||
# pool into the total and quietly inflate it.
|
||||
#
|
||||
# Excluded are modules the suite cannot or should not exercise; counting them
|
||||
# would move the number without saying anything about app behavior:
|
||||
#
|
||||
# * framework glue generated by `use`:
|
||||
# Firehose.Repo `use Ecto.Repo`
|
||||
# Firehose.Mailer `use Swoosh.Mailer`
|
||||
# FirehoseWeb.Telemetry Phoenix's telemetry supervisor + metric list
|
||||
# FirehoseWeb.ErrorHTML Phoenix error renderers, both delegating to
|
||||
# FirehoseWeb.ErrorJSON Phoenix.Controller.status_message_from_template/1
|
||||
# * FirehoseWeb the `use FirehoseWeb` macro: imports and aliases
|
||||
# only, nothing that executes
|
||||
# * embed_templates wrappers FirehoseWeb.BlogHTML,
|
||||
# FirehoseWeb.UserRegistrationHTML,
|
||||
# FirehoseWeb.UserSettingsHTML: each is `use` +
|
||||
# `embed_templates`, so every function in them is
|
||||
# generated by the macro. Their TEMPLATES are
|
||||
# exercised through the controller tests; the
|
||||
# wrapper modules hold no hand-written code.
|
||||
# NOTE: PageHTML and UserSessionHTML are
|
||||
# deliberately NOT excluded — both carry private
|
||||
# helpers with real logic (blog_label,
|
||||
# post_base_path, local_mail_adapter?), so their
|
||||
# gaps are behavior gaps, not macro noise.
|
||||
# * Firehose.Application start/2 is proven by the suite booting at all (a
|
||||
# broken supervision tree fails every test), and
|
||||
# config_change/3 only runs on a release hot
|
||||
# upgrade, which no test can reach.
|
||||
# * Firehose.Release production release tasks (`mix ecto.migrate` on
|
||||
# Dokku); the suite never runs them
|
||||
# * test scaffolding Firehose.DataCase, FirehoseWeb.ConnCase,
|
||||
# Firehose.AccountsFixtures, Firehose.Test.FakeBlog:
|
||||
# fixtures measure the tests, not the app
|
||||
#
|
||||
# `Firehose.Checks.NoConnShadowing` needs no entry: lib_dev/ is no longer in
|
||||
# elixirc_paths (credo loads it from source), so it is not measurable here.
|
||||
#
|
||||
# Everything else counts — CoreComponents, controllers, LiveViews included:
|
||||
# that is where the behavior-test work shows up as a higher floor.
|
||||
defp test_coverage do
|
||||
[
|
||||
summary: [threshold: 94],
|
||||
ignore_modules: [
|
||||
Firehose.Repo,
|
||||
Firehose.Mailer,
|
||||
Firehose.Release,
|
||||
Firehose.Application,
|
||||
FirehoseWeb,
|
||||
FirehoseWeb.Telemetry,
|
||||
FirehoseWeb.ErrorHTML,
|
||||
FirehoseWeb.ErrorJSON,
|
||||
FirehoseWeb.BlogHTML,
|
||||
FirehoseWeb.UserRegistrationHTML,
|
||||
FirehoseWeb.UserSettingsHTML,
|
||||
Firehose.DataCase,
|
||||
FirehoseWeb.ConnCase,
|
||||
Firehose.AccountsFixtures,
|
||||
Firehose.Test.FakeBlog
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
+26
-19
@@ -1,22 +1,26 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
|
||||
"bandit": {:hex, :bandit, "1.13.1", "1ce25a7beb750ed3fa2e905962f1f9a679cb585f1da0ae45637d06e385ce2eae", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "39028f0b5a21404661410a6c19275060a9822c9dcca70b7c3e4e4fa50b11da58"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"boundary": {:hex, :boundary, "0.11.0", "56cff565b5565930765e02782281b022935fe8c2e1504543c8c1b44b85893b40", [:mix], [], "hexpm", "b9297a96ea14c0e63dbd77b8135e0d483f9c6f34ed1c9dee4b3fea82c596f55a"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
|
||||
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
|
||||
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
|
||||
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
|
||||
"decimal": {:hex, :decimal, "3.2.0", "f1235f05d841a211fda78a08fdb9777f2ff51a71f18455233fae186ff76187c6", [:mix], [], "hexpm", "190bca80580ce61aa69bb0eacadd7677710205c770225c2c2c07e32a5c31b234"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
||||
"ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"},
|
||||
"ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.1", "32b44b468d2c6386b6bf9229617cbf51be025ce4380eacd383a8979cbb8327ef", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "890f251330052dcacafa40c409769e7c42e3876fb0ae450707bde831be7b34ea"},
|
||||
"ex_crap": {:hex, :ex_crap, "0.1.1", "c1770e7ce1843f304a27f5d32bfa867a340140e337684ee1929c770b2499f884", [:mix], [{:boundary, "~> 0.10", [hex: :boundary, repo: "hexpm", optional: false]}], "hexpm", "ada5d59bce442c1a8a987cb5328cbf4469fb56eb8d04641886f2199569a75b12"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"},
|
||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
@@ -24,38 +28,41 @@
|
||||
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"},
|
||||
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
|
||||
"makeup": {:hex, :makeup, "1.2.3", "c3ef0ff83acf305384acba0f70bff87d8732502baa1eb3eb081d8c154fbe18f5", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "15970751b7021c2bcd7ed56a54b3def23affd3969fb7c214dffdf28e97ce6f76"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
|
||||
"mdex_native": {:hex, :mdex_native, "0.2.4", "a74b9d799d3fc487b1f97b70ee9b04108c7af0514920d56828e173d19312c2a9", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d71185c43c5209c6939f4b65f903f71c892512c93ea6843171227855d2d976f5"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
|
||||
"mdex_native": {:hex, :mdex_native, "0.2.12", "867912ab5675e0a315e44ea5eb9ec22ac09c12559cc89fc1ba71afd7d95bf96a", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "c9557b4e576515b01edc5bc48f6ac0a6e13c25a0bd3bf4a6e0a2ccd2199d747b"},
|
||||
"microprints": {:git, "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", "f2a361555310518870ad3c093050169f30ff870c", [branch: "main"]},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"},
|
||||
"muex": {:hex, :muex, "0.12.0", "80d8f1b2f7bfcba8ecb46b7b43dce55b555d33da54b84e74feedc4079fa2bd36", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b395b87d23b8d34f4508d0c13494b734f1fff8156eca2360fd279a0799d52e"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"nimble_publisher": {:hex, :nimble_publisher, "2.0.0", "f4fc100949ccdf1831094ff303b79f27c6f04b2ead829409426ee0d9ed3aa55b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "88997f74b763c24a15d20a90d7cd4ef1fd01172a2b4651e54d8021a44659f2c8"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"},
|
||||
"nimble_publisher": {:hex, :nimble_publisher, "2.1.0", "8cda388aecfcaf3f2b2ed1c26a609c1e1266f73fe1fda0fd277a9f61007dd274", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "f5cc1b32a3705f37afeade44e21d750de74979230f7b892f9790fc02487e6e10"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.16", "6a4742054740496bfabefc95253f978d8f595f07bb601039b7a18b384a873916", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.4", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "e7355c35889305bc539eb09dae2393abcbaf0f65f6874a3f0dcb5f0ec53737fe"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.0", "be29b070ec9298df8c3c90a2ae0712d4c9e37c214f6a6ca81341bd19f1bde00b", [:mix], [], "hexpm", "80776f727460ed104c95ecddcacc5098d3cd31f40e5f05741e1219abfb20f4a8"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.9.1", "d4786b24bbe1eacc431049dbbb8d377f5359fce57b9d7cdd773bba4ac0af2ef6", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "de25e1a28cfb64413d8bdaf3ba5b64da1a19e0ed44a02320119523cc45da051d"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.13", "850284c529f013e82d9596325bd92cb5859c0872176616b6e2ec4294e2ef616e", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a5cd2a3df10552bce6b7e68e5d376ff3b46cca3ca62d6c43bd4398d80c197fbe"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.1", "2ff3e7ba8b432120989870997b9adaefb76e1f63805554ab0b9e948987981f4b", [:mix], [], "hexpm", "352fba7a87bf61395b6e97da66944ad9b1bfe5459e5981039f60d68270bd9700"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"},
|
||||
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.2.1", "bd1d0358a2e2f5ffb818217e64c394f9d24ed191d862b7fa1bcce264ace2b8ea", [:mix], [], "hexpm", "4417ccbd8f85e627321cd07d2ab9c4b621f1fd883b9905af241f9b4b780b94b6"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
|
||||
"ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"},
|
||||
"req": {:hex, :req, "0.7.5", "0c3293cbf7717aba20b1b1b3ea13fe8d3fdf6f1557490f390c3f1ef6dd9db955", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "576853c4bf403faa3c1fa5d093e0759227083b78f8a6864c67d62c0a9c45514d"},
|
||||
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
|
||||
"swoosh": {:hex, :swoosh, "1.28.1", "1a836e6a938a93bd2dbd99734267bda876894473e76b510ece20e5681510ceb1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d84e08c445f70617afd3a31633db2ea02ceabdfec482bde4b824da63b8150bdc"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"},
|
||||
"sobelow": {:hex, :sobelow, "0.17.0", "57436898f5b5eac05244ddb301927b1e6521d7de5df3023929d0fe2818d11055", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "02914e7cdc5bd9bdcb1a2be7f08d793fd765f8c238c924f29857d3996d435928"},
|
||||
"swoosh": {:hex, :swoosh, "1.29.0", "b938791267d8a55415344018aa0421d214f9bc36c93cbe27305d3284a853785c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d23ea419c6c67604628c0e93a8d5c6d13bcc442137870671e1b05325b2956a13"},
|
||||
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
|
||||
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.6.0", "d6ad7c2220c96a270cc8ca1586ec4d8bde648a4a3a53f003ce093f41beec0288", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4d4720470f091fc21a1311777c2ac66916c43621bffc068590c9f9bb3cd057a3"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
%{
|
||||
title: "Hello World",
|
||||
author: "Firehose Team",
|
||||
published: false,
|
||||
tags: ~w(elixir phoenix),
|
||||
description: "Our first engineering blog post"
|
||||
}
|
||||
---
|
||||
Welcome to the Firehose engineering blog! We'll be sharing deep dives into our tech stack here.
|
||||
+4
-2
@@ -2,14 +2,16 @@
|
||||
title: "To Cheap to Meter - Open Weight models in October 2026",
|
||||
author: "Willem van den Ende",
|
||||
tags: ~w(Local-Models),
|
||||
description: "Earlier today I saw that the 17 October release of Deepseek v4 Flash is even more efficient than previous versions, threatening dominance of american AI labs. GLM 5.3 is already very cheap, I dug out some numbers from my inference provider for you to check.",
|
||||
description: "Earlier today I saw that the 17 October release of Deepseek v4 Flash is even more efficient than previous versions, threatening dominance of american AI labs. GLM 5.3 Flash is already very cheap, I dug out some numbers from my inference provider for you to check.",
|
||||
image: "/images/blog/2026/cache-use-glm.png",
|
||||
published: true
|
||||
}
|
||||
---
|
||||
Earlier today I saw that the 17 October release of Deepseek v4 Flash is even more efficient than previous versions, [threatening dominance of american AI labs.](https://georgzoeller.com/blog/posts/chinese-flash-models-are-the-coup-de-gr-ce-for-us-ai-lab-investor-fantasies/)
|
||||
|
||||
Someone asked me about my current setup, as they burn through their weekly AI plan quickly. I did a shallow dive in the costs of the larger open weight model I use online. I didn’t use much local tokens this week, see below for my local setup this month.
|
||||
Someone asked me about my current setup, as they burn through their weekly AI plan quickly. I did a shallow dive in the costs of the larger open weight model I use online, GLM 5.3 Flash. I didn’t use much local tokens this week, see below for my local setup this month.
|
||||
|
||||
I will try the latest Deepseek as soon as my current inference provider has it up and running, probably somewhere later this week.
|
||||
|
||||
## GLM 5.3 what does it cost for a coding agent, with Zero Data Retention in Europe?
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
%{
|
||||
title: "I reduced the attack surface of an old Wordpress blog by shutting it down",
|
||||
author: "Willem van den Ende",
|
||||
tags: ~w(security pi.dev),
|
||||
description: "Converting an interactive site like a blog to a static site is now very easy. Static sites have a tiny attack surface. They are not great for interaction, but for blogs and marketing sites they may be enough, and let you sleep at night.",
|
||||
published: true
|
||||
}
|
||||
---
|
||||
I reduced the attack surface on an old Wordpress blog by shutting it down, and then converting it to a static site from the backup, without running wordpress at all. The data, code and images was enough. It only took a single prompt (!) to get the basics done, then a few more to fix up the layout. Most of it happened while I was free to do other things.
|
||||
|
||||
A static site conversion had been on my mind for years, but the devil is in the details, and eventually would take a couple of days of fiddling. Now most of it happened while I was taking a nap, thanks to Pi.dev and GLM 5.3 Flash, and a feedback loop so the model could do exploratory testing with a web browser.
|
||||
|
||||
Seeing [Bert Hubert](https://www.linkedin.com/in/bert-hubert-b05452/)'s call yesterday to take your PHP server offline, and reading about 'Black October' was the push I needed.
|
||||
|
||||
The creaking old VM my blog ran on was fine, it had trouble responding to requests - a server that does not respond can not get hacked 😄 . So maybe there is no need to panic, but to take steps regardless.
|
||||
|
||||
I'm happy to write out the details if you want to do the same, but I'm only going to do it if there is interest, these kind of write-ups do take time - the devil is in the details, as always.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
%{
|
||||
title: "The Intention, Location, Details communication Pattern",
|
||||
author: "Willem van den Ende",
|
||||
tags: ~w(AI communication ensemble-programming coaching CollaborationMaxxing),
|
||||
description: "Communicating our intent clearly is helpful in getting the results we want. I find the pattern Intention, Location Details as described by Emily Bache quite handy, It took me a while to find sources, I am collecting them here, before expanding on my own musings.",
|
||||
image: nil,
|
||||
published: true
|
||||
}
|
||||
---
|
||||
When developing software, helping others do it, or instructing language models, it helps when we are clear about why we want to change something, [where the change should happen](https://www.qwan.eu/2020/12/23/what-to-put-where.html) (or not) and occasionally be very precise in what the change should be.
|
||||
|
||||
I find this pattern “Intention, Location Details” by [Emily Bache](https://coding-is-like-cooking.info) quite handy, and it is food for thought for AI augmented work. It took me a while to find sources, so I am collecting them here, before expanding on my own musings.
|
||||
|
||||
I came across this pattern, as I was going through [Emily Bache](https://coding-is-like-cooking.info) ’s post [Agentic AI coding breaks Mob programming](https://coding-is-like-cooking.info/2026/10/agentic-ai-coding-breaks-mob-programming-heres-how-to-fix-it/) . In it, there is a paragraph on the Intention, Location, Details communication pattern, aimed at working together effectively in an ensemble:
|
||||
|
||||
> To keep everyone aligned and to enable smooth transition between roles, I also teach the communication pattern ‘Intention, location, details’.
|
||||
>
|
||||
> This means the talker keeps everyone on board by frequently explaining their ‘intention’. Sometimes they need to go to ‘location’ – point at somewhere on the screen or in the codebase, and once everyone’s attention is on that, it’s clear what they mean. Occasionally that’s not enough and they give explicit ‘detail’ so the ‘typist’ gets told exactly what to do.
|
||||
>
|
||||
> In the main though, you communicate intention, so when you rotate roles, the next person should have a good idea about where the previous person was headed, and can seamlessly take over.
|
||||
|
||||
## More sources
|
||||
|
||||
[Emily Bache](https://coding-is-like-cooking.info) says she learnt this pattern from [Llewellyn Falco](https://llewellynfalco.blogspot.com/). Emily’s [Samman coaching book](https://emilybache.com/samman-coaching-book/) has it on page 27. I have read the book, and would recommend, but didn’t notice the pattern as I went through it.
|
||||
|
||||
I found that the pattern was in the book through [Jo van Eyck](https://jovaneyck.github.io/)’s video [IMMEDIATELY improve your pair programming skills with “intention, location, detail”](https://www.youtube.com/watch?v=kG70AzY44GY) .
|
||||
|
||||
Jo’s video description refers to a learning hour that is now a dead link. I guess it is now the [Talking with Intent, Typing Syntax](https://sammancoaching.org/learning_hours/ensemble/talking_typing.html) learning hour, which also contains a description of the pattern.
|
||||
|
||||
## More rambling
|
||||
|
||||
Now that I have finally put the words in a comprehensible order, the pattern is not in a recognisable pattern form. But it does fit many contexts. Intent(ion), location and details in a sense are each micro patterns. Provide clear intent. When the intent is not clear enough, clarify, or point at the location (this is the context for Location). When location (more context) is not enough, provide details.
|
||||
|
||||
I associate this with [Commanders Intent](https://www.mca-marines.org/gazette/commanders-intent-easy-to-understand-tough-to-articulate/) and Backbriefing, as explained in the book [The Art of Action](https://www.stephenbungay.com/Books.ink).
|
||||
|
||||
What I like about the pattern description above, is that fits in a paragraph. The explanation does not require any background. It took a while before I made it through [The Art of Action](https://www.stephenbungay.com/Books.ink), and the lessons from it gelling appears to be a process that is taking me years.
|
||||
|
||||
How about you? Do you often provide details where clear intent might have worked better? (micro management). Does intent sometimes surface **after** providing location and details? (hindsight from working through a problem) I am curious. The devil is, as always, is in the intent(ion).
|
||||
|
||||
Happy communicating, read you next time!
|
||||
@@ -0,0 +1,80 @@
|
||||
defmodule Firehose.CredoConfigTest do
|
||||
@moduledoc """
|
||||
Pins the *policy* in `app/.credo.exs`, not just the file's presence.
|
||||
|
||||
The gate is `mix credo --strict` (the `credo` target in `app/Makefile`, a
|
||||
prerequisite of `check`), so the config decides what every future commit has
|
||||
to satisfy. Plain `ExUnit.Case`: this reads and evaluates a file, it does not
|
||||
touch the database or boot the app's supervision tree.
|
||||
|
||||
The assertions are deliberately coarse - "this check sits in that list",
|
||||
"the disable says why", "the custom check is still loaded" - so they survive
|
||||
credo's own boilerplate churn but not a policy change.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@config_path ".credo.exs"
|
||||
@complexity_check "Credo.Check.Refactor.CyclomaticComplexity"
|
||||
@custom_check "Firehose.Checks.NoConnShadowing"
|
||||
@custom_check_load_path "lib_dev/firehose/checks/"
|
||||
|
||||
test "cyclomatic complexity is disabled in favour of CRAP" do
|
||||
checks = default_config()[:checks]
|
||||
|
||||
assert @complexity_check in check_modules(checks[:disabled])
|
||||
refute @complexity_check in check_modules(checks[:enabled])
|
||||
end
|
||||
|
||||
test "the disable carries a rationale comment" do
|
||||
rationale = rationale_above(@complexity_check)
|
||||
|
||||
refute rationale == [], "no comment block above the #{@complexity_check} entry"
|
||||
assert Enum.join(rationale, "\n") =~ ~r/crap/i
|
||||
end
|
||||
|
||||
# Regression guard: `mix credo.gen.config` would silently drop both of these,
|
||||
# and the custom check would stop running without one word of warning.
|
||||
test "the custom check and its load path survive" do
|
||||
config = default_config()
|
||||
|
||||
assert @custom_check in check_modules(config[:checks][:enabled])
|
||||
assert @custom_check_load_path in config[:requires]
|
||||
end
|
||||
|
||||
defp default_config do
|
||||
{config, _bindings} = Code.eval_file(@config_path)
|
||||
Enum.find(config.configs, &(&1.name == "default"))
|
||||
end
|
||||
|
||||
defp check_modules(checks) do
|
||||
Enum.map(checks, fn
|
||||
{module, _opts} -> module_name(module)
|
||||
module -> module_name(module)
|
||||
end)
|
||||
end
|
||||
|
||||
# `Atom.to_string/1` keeps the "Elixir." prefix that `inspect/1` drops; the
|
||||
# assertions read better (and diff better) without it.
|
||||
defp module_name(module) do
|
||||
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
|
||||
end
|
||||
|
||||
# The contiguous `#` block immediately above the check's own line. The entry
|
||||
# line is the one that is not itself a comment: a comment mentioning the
|
||||
# check by name must not be mistaken for the entry.
|
||||
defp rationale_above(check_name) do
|
||||
lines = @config_path |> File.read!() |> String.split("\n")
|
||||
|
||||
index =
|
||||
Enum.find_index(lines, fn line ->
|
||||
String.contains?(line, check_name) and not comment?(line)
|
||||
end)
|
||||
|
||||
lines
|
||||
|> Enum.take(index)
|
||||
|> Enum.reverse()
|
||||
|> Enum.take_while(&comment?/1)
|
||||
end
|
||||
|
||||
defp comment?(line), do: line |> String.trim() |> String.starts_with?("#")
|
||||
end
|
||||
@@ -390,8 +390,57 @@ defmodule Firehose.AccountsTest do
|
||||
end
|
||||
|
||||
describe "inspect/2 for the User module" do
|
||||
test "does not include password" do
|
||||
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
||||
# Rendering is `#Firehose.Accounts.User<__meta__: ..., id: 42, ...>`: a
|
||||
# single line that ends with `, ...>`. The tests below pin the stable parts
|
||||
# (module name, one line, redaction, field names and values) rather than
|
||||
# the full string, so a toolchain bump that changes formatting does not
|
||||
# fail them.
|
||||
test "redacts every field the schema marks redacted, by name and by value" do
|
||||
user = %User{
|
||||
id: 42,
|
||||
email: "shape-pin@example.com",
|
||||
password: "plaintext-secret-value",
|
||||
hashed_password: "hashed-secret-value"
|
||||
}
|
||||
|
||||
output = inspect(user)
|
||||
redacted = User.__schema__(:redact_fields)
|
||||
|
||||
# Guard against a vacuous loop if the schema key ever changes meaning.
|
||||
# (`match?` rather than a comparison against `[]`: the type checker
|
||||
# proves this schema's literal list non-empty and warns on the compare.)
|
||||
assert match?([_ | _], redacted)
|
||||
# The two secrets this schema declares; ties the loop to real fields.
|
||||
assert :password in redacted
|
||||
assert :hashed_password in redacted
|
||||
|
||||
for field <- redacted do
|
||||
refute output =~ Atom.to_string(field)
|
||||
refute output =~ Map.fetch!(user, field)
|
||||
end
|
||||
end
|
||||
|
||||
test "renders a single struct line with non-redacted fields and values" do
|
||||
user = %User{
|
||||
id: 42,
|
||||
email: "shape-pin@example.com",
|
||||
password: "plaintext-secret-value",
|
||||
hashed_password: "hashed-secret-value"
|
||||
}
|
||||
|
||||
output = inspect(user)
|
||||
redacted = User.__schema__(:redact_fields)
|
||||
|
||||
assert String.starts_with?(output, "#Firehose.Accounts.User<")
|
||||
assert String.ends_with?(output, ">")
|
||||
refute output =~ "\n"
|
||||
|
||||
assert output =~ "id: 42"
|
||||
assert output =~ ~s(email: "shape-pin@example.com")
|
||||
|
||||
for %{field: field} <- User.__info__(:struct), field not in redacted do
|
||||
assert output =~ Atom.to_string(field)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
defmodule Firehose.BoundaryTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mix.Dep.Lock
|
||||
|
||||
# Boundary enforcement itself is a compile-time property: the pin on it is the
|
||||
# warnings-fatal compile in `make verify` (and the recorded violation
|
||||
# demonstration in the yak notes). These tests pin the *declarations* that
|
||||
# compile gate reads, so a later edit that quietly re-opens the web -> Repo
|
||||
# hole shows up as a failure here instead of only as an architectural drift.
|
||||
#
|
||||
# They read the `Boundary` module attribute that `use Boundary` persists. That
|
||||
# is the same attribute boundary's own classifier reads
|
||||
# (deps/boundary/lib/boundary/definition.ex: `Module.register_attribute(...,
|
||||
# Boundary, persist: true)`), so this is the declaration as compiled, not a
|
||||
# grep of the source text.
|
||||
describe "boundary dependency" do
|
||||
test "boundary is a direct dependency, not one inherited from ex_crap" do
|
||||
assert {:boundary, requirement, opts} =
|
||||
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
|
||||
|
||||
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
|
||||
assert Version.match?(locked_version(:boundary), requirement)
|
||||
end
|
||||
end
|
||||
|
||||
describe "a sibling Repo boundary" do
|
||||
test "Firehose.Repo is its own top-level boundary with no dependencies" do
|
||||
definition = boundary(Firehose.Repo)
|
||||
|
||||
assert definition.opts[:top_level?] == true,
|
||||
"Firehose.Repo must be a sibling of Firehose/FirehoseWeb, not nested inside Firehose"
|
||||
|
||||
assert deps(Firehose.Repo) == [],
|
||||
"the Repo is the bottom of the graph: it may depend on nothing"
|
||||
end
|
||||
|
||||
test "Firehose may use Firehose.Repo" do
|
||||
assert Firehose.Repo in deps(Firehose)
|
||||
end
|
||||
|
||||
test "FirehoseWeb may not use Firehose.Repo" do
|
||||
refute Firehose.Repo in deps(FirehoseWeb),
|
||||
"web -> Repo is the hole this boundary closes; only contexts may touch the Repo"
|
||||
end
|
||||
end
|
||||
|
||||
describe "the context boundary's surface" do
|
||||
test "Firehose exports everything, so the web tier can keep using context modules" do
|
||||
assert boundary(Firehose).opts[:exports] == :all
|
||||
end
|
||||
end
|
||||
|
||||
# Application boot is the only thing that reaches from the context tier into
|
||||
# the web tier. Making it its own top-level boundary (boundary's README
|
||||
# prescribes exactly this for `mix phx.new` layouts) is what keeps the graph
|
||||
# acyclic; the alternative -- FirehoseWeb in Firehose's deps -- is rejected by
|
||||
# boundary with "dependency cycle found: Firehose -> FirehoseWeb -> Firehose".
|
||||
describe "the application boundary" do
|
||||
test "Firehose.Application depends on both tiers, and neither tier on the other's boot code" do
|
||||
assert boundary(Firehose.Application).opts[:top_level?] == true
|
||||
|
||||
assert Enum.sort(deps(Firehose.Application)) == [Firehose, FirehoseWeb]
|
||||
|
||||
refute FirehoseWeb in deps(Firehose),
|
||||
"Firehose depending on FirehoseWeb is the dependency cycle"
|
||||
end
|
||||
end
|
||||
|
||||
describe "enforcement wiring" do
|
||||
# A compiler that is registered after :elixir looks wired but never reports
|
||||
# anything, so the gate would be decoration. Position is the property.
|
||||
test "the boundary compiler runs before :elixir in this environment" do
|
||||
compilers = Mix.Project.config()[:compilers]
|
||||
|
||||
assert :boundary in compilers, "boundary compiler is not wired in #{Mix.env()}"
|
||||
|
||||
assert Enum.find_index(compilers, &(&1 == :boundary)) <
|
||||
Enum.find_index(compilers, &(&1 == :elixir)),
|
||||
"boundary must be listed before :elixir: it hooks after_compiler(:elixir, ...)"
|
||||
end
|
||||
end
|
||||
|
||||
defp boundary(module) do
|
||||
assert [definition] = module.__info__(:attributes)[Boundary],
|
||||
"#{inspect(module)} declares no boundary"
|
||||
|
||||
definition
|
||||
end
|
||||
|
||||
defp deps(module), do: Keyword.get(boundary(module).opts, :deps, [])
|
||||
|
||||
defp locked_version(app) do
|
||||
Lock.read()
|
||||
|> Map.fetch!(app)
|
||||
|> elem(2)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,226 @@
|
||||
defmodule FirehoseWeb.CoreComponentsTest do
|
||||
# Pure render tests: no conn, no database, so plain ExUnit.Case (the same
|
||||
# choice layouts_test.exs makes) instead of the ConnCase sandbox.
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias FirehoseWeb.CoreComponents
|
||||
|
||||
@checkbox_assigns %{
|
||||
type: "checkbox",
|
||||
name: "prefs[news]",
|
||||
id: "prefs_news",
|
||||
label: "News",
|
||||
value: "true",
|
||||
rest: %{},
|
||||
errors: [],
|
||||
field: nil
|
||||
}
|
||||
|
||||
@select_assigns %{
|
||||
type: "select",
|
||||
name: "role",
|
||||
id: "role",
|
||||
label: "Role",
|
||||
prompt: "Pick one",
|
||||
options: [{"Admin", "admin"}, {"Editor", "editor"}],
|
||||
value: "admin",
|
||||
multiple: false,
|
||||
rest: %{},
|
||||
errors: [],
|
||||
field: nil
|
||||
}
|
||||
|
||||
@textarea_assigns %{
|
||||
type: "textarea",
|
||||
name: "body",
|
||||
id: "body",
|
||||
label: "Body",
|
||||
value: "hello world",
|
||||
rest: %{},
|
||||
errors: [],
|
||||
field: nil
|
||||
}
|
||||
|
||||
describe "input/1 checkbox" do
|
||||
test "a checked box renders checked when the value is true" do
|
||||
html = render_checkbox(%{})
|
||||
|
||||
assert attr(html, "input[type='checkbox']", "name") == "prefs[news]"
|
||||
assert attr(html, "input[type='checkbox']", "value") == "true"
|
||||
assert attr(html, "input[type='checkbox']", "checked") == ""
|
||||
assert text(html) =~ "News"
|
||||
end
|
||||
|
||||
test "an unchecked box still posts false for the field" do
|
||||
html = render_checkbox(%{value: "false"})
|
||||
|
||||
assert attr(html, "input[type='checkbox']", "checked") == nil
|
||||
|
||||
# Markup is the behaviour here: the hidden field carrying the same name is
|
||||
# what makes an unchecked box post "false" instead of posting nothing.
|
||||
assert attr(html, "input[type='hidden']", "name") == "prefs[news]"
|
||||
assert attr(html, "input[type='hidden']", "value") == "false"
|
||||
end
|
||||
|
||||
test "shows the error message when the field has errors" do
|
||||
html = render_checkbox(%{errors: ["can't be blank"]})
|
||||
|
||||
assert text(html) =~ "can't be blank"
|
||||
end
|
||||
end
|
||||
|
||||
describe "input/1 select" do
|
||||
test "renders the prompt, every option, and preselects the current value" do
|
||||
html = render_select(%{})
|
||||
|
||||
assert text(html) =~ "Pick one"
|
||||
assert text(html) =~ "Admin"
|
||||
assert text(html) =~ "Editor"
|
||||
# The outcome, not the serialisation: the option carrying the current
|
||||
# value is selected, the other one is not.
|
||||
assert attr(html, "option[value='admin']", "selected") == ""
|
||||
assert attr(html, "option[value='editor']", "selected") == nil
|
||||
end
|
||||
|
||||
test "marks the select erroneous and shows the message when given errors" do
|
||||
html = render_select(%{errors: ["is invalid"]})
|
||||
|
||||
assert text(html) =~ "is invalid"
|
||||
assert "select-error" in classes(html, "select")
|
||||
end
|
||||
end
|
||||
|
||||
describe "input/1 textarea" do
|
||||
test "keeps the value inside the textarea" do
|
||||
html = render_textarea(%{value: "hello world"})
|
||||
|
||||
# normalize_value/2 prefixes the content with a newline, so compare the
|
||||
# trimmed text: what matters is the value, not the surrounding whitespace.
|
||||
assert String.trim(text(html, "textarea")) == "hello world"
|
||||
end
|
||||
|
||||
test "renders an empty textarea for a nil value instead of the text nil" do
|
||||
html = render_textarea(%{value: nil})
|
||||
|
||||
assert String.trim(text(html, "textarea")) == ""
|
||||
refute html =~ "nil"
|
||||
end
|
||||
|
||||
test "keeps a leading newline in the value" do
|
||||
# HTML drops one leading newline of a <textarea> before the browser sees
|
||||
# it, so normalize_value/2 prefixes the content with one: a value that
|
||||
# starts with a newline survives, and one that does not gain one.
|
||||
html = render_textarea(%{value: "\nsecond line"})
|
||||
|
||||
assert text(html, "textarea") == "\nsecond line"
|
||||
end
|
||||
|
||||
test "marks the textarea erroneous and shows the message when given errors" do
|
||||
html = render_textarea(%{errors: ["is too short"]})
|
||||
|
||||
assert text(html) =~ "is too short"
|
||||
assert "textarea-error" in classes(html, "textarea")
|
||||
end
|
||||
end
|
||||
|
||||
describe "button/1" do
|
||||
test "renders a button element when given no destination" do
|
||||
html = render_button(%{})
|
||||
|
||||
assert count(html, "button") == 1
|
||||
assert count(html, "a") == 0
|
||||
assert String.trim(text(html)) == "Save"
|
||||
end
|
||||
|
||||
test "renders a link to the destination when given an href, keeping the label" do
|
||||
html = render_button(%{rest: %{href: "/somewhere"}})
|
||||
|
||||
assert attr(html, "a", "href") == "/somewhere"
|
||||
assert String.trim(text(html)) == "Save"
|
||||
assert count(html, "button") == 0
|
||||
end
|
||||
|
||||
test "renders a live-navigation link when given navigate" do
|
||||
html = render_button(%{rest: %{navigate: "/elsewhere"}})
|
||||
|
||||
# data-phx-link is the only thing distinguishing a live-navigation link
|
||||
# from a full-page one, so it is the behaviour to pin here.
|
||||
assert attr(html, "a", "data-phx-link") == "redirect"
|
||||
assert attr(html, "a", "href") == "/elsewhere"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_checkbox(overrides) do
|
||||
render_component(&CoreComponents.input/1, Map.merge(@checkbox_assigns, overrides))
|
||||
end
|
||||
|
||||
defp render_select(overrides) do
|
||||
render_component(&CoreComponents.input/1, Map.merge(@select_assigns, overrides))
|
||||
end
|
||||
|
||||
defp render_textarea(overrides) do
|
||||
render_component(&CoreComponents.input/1, Map.merge(@textarea_assigns, overrides))
|
||||
end
|
||||
|
||||
defp render_button(overrides) do
|
||||
assigns =
|
||||
Map.merge(
|
||||
%{
|
||||
variant: nil,
|
||||
class: "",
|
||||
rest: %{},
|
||||
# A slot, as HEEx builds it: a list of entries whose inner_block takes
|
||||
# (argument, changed).
|
||||
inner_block: [%{inner_block: fn _, _ -> "Save" end}]
|
||||
},
|
||||
overrides
|
||||
)
|
||||
|
||||
render_component(&CoreComponents.button/1, assigns)
|
||||
end
|
||||
|
||||
# Every attribute assertion goes through here, so the LazyHTML mechanism lives
|
||||
# in one place: one value per matched element, nil when the element (or the
|
||||
# attribute) is absent, "" for a valueless attribute such as `checked`.
|
||||
defp attr(html, selector, name) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> LazyHTML.attribute(name)
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
# The class tokens of every matched element, for the "this field is marked
|
||||
# erroneous" assertions.
|
||||
defp classes(html, selector) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> LazyHTML.attribute("class")
|
||||
|> Enum.flat_map(&String.split/1)
|
||||
end
|
||||
|
||||
defp nodes(html, selector) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(selector)
|
||||
end
|
||||
|
||||
defp count(html, selector) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> Enum.count()
|
||||
end
|
||||
|
||||
defp text(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.text()
|
||||
end
|
||||
|
||||
defp text(html, selector) do
|
||||
html
|
||||
|> nodes(selector)
|
||||
|> LazyHTML.text()
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,19 @@
|
||||
defmodule FirehoseWeb.BlogControllerTest do
|
||||
use FirehoseWeb.ConnCase, async: false
|
||||
|
||||
# Content churns; the behavior under test does not. Pick deterministically
|
||||
# from the compiled posts instead of pinning slugs - the pinned "hello-world"
|
||||
# post was deleted in 1709cb7 and ten tests silently rotted with it.
|
||||
defp a_draft_post do
|
||||
Enum.find(Firehose.EngineeringBlog.all_posts(), &(&1.published == false)) ||
|
||||
raise "no draft (published: false) engineering post to exercise status banners"
|
||||
end
|
||||
|
||||
defp a_live_post do
|
||||
Enum.find(Firehose.EngineeringBlog.all_posts(), &(Blogex.Post.visibility(&1) == :live)) ||
|
||||
raise "no live engineering post to exercise og meta tags"
|
||||
end
|
||||
|
||||
describe "GET /blog/:blog_id (index) - date filtering" do
|
||||
test "does not show future-dated posts", %{conn: conn} do
|
||||
html = conn |> get(~p"/blog/engineering") |> html_response(200)
|
||||
@@ -26,7 +39,8 @@ defmodule FirehoseWeb.BlogControllerTest do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
test "authenticated user sees draft banner on draft post", %{conn: conn} do
|
||||
response = conn |> get(~p"/blog/engineering/hello-world")
|
||||
post = a_draft_post()
|
||||
response = conn |> get(~p"/blog/engineering/#{post.id}")
|
||||
|
||||
assert html_response(response, 200) =~ "Draft"
|
||||
assert response.resp_body =~ "not published"
|
||||
@@ -49,9 +63,11 @@ defmodule FirehoseWeb.BlogControllerTest do
|
||||
|
||||
describe "GET /blog/:blog_id/:slug - no banners for unauthenticated" do
|
||||
test "unauthenticated user sees no banner on draft post", %{conn: conn} do
|
||||
post = a_draft_post()
|
||||
|
||||
response =
|
||||
conn
|
||||
|> get(~p"/blog/engineering/hello-world")
|
||||
|> get(~p"/blog/engineering/#{post.id}")
|
||||
|> html_response(200)
|
||||
|
||||
refute response =~ "post-status-banner"
|
||||
@@ -69,26 +85,30 @@ defmodule FirehoseWeb.BlogControllerTest do
|
||||
|
||||
describe "GET /blog/:blog_id/:slug - Open Graph meta tags" do
|
||||
test "meta tags include og_title", %{conn: conn} do
|
||||
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
|
||||
post = a_live_post()
|
||||
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
|
||||
|
||||
assert response =~ ~s(<meta property="og:title")
|
||||
assert response =~ "Hello World"
|
||||
assert response =~ post.title
|
||||
end
|
||||
|
||||
test "meta tags include og_description", %{conn: conn} do
|
||||
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
|
||||
post = a_live_post()
|
||||
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
|
||||
|
||||
assert response =~ ~s(<meta property="og:description")
|
||||
end
|
||||
|
||||
test "meta tags include og_type article", %{conn: conn} do
|
||||
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
|
||||
post = a_live_post()
|
||||
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
|
||||
|
||||
assert response =~ ~s(<meta property="og:type" content="article")
|
||||
end
|
||||
|
||||
test "meta tags include twitter:card", %{conn: conn} do
|
||||
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
|
||||
post = a_live_post()
|
||||
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
|
||||
|
||||
assert response =~ ~s(<meta name="twitter:card" content="summary_large_image")
|
||||
end
|
||||
@@ -101,10 +121,11 @@ defmodule FirehoseWeb.BlogControllerTest do
|
||||
end
|
||||
|
||||
test "meta tags have correct og_url", %{conn: conn} do
|
||||
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
|
||||
post = a_live_post()
|
||||
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
|
||||
|
||||
assert response =~
|
||||
~s(<meta property="og:url" content="http://localhost:4002/blog/engineering/hello-world")
|
||||
~s(<meta property="og:url" content="http://localhost:4002/blog/engineering/#{post.id}")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
defmodule FirehoseWeb.BlogTagsTest do
|
||||
use FirehoseWeb.ConnCase
|
||||
|
||||
# Content churns; the behavior under test does not. The newest live post
|
||||
# with a tag is a deterministic stand-in, and tag pages sort date-descending,
|
||||
# so it is on page 1 whatever else is published under that tag. (The pinned
|
||||
# "Hello World" post was deleted in 1709cb7 and this test silently rotted.)
|
||||
defp a_newest_live_post_with_tag do
|
||||
Enum.find(Firehose.EngineeringBlog.all_posts(), fn post ->
|
||||
Blogex.Post.visibility(post) == :live and post.tags != []
|
||||
end) || raise "no live tagged engineering post to exercise the tag page"
|
||||
end
|
||||
|
||||
describe "engineering blog tags" do
|
||||
test "GET /blog/engineering/tag/:tag shows tag page with filtered posts", %{conn: conn} do
|
||||
body = conn |> get("/blog/engineering/tag/elixir") |> html_response(200)
|
||||
assert body =~ ~s(tagged "elixir")
|
||||
post = a_newest_live_post_with_tag()
|
||||
tag = hd(post.tags)
|
||||
|
||||
body = conn |> get("/blog/engineering/tag/#{tag}") |> html_response(200)
|
||||
assert body =~ ~s(tagged "#{tag}")
|
||||
assert body =~ "Engineering Blog"
|
||||
assert body =~ "Hello World"
|
||||
assert body =~ post.title
|
||||
end
|
||||
|
||||
test "GET /blog/engineering/tag/:tag shows empty list for nonexistent tag", %{conn: conn} do
|
||||
|
||||
@@ -9,6 +9,14 @@ defmodule FirehoseWeb.BlogTest do
|
||||
body
|
||||
end
|
||||
|
||||
# Content churns; the behavior under test does not. The newest live post is
|
||||
# a deterministic stand-in - the pinned "hello-world" post was deleted in
|
||||
# 1709cb7 and the slug tests silently rotted with it.
|
||||
defp a_live_post do
|
||||
Enum.find(Firehose.EngineeringBlog.all_posts(), &(Blogex.Post.visibility(&1) == :live)) ||
|
||||
raise "no live engineering post to exercise the post pages"
|
||||
end
|
||||
|
||||
describe "engineering blog (HTML)" do
|
||||
test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do
|
||||
body = visit_blog_page(conn, "engineering")
|
||||
@@ -17,8 +25,32 @@ defmodule FirehoseWeb.BlogTest do
|
||||
end
|
||||
|
||||
test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do
|
||||
body = visit_blog_page(conn, "engineering", "/hello-world")
|
||||
assert body =~ "Hello World"
|
||||
post = a_live_post()
|
||||
body = visit_blog_page(conn, "engineering", "/#{post.id}")
|
||||
assert body =~ post.title
|
||||
end
|
||||
|
||||
test "GET /blog/engineering?page=2 shows the next page of older posts", %{conn: conn} do
|
||||
# Page size is 10 (`Blogex.Blog.paginate/2`, blogex/lib/blogex/blog.ex:121)
|
||||
# and the test env lists drafts (`show_drafts: true`,
|
||||
# app/config/config.exs), so the 34 visible engineering posts (the
|
||||
# 2099-dated future post is excluded by `all_posts/0`) fill 4 pages.
|
||||
# Page 2, date-descending, runs 2026-07-20 .. 2026-05-14.
|
||||
body = visit_blog_page(conn, "engineering", "?page=2")
|
||||
|
||||
# The pager reports the page the reader asked for, not page 1
|
||||
# (markup: blogex/lib/blogex/components.ex).
|
||||
assert body =~ ~r/Page 2 of \d+/
|
||||
|
||||
# "Synthetic TDD" (2026-07-13) sits mid-page-2: five posts of headroom
|
||||
# before new publications would push it to page 3. Matched as an anchor
|
||||
# because the description of the page-2 post above it also says
|
||||
# "Synthetic TDD".
|
||||
assert body =~ ~s|>Synthetic TDD</a>|
|
||||
|
||||
# The newest post is unambiguously on page 1, so page 2 is a genuinely
|
||||
# different set of posts rather than page 1 relabelled.
|
||||
refute body =~ "I reduced the attack surface"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,6 +111,56 @@ defmodule FirehoseWeb.BlogTest do
|
||||
|
||||
assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\""
|
||||
end
|
||||
|
||||
test "rendered post page carries the screen-reader hint for external links", %{conn: conn} do
|
||||
body =
|
||||
conn |> get("/blog/engineering/delayed-gratification-in-ai-summer") |> html_response(200)
|
||||
|
||||
assert body =~ ~s|<span class="blogex-external-hint"> (opens in a new tab)</span>|
|
||||
end
|
||||
end
|
||||
|
||||
describe "internal links in compiled posts" do
|
||||
# `Blogex.Blog._validate_links/2` validates `post.body` after
|
||||
# NimblePublisher rendered it to HTML. If extraction cannot read that HTML,
|
||||
# every post looks link-free and compile-time validation is vacuous - which
|
||||
# is the failure the real posts below pin against.
|
||||
defp posts_with_internal_links do
|
||||
Enum.filter(Firehose.EngineeringBlog.all_posts(), fn post ->
|
||||
Blogex.LinkValidator.extract_links(post.body) != []
|
||||
end)
|
||||
end
|
||||
|
||||
test "the validator sees internal links in real compiled posts" do
|
||||
refute posts_with_internal_links() == []
|
||||
end
|
||||
|
||||
test "the post that links to swim-the-test exposes that link" do
|
||||
post =
|
||||
Enum.find(posts_with_internal_links(), &(&1.id == "delayed-gratification-in-ai-summer"))
|
||||
|
||||
assert post, "expected the compiled post to be visible with internal links extracted"
|
||||
|
||||
assert "/blog/engineering/swim-the-test" in Blogex.LinkValidator.extract_links(post.body)
|
||||
end
|
||||
|
||||
test "no internal link in a real compiled post is invisible to extraction" do
|
||||
# Deliberately permissive scan (any <a> whose href appears in any
|
||||
# position, quoted or not): every /blog/ link it finds must also come out
|
||||
# of extract_links/1, or a bad one could never be reported.
|
||||
links_in_body = fn body ->
|
||||
~r/<a\s[^>]*?href=["']?(\/blog\/[^"'\s>]+)/
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, link] -> link end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
for post <- Firehose.EngineeringBlog.all_posts(),
|
||||
link <- links_in_body.(post.body) do
|
||||
assert link in Blogex.LinkValidator.extract_links(post.body),
|
||||
"#{post.id}: #{link} is in the compiled body but not extracted"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "input validation" do
|
||||
@@ -118,11 +200,16 @@ defmodule FirehoseWeb.BlogTest do
|
||||
end
|
||||
|
||||
test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do
|
||||
assert %{"id" => "hello-world", "title" => "Hello World"} =
|
||||
post = a_live_post()
|
||||
|
||||
assert %{"id" => id, "title" => title} =
|
||||
conn
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> get("/api/blog/engineering/hello-world")
|
||||
|> get("/api/blog/engineering/#{post.id}")
|
||||
|> json_response(200)
|
||||
|
||||
assert id == post.id
|
||||
assert title == post.title
|
||||
end
|
||||
|
||||
test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do
|
||||
|
||||
@@ -21,6 +21,17 @@ defmodule FirehoseWeb.PageControllerTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /contact" do
|
||||
test "renders the contact page inside the app layout", %{conn: conn} do
|
||||
body = conn |> get(~p"/contact") |> html_response(200)
|
||||
|
||||
# Page content (contact.html.heex) ...
|
||||
assert body =~ "Get in touch"
|
||||
# ... wrapped by the app layout, which is where the site nav lives.
|
||||
assert body =~ ~s|/blog/engineering|
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET / when authenticated" do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
|
||||
@@ -61,13 +61,37 @@ defmodule FirehoseWeb.UserSessionControllerTest do
|
||||
assert html =~ "Keep me logged in on this device"
|
||||
end
|
||||
|
||||
test "raises error for invalid token", %{conn: conn} do
|
||||
# The two ways a magic link can be dead arrive here through different arms
|
||||
# of `Firehose.Accounts.get_user_by_magic_link_token/1`: a token that is
|
||||
# not base64 at all fails to decode (`UserToken.verify_magic_link_token_query/1`
|
||||
# returns `:error`), while a well-formed token simply matches no row. Both
|
||||
# must land the visitor back on the log-in page with the error flash, not
|
||||
# raise or 404.
|
||||
test "sends the visitor back to log-in when the magic link token is not valid base64",
|
||||
%{conn: conn} do
|
||||
# 13 characters is not decodable with `padding: false`; the 0 hits on the
|
||||
# magic-link `:error` line in cover/ show this test is what covers it.
|
||||
assert :error = Base.url_decode64("invalid-token", padding: false)
|
||||
|
||||
response = conn |> get(~p"/users/log-in/invalid-token")
|
||||
assert redirected_to(response) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
|
||||
"Magic link is invalid or it has expired."
|
||||
end
|
||||
|
||||
test "sends the visitor back to log-in for a decodable but unknown token", %{conn: conn} do
|
||||
# The assertion keeps this case distinct from the not-base64 one above:
|
||||
# if this token ever stopped decoding, the two tests would silently pin
|
||||
# the same arm.
|
||||
assert {:ok, _decoded} = Base.url_decode64("invalidtoken", padding: false)
|
||||
|
||||
response = conn |> get(~p"/users/log-in/invalidtoken")
|
||||
assert redirected_to(response) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
|
||||
"Magic link is invalid or it has expired."
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/log-in - email and password" do
|
||||
|
||||
@@ -87,6 +87,41 @@ defmodule FirehoseWeb.EditorDashboardLiveTest do
|
||||
refute html =~ "Live Post"
|
||||
end
|
||||
|
||||
test "switching Drafts -> Scheduled -> Drafts shows the matching panel", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/editor/dashboard")
|
||||
|
||||
# Both panels are always in the DOM, and only the inactive one is marked
|
||||
# `hidden`: the panel's own class is the only server-side signal of what
|
||||
# the reader sees. The panel-scoped content assertions below keep this
|
||||
# from being a class check alone.
|
||||
assert has_element?(view, "#drafts-tab")
|
||||
refute has_element?(view, "#drafts-tab.hidden")
|
||||
assert has_element?(view, "#scheduled-tab.hidden")
|
||||
|
||||
drafts_html = view |> element("#drafts-tab") |> render()
|
||||
assert drafts_html =~ "Draft Post"
|
||||
refute drafts_html =~ "Scheduled Post"
|
||||
|
||||
view |> element(~s(button[phx-value-tab="scheduled"])) |> render_click()
|
||||
|
||||
assert has_element?(view, "#scheduled-tab")
|
||||
refute has_element?(view, "#scheduled-tab.hidden")
|
||||
assert has_element?(view, "#drafts-tab.hidden")
|
||||
|
||||
scheduled_html = view |> element("#scheduled-tab") |> render()
|
||||
assert scheduled_html =~ "Scheduled Post"
|
||||
refute scheduled_html =~ "Draft Post"
|
||||
|
||||
view |> element(~s(button[phx-value-tab="drafts"])) |> render_click()
|
||||
|
||||
refute has_element?(view, "#drafts-tab.hidden")
|
||||
assert has_element?(view, "#scheduled-tab.hidden")
|
||||
|
||||
drafts_again_html = view |> element("#drafts-tab") |> render()
|
||||
assert drafts_again_html =~ "Draft Post"
|
||||
refute drafts_again_html =~ "Scheduled Post"
|
||||
end
|
||||
|
||||
test "renders with app layout including menu", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
|
||||
# Verify the app layout is applied by checking for menu elements
|
||||
|
||||
@@ -3,6 +3,7 @@ alias FirehoseWeb.MicroprintsLive
|
||||
defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
use FirehoseWeb.ConnCase, async: true
|
||||
|
||||
import FirehoseWeb.MicroprintsCase
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "scan_source_files/0" do
|
||||
@@ -81,18 +82,15 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints")
|
||||
|
||||
# Expand file A
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_a)
|
||||
|
||||
# Verify file A shows "Collapse" button (it's expanded)
|
||||
html = render(view)
|
||||
assert html =~ ~s(phx-value-path="#{file_a}")
|
||||
assert html =~ "Collapse"
|
||||
|
||||
# Highlight a line in file B by dispatching the highlight_line event
|
||||
# (microprint renders multiple rect segments per line, element() can't pick one)
|
||||
render_click(view, "highlight_line", %{"line" => "1", "path" => file_b})
|
||||
# Highlight a line in file B
|
||||
click_microprint_rect(view, file_b, 1)
|
||||
|
||||
# file A should be collapsed when highlighting a different file
|
||||
html = render(view)
|
||||
@@ -112,9 +110,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints")
|
||||
|
||||
# Expand file A
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_a)
|
||||
|
||||
# Capture file A's source content from the rendered HTML
|
||||
html_a = render(view)
|
||||
@@ -123,9 +119,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
"File A source should be visible when expanded"
|
||||
|
||||
# Expand file B (should auto-collapse A)
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_b}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_b)
|
||||
|
||||
# Capture file B's source content
|
||||
html_b = render(view)
|
||||
@@ -144,6 +138,34 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "collapsing an expanded file" do
|
||||
test "hides the source lines and brings the Expand control back", %{conn: conn} do
|
||||
# The URL route is used rather than clicking Expand: `restore_state_from_params/2`
|
||||
# validates the param against the scanned cards (Enum.find over @microprints),
|
||||
# so a real scanned path is required either way - see the "unknown expanded
|
||||
# path in URL is ignored" test for the other half of that contract.
|
||||
path = MicroprintsLive.scan_source_files() |> List.first()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints?expanded=#{path}")
|
||||
|
||||
assert has_element?(view, "#line-1"),
|
||||
"restored expansion should render the file's source lines"
|
||||
|
||||
assert has_element?(view, ~s(button[phx-value-path="#{path}"]), "Collapse")
|
||||
|
||||
collapse_file(view, path)
|
||||
|
||||
refute has_element?(view, "#line-1"),
|
||||
"collapsing should stop rendering the source viewer"
|
||||
|
||||
refute render(view) =~ "Collapse",
|
||||
"no card should still be expanded once the only expanded one is collapsed"
|
||||
|
||||
assert has_element?(view, ~s(button[phx-value-path="#{path}"]), "Expand"),
|
||||
"the card's control should be Expand again"
|
||||
end
|
||||
end
|
||||
|
||||
describe "app layout with menu" do
|
||||
test "renders the microprints page", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/microprints")
|
||||
@@ -190,9 +212,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/microprints")
|
||||
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|
||||
|> render_click()
|
||||
expand_file(view, file_a)
|
||||
|
||||
# URL should contain expanded param after clicking Expand
|
||||
html = render(view)
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
defmodule FirehoseWeb.SourceViewerTest do
|
||||
use FirehoseWeb.ConnCase, async: true
|
||||
|
||||
import FirehoseWeb.MicroprintsCase
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias FirehoseWeb.MicroprintsLive
|
||||
|
||||
defp expand_file(view, path) do
|
||||
view
|
||||
|> element("button[phx-value-path=\"#{path}\"]", "Expand")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
defp click_microprint_rect(view, path, line) do
|
||||
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
|
||||
end
|
||||
|
||||
describe "line elements" do
|
||||
test "render with unique ids when file is expanded", %{conn: conn} do
|
||||
files = MicroprintsLive.scan_source_files()
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
defmodule FirehoseWeb.ThemeStylesheetTest do
|
||||
@moduledoc """
|
||||
Guards the theme's source files against the two defects the theme yak records.
|
||||
|
||||
daisyUI 5 stores a *complete* colour in every `--color-*` custom property
|
||||
(`--color-base-content: oklch(25% 0.015 50)`), so `oklch(var(--color-…))`
|
||||
re-wraps a complete value and the browser drops the whole declaration. The
|
||||
visible symptom was a transparent chip background and unstyled links.
|
||||
|
||||
Each theme's `prefersdark` option makes daisyUI emit an *unscoped*
|
||||
`@media (prefers-color-scheme: dark) { :root { … } }`, which lands after the
|
||||
light block and wins on equal specificity: an explicit `data-theme="light"`
|
||||
rendered dark on a dark-OS browser. Theme selection now lives in the inline
|
||||
script in `root.html.heex`, which always writes an explicit `data-theme`
|
||||
before first paint, so no stylesheet rule may follow the OS on its own.
|
||||
|
||||
These read the two source files as text: they are regression guards, not
|
||||
behavioural tests. The authoritative check for *rendered* colour and theme
|
||||
selection is the factory probe (`mastra-factory/scripts/theme-probe.mjs`),
|
||||
which needs a browser; `make verify` never parses or renders CSS.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
# `__DIR__`-relative so the assertions do not depend on the working directory.
|
||||
@stylesheet Path.expand("../../assets/css/app.css", __DIR__)
|
||||
@root_layout Path.expand("../../lib/firehose_web/components/layouts/root.html.heex", __DIR__)
|
||||
@layouts Path.expand("../../lib/firehose_web/components/layouts.ex", __DIR__)
|
||||
|
||||
# Measured by the factory probe (chromium, 2026-10-29) for `.blogex-post-meta`
|
||||
# in the light theme: base-content over base-100 composited at 0.6 opacity is
|
||||
# 4.16:1, below the 4.5:1 acceptance floor. The alpha the floor needs is
|
||||
# 0.625; anything below that fails.
|
||||
@meta_min_opacity 0.625
|
||||
|
||||
# Same probe, same day, for `--color-primary` as *text* on `--color-base-100`
|
||||
# in the light theme - what pagination links, body links and the post-title
|
||||
# hover now use. 58% lightness measured 4.18:1; 56% is 4.54:1, too thin a
|
||||
# margin; 54% is 4.93:1 (and 4.52:1 on base-200, the lightest chip ground).
|
||||
@light_primary_max_lightness 0.54
|
||||
|
||||
describe "colour tokens" do
|
||||
test "theme colour tokens are used as complete values" do
|
||||
offenders =
|
||||
@stylesheet
|
||||
|> read()
|
||||
|> String.split("\n")
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.filter(fn {line, _number} -> line =~ ~r/oklch\(\s*var\(--color-/ end)
|
||||
|
||||
assert offenders == [],
|
||||
"""
|
||||
oklch(var(--color-…)) is invalid: the --color-* properties already hold a
|
||||
complete oklch(…) value, so the declaration is dropped. Use var(--color-…)
|
||||
directly, or color-mix() where a derived colour is genuinely needed.
|
||||
|
||||
Offending line(s):
|
||||
#{Enum.map_join(offenders, "\n", fn {line, number} -> " #{number}: #{String.trim(line)}" end)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
describe "theme selection" do
|
||||
test "the head script always writes an explicit data-theme" do
|
||||
script = theme_script()
|
||||
|
||||
# "system" is resolved here, from the OS preference, before first paint.
|
||||
assert script =~ ~r/matchMedia\(\s*["']\(prefers-color-scheme:\s*dark\)["']\s*\)/
|
||||
assert script =~ ~r/setAttribute\(\s*["']data-theme["']/
|
||||
# Removing the attribute is what let the unstyled root fall back to the
|
||||
# OS-driven media block, so "system" must never be expressed that way.
|
||||
refute script =~ "removeAttribute"
|
||||
end
|
||||
|
||||
test "an OS preference change re-resolves only while 'system' is the choice" do
|
||||
assert theme_script() =~
|
||||
~r/addEventListener\(\s*["']change["'][\s\S]*?stored\(\)\s*===\s*["']system["']/
|
||||
end
|
||||
|
||||
test "the toggle knob follows the choice, not the resolved palette" do
|
||||
# A system choice can resolve to the dark palette; the indicator must still
|
||||
# sit on the system icon, or the toggle lies about what is selected.
|
||||
layouts = read(@layouts)
|
||||
|
||||
assert layouts =~ "[[data-theme-choice=light]_&]:left-1/3"
|
||||
assert layouts =~ "[[data-theme-choice=dark]_&]:left-2/3"
|
||||
refute layouts =~ "[[data-theme=light]_&]"
|
||||
refute layouts =~ "[[data-theme=dark]_&]"
|
||||
end
|
||||
|
||||
# The human's decision (REVIEW.md, Q1): "default to light theme, and make
|
||||
# setting explicit". Light is what an unset preference resolves to; "system"
|
||||
# is an explicit choice, not the default.
|
||||
test "no stored preference means the light theme" do
|
||||
assert theme_script() =~ ~r/apply\(\s*stored\(\)\s*\|\|\s*["']light["']\s*\)/
|
||||
end
|
||||
|
||||
test "no theme follows the OS preference on its own" do
|
||||
# `prefersdark: true` mints the unscoped media block that outranked the
|
||||
# explicit attribute. Selection is the script's job; the stylesheet's
|
||||
# default (light) must apply whenever an attribute is absent.
|
||||
assert theme_option("light", "prefersdark") == "false"
|
||||
assert theme_option("dark", "prefersdark") == "false"
|
||||
end
|
||||
end
|
||||
|
||||
describe "post meta contrast" do
|
||||
test "the post meta is dimmed no further than the contrast floor allows" do
|
||||
opacity = declaration(".blogex-post-meta", "opacity")
|
||||
|
||||
assert opacity >= @meta_min_opacity,
|
||||
"""
|
||||
.blogex-post-meta has opacity #{opacity}, which composites base-content over
|
||||
base-100 below the 4.5:1 floor (probe: 0.6 -> 4.16:1, 0.625 -> 4.51:1).
|
||||
Raise it, or the meta line is unreadable in the light theme.
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
describe "light theme link colour" do
|
||||
test "primary is dark enough to be text on base-100" do
|
||||
lightness = lightness(theme_option("light", "--color-primary"))
|
||||
|
||||
assert lightness <= @light_primary_max_lightness,
|
||||
"""
|
||||
The light theme's --color-primary has #{lightness} lightness, so link text
|
||||
(pagination, body links, the post-title hover) composites below 4.5:1 on
|
||||
base-100: the probe measured 4.18:1 at 0.58. 0.54 measures 4.93:1.
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp read(path), do: File.read!(path)
|
||||
|
||||
# OKLCH lightness as a 0..1 fraction; daisyUI writes either `54%` or `0.54`.
|
||||
defp lightness(value) do
|
||||
case Regex.run(~r/^oklch\(\s*([\d.]+)\s*(%?)/, value) do
|
||||
[_, number, "%"] -> number |> Float.parse() |> elem(0) |> Kernel./(100)
|
||||
[_, number, ""] -> number |> Float.parse() |> elem(0)
|
||||
_ -> flunk("cannot read the lightness of #{inspect(value)}")
|
||||
end
|
||||
end
|
||||
|
||||
# The declarations of a single plain rule (`selector { … }`).
|
||||
defp declaration(selector, property) do
|
||||
selector
|
||||
|> rule_body()
|
||||
|> then(fn body ->
|
||||
case Regex.run(~r/^\s*#{property}:\s*([^;]+);/m, body) do
|
||||
[_, value] -> value |> String.trim() |> String.to_float()
|
||||
nil -> flunk("no #{property} in #{selector}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp rule_body(selector) do
|
||||
case Regex.run(~r/#{Regex.escape(selector)}\s*\{([^}]*)\}/, read(@stylesheet)) do
|
||||
[_, body] -> body
|
||||
nil -> flunk("no rule for #{selector} in #{@stylesheet}")
|
||||
end
|
||||
end
|
||||
|
||||
# The inline script in <head>, not the deferred `app.js` tag.
|
||||
defp theme_script do
|
||||
@root_layout
|
||||
|> read()
|
||||
|> then(&Regex.scan(~r/<script[^>]*>(.*?)<\/script>/s, &1, capture: :all_but_first))
|
||||
|> List.flatten()
|
||||
|> Enum.find(fn body -> body =~ "data-theme" end) ||
|
||||
flunk("no inline theme script in #{@root_layout}")
|
||||
end
|
||||
|
||||
# The body of the `@plugin "../vendor/daisyui-theme" { … }` block for `name`.
|
||||
defp theme_block(name) do
|
||||
@stylesheet
|
||||
|> read()
|
||||
|> String.split(~s(@plugin "../vendor/daisyui-theme" {))
|
||||
|> Enum.find(fn block ->
|
||||
Enum.any?(String.split(block, "\n"), &(&1 |> String.trim() == ~s(name: "#{name}";)))
|
||||
end) || flunk("no daisyui-theme block named #{inspect(name)} in #{@stylesheet}")
|
||||
end
|
||||
|
||||
defp theme_option(name, option) do
|
||||
case Regex.run(~r/^\s*#{option}:\s*([^;]+);/m, theme_block(name)) do
|
||||
[_, value] -> String.trim(value)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
alias Firehose.Accounts
|
||||
alias Firehose.Accounts.Scope
|
||||
alias FirehoseWeb.UserAuth
|
||||
alias Phoenix.LiveView.Socket
|
||||
|
||||
import Firehose.AccountsFixtures
|
||||
|
||||
@@ -27,6 +28,13 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
assert Accounts.get_user_by_session_token(token)
|
||||
end
|
||||
|
||||
test "stores a live socket id derived from the session token", %{conn: conn, user: user} do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
token = get_session(conn, :user_token)
|
||||
|
||||
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
|
||||
end
|
||||
|
||||
test "clears everything previously stored in the session", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
|
||||
refute get_session(conn, :to_be_removed)
|
||||
@@ -113,6 +121,21 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
refute Accounts.get_user_by_session_token(user_token)
|
||||
end
|
||||
|
||||
test "broadcasts a disconnect on the session's live socket id", %{conn: conn, user: user} do
|
||||
logged_in_conn = UserAuth.log_in_user(conn, user)
|
||||
token = get_session(logged_in_conn, :user_token)
|
||||
live_socket_id = get_session(logged_in_conn, :live_socket_id)
|
||||
FirehoseWeb.Endpoint.subscribe(live_socket_id)
|
||||
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, live_socket_id)
|
||||
|> UserAuth.log_out_user()
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
|
||||
refute_receive %Phoenix.Socket.Broadcast{topic: ^live_socket_id}
|
||||
end
|
||||
|
||||
test "works even if user is already logged out", %{conn: conn} do
|
||||
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
|
||||
refute get_session(conn, :user_token)
|
||||
@@ -183,6 +206,56 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
assert new_signed_token != signed_token
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
end
|
||||
|
||||
test "reissues the live socket id together with the token", %{conn: conn, user: user} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
|
||||
token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
old_live_socket_id = get_session(logged_in_conn, :live_socket_id)
|
||||
|
||||
offset_user_token(token, -10, :day)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_session(:live_socket_id, old_live_socket_id)
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> UserAuth.fetch_current_scope_for_user([])
|
||||
|
||||
assert new_token = get_session(conn, :user_token)
|
||||
refute new_token == token
|
||||
|
||||
assert get_session(conn, :live_socket_id) ==
|
||||
"users_sessions:#{Base.url_encode64(new_token)}"
|
||||
|
||||
refute get_session(conn, :live_socket_id) == old_live_socket_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "live view sockets" do
|
||||
# A real websocket disconnect cannot be driven through Phoenix.LiveViewTest:
|
||||
# the "disconnect" broadcast is handled by Phoenix.Socket.__info__/2 on the
|
||||
# transport process (deps/phoenix/lib/phoenix/socket.ex:571), and the test
|
||||
# harness (phoenix_live_view/test/client_proxy.ex) never subscribes to the
|
||||
# socket id topic. What can be pinned is the contract the transport relies
|
||||
# on: the value in the session is the topic Phoenix.LiveView.Socket.id/1
|
||||
# returns for the session the websocket receives (string keys).
|
||||
test "the id written to the session is the topic Socket.id/1 reads", %{conn: conn, user: user} do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
live_socket_id = get_session(conn, :live_socket_id)
|
||||
|
||||
socket_session =
|
||||
Map.new(conn.private[:plug_session], fn {key, value} -> {to_string(key), value} end)
|
||||
|
||||
socket = %Socket{
|
||||
private: %{connect_info: %{session: socket_session}}
|
||||
}
|
||||
|
||||
assert Socket.id(socket) == live_socket_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "require_sudo_mode/2" do
|
||||
@@ -290,4 +363,27 @@ defmodule FirehoseWeb.UserAuthTest do
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
|
||||
describe "a session token that no longer exists" do
|
||||
# This is the plug-layer contract that keeps UserAuth's LiveView-side
|
||||
# defences unreachable in this app: the router wraps /editor/dashboard in
|
||||
# `pipe_through [:browser, :require_authenticated_user]`, so the plug
|
||||
# redirects before `on_mount {UserAuth, :ensure_authenticated}` can run and
|
||||
# its halt arm never fires for a stale token. The same goes for the nil
|
||||
# scope fallbacks in `scope_from_session/1`.
|
||||
test "is treated as logged out", %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
token = get_session(conn, :user_token)
|
||||
assert Accounts.get_user_by_session_token(token)
|
||||
|
||||
:ok = Accounts.delete_user_session_token(token)
|
||||
|
||||
response = conn |> get(~p"/users/settings")
|
||||
|
||||
assert redirected_to(response) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
|
||||
"You must log in to access this page."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
defmodule Firehose.MutationConfigTest do
|
||||
# Same wiring pins as blogex, plus the app-specific one: because the app
|
||||
# scope is deliberately empty (Q45), the aliases must fail loudly. A sensor
|
||||
# that reports a score for a run that tested nothing is worse than no sensor.
|
||||
use ExUnit.Case
|
||||
|
||||
test "muex is a dev/test-only, non-runtime dependency" do
|
||||
deps = Mix.Project.config()[:deps]
|
||||
|
||||
assert {:muex, requirement, opts} = dep!(deps, :muex)
|
||||
assert requirement =~ "0.12"
|
||||
assert opts[:only] == [:dev, :test]
|
||||
assert opts[:runtime] == false
|
||||
end
|
||||
|
||||
test "mutate and mutate.fast aliases are defined" do
|
||||
aliases = Mix.Project.config()[:aliases]
|
||||
|
||||
assert is_function(aliases[:mutate], 1)
|
||||
assert is_function(aliases[:"mutate.fast"], 1)
|
||||
end
|
||||
|
||||
test "an empty app scope fails loudly instead of running over nothing" do
|
||||
assert_raise Mix.Error, ~r/empty/, fn ->
|
||||
Firehose.MixProject.mutation_args("mutation_scope.txt", [])
|
||||
end
|
||||
end
|
||||
|
||||
test "ad-hoc args do not bypass the empty-scope failure" do
|
||||
assert_raise Mix.Error, ~r/empty/, fn ->
|
||||
Firehose.MixProject.mutation_args("mutation_scope.txt", [
|
||||
"--files",
|
||||
"lib/firehose/accounts/user.ex"
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
defp dep!(deps, name) do
|
||||
case Enum.find(deps, fn
|
||||
{^name, _requirement} -> true
|
||||
{^name, _requirement, _opts} -> true
|
||||
_ -> false
|
||||
end) do
|
||||
{^name, requirement} -> {name, requirement, []}
|
||||
{^name, requirement, opts} -> {name, requirement, opts}
|
||||
nil -> flunk("expected #{inspect(name)} in deps/0")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
defmodule Firehose.MutationScopeTest do
|
||||
# The app's candidate pure modules (Firehose.Accounts.User, Scope and
|
||||
# UserToken) are only exercised through Firehose.DataCase: their tests use
|
||||
# the SQL sandbox, fixtures and Repo. Per the yak's Q45 decision, an app
|
||||
# mutation run would therefore need PostgreSQL, so the scope is deliberately
|
||||
# empty and the alias fails loudly rather than silently running over nothing.
|
||||
#
|
||||
# These pins keep that decision visible: if the scope grows, this file must
|
||||
# change with it, and it may never grow into the web layer.
|
||||
use ExUnit.Case
|
||||
|
||||
@root Path.expand("..", __DIR__)
|
||||
|
||||
test "scope files exist" do
|
||||
assert File.exists?(Path.join(@root, "mutation_scope.txt"))
|
||||
assert File.exists?(Path.join(@root, "mutation_scope_fast.txt"))
|
||||
end
|
||||
|
||||
test "app scope is empty because the candidate modules' tests need PostgreSQL" do
|
||||
assert scope("mutation_scope.txt") == []
|
||||
end
|
||||
|
||||
test "fast scope is a subset of full scope" do
|
||||
out_of_scope =
|
||||
MapSet.difference(
|
||||
MapSet.new(scope("mutation_scope_fast.txt")),
|
||||
MapSet.new(scope("mutation_scope.txt"))
|
||||
)
|
||||
|
||||
assert MapSet.size(out_of_scope) == 0,
|
||||
"mutate.fast names files outside the audited scope: #{inspect(MapSet.to_list(out_of_scope))}"
|
||||
end
|
||||
|
||||
test "scope never includes web-layer code" do
|
||||
web_entries =
|
||||
Enum.filter(
|
||||
scope("mutation_scope.txt") ++ scope("mutation_scope_fast.txt"),
|
||||
&String.starts_with?(&1, "lib/firehose_web")
|
||||
)
|
||||
|
||||
assert web_entries == [],
|
||||
"the web layer (controllers, LiveViews, components) is out of scope: #{inspect(web_entries)}"
|
||||
end
|
||||
|
||||
defp scope(file) do
|
||||
@root
|
||||
|> Path.join(file)
|
||||
|> File.read!()
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
defmodule FirehoseWeb.MicroprintsCase do
|
||||
@moduledoc """
|
||||
Helpers for driving the `/microprints` LiveView.
|
||||
|
||||
Both `FirehoseWeb.MicroprintsLiveTest` and `FirehoseWeb.SourceViewerTest` walk
|
||||
the same page through the same two controls (the per-card Expand/Collapse
|
||||
button and the microprint rects), so the mechanics live here instead of being
|
||||
re-implemented in each test module.
|
||||
|
||||
A plain module, imported by the tests, rather than a `use`-able case template:
|
||||
the LiveView tests own their own setup and only share these three actions.
|
||||
"""
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
@doc """
|
||||
Clicks the Expand control of the card for `path` and returns the render.
|
||||
"""
|
||||
def expand_file(view, path) do
|
||||
view
|
||||
|> element(~s(button[phx-value-path="#{path}"]), "Expand")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clicks the Collapse control of the card for `path` and returns the render.
|
||||
"""
|
||||
def collapse_file(view, path) do
|
||||
view
|
||||
|> element(~s(button[phx-value-path="#{path}"]), "Collapse")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clicks a single line of a microprint, highlighting it in the source viewer.
|
||||
|
||||
The microprint renders several rect segments per line, so `element/3` cannot
|
||||
pick one; the `highlight_line` event is dispatched directly instead.
|
||||
"""
|
||||
def click_microprint_rect(view, path, line) do
|
||||
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,238 @@
|
||||
# This file contains the configuration for Credo and you are probably reading
|
||||
# this after creating it with `mix credo.gen.config`.
|
||||
#
|
||||
# If you find anything wrong or unclear in this file, please report an
|
||||
# issue on GitHub: https://github.com/rrrene/credo/issues
|
||||
#
|
||||
# The gate for this library is `mix credo --strict` on the command line (the
|
||||
# `credo` target in Makefile). The command line, not this file, decides:
|
||||
# `--strict` overrides the inert `strict: false` key below. Editing `strict`
|
||||
# here to silence a finding would change nothing - fix the finding, or write
|
||||
# down why the check is wrong for this codebase (see `disabled:`).
|
||||
#
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
configs: [
|
||||
%{
|
||||
#
|
||||
# Run any config using `mix credo -C <name>`. If no config name is given
|
||||
# "default" is used.
|
||||
#
|
||||
name: "default",
|
||||
#
|
||||
# These are the files included in the analysis:
|
||||
files: %{
|
||||
#
|
||||
# You can give explicit globs or simply directories.
|
||||
# In the latter case `**/*.{ex,exs}` will be used.
|
||||
#
|
||||
included: [
|
||||
"lib/",
|
||||
"src/",
|
||||
"test/",
|
||||
"web/",
|
||||
"apps/*/lib/",
|
||||
"apps/*/src/",
|
||||
"apps/*/test/",
|
||||
"apps/*/web/"
|
||||
],
|
||||
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
|
||||
},
|
||||
#
|
||||
# Load and configure plugins here:
|
||||
#
|
||||
plugins: [],
|
||||
#
|
||||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
requires: [],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
# experience, you can change `strict` to `true` below:
|
||||
#
|
||||
strict: false,
|
||||
#
|
||||
# To modify the timeout for parsing files, change this value:
|
||||
#
|
||||
parse_timeout: 5000,
|
||||
#
|
||||
# If you want to use uncolored output by default, you can change `color`
|
||||
# to `false` below:
|
||||
#
|
||||
color: true,
|
||||
#
|
||||
# You can customize the parameters of any check by adding a second element
|
||||
# to the tuple.
|
||||
#
|
||||
# To disable a check put `false` as second element:
|
||||
#
|
||||
# {Credo.Check.Design.DuplicatedCode, false}
|
||||
#
|
||||
checks: %{
|
||||
enabled: [
|
||||
#
|
||||
## Consistency Checks
|
||||
#
|
||||
{Credo.Check.Consistency.ExceptionNames, []},
|
||||
{Credo.Check.Consistency.LineEndings, []},
|
||||
{Credo.Check.Consistency.ParameterPatternMatching, []},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators, []},
|
||||
{Credo.Check.Consistency.SpaceInParentheses, []},
|
||||
{Credo.Check.Consistency.TabsOrSpaces, []},
|
||||
|
||||
#
|
||||
## Design Checks
|
||||
#
|
||||
# You can customize the priority of any check
|
||||
# Priority values are: `low, normal, high, higher`
|
||||
#
|
||||
{Credo.Check.Design.AliasUsage,
|
||||
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 2]},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
#
|
||||
{Credo.Check.Readability.AliasOrder, []},
|
||||
{Credo.Check.Readability.FunctionNames, []},
|
||||
{Credo.Check.Readability.LargeNumbers, []},
|
||||
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
|
||||
{Credo.Check.Readability.ModuleAttributeNames, []},
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
{Credo.Check.Readability.ModuleNames, []},
|
||||
{Credo.Check.Readability.ParenthesesInCondition, []},
|
||||
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
|
||||
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
|
||||
{Credo.Check.Readability.PredicateFunctionNames, []},
|
||||
{Credo.Check.Readability.PreferImplicitTry, []},
|
||||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
|
||||
{Credo.Check.Readability.VariableNames, []},
|
||||
{Credo.Check.Readability.WithSingleClause, []},
|
||||
|
||||
#
|
||||
## Refactoring Opportunities
|
||||
#
|
||||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, []},
|
||||
{Credo.Check.Refactor.MapJoin, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
|
||||
{Credo.Check.Refactor.Nesting, []},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.UnlessWithElse, []},
|
||||
{Credo.Check.Refactor.WithClauses, []},
|
||||
|
||||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.Dbg, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
{Credo.Check.Warning.IExPry, []},
|
||||
{Credo.Check.Warning.IoInspect, []},
|
||||
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
|
||||
{Credo.Check.Warning.OperationOnSameValues, []},
|
||||
{Credo.Check.Warning.OperationWithConstantResult, []},
|
||||
{Credo.Check.Warning.RaiseInsideRescue, []},
|
||||
{Credo.Check.Warning.SpecWithStruct, []},
|
||||
{Credo.Check.Warning.StructFieldAmount, []},
|
||||
{Credo.Check.Warning.UnsafeExec, []},
|
||||
{Credo.Check.Warning.UnusedEnumOperation, []},
|
||||
{Credo.Check.Warning.UnusedFileOperation, []},
|
||||
{Credo.Check.Warning.UnusedKeywordOperation, []},
|
||||
{Credo.Check.Warning.UnusedListOperation, []},
|
||||
{Credo.Check.Warning.UnusedMapOperation, []},
|
||||
{Credo.Check.Warning.UnusedPathOperation, []},
|
||||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFilename, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Our one deliberate deviation from credo's defaults. CRAP (`ex_crap`,
|
||||
# threshold 30, wired into the Lens pass in Makefile) is this
|
||||
# library's complexity sensor, and it scores complexity against real
|
||||
# coverage: a wide `case` that the tests exercise is not a problem -
|
||||
# that was the itch behind ExCrap. A second, coverage-blind complexity
|
||||
# sensor would just argue with the first, so complexity questions are
|
||||
# settled by the CRAP target. Re-enabling this check without retiring
|
||||
# the CRAP target is a policy change, not a cleanup.
|
||||
#
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
||||
# and be sure to use `mix credo --strict` to see low priority checks)
|
||||
#
|
||||
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
|
||||
{Credo.Check.Consistency.UnusedVariableNames, []},
|
||||
{Credo.Check.Design.DuplicatedCode, []},
|
||||
{Credo.Check.Design.SkipTestWithoutComment, []},
|
||||
{Credo.Check.Readability.AliasAs, []},
|
||||
{Credo.Check.Readability.BlockPipe, []},
|
||||
{Credo.Check.Readability.ImplTrue, []},
|
||||
{Credo.Check.Readability.MultiAlias, []},
|
||||
{Credo.Check.Readability.NestedFunctionCalls, []},
|
||||
{Credo.Check.Readability.OneArityFunctionInPipe, []},
|
||||
{Credo.Check.Readability.OnePipePerLine, []},
|
||||
{Credo.Check.Readability.SeparateAliasRequire, []},
|
||||
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
||||
{Credo.Check.Readability.SinglePipe, []},
|
||||
{Credo.Check.Readability.Specs, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
||||
{Credo.Check.Refactor.ABCSize, []},
|
||||
{Credo.Check.Refactor.AppendSingleItem, []},
|
||||
{Credo.Check.Refactor.CondInsteadOfIfElse, []},
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
{Credo.Check.Refactor.IoPuts, []},
|
||||
{Credo.Check.Refactor.MapMap, []},
|
||||
{Credo.Check.Refactor.ModuleDependencies, []},
|
||||
{Credo.Check.Refactor.NegatedIsNil, []},
|
||||
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
||||
{Credo.Check.Refactor.PipeChainStart, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.VariableRebinding, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
# {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]}
|
||||
|
||||
# {Credo.Check.Refactor.MapInto, []},
|
||||
|
||||
#
|
||||
# Custom checks can be created using `mix credo.gen.check`.
|
||||
#
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/_build/
|
||||
/deps/
|
||||
/doc/
|
||||
/cover/
|
||||
*.ez
|
||||
*.beam
|
||||
.elixir_ls/
|
||||
|
||||
+111
-2
@@ -3,13 +3,122 @@
|
||||
MISE_BIN ?= mise
|
||||
MISE_EXEC = $(MISE_BIN) exec --
|
||||
|
||||
.PHONY: deps test format
|
||||
.PHONY: check precommit deps deps.check compile test test.coverage coverage crap format format.check verify
|
||||
|
||||
# Cheap static gate for this library (no database, no network, no coverage).
|
||||
# The deps.check guard fails loudly with a pointer at `make setup` when Hex
|
||||
# deps are missing. Runs from the root `make check`, which loops app then
|
||||
# blogex. `format.check` only reports:
|
||||
# a gate that rewrites the files it judges can never fail. Fix formatting with
|
||||
# `make format`.
|
||||
#
|
||||
# Sensor slot: add `credo` once the "credo strict, tuned config (both apps)"
|
||||
# yak lands blogex's credo dep + .credo.exs. Do not list it before then — a
|
||||
# gate that claims a check it did not run is worse than no gate.
|
||||
check: deps.check compile format.check
|
||||
|
||||
# Per-app precommit loop: same target name and alias shape as app/, so both
|
||||
# apps share one habit (compile --warning-as-errors, format, test).
|
||||
precommit:
|
||||
$(MISE_EXEC) mix precommit
|
||||
|
||||
# Sync dependencies and compile them: NimblePublisher pulls in mdex_native,
|
||||
# whose precompiled NIF downloads from GitHub at first compile. Keeping that
|
||||
# in `deps` (called by root `make setup`) keeps check/test/verify offline on
|
||||
# a cold clone.
|
||||
deps:
|
||||
$(MISE_EXEC) mix deps.get
|
||||
$(MISE_EXEC) mix deps.compile
|
||||
|
||||
test: deps
|
||||
# Local, network-free guard: every package pinned in mix.lock must be present
|
||||
# in deps/. check, test and test.coverage depend on this instead of `deps`, so
|
||||
# the everyday loops never touch the network: `mix deps.get` contacts hex.pm
|
||||
# even when everything is already installed (the advisory check), which fails
|
||||
# in offline/sandboxed environments. Fetching is `make setup`'s explicit job,
|
||||
# run from the repo root.
|
||||
deps.check:
|
||||
@test -f mix.lock || { echo "deps.check: no mix.lock - run 'make setup' at the repo root" >&2; exit 1; }
|
||||
@missing=""; \
|
||||
for dep in $$(sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' mix.lock); do \
|
||||
[ -d "deps/$$dep" ] || missing="$$missing $$dep"; \
|
||||
done; \
|
||||
test -z "$$missing" || { echo "deps.check: missing:$$missing - run 'make setup' at the repo root" >&2; exit 1; }
|
||||
|
||||
# Compile the project (warnings are fatal)
|
||||
compile:
|
||||
$(MISE_EXEC) mix compile --warnings-as-errors
|
||||
|
||||
# Run the test suite
|
||||
test: deps.check
|
||||
$(MISE_EXEC) mix test
|
||||
|
||||
# Run the suite once with coverage exported, so the coverage floor check (and,
|
||||
# later, CRAP) reads the export instead of running the suite a second time.
|
||||
# Stale exports are removed first: the floor must never report an older run.
|
||||
test.coverage: deps.check
|
||||
rm -f cover/*.coverdata
|
||||
$(MISE_EXEC) mix test --cover --export-coverage default
|
||||
|
||||
# Enforce the coverage floor (mix.exs -> test_coverage) from that export. No
|
||||
# test run. `mix test.coverage` exits 0 when it finds no export at all, so the
|
||||
# guard below is load-bearing: a floor check that did not run must not pass.
|
||||
coverage:
|
||||
@test -f cover/default.coverdata || { echo "coverage: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
|
||||
$(MISE_EXEC) mix test.coverage
|
||||
|
||||
# Enforce CRAP scores (complexity x coverage) from the same export. No test run.
|
||||
# Runs in MIX_ENV=test because that is the environment the export came from:
|
||||
# running in dev would compile a second build and score against a different set
|
||||
# of modules. The guard is load-bearing for the same reason as `coverage`:
|
||||
# ex_crap falls back to 0% coverage when the export is missing, which would
|
||||
# report scores that are pessimistic instead of absent - a false failure is
|
||||
# less bad than a false pass, but neither is acceptable in the Lens pass.
|
||||
#
|
||||
# Threshold is ex_crap's default (30), kept deliberately: decision 2026-10-28
|
||||
# (human, yak "ratchets, deferred decisions, handoff practice") is not to
|
||||
# ratchet --max-score down yet - below 30 is a policy change, not a cleanup.
|
||||
# Baseline at that threshold, same date: 0 offenders above 30, worst functions
|
||||
# Blogex.LinkValidator.validate_link/1 and validate_links/1 at 6.00
|
||||
# ("Summary: files=12 functions=61 scored=61 worst_score=6.00"). A failing run
|
||||
# prints "High scores: N" plus the offending functions; on a passing run read
|
||||
# N=0 off the worst score rather than inventing a count.
|
||||
crap:
|
||||
@test -f cover/default.coverdata || { echo "crap: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
|
||||
MIX_ENV=test $(MISE_EXEC) mix crap
|
||||
|
||||
# Format code. MUTATES the tree; deliberately not part of `check` (nor of the
|
||||
# Lens pass), which must never write: a gate that fixes what it judges can never
|
||||
# fail, and would dirty the tree a push is about to send. `make check` uses
|
||||
# `format.check` below instead.
|
||||
format:
|
||||
$(MISE_EXEC) mix format
|
||||
|
||||
# Verify formatting without writing. Fails with the offending files, so the fix
|
||||
# is explicit: run `make format` (or `make -C blogex format`).
|
||||
format.check:
|
||||
$(MISE_EXEC) mix format --check-formatted
|
||||
|
||||
# =============================================================================
|
||||
# Lens pass (heavy checks; root `make verify` runs this before every push)
|
||||
# =============================================================================
|
||||
#
|
||||
# Sensor slots, cheapest first. Add a sensor as its yak lands, both as a target
|
||||
# and to VERIFY_SENSORS — that variable is the single source of truth for what
|
||||
# this target reports as wired.
|
||||
# - coverage (yak: coverage floors + framework-glue exclusions policy)
|
||||
# WIRED below (test.coverage + coverage).
|
||||
# - crap (yak: CRAP scores with ex_crap (both apps))
|
||||
# WIRED below (crap); reuses the coverage export.
|
||||
# - mutation (yak: mutation testing with muex — a sensor, not a gate)
|
||||
#
|
||||
# The suite runs FIRST: it is what produces the coverage export that the
|
||||
# sensors below read. (Sampling and reporting are separate steps because
|
||||
# `mix test --cover --export-coverage` exports instead of printing a summary,
|
||||
# and reporting through `mix test.coverage` keeps it to one suite run.) Sensors
|
||||
# still run cheapest-first among themselves.
|
||||
VERIFY_SENSORS := coverage crap
|
||||
|
||||
verify:
|
||||
@echo "==> verify(blogex): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]"
|
||||
@$(MAKE) test.coverage
|
||||
@for sensor in $(VERIFY_SENSORS); do echo "==> verify(blogex): sensor $$sensor"; $(MAKE) $$sensor || exit 1; done
|
||||
|
||||
@@ -9,38 +9,60 @@ defmodule Blogex.ExternalLinks do
|
||||
* `rel="noopener"` — the new tab cannot access `window.opener`
|
||||
* `class="blogex-external"` — hook for CSS to draw an external-link
|
||||
marker icon (the host app's stylesheet owns the actual styling)
|
||||
* a `<span class="blogex-external-hint"> (opens in a new tab)</span>`
|
||||
at the end of the link text — announced by screen readers, hidden
|
||||
visually by the host app's stylesheet
|
||||
|
||||
Internal links (relative paths), `mailto:` and `#anchor` links are left
|
||||
untouched.
|
||||
|
||||
The rewrite is idempotent: applying it to already-rewritten HTML does
|
||||
not duplicate attributes.
|
||||
not duplicate attributes or hints.
|
||||
|
||||
Assumptions: the HTML comes from NimblePublisher's Earmark conversion,
|
||||
which emits double-quoted, lowercase attribute names and never adds
|
||||
`target`, `rel` or `class` to anchors. Attribute values are matched
|
||||
with case-insensitive regexes to stay robust for other producers.
|
||||
Anchors are matched together with their closing tag (the hint goes
|
||||
inside), so a stray unclosed `<a>` is left alone.
|
||||
"""
|
||||
|
||||
@marker_class "blogex-external"
|
||||
@hint_class "blogex-external-hint"
|
||||
@hint_text " (opens in a new tab)"
|
||||
@external_href ~r|^https?://|i
|
||||
@anchor ~r|(<a\s[^>]*>)(.*?)(</a>)|is
|
||||
|
||||
@doc """
|
||||
Rewrites all external anchors in an HTML fragment.
|
||||
"""
|
||||
@spec rewrite(String.t()) :: String.t()
|
||||
def rewrite(html) when is_binary(html) do
|
||||
Regex.replace(~r/<a\s[^>]*>/i, html, &decorate_anchor/1)
|
||||
Regex.replace(@anchor, html, fn _anchor, opening, content, closing ->
|
||||
{opening, content} = decorate_anchor(opening, content)
|
||||
opening <> content <> closing
|
||||
end)
|
||||
end
|
||||
|
||||
defp decorate_anchor(anchor) do
|
||||
if external?(href(anchor)) do
|
||||
anchor
|
||||
|> ensure_attribute("target", "_blank")
|
||||
|> ensure_rel("noopener")
|
||||
|> ensure_class(@marker_class)
|
||||
defp decorate_anchor(opening, content) do
|
||||
if external?(href(opening)) do
|
||||
opening =
|
||||
opening
|
||||
|> ensure_attribute("target", "_blank")
|
||||
|> ensure_rel("noopener")
|
||||
|> ensure_class(@marker_class)
|
||||
|
||||
{opening, ensure_hint(content)}
|
||||
else
|
||||
anchor
|
||||
{opening, content}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_hint(content) do
|
||||
if String.contains?(content, @hint_class) do
|
||||
content
|
||||
else
|
||||
content <> ~s|<span class="#{@hint_class}">#{@hint_text}</span>|
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -38,11 +38,9 @@ defmodule Blogex.LinkError do
|
||||
end
|
||||
|
||||
errors_list =
|
||||
errors
|
||||
|> Enum.map(fn {line, link, reason, _meta} ->
|
||||
Enum.map_join(errors, "\n", fn {line, link, reason, _meta} ->
|
||||
" line #{line}: #{link} — #{reason}"
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
"""
|
||||
invalid internal blog links in #{blog}#{post_label}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
defmodule Blogex.LinkValidator do
|
||||
@moduledoc """
|
||||
Validates internal blog link semantics in markdown post bodies.
|
||||
Validates internal blog link semantics in compiled post bodies.
|
||||
|
||||
Post bodies reach this module as HTML: `Blogex.Blog` validates them in
|
||||
`@before_compile`, after NimblePublisher has rendered the markdown. Markdown
|
||||
link syntax is still understood so bodies and snippets can be validated
|
||||
before rendering, but the HTML `<a href="…">` anchors are the ones that
|
||||
matter in the real pipeline.
|
||||
|
||||
Checks that internal links follow the pattern `/blog/{blog_id}/{slug}`
|
||||
where `blog_id` is a valid blog identifier and `slug` matches the
|
||||
@@ -50,21 +56,25 @@ defmodule Blogex.LinkValidator do
|
||||
@slug_regex ~r/^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
@doc """
|
||||
Extracts internal blog links from a markdown body.
|
||||
Extracts internal blog links from a post body.
|
||||
|
||||
Returns a list of link paths (strings) that match the pattern
|
||||
`/blog/{engineering|releases}/{slug}`. External links and non-blog
|
||||
internal links are ignored.
|
||||
Returns a list of link paths (strings) that look like `/blog/…` links.
|
||||
External links and non-blog internal links are ignored. Blog IDs and slugs
|
||||
are *not* judged here — `validate_link/1` classifies those.
|
||||
|
||||
Handles both markdown link syntax `[text](url)` and HTML `<a href="url">`.
|
||||
(Post bodies are HTML, rendered by NimblePublisher at compile time.)
|
||||
HTML anchors are read with Floki, so attribute order, quoting and extra
|
||||
attributes do not matter. Markdown link syntax `[text](url)` is extracted
|
||||
too (raw markdown bodies and snippets), and the two scans are merged.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> extract_links("[link](/blog/engineering/post)")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("<p><a href=\"/blog/engineering/post\">link</a></p>")
|
||||
iex> extract_links("<p><a href=\\"/blog/engineering/post\\">link</a></p>")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("<p><a class=\\"x\\" href='/blog/engineering/post'>link</a></p>")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("See [GitHub](https://github.com)")
|
||||
@@ -72,21 +82,24 @@ defmodule Blogex.LinkValidator do
|
||||
"""
|
||||
@spec extract_links(String.t()) :: [String.t()]
|
||||
def extract_links(body) when is_binary(body) do
|
||||
markdown_links =
|
||||
~r/\[([^\]]+)\]\(([^)]+)\)/
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, _, path] -> path end)
|
||||
|
||||
html_links =
|
||||
~r/<a\s+href=["']([^"']*)["']/i
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, path] -> path end)
|
||||
|
||||
(markdown_links ++ html_links)
|
||||
(markdown_links(body) ++ html_links(body))
|
||||
|> Enum.uniq()
|
||||
|> Enum.filter(&internal_blog_link?/1)
|
||||
end
|
||||
|
||||
defp markdown_links(body) do
|
||||
~r/\[([^\]]+)\]\(([^)]+)\)/
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, _, path] -> path end)
|
||||
end
|
||||
|
||||
defp html_links(body) do
|
||||
body
|
||||
|> Floki.parse_document!()
|
||||
|> Floki.find("a[href]")
|
||||
|> Enum.flat_map(&Floki.attribute(&1, "href"))
|
||||
end
|
||||
|
||||
defp internal_blog_link?(path) do
|
||||
case parse_blog_link(path) do
|
||||
{_, _} -> true
|
||||
@@ -116,23 +129,25 @@ defmodule Blogex.LinkValidator do
|
||||
@spec validate_link(String.t()) :: :ok | {:error, String.t()}
|
||||
def validate_link(link) when is_binary(link) do
|
||||
case parse_blog_link(link) do
|
||||
nil ->
|
||||
{:error, "not a blog link: #{link}"}
|
||||
|
||||
{blog_id_str, slug_part} ->
|
||||
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
||||
{:ok, _blog_atom} ->
|
||||
if String.starts_with?(slug_part, "tag/") do
|
||||
validate_tag(String.replace_prefix(slug_part, "tag/", ""))
|
||||
else
|
||||
validate_slug(slug_part)
|
||||
end
|
||||
|
||||
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
|
||||
end
|
||||
nil -> {:error, "not a blog link: #{link}"}
|
||||
{blog_id_str, path} -> validate_blog_path(blog_id_str, path)
|
||||
end
|
||||
end
|
||||
|
||||
# The blog ID must be one this app knows about; only then is the rest of the
|
||||
# path worth looking at. Extracted so `validate_link/1` stays one `case` deep:
|
||||
# the branching here is data (which part of the path is this?), not control.
|
||||
@spec validate_blog_path(String.t(), String.t()) :: :ok | {:error, String.t()}
|
||||
defp validate_blog_path(blog_id_str, path) do
|
||||
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
||||
{:ok, _blog_atom} -> validate_path(path)
|
||||
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_path("tag/" <> tag), do: validate_tag(tag)
|
||||
defp validate_path(slug), do: validate_slug(slug)
|
||||
|
||||
@doc """
|
||||
Validates a list of links, returning errors with line numbers.
|
||||
|
||||
@@ -207,21 +222,23 @@ defmodule Blogex.LinkValidator do
|
||||
@doc false
|
||||
@spec parse_blog_link(String.t()) :: {String.t(), String.t()} | nil
|
||||
def parse_blog_link(path) do
|
||||
with ["", "blog", blog_id, rest] <- String.split(path, "/", parts: 4) do
|
||||
slug = String.slice(rest, 0, slug_end(rest))
|
||||
{blog_id, slug}
|
||||
else
|
||||
_ -> nil
|
||||
case String.split(path, "/", parts: 4) do
|
||||
["", "blog", blog_id, rest] ->
|
||||
slug = String.slice(rest, 0, slug_end(rest))
|
||||
{blog_id, slug}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec slug_end(String.t()) :: integer()
|
||||
defp slug_end(str) do
|
||||
case String.split(str, ["?", "#"], parts: 2) do
|
||||
[slug | _] -> String.length(slug)
|
||||
_ -> String.length(str)
|
||||
end
|
||||
# `String.split/3` always returns a non-empty list whose head is a binary,
|
||||
# so there is no empty/other case to handle here.
|
||||
[slug | _] = String.split(str, ["?", "#"], parts: 2)
|
||||
String.length(slug)
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
||||
@@ -34,12 +34,15 @@ defmodule Blogex.Router do
|
||||
|
||||
use Plug.Router
|
||||
|
||||
alias Blogex.Router.Helpers
|
||||
alias Phoenix.HTML.Safe
|
||||
|
||||
plug :match
|
||||
plug :dispatch
|
||||
|
||||
get "/feed.xml" do
|
||||
blog = conn.private[:blogex_blog]
|
||||
base_url = Blogex.Router.Helpers.base_url(conn)
|
||||
base_url = Helpers.base_url(conn)
|
||||
|
||||
xml = Blogex.Feed.rss(blog, base_url)
|
||||
|
||||
@@ -50,7 +53,7 @@ defmodule Blogex.Router do
|
||||
|
||||
get "/atom.xml" do
|
||||
blog = conn.private[:blogex_blog]
|
||||
base_url = Blogex.Router.Helpers.base_url(conn)
|
||||
base_url = Helpers.base_url(conn)
|
||||
|
||||
xml = Blogex.Feed.atom(blog, base_url)
|
||||
|
||||
@@ -164,7 +167,7 @@ defmodule Blogex.Router do
|
||||
defp send_html(conn, rendered) do
|
||||
html =
|
||||
rendered
|
||||
|> Phoenix.HTML.Safe.to_iodata()
|
||||
|> Safe.to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
conn
|
||||
|
||||
+116
-1
@@ -11,7 +11,9 @@ defmodule Blogex.MixProject do
|
||||
elixir: "~> 1.15",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
test_coverage: test_coverage(),
|
||||
docs: docs(),
|
||||
package: package(),
|
||||
description: "A multi-blog engine powered by NimblePublisher for Phoenix apps"
|
||||
@@ -27,9 +29,16 @@ defmodule Blogex.MixProject do
|
||||
]
|
||||
end
|
||||
|
||||
def cli do
|
||||
[
|
||||
preferred_envs: [precommit: :test]
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:nimble_publisher, "~> 2.0"},
|
||||
{:floki, "~> 0.38"},
|
||||
{:makeup_elixir, ">= 0.0.0"},
|
||||
{:makeup_erlang, ">= 0.0.0"},
|
||||
{:phoenix, "~> 1.7"},
|
||||
@@ -37,8 +46,43 @@ defmodule Blogex.MixProject do
|
||||
{:phoenix_live_view, "~> 1.0"},
|
||||
{:jason, "~> 1.4"},
|
||||
{:plug, "~> 1.15"},
|
||||
# Declared directly although unused today (no boundaries, no compiler
|
||||
# entry): `ex_crap` supplies boundary transitively, and a compile-time
|
||||
# tool must not arrive or vanish as a side effect of a metric tool.
|
||||
{:boundary, "~> 0.11", runtime: false},
|
||||
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
|
||||
{:rustler, "~> 0.38.0", optional: true}
|
||||
{:rustler, "~> 0.38.0", optional: true},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
|
||||
{:muex, "~> 0.12", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
# The coverage floor the Lens pass enforces (`make verify` -> `make coverage`
|
||||
# in blogex/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks
|
||||
# land; never lower it. A drop means the change (or an exclusion) is wrong.
|
||||
#
|
||||
# Measured 2026-10-29, with the test scaffolding below excluded: 100.00% —
|
||||
# the same via `mix test --cover` and via `mix test --cover --export-coverage
|
||||
# default` + `mix test.coverage`. Floor is the integer below that.
|
||||
#
|
||||
# Excluded: the test scaffolding in `ignore_modules` below. The two registry
|
||||
# blog doubles (Blogex.Test.AlphaBlog/BetaBlog) are deliberately not listed
|
||||
# there: they measure 100.00% too, so counting them costs nothing.
|
||||
#
|
||||
# Everything in lib/blogex counts, and nothing is uncovered: Blogex.LinkError
|
||||
# was the last gap at 88.89% (8 of 9 lines) until the credo yak's tests closed
|
||||
# it. The rendered-HTML behavior tests took Blogex.Components and Blogex.Layout
|
||||
# from 0% to 100%, and Blogex.Router covers its browser arms and `match _`.
|
||||
# A floor of 100 means every new line needs a test before `make verify` passes.
|
||||
defp test_coverage do
|
||||
[
|
||||
summary: [threshold: 100],
|
||||
ignore_modules: [
|
||||
Blogex.Test.FakeBlog,
|
||||
Blogex.Test.PostBuilder,
|
||||
Blogex.Test.Setup
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -50,6 +94,77 @@ defmodule Blogex.MixProject do
|
||||
]
|
||||
end
|
||||
|
||||
# Same `precommit` alias name and shape as app/mix.exs, so both apps share
|
||||
# one loop: compile (warnings fatal), format, test. Heavy sensors belong in
|
||||
# the Lens pass (`make verify`), not here.
|
||||
#
|
||||
# `mutate` / `mutate.fast` are a sensor, not a gate: they are deliberately
|
||||
# absent from Makefile's VERIFY_SENSORS and always pass `--fail-at 0`, so a
|
||||
# survivor is reported rather than fatal. The scope lives in the plain-text
|
||||
# files beside this one; the aliases read them at run time, so the text file
|
||||
# stays the single source of truth.
|
||||
defp aliases do
|
||||
[
|
||||
precommit: ["compile --warning-as-errors", "format", "test"],
|
||||
mutate: &mutate/1,
|
||||
"mutate.fast": &mutate_fast/1
|
||||
]
|
||||
end
|
||||
|
||||
# No macro/`use` mutators: the scoped modules are plain functions, and the
|
||||
# macro-heavy modules (blog.ex, router.ex, components.ex, layout.ex) are out
|
||||
# of scope entirely. Spellings are muex 0.12's CLI names.
|
||||
@mutation_mutators "arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
|
||||
|
||||
defp mutate(args), do: run_mutation("mutation_scope.txt", args)
|
||||
|
||||
defp mutate_fast(args), do: run_mutation("mutation_scope_fast.txt", args)
|
||||
|
||||
defp run_mutation(scope_file, args) do
|
||||
Mix.Task.run("muex", mutation_args(scope_file, args))
|
||||
end
|
||||
|
||||
@doc false
|
||||
def mutation_args(scope_file, extra_args \\ []) do
|
||||
files = read_mutation_scope(scope_file)
|
||||
|
||||
# `--fail-at 0` comes first so an ad-hoc override cannot turn the sensor
|
||||
# into a gate. Extra args come before our defaults, and OptionParser keeps
|
||||
# the first value, so `mix mutate --files lib/blogex/seo.ex` targets one
|
||||
# file while `mix mutate` covers the whole scope file.
|
||||
["--fail-at", "0"] ++
|
||||
extra_args ++
|
||||
["--files", Enum.join(files, ","), "--mutators", @mutation_mutators]
|
||||
end
|
||||
|
||||
@doc false
|
||||
def read_mutation_scope(scope_file) do
|
||||
path = Path.expand(scope_file, Path.dirname(Mix.Project.project_file()))
|
||||
|
||||
files =
|
||||
case File.read(path) do
|
||||
{:ok, contents} ->
|
||||
contents
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
{:error, reason} ->
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is unreadable (#{:file.format_error(reason)}); " <>
|
||||
"refusing to run the mutation sensor over an unknown scope"
|
||||
)
|
||||
end
|
||||
|
||||
if files == [] do
|
||||
Mix.raise(
|
||||
"mutation scope #{scope_file} is empty; refusing to run the mutation sensor over nothing"
|
||||
)
|
||||
end
|
||||
|
||||
files
|
||||
end
|
||||
|
||||
defp package do
|
||||
[
|
||||
licenses: ["MIT"],
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
%{
|
||||
"boundary": {:hex, :boundary, "0.11.0", "56cff565b5565930765e02782281b022935fe8c2e1504543c8c1b44b85893b40", [:mix], [], "hexpm", "b9297a96ea14c0e63dbd77b8135e0d483f9c6f34ed1c9dee4b3fea82c596f55a"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
|
||||
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
|
||||
"ex_crap": {:hex, :ex_crap, "0.1.1", "c1770e7ce1843f304a27f5d32bfa867a340140e337684ee1929c770b2499f884", [:mix], [{:boundary, "~> 0.10", [hex: :boundary, repo: "hexpm", optional: false]}], "hexpm", "ada5d59bce442c1a8a987cb5328cbf4469fb56eb8d04641886f2199569a75b12"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
|
||||
"mdex_native": {:hex, :mdex_native, "0.2.4", "a74b9d799d3fc487b1f97b70ee9b04108c7af0514920d56828e173d19312c2a9", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d71185c43c5209c6939f4b65f903f71c892512c93ea6843171227855d2d976f5"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"muex": {:hex, :muex, "0.12.0", "80d8f1b2f7bfcba8ecb46b7b43dce55b555d33da54b84e74feedc4079fa2bd36", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b395b87d23b8d34f4508d0c13494b734f1fff8156eca2360fd279a0799d52e"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"nimble_publisher": {:hex, :nimble_publisher, "2.0.0", "f4fc100949ccdf1831094ff303b79f27c6f04b2ead829409426ee0d9ed3aa55b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "88997f74b763c24a15d20a90d7cd4ef1fd01172a2b4651e54d8021a44659f2c8"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
lib/blogex/link_validator.ex
|
||||
lib/blogex/seo.ex
|
||||
lib/blogex/feed.ex
|
||||
lib/blogex/post.ex
|
||||
lib/blogex/external_links.ex
|
||||
lib/blogex/registry.ex
|
||||
lib/blogex/not_found_error.ex
|
||||
lib/blogex/link_error.ex
|
||||
@@ -0,0 +1,3 @@
|
||||
lib/blogex/link_validator.ex
|
||||
lib/blogex/seo.ex
|
||||
lib/blogex/post.ex
|
||||
@@ -0,0 +1,8 @@
|
||||
%{
|
||||
title: "Draft Post (test fixture)",
|
||||
author: "Test Author",
|
||||
description: "A draft used by the show_drafts? tests",
|
||||
published: false
|
||||
}
|
||||
---
|
||||
This post is a draft and is hidden unless :show_drafts is on.
|
||||
@@ -0,0 +1,7 @@
|
||||
%{
|
||||
title: "Internal Link Post",
|
||||
author: "Test Author",
|
||||
description: "A post that links to another post"
|
||||
}
|
||||
---
|
||||
This post links to [hello world](/blog/engineering/hello-world) in its body.
|
||||
@@ -93,4 +93,75 @@ defmodule Blogex.BlogIntegrationTest do
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
end
|
||||
|
||||
describe "validation sees compiled post bodies" do
|
||||
# `_validate_links/2` runs on `post.body` *after* NimblePublisher rendered
|
||||
# the markdown, so validation only works if extraction reads the compiled
|
||||
# HTML. This pins that contract against a real compiled body: if
|
||||
# extraction ever goes back to scanning markdown syntax only, it fails.
|
||||
test "extract_links finds internal links in a compiled post body" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_compiled_body.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogCompiledBody do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-03-internal-link-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
[{TestBlogCompiledBody, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||
|
||||
# `apply/3`: the module is compiled at runtime, so a static call would
|
||||
# warn ("module is not available") on every test compile.
|
||||
[post] = apply(TestBlogCompiledBody, :all_posts, [])
|
||||
|
||||
# The markdown source is `[hello world](/blog/engineering/hello-world)`.
|
||||
assert post.body =~ "/blog/engineering/hello-world"
|
||||
|
||||
assert "/blog/engineering/hello-world" in Blogex.LinkValidator.extract_links(post.body)
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_drafts?/0" do
|
||||
# The only path through `Blogex.show_drafts?/0` (blogex.ex): `use Blogex.Blog`
|
||||
# compiles a real module over a real draft fixture, so the flag is read by
|
||||
# the generated `all_posts/0` rather than asserted in isolation.
|
||||
#
|
||||
# One test, one compile: compiling the same module name twice would print a
|
||||
# "redefining module" warning, and warnings are fatal everywhere in this repo.
|
||||
test "drafts are hidden by default and visible when :show_drafts is on" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_show_drafts.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogShowDrafts do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-03-draft-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
[{TestBlogShowDrafts, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||
|
||||
on_exit(fn ->
|
||||
File.rm!(tmp_file)
|
||||
Application.delete_env(:blogex, :show_drafts)
|
||||
end)
|
||||
|
||||
assert apply(TestBlogShowDrafts, :all_posts, []) == []
|
||||
|
||||
Application.put_env(:blogex, :show_drafts, true)
|
||||
|
||||
assert [post] = apply(TestBlogShowDrafts, :all_posts, [])
|
||||
assert post.title == "Draft Post (test fixture)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
defmodule Blogex.BoundaryDependencyTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mix.Dep.Lock
|
||||
|
||||
# blogex declares no boundaries and does not run the boundary compiler, so
|
||||
# this dependency is inert today. It is declared directly anyway (app/mix.exs
|
||||
# does the same) so the compiler cannot arrive or vanish as a side effect of
|
||||
# `ex_crap`, which supplies it transitively. This pins "direct, and locked to a
|
||||
# version that satisfies the requirement"; if boundary is ever wired up here,
|
||||
# the compile gate is that change's pin, not this test.
|
||||
describe "boundary dependency" do
|
||||
test "boundary is a direct dependency, not one inherited from ex_crap" do
|
||||
assert {:boundary, requirement, opts} =
|
||||
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
|
||||
|
||||
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
|
||||
assert Version.match?(locked_version(:boundary), requirement)
|
||||
end
|
||||
end
|
||||
|
||||
defp locked_version(app) do
|
||||
Lock.read()
|
||||
|> Map.fetch!(app)
|
||||
|> elem(2)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
defmodule Blogex.ComponentsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
@base_path "/blog/engineering"
|
||||
|
||||
describe "post_index/1" do
|
||||
test "links each post title to its own page under base_path" do
|
||||
html =
|
||||
render_post_index([
|
||||
build(id: "first-post", title: "First post"),
|
||||
build(id: "second-post", title: "Second post")
|
||||
])
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/first-post")
|
||||
assert html =~ ">First post</a>"
|
||||
assert html =~ ~s(href="#{@base_path}/second-post")
|
||||
assert html =~ ">Second post</a>"
|
||||
end
|
||||
|
||||
test "shows each post's description" do
|
||||
html =
|
||||
render_post_index([
|
||||
build(id: "processes-post", description: "Everything about processes"),
|
||||
build(id: "supervision-post", description: "Everything about supervision")
|
||||
])
|
||||
|
||||
assert html =~ "Everything about processes"
|
||||
assert html =~ "Everything about supervision"
|
||||
end
|
||||
|
||||
test "shows date, author and tags for each post" do
|
||||
html =
|
||||
render_post_index([
|
||||
build(
|
||||
id: "authored-post",
|
||||
date: ~D[2026-03-10],
|
||||
author: "Ada Lovelace",
|
||||
tags: ["elixir"]
|
||||
)
|
||||
])
|
||||
|
||||
assert html =~ "March 10, 2026"
|
||||
assert html =~ "by Ada Lovelace"
|
||||
assert html =~ ~s(href="#{@base_path}/tag/elixir")
|
||||
end
|
||||
end
|
||||
|
||||
describe "post_show/1" do
|
||||
test "renders the post title as the page heading" do
|
||||
html = render_post_show(build(id: "heading-post", title: "Rendering Rendered HTML"))
|
||||
|
||||
assert html =~ "<h1>Rendering Rendered HTML</h1>"
|
||||
end
|
||||
|
||||
test "renders the body as markup, not escaped text" do
|
||||
html =
|
||||
render_post_show(build(id: "markup-post", body: "<p>Hello <strong>world</strong></p>"))
|
||||
|
||||
assert html =~ "<p>Hello <strong>world</strong></p>"
|
||||
refute html =~ "<p>"
|
||||
refute html =~ "<strong>"
|
||||
end
|
||||
|
||||
test "shows the post's date, author and tags" do
|
||||
html =
|
||||
render_post_show(
|
||||
build(
|
||||
id: "meta-post",
|
||||
date: ~D[2026-03-10],
|
||||
author: "Grace Hopper",
|
||||
tags: ["compilers", "navy"]
|
||||
)
|
||||
)
|
||||
|
||||
assert html =~ ~s(<time datetime="2026-03-10">)
|
||||
assert html =~ "March 10, 2026"
|
||||
assert html =~ "by Grace Hopper"
|
||||
assert html =~ ~s(href="#{@base_path}/tag/compilers")
|
||||
assert html =~ ~s(href="#{@base_path}/tag/navy")
|
||||
end
|
||||
end
|
||||
|
||||
describe "post_meta/1" do
|
||||
test "renders the date as a machine-readable time element and a human date" do
|
||||
html = render_post_meta(build(date: ~D[2026-03-10]))
|
||||
|
||||
assert html =~ ~s(<time datetime="2026-03-10">)
|
||||
assert html =~ "March 10, 2026"
|
||||
end
|
||||
|
||||
test "shows the author when the post has one" do
|
||||
html = render_post_meta(build(author: "Ada Lovelace"))
|
||||
|
||||
assert html =~ ~s(<span class="blogex-post-author">)
|
||||
assert html =~ "by Ada Lovelace"
|
||||
end
|
||||
|
||||
test "has no author line when the post has no author" do
|
||||
html = render_post_meta(build(author: nil))
|
||||
|
||||
refute html =~ "blogex-post-author"
|
||||
refute html =~ "by "
|
||||
end
|
||||
|
||||
test "links every tag under base_path" do
|
||||
html = render_post_meta(build(tags: ["elixir", "otp"]))
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/tag/elixir")
|
||||
assert html =~ ~s(href="#{@base_path}/tag/otp")
|
||||
end
|
||||
|
||||
test "marks only the current tag as active" do
|
||||
html = render_post_meta(build(tags: ["elixir", "otp"]), current_tag: "elixir")
|
||||
|
||||
assert occurrences(html, "blogex-tag-active") == 1
|
||||
|
||||
assert [_, after_active] =
|
||||
String.split(html, ~s(class="blogex-tag-link blogex-tag-active"))
|
||||
|
||||
assert after_active =~ ~r/^>\s*elixir\s*<\/a>/
|
||||
end
|
||||
end
|
||||
|
||||
describe "tag_list/1" do
|
||||
test "links every tag under base_path" do
|
||||
html = render_tag_list(["elixir", "otp"])
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/tag/elixir")
|
||||
assert html =~ ~s(href="#{@base_path}/tag/otp")
|
||||
end
|
||||
|
||||
test "marks only the current tag as active" do
|
||||
html = render_tag_list(["elixir", "otp"], current_tag: "elixir")
|
||||
|
||||
assert occurrences(html, "blogex-tag-active") == 1
|
||||
|
||||
assert [_, after_active] =
|
||||
String.split(html, ~s(class="blogex-tag-link blogex-tag-active"))
|
||||
|
||||
assert after_active =~ ~r/^>\s*elixir\s*<\/a>/
|
||||
end
|
||||
end
|
||||
|
||||
describe "pagination/1" do
|
||||
test "renders nothing when there is only one page" do
|
||||
assert render_pagination(page: 1, total_pages: 1) |> String.trim() == ""
|
||||
end
|
||||
|
||||
test "on the first of several pages there is an Older link and no Newer link" do
|
||||
html = render_pagination(page: 1, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 1 of 3"
|
||||
assert html =~ ~s(href="#{@base_path}?page=2")
|
||||
assert html =~ "Older"
|
||||
refute html =~ "Newer"
|
||||
end
|
||||
|
||||
test "on a middle page there are both Newer and Older links" do
|
||||
html = render_pagination(page: 2, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 2 of 3"
|
||||
assert html =~ ~s(href="#{@base_path}?page=1")
|
||||
assert html =~ ~s(href="#{@base_path}?page=3")
|
||||
assert html =~ "Newer"
|
||||
assert html =~ "Older"
|
||||
end
|
||||
|
||||
test "on the last page there is a Newer link and no Older link" do
|
||||
html = render_pagination(page: 3, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 3 of 3"
|
||||
assert html =~ ~s(href="#{@base_path}?page=2")
|
||||
assert html =~ "Newer"
|
||||
refute html =~ "Older"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_post_index(posts, current_tag \\ nil) do
|
||||
render_component(&Blogex.Components.post_index/1,
|
||||
posts: posts,
|
||||
base_path: @base_path,
|
||||
current_tag: current_tag
|
||||
)
|
||||
end
|
||||
|
||||
defp render_post_show(post) do
|
||||
render_component(&Blogex.Components.post_show/1, post: post, base_path: @base_path)
|
||||
end
|
||||
|
||||
defp render_post_meta(post, opts \\ []) do
|
||||
render_component(&Blogex.Components.post_meta/1,
|
||||
post: post,
|
||||
base_path: @base_path,
|
||||
current_tag: opts[:current_tag]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_tag_list(tags, opts \\ []) do
|
||||
render_component(&Blogex.Components.tag_list/1,
|
||||
tags: tags,
|
||||
base_path: @base_path,
|
||||
current_tag: opts[:current_tag]
|
||||
)
|
||||
end
|
||||
|
||||
defp render_pagination(opts) do
|
||||
render_component(&Blogex.Components.pagination/1,
|
||||
page: opts[:page],
|
||||
total_pages: opts[:total_pages],
|
||||
base_path: @base_path
|
||||
)
|
||||
end
|
||||
|
||||
defp occurrences(html, token) do
|
||||
html
|
||||
|> String.split(token)
|
||||
|> length()
|
||||
|> Kernel.-(1)
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ defmodule Blogex.ExternalLinksTest do
|
||||
rewritten = ExternalLinks.rewrite(html)
|
||||
|
||||
assert rewritten ==
|
||||
~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>.</p>)
|
||||
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
|
||||
end
|
||||
|
||||
test "also rewrites external http links" do
|
||||
@@ -26,7 +26,7 @@ defmodule Blogex.ExternalLinksTest do
|
||||
rewritten = ExternalLinks.rewrite(html)
|
||||
|
||||
assert rewritten ==
|
||||
~s(<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a</a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b</a></p>)
|
||||
~s|<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a<span class="blogex-external-hint"> (opens in a new tab)</span></a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b<span class="blogex-external-hint"> (opens in a new tab)</span></a></p>|
|
||||
end
|
||||
|
||||
test "preserves other attributes on the anchor" do
|
||||
@@ -56,6 +56,48 @@ defmodule Blogex.ExternalLinksTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "rewrite/1 accessible hint" do
|
||||
test "appends a visually hidden hint inside the external anchor" do
|
||||
html = ~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog">Wikipedia</a>.</p>)
|
||||
|
||||
assert ExternalLinks.rewrite(html) ==
|
||||
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
|
||||
end
|
||||
|
||||
test "adds one hint per external link" do
|
||||
html = ~s(<a href="https://a.example/">a</a> and <a href="https://b.example/">b</a>)
|
||||
|
||||
assert html |> ExternalLinks.rewrite() |> hints() == 2
|
||||
end
|
||||
|
||||
test "places the hint after inner markup, keeping it untouched" do
|
||||
html = ~s(<a href="https://example.com/"><code>docs</code> and more</a>)
|
||||
|
||||
assert ExternalLinks.rewrite(html) =~
|
||||
~s|<code>docs</code> and more<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
|
||||
end
|
||||
|
||||
test "does not add a second hint when applied twice" do
|
||||
once = ExternalLinks.rewrite(~s(<a href="https://example.com/">once</a>))
|
||||
twice = ExternalLinks.rewrite(once)
|
||||
|
||||
assert twice == once
|
||||
assert hints(twice) == 1
|
||||
end
|
||||
|
||||
test "adds no hint to links that are not external" do
|
||||
for html <- [
|
||||
~s(<a href="/blog/engineering/swim-the-test">internal</a>),
|
||||
~s(<a href="mailto:hi@example.com">mail</a>),
|
||||
~s(<a href="#section">jump</a>)
|
||||
] do
|
||||
assert ExternalLinks.rewrite(html) == html
|
||||
end
|
||||
end
|
||||
|
||||
defp hints(html), do: length(Regex.scan(~r/class="blogex-external-hint"/, html))
|
||||
end
|
||||
|
||||
describe "rewrite/1 leaves other links untouched" do
|
||||
test "internal relative links are unchanged" do
|
||||
html = ~s(<a href="/blog/engineering/swim-the-test">Swim the Test</a>)
|
||||
@@ -75,6 +117,12 @@ defmodule Blogex.ExternalLinksTest do
|
||||
assert ExternalLinks.rewrite(html) == html
|
||||
end
|
||||
|
||||
test "anchors without an href are unchanged" do
|
||||
html = ~s(<a name="top">top</a>)
|
||||
|
||||
assert ExternalLinks.rewrite(html) == html
|
||||
end
|
||||
|
||||
test "plain text without anchors is unchanged" do
|
||||
assert ExternalLinks.rewrite("<p>no links here</p>") == "<p>no links here</p>"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
defmodule Blogex.LayoutTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
@base_path "/blog/engineering"
|
||||
@blog_title "Engineering Blog"
|
||||
@blog_description "Notes from the platform team"
|
||||
|
||||
describe "page/1" do
|
||||
test "titles the document and wraps the inner content" do
|
||||
html = render_page("A document title")
|
||||
|
||||
assert html =~ "<!DOCTYPE html>"
|
||||
assert html =~ ~s(<html lang="en">)
|
||||
assert html =~ "<title>A document title</title>"
|
||||
end
|
||||
|
||||
test "renders inner_content as markup, not escaped text" do
|
||||
html = render_page("A document title", Phoenix.HTML.raw("<p>inner</p>"))
|
||||
|
||||
assert html =~ "<p>inner</p>"
|
||||
refute html =~ "<p>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "index_page/1" do
|
||||
test "shows the blog title and description" do
|
||||
html = render_index_page([])
|
||||
|
||||
assert html =~ "<h1>#{@blog_title}</h1>"
|
||||
assert html =~ @blog_description
|
||||
end
|
||||
|
||||
test "lists the posts with their titles linked" do
|
||||
html = render_index_page([build(id: "first-post", title: "First post")])
|
||||
|
||||
assert html =~ ~s(href="#{@base_path}/first-post")
|
||||
assert html =~ ">First post</a>"
|
||||
end
|
||||
|
||||
test "shows no pagination when the blog has a single page" do
|
||||
html = render_index_page([build(id: "only-post")], page: 1, total_pages: 1)
|
||||
|
||||
refute html =~ "Older"
|
||||
refute html =~ "Newer"
|
||||
refute html =~ "Page 1 of 1"
|
||||
end
|
||||
|
||||
test "shows pagination when there is more than one page" do
|
||||
html = render_index_page([build(id: "paged-post")], page: 2, total_pages: 3)
|
||||
|
||||
assert html =~ "Page 2 of 3"
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_page/1" do
|
||||
test "uses the post title as the document title" do
|
||||
post = build(id: "titled-post", title: "A Post About Layout")
|
||||
|
||||
assert render_show_page(post) =~ "<title>#{post.title}</title>"
|
||||
end
|
||||
|
||||
test "offers a link back to the blog index" do
|
||||
html = render_show_page(build())
|
||||
|
||||
assert html =~ ~s(<a href="#{@base_path}">← Back</a>)
|
||||
end
|
||||
|
||||
test "renders the post body as markup, not escaped text" do
|
||||
html = render_show_page(build(body: "<p>Hello <strong>world</strong></p>"))
|
||||
|
||||
assert html =~ "<p>Hello <strong>world</strong></p>"
|
||||
refute html =~ "<p>"
|
||||
refute html =~ "<strong>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "tag_page/1" do
|
||||
test "titles the page with the blog title and the tag" do
|
||||
html = render_tag_page(tag: "elixir", posts: [])
|
||||
|
||||
assert html =~ "<title>#{@blog_title} — #elixir</title>"
|
||||
end
|
||||
|
||||
test "announces the tag and lists the posts it was given" do
|
||||
html =
|
||||
render_tag_page(
|
||||
tag: "elixir",
|
||||
posts: [
|
||||
build(id: "tagged-post", title: "Tagged post"),
|
||||
build(id: "another-tagged-post", title: "Another tagged post")
|
||||
]
|
||||
)
|
||||
|
||||
assert html =~ ~s(<h1>Posts tagged "elixir"</h1>)
|
||||
assert html =~ ~s(href="#{@base_path}/tagged-post")
|
||||
assert html =~ ">Tagged post</a>"
|
||||
assert html =~ ~s(href="#{@base_path}/another-tagged-post")
|
||||
assert html =~ ">Another tagged post</a>"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_page(title, inner_content \\ Phoenix.HTML.raw("<p>inner</p>")) do
|
||||
render_component(&Blogex.Layout.page/1, title: title, inner_content: inner_content)
|
||||
end
|
||||
|
||||
defp render_index_page(posts, opts \\ []) do
|
||||
render_component(&Blogex.Layout.index_page/1,
|
||||
blog_title: @blog_title,
|
||||
blog_description: @blog_description,
|
||||
posts: posts,
|
||||
base_path: @base_path,
|
||||
page: Keyword.get(opts, :page, 1),
|
||||
total_pages: Keyword.get(opts, :total_pages, 1)
|
||||
)
|
||||
end
|
||||
|
||||
defp render_show_page(post) do
|
||||
render_component(&Blogex.Layout.show_page/1, post: post, base_path: @base_path)
|
||||
end
|
||||
|
||||
defp render_tag_page(opts) do
|
||||
render_component(&Blogex.Layout.tag_page/1,
|
||||
blog_title: @blog_title,
|
||||
tag: opts[:tag],
|
||||
posts: opts[:posts],
|
||||
base_path: @base_path
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
defmodule Blogex.LinkErrorTest do
|
||||
@moduledoc """
|
||||
Pins the rendered exception message, not just the struct.
|
||||
|
||||
The message is what a compile-time failure shows a human: the indentation and
|
||||
the newline between errors are load-bearing. This is a characterization test
|
||||
written before `message/1` was rewritten (`Enum.map/2 |> Enum.join/2` ->
|
||||
`Enum.map_join/3`), so it passes on both sides of that change by design - it
|
||||
exists to make the rewrite safe, not to prove new behaviour.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "message/1 renders one indented line per error" do
|
||||
error = %Blogex.LinkError{
|
||||
blog: :engineering,
|
||||
post_id: "my-post",
|
||||
errors: [
|
||||
{1, "/blog/unknown/broken", "unknown blog ID: unknown", [post_id: "my-post"]},
|
||||
{7, "/blog/engineering/", "empty slug", [post_id: "my-post"]}
|
||||
]
|
||||
}
|
||||
|
||||
assert Exception.message(error) == """
|
||||
invalid internal blog links in engineering (post: my-post)
|
||||
|
||||
line 1: /blog/unknown/broken — unknown blog ID: unknown
|
||||
line 7: /blog/engineering/ — empty slug
|
||||
"""
|
||||
end
|
||||
|
||||
test "message/1 omits the post label when there is no post id" do
|
||||
error = %Blogex.LinkError{
|
||||
blog: :engineering,
|
||||
post_id: nil,
|
||||
errors: [{3, "/blog/unknown/broken", "unknown blog ID: unknown", []}]
|
||||
}
|
||||
|
||||
assert Exception.message(error) == """
|
||||
invalid internal blog links in engineering
|
||||
|
||||
line 3: /blog/unknown/broken — unknown blog ID: unknown
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,21 @@ defmodule Blogex.LinkValidatorTest do
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/tag/pi.dev"]
|
||||
end
|
||||
|
||||
# HTML anchors are not "the href right after <a (" - they are whatever the
|
||||
# renderer emits. Pinned with the shapes a hand-rolled scan misses.
|
||||
test "extracts internal blog links whatever the anchor's attributes look like" do
|
||||
body =
|
||||
~s(<p><a class="post-link" href="/blog/engineering/first">first</a>) <>
|
||||
~s( <a href=/blog/releases/second>second</a>) <>
|
||||
~s( <a data-x="1" aria-label="third" href='/blog/engineering/third'>third</a></p>)
|
||||
|
||||
assert LinkValidator.extract_links(body) == [
|
||||
"/blog/engineering/first",
|
||||
"/blog/releases/second",
|
||||
"/blog/engineering/third"
|
||||
]
|
||||
end
|
||||
|
||||
test "ignores external links" do
|
||||
body = "See [GitHub](https://github.com) and [internal](/blog/engineering/post)."
|
||||
|
||||
@@ -157,6 +172,12 @@ defmodule Blogex.LinkValidatorTest do
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev#top") == :ok
|
||||
end
|
||||
|
||||
# Tags are user-defined: the documented rule restricts *slugs*, not tags.
|
||||
test "accepts tag links with dots and capitals" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev") == :ok
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/Pi.dev") == :ok
|
||||
end
|
||||
|
||||
test "rejects tag page link with empty tag" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/tag/") ==
|
||||
{:error, "empty tag in tag link"}
|
||||
@@ -224,6 +245,24 @@ defmodule Blogex.LinkValidatorTest do
|
||||
end
|
||||
|
||||
describe "validate_body/2" do
|
||||
# The guard is a conjunction, not a disjunction: both arguments must have
|
||||
# the documented types. Mutating `and` to `or` would let a string blog id
|
||||
# (or a non-binary body) through the guard to crash somewhere else, so pin
|
||||
# the contract at the door.
|
||||
test "rejects a non-atom blog id at the function clause guard" do
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
# apply/3 keeps the compiler's type checker quiet: the point is the
|
||||
# runtime guard, and the test deliberately passes an invalid type.
|
||||
apply(LinkValidator, :validate_body, ["Just text, no links.", "engineering"])
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects a non-binary body at the function clause guard" do
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
apply(LinkValidator, :validate_body, [[], :engineering])
|
||||
end
|
||||
end
|
||||
|
||||
test "returns :ok when body has no internal blog links" do
|
||||
body = "Just text, no links."
|
||||
|
||||
@@ -267,5 +306,61 @@ defmodule Blogex.LinkValidatorTest do
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
# `_validate_links/2` hands this module `post.body`, which NimblePublisher
|
||||
# already rendered to HTML, so failure reporting has to work on HTML bodies.
|
||||
# These are the two reports the compile-time integration tests depend on.
|
||||
test "reports an unknown blog ID in an HTML body" do
|
||||
body = ~s(<p><a href="/blog/unknown/broken">bad</a></p>)
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering, post_id: "html-post") == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/unknown/broken",
|
||||
"unknown blog ID: unknown",
|
||||
post_id: "html-post"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "reports an invalid slug in an HTML body" do
|
||||
body = ~s(<p><a href="/blog/engineering/My-Post">bad</a></p>)
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering, post_id: "html-post") == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/engineering/My-Post",
|
||||
"slug must be lowercase alphanumeric with hyphens: My-Post",
|
||||
post_id: "html-post"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_blog_link/1" do
|
||||
# `slug_end/1` decides where the slug stops so `?query` / `#fragment` are
|
||||
# not validated as part of it. Pinned directly on `parse_blog_link/1`
|
||||
# (both are `@doc false`) because `validate_link/1` only reaches them via
|
||||
# "valid slug" cases, and a wrong cut would still validate by luck.
|
||||
test "strips query strings and fragments from the slug" do
|
||||
assert LinkValidator.parse_blog_link("/blog/engineering/hello-world?utm=1") ==
|
||||
{"engineering", "hello-world"}
|
||||
|
||||
assert LinkValidator.parse_blog_link("/blog/engineering/hello-world#top") ==
|
||||
{"engineering", "hello-world"}
|
||||
end
|
||||
|
||||
# Only the paths that cannot be a blog link return nil; a nested slug
|
||||
# ("/blog/eng/a/post") still parses and is rejected later by validate_slug/1.
|
||||
test "returns nil for anything that is not /blog/<blog>/<slug>" do
|
||||
refute LinkValidator.parse_blog_link("/blog/engineering")
|
||||
refute LinkValidator.parse_blog_link("/notblog/engineering/a-post")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,7 +51,7 @@ defmodule Blogex.PostTest do
|
||||
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
|
||||
|
||||
assert post.body =~
|
||||
~s(<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>)
|
||||
~s|<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
|
||||
end
|
||||
|
||||
test "leaves internal links in stored body untouched" do
|
||||
|
||||
@@ -2,41 +2,7 @@ defmodule Blogex.RegistryTest do
|
||||
use ExUnit.Case
|
||||
|
||||
alias Blogex.Registry
|
||||
|
||||
defmodule AlphaBlog do
|
||||
def blog_id, do: :alpha
|
||||
|
||||
def all_posts,
|
||||
do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
|
||||
|
||||
def all_tags, do: ["elixir"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
|
||||
Blogex.Test.PostBuilder.build(
|
||||
id: "a-draft",
|
||||
date: ~D[2026-03-05],
|
||||
blog: :alpha,
|
||||
published: false
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
defmodule BetaBlog do
|
||||
def blog_id, do: :beta
|
||||
|
||||
def all_posts,
|
||||
do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
|
||||
|
||||
def all_tags, do: ["devops"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta),
|
||||
Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta)
|
||||
]
|
||||
end
|
||||
alias Blogex.Test.{AlphaBlog, BetaBlog}
|
||||
|
||||
setup do
|
||||
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule Blogex.RouterTest do
|
||||
use ExUnit.Case
|
||||
import Plug.Test
|
||||
import Plug.Conn, only: [put_req_header: 3]
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
alias Blogex.Test.FakeBlog
|
||||
@@ -35,6 +36,18 @@ defmodule Blogex.RouterTest do
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
defp call_html(path) do
|
||||
conn(:get, path)
|
||||
|> put_req_header("accept", "text/html")
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
defp call_json(path) do
|
||||
conn(:get, path)
|
||||
|> put_req_header("accept", "application/json")
|
||||
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
|
||||
end
|
||||
|
||||
describe "GET /feed.xml" do
|
||||
test "returns RSS XML" do
|
||||
conn = call(:get, "/feed.xml")
|
||||
@@ -153,6 +166,96 @@ defmodule Blogex.RouterTest do
|
||||
end
|
||||
end
|
||||
|
||||
# The browser arms were previously unexercised: every test above goes through
|
||||
# `call/2`, which sends no Accept header, so `wants_html?/1` was always false.
|
||||
describe "GET / (browser)" do
|
||||
test "returns an HTML page with the blog title and the published posts" do
|
||||
conn = call_html("/")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "text/html"
|
||||
assert conn.resp_body =~ "Test Blog"
|
||||
assert conn.resp_body =~ "First"
|
||||
assert conn.resp_body =~ ~s(href="/blog/test/first-post")
|
||||
refute conn.resp_body =~ "future-post"
|
||||
end
|
||||
|
||||
test "leaves JSON clients alone" do
|
||||
conn = call_json("/")
|
||||
|
||||
assert get_content_type(conn) =~ "application/json"
|
||||
body = Jason.decode!(conn.resp_body)
|
||||
assert body["title"] == "Test Blog"
|
||||
assert body["total_entries"] == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /:slug (browser)" do
|
||||
test "returns the post as an HTML page" do
|
||||
conn = call_html("/first-post")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "text/html"
|
||||
assert conn.resp_body =~ "First"
|
||||
assert conn.resp_body =~ "<p>Post body.</p>"
|
||||
refute conn.resp_body =~ "<p>"
|
||||
end
|
||||
|
||||
test "leaves JSON clients alone" do
|
||||
conn = call_json("/first-post")
|
||||
|
||||
assert get_content_type(conn) =~ "application/json"
|
||||
assert Jason.decode!(conn.resp_body)["id"] == "first-post"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /tag/:tag (browser)" do
|
||||
test "returns HTML with only the posts carrying that tag" do
|
||||
conn = call_html("/tag/elixir")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_content_type(conn) =~ "text/html"
|
||||
assert conn.resp_body =~ "First"
|
||||
refute conn.resp_body =~ "Second"
|
||||
end
|
||||
|
||||
test "leaves JSON clients alone" do
|
||||
conn = call_json("/tag/elixir")
|
||||
|
||||
assert get_content_type(conn) =~ "application/json"
|
||||
assert Jason.decode!(conn.resp_body)["tag"] == "elixir"
|
||||
end
|
||||
end
|
||||
|
||||
describe "unknown routes" do
|
||||
test "answers 404 Not found" do
|
||||
conn = call(:post, "/whatever")
|
||||
|
||||
assert conn.status == 404
|
||||
assert conn.resp_body == "Not found"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Helpers.base_url/1" do
|
||||
# `port_suffix/2` has one clause per (scheme, default port) pair plus the
|
||||
# catch-all, and the feeds embed this URL. All four shapes are pinned so a
|
||||
# clause cannot be dropped or reordered without a failure.
|
||||
test "omits the port for the default scheme ports" do
|
||||
assert base_url(:http, 80) == "http://www.example.com"
|
||||
assert base_url(:https, 443) == "https://www.example.com"
|
||||
end
|
||||
|
||||
test "keeps a non-default port" do
|
||||
assert base_url(:http, 4000) == "http://www.example.com:4000"
|
||||
assert base_url(:https, 8443) == "https://www.example.com:8443"
|
||||
end
|
||||
|
||||
defp base_url(scheme, port) do
|
||||
%{Plug.Test.conn(:get, "/") | scheme: scheme, port: port}
|
||||
|> Blogex.Router.Helpers.base_url()
|
||||
end
|
||||
end
|
||||
|
||||
defp get_content_type(conn) do
|
||||
conn
|
||||
|> Plug.Conn.get_resp_header("content-type")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
defmodule BlogexTest do
|
||||
@moduledoc """
|
||||
Behaviour tests for the `Blogex` facade.
|
||||
|
||||
The facade is a thin delegating layer over `Blogex.Registry`, but callers
|
||||
(Phoenix controllers, LiveViews) only ever see the answers, so these tests
|
||||
assert the answers: the exact module lists, post orderings and tag sets the
|
||||
fixtures imply. Asserting `Blogex.x() == Blogex.Registry.x()` would pin the
|
||||
delegation instead of the behaviour and would keep passing if both sides
|
||||
drifted together.
|
||||
|
||||
Non-async: the registry is configured through the process-global `:blogex`
|
||||
application env.
|
||||
"""
|
||||
use ExUnit.Case
|
||||
|
||||
alias Blogex.Test.{AlphaBlog, BetaBlog}
|
||||
|
||||
setup do
|
||||
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
|
||||
on_exit(fn -> Application.delete_env(:blogex, :blogs) end)
|
||||
end
|
||||
|
||||
describe "blogs/0" do
|
||||
test "returns the configured blog modules" do
|
||||
assert Blogex.blogs() == [AlphaBlog, BetaBlog]
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_blog!/1 and get_blog/1" do
|
||||
test "return the module registered for a blog_id" do
|
||||
assert Blogex.get_blog!(:alpha) == AlphaBlog
|
||||
assert Blogex.get_blog(:beta) == BetaBlog
|
||||
end
|
||||
|
||||
test "an unknown blog_id returns nil and raises" do
|
||||
assert Blogex.get_blog(:nope) == nil
|
||||
|
||||
assert_raise Blogex.NotFoundError, fn -> Blogex.get_blog!(:nope) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_posts/0" do
|
||||
# Newest first across both blogs: beta's 2026-03-15 post, then alpha's
|
||||
# 2026-03-01. Drafts (a-draft) and future-dated posts (b-future) are not
|
||||
# visible to readers, so neither appears.
|
||||
test "merges published posts from every blog, newest first" do
|
||||
assert ids(Blogex.all_posts()) == ["b1", "a1"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_posts_unfiltered/0" do
|
||||
# Everything, newest first: the 2099 scheduled post, then b1, the alpha
|
||||
# draft, and alpha's oldest.
|
||||
test "includes drafts and future-dated posts, newest first" do
|
||||
assert ids(Blogex.all_posts_unfiltered()) == ["b-future", "b1", "a-draft", "a1"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "all_tags/0" do
|
||||
test "returns the unique tags across blogs, sorted" do
|
||||
assert Blogex.all_tags() == ["devops", "elixir"]
|
||||
end
|
||||
end
|
||||
|
||||
defp ids(posts), do: Enum.map(posts, & &1.id)
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
defmodule Blogex.CredoConfigTest do
|
||||
@moduledoc """
|
||||
Pins the *policy* in `.credo.exs`, not just the file's presence.
|
||||
|
||||
The gate is `mix credo --strict` (see the `credo` target in `Makefile`), so
|
||||
the config is load-bearing: a check silently re-enabled here changes what
|
||||
every future commit has to satisfy. These assertions are deliberately coarse
|
||||
- "this check sits in that list", "the disable says why" - so they survive
|
||||
credo's own boilerplate churn but not a policy change.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@config_path ".credo.exs"
|
||||
@complexity_check "Credo.Check.Refactor.CyclomaticComplexity"
|
||||
|
||||
test "cyclomatic complexity is disabled in favour of CRAP" do
|
||||
checks = default_config()[:checks]
|
||||
|
||||
assert @complexity_check in check_modules(checks[:disabled])
|
||||
refute @complexity_check in check_modules(checks[:enabled])
|
||||
end
|
||||
|
||||
test "the disable carries a rationale comment" do
|
||||
rationale = rationale_above(@complexity_check)
|
||||
|
||||
refute rationale == [], "no comment block above the #{@complexity_check} entry"
|
||||
assert Enum.join(rationale, "\n") =~ ~r/crap/i
|
||||
end
|
||||
|
||||
defp default_config do
|
||||
{config, _bindings} = Code.eval_file(@config_path)
|
||||
Enum.find(config.configs, &(&1.name == "default"))
|
||||
end
|
||||
|
||||
defp check_modules(checks) do
|
||||
Enum.map(checks, fn
|
||||
{module, _opts} -> module_name(module)
|
||||
module -> module_name(module)
|
||||
end)
|
||||
end
|
||||
|
||||
# `Atom.to_string/1` keeps the "Elixir." prefix that `inspect/1` drops; the
|
||||
# assertions read better (and diff better) without it.
|
||||
defp module_name(module) do
|
||||
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
|
||||
end
|
||||
|
||||
# The contiguous `#` block immediately above the check's own line. The entry
|
||||
# line is the one that is not itself a comment: a comment mentioning the
|
||||
# check by name must not be mistaken for the entry.
|
||||
defp rationale_above(check_name) do
|
||||
lines = @config_path |> File.read!() |> String.split("\n")
|
||||
|
||||
index =
|
||||
Enum.find_index(lines, fn line ->
|
||||
String.contains?(line, check_name) and not comment?(line)
|
||||
end)
|
||||
|
||||
lines
|
||||
|> Enum.take(index)
|
||||
|> Enum.reverse()
|
||||
|> Enum.take_while(&comment?/1)
|
||||
end
|
||||
|
||||
defp comment?(line), do: line |> String.trim() |> String.starts_with?("#")
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
defmodule Blogex.MutationConfigTest do
|
||||
# The mutation sensor is opt-in: a dependency and two aliases, deliberately
|
||||
# absent from `make verify`'s VERIFY_SENSORS. These pins keep the wiring
|
||||
# honest (dev/test only, never runtime) and keep the aliases reading the
|
||||
# scope files rather than duplicating a list in mix.exs.
|
||||
use ExUnit.Case
|
||||
|
||||
test "muex is a dev/test-only, non-runtime dependency" do
|
||||
deps = Mix.Project.config()[:deps]
|
||||
|
||||
assert {:muex, requirement, opts} = dep!(deps, :muex)
|
||||
assert requirement =~ "0.12"
|
||||
assert opts[:only] == [:dev, :test]
|
||||
assert opts[:runtime] == false
|
||||
end
|
||||
|
||||
test "mutate and mutate.fast aliases are defined" do
|
||||
aliases = Mix.Project.config()[:aliases]
|
||||
|
||||
assert is_function(aliases[:mutate], 1)
|
||||
assert is_function(aliases[:"mutate.fast"], 1)
|
||||
end
|
||||
|
||||
test "mutation args always include the scope, the curated mutators and --fail-at 0" do
|
||||
args = Blogex.MixProject.mutation_args("mutation_scope_fast.txt", [])
|
||||
scope = Blogex.MixProject.read_mutation_scope("mutation_scope_fast.txt")
|
||||
|
||||
assert "--fail-at" in args
|
||||
assert Enum.at(args, Enum.find_index(args, &(&1 == "--fail-at")) + 1) == "0"
|
||||
assert files_arg(args) == Enum.join(scope, ",")
|
||||
|
||||
mutators = Enum.at(args, Enum.find_index(args, &(&1 == "--mutators")) + 1)
|
||||
|
||||
assert mutators ==
|
||||
"arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
|
||||
end
|
||||
|
||||
test "extra CLI args pass through so a single file can be targeted" do
|
||||
args =
|
||||
Blogex.MixProject.mutation_args("mutation_scope_fast.txt", ["--files", "lib/blogex/seo.ex"])
|
||||
|
||||
assert args
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.any?(fn
|
||||
["--files", "lib/blogex/seo.ex"] -> true
|
||||
_ -> false
|
||||
end)
|
||||
end
|
||||
|
||||
test "an empty scope fails loudly instead of running over nothing" do
|
||||
path =
|
||||
Path.join(System.tmp_dir!(), "muex-empty-scope-#{System.unique_integer([:positive])}.txt")
|
||||
|
||||
File.write!(path, "\n \n")
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
|
||||
assert_raise Mix.Error, ~r/empty/, fn ->
|
||||
Blogex.MixProject.read_mutation_scope(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp dep!(deps, name) do
|
||||
case Enum.find(deps, fn
|
||||
{^name, _requirement} -> true
|
||||
{^name, _requirement, _opts} -> true
|
||||
_ -> false
|
||||
end) do
|
||||
{^name, requirement} -> {name, requirement, []}
|
||||
{^name, requirement, opts} -> {name, requirement, opts}
|
||||
nil -> flunk("expected #{inspect(name)} in deps/0")
|
||||
end
|
||||
end
|
||||
|
||||
defp files_arg(args) do
|
||||
args
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.find_value(fn
|
||||
["--files", files] -> files
|
||||
_ -> nil
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
defmodule Blogex.MutationScopeTest do
|
||||
# The mutation scope files are the single source of truth for what `mix
|
||||
# mutate` / `mix mutate.fast` are allowed to touch. These pins keep the
|
||||
# sensor honest: a scope that quietly shrinks to an empty (or stale) set
|
||||
# would report a score for a check it never ran.
|
||||
use ExUnit.Case
|
||||
|
||||
@root Path.expand("..", __DIR__)
|
||||
@full "mutation_scope.txt"
|
||||
@fast "mutation_scope_fast.txt"
|
||||
|
||||
# Pure modules only. These are excluded deliberately:
|
||||
# blogex.ex facade + moduledoc, nothing to mutate
|
||||
# blogex/blog.ex the `__using__` macro (NimblePublisher expansion)
|
||||
# router.ex Phoenix router DSL
|
||||
# components.ex Phoenix component DSL
|
||||
# layout.ex Phoenix/LiveView layout DSL
|
||||
@shell_modules [
|
||||
"lib/blogex.ex",
|
||||
"lib/blogex/blog.ex",
|
||||
"lib/blogex/router.ex",
|
||||
"lib/blogex/components.ex",
|
||||
"lib/blogex/layout.ex"
|
||||
]
|
||||
|
||||
test "full scope lists only existing files" do
|
||||
missing = Enum.reject(scope(@full), &File.exists?(Path.join(@root, &1)))
|
||||
|
||||
assert missing == [],
|
||||
"mutation scope names files that do not exist: #{inspect(missing)}"
|
||||
end
|
||||
|
||||
test "full scope is not empty" do
|
||||
assert scope(@full) != []
|
||||
end
|
||||
|
||||
test "fast scope is not empty" do
|
||||
assert scope(@fast) != []
|
||||
end
|
||||
|
||||
test "fast scope is a subset of full scope" do
|
||||
out_of_scope = MapSet.difference(MapSet.new(scope(@fast)), MapSet.new(scope(@full)))
|
||||
|
||||
assert MapSet.size(out_of_scope) == 0,
|
||||
"mutate.fast names files outside the audited scope: #{inspect(MapSet.to_list(out_of_scope))}"
|
||||
end
|
||||
|
||||
test "scope excludes the macro and shell modules" do
|
||||
scoped = MapSet.new(scope(@full) ++ scope(@fast))
|
||||
present = Enum.filter(@shell_modules, &MapSet.member?(scoped, &1))
|
||||
|
||||
assert present == [],
|
||||
"shell/macro modules belong outside the mutation scope: #{inspect(present)}"
|
||||
end
|
||||
|
||||
defp scope(file) do
|
||||
@root
|
||||
|> Path.join(file)
|
||||
|> File.read!()
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
defmodule Blogex.Test.AlphaBlog do
|
||||
@moduledoc """
|
||||
A minimal blog module for cross-blog tests.
|
||||
|
||||
Implements the interface `Blogex.Registry` queries (and nothing else), so a
|
||||
test can configure `:blogex, :blogs` with real modules without compiling a
|
||||
`use Blogex.Blog` module or touching the filesystem.
|
||||
|
||||
`all_posts/0`/`unfiltered_posts/0` are hard-coded — the dates are literals so
|
||||
tests can assert exact orderings rather than re-deriving them.
|
||||
"""
|
||||
|
||||
alias Blogex.Test.PostBuilder
|
||||
|
||||
def blog_id, do: :alpha
|
||||
|
||||
def all_posts,
|
||||
do: [PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
|
||||
|
||||
def all_tags, do: ["elixir"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
|
||||
PostBuilder.build(
|
||||
id: "a-draft",
|
||||
date: ~D[2026-03-05],
|
||||
blog: :alpha,
|
||||
published: false
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
defmodule Blogex.Test.BetaBlog do
|
||||
@moduledoc """
|
||||
The second blog module for cross-blog tests; see `Blogex.Test.AlphaBlog`.
|
||||
"""
|
||||
|
||||
alias Blogex.Test.PostBuilder
|
||||
|
||||
def blog_id, do: :beta
|
||||
|
||||
def all_posts,
|
||||
do: [PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
|
||||
|
||||
def all_tags, do: ["devops"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta),
|
||||
PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta)
|
||||
]
|
||||
end
|
||||
@@ -5,6 +5,8 @@ defmodule Blogex.Test.Setup do
|
||||
|
||||
import Blogex.Test.PostBuilder
|
||||
|
||||
alias Blogex.Test.FakeBlog
|
||||
|
||||
@doc """
|
||||
Starts a FakeBlog with a standard set of posts.
|
||||
Returns `%{blog: module, posts: posts}`.
|
||||
@@ -13,9 +15,9 @@ defmodule Blogex.Test.Setup do
|
||||
posts = Keyword.get(opts, :posts, default_posts())
|
||||
blog_opts = Keyword.drop(opts, [:posts])
|
||||
|
||||
{:ok, _} = Blogex.Test.FakeBlog.start(posts, blog_opts)
|
||||
{:ok, _} = FakeBlog.start(posts, blog_opts)
|
||||
|
||||
Map.merge(context, %{blog: Blogex.Test.FakeBlog, posts: posts})
|
||||
Map.merge(context, %{blog: FakeBlog, posts: posts})
|
||||
end
|
||||
|
||||
@doc "A small set of posts covering common scenarios."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 844 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,243 @@
|
||||
# Improve verification for firehose
|
||||
|
||||
Plan for bringing the "internal code health" sensor stack to firehose, based on
|
||||
what worked in stickies (Oct 2026) and
|
||||
[Velasco's ExCrap blog post](https://www.germanvelasco.com/blog/i-wrote-excrap-without-looking-at-the-code).
|
||||
|
||||
Two kinds of verification (the post's frame):
|
||||
|
||||
1. **External** — is the product what we wanted? Visible: the site, the blogs.
|
||||
Firehose already has decent coverage here: Playwright suites for the app
|
||||
(`make playwright*`), controller/LiveView tests.
|
||||
2. **Internal** — is the codebase healthy and evolving well? Invisible unless
|
||||
we install **sensors and signals**: coverage, CRAP scores, credo, sobelow,
|
||||
mutation testing, boundary checks. This plan is about the sensors.
|
||||
|
||||
## Where firehose differs from stickies
|
||||
|
||||
- **Two OTP apps.** `app/` (Phoenix, the Firehose site) and `blogex/`
|
||||
(publishable library, NimblePublisher-based blog engine, no DB). Every sensor
|
||||
must run per-app; the root `Makefile` orchestrates (`make test` already runs
|
||||
both).
|
||||
- **blogex is a library** (has `package`/`docs` config). Quality bar is
|
||||
higher; it's also mostly *pure* code (post parsing, link validation, feeds,
|
||||
SEO) — the ideal mutation-testing target, like stickies' `lib/cardgame/domain`.
|
||||
- **blogex has no mix aliases at all** (no precommit, no format in aliases).
|
||||
`app` has a `precommit` alias (compile --warnings-as-errors, deps.unlock,
|
||||
format, test).
|
||||
- **A gitea remote already exists** — unlike stickies at the start, a pre-push
|
||||
hook will actually fire on every push from day one.
|
||||
- Already present: credo dep in `app` (default config, not tuned/strict);
|
||||
nothing else yet. No coverage config, no boundary/ex_crap/sobelow/muex in
|
||||
either app. Current suites green: app 178 tests, blogex 156.
|
||||
- `AGENTS.md` has a **zero-defects policy** ("never continue when there are
|
||||
errors") — the fast/push split must be written to fit that policy: Lens
|
||||
failures are triaged and reported, never ignored (see triage classes below).
|
||||
|
||||
## Phases
|
||||
|
||||
Order matters: the first yak is the *container* the other sensors wire into
|
||||
(same conclusion as stickies — `pre-push-verification-loop-and-lens-review-practice`).
|
||||
|
||||
### Phase 1 — Fast/push split (the container)
|
||||
|
||||
Keep `precommit` cheap and fast (commit freely); move heavy checks to a
|
||||
**Lens pass** = `make verify` + `.githooks/pre-push`.
|
||||
|
||||
- [ ] Root `Makefile`: `verify` target that loops over `app` and `blogex`,
|
||||
running the heavy checks cheapest-first (credo → sobelow → coverage+CRAP).
|
||||
- [ ] `.githooks/pre-push` + `core.hooksPath=.githooks` (wire into `make`/setup).
|
||||
Fail loudly with a summary; trivially skippable via `git push --no-verify`.
|
||||
- [ ] `blogex/mix.exs`: add an `aliases` block with `precommit`
|
||||
(compile --warnings-as-errors, format, test) so both apps share the loop.
|
||||
- [ ] AGENTS.md: write the **Lens practice** down — review is a separate pass
|
||||
with its own context; don't chase hook nits inline while building; file a
|
||||
yak instead. Add the **triage classes** for Lens failures:
|
||||
*real bug* (fix inline) / *environment-dependent test* (the test is the
|
||||
bug — make it deterministic by construction, no ambient state as
|
||||
fixtures) / *style or design nit* (note or file a yak). Reconcile with
|
||||
the zero-defects policy: "never continue silently" ≠ "fix nits inline";
|
||||
it means report, triage, decide.
|
||||
- [ ] Watch hook runtime: `app`'s `test` alias drops/creates/migrates the DB
|
||||
every run. If the pre-push run re-tests, suite time doubles. Consider a
|
||||
`test.crap` alias reusing an exported coverage run (stickies:
|
||||
`test --cover --export-coverage default` + `test.coverage` + `crap`).
|
||||
|
||||
### Phase 2 — Coverage floor + exclusions policy
|
||||
|
||||
- [ ] Add `test_coverage: [threshold: N]` to **both** `mix.exs` files
|
||||
(blogex first — it's pure and should be near-perfect; app likely lower).
|
||||
Run coverage once to see actual numbers before picking the floor.
|
||||
- [ ] Make the **exclusions policy decision early**, not later (in stickies
|
||||
this came late, commit `af12c2d`): exclude framework-owned glue
|
||||
(`Firehose.Repo`, `ErrorHTML/JSON`, `Telemetry`, `Mailer`) — counting it
|
||||
inflates the floor without reflecting app behavior.
|
||||
- [ ] Rule from stickies: the threshold is a **floor, ratcheted up as yaks
|
||||
land** — never lowered.
|
||||
|
||||
### Phase 3 — Behavior tests for the gaps (the bulk of the work)
|
||||
|
||||
Run per-app coverage, list the uncovered modules, file yaks per area
|
||||
(stickies: "behavior tests: boundary shell", "…: web LiveViews", "…:
|
||||
DevToolsPage"). See *Steering the model on missing tests* below for the
|
||||
principles — this is where most steering happened.
|
||||
|
||||
Expected gaps in firehose, by module type:
|
||||
|
||||
- `blogex/*` — pure library, should be drivable to very high coverage through
|
||||
behavior tests (post → rendered HTML, invalid links → errors, feed output).
|
||||
- `app/lib/firehose/accounts*` — context behavior (stickies did this for
|
||||
MultiTenancy: narrate from the actors' view).
|
||||
- `firehose_web/live/*` and controllers — outcome tests through
|
||||
`Phoenix.LiveViewTest` / conn tests, not HTML-string assertions.
|
||||
- Editorial features (`editor_dashboard_live`, `microprints_live`) — likely the
|
||||
thinnest coverage; behavior-test them like stickies' DevToolsPage.
|
||||
|
||||
- [ ] Coverage-driven yak list per app; work them one by one.
|
||||
- [ ] After each yak: **ratchet the floor** up to the new total + small margin.
|
||||
|
||||
### Phase 4 — CRAP scores with ex_crap
|
||||
|
||||
- [ ] Add `{:ex_crap, "~> 0.1", only: [:dev, :test], runtime: false}` to both
|
||||
apps. **Sandbox gotcha:** `mix deps.get` cannot fetch new hex packages
|
||||
from inside the sandbox — edit `mix.exs`, then ask the user to run
|
||||
`mix deps.get` outside; stop there. Same for every dep below.
|
||||
- [ ] Wire `mix crap` into the Lens pass (needs the coverage export from
|
||||
Phase 1's `test.crap` alias).
|
||||
- [ ] Baseline at default `--max-score 30`. Fix offenders the stickies way:
|
||||
* pure data refactor where the complexity is real logic (9-branch
|
||||
`case` → compile-time lookup map);
|
||||
* for shell glue (LiveView `handle_event`), **close coverage gaps
|
||||
instead of refactoring** — a fully-covered complexity-22 function
|
||||
scores 24.48, under the default threshold.
|
||||
- [ ] CRAP formula reminder: `comp^2*(1-cov)^3 + comp`; coverage ≥ ~75% of a
|
||||
comp-22 function clears threshold 30.
|
||||
|
||||
### Phase 5 — Credo strict, tuned
|
||||
|
||||
- [ ] Tune `.credo.exs` (both apps; blogex has none): **defaults, zero
|
||||
findings under `--strict` at introduction** — the config is the policy.
|
||||
Deliberate deviation: disable `Credo.Check.Refactor.CyclomaticComplexity`
|
||||
in favor of CRAP scores (a wide `case` with good coverage is not a
|
||||
problem; that was the original itch behind ExCrap).
|
||||
- [ ] Runs in the Lens pass, not precommit.
|
||||
- [ ] When strict flags code: refactor along **pure-data lines** (lookup
|
||||
tables, small pure classifiers, pure helpers that return values) — never
|
||||
split orchestration/shell code into async calls or scattered processes
|
||||
just to satisfy a metric (user, 2026-10-10: "I don't want an agent to
|
||||
arbitrarily break them down, but respect our design principles").
|
||||
|
||||
### Phase 6 — Sobelow
|
||||
|
||||
- [x] Add to the Lens pass for `app` (Phoenix surface). blogex is a library
|
||||
without endpoints; decide whether to run it there anyway — in ex_crap's
|
||||
own history sobelow caught a `String.to_atom` in a library, so it's not
|
||||
strictly web-only value. Decided 2026-10-28: **app only**; blogex's
|
||||
sobelow scope is a separate yak.
|
||||
- [x] **Fail when sobelow is not installed** (stickies: user explicitly
|
||||
removed the skip — a silently-skipped check is a lying sensor).
|
||||
`make -C app sobelow` has no fallback: a missing task is a hard Mix
|
||||
error, and `verify`'s `|| exit 1` loop fails the pass.
|
||||
- [x] Fix findings or add per-check skips *with rationale comments*
|
||||
(stickies: `Config.CSP` skipped because the plug is invisible to sobelow).
|
||||
Baseline 2026-10-28 (sobelow 0.17.0), 3 findings: `Config.HTTPS` (High),
|
||||
`Config.CSP` (High), `Traversal.FileModule` (Low). All three are skipped
|
||||
in `app/.sobelow-skips`, each with a written rationale: HTTPS and CSP are
|
||||
deployment/design decisions with follow-up yaks, and the Traversal flag
|
||||
is a false positive (the path is matched against the server-scanned list
|
||||
before `File.read`). Unlike stickies, the CSP skip is *not* "the plug is
|
||||
invisible" — this app genuinely has no CSP yet.
|
||||
|
||||
### Phase 7 — Boundary
|
||||
|
||||
Different shape here than in stickies:
|
||||
|
||||
- The **app/blogex split already enforces the biggest boundary** via the mix
|
||||
dep (`{:blogex, path: "../blogex"}`): app code can only use blogex's public
|
||||
API. The library direction is naturally clean.
|
||||
- [ ] Inside `app`: declare boundaries for `Firehose` (contexts),
|
||||
`FirehoseWeb` (exports Endpoint/Telemetry), and make **`Firehose.Repo` a
|
||||
top-level sibling boundary with `deps: []`**, removed from `Firehose`'s
|
||||
exports — so web→Repo calls become compile-time violations, not
|
||||
convention (stickies commits `35f2eff`, `337a0c8`).
|
||||
- [ ] Enable the boundary compiler in `:dev` **and** `:test` so precommit's
|
||||
`compile --warning-as-errors` gates on violations. Watch for
|
||||
unclassified-module warnings from test support modules (declare an
|
||||
ignored top-level boundary for them, as stickies did for `CardgameTest`).
|
||||
- [ ] External apps whitelist: `[:ecto, :phoenix, :plug, :phoenix_live_view]`
|
||||
with per-module lists where boundary splits them.
|
||||
- [ ] *Optional, not used in stickies:* Velasco's `boundary.spec.check` /
|
||||
`boundary.spec.accept` tasks — an agent-proof sensor that fails when the
|
||||
boundary definition itself changed without human approval. Cheap to add
|
||||
once boundaries exist; worth considering given firehose's zero-defects
|
||||
culture.
|
||||
|
||||
### Phase 8 — Mutation testing (muex)
|
||||
|
||||
- [ ] Add muex (dev-only, `runtime: false`) + `mix mutate` / `mix mutate.fast`
|
||||
aliases. **Scope tightly to pure code**: `blogex/lib/**` (link_validator,
|
||||
seo, feed, post parsing) and any pure modules in `app` — stickies scoped
|
||||
to `lib/cardgame/{domain,events}` and left shell code alone.
|
||||
- [ ] Curated mutator set: `arithmetic,boolean,comparison,conditional,
|
||||
function_call,literal,return_value` (excludes macro/use-based mutators —
|
||||
in ex_crap itself, `use Boundary` removals produced false-positive
|
||||
survivors).
|
||||
- [ ] `--fail-at 0`: mutation testing stays a **sensor, not a gate**; documented
|
||||
false positives (guard `and`↔`or` mutants, removed log lines) are noted,
|
||||
not chased.
|
||||
- [ ] Genuine survivors → new tests that pin the behavior (e.g. per-field
|
||||
error tests pinning chained guards).
|
||||
|
||||
### Phase 9 — Ratchets, decisions, handoff
|
||||
|
||||
- [ ] Ratchet coverage floors after each behavior yak lands.
|
||||
- [ ] Decide (with the user, don't assume): CRAP `--max-score` below 30?
|
||||
boundary.spec.check adoption? Playwright in the Lens pass or kept as a
|
||||
separate external-verification loop?
|
||||
- [ ] "Landing the plane": after each session with code changes, run
|
||||
`make precommit` always; run `make verify` (or push, which fires the
|
||||
hook) before handing off.
|
||||
|
||||
## Steering the model on missing tests (what made stickies work)
|
||||
|
||||
The sensors tell you *where* tests are missing; the steering below is what
|
||||
made the resulting tests good rather than line-chasing. Recorded from the
|
||||
stickies session history (Oct 10–12) so it can be pasted into firehose
|
||||
sessions or this repo's AGENTS.md:
|
||||
|
||||
1. **Behavior, not state.** Narrate tests from the actors' view — "host starts
|
||||
a team, members join, roles change, members leave" — and assert *outcomes*
|
||||
(the next actor's experience), not internal struct/assign shapes. Pin
|
||||
current behavioral decisions explicitly when a test does so (e.g. "same-name
|
||||
teams cannot both exist").
|
||||
2. **Functional core, imperative shell.** Details of persistence (how joining
|
||||
works internally) stay out of most tests; the test drives the public API of
|
||||
the core. GenServers/stores get *outcome* tests (persistence failure → state
|
||||
unchanged; restart → replay), not process-internals tests.
|
||||
3. **Don't test dead code — delete it.** Coverage gaps that are unreachable
|
||||
mount branches (plug guarantees the precondition) are removed with a comment
|
||||
stating the contract, not tested.
|
||||
4. **Leave genuinely defensive branches uncovered, deliberately.** Store
|
||||
failure paths only reachable by DB failure, fallbacks the router guarantees
|
||||
— chasing them with artificial mocks tests lines, not behavior. Note them
|
||||
in the yak context so the next ratchet isn't confused.
|
||||
5. **Refactor along pure-data lines when a metric flags code**; never split a
|
||||
linear shell script into async tasks to satisfy a metric. A flat lookup
|
||||
`case` that is genuinely clearer than a table may stay — note it, move on.
|
||||
6. **Don't chase Lens nits inline** while building; file a yak, let the Lens
|
||||
pass handle them.
|
||||
7. **Sandbox:** new hex deps need the user to run `mix deps.get` outside the
|
||||
sandbox (read-only `~/.hex/packages`). Never work around it.
|
||||
|
||||
## Suggested yak list (for `yx` in firehose)
|
||||
|
||||
1. Fast/push split: make verify + pre-push hook + Lens practice in AGENTS.md
|
||||
2. Coverage floors + framework-glue exclusions policy (both apps)
|
||||
3. Behavior tests: blogex library (probably 2–3 yaks by area)
|
||||
4. Behavior tests: accounts context
|
||||
5. Behavior tests: firehose_web LiveViews/controllers
|
||||
6. CRAP scores with ex_crap (both apps)
|
||||
7. Credo strict tuned config (both apps)
|
||||
8. Sobelow in the Lens pass
|
||||
9. Boundary: Repo as sibling boundary, web→Repo forbidden
|
||||
10. Mutation testing with muex (blogex + pure app code)
|
||||
@@ -0,0 +1,163 @@
|
||||
# Mutation testing with muex
|
||||
|
||||
Mutation testing is a **sensor, not a gate**. It deliberately breaks
|
||||
production code one mutant at a time and re-runs the suite: a mutant *killed*
|
||||
by a test is evidence the test pins that behaviour; a mutant that *survives*
|
||||
is a question for a Lens session. Runs always pass `--fail-at 0`, so a survivor
|
||||
is reported and triaged, never a failing build.
|
||||
|
||||
Nothing here is wired into `VERIFY_SENSORS`. `make check`, `make verify`,
|
||||
coverage floors and `.githooks/pre-push` are unchanged by this yak.
|
||||
|
||||
## How to run it
|
||||
|
||||
Both apps declare two aliases in `mix.exs`; both read a plain-text scope file
|
||||
at run time, so the file stays the single source of truth.
|
||||
|
||||
| Command | Scope | Blogex wall clock (2026-10-28) |
|
||||
|---------|-------|--------------------------------|
|
||||
| `mix mutate.fast` | `mutation_scope_fast.txt` (3 modules) | ~3 s |
|
||||
| `mix mutate` | `mutation_scope.txt` (8 modules) | ~4 s |
|
||||
|
||||
The app declares the same aliases but both scope files are empty (see
|
||||
"Scope: app" below), so `mix mutate` there raises a clear Mix error instead of
|
||||
running a sensor over nothing.
|
||||
|
||||
Extra arguments pass through to muex, so a one-off target is explicit:
|
||||
|
||||
```bash
|
||||
cd blogex
|
||||
mix mutate --files lib/blogex/seo.ex # one module, ad hoc
|
||||
mix mutate.fast --no-optimize # full mutant list, slower
|
||||
```
|
||||
|
||||
The aliases pin `--fail-at 0` first, so an ad-hoc flag cannot turn the sensor
|
||||
into a gate. `mix mutate` does not enable `--cover`: muex runs each mutant's
|
||||
suite under `MIX_ENV=test`, and coverage would let a code-deleting mutant trip
|
||||
the project's coverage floor and abort the run.
|
||||
|
||||
## Scope: blogex
|
||||
|
||||
| In scope (`mutation_scope.txt`) | Why |
|
||||
|---------------------------------|-----|
|
||||
| `lib/blogex/link_validator.ex` | Pure link parsing/validation; the yak's example module. |
|
||||
| `lib/blogex/seo.ex` | Pure sitemap/JSON-LD/OpenGraph string building. |
|
||||
| `lib/blogex/feed.ex` | Pure feed generation. |
|
||||
| `lib/blogex/post.ex` | Pure post parsing/visibility helpers. |
|
||||
| `lib/blogex/external_links.ex` | Pure HTML rewriting for external links. |
|
||||
| `lib/blogex/registry.ex` | Module-registry lookup; no I/O. |
|
||||
| `lib/blogex/not_found_error.ex` | Exception definition. |
|
||||
| `lib/blogex/link_error.ex` | Exception definition. |
|
||||
|
||||
| Out of scope | Why |
|
||||
|--------------|-----|
|
||||
| `lib/blogex.ex` | Facade + moduledoc: delegation and docs, no logic to mutate. |
|
||||
| `lib/blogex/blog.ex` | A `defmacro __using__` that expands `NimblePublisher`; mutating macro bodies produces noise, not behaviour. |
|
||||
| `lib/blogex/router.ex` | Phoenix router DSL (`use`, `scope`, `get`); macro-generated. |
|
||||
| `lib/blogex/components.ex` | Phoenix component DSL (`use`, `attr`, `slot`, `~H`). |
|
||||
| `lib/blogex/layout.ex` | LiveView layout DSL. |
|
||||
|
||||
`mutation_scope_fast.txt` is a subset (`link_validator`, `seo`, `post`) so the
|
||||
inner loop stays seconds long; a pin in
|
||||
`test/mutation_scope_test.exs` fails if it ever drifts outside the audited
|
||||
scope. Another pin fails if any scoped path stops existing, so a rename cannot
|
||||
silently leave the sensor reporting a score for a reduced set.
|
||||
|
||||
## Mutators
|
||||
|
||||
The aliases pass an explicit curated set (muex 0.12 CLI names):
|
||||
|
||||
```
|
||||
arithmetic, boolean, comparison, conditional, function_call, literal, return_value
|
||||
```
|
||||
|
||||
Macro/`use`-based mutators are deliberately absent. The scoped modules are plain
|
||||
functions, and the macro-heavy modules are out of scope; macro mutations produce
|
||||
false-positive survivors rather than behavioural questions. The `arithmetic`,
|
||||
`boolean`, `comparison` and `conditional` families are the behavioural core;
|
||||
`function_call`, `literal` and `return_value` catch removed calls, changed
|
||||
constants and stubbed return values.
|
||||
|
||||
## Baseline (blogex, 2026-10-28)
|
||||
|
||||
Command: `mix mutate` (default muex optimization, `--fail-at 0`).
|
||||
Result: **19 mutants, 12 killed, 0 survived, 0 invalid, 7 equivalent — 100%.**
|
||||
Wall clock: 3.5 s. The run that found the survivor below was 11 killed / 1
|
||||
survived (91.67%) before its pin landed.
|
||||
|
||||
| Module | Mutants | Killed | Survived | Equivalent |
|
||||
|--------|--------:|-------:|---------:|-----------:|
|
||||
| `link_validator.ex` | 7 | 4 | 0 | 3 |
|
||||
| `external_links.ex` | 6 | 5 | 0 | 1 |
|
||||
| `registry.ex` | 4 | 1 | 0 | 3 |
|
||||
| `post.ex` | 1 | 1 | 0 | 0 |
|
||||
| `seo.ex` | 1 | 1 | 0 | 0 |
|
||||
| `feed.ex` | 0 | 0 | 0 | 0 |
|
||||
| `not_found_error.ex` | 0 | 0 | 0 | 0 |
|
||||
| `link_error.ex` | 0 | 0 | 0 | 0 |
|
||||
|
||||
muex's file analyser gives `not_found_error.ex` a complexity score of 0 and
|
||||
skips it under the default filter. The scope file still lists it: it is pure
|
||||
code and stays audited if it grows logic. `feed.ex` is analysed but the curated
|
||||
mutator set generates nothing for it.
|
||||
|
||||
### Survivors found and killed
|
||||
|
||||
| Mutant | Resolution |
|
||||
|--------|------------|
|
||||
| `link_validator.ex:201` — `Boolean: and to or` in `validate_body/3`'s guard `when is_binary(body) and is_atom(blog)` | Genuine: the mutant let a string blog id through the guard. Pinned by two `FunctionClauseError` tests in `test/blogex/link_validator_test.exs`; `mix mutate.fast` then reports 0 survivors. |
|
||||
|
||||
## False-positive classes
|
||||
|
||||
These are not gaps in behaviour; do not weaken a test to "kill" them.
|
||||
|
||||
- **Commutative argument swaps.** `function_call` mutates `a and b` to
|
||||
`b and a`, `a == b` to `b == a`, and `a || b` to `b || a`. These compile to
|
||||
the same behaviour, and muex's trivial-compiler-equivalence check marks them
|
||||
`equivalent` (7 of the 19 baseline mutants). They are noise from the
|
||||
mutator, not survivors.
|
||||
- **Removed log lines.** A removed `Logger` call changes no return value; the
|
||||
suite cannot observe it. Such a mutant is documented here instead of adding
|
||||
a log-capture assertion that pins an implementation detail.
|
||||
- **`and`/`or` in guards is *not* automatically a false positive.** The
|
||||
baseline survivor above was a real guard-contract gap; only commutative
|
||||
rewrites are equivalent.
|
||||
- **Invalid mutants.** Mutated code that does not compile is counted
|
||||
`invalid`, not `survived`, and excluded from the score.
|
||||
|
||||
## Deeper runs
|
||||
|
||||
muex's default `balanced` optimization reduced 1304 generated mutants to 12
|
||||
tested ones (99.1%) on the blogex full scope. That is what keeps the sensor in
|
||||
seconds, but it is sampled, not exhaustive. `mix mutate --no-optimize` tests
|
||||
everything; on the three-module fast scope it took ~2 min 15 s and produced
|
||||
434 mutants (329 killed, 19 survived, 83 invalid, 3 equivalent) before the
|
||||
guard pin. Use it when triaging a specific module, not in a loop.
|
||||
|
||||
Re-baseline after a yak that changes the scoped modules (notably the Boundary
|
||||
work, which adds `use` lines inside them); do not compare scores across such a
|
||||
change.
|
||||
|
||||
## Scope: app
|
||||
|
||||
**Deliberately empty; no app baseline was run.** The candidate pure modules
|
||||
are `Firehose.Accounts.User`, `Firehose.Accounts.Scope` and
|
||||
`Firehose.Accounts.UserToken`, but every test that exercises them lives in
|
||||
`test/firehose/accounts_test.exs`, which uses `Firehose.DataCase`, fixtures and
|
||||
`Repo`; the SQL sandbox needs PostgreSQL. The yak's Q45 decision was: use those
|
||||
modules only after confirming their tests need no database, otherwise leave the
|
||||
app scope empty. They need one, so the scope is empty.
|
||||
|
||||
Consequences, all pinned by `app/test/mutation_scope_test.exs` and
|
||||
`app/test/mutation_config_test.exs`:
|
||||
|
||||
- Both app scope files list nothing, so `mix mutate` / `mix mutate.fast` raise
|
||||
`mutation scope … is empty; refusing to run the mutation sensor over nothing`.
|
||||
- Ad-hoc `--files` arguments do not bypass the guard: the scope is validated
|
||||
before muex arguments are built.
|
||||
- No scope entry may ever begin with `lib/firehose_web` (controllers,
|
||||
LiveViews, components).
|
||||
|
||||
There is no app mutation baseline to report. Do not add one until the scoped
|
||||
modules have tests that run without the database; change the scope file and
|
||||
these pins in the same commit.
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
elixir = "latest"
|
||||
erlang = "latest"
|
||||
elixir = "1.20.4-otp-27"
|
||||
erlang = "27.3.4.19"
|
||||
node = "latest"
|
||||
go = "1.26"
|
||||
go = "1.27"
|
||||
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-lens-sensors.sh - pin the Lens sensor wiring that must not drift.
|
||||
#
|
||||
# Why this exists: `make verify` echoes VERIFY_SENSORS and runs each name from
|
||||
# the app's sensor loop. A sensor missing from that list is never run, and a
|
||||
# sensor target that can fall back (or skip the tool when it is absent) exits 0
|
||||
# when it did not check anything - in both cases the pass reports a check it
|
||||
# did not run. AGENTS.md: "A gate that reports a check it did not run is worse
|
||||
# than no gate."
|
||||
#
|
||||
# This script is static and grep-only: no mise, no elixir, no network, no
|
||||
# database. It pins load-bearing text (the VERIFY_SENSORS line and the sobelow
|
||||
# recipe) and the rationale rule for per-finding skips. Renaming the target or
|
||||
# the variable means updating this script in the same commit - that is the
|
||||
# point: the wiring stays a reviewed decision rather than an accident.
|
||||
#
|
||||
# Run standalone, or via root `make check`.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
app_makefile="$root/app/Makefile"
|
||||
skips="$root/app/.sobelow-skips"
|
||||
status=0
|
||||
|
||||
# Verified 2026-10-28 against sobelow 0.17.0:
|
||||
# `cd app && mix sobelow` with 3 findings -> exit 0 (findings alone are NOT
|
||||
# fatal); `cd app && mix sobelow --exit` -> exit 1. `--exit` defaults to the
|
||||
# `low` confidence threshold, which is what we want: every finding counts.
|
||||
SOBELOW_FAIL_FLAG='--exit'
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; status=1; }
|
||||
|
||||
# --- T1: sobelow is wired and listed first in VERIFY_SENSORS ---------------
|
||||
# `make verify` echoes this list, so a missing name means the pass reports a
|
||||
# sensor it never runs. Cheapest-first ordering is part of the contract.
|
||||
check_verify_sensors() {
|
||||
local line sensors i
|
||||
line=$(grep -E '^VERIFY_SENSORS[[:space:]]*:=' "$app_makefile" || true)
|
||||
if [ -z "$line" ]; then
|
||||
fail "app/Makefile has no VERIFY_SENSORS := line"
|
||||
return
|
||||
fi
|
||||
|
||||
sensors=$(printf '%s' "${line#*:=}" | tr -s '[:space:]' ' ')
|
||||
local -a order
|
||||
read -r -a order <<<"$sensors"
|
||||
|
||||
idx() {
|
||||
local n
|
||||
for n in "${!order[@]}"; do
|
||||
if [ "${order[$n]}" = "$1" ]; then
|
||||
echo "$n"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo 999
|
||||
}
|
||||
|
||||
for sensor in sobelow coverage crap; do
|
||||
[ "$(idx "$sensor")" -ne 999 ] || fail "VERIFY_SENSORS does not list '$sensor' (line: $line)"
|
||||
done
|
||||
[ "$(idx sobelow)" -lt "$(idx coverage)" ] ||
|
||||
fail "VERIFY_SENSORS puts coverage before sobelow (cheapest first)"
|
||||
[ "$(idx sobelow)" -lt "$(idx crap)" ] ||
|
||||
fail "VERIFY_SENSORS puts crap before sobelow (cheapest first)"
|
||||
}
|
||||
|
||||
# --- T2: the sobelow target fails loudly and has no skip/fallback path -----
|
||||
# The stickies regression: the user explicitly removed a "not installed,
|
||||
# skipping" fallback because a silently-skipped check is a lying sensor.
|
||||
check_sobelow_recipe() {
|
||||
local recipe
|
||||
recipe=$(awk '
|
||||
/^sobelow:/ { found = 1; next }
|
||||
found && /^\t/ { print; next }
|
||||
found { exit }
|
||||
' "$app_makefile")
|
||||
|
||||
if [ -z "$recipe" ]; then
|
||||
fail "app/Makefile has no sobelow: target with a recipe"
|
||||
return
|
||||
fi
|
||||
|
||||
case "$recipe" in
|
||||
*"mix sobelow"*) ;;
|
||||
*) fail "sobelow recipe does not invoke 'mix sobelow': $recipe" ;;
|
||||
esac
|
||||
|
||||
case "$recipe" in
|
||||
*"$SOBELOW_FAIL_FLAG"*) ;;
|
||||
*) fail "sobelow recipe is missing $SOBELOW_FAIL_FLAG, so findings would exit 0: $recipe" ;;
|
||||
esac
|
||||
|
||||
local banned
|
||||
for banned in '||' 'command -v sobelow' 'skipping' '2>/dev/null'; do
|
||||
case "$recipe" in
|
||||
*"$banned"*) fail "sobelow recipe contains a fallback/skip path ('$banned'): $recipe" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# --- T3: every per-finding skip carries a written rationale ----------------
|
||||
# Sobelow 0.17 keeps per-finding skips as fingerprinted lines in
|
||||
# `app/.sobelow-skips` (enabled with `--skip`); `.sobelow-conf` holds only
|
||||
# options. A skip entry without a reason is a defect: the next reader cannot
|
||||
# tell a false positive from a debt someone hid. Comments are preserved by
|
||||
# sobelow's skip loader (they sort first and never match a fingerprint).
|
||||
check_skips() {
|
||||
[ -f "$skips" ] || return 0
|
||||
|
||||
local prev="" line n=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
n=$((n + 1))
|
||||
case "$line" in
|
||||
"" | "#"*)
|
||||
prev="$line"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
case "$prev" in
|
||||
"#"*) ;;
|
||||
*) fail "app/.sobelow-skips:$n: skip entry has no rationale comment on the line above: $line" ;;
|
||||
esac
|
||||
prev="$line"
|
||||
done <"$skips"
|
||||
}
|
||||
|
||||
check_verify_sensors
|
||||
check_sobelow_recipe
|
||||
check_skips
|
||||
|
||||
if [ $status -eq 0 ]; then
|
||||
echo "lens sensors ok: sobelow wired first in VERIFY_SENSORS, fail-loud recipe, app skips rationalised"
|
||||
else
|
||||
echo >&2 "fix the Lens sensor wiring/skips above, then re-run."
|
||||
fi
|
||||
exit $status
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-pins.sh - keep mise.toml and the Dockerfile base-image pins in sync.
|
||||
#
|
||||
# The Phoenix build has two sources of truth for toolchain versions:
|
||||
# - mise.toml [tools]: local dev toolchain (what `mise install` gives you)
|
||||
# - Dockerfile ARG pins: the hexpm/elixir builder image used for deploys
|
||||
# They must agree, otherwise local builds and deployed images run different
|
||||
# Elixir/OTP combinations. Run standalone, or via `make check`.
|
||||
#
|
||||
# Also verifies that the BUILDER_IMAGE / RUNNER_IMAGE ARGs still expand to the
|
||||
# tags composed from the pins (catches hand-edited image references).
|
||||
#
|
||||
# Pure shell + sed: no mise, docker or elixir needed, so it runs in CI and in
|
||||
# sandboxes before the toolchain is installed. Keep regexes POSIX-BRE: GNU
|
||||
# extensions like \+ silently break under /usr/bin/sed on macOS (it treats
|
||||
# \+ as a literal plus), which false-fails every pin as "not found".
|
||||
set -euo pipefail
|
||||
|
||||
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
mise_toml="$root/mise.toml"
|
||||
dockerfile="$root/Dockerfile"
|
||||
status=0
|
||||
|
||||
die() { echo "MISMATCH: $*" >&2; status=1; }
|
||||
|
||||
# sed-extract a `key = "value"` (or `ARG KEY=value`) line; dies when absent.
|
||||
grab() { # grab <description> <key-regex> <file>
|
||||
local value
|
||||
value=$(sed -n "s/^$2[[:space:]]*=[[:space:]]*//p" "$3")
|
||||
if [ -z "$value" ]; then
|
||||
die "$1 not found in $3 (expected key = value)"
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"} # strip optional surrounding quotes
|
||||
value=$(printf '%s' "$value" | sed 's/[[:space:]]*$//') # trim trailing ws
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
mise_elixir=$(grab 'mise elixir' 'elixir' "$mise_toml")
|
||||
mise_erlang=$(grab 'mise erlang' 'erlang' "$mise_toml")
|
||||
d_elixir=$(grab 'Dockerfile ELIXIR_VERSION' 'ARG[[:space:]]\{1,\}ELIXIR_VERSION' "$dockerfile")
|
||||
d_otp=$(grab 'Dockerfile OTP_VERSION' 'ARG[[:space:]]\{1,\}OTP_VERSION' "$dockerfile")
|
||||
d_debian=$(grab 'Dockerfile DEBIAN_VERSION' 'ARG[[:space:]]\{1,\}DEBIAN_VERSION' "$dockerfile")
|
||||
|
||||
if [ $status -eq 0 ]; then
|
||||
mise_elixir_ver=${mise_elixir%%-otp-*}
|
||||
mise_elixir_otp=${mise_elixir##*-otp-}
|
||||
d_otp_major=${d_otp%%.*}
|
||||
|
||||
# Elixir: full version must match exactly.
|
||||
[ "$mise_elixir_ver" = "$d_elixir" ] ||
|
||||
die "Elixir: mise.toml has $mise_elixir_ver, Dockerfile ELIXIR_VERSION is $d_elixir"
|
||||
|
||||
# mise elixir builds embed their OTP major (…-otp-27); must match the
|
||||
# major of the Dockerfile OTP_VERSION.
|
||||
[ "$mise_elixir_otp" = "$d_otp_major" ] ||
|
||||
die "OTP major: mise.toml elixir built for otp-$mise_elixir_otp, Dockerfile OTP_VERSION major is $d_otp_major"
|
||||
|
||||
# mise `erlang` IS OTP: must match the Dockerfile pin exactly.
|
||||
[ "$mise_erlang" = "$d_otp" ] ||
|
||||
die "OTP: mise.toml erlang is $mise_erlang, Dockerfile OTP_VERSION is $d_otp"
|
||||
|
||||
# BUILDER_IMAGE must expand to the tag composed from the pins.
|
||||
expected_builder="docker.io/hexpm/elixir:${d_elixir}-erlang-${d_otp}-debian-${d_debian}"
|
||||
d_builder=$(grab 'Dockerfile BUILDER_IMAGE' 'ARG[[:space:]]\{1,\}BUILDER_IMAGE' "$dockerfile")
|
||||
expanded_builder=$(printf '%s' "$d_builder" |
|
||||
sed -e "s/\${ELIXIR_VERSION}/$d_elixir/" \
|
||||
-e "s/\${OTP_VERSION}/$d_otp/" \
|
||||
-e "s/\${DEBIAN_VERSION}/$d_debian/")
|
||||
[ "$expanded_builder" = "$expected_builder" ] ||
|
||||
die "BUILDER_IMAGE expands to $expanded_builder, expected $expected_builder"
|
||||
|
||||
# RUNNER_IMAGE must expand to the Debian pin.
|
||||
expected_runner="docker.io/debian:${d_debian}"
|
||||
d_runner=$(grab 'Dockerfile RUNNER_IMAGE' 'ARG[[:space:]]\{1,\}RUNNER_IMAGE' "$dockerfile")
|
||||
expanded_runner=${d_runner/\$\{DEBIAN_VERSION\}/$d_debian}
|
||||
[ "$expanded_runner" = "$expected_runner" ] ||
|
||||
die "RUNNER_IMAGE expands to $expanded_runner, expected $expected_runner"
|
||||
fi
|
||||
|
||||
if [ $status -eq 0 ]; then
|
||||
echo "pins ok: Elixir $d_elixir, OTP $d_otp, Debian $d_debian (mise and Dockerfile agree)"
|
||||
echo " builder: $expected_builder"
|
||||
echo " runner: $expected_runner"
|
||||
echo " (upstream drift of these pins is reported by dokku-updates 'firehose check')"
|
||||
else
|
||||
echo >&2 "fix mise.toml and/or the Dockerfile ARG pins so they agree, then re-run."
|
||||
fi
|
||||
exit $status
|
||||
@@ -1,188 +0,0 @@
|
||||
;;; roam-export-blog-test.el --- ERT tests for roam-export-blog -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; Run with:
|
||||
;; emacs --batch -Q -l org -l ox-md -l scripts/roam-export-blog.el \
|
||||
;; -l scripts/roam-export-blog-test.el -f ert-run-tests-batch-and-exit
|
||||
;;
|
||||
;; Tests use a fixture vault under scripts/test-fixtures/vault and write
|
||||
;; output into throw-away firehose repo skeletons under /tmp.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'ert)
|
||||
(require 'roam-export-blog)
|
||||
|
||||
(defconst reb-test-dir
|
||||
(directory-file-name (file-name-directory (or load-file-name default-directory))))
|
||||
|
||||
(defconst reb-test-repo-root (expand-file-name ".." reb-test-dir))
|
||||
|
||||
(defconst reb-test-vault (expand-file-name "test-fixtures/vault" reb-test-dir))
|
||||
|
||||
(defun reb-test--file (name)
|
||||
"Path to fixture file NAME in the test vault."
|
||||
(expand-file-name name reb-test-vault))
|
||||
|
||||
(defun reb-test--new-root ()
|
||||
"Create a throw-away firehose repo skeleton and return its root."
|
||||
(let ((root (make-temp-file "/tmp/reb-root-" t)))
|
||||
(make-directory (expand-file-name "app/priv/blog/engineering" root) t)
|
||||
(make-directory (expand-file-name "app/priv/static/images/blog" root) t)
|
||||
root))
|
||||
|
||||
(defun reb-test--setup-org-id ()
|
||||
"Point org-id at a writable temp locations file and index the fixture vault."
|
||||
(setq org-id-locations-file (make-temp-file "/tmp/reb-orgid-"))
|
||||
(setq org-id-locations nil)
|
||||
(org-id-update-id-locations
|
||||
(mapcar #'reb-test--file '("note.org" "jev.org" "laya.org" "notitle.org")) t))
|
||||
|
||||
(defmacro reb-test--with-export (root &rest body)
|
||||
"Run BODY with a fresh repo ROOT and a pinned export date."
|
||||
(declare (indent 1))
|
||||
`(progn
|
||||
(reb-test--setup-org-id)
|
||||
(let ((roam-export-blog-firehose-root ,root)
|
||||
(roam-export-blog--current-date '("2026" "10" "08")))
|
||||
,@body)))
|
||||
|
||||
;;; Pure helpers
|
||||
|
||||
(ert-deftest roam-export-blog/slug ()
|
||||
(should (equal "will-it-blend" (roam-export-blog--slug "Will It Blend")))
|
||||
(should (equal "improve-diagnostic-in-the-tdd-cycle"
|
||||
(roam-export-blog--slug "Improve Diagnostic in the TDD cycle")))
|
||||
(should (equal "v0-2-0-rss-subscribe-links"
|
||||
(roam-export-blog--slug "v0.2.0 — RSS Subscribe Links"))))
|
||||
|
||||
(ert-deftest roam-export-blog/truncate-description-short ()
|
||||
(should (equal "Short paragraph."
|
||||
(roam-export-blog--truncate-description "Short paragraph."))))
|
||||
|
||||
(ert-deftest roam-export-blog/truncate-description-at-word-boundary ()
|
||||
(let* ((words (make-list 60 "word"))
|
||||
(text (string-join words " "))
|
||||
(result (roam-export-blog--truncate-description text)))
|
||||
(should (<= (length result) (+ roam-export-blog-description-length 1)))
|
||||
(should (string-suffix-p "…" result))
|
||||
(should-not (string-suffix-p " " result))
|
||||
(should (string-prefix-p "word word" result))))
|
||||
|
||||
;;; Roam ref resolution
|
||||
|
||||
(ert-deftest roam-export-blog/roam-refs ()
|
||||
(reb-test--setup-org-id)
|
||||
(should (equal "https://example.com/jev"
|
||||
(roam-export-blog--roam-refs (reb-test--file "jev.org"))))
|
||||
(should (null (roam-export-blog--roam-refs (reb-test--file "laya.org")))))
|
||||
|
||||
;;; Aborts
|
||||
|
||||
(ert-deftest roam-export-blog/no-title-aborts ()
|
||||
(let ((root (reb-test--new-root)))
|
||||
(reb-test--with-export root
|
||||
(should-error (roam-export-blog--export-file (reb-test--file "notitle.org"))
|
||||
:type 'user-error)
|
||||
;; Aborting must not have written any posts.
|
||||
(should (null
|
||||
(directory-files-recursively
|
||||
(expand-file-name "app/priv/blog" root) "\\.md$"))))))
|
||||
|
||||
(ert-deftest roam-export-blog/filename-collision-aborts ()
|
||||
(let ((root (reb-test--new-root)))
|
||||
(reb-test--with-export root
|
||||
;; Pre-create the exact target file.
|
||||
(make-directory
|
||||
(expand-file-name "app/priv/blog/engineering/2026" root) t)
|
||||
(write-region "" nil
|
||||
(expand-file-name
|
||||
"app/priv/blog/engineering/2026/10-08-will-it-blend.md" root))
|
||||
(should-error (roam-export-blog--export-file (reb-test--file "note.org"))
|
||||
:type 'user-error)
|
||||
;; Aborting must not have copied any images (no side effects).
|
||||
(should (null
|
||||
(directory-files-recursively
|
||||
(expand-file-name "app/priv/static/images/blog" root)
|
||||
"."))))))
|
||||
|
||||
;;; Full export
|
||||
|
||||
(ert-deftest roam-export-blog/end-to-end ()
|
||||
(let ((root (reb-test--new-root))
|
||||
result path body)
|
||||
(reb-test--with-export root
|
||||
(setq result (roam-export-blog--export-file (reb-test--file "note.org"))))
|
||||
(setq path (car result))
|
||||
;; Written at the agreed path.
|
||||
(should (equal (expand-file-name
|
||||
"app/priv/blog/engineering/2026/10-08-will-it-blend.md" root)
|
||||
path))
|
||||
(should (file-exists-p path))
|
||||
;; Image attachment copied into static images with unchanged content.
|
||||
(let ((image (expand-file-name "app/priv/static/images/blog/2026/will-it-blend.svg" root)))
|
||||
(should (file-exists-p image))
|
||||
(should (equal (with-temp-buffer (insert-file-contents image) (buffer-string))
|
||||
(with-temp-buffer
|
||||
(insert-file-contents (reb-test--file ".attach/00/000000-0000-0000-0000-000000000001/will-it-blend.svg"))
|
||||
(buffer-string)))))
|
||||
(setq body (with-temp-buffer (insert-file-contents path) (buffer-string)))
|
||||
;; Frontmatter.
|
||||
(should (string-match-p "title: \"Will It Blend\"" body))
|
||||
(should (string-match-p "author: \"Willem van den Ende\"" body))
|
||||
(should (string-match-p "tags: ~w()" body))
|
||||
(should (string-match-p
|
||||
"description: \"Will it Blend was a series of videos promoting a blender brand.\""
|
||||
body))
|
||||
(should (string-match-p "image: \"/images/blog/2026/will-it-blend.svg\"" body))
|
||||
(should (string-match-p "published: false" body))
|
||||
;; Markdown body.
|
||||
(should (string-match-p "Will it Blend was a series of videos promoting a blender brand." body))
|
||||
(should (string-match-p
|
||||
(regexp-quote "")
|
||||
body))
|
||||
(should (string-match-p (regexp-quote "[Jev](https://example.com/jev)") body))
|
||||
(should (string-match-p "Laya popped up" body))
|
||||
(should (string-match-p "a missing note vanished" body))
|
||||
(should (string-match-p "TODO source needed (LinkedIn)" body))
|
||||
(should (string-match-p "^## I learnt this from Julia$" body))
|
||||
(should (string-match-p "^> A quoted comment.$" body))
|
||||
(should (string-match-p "^```elixir$" body))
|
||||
(should (string-match-p (regexp-quote "**bold**") body))
|
||||
;; Org syntax must not leak into the markdown.
|
||||
(should-not (string-match-p "#\\+begin" body))
|
||||
(should-not (string-match-p "attachment:" body))
|
||||
(should-not (string-match-p "id:0000" body))
|
||||
(should-not (string-match-p "A private comment that must not be exported" body))
|
||||
;; Warnings: dangling ref, ref without ROAM_REFS, ignored non-image
|
||||
;; attachment.
|
||||
(should (= 3 (length (cdr result))))
|
||||
(should (cl-some (lambda (w) (string-match-p "00000000-0000-0000-0000-000000009999" w))
|
||||
(cdr result)))
|
||||
(should (cl-some (lambda (w) (string-match-p "00000000-0000-0000-0000-000000000003" w))
|
||||
(cdr result)))
|
||||
(should (cl-some (lambda (w) (string-match-p "attached-notes.txt" w))
|
||||
(cdr result)))))
|
||||
|
||||
(ert-deftest roam-export-blog/description-truncated-in-frontmatter ()
|
||||
(let* ((root (reb-test--new-root))
|
||||
(vault (make-temp-file "/tmp/reb-vault-" t))
|
||||
(note (expand-file-name "longpar.org" vault))
|
||||
(long-text (string-join (make-list 50 "lorem" ) " ")))
|
||||
(with-temp-file note
|
||||
(insert ":PROPERTIES:\n:ID: 00000000-0000-0000-0000-000000000005\n:END:\n"
|
||||
"#+title: Long Paragraph\n\n" long-text "\n"))
|
||||
(reb-test--with-export root
|
||||
(let ((result (roam-export-blog--export-file note)))
|
||||
(let ((body (with-temp-buffer
|
||||
(insert-file-contents (car result))
|
||||
(buffer-string))))
|
||||
(string-match "description: \"\\([^\"]*\\)\"" body)
|
||||
(let ((desc (match-string 1 body)))
|
||||
(should (<= (length desc) (+ roam-export-blog-description-length 1)))
|
||||
(should (string-suffix-p "…" desc))
|
||||
(should (string-prefix-p "lorem lorem" desc))))))))
|
||||
|
||||
(provide 'roam-export-blog-test)
|
||||
;;; roam-export-blog-test.el ends here
|
||||
@@ -1,453 +0,0 @@
|
||||
;;; roam-export-blog.el --- Export an org-roam note as a firehose blog post -*- lexical-binding: t; -*-
|
||||
|
||||
;;; Commentary:
|
||||
|
||||
;; M-x roam-export-blog, run from an org-roam note, converts the note to
|
||||
;; markdown and writes it directly into the firehose repo as a draft post:
|
||||
;;
|
||||
;; <repo>/app/priv/blog/engineering/<YYYY>/<MM>-<DD>-<slug>.md
|
||||
;;
|
||||
;; with `published: false' frontmatter, so the repo's git diff acts as the
|
||||
;; review mechanism.
|
||||
;;
|
||||
;; What happens during export:
|
||||
;;
|
||||
;; - `[[id:<uuid>][text]]' roam refs are resolved via org-id. When the
|
||||
;; target note carries a :ROAM_REFS: property, the link becomes a
|
||||
;; markdown link to the first URL; otherwise it degrades to plain text.
|
||||
;; - `[[attachment:...]]' links are resolved via the org-attach ID scheme
|
||||
;; (.attach/<xx>/<rest-of-id>/<file>). Images are copied into
|
||||
;; app/priv/static/images/blog/<YYYY>/ and referenced with the
|
||||
;; /images/blog/... web path. Non-image attachments are ignored.
|
||||
;; - Org syntax is converted to markdown with a derived ox-md backend
|
||||
;; (fenced code blocks with language, H2 top-level headings, no TOC).
|
||||
;; - Dropped refs, ignored attachments and unhandled constructs are
|
||||
;; reported as warnings.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'org)
|
||||
(require 'ox-md)
|
||||
(require 'org-id)
|
||||
(require 'cl-lib)
|
||||
|
||||
(defgroup roam-export-blog nil
|
||||
"Export org-roam notes as firehose blog posts."
|
||||
:group 'org)
|
||||
|
||||
(defcustom roam-export-blog-firehose-root "/Users/willem/dev/elixir/firehose"
|
||||
"Path to the firehose repository root."
|
||||
:type 'directory)
|
||||
|
||||
(defcustom roam-export-blog-author "Willem van den Ende"
|
||||
"Author name written into generated frontmatter."
|
||||
:type 'string)
|
||||
|
||||
(defcustom roam-export-blog-description-length 200
|
||||
"Maximum length of the generated description (before the ellipsis)."
|
||||
:type 'integer)
|
||||
|
||||
(defcustom roam-export-blog-image-extensions
|
||||
'("svg" "png" "jpg" "jpeg" "gif" "webp" "avif")
|
||||
"Attachment extensions that count as images."
|
||||
:type '(repeat string))
|
||||
|
||||
(defvar roam-export-blog--current-date nil
|
||||
"Override for the export date during tests, as a list (YEAR MONTH DAY).")
|
||||
|
||||
;;; Derived markdown backend
|
||||
|
||||
(defun roam-export-blog-md-src-block (src-block _contents info)
|
||||
"Transcode SRC-BLOCK into a fenced markdown code block with language."
|
||||
(let ((lang (org-element-property :language src-block))
|
||||
(value (org-export-format-code-default src-block info)))
|
||||
(concat "```" (or lang "") "\n" value "```")))
|
||||
|
||||
(defun roam-export-blog-md-link (link desc info)
|
||||
"Transcode LINK; file links to images become markdown images.
|
||||
|
||||
Everything else is delegated to `org-md-link'."
|
||||
(let* ((type (org-element-property :type link))
|
||||
(path (org-element-property :path link)))
|
||||
(if (and (equal type "file") (roam-export-blog--image-p path))
|
||||
(format ""
|
||||
(or (and desc (string-trim (substring-no-properties desc)))
|
||||
(file-name-base path))
|
||||
path)
|
||||
(org-md-link link desc info))))
|
||||
|
||||
(org-export-define-derived-backend 'firehose-md 'md
|
||||
:translate-alist '((src-block . roam-export-blog-md-src-block)
|
||||
(link . roam-export-blog-md-link)))
|
||||
|
||||
(defconst roam-export-blog--export-options
|
||||
'(:with-toc nil :section-numbers nil :md-toplevel-hlevel 2))
|
||||
|
||||
;;; Small helpers
|
||||
|
||||
(defun roam-export-blog--slug (title)
|
||||
"Derive a filename slug from TITLE."
|
||||
(let* ((lower (downcase title))
|
||||
(slugged (replace-regexp-in-string "[^a-z0-9]+" "-" lower)))
|
||||
(replace-regexp-in-string "\\`-+\\|-+\\'" "" slugged)))
|
||||
|
||||
(defun roam-export-blog--date-parts ()
|
||||
"Export date as a list of strings (YEAR MONTH DAY)."
|
||||
(or roam-export-blog--current-date
|
||||
(list (format-time-string "%Y")
|
||||
(format-time-string "%m")
|
||||
(format-time-string "%d"))))
|
||||
|
||||
(defun roam-export-blog--escape-elixir-string (text)
|
||||
"Escape TEXT for embedding in an Elixir double-quoted string."
|
||||
(replace-regexp-in-string
|
||||
"\"" "\\\\\""
|
||||
(replace-regexp-in-string "\\\\" "\\\\\\\\" text)))
|
||||
|
||||
(defun roam-export-blog--truncate-description (text)
|
||||
"Truncate TEXT to about `roam-export-blog-description-length' characters."
|
||||
(let ((max roam-export-blog-description-length))
|
||||
(if (<= (length text) max)
|
||||
text
|
||||
(let ((cut (cl-position ?\s (substring text 0 max) :from-end t)))
|
||||
(concat (if cut (substring text 0 cut) (substring text 0 max))
|
||||
"…")))))
|
||||
|
||||
(defun roam-export-blog--plain-text (element)
|
||||
"Return a plain-text approximation of org ELEMENT."
|
||||
(cond
|
||||
((stringp element) element)
|
||||
((memq (org-element-type element) '(code verbatim inline-src-block))
|
||||
(or (org-element-property :value element) ""))
|
||||
((eq (org-element-type element) 'entity)
|
||||
(or (org-element-property :utf8 element) ""))
|
||||
(t (mapconcat #'roam-export-blog--plain-text (org-element-contents element) ""))))
|
||||
|
||||
;;; Reading the note
|
||||
|
||||
(defun roam-export-blog--title (buffer)
|
||||
"Return the #+TITLE: keyword value in BUFFER, or nil."
|
||||
(with-current-buffer buffer
|
||||
(cadr (assoc "TITLE" (org-collect-keywords '("TITLE"))))))
|
||||
|
||||
(defun roam-export-blog--first-paragraph-text (buffer)
|
||||
"Return the plain text of the first paragraph in BUFFER."
|
||||
(with-current-buffer buffer
|
||||
(let ((para (org-element-map (org-element-parse-buffer) 'paragraph
|
||||
#'identity nil t)))
|
||||
(when para
|
||||
(replace-regexp-in-string
|
||||
"[ \t\n]+" " "
|
||||
(string-trim
|
||||
(substring-no-properties (roam-export-blog--plain-text para))))))))
|
||||
|
||||
(defun roam-export-blog--roam-refs (file)
|
||||
"Return the first :ROAM_REFS: URL of the note in FILE, or nil."
|
||||
(with-current-buffer (find-file-noselect file)
|
||||
(org-with-wide-buffer
|
||||
(goto-char (point-min))
|
||||
(let ((value (org-entry-get (point) "ROAM_REFS")))
|
||||
(when value (car (split-string value "[ \t]+")))))))
|
||||
|
||||
(defun roam-export-blog--note-title (file)
|
||||
"Return the #+TITLE: of the note in FILE, or nil."
|
||||
(roam-export-blog--title (find-file-noselect file)))
|
||||
|
||||
;;; Link processing
|
||||
|
||||
(defun roam-export-blog--vault-attach-dir (file)
|
||||
"Return the .attach directory dominating FILE, or nil."
|
||||
(when-let* ((root (locate-dominating-file file ".attach")))
|
||||
(expand-file-name ".attach" root)))
|
||||
|
||||
(defun roam-export-blog--image-p (file)
|
||||
"Return non-nil when FILE looks like an image attachment."
|
||||
(member (downcase (file-name-extension file))
|
||||
roam-export-blog-image-extensions))
|
||||
|
||||
(defun roam-export-blog--link-description (link)
|
||||
"Return the description text of LINK, or nil."
|
||||
(let ((begin (org-element-property :contents-begin link))
|
||||
(end (org-element-property :contents-end link)))
|
||||
(when (and begin end)
|
||||
(buffer-substring-no-properties begin end))))
|
||||
|
||||
(defun roam-export-blog--desc-or (desc fallback)
|
||||
"Return trimmed DESC, or FALLBACK when DESC is nil or empty.
|
||||
|
||||
An empty string is truthy in Elisp, so descriptions must be
|
||||
treated as absent explicitly."
|
||||
(let ((trimmed (when desc (string-trim desc))))
|
||||
(if (and trimmed (not (string-empty-p trimmed)))
|
||||
trimmed
|
||||
fallback)))
|
||||
|
||||
(defun roam-export-blog--desc-verbatim-or (desc fallback)
|
||||
"Return DESC unchanged, or FALLBACK when DESC is nil or blank.
|
||||
|
||||
Used for plain-text replacements: a description like \"Jev \"
|
||||
may carry the spacing that separated it from surrounding text in
|
||||
the org source."
|
||||
(if (and desc (not (string-empty-p (string-trim desc))))
|
||||
desc
|
||||
fallback))
|
||||
|
||||
(defun roam-export-blog--attachment-path (link vault-attach-dir note-id)
|
||||
"Return the absolute source path of attachment LINK, or nil.
|
||||
|
||||
Handles three shapes: file links already normalized by org to an
|
||||
absolute path under the vault's .attach directory, plain
|
||||
attachment: links and fuzzy links carrying an attachment: prefix.
|
||||
Resolution follows the org-attach ID scheme:
|
||||
.attach/<first two ID chars>/<rest of ID>/<path>."
|
||||
(let* ((type (org-element-property :type link))
|
||||
(path (org-element-property :path link))
|
||||
(bare (if (and (equal type "fuzzy")
|
||||
(string-prefix-p "attachment:" path))
|
||||
(substring path (length "attachment:"))
|
||||
path)))
|
||||
(cond
|
||||
((and (equal type "file") (file-name-absolute-p path)
|
||||
vault-attach-dir
|
||||
(string-prefix-p vault-attach-dir (expand-file-name path)))
|
||||
(expand-file-name path))
|
||||
((and vault-attach-dir note-id bare
|
||||
(>= (length note-id) 2))
|
||||
(let ((candidate (expand-file-name
|
||||
(concat (substring note-id 0 2)
|
||||
"/" (substring note-id 2)
|
||||
"/" bare)
|
||||
vault-attach-dir)))
|
||||
(when (file-exists-p candidate) candidate)))
|
||||
(t nil))))
|
||||
|
||||
(defun roam-export-blog--resolve-id (id)
|
||||
"Return the file containing org ID, or nil."
|
||||
(ignore-errors (org-id-find id)))
|
||||
|
||||
(defun roam-export-blog--link-replacement (link vault-attach-dir note-id)
|
||||
"Compute the replacement for LINK.
|
||||
|
||||
Return (REPLACEMENT WARNINGS IMAGES), where REPLACEMENT is org
|
||||
syntax to substitute for the link (nil leaves the link alone),
|
||||
WARNINGS is a list of warning strings and IMAGES is a list of
|
||||
(SOURCE . DEST) file copies to perform."
|
||||
(let* ((type (org-element-property :type link))
|
||||
(path (org-element-property :path link))
|
||||
(desc (roam-export-blog--link-description link))
|
||||
(replacement nil)
|
||||
(warnings nil)
|
||||
(images nil))
|
||||
(cond
|
||||
;; Roam refs: [[id:uuid][text]]
|
||||
((equal type "id")
|
||||
(let* ((target (roam-export-blog--resolve-id path))
|
||||
(refs (when target (roam-export-blog--roam-refs (car target))))) (cond
|
||||
((and target refs)
|
||||
(setq replacement (format "[[%s][%s]]"
|
||||
refs (roam-export-blog--desc-or desc refs))))
|
||||
(target
|
||||
(setq replacement (roam-export-blog--desc-verbatim-or
|
||||
desc (or (roam-export-blog--note-title (car target))
|
||||
"")))
|
||||
(push (format "dropped ref: id %s has no ROAM_REFS, kept plain text %S"
|
||||
path replacement)
|
||||
warnings))
|
||||
(t
|
||||
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
|
||||
(push (format "dropped ref: unresolved id %s, kept plain text %S"
|
||||
path replacement)
|
||||
warnings)))))
|
||||
;; Attachments (and file links that org normalized from attachments)
|
||||
((and vault-attach-dir
|
||||
(or (member type '("file" "attachment"))
|
||||
(and (equal type "fuzzy")
|
||||
(string-prefix-p "attachment:" path))))
|
||||
(let ((source (roam-export-blog--attachment-path link vault-attach-dir note-id)))
|
||||
(cond
|
||||
((and source (file-exists-p source) (roam-export-blog--image-p source))
|
||||
(let* ((name (file-name-nondirectory source))
|
||||
(year (car (roam-export-blog--date-parts)))
|
||||
(web-path (format "/images/blog/%s/%s" year name))
|
||||
(dest (expand-file-name
|
||||
(format "app/priv/static/images/blog/%s/%s" year name)
|
||||
roam-export-blog-firehose-root)))
|
||||
(setq images (list (cons source dest)))
|
||||
(setq replacement
|
||||
(format "[[file:%s][%s]]" web-path
|
||||
(roam-export-blog--desc-or desc
|
||||
(file-name-base name))))))
|
||||
((and source (file-exists-p source))
|
||||
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
|
||||
(push (format "ignored attachment (not an image): %s"
|
||||
(file-name-nondirectory source))
|
||||
warnings))
|
||||
(t
|
||||
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
|
||||
(push (format "unhandled link dropped: %s:%s" type path)
|
||||
warnings)))))
|
||||
;; Any other file: link points into the vault, not the blog.
|
||||
((equal type "file")
|
||||
(setq replacement (roam-export-blog--desc-verbatim-or desc ""))
|
||||
(push (format "unhandled link dropped: file:%s" path) warnings))
|
||||
;; Everything else (https, mailto, ...) passes through untouched.
|
||||
(t nil))
|
||||
(list replacement warnings images)))
|
||||
|
||||
(defun roam-export-blog--process-links (buffer vault-attach-dir)
|
||||
"Rewrite id/attachment/file links in BUFFER's text.
|
||||
|
||||
BUFFER is not modified; a processed copy of its text is returned.
|
||||
Return (TEXT WARNINGS IMAGES) where IMAGES is an alist of
|
||||
(SOURCE . DEST) image copies to perform."
|
||||
(with-current-buffer buffer
|
||||
(let ((text (buffer-string))
|
||||
(note-id (org-entry-get (point-min) "ID"))
|
||||
(warnings nil)
|
||||
(all-images nil))
|
||||
(let* ((tree (org-element-parse-buffer))
|
||||
(replacements
|
||||
(reverse
|
||||
(org-element-map tree 'link
|
||||
(lambda (link)
|
||||
(let* ((result (roam-export-blog--link-replacement
|
||||
link vault-attach-dir note-id))
|
||||
(replacement (nth 0 result))
|
||||
(warns (nth 1 result))
|
||||
(images (nth 2 result)))
|
||||
(setq warnings (append warnings warns)
|
||||
all-images (append all-images images))
|
||||
(when replacement
|
||||
(list (org-element-property :begin link)
|
||||
;; :end includes :post-blank (trailing
|
||||
;; whitespace); exclude it from the
|
||||
;; replaced region.
|
||||
(- (org-element-property :end link)
|
||||
(or (org-element-property :post-blank link) 0))
|
||||
replacement))))
|
||||
nil nil nil))))
|
||||
(dolist (r replacements)
|
||||
(let ((begin (nth 0 r))
|
||||
(end (nth 1 r))
|
||||
(replacement (nth 2 r)))
|
||||
(setq text (concat (substring text 0 (1- begin))
|
||||
replacement
|
||||
(substring text (1- end))))))
|
||||
(list text warnings all-images)))))
|
||||
|
||||
;;; Markdown generation
|
||||
|
||||
(defun roam-export-blog--to-markdown (text)
|
||||
"Export org TEXT to markdown with the firehose-md backend."
|
||||
(with-temp-buffer
|
||||
(insert text)
|
||||
(org-mode)
|
||||
(let ((out (org-export-to-buffer
|
||||
'firehose-md "*roam-export-blog export*"
|
||||
nil nil nil nil roam-export-blog--export-options)))
|
||||
(with-current-buffer out (buffer-string)))))
|
||||
|
||||
(defun roam-export-blog--cleanup (markdown)
|
||||
"Collapse runs of blank lines in MARKDOWN."
|
||||
(replace-regexp-in-string "\n\\(?:[ \t]*\n\\)+\\'" "\n"
|
||||
(replace-regexp-in-string "\n\\{3,\\}" "\n\n" markdown)))
|
||||
|
||||
;;; Frontmatter and writing
|
||||
|
||||
(defun roam-export-blog--frontmatter (title description image)
|
||||
"Build the Elixir-term frontmatter for the post."
|
||||
(concat "%{\n"
|
||||
(format " title: %S,\n" (roam-export-blog--escape-elixir-string title))
|
||||
(format " author: %S,\n" roam-export-blog-author)
|
||||
" tags: ~w(),\n"
|
||||
(format " description: %S,\n"
|
||||
(roam-export-blog--escape-elixir-string description))
|
||||
(format " image: %S,\n" image)
|
||||
" published: false\n"
|
||||
"}\n---\n"))
|
||||
|
||||
(defun roam-export-blog--post-path (slug)
|
||||
"Return the target post path for SLUG."
|
||||
(let ((year (car (roam-export-blog--date-parts)))
|
||||
(month (nth 1 (roam-export-blog--date-parts)))
|
||||
(day (nth 2 (roam-export-blog--date-parts))))
|
||||
(expand-file-name
|
||||
(format "app/priv/blog/engineering/%s/%s-%s-%s.md" year month day slug)
|
||||
roam-export-blog-firehose-root)))
|
||||
|
||||
(defun roam-export-blog--copy-images (images)
|
||||
"Copy IMAGES (alist of SOURCE . DEST), warning on basename clashes."
|
||||
(let ((copied nil)
|
||||
(warnings nil))
|
||||
(dolist (pair images)
|
||||
(let ((source (car pair))
|
||||
(dest (cdr pair)))
|
||||
(cond
|
||||
((member dest copied)
|
||||
(push (format "skipped duplicate image copy: %s"
|
||||
(file-name-nondirectory source))
|
||||
warnings))
|
||||
(t
|
||||
(make-directory (file-name-directory dest) t)
|
||||
(copy-file source dest t)
|
||||
(push dest copied)))))
|
||||
(nreverse warnings)))
|
||||
|
||||
;;; The command
|
||||
|
||||
(defun roam-export-blog--export-file (&optional file)
|
||||
"Export org-roam note FILE to a firehose blog post.
|
||||
|
||||
Return (OUTPUT-PATH . WARNINGS). Abort with `user-error' when the
|
||||
note has no title or the target file already exists."
|
||||
(setq file (or file (buffer-file-name)))
|
||||
(unless file
|
||||
(user-error "roam-export-blog: not visiting a file"))
|
||||
(let* ((buffer (find-file-noselect file))
|
||||
(title (roam-export-blog--title buffer)))
|
||||
(unless title
|
||||
(user-error "roam-export-blog: %s has no #+title: keyword, aborting" file))
|
||||
(let* ((slug (roam-export-blog--slug title))
|
||||
(post-path (roam-export-blog--post-path slug)))
|
||||
(when (file-exists-p post-path)
|
||||
(user-error "roam-export-blog: %s already exists, aborting" post-path))
|
||||
(let* ((processed (roam-export-blog--process-links
|
||||
buffer (roam-export-blog--vault-attach-dir file)))
|
||||
(text (nth 0 processed))
|
||||
(link-warnings (nth 1 processed))
|
||||
(images (nth 2 processed))
|
||||
(copy-warnings (roam-export-blog--copy-images images))
|
||||
(warnings (append (nreverse link-warnings) copy-warnings))
|
||||
(image (when images
|
||||
(let ((static (expand-file-name
|
||||
"app/priv/static"
|
||||
roam-export-blog-firehose-root)))
|
||||
(string-remove-prefix static (cdr (car images))))))
|
||||
(description (roam-export-blog--truncate-description
|
||||
(roam-export-blog--first-paragraph-text buffer)))
|
||||
(markdown (roam-export-blog--cleanup (roam-export-blog--to-markdown text))))
|
||||
(make-directory (file-name-directory post-path) t)
|
||||
(with-temp-file post-path
|
||||
(insert (roam-export-blog--frontmatter title description image)
|
||||
markdown))
|
||||
(cons post-path warnings)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun roam-export-blog ()
|
||||
"Export the current org-roam note as a firehose blog post."
|
||||
(interactive)
|
||||
(let ((result (roam-export-blog--export-file (buffer-file-name))))
|
||||
(let ((path (car result))
|
||||
(warnings (cdr result)))
|
||||
(message "roam-export-blog: wrote %s%s" path
|
||||
(if warnings
|
||||
(format " (%d warning%s)" (length warnings)
|
||||
(if (= 1 (length warnings)) "" "s"))
|
||||
""))
|
||||
(when warnings
|
||||
(with-output-to-temp-buffer "*roam-export-blog warnings*"
|
||||
(dolist (w warnings)
|
||||
(princ (format "%s\n" w))))))))
|
||||
|
||||
(provide 'roam-export-blog)
|
||||
;;; roam-export-blog.el ends here
|
||||
-1
@@ -1 +0,0 @@
|
||||
some attached notes
|
||||
-1
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>
|
||||
|
Before Width: | Height: | Size: 100 B |
@@ -1,7 +0,0 @@
|
||||
:PROPERTIES:
|
||||
:ID: 00000000-0000-0000-0000-000000000002
|
||||
:ROAM_REFS: https://example.com/jev
|
||||
:END:
|
||||
#+title: Jev
|
||||
|
||||
Body of Jev.
|
||||
@@ -1,6 +0,0 @@
|
||||
:PROPERTIES:
|
||||
:ID: 00000000-0000-0000-0000-000000000003
|
||||
:END:
|
||||
#+title: Laya
|
||||
|
||||
Body of Laya.
|
||||
@@ -1,26 +0,0 @@
|
||||
:PROPERTIES:
|
||||
:ID: 00000000-0000-0000-0000-000000000001
|
||||
:END:
|
||||
#+title: Will It Blend
|
||||
|
||||
Will it Blend was a series of videos promoting a blender brand.
|
||||
|
||||
# A private comment that must not be exported.
|
||||
|
||||
[[attachment:will-it-blend.svg]]
|
||||
|
||||
For instance, a day after [[id:00000000-0000-0000-0000-000000000002][Jev]] came out, [[id:00000000-0000-0000-0000-000000000003][Laya]] popped up, and [[id:00000000-0000-0000-0000-000000009999][a missing note]] vanished. TODO source needed (LinkedIn)
|
||||
|
||||
[[attachment:attached-notes.txt]]
|
||||
|
||||
* I learnt this from Julia
|
||||
|
||||
Body text with *bold*.
|
||||
|
||||
#+begin_quote
|
||||
A quoted comment.
|
||||
#+end_quote
|
||||
|
||||
#+begin_src elixir
|
||||
IO.puts("hi")
|
||||
#+end_src
|
||||
@@ -1,5 +0,0 @@
|
||||
:PROPERTIES:
|
||||
:ID: 00000000-0000-0000-0000-000000000004
|
||||
:END:
|
||||
|
||||
No title here.
|
||||
Reference in New Issue
Block a user