Compare commits
8
Commits
8fa342e80d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f9162872 | ||
|
|
611f0887c4 | ||
|
|
e38afb523c | ||
|
|
7fda0455c1 | ||
|
|
0d344a4f57 | ||
|
|
84539ec40f | ||
|
|
97fc024a38 | ||
|
|
7215cd6db0 |
@@ -70,6 +70,12 @@ This is a Phoenix Liveview monorepo, the blogging library is in blogex, the appl
|
||||
|
||||
Main make targets:
|
||||
|
||||
- `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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
MISE_BIN ?= mise
|
||||
|
||||
.PHONY: check precommit verify hooks deps compile test test-elisp format
|
||||
.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.
|
||||
@@ -47,9 +47,18 @@ hooks:
|
||||
echo "installed $$hooks_dir/$$name -> $$hook"; \
|
||||
done
|
||||
|
||||
# Sync dependencies
|
||||
deps:
|
||||
@make -C app deps
|
||||
# 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:
|
||||
@@ -60,10 +69,6 @@ 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 in both apps. Mutating convenience target: `check` (and so
|
||||
# `verify`) never writes. If a gate fails on formatting, run this.
|
||||
format:
|
||||
|
||||
+31
-8
@@ -3,12 +3,13 @@
|
||||
MISE_BIN ?= mise
|
||||
MISE_EXEC = $(MISE_BIN) exec --
|
||||
|
||||
.PHONY: check precommit deps assets compile test test.coverage sobelow coverage crap format format.check credo verify
|
||||
.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). `format.check` only
|
||||
# reports: a gate that rewrites the files it judges can never fail. Fix
|
||||
# formatting with `make format`.
|
||||
check: credo format.check
|
||||
# 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
|
||||
@@ -23,9 +24,31 @@ 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)
|
||||
# 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:
|
||||
@@ -33,13 +56,13 @@ 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
|
||||
|
||||
# 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 compile
|
||||
test.coverage: deps.check compile
|
||||
rm -f cover/*.coverdata
|
||||
$(MISE_EXEC) mix test --cover --export-coverage default
|
||||
|
||||
|
||||
+8
-7
@@ -1,9 +1,8 @@
|
||||
%{
|
||||
title: "The Intention, Location, Details communication Pattern",
|
||||
author: "Willem van den Ende",
|
||||
tags: ~w(),
|
||||
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."
|
||||
",
|
||||
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
|
||||
}
|
||||
@@ -22,18 +21,20 @@ I came across this pattern, as I was going through [Emily Bache](https://coding
|
||||
|
||||
## 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/)[https://sammancoaching.org](https://sammancoaching.org) has it on page 27. I have read the book, and would recommend, but didn’t notice it as I went through it.
|
||||
[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 our, which also contains a description of the pattern.
|
||||
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, is that 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.
|
||||
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).
|
||||
|
||||
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 it) I am curious. The devil is, as always, is in the intent(ion).
|
||||
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!
|
||||
|
||||
@@ -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,9 @@ 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
|
||||
@@ -191,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
|
||||
|
||||
+27
-7
@@ -3,39 +3,59 @@
|
||||
MISE_BIN ?= mise
|
||||
MISE_EXEC = $(MISE_BIN) exec --
|
||||
|
||||
.PHONY: check precommit deps compile test test.coverage coverage crap format format.check verify
|
||||
.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 coverage). Runs from the
|
||||
# root `make check`, which loops app then blogex. `format.check` only reports:
|
||||
# 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: compile format.check
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
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
|
||||
test.coverage: deps.check
|
||||
rm -f cover/*.coverdata
|
||||
$(MISE_EXEC) mix test --cover --export-coverage default
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
# 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.
|
||||
# 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)
|
||||
@@ -35,9 +37,9 @@ grab() { # grab <description> <key-regex> <file>
|
||||
|
||||
mise_elixir=$(grab 'mise elixir' 'elixir' "$mise_toml")
|
||||
mise_erlang=$(grab 'mise erlang' 'erlang' "$mise_toml")
|
||||
d_elixir=$(grab 'Dockerfile ELIXIR_VERSION' 'ARG[[:space:]]\+ELIXIR_VERSION' "$dockerfile")
|
||||
d_otp=$(grab 'Dockerfile OTP_VERSION' 'ARG[[:space:]]\+OTP_VERSION' "$dockerfile")
|
||||
d_debian=$(grab 'Dockerfile DEBIAN_VERSION' 'ARG[[:space:]]\+DEBIAN_VERSION' "$dockerfile")
|
||||
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-*}
|
||||
@@ -59,7 +61,7 @@ if [ $status -eq 0 ]; then
|
||||
|
||||
# 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:]]\+BUILDER_IMAGE' "$dockerfile")
|
||||
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/" \
|
||||
@@ -69,7 +71,7 @@ if [ $status -eq 0 ]; then
|
||||
|
||||
# RUNNER_IMAGE must expand to the Debian pin.
|
||||
expected_runner="docker.io/debian:${d_debian}"
|
||||
d_runner=$(grab 'Dockerfile RUNNER_IMAGE' 'ARG[[:space:]]\+RUNNER_IMAGE' "$dockerfile")
|
||||
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"
|
||||
|
||||
@@ -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