90 Commits
v1.16 ... v1.20

Author SHA1 Message Date
Alex Schroeder
991260b78c Release 1.20 2026-02-06 15:57:16 +01:00
Alex Schroeder
751b9fe63d Switch -shrink quality to 30% instead of 10%
At 30% the images seem to be a lot let worse than 10% and are only
twice as big.
2026-02-06 10:58:12 +01:00
Alex Schroeder
34afc151a4 Explain how to use real usernames and passwords 2026-01-29 14:00:31 +01:00
Alex Schroeder
efbea969fa Typo fix 2026-01-29 13:50:24 +01:00
Alex Schroeder
e12ab8594c Add dist-upload target to my server 2026-01-14 18:38:54 +01:00
Alex Schroeder
233b9817b5 Add sitemap command and handler 2026-01-03 18:09:31 +01:00
Alex Schroeder
ecfee31cbd Fixed golint and gocritic issues 2026-01-02 22:36:43 +01:00
Alex Schroeder
e86de1beb8 Remove list.html from the templates 2026-01-02 22:31:44 +01:00
Alex Schroeder
e9666a5ec5 Fixed missing command
Outside of the test it always got an empty index and therefore never
found any missing pages.
2026-01-02 14:33:42 +01:00
Alex Schroeder
f71a0e9780 Feeds page through yearly archives, too 2026-01-01 17:44:44 +01:00
Alex Schroeder
aca1d82fe0 Feed subcommand uses page name to determine date
The pubDate of the items in an archive feed should not be based on the
last modified time of the file because mass edits are irrelevant in
this context. The intended creation time is more important.
2025-12-31 20:28:41 +01:00
Alex Schroeder
290ad16e09 Change {{.Html}} to {{.HTML}} in the themes folder
Add note to the release page.
2025-12-06 14:24:19 +01:00
Alex Schroeder
ad1732f57f Fix all gocritic and golint issues 2025-12-06 14:10:20 +01:00
Alex Schroeder
67120af7cc Run goimports and rearrange imports 2025-12-06 13:38:46 +01:00
Alex Schroeder
c186253a25 Add check and fix targets to Makefile 2025-12-06 13:35:34 +01:00
Alex Schroeder
d0d4545f74 Ignore some errors when generating a static site
When using the static subcommand:

- ignore existing directory;
- remove existing files before creating a link;
- report any remaining errors.
2025-12-06 13:35:31 +01:00
Alex Schroeder
c32f087af4 Add shrink option to static command 2025-12-05 14:22:21 +01:00
Alex Schroeder
f657ac60a3 Extract MIME type detection by extension 2025-12-04 23:33:46 +01:00
Alex Schroeder
16ae6cc143 Don't load the index for notifyCli
It's not required for the command-line and improves execution time.
2025-11-08 23:33:33 +01:00
Alex Schroeder
e041c5ecae Fiddle with the wording of the README 2025-09-29 16:53:25 +02:00
Alex Schroeder
3078d63890 Release v1.19 2025-09-28 19:38:52 +02:00
Alex Schroeder
143ecb8a0a Update release info 2025-09-26 00:34:37 +02:00
Alex Schroeder
d66aa03a2d Document missing attributes of the feed template 2025-09-24 08:30:44 +02:00
Alex Schroeder
64954ddf5d Use & instead of & in the feed test 2025-09-24 08:30:21 +02:00
Alex Schroeder
a1d6ebfdff Fix ampersand quoting in feed.html 2025-09-22 23:15:42 +02:00
Alex Schroeder
db3a3f5009 Remove the .git suffix from the module
This counters the change in 2092b57. The .git suffix is technically
correct for the git repository but results in a binary called
"oddmu.git" which is incorrect.
2025-09-22 20:22:07 +02:00
Alex Schroeder
ece9649e3d Test for complete feeds 2025-09-22 13:44:58 +02:00
Alex Schroeder
23074cdd58 Video width 100% in CSS 2025-09-22 13:34:59 +02:00
Alex Schroeder
06c07209a2 Add test for feed command 2025-09-21 23:24:44 +02:00
Alex Schroeder
7b2a835729 Add feed pagination 2025-09-21 23:24:24 +02:00
Alex Schroeder
d0fe534f8e Explicitly gzip the .tar.gz file 2025-09-21 22:18:33 +02:00
Alex Schroeder
ac7de17a87 Use better globbing in the Makefile 2025-09-11 22:12:51 +02:00
Alex Schroeder
84e6a757b2 Upload the README, too 2025-09-11 22:12:40 +02:00
Alex Schroeder
2dfb2afbf5 Add a paragraph about "go install" 2025-09-11 22:11:48 +02:00
Alex Schroeder
2092b5777c Switch module identifier
From alexschroeder.ch/cgit/oddmu to src.alexschroeder.ch/oddmu.git
2025-09-02 23:00:36 +02:00
Alex Schroeder
f635cb738a go fmt 2025-09-01 17:40:17 +02:00
Alex Schroeder
da398a3315 Fix feed title and add test 2025-08-31 22:08:43 +02:00
Alex Schroeder
7315abd5bb Check for zero page names 2025-08-31 21:56:24 +02:00
Alex Schroeder
b39901b244 More man page fiddling 2025-08-31 21:47:23 +02:00
Alex Schroeder
bb4843c2f4 Updated oddmu-releases(7) 2025-08-31 16:29:35 +02:00
Alex Schroeder
816c981200 More cross-linking on the man pages 2025-08-31 16:28:22 +02:00
Alex Schroeder
89d550a1a4 Add feed subcommand 2025-08-31 13:13:44 +02:00
Alex Schroeder
4eb013a4da Remove link to list from upload.html 2025-08-10 08:59:19 +02:00
Alex Schroeder
e8f6ae0450 Release 1.18 2025-08-10 08:47:04 +02:00
Alex Schroeder
9bf3beb440 Add the new feature to the release docs 2025-08-09 18:21:20 +02:00
Alex Schroeder
cd6809d791 Add -update and -dry-run options to hashtags command 2025-08-09 18:18:11 +02:00
Alex Schroeder
7c5a3860e7 Fix addLinkToPage
It now tries to keep the links sorted.
2025-08-09 18:17:56 +02:00
Alex Schroeder
a7c343decb Be more restrictive about the request methods 2025-08-08 17:16:41 +02:00
Alex Schroeder
18bb5da8c0 Preview requests using GET redirect to view requests 2025-08-08 00:58:50 +02:00
Alex Schroeder
2a0ea791ec Removed unnecessary option description 2025-08-07 22:12:11 +02:00
Alex Schroeder
726586b39d Updated README 2025-08-07 07:11:54 +02:00
Alex Schroeder
8f30704be9 Improve WebDAV documentation 2025-07-16 16:24:36 +02:00
Alex Schroeder
616ae0a1ba Mention how to sudo make install PREFIX= 2025-07-16 11:20:04 +02:00
Alex Schroeder
af86b865bf Delete list, delete and rename actions
In an effort to remove features that can be handled by the web server, the
list, delete and rename actions were removed again.

The sentence linking to the list action from the upload
template ("upload.html") was also deleted.
2025-07-16 11:03:40 +02:00
Alex Schroeder
7110e0af68 Release 1.17 2025-04-26 19:39:17 +02:00
Alex Schroeder
8841372814 Fix typos 2025-04-26 19:36:33 +02:00
Alex Schroeder
fefa283775 Reword oddmu-releases 2025-04-23 08:05:46 +02:00
Alex Schroeder
5a09d65dab Theme fixes 2025-04-23 08:05:33 +02:00
Alex Schroeder
2cf0855994 Add changes link
To transjovian.org and default theme. I must have dropped it at some
point but I think it makes no sense.
2025-04-22 08:26:28 +02:00
Alex Schroeder
f98312e12f Fix communitywiki theme
Add missing templates.
2025-04-22 08:25:11 +02:00
Alex Schroeder
d213ee2815 Add communitywiki.org theme
This is a very minor theme.
2025-04-22 07:59:27 +02:00
Alex Schroeder
0cd09666c6 Don't inherit the color for buttons 2025-04-21 23:20:53 +02:00
Alex Schroeder
bd9364dc09 Change "Skip navigation" to "Skip" in all templates 2025-04-21 16:39:42 +02:00
Alex Schroeder
93fd49bc4c Fix template test
I changed "Skip navigation" to "Skip" in the templates.
2025-04-21 16:36:49 +02:00
Alex Schroeder
300e411960 Make search handler more robust 2025-04-21 16:36:18 +02:00
Alex Schroeder
10cea2bf2c Fix comment 2025-04-21 16:30:09 +02:00
Alex Schroeder
830af140eb Add initial-scale=1.0, user-scalable=no to all templates 2025-04-21 16:28:39 +02:00
Alex Schroeder
c758dd7df7 Fix image inclusion for the search template 2025-04-21 16:25:47 +02:00
Alex Schroeder
969df2aef9 Add pasting of images to chat theme 2025-04-21 16:25:30 +02:00
Alex Schroeder
39f414694c Update alexschroeder.ch theme
Drop the separate CSS file and switch to inline CSS again.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 16:24:32 +02:00
Alex Schroeder
fa67508692 Update campaignwiki.org theme
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:47:02 +02:00
Alex Schroeder
d5696135c1 Use image-1.jpg as the default filename 2025-04-21 00:40:14 +02:00
Alex Schroeder
284fc3094d Update flying-carpet.ch theme
Use .Path instead of .Name in URLs.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:33:23 +02:00
Alex Schroeder
57161bbc98 Updated release info
The link to the upload page needs two query parameters, most likely.
2025-04-21 00:28:08 +02:00
Alex Schroeder
d855d9d91a Update transjovian.org theme
Use .Path instead of .Name in URLs.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:25:19 +02:00
Alex Schroeder
ca85250514 Remove trailing semicolon in standard theme 2025-04-21 00:24:52 +02:00
Alex Schroeder
649fde81fe Add a warning about JavaScript to plain theme 2025-04-21 00:24:19 +02:00
Alex Schroeder
8a47e9c5fe Remove some extra semicolons in a theme 2025-04-21 00:05:34 +02:00
Alex Schroeder
fd9a515e0f Use .Path instead of .Name in list.html
This ensures that files with hash signs or question marks in their
name get handled correctly.
2025-04-21 00:04:32 +02:00
Alex Schroeder
da04c6dc27 Major update of upload.html! 2025-04-21 00:02:28 +02:00
Alex Schroeder
bd2da1414c Remove trailing semicolons in CSS 2025-04-09 21:14:16 +02:00
Alex Schroeder
6d1a5462b4 Make textarea "full size" 2025-04-09 21:10:54 +02:00
Alex Schroeder
3dcaf8aca1 Fix markup 2025-04-08 08:49:28 +02:00
Alex Schroeder
80ce16f873 Improve the documentation of the template changes 2025-04-08 08:41:41 +02:00
Alex Schroeder
41347ad5dc Fix list action
More explicit path encoding for directory and file name.
2025-04-08 08:04:52 +02:00
Alex Schroeder
6a911b2860 Fix handling of hashes in filenames 2025-04-08 00:14:59 +02:00
Alex Schroeder
1d6db77660 Handle files with # in their name 2025-04-06 23:44:57 +02:00
Alex Schroeder
8a8afcb56f List action skips dot files and dot directories 2025-04-06 21:08:36 +02:00
Alex Schroeder
6803b8e90d Update the themes 2025-04-06 20:44:57 +02:00
Alex Schroeder
ff357a4048 Added more steps to the RELEASE file 2025-04-06 13:48:12 +02:00
169 changed files with 3774 additions and 1548 deletions

View File

@@ -1,7 +1,8 @@
SHELL=/bin/bash
PREFIX=${HOME}/.local
BINARIES=oddmu-linux-amd64.tar.gz oddmu-linux-arm64.tar.gz oddmu-darwin-amd64.tar.gz oddmu-windows-amd64.tar.gz
.PHONY: help build test run upload docs install priv
.PHONY: help build test run upload docs install priv clean dist dist-upload
help:
@echo Help for Oddmu
@@ -10,12 +11,18 @@ help:
@echo " runs program, offline"
@echo make test
@echo " runs the tests without log output"
@echo make check
@echo " checks the code with golint and gocritic"
@echo make fix
@echo " fixes formatting issues with goimports instead of go fmt"
@echo make docs
@echo " create man pages from text files"
@echo make build
@echo " just build it"
@echo make install
@echo " install the files to ~/.local"
@echo sudo make install PREFIX=/usr/local
@echo " install the files to /usr/local"
@echo make upload
@echo " this is how I upgrade my server"
@echo make dist
@@ -32,6 +39,14 @@ test:
rm -rf testdata/*
go test -shuffle on .
check:
golint
gocritic check
fix:
goimports -w *.go
run:
go run .
@@ -51,7 +66,10 @@ clean:
rm --force oddmu oddmu.exe oddmu-{linux,darwin,windows}-{amd64,arm64}{,.tar.gz}
cd man && make clean
dist: oddmu-linux-amd64.tar.gz oddmu-linux-arm64.tar.gz oddmu-darwin-amd64.tar.gz oddmu-windows-amd64.tar.gz
dist-upload: $(BINARIES)
rsync -ai $^ sibirocobombus:alexschroeder.ch/wiki/oddmu/
dist: $(BINARIES)
oddmu-linux-amd64: *.go
GOOS=linux GOARCH=amd64 go build -o $@
@@ -71,8 +89,8 @@ oddmu-windows-amd64.tar.gz: oddmu.exe
$< *.md man/*.[157].{html,md} themes/
%.tar.gz: %
tar --create --file $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< *.html Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
tar --create --gzip --file $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< *.html Makefile *.socket *.service *.md man/Makefile man/*.[157] themes/
priv:
sudo setcap 'cap_net_bind_service=+ep' oddmu

View File

@@ -1,28 +1,33 @@
# Oddμ: A minimal wiki
This program helps you run a minimal wiki, blog, digital garden, memex
or Zettelkasten. There is no version history.
Oddμ (or Oddmu) helps you run a minimal wiki, blog, digital garden,
memex or Zettelkasten.
It's well suited as a self-hosted, single-user web application, when
there is no need for collaboration on the site itself. Links and email
connect you to the rest of the net. The wiki can be public or private.
Perhaps it just runs on your local machine, unreachable from the
Internet.
Oddμ can be run as a static site generator, processing a directory
with Markdown files, turning them into HTML files. HTML templates
allow the customisation of headers, footers and styling. There are no
plugins.
It's well suited as a secondary medium for a close-knit group:
Oddμ is well suited as a self-hosted, single-user web application.
Edit the pages from your phone or laptop, while you're on the move.
If the site is public, use a regular web server as a proxy to make
people log in before making changes. As there is no version history,
it is not easy to undo vandalism and spam. Only grant write-access
to people you trust.
If the site is private, running on a local machine and unreachable
from the Internet, no such precautions are necessary.
This makes Oddμ well suited as a secondary medium for a close-knit group:
collaboration and conversation happens elsewhere, in chat, on social
media. The wiki serves as the text repository that results from these
discussions. As there are no logins and no version histories, it is
not possible to undo vandalism and spam. Only allow people you trust
write-access to the site.
It's well suited as a simple static site generator. There are no
plugins.
discussions.
When Oddμ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages. These pages can be edited via the web.
Oddmu adds the following extensions to Markdown: local links `[[like
Oddμ adds the following extensions to Markdown: local links `[[like
this]]`, hashtags `#Like_This` and fediverse account links like
`@alex@alexschroeder.ch`.
@@ -36,21 +41,21 @@ Other files can be uploaded and images (ending in `.jpg`, `.jpeg`,
## Documentation
This project uses man(1) pages. They are generated from text files
This project uses man pages. They are generated from text files
using [scdoc](https://git.sr.ht/~sircmpwn/scdoc). These are the files
available:
[oddmu(1)](https://alexschroeder.ch/view/oddmu/oddmu.1): This man page
has a short introduction to Oddmu, its configuration via templates and
environment variables, plus points to the other man pages.
environment variables, plus pointers to the other man pages.
[oddmu(5)](https://alexschroeder.ch/view/oddmu/oddmu.5): This man page
talks about the Markdown and includes some examples for the
non-standard features such as table markup. It also talks about the
Oddmu extensions to Markdown: wiki links, hashtags and fediverse
account links. Local links must use percent encoding for page names so
there is a section about percent encoding. The man page also explains
how feeds are generated.
talks about Markdown and includes some examples for the non-standard
features such as table markup. It also talks about the Oddmu
extensions to Markdown: wiki links, hashtags and fediverse account
links. Local links must use percent encoding for page names so there
is a section about percent encoding. The man page also explains how
feeds are generated.
[oddmu-releases(7)](https://alexschroeder.ch/view/oddmu/oddmu-releases.7):
This man page lists all the Oddmu versions and their user-visible
@@ -101,6 +106,14 @@ Static site generator:
This man page documents the "html" subcommand to generate HTML from
Markdown pages from the command line.
[oddmu-feed(1)](https://alexschroeder.ch/view/oddmu/oddmu-feed.1):
This man page documents the "feed" subcommand to generate a feed from
Markdown pages from the command line.
[oddmu-sitemap(1)](https://alexschroeder.ch/view/oddmu/oddmu-sitemap.1):
This man page documents the "sitemap" subcommand to generate the
static sitemap from the command line.
[oddmu-static(1)](https://alexschroeder.ch/view/oddmu/oddmu-static.1):
This man page documents the "static" subcommand to generate an entire
static website from the command line, avoiding the need to run Oddmu
@@ -246,6 +259,7 @@ high-level introduction to the various source files.
- `preview.go` implements the `/preview` handler
- `score.go` implements the page scoring when showing search results
- `search.go` implements the `/search` handler
- `sitemap.go` implements the `/sitemap` handler
- `snippets.go` implements the page summaries for search results
- `templates.go` implements template loading and reloading
- `tokenizer.go` implements the various tokenizers used

View File

@@ -13,4 +13,10 @@ When preparing a new release
5. Tag the release and push the tag to all remotes
6. cd man && make upload
6. cd man && make upload
7. make dist
8. create a new release at https://github.com/kensanata/oddmu/releases
9. upload the four .tar.gz binaries to the GitHub release

View File

@@ -2,13 +2,14 @@ package main
import (
"encoding/json"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"io"
"log"
"net/http"
"os"
"sync"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
)
// useWebfinger indicates whether Oddmu looks up the profile pages of fediverse accounts. To enable this, set the
@@ -38,7 +39,7 @@ func init() {
// accountLink links a social media accountLink like @accountLink@domain to a profile page like https://domain/user/accountLink. Any
// accountLink seen for the first time uses a best guess profile URI. It is also looked up using webfinger, in parallel. See
// lookUpAccountUri. If the lookup succeeds, the best guess is replaced with the new URI so on subsequent requests, the
// lookUpAccountURI. If the lookup succeeds, the best guess is replaced with the new URI so on subsequent requests, the
// URI is correct.
func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
data = data[offset:]
@@ -56,9 +57,8 @@ func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
if d != 0 {
// more than one @ is invalid
return 0, nil
} else {
d = i + 1 // skip @ of domain
}
d = i + 1 // skip @ of domain
}
i++
}
@@ -79,7 +79,7 @@ func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
log.Printf("Looking up %s\n", account)
uri = "https://" + string(domain) + "/users/" + string(user[1:])
accounts.uris[string(account)] = uri // prevent more lookings
go lookUpAccountUri(string(account), string(domain))
go lookUpAccountURI(string(account), string(domain))
}
link := &ast.Link{
AdditionalAttributes: []string{`class="account"`},
@@ -90,9 +90,9 @@ func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
return i, link
}
// lookUpAccountUri is called for accounts that haven't been seen before. It calls webfinger and parses the JSON. If
// lookUpAccountURI is called for accounts that haven't been seen before. It calls webfinger and parses the JSON. If
// possible, it extracts the link to the profile page and replaces the entry in accounts.
func lookUpAccountUri(account, domain string) {
func lookUpAccountURI(account, domain string) {
uri := "https://" + domain + "/.well-known/webfinger"
resp, err := http.Get(uri + "?resource=acct:" + account)
if err != nil {

View File

@@ -1,8 +1,9 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestWebfingerParsing(t *testing.T) {

View File

@@ -3,16 +3,19 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Path}}" method="POST">
<form id="editor" action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">

View File

@@ -48,15 +48,17 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
}
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
http.Redirect(w, r, "/view/"+nameEscape(name), http.StatusFound)
}
func (p *Page) append(body []byte) {
// ensure an empty line at the end
if bytes.HasSuffix(p.Body, []byte("\n\n")) {
} else if bytes.HasSuffix(p.Body, []byte("\n")) {
switch {
case bytes.HasSuffix(p.Body, []byte("\n\n")):
// two newlines, nothing to add
case bytes.HasSuffix(p.Body, []byte("\n")):
p.Body = append(p.Body, '\n')
} else {
default:
p.Body = append(p.Body, '\n', '\n')
}
p.Body = append(p.Body, body...)

View File

@@ -1,12 +1,14 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestEmptyLineAdd(t *testing.T) {
@@ -34,15 +36,15 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, false),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true),
assert.HTTPBody(makeHandler(addHandler, true, http.MethodGet),
"GET", "/add/testdata/add/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, false),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
}
@@ -57,7 +59,7 @@ Blue and green and pebbles gray
data := url.Values{}
data.Set("body", "Stand in cold water")
data.Add("notify", "on")
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/append/"+today+"-water",
data, "/view/testdata/append/"+today+"-water")
// The changes.md file was created

View File

@@ -2,10 +2,12 @@ package main
import (
"archive/zip"
"github.com/stretchr/testify/assert"
"net/http"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestArchive(t *testing.T) {
@@ -15,7 +17,7 @@ func TestArchive(t *testing.T) {
assert.NoError(t, os.WriteFile("testdata/archive/public/index.md", []byte("# Public\nChurch tower bells ringing\nA cold wind biting my ears\nWalk across the square"), 0644))
assert.NoError(t, os.WriteFile("testdata/archive/secret/index.md", []byte("# Secret\nMany years ago I danced\nSpending nights in clubs and bars\nIt is my secret"), 0644))
os.Setenv("ODDMU_FILTER", "^testdata/archive/secret/")
body := assert.HTTPBody(makeHandler(archiveHandler, true), "GET", "/archive/testdata/data.zip", nil)
body := assert.HTTPBody(makeHandler(archiveHandler, true, http.MethodGet), "GET", "/archive/testdata/data.zip", nil)
r, err := zip.NewReader(strings.NewReader(body), int64(len(body)))
assert.NoError(t, err, "Unzip")
names := []string{}

View File

@@ -38,7 +38,7 @@ func (p *Page) notify() error {
return err
}
}
p.renderHtml() // to set hashtags
p.renderHTML() // to set hashtags
for _, hashtag := range p.Hashtags {
err := addLink(path.Join(dir, hashtag), false, link, re)
if err != nil {
@@ -98,12 +98,13 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
if loc[0] >= 14 {
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n`)
m := re.Find(p.Body[loc[0]-14 : loc[0]])
if m == nil {
switch {
case m == nil:
// not a date: insert date, don't move insertion point
} else if string(p.Body[loc[0]-11:loc[0]-1]) == date {
case string(p.Body[loc[0]-11:loc[0]-1]) == date:
// if the date is our date, don't add it, don't move insertion point
addDate = false
} else {
default:
// if the date is not out date, move the insertion point
loc[0] -= 14
}
@@ -148,27 +149,61 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
if mandatory {
p = &Page{Name: name, Body: []byte(link)}
return p.save()
} else {
// Skip non-existing files: no error
return nil
}
// Skip non-existing files: no error
return nil
}
org := string(p.Body)
addLinkToPage(p, link, re)
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
func addLinkToPage(p *Page, link string, re *regexp.Regexp) {
// if a link exists, that's the place to insert the new link (in which case loc[0] and loc[1] differ)
loc := re.FindIndex(p.Body)
// if no link exists, find a good place to insert it
if loc == nil {
// locate the beginning of the list to insert the line
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(p.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(p.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
loc[1] = loc[0]
// locate the list items
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n?`)
items := re.FindAllIndex(p.Body, -1)
first := false
pos := -1
// skip newer items
for i, it := range items {
// break if the current line is older (earlier in sort order)
stop := string(p.Body[it[0]:it[1]]) < link
// before the first match is always a good insert point
if i == 0 {
pos = it[0]
first = true
}
// if we're not stopping, then after the current item is a good insert point
if !stop {
pos = it[1]
first = false
} else {
break
}
}
// otherwise it's at the end of the list, after the last item
if pos == -1 && len(items) > 0 {
pos = items[len(items)-1][1]
first = false
}
// if no list was found, use the end of the page
if pos == -1 {
pos = len(p.Body)
first = true
}
if first {
p.Body, pos = ensureTwoNewlines(p.Body, pos)
}
// mimic a zero-width match at the insert point
loc = []int{pos, pos}
}
// start with new page content
r := []byte("")
@@ -179,9 +214,27 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
// append the rest
r = append(r, p.Body[loc[1]:]...)
p.Body = r
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
// ensureTwoNewlines makes sure that the two bytes before pos in buf are newlines. If the are not, newlines are inserted
// and pos is increased. The new buf and pos is returned.
func ensureTwoNewlines(buf []byte, pos int) ([]byte, int) {
var insert []byte
if pos >= 1 && buf[pos-1] != '\n' {
// add two newlines if buf doesn't end with a newline
insert = []byte("\n\n")
} else if pos >= 2 && buf[pos-2] != '\n' {
// add one newline if Body ends with just one newline
insert = []byte("\n")
}
if insert != nil {
r := []byte("")
r = append(r, buf[:pos]...)
r = append(r, insert...)
r = append(r, buf[pos:]...)
buf = r
pos += len(insert)
}
return buf, pos
}

View File

@@ -1,14 +1,70 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// Note TestEditSaveChanges and TestAddAppendChanges.
func TestAddLinkToPageWithNoList(t *testing.T) {
// no newlines
title := "# Test"
p := &Page{Body: []byte(title)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+"\n\n"+link, string(p.Body))
}
func TestAddLinkToPageWithOlderLink(t *testing.T) {
// one newline
title := "# Test\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-10\)\n`)
link := "* [2025-08-10](2025-08-10)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+"\n"+link+old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-09\)\n`)
link := "* [2025-08-09](2025-08-09)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+new+link+old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks2(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n* [2025-08-09](2025-08-09)\n"
old := "* [2025-08-07](2025-08-07)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+new+link+old, string(p.Body))
}
func TestAddLinkToPageAtTheEnd(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-07\)\n`)
link := "* [2025-08-07](2025-08-07)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+new+old+link, string(p.Body))
}
func TestChanges(t *testing.T) {
cleanup(t, "testdata/washing")
today := time.Now().Format(time.DateOnly)
@@ -48,7 +104,8 @@ Home away from home
assert.Contains(t, string(s), line)
s, err = os.ReadFile("testdata/changes/Haiku.md")
assert.NoError(t, err)
assert.Equal(t, intro+line, string(s))
// ensure an empty line when adding at the end of the page
assert.Equal(t, intro+"\n"+line, string(s))
assert.NoFileExists(t, "testdata/changes/Poetry.md")
}
@@ -63,9 +120,9 @@ func TestChangesWithList(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+line, string(s))
assert.Equal(t, intro+d+newLine+line, string(s))
}
func TestChangesWithOldList(t *testing.T) {
@@ -80,9 +137,9 @@ func TestChangesWithOldList(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+"\n"+y+line, string(s))
assert.Equal(t, intro+d+newLine+"\n"+y+line, string(s))
}
func TestChangesWithOldDisappearingListAtTheEnd(t *testing.T) {
@@ -97,9 +154,9 @@ func TestChangesWithOldDisappearingListAtTheEnd(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line, string(s))
assert.Equal(t, intro+d+newLine, string(s))
}
func TestChangesWithOldDisappearingListInTheMiddle(t *testing.T) {
@@ -116,9 +173,9 @@ func TestChangesWithOldDisappearingListInTheMiddle(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line+"\n"+yy+other, string(s))
assert.Equal(t, intro+d+newLine+"\n"+yy+other, string(s))
}
func TestChangesWithListAtTheTop(t *testing.T) {
@@ -131,9 +188,9 @@ func TestChangesWithListAtTheTop(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the top, no error due to missing introduction
assert.Equal(t, d+new_line+line, string(s))
assert.Equal(t, d+newLine+line, string(s))
}
func TestChangesWithNoList(t *testing.T) {
@@ -146,9 +203,9 @@ func TestChangesWithNoList(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// into is still there and a new list was started
assert.Equal(t, intro+"\n\n"+d+new_line, string(s))
assert.Equal(t, intro+"\n\n"+d+newLine, string(s))
}
func TestChangesWithUpdate(t *testing.T) {
@@ -163,9 +220,9 @@ func TestChangesWithUpdate(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// the change was already listed, but now it moved up and has a new title
assert.Equal(t, intro+d+new_line+other, string(s))
assert.Equal(t, intro+d+newLine+other, string(s))
}
func TestChangesWithNoChangeToTheOrder(t *testing.T) {
@@ -180,9 +237,9 @@ func TestChangesWithNoChangeToTheOrder(t *testing.T) {
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// the change was already listed at the top, so just use the new title
assert.Equal(t, intro+d+new_line+other, string(s))
assert.Equal(t, intro+d+newLine+other, string(s))
// since the file has changed, a backup was necessary
assert.FileExists(t, "testdata/changes/changes.md~")
}

View File

@@ -2,13 +2,14 @@ package main
import (
"bytes"
"github.com/sergi/go-diff/diffmatchpatch"
"html"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/sergi/go-diff/diffmatchpatch"
)
func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
@@ -18,11 +19,11 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
p.handleTitle(true)
p.renderHtml()
p.renderHTML()
renderTemplate(w, p.Dir(), "diff", p)
}
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
// Diff computes the diff for a page. At this point, renderHTML has already been called so the Name is escaped.
func (p *Page) Diff() template.HTML {
fp := filepath.FromSlash(p.Name)
a := fp + ".md~"

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }

View File

@@ -1,10 +1,12 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDiff(t *testing.T) {
@@ -24,7 +26,7 @@ Oh so fresh, so warm.`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/bread", nil)
assert.Contains(t, body, `<del>breathe</del>`)
assert.Contains(t, body, `<ins>whisper</ins>`)
@@ -47,7 +49,7 @@ Mispronouncing words`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
assert.Contains(t, body, `<del>s</del>`)
assert.Contains(t, body, `<ins>ce</ins>`)

View File

@@ -3,17 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Path}}" method="POST">
<form id="editor" action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>

View File

@@ -41,5 +41,5 @@ func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
}
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
http.Redirect(w, r, "/view/"+nameEscape(name), http.StatusFound)
}

View File

@@ -1,12 +1,13 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestEditSave(t *testing.T) {
@@ -16,24 +17,24 @@ func TestEditSave(t *testing.T) {
data.Set("body", "Hallo!")
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, false),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/save/alex", nil, 200)
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false),
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil),
"Hallo!")
// Delete the page and you're sent to the empty page
data.Set("body", "")
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Viewing the non-existing page redirects to the edit page (like in the beginning)
HTTPRedirectTo(t, makeHandler(viewHandler, false),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
@@ -44,7 +45,7 @@ func TestEditSaveChanges(t *testing.T) {
data.Add("notify", "on")
today := time.Now().Format("2006-01-02")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/notification/"+today,
data, "/view/testdata/notification/"+today)
// The changes.md file was created
@@ -73,15 +74,15 @@ func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
data := url.Values{}
data.Set("id", "testdata/id/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusBadRequest,
"No slashes in id")
data.Set("id", ".alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusForbidden,
"No hidden files")
data.Set("id", "alex")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/id/", data),
"Editing testdata/id/alex")
}

View File

@@ -4,13 +4,14 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
htmlTemplate "html/template"
"io"
"os"
"strings"
textTemplate "text/template"
"time"
"github.com/google/subcommands"
)
type exportCmd struct {
@@ -34,10 +35,6 @@ func (*exportCmd) Usage() string {
it:
oddmu export > /tmp/export.rss
Options:
-template "filename" specifies the template to use (default: feed.html)
`
}
@@ -66,7 +63,7 @@ func exportCli(w io.Writer, templateName string, idx *indexStore) subcommands.Ex
return subcommands.ExitFailure
}
p.handleTitle(false)
p.renderHtml()
p.renderHTML()
fi, err := os.Stat(name + ".md")
if err != nil {
fmt.Fprintf(os.Stderr, "Stat %s: %s\n", name, err)
@@ -76,7 +73,7 @@ func exportCli(w io.Writer, templateName string, idx *indexStore) subcommands.Ex
it.Title = p.Title
it.Name = p.Name
it.Body = p.Body
it.Html = htmlTemplate.HTML(htmlTemplate.HTMLEscaper(p.Html))
it.HTML = htmlTemplate.HTML(htmlTemplate.HTMLEscaper(p.HTML))
it.Hashtags = p.Hashtags
items = append(items, it)
}

View File

@@ -2,11 +2,12 @@ package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
func TestExportCmd(t *testing.T) {
@@ -40,7 +41,7 @@ func TestExportCmdJsonFeed(t *testing.T) {
"title": "{{.Title}}",
"language": "{{.Language}}"
"date_modified": "{{.Date}}",
"content_html": "{{.Html}}",
"content_html": "{{.HTML}}",
"tags": [{{range .Hashtags}}"{{.}}",{{end}}""],
},{{end}}
{}

115
feed.go
View File

@@ -2,13 +2,24 @@ package main
import (
"bytes"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"html/template"
"os"
"path"
"path/filepath"
"strconv"
"time"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
)
type dateSource int
const (
// ModTime means that the feed item date is based on the page file's last modification date.
ModTime dateSource = iota
// URL means that the feed item date is based on the ISO date contained in the page name.
URL
)
// Item is a Page plus a Date.
@@ -34,19 +45,47 @@ type Feed struct {
// Items are based on the pages linked in list items starting with an asterisk ("*"). Links in
// list items starting with a minus ("-") are ignored!
Items []Item
// From is where the item number where the feed starts. It defaults to 0. Prev and From are the item numbers of
// The previous and the next page of the feed. N is the number of items per page. Next goes further into the
// past.
Prev, Next, From, N int
// When paging through the index or year pages, link to the next or previous years. NextYear goes further into
// the past (is smaller).
PrevYear, NextYear int
// Complete is set when there is no pagination.
Complete bool
}
// feed returns a RSS 2.0 feed for any page. The feed items it contains are the pages linked from in list items starting
// with an asterisk ("*").
func feed(p *Page, ti time.Time) *Feed {
// with an asterisk ("*"). The feed starts from a certain item and contains n items. If n is 0, the feed is complete
// (unpaginated). The
func feed(p *Page, ti time.Time, from, n int, source dateSource) *Feed {
feed := new(Feed)
feed.Name = p.Name
feed.Title = p.Title
feed.Date = ti.Format(time.RFC1123Z)
feed.From = from
feed.N = n
switch {
case n == 0:
feed.Complete = true
case from > n:
feed.Prev = from - n
default:
year, err := p.BlogYear()
if err == nil && p.ArchiveExists(year+1) {
feed.PrevYear = year + 1
}
}
to := from + n
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
items := make([]Item, 0)
inListItem := false
i := 0
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
// set the flag if we're in a list item
listItem, ok := node.(*ast.ListItem)
@@ -58,33 +97,81 @@ func feed(p *Page, ti time.Time) *Feed {
if !inListItem || !entering {
return ast.GoToNext
}
// if we're in a link and it's local
// if we're in a link and it's not local
link, ok := node.(*ast.Link)
if !ok || bytes.Contains(link.Destination, []byte("//")) {
return ast.GoToNext
}
name := path.Join(p.Dir(), string(link.Destination))
fi, err := os.Stat(filepath.FromSlash(name) + ".md")
if err != nil {
// if we're too early or too late
i++
if i <= from {
return ast.GoToNext
}
if n > 0 && i > to {
// set if it's likely that more items exist
feed.Next = to
return ast.Terminate
}
// i counts links, not actual existing pages
name := path.Join(p.Dir(), string(link.Destination))
p2, err := loadPage(name)
if err != nil {
return ast.GoToNext
}
p2.handleTitle(false)
p2.renderHtml()
it := Item{Date: fi.ModTime().Format(time.RFC1123Z)}
p2.renderHTML()
date, err := p2.Date(source)
if err != nil {
return ast.GoToNext
}
it := Item{Date: date.Format(time.RFC1123Z)}
it.Title = p2.Title
it.Name = p2.Name
it.Html = template.HTML(template.HTMLEscaper(p2.Html))
it.HTML = template.HTML(template.HTMLEscaper(p2.HTML))
it.Hashtags = p2.Hashtags
items = append(items, it)
if len(items) >= 10 {
return ast.Terminate
}
return ast.GoToNext
})
// If there are no more "next" links but there is a next page, add it.
if feed.Next == 0 {
year, err := p.BlogYear()
if err == nil && p.ArchiveExists(year-1) {
feed.NextYear = year - 1
}
}
feed.Items = items
return feed
}
// Date returns the page's last modification date if the data source is ModTime. If the data source is URL, then the
// first 10 characters are parsed as an ISO date string and the time returned is for that date, 0:00, UTC.
func (p *Page) Date(source dateSource) (time.Time, error) {
if source == URL && p.IsBlog() {
name := path.Base(p.Name)
return time.Parse(time.DateOnly, name[0:10])
}
return p.ModTime()
}
// BlogYear returns the current year if the page name is "index". If the page name is a number such as "2026" then
// this is parsed as an integer and returned.
func (p *Page) BlogYear() (int, error) {
name := path.Base(p.Name)
if name == "index" {
return time.Now().Year(), nil
}
ui, err := strconv.ParseUint(name, 10, 16)
if err == nil {
return int(ui), nil
}
return 0, err
}
// ArchiveExists returns true if a page exists in the same directory as the current one with a page name matching
// the year given.
func (p *Page) ArchiveExists(year int) bool {
name := path.Join(p.Dir(), strconv.Itoa(year))
fp := filepath.FromSlash(name) + ".md"
_, err := os.Stat(fp)
return err == nil
}

View File

@@ -1,11 +1,17 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"
xmlns:fh="http://purl.org/syndication/history/1.0">
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<link>https://example.org/</link>
<managingEditor>you@example.org (Your Name)</managingEditor>
<webMaster>you@example.org (Your Name)</webMaster>
<atom:link href="https://example.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://example.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>{{if .From}}
<atom:link href="https://example.org/view/{{.Path}}.rss?from={{.Prev}}&amp;n={{.N}}" rel="previous" type="application/rss+xml"/>{{end}}{{if .PrevYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.PrevYear}}.rss?n={{.N}}" rel="previous" type="application/rss+xml"/>{{end}}{{if .Next}}
<atom:link href="https://example.org/view/{{.Path}}.rss?from={{.Next}}&amp;n={{.N}}" rel="next" type="application/rss+xml"/>{{end}}{{if .NextYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.NextYear}}.rss?n={{.N}}" rel="next" type="application/rss+xml"/>{{end}}{{if .Complete}}
<fh:complete/>{{end}}
<description>This is the digital garden of Your Name.</description>
<image>
<url>https://example.org/view/logo.jpg</url>
@@ -17,7 +23,7 @@
<title>{{.Title}}</title>
<link>https://example.org/view/{{.Path}}</link>
<guid>https://example.org/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<description>{{.HTML}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>

90
feed_cmd.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/google/subcommands"
)
type feedCmd struct {
}
func (*feedCmd) Name() string { return "feed" }
func (*feedCmd) Synopsis() string { return "render a page as feed" }
func (*feedCmd) Usage() string {
return `feed <page name> ...:
Render one or more pages as a single feed.
Use a single - to read Markdown from stdin.
`
}
func (cmd *feedCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *feedCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if len(f.Args()) == 0 {
fmt.Fprint(os.Stderr, cmd.Usage())
return subcommands.ExitFailure
}
return feedCli(os.Stdout, f.Args())
}
func feedCli(w io.Writer, args []string) subcommands.ExitStatus {
if len(args) == 1 && args[0] == "-" {
body, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read from stdin: %s\n", err)
return subcommands.ExitFailure
}
p := &Page{Name: "stdin", Body: body}
return p.printFeed(w, time.Now())
}
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0 : len(name)-3]
p, err := loadPage(name)
p.handleTitle(false)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return subcommands.ExitFailure
}
ti, _ := p.ModTime()
status := p.printFeed(w, ti)
if status != subcommands.ExitSuccess {
return status
}
}
return subcommands.ExitSuccess
}
// printFeed prints the complete feed for a page (unpaginated).
func (p *Page) printFeed(w io.Writer, ti time.Time) subcommands.ExitStatus {
f := feed(p, ti, 0, 0, URL)
if len(f.Items) == 0 {
fmt.Fprintf(os.Stderr, "Empty feed for %s\n", p.Name)
return subcommands.ExitFailure
}
_, err := w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot write prefix: %s\n", err)
return subcommands.ExitFailure
}
loadTemplates()
templates.RLock()
defer templates.RUnlock()
err = templates.template["feed.html"].Execute(w, f)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute template: %s\n", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

26
feed_cmd_test.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
func TestFeedCmd(t *testing.T) {
cleanup(t, "testdata/complete")
p := &Page{Name: "testdata/complete/2025-12-01", Body: []byte("# 2025-12-01\n")}
p.save()
p = &Page{Name: "testdata/complete/index", Body: []byte(`# Index
* [2025-12-01](2025-12-01)
`)}
p.save()
b := new(bytes.Buffer)
s := feedCli(b, []string{"testdata/complete/index.md"})
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), "<fh:complete/>")
assert.Contains(t, b.String(), "<title>2025-12-01</title>")
assert.Contains(t, b.String(), "<pubDate>Mon, 01 Dec 2025 00:00:00") // ignore timezone
}

View File

@@ -1,25 +1,27 @@
package main
import (
"github.com/stretchr/testify/assert"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index.rss", nil),
"Welcome to Oddμ")
}
func TestNoFeed(t *testing.T) {
assert.HTTPStatusCode(t,
makeHandler(viewHandler, false), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
}
func TestFeedItems(t *testing.T) {
cleanup(t, "testdata/feed")
index.load()
p1 := &Page{Name: "testdata/feed/cactus", Body: []byte(`# Cactus
Green head and white hair
@@ -44,7 +46,7 @@ Writing poems about plants.
* [My Dragon Tree](dragon)`)}
p3.save()
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/feed/plants.rss", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/feed/plants.rss", nil)
assert.Contains(t, body, "<title>Plants</title>")
assert.Contains(t, body, "<title>Cactus</title>")
assert.Contains(t, body, "<title>Dragon</title>")
@@ -53,3 +55,148 @@ Writing poems about plants.
assert.Contains(t, body, "<category>Succulent</category>")
assert.Contains(t, body, "<category>Palmtree</category>")
}
func TestFeedPagination(t *testing.T) {
cleanup(t, "testdata/pagination")
p := &Page{Name: "testdata/pagination/one", Body: []byte("# One\n")}
p.save()
p = &Page{Name: "testdata/pagination/two", Body: []byte("# Two\n")}
p.save()
p = &Page{Name: "testdata/pagination/three", Body: []byte("# Three\n")}
p.save()
p = &Page{Name: "testdata/pagination/four", Body: []byte("# Four\n")}
p.save()
p = &Page{Name: "testdata/pagination/five", Body: []byte("# Five\n")}
p.save()
p = &Page{Name: "testdata/pagination/six", Body: []byte("# Six\n")}
p.save()
p = &Page{Name: "testdata/pagination/seven", Body: []byte("# Seven\n")}
p.save()
p = &Page{Name: "testdata/pagination/eight", Body: []byte("# Eight\n")}
p.save()
p = &Page{Name: "testdata/pagination/nine", Body: []byte("# Nine\n")}
p.save()
p = &Page{Name: "testdata/pagination/ten", Body: []byte("# Ten\n")}
p.save()
p = &Page{Name: "testdata/pagination/index", Body: []byte(`# Index
* [one](one)
* [two](two)
* [three](three)
* [four](four)
* [five](five)
* [six](six)
* [seven](seven)
* [eight](eight)
* [nine](nine)
* [ten](ten)
`)}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", nil)
assert.Contains(t, body, "<title>One</title>")
assert.Contains(t, body, "<title>Ten</title>")
assert.NotContains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=10&n=10" rel="next" type="application/rss+xml"/>`)
p = &Page{Name: "testdata/pagination/eleven", Body: []byte("# Eleven\n")}
p.save()
p = &Page{Name: "testdata/pagination/index", Body: []byte(`# Index
* [one](one)
* [two](two)
* [three](three)
* [four](four)
* [five](five)
* [six](six)
* [seven](seven)
* [eight](eight)
* [nine](nine)
* [ten](ten)
* [eleven](eleven)
`)}
p.save()
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", nil)
assert.NotContains(t, body, "<title>Eleven</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=10&amp;n=10" rel="next" type="application/rss+xml"/>`)
params := url.Values{}
params.Set("n", "0")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.Contains(t, body, "<title>Eleven</title>")
assert.Contains(t, body, `<fh:complete/>`)
params = url.Values{}
params.Set("n", "3")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.Contains(t, body, "<title>One</title>")
assert.Contains(t, body, "<title>Three</title>")
assert.NotContains(t, body, "<title>Four</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=3&amp;n=3" rel="next" type="application/rss+xml"/>`)
params = url.Values{}
params.Set("from", "3")
params.Set("n", "3")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.NotContains(t, body, "<title>Three</title>")
assert.Contains(t, body, "<title>Four</title>")
assert.Contains(t, body, "<title>Six</title>")
assert.NotContains(t, body, "<title>Seven</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=0&amp;n=3" rel="previous" type="application/rss+xml"/>`)
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=6&amp;n=3" rel="next" type="application/rss+xml"/>`)
params = url.Values{}
params.Set("from", "2")
params.Set("n", "3")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.NotContains(t, body, "<title>Two</title>")
assert.Contains(t, body, "<title>Three</title>")
assert.Contains(t, body, "<title>Five</title>")
assert.NotContains(t, body, "<title>Six</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=0&amp;n=3" rel="previous" type="application/rss+xml"/>`)
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=5&amp;n=3" rel="next" type="application/rss+xml"/>`)
}
func TestFeedYearArchives(t *testing.T) {
cleanup(t, "testdata/archives")
p := &Page{Name: "testdata/archives/index", Body: []byte(`# Archives
my bent fingers hurt
keyboard rattling in the dark
but no child in sight
`)}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET",
"/view/testdata/archives/index.rss", nil)
year, err := p.BlogYear()
assert.Greater(t, year, 0)
assert.NoError(t, err)
prevLink := fmt.Sprintf(`<atom:link href="https://example.org/view/testdata/archives/%d.rss?n=10" rel="previous" type="application/rss+xml"/>`, year+1)
nextLink := fmt.Sprintf(`<atom:link href="https://example.org/view/testdata/archives/%d.rss?n=10" rel="next" type="application/rss+xml"/>`, year-1)
assert.NotContains(t, body, prevLink)
assert.NotContains(t, body, nextLink)
p = &Page{Name: fmt.Sprintf("testdata/archives/%d", year-1), Body: []byte(`# Previously
I have seen it all
invasion and denial
and cold winter hearts
`)}
p.save()
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET",
"/view/testdata/archives/index.rss", nil)
assert.NotContains(t, body, prevLink)
assert.Contains(t, body, nextLink)
p = &Page{Name: fmt.Sprintf("testdata/archives/%d", year+1), Body: []byte(`# Coming
A night of thunder
lightning, children, it's the war
of our New Year's Eve
`)}
p.save()
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET",
"/view/testdata/archives/index.rss", nil)
assert.Contains(t, body, prevLink)
assert.Contains(t, body, nextLink)
}

4
go.mod
View File

@@ -1,4 +1,4 @@
module alexschroeder.ch/cgit/oddmu
module src.alexschroeder.ch/oddmu
go 1.22
@@ -10,6 +10,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gen2brain/heic v0.3.1
github.com/gen2brain/webp v0.5.2
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
@@ -25,7 +26,6 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/gen2brain/webp v0.5.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect

6
go.sum
View File

@@ -5,8 +5,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
@@ -19,8 +17,6 @@ github.com/gen2brain/heic v0.3.1 h1:ClY5YTdXdIanw7pe9ZVUM9XcsqH6CCCa5CZBlm58qOs=
github.com/gen2brain/heic v0.3.1/go.mod h1:m2sVIf02O7wfO8mJm+PvE91lnq4QYJy2hseUon7So10=
github.com/gen2brain/webp v0.5.2 h1:aYdjbU/2L98m+bqUdkYMOIY93YC+EN3HuZLMaqgMD9U=
github.com/gen2brain/webp v0.5.2/go.mod h1:Nb3xO5sy6MeUAHhru9H3GT7nlOQO5dKRNNlE92CZrJw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5 h1:qIhG9h8tUzKsVHn0iHtWUohq7Ve7btgA8rGp7TvrIHw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
@@ -61,8 +57,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw=
github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA550=
github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=

View File

@@ -4,16 +4,28 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"regexp"
"sort"
"strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
)
type hashtagsCmd struct {
update bool
dryRun bool
}
func (cmd *hashtagsCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.update, "update", false, "create and update hashtag pages")
f.BoolVar(&cmd.dryRun, "dry-run", false, "only report the changes it would make")
}
func (*hashtagsCmd) Name() string { return "hashtags" }
@@ -25,6 +37,9 @@ func (*hashtagsCmd) Usage() string {
}
func (cmd *hashtagsCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if cmd.update {
return hashtagsUpdateCli(os.Stdout, cmd.dryRun)
}
return hashtagsCli(os.Stdout)
}
@@ -57,3 +72,137 @@ func hashtagsCli(w io.Writer) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// hashtagsUpdateCli runs the hashtags command on the command line and creates and updates the hashtag pages in the
// current directory. That is, pages in subdirectories are skipped! It is used here with an io.Writer for easy testing.
func hashtagsUpdateCli(w io.Writer, dryRun bool) subcommands.ExitStatus {
index.load()
// no locking necessary since this is for the command-line
namesMap := make(map[string]string)
for hashtag, docids := range index.token {
if len(docids) <= 5 {
if dryRun {
fmt.Fprintf(w, "Skipping #%s because there are not enough entries (%d)\n", hashtag, len(docids))
}
continue
}
title, ok := namesMap[hashtag]
if !ok {
title = hashtagName(namesMap, hashtag, docids)
namesMap[hashtag] = title
}
pageName := strings.ReplaceAll(title, " ", "_")
h, err := loadPage(pageName)
original := ""
new := false
if err != nil {
new = true
h = &Page{Name: pageName, Body: []byte("# " + title + "\n\n#" + pageName + "\n\nBlog posts:\n\n")}
} else {
original = string(h.Body)
}
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
if !p.IsBlog() {
continue
}
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
// I guess & used to get escaped and now no longer does
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + strings.ReplaceAll(esc, "&", "(&|%26)") + `\)\n`)
addLinkToPage(h, link, re)
}
// only save if something changed
if string(h.Body) != original {
if dryRun {
if new {
fmt.Fprintf(w, "Creating %s.md\n", title)
} else {
fmt.Fprintf(w, "Updating %s.md\n", title)
}
fn := h.Name + ".md"
edits := myers.ComputeEdits(span.URIFromPath(fn), original, string(h.Body))
diff := fmt.Sprint(gotextdiff.ToUnified(fn+"~", fn, original, edits))
fmt.Fprint(w, diff)
} else {
err = h.save()
if err != nil {
fmt.Fprintf(w, "Saving hashtag %s failed: %s", hashtag, err)
return subcommands.ExitFailure
}
}
}
}
return subcommands.ExitSuccess
}
// Go through all the documents in the same directory and look for hashtag matches in the rendered HTML in order to
// determine the most likely capitalization.
func hashtagName(namesMap map[string]string, hashtag string, docids []docid) string {
candidate := make(map[string]int)
var mostPopular string
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
continue
}
// parsing finds all the hashtags
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
if v, ok := node.(*ast.Link); ok {
for _, attr := range v.AdditionalAttributes {
if attr == `class="tag"` {
tagName := []byte("")
ast.WalkFunc(v, func(node ast.Node, entering bool) ast.WalkStatus {
if entering && node.AsLeaf() != nil {
tagName = append(tagName, node.AsLeaf().Literal...)
}
return ast.GoToNext
})
tag := string(tagName[1:])
if strings.EqualFold(hashtag, strings.ReplaceAll(tag, " ", "_")) {
_, ok := candidate[tag]
if ok {
candidate[tag]++
} else {
candidate[tag] = 1
}
}
}
}
}
}
return ast.GoToNext
})
count := 0
for key, val := range candidate {
if val > count {
mostPopular = key
count = val
}
}
// shortcut
if count >= 5 {
return mostPopular
}
}
return mostPopular
}

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestHashtagsCmd(t *testing.T) {

View File

@@ -4,10 +4,12 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"html/template"
"io"
"os"
"strings"
"github.com/google/subcommands"
)
type htmlCmd struct {
@@ -40,20 +42,20 @@ func htmlCli(w io.Writer, template string, args []string) subcommands.ExitStatus
return subcommands.ExitFailure
}
p := &Page{Name: "stdin", Body: body}
return p.printHtml(w, template)
return p.printHTML(w, template)
}
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
name = name[0 : len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return subcommands.ExitFailure
}
status := p.printHtml(w, template)
status := p.printHTML(w, template)
if status != subcommands.ExitSuccess {
return status
}
@@ -61,21 +63,28 @@ func htmlCli(w io.Writer, template string, args []string) subcommands.ExitStatus
return subcommands.ExitSuccess
}
func (p *Page) printHtml(w io.Writer, template string) subcommands.ExitStatus {
if len(template) > 0 {
t := template
loadTemplates()
p.handleTitle(true)
p.renderHtml()
err := templates.template[t].Execute(w, p)
func (p *Page) printHTML(w io.Writer, fn string) subcommands.ExitStatus {
if fn == "" {
// do not handle title
p.renderHTML()
_, err := fmt.Fprintln(w, p.HTML)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, p.Name, err)
fmt.Fprintf(os.Stderr, "Cannot write to stdout: %s\n", err)
return subcommands.ExitFailure
}
} else {
// do not handle title
p.renderHtml()
fmt.Fprintln(w, p.Html)
return subcommands.ExitSuccess
}
p.handleTitle(true)
p.renderHTML()
t, err := template.ParseFiles(fn)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot parse template %s for %s: %s\n", fn, p.Name, err)
return subcommands.ExitFailure
}
err = t.Execute(w, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute template %s for %s: %s\n", fn, p.Name, err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestHtmlCmd(t *testing.T) {

View File

@@ -5,7 +5,6 @@
package main
import (
"golang.org/x/exp/constraints"
"html/template"
"io/fs"
"log"
@@ -13,6 +12,8 @@ import (
"sort"
"strings"
"sync"
"golang.org/x/exp/constraints"
)
type docid uint
@@ -23,15 +24,15 @@ type docid uint
// It depends on the fact that Title is always plain text.
type ImageData struct {
Title, Name string
Html template.HTML
HTML template.HTML
}
// indexStore controls access to the maps used for search. Make sure to lock and unlock as appropriate.
type indexStore struct {
sync.RWMutex
// next_id is the number of the next document added to the index
next_id docid
// nextID is the number of the next document added to the index
nextID docid
// index is an inverted index mapping tokens to document ids.
token map[string][]docid
@@ -54,7 +55,7 @@ func init() {
// reset the index. This assumes that the index is locked. It's useful for tests.
func (idx *indexStore) reset() {
idx.next_id = 0
idx.nextID = 0
idx.token = make(map[string][]docid)
idx.documents = make(map[docid]string)
idx.titles = make(map[string]string)
@@ -64,8 +65,8 @@ func (idx *indexStore) reset() {
// addDocument adds the text as a new document. This assumes that the index is locked!
// The hashtags (only!) are used as tokens. They are stored in lower case.
func (idx *indexStore) addDocument(text []byte) docid {
id := idx.next_id
idx.next_id++
id := idx.nextID
idx.nextID++
for _, token := range hashtags(text) {
token = strings.ToLower(token)
ids := idx.token[token]
@@ -147,9 +148,8 @@ func (idx *indexStore) walk(fp string, info fs.FileInfo, err error) error {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
return nil
}
// skipp all but page files
if !strings.HasSuffix(fp, ".md") {
@@ -238,11 +238,12 @@ func intersection[T constraints.Ordered](a []T, b []T) []T {
r := make([]T, 0, maxLen)
var i, j int
for i < len(a) && j < len(b) {
if a[i] < b[j] {
switch {
case a[i] < b[j]:
i++
} else if a[i] > b[j] {
case a[i] > b[j]:
j++
} else {
default:
r = append(r, a[i])
i++
j++

View File

@@ -1,3 +1,4 @@
# Welcome to Oddμ
Hello! 🙃

View File

@@ -1,9 +1,10 @@
package main
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIndexAdd(t *testing.T) {

View File

@@ -2,9 +2,10 @@ package main
import (
"errors"
"github.com/pemistahl/lingua-go"
"os"
"strings"
"github.com/pemistahl/lingua-go"
)
// getLanguages returns the environment variable ODDMU_LANGUAGES or all languages.

View File

@@ -1,9 +1,10 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAllLanguage(t *testing.T) {

View File

@@ -4,10 +4,11 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"strings"
"github.com/google/subcommands"
)
type linksCmd struct {
@@ -48,7 +49,7 @@ func linksCli(w io.Writer, args []string) subcommands.ExitStatus {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
name = name[0 : len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestLinksCmd(t *testing.T) {

108
list.go
View File

@@ -1,108 +0,0 @@
package main
import (
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
)
// ListItem is used to display the list of files.
type File struct {
Name, Title string
IsDir, IsUp bool
// Date is the last modification date of the file storing the page. As the pages used by Oddmu are plain
// Markdown files, they don't contain any metadata. Instead, the last modification date of the file is used.
// This makes it work well with changes made to the files outside of Oddmu.
Date string
}
type List struct {
Dir string
Files []File
}
// listHandler uses the "list.html" template to enable file management in a particular directory.
func listHandler(w http.ResponseWriter, r *http.Request, name string) {
files := []File{}
d := filepath.FromSlash(name)
if d == "" {
d = "."
} else if !strings.HasSuffix(d, "/") {
http.Redirect(w, r, "/list/" + nameEscape(name) + "/", http.StatusFound)
return
} else {
it := File{Name: "..", IsUp: true, IsDir: true }
files = append(files, it)
}
err := filepath.Walk(d, func (fp string, fi fs.FileInfo, err error) error {
if err != nil {
return err
}
isDir := false
if fi.IsDir() {
if d == fp {
return nil
}
isDir = true
}
name := filepath.ToSlash(fp)
base := filepath.Base(fp)
title := ""
if !isDir && strings.HasSuffix(name, ".md") {
index.RLock()
defer index.RUnlock()
title = index.titles[name[:len(name)-3]]
} else if isDir {
// even on Windows, this looks like a Unix directory
base += "/"
}
it := File{Name: base, Title: title, Date: fi.ModTime().Format(time.DateTime), IsDir: isDir }
files = append(files, it)
if isDir {
return filepath.SkipDir
}
return nil
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
renderTemplate(w, d, "list", &List{Dir: name, Files: files})
}
// deleteHandler deletes the named file and then redirects back to the list
func deleteHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
err := os.RemoveAll(fn) // and all its children!
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(name)) + "/", http.StatusFound)
}
// renameHandler renames the named file and then redirects back to the list
func renameHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
dir := path.Dir(name)
target := path.Join(dir, r.FormValue("name"))
if (isHiddenName(target)) {
http.Error(w, "the target file would be hidden", http.StatusForbidden)
return
}
err := os.Rename(fn, filepath.FromSlash(target))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(filepath.ToSlash(target))) + "/", http.StatusFound)
}

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form { width: 100% }
table { border-collapse: collapse }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 30ch; overflow: hidden }
tr:nth-child(odd) { background-color: #eed }
td:first-child, td:last-child { white-space: nowrap }
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Name}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Name}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Name}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Name}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

@@ -4,11 +4,12 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/subcommands"
)
type listCmd struct {

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestListCmd(t *testing.T) {

View File

@@ -1,30 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
)
// relies on index.md in the current directory!
func TestListHandler(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/", nil),
"index.md")
}
func TestDeleteHandler(t *testing.T) {
cleanup(t, "testdata/delete")
assert.NoError(t, os.Mkdir("testdata/delete", 0755))
p := &Page{Name: "testdata/delete/haiku", Body: []byte(`# Sunset
Walk the fields outside
See the forest loom above
And an orange sky
`)}
p.save()
list := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.Contains(t, list, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.Contains(t, list, `<td>Sunset</td>`)
assert.Contains(t, list, `<button form="manage" formaction="/delete/testdata/delete/haiku.md" title="Delete haiku.md">`)
}

View File

@@ -27,7 +27,7 @@ html: ${HTML}
%.html: %.md
@echo Making $@
@echo '<!DOCTYPE html>' > $@
@oddmu html $(basename $<) | sed --regexp-extended \
@oddmu html $< | sed --regexp-extended \
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
md: ${MD}
@@ -43,7 +43,7 @@ README.md: ../README.md
< $< > $@
upload: ${MD} README.md
rsync --itemize-changes --archive *.md sibirocobombus:alexschroeder.ch/wiki/oddmu/
rsync --itemize-changes --archive *.md ../README.md sibirocobombus:alexschroeder.ch/wiki/oddmu/
make clean
clean:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2024-09-25"
.TH "ODDMU-APACHE" "5" "2026-01-29"
.PP
.SH NAME
.PP
@@ -48,7 +48,7 @@ ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -126,13 +126,13 @@ ServerAdmin alex@alexschroeder\&.ch
ServerName transjovian\&.org
ProxyPassMatch "^/((view|diff|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(\&.*))?$"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*)|sitemap.xml)?$"
"https://transjovian\&.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -170,7 +170,7 @@ In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -189,7 +189,7 @@ A workaround is to add the redirect manually and drop the question-mark:
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -234,12 +234,12 @@ htpasswd -D \&.htpasswd berta
.RE
.PP
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost *:443>" section:
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost *:443>" section:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
@@ -248,12 +248,74 @@ a password by adding the following to your "<VirtualHost *:443>" section:
.fi
.RE
.PP
.SS Actual usernames and passwords for authentication
.PP
On a community server where the users have accounts, wiki editing can be limited
to the system'\&s users.\&
.PP
In order to do this, install the \fBmod-authnz-external\fR module for Apache and the
\fBpwauth\fR binary.\& The module allows the password checking normally done inside
Apache to be done by an separate external program running outside of Apache.\&
.PP
Here'\&s an example configuration:
.PP
.nf
.RS 4
AddExternalAuth pwauth /usr/sbin/pwauth
SetExternalAuthMethod pwauth pipe
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthBasicProvider external
AuthExternal pwauth
Require valid-user
</LocationMatch>
.fi
.RE
.PP
.SS Different logins for different access rights
.PP
What if you have a site with various subdirectories and each subdirectory is for
a different group of friends?\& You can set this up using your webserver.\& One way
to do this is to require specific usernames (which must have a password in the
password file mentioned above.\&
.PP
This requires a valid login by the user "alex" or "berta":
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
.fi
.RE
.PP
.SS Private wikis
.PP
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The location
must cover all the URLs in order to protect everything.\&
.PP
.nf
.RS 4
<Location />
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require valid-user
</Location>
.fi
.RE
.PP
.SS Subdirectories as separate sites
.PP
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period).\& Specifically, do not rely on Apache to hide locations in subdirectories
from public view.\& Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links.\& Archive links pack all the
subdirectories, including locations you may have hidden from view using Apache.\&
extract, even if users cannot follow the links.\& The Sitemap lists all pages,
including subdirectories.\& Archive links pack all the subdirectories, including
locations you may have hidden from view using Apache.\&
.PP
If you to treat subdirectories as separate sites, you need to set the
environment variable ODDMU_FILTER to a regular expression matching the those
@@ -274,7 +336,7 @@ directory:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
@@ -300,9 +362,8 @@ DocumentRoot /home/oddmu
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/list", "/delete/", "/rename/" "/search/" or "/archive/".\& For example, create a
file called "robots.\&txt" containing the following, telling all robots that
they'\&re not welcome.\&
"/search/" or "/archive/".\& For example, create a file called "robots.\&txt"
containing the following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
@@ -339,46 +400,13 @@ In this case, "/css/oddmu-2023.\&css" would be the name of your stylesheet.\& If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.\&css" for this to work.\&
.PP
.SS Different logins for different access rights
.PP
What if you have a site with various subdirectories and each subdirectory is for
a different group of friends?\& You can set this up using your webserver.\& One way
to do this is to require specific usernames (which must have a password in the
password file mentioned above.\&
.PP
This requires a valid login by the user "alex" or "berta":
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
Require user alex berta
</LocationMatch>
.fi
.RE
.PP
.SS Private wikis
.PP
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The location
must cover all the URLs in order to protect everything.\&
.PP
.nf
.RS 4
<Location />
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require valid-user
</Location>
.fi
.RE
.PP
.SS Virtual hosting
.PP
Virtual hosting in this context means that the program serves two different
sites for two different domains from the same machine.\& Oddmu doesn'\&t support
that, but your webserver does.\& Therefore, start an Oddmu instance for every
domain name, each listening on a different port.\& Then set up your web server
such that ever domain acts as a reverse proxy to a different Oddmu instance.\&
such that every domain proxies for a different Oddmu instance.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -40,7 +40,7 @@ ServerAdmin alex@alexschroeder.ch
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -106,13 +106,13 @@ ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(.*))?$" \
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*)|sitemap\.xml)?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -144,7 +144,7 @@ You probably want to serve some static files as well (see *Serve static files*).
In that case, you need to use the ProxyPassMatch directive.
```
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -159,7 +159,7 @@ A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -197,11 +197,11 @@ htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost \*:443>" section:
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost \*:443>" section:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -209,12 +209,68 @@ a password by adding the following to your "<VirtualHost \*:443>" section:
</LocationMatch>
```
## Actual usernames and passwords for authentication
On a community server where the users have accounts, wiki editing can be limited
to the system's users.
In order to do this, install the *mod-authnz-external* module for Apache and the
*pwauth* binary. The module allows the password checking normally done inside
Apache to be done by an separate external program running outside of Apache.
Here's an example configuration:
```
AddExternalAuth pwauth /usr/sbin/pwauth
SetExternalAuthMethod pwauth pipe
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthBasicProvider external
AuthExternal pwauth
Require valid-user
</LocationMatch>
```
## Different logins for different access rights
What if you have a site with various subdirectories and each subdirectory is for
a different group of friends? You can set this up using your webserver. One way
to do this is to require specific usernames (which must have a password in the
password file mentioned above.
This requires a valid login by the user "alex" or "berta":
```
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
```
## Private wikis
Based on the above, you can prevent people from _reading_ the wiki. The location
must cover all the URLs in order to protect everything.
```
<Location />
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require valid-user
</Location>
```
## Subdirectories as separate sites
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period). Specifically, do not rely on Apache to hide locations in subdirectories
from public view. Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links. Archive links pack all the
subdirectories, including locations you may have hidden from view using Apache.
extract, even if users cannot follow the links. The Sitemap lists all pages,
including subdirectories. Archive links pack all the subdirectories, including
locations you may have hidden from view using Apache.
If you to treat subdirectories as separate sites, you need to set the
environment variable ODDMU_FILTER to a regular expression matching the those
@@ -234,7 +290,7 @@ You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -257,9 +313,8 @@ DocumentRoot /home/oddmu
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/list", "/delete/", "/rename/" "/search/" or "/archive/". For example, create a
file called "robots.txt" containing the following, telling all robots that
they're not welcome.
"/search/" or "/archive/". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
```
User-agent: *
@@ -292,42 +347,13 @@ In this case, "/css/oddmu-2023.css" would be the name of your stylesheet. If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.css" for this to work.
## Different logins for different access rights
What if you have a site with various subdirectories and each subdirectory is for
a different group of friends? You can set this up using your webserver. One way
to do this is to require specific usernames (which must have a password in the
password file mentioned above.
This requires a valid login by the user "alex" or "berta":
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
Require user alex berta
</LocationMatch>
```
## Private wikis
Based on the above, you can prevent people from _reading_ the wiki. The location
must cover all the URLs in order to protect everything.
```
<Location />
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require valid-user
</Location>
```
## Virtual hosting
Virtual hosting in this context means that the program serves two different
sites for two different domains from the same machine. Oddmu doesn't support
that, but your webserver does. Therefore, start an Oddmu instance for every
domain name, each listening on a different port. Then set up your web server
such that ever domain acts as a reverse proxy to a different Oddmu instance.
such that every domain proxies for a different Oddmu instance.
# SEE ALSO

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-EXPORT" "1" "2024-08-29"
.TH "ODDMU-EXPORT" "1" "2026-01-03"
.PP
.SH NAME
.PP
@@ -22,8 +22,8 @@ You probably want to redirect this into a file so that you can upload and import
it somewhere.\&
.PP
Note that this only handles pages (Markdown files).\& All other files (images,
PDFs, whatever else you uploaded) are not part of the feed and has to be
uploaded to the new platform in some other way.\&
PDFs, whatever else you uploaded) are not part of the feed and have to be
uploaded to the new platform using some other way.\&
.PP
The \fB-template\fR option specifies the template to use.\& If the template filename
ends in \fI.\&xml\fR, \fI.\&html\fR or \fI.\&rss\fR, it is assumed to contain XML and the optional
@@ -31,7 +31,8 @@ XML preamble is printed and appropriate escaping rules are used.\&
.PP
.SH FILES
.PP
By default, the export uses the \fB\fRfeed.\&html\fB\fR template in the current directory.\&
By default, the export uses the feed template ("feed.\&html") in the current
directory.\&
.PP
.SH EXAMPLES
.PP

View File

@@ -15,8 +15,8 @@ You probably want to redirect this into a file so that you can upload and import
it somewhere.
Note that this only handles pages (Markdown files). All other files (images,
PDFs, whatever else you uploaded) are not part of the feed and has to be
uploaded to the new platform in some other way.
PDFs, whatever else you uploaded) are not part of the feed and have to be
uploaded to the new platform using some other way.
The *-template* option specifies the template to use. If the template filename
ends in _.xml_, _.html_ or _.rss_, it is assumed to contain XML and the optional
@@ -24,7 +24,8 @@ XML preamble is printed and appropriate escaping rules are used.
# FILES
By default, the export uses the **feed.html** template in the current directory.
By default, the export uses the feed template ("feed.html") in the current
directory.
# EXAMPLES

59
man/oddmu-feed.1 Normal file
View File

@@ -0,0 +1,59 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-FEED" "1" "2025-12-31"
.PP
.SH NAME
.PP
oddmu-feed - render Oddmu page feed
.PP
.SH SYNOPSIS
.PP
\fBoddmu feed\fR \fIpage-name\fR .\&.\&.\&
.PP
.SH DESCRIPTION
.PP
The "feed" subcommand opens the given Markdown files and writes the resulting
RSS files without item limit (ordinarily, this default is 10 items per feed).\&
This uses the "feed.\&html" template.\& Use "-" as the page name if you want to read
Markdown from \fBstdin\fR.\&
.PP
Unlike the feeds generated by the \fBstatic\fR subcommand, the \fBfeed\fR command does
not limit the feed to the ten most recent items.\& Instead, all items on the list
are turned into feed items.\&
.PP
Furthermore, if the items on the list are blog posts (their page name starts
with an ISO date), then this ISO date is used for the last update date to the
page instead of the last modification time of the file.\& The idea, more or less,
is that this feed is an archive feed and that in this context the creation date
is more important than the last modification date.\&
.PP
.SH EXAMPLES
.PP
Generate "emacs.\&rss" from "emacs.\&md":
.PP
.nf
.RS 4
oddmu feed emacs\&.md
.fi
.RE
.PP
Alternatively:
.PP
.nf
.RS 4
oddmu feed - < emacs\&.md > emacs\&.rss
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-export\fR(1), \fIoddmu-static\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

48
man/oddmu-feed.1.txt Normal file
View File

@@ -0,0 +1,48 @@
ODDMU-FEED(1)
# NAME
oddmu-feed - render Oddmu page feed
# SYNOPSIS
*oddmu feed* _page-name_ ...
# DESCRIPTION
The "feed" subcommand opens the given Markdown files and writes the resulting
RSS files without item limit (ordinarily, this default is 10 items per feed).
This uses the "feed.html" template. Use "-" as the page name if you want to read
Markdown from *stdin*.
Unlike the feeds generated by the *static* subcommand, the *feed* command does
not limit the feed to the ten most recent items. Instead, all items on the list
are turned into feed items.
Furthermore, if the items on the list are blog posts (their page name starts
with an ISO date), then this ISO date is used for the last update date to the
page instead of the last modification time of the file. The idea, more or less,
is that this feed is an archive feed and that in this context the creation date
is more important than the last modification date.
# EXAMPLES
Generate "emacs.rss" from "emacs.md":
```
oddmu feed emacs.md
```
Alternatively:
```
oddmu feed - < emacs.md > emacs.rss
```
# SEE ALSO
_oddmu_(1), _oddmu-export_(1), _oddmu-static_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-FILTER" "7" "2024-09-30"
.TH "ODDMU-FILTER" "7" "2026-01-03"
.PP
.SH NAME
.PP
@@ -13,13 +13,13 @@ oddmu-filter - keeping subdirectories separate
.PP
.SH DESCRIPTION
.PP
There are actions such as searching and archiving that act on multiple pages,
not just a single page.\& These actions walk the directory tree, including all
subdirectories.\& In some cases, this is not desirable.\&
There are actions such as producing the sitemap, searching and archiving that
act on multiple pages, not just a single page.\& These actions walk the directory
tree, including all subdirectories.\& In some cases, this is not desirable.\&
.PP
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people.\& Depending on how you think about it, you might not want to
include those "sites" in searches or archives of the whole site.\&
include those "sites" in searches, sitemaps or archives of the whole site.\&
.PP
Since directory tree actions always start in the directory the visitor is
currently looking at, directory tree actions starting in a "separate site"

View File

@@ -6,13 +6,13 @@ oddmu-filter - keeping subdirectories separate
# DESCRIPTION
There are actions such as searching and archiving that act on multiple pages,
not just a single page. These actions walk the directory tree, including all
subdirectories. In some cases, this is not desirable.
There are actions such as producing the sitemap, searching and archiving that
act on multiple pages, not just a single page. These actions walk the directory
tree, including all subdirectories. In some cases, this is not desirable.
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people. Depending on how you think about it, you might not want to
include those "sites" in searches or archives of the whole site.
include those "sites" in searches, sitemaps or archives of the whole site.
Since directory tree actions always start in the directory the visitor is
currently looking at, directory tree actions starting in a "separate site"

View File

@@ -5,20 +5,31 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HASHTAGS" "1" "2024-08-29"
.TH "ODDMU-HASHTAGS" "1" "2025-08-09"
.PP
.SH NAME
.PP
oddmu-hashtags - count the hashtags used
oddmu-hashtags - work with hashtags
.PP
.SH SYNOPSIS
.PP
\fBoddmu hashtags\fR
.PP
\fBoddmu hashtags -update\fR [\fB-dry-run\fR]
.PP
.SH DESCRIPTION
.PP
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.\&
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.\&
.PP
With the \fB-update\fR flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag.\& This only necessary when migrating a
collection of Markdown files.\& Ordinarily, Oddmu maintains the hashtag pages
automatically.\& When writing pages offline, use \fIoddmu-notify\fR(1) to update the
hashtag pages.\&
.PP
Use the \fB-dry-run\fR flag to see what would change with the \fB-update\fR flag without
actually changing any files.\&
.PP
.SH EXAMPLES
.PP
@@ -30,6 +41,22 @@ oddmu hashtags | head -n 11
.fi
.RE
.PP
See what kind of changes Oddmu would suggest:
.PP
.nf
.RS 4
oddmu hashtags -update -dry-run
.fi
.RE
.PP
And then do it:
.PP
.nf
.RS 4
oddmu hashtags -update
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)

View File

@@ -2,16 +2,27 @@ ODDMU-HASHTAGS(1)
# NAME
oddmu-hashtags - count the hashtags used
oddmu-hashtags - work with hashtags
# SYNOPSIS
*oddmu hashtags*
*oddmu hashtags -update* [*-dry-run*]
# DESCRIPTION
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.
With the *-update* flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag. This only necessary when migrating a
collection of Markdown files. Ordinarily, Oddmu maintains the hashtag pages
automatically. When writing pages offline, use _oddmu-notify_(1) to update the
hashtag pages.
Use the *-dry-run* flag to see what would change with the *-update* flag without
actually changing any files.
# EXAMPLES
@@ -21,6 +32,18 @@ List the top 10 hashtags. This requires 11 lines because of the header line.
oddmu hashtags | head -n 11
```
See what kind of changes Oddmu would suggest:
```
oddmu hashtags -update -dry-run
```
And then do it:
```
oddmu hashtags -update
```
# SEE ALSO
_oddmu_(1)

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HTML" "1" "2025-04-05"
.TH "ODDMU-HTML" "1" "2026-01-03"
.PP
.SH NAME
.PP
@@ -13,7 +13,7 @@ oddmu-html - render Oddmu page HTML
.PP
.SH SYNOPSIS
.PP
\fBoddmu html\fR [\fB\fR-template\fB\fR \fItemplate-name\fR] \fIpage-name\fR
\fBoddmu html\fR [\fB-template\fR \fItemplate-name\fR] \fIpage-name\fR
.PP
.SH DESCRIPTION
.PP
@@ -23,7 +23,7 @@ name if you want to read Markdown from \fBstdin\fR.\&
.PP
.SH OPTIONS
.PP
\fB\fR-template\fB\fR \fItemplate-name\fR
\fB-template\fR \fItemplate-name\fR
.RS 4
Use the given template to render the page.\& Without this, the HTML lacks
html and body tags.\& The only two options that make sense are "view.\&html"

View File

@@ -6,7 +6,7 @@ oddmu-html - render Oddmu page HTML
# SYNOPSIS
*oddmu html* [**-template** _template-name_] _page-name_
*oddmu html* [*-template* _template-name_] _page-name_
# DESCRIPTION
@@ -16,7 +16,7 @@ name if you want to read Markdown from *stdin*.
# OPTIONS
**-template** _template-name_
*-template* _template-name_
Use the given template to render the page. Without this, the HTML lacks
html and body tags. The only two options that make sense are "view.html"
and "static.html".

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LIST" "1" "2024-08-29"
.TH "ODDMU-LIST" "1" "2025-08-31"
.PP
.SH NAME
.PP

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2025-03-16"
.TH "ODDMU-NGINX" "5" "2026-01-03"
.PP
.SH NAME
.PP
@@ -27,7 +27,7 @@ section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR se
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
@@ -53,7 +53,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf\&.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -97,7 +97,7 @@ server configuration.\& On a Debian system, that'\&d be in
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu\&.sock:;
}
.fi

View File

@@ -19,7 +19,7 @@ The site is defined in "/etc/nginx/sites-available/default", in the _server_
section. Add a new _location_ section after the existing _location_ section:
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://localhost:8080;
}
```
@@ -43,7 +43,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -81,7 +81,7 @@ server configuration. On a Debian system, that'd be in
"/etc/nginx/sites-available/default".
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu.sock:;
}
```

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2025-04-06"
.TH "ODDMU-RELEASES" "7" "2026-02-06"
.PP
.SH NAME
.PP
@@ -15,14 +15,143 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.20 (2026)
.PP
Add -shrink and -glob options to the \fIstatic\fR subcommand.\& See \fIoddmu-static\fR(1)
for more.\&
.PP
Some tools were used to check the code (goimports, golint, gocritic).\&
Unfortunately, the resulting changes necessitates a change in the templates
("feed.\&html", "preview.\&html", "search.\&html", "static.\&html", "view.\&html"):
"{{.\&Html}}" must be changed to "{{.\&HTML}}".\& One way to do this:
.PP
.nf
.RS 4
find \&. -regex \&'\&.*/(feed|preview|search|static|view).html\&'
-exec sed -i~ \&'s/{{\&.Html}}/{{\&.HTML}}/g\&' \&'{}\&' \&'+\&'
.fi
.RE
.PP
The \fIfeed\fR subcommand uses the page URL to extract a pubDate instead of relying
on the file'\&s last modified time.\& For a complete feed (an archive), the last
modified time is less important.\&
.PP
The feed for the index page is paginated, like other feeds.\& But since it grows
faster than any of the feeds for hashtag pages, presumably, an extra features
was added: on the first and on the last page of the feed, a link to the next or
the previous year is added, if such a page exists.\& This works if at beginning of
every year, you move all the entries on to a dedicated year page.\& You need to
add the necessary links to the "feed.\&html" template.\& See \fIoddmu-templates\fR(5)
for more.\&
.PP
Example:
.PP
.nf
.RS 4
<rss xmlns:atom="http://www\&.w3\&.org/2005/Atom" version="2\&.0"
xmlns:fh="http://purl\&.org/syndication/history/1\&.0">
{{if \&.PrevYear}}
<atom:link href="https://example\&.org/view/{{\&.Dir}}{{\&.PrevYear}}\&.rss?n={{\&.N}}"
rel="previous" type="application/rss+xml"/>
{{end}}
{{if \&.NextYear}}
<atom:link href="https://example\&.org/view/{{\&.Dir}}{{\&.NextYear}}\&.rss?n={{\&.N}}"
rel="next" type="application/rss+xml"/>
{{end}}
.fi
.RE
.PP
Add \fIsitemap\fR subcommand and handler.\& See \fIoddmu-sitemap\fR(1) for more.\& If you
want to make it available for search engines and the like, you most likely have
to add it to your proxy configuration.\& See \fIoddmu-apache\fR(5) or \fIoddmu-nginx\fR(5)
for more.\&
.PP
.SS 1.19 (2025)
.PP
Add \fIfeed\fR subcommand.\& This produces a "complete" feed.\&
.PP
Add feed pagination for the \fIfeed\fR action.\& This produces a "paginated" feed.\&
.PP
See RFC 5005 for more information.\&
.PP
If you like the idea of feed pagination (not a given since that also helps bots
scrape your site!\&) you need to add the necessary links to the "feed.\&html"
template.\& See \fIoddmu-templates\fR(5) for more.\&
.PP
Example, adding the feed history namespace:
.PP
.nf
.RS 4
<rss xmlns:atom="http://www\&.w3\&.org/2005/Atom" version="2\&.0"
xmlns:fh="http://purl\&.org/syndication/history/1\&.0">
{{if \&.From}}
<atom:link rel="previous" type="application/rss+xml"
href="https://example\&.org/view/{{\&.Path}}\&.rss?from={{\&.Prev}}&amp;n={{\&.N}}"/>
{{end}}
{{if \&.Next}}
<atom:link rel="next" type="application/rss+xml"
href="https://example\&.org/view/{{\&.Path}}\&.rss?from={{\&.Next}}&amp;n={{\&.N}}"/>
{{end}}
{{if \&.Complete}}<fh:complete/>{{end}}
.fi
.RE
.PP
.SS 1.18 (2025)
.PP
The \fIhashtags\fR gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages.\& See \fIoddmu-hashtags\fR(1) for more.\&
.PP
In an effort to remove features that can be handled by the web server, the
\fIlist\fR, \fIdelete\fR and \fIrename\fR actions were removed again.\& See \fIoddmu-webdav\fR(5)
for a better solution.\&
.PP
You probably need to remove a sentence linking to the list action from the
"upload.\&html" template.\&
.PP
.SS 1.17 (2025)
.PP
You need to update the "upload.\&html" template.\& Many things have changed!\& See
\fIoddmu-templates\fR(5) for more.\&
.PP
You probably want to ensure that the upload link on the "view.\&html" template and
others, if you added it, has a \fIfilename\fR and \fIpagename\fR parameters.\&
.PP
Example:
.PP
.nf
.RS 4
<a href="/upload/{{\&.Dir}}?filename={{\&.Base}}-1\&.jpg&pagename={{\&.Base}}">Upload</a>
.fi
.RE
.PP
You need to change {{.\&Name}} to {{.\&Path}} when it is used in URLs, in the
"list.\&html" template.\& If you don'\&t do this, file deleting and rename may not
work on files containing a comma, a semicolon, a questionmark or a hash
character.\& This fix was necessary because URLs for files containing a
questionmark or a hash character would end the path at this character and treat
the rest as a query parameter or fragment, respectively.\&
.PP
Updated the example themes.\& Some of my sites got a text area that tries to take
all the vertical space available.\& This is great for monitors in portrait mode.\&
.PP
\fIlist\fR action now skips dot files.\&
.PP
.SS 1.16 (2025)
.PP
Add support for WebP images for uploading and resizing.\&
.PP
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates.\& If you don'\&t do
this, your page names (i.\&e.\& filenames for pages) may not include a comma, a
semicolon or a questionmark.\& This fix was necessary because file uploads of
filenames with non-ASCII characters ended up double-encoded.\&
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates where pages are
concerned.\& If you don'\&t do this, your page names (i.\&e.\& filenames for pages) may
not include a comma, a semicolon, a questionmark or a hash sign.\& This fix was
necessary because file uploads of filenames with non-ASCII characters ended up
double-encoded.\&
.PP
Note that on the "list.\&html" template, {{.\&Name}} refers to file instead of a
page and File.\&Path() isn'\&t implemented, yet.\& This is fixed in the next release.\&
.PP
Improved the example themes.\& The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket
@@ -55,7 +184,7 @@ together with appropriate permission checks.\&
See \fIoddmu-apache\fR(5) or \fIoddmu-nginx\fR(5) for example.\&
.PP
In addition to that, you might want a link to the \fIlist\fR action from one of the
existing templates.\& For example, from upload.\&html:
existing templates.\& For example, from the "upload.\&html" template:
.PP
.nf
.RS 4
@@ -92,10 +221,9 @@ These are the quotation marks currently supported: '\&foo'\& "foo" foo
“foo” „foo“ ”foo” «foo» »foo« foo foo 「foo」 「foo」 『foo』 any such
quoted text is searched as-is, including whitespace.\&
.PP
Add loading="lazy" for images in search.\&html
.PP
If you want to take advantage of this, you'\&ll need to adapt your "search.\&html"
template accordingly.\& Use like this, for example:
Add loading="lazy" for images in the search template.\& If you want to take
advantage of this, you'\&ll need to adapt your "search.\&html" template accordingly.\&
Use like this, for example:
.PP
.nf
.RS 4
@@ -155,10 +283,10 @@ If you want to take advantage of this, you'\&ll need to adapt your templates
accordingly.\& The "preview.\&html" template is a mix of "view.\&html" and
"edit.\&html".\&
.PP
There is an optional change to make to copies of \fIupload.\&html\fR if you upload
multiple images at a time.\& Instead of showing just the link to the last upload,
you can now show the link (and the images or links, if you want to) to all the
files uploaded.\& Use like this, for example:
There is an optional change to make to copies of the "upload.\&html" template if
you upload multiple images at a time.\& Instead of showing just the link to the
last upload, you can now show the link (and the images or links, if you want to)
to all the files uploaded.\& Use like this, for example:
.PP
.nf
.RS 4
@@ -168,9 +296,9 @@ Links:<tt>{{range \&.Actual}}<br>![]({{\&.}}){{end}}</tt>
.PP
.SS 1.9 (2024)
.PP
There is a change to make to copies of \fIupload.\&html\fR if subdirectories are being
used.\& The \fILast\fR property no longer contains the directory.\& It has to be added
to the template as follows:
There is a change to make to copies of the "upload.\&html" template if
subdirectories are being used.\& The \fILast\fR property no longer contains the
directory.\& It has to be added to the template as follows:
.PP
.nf
.RS 4
@@ -198,7 +326,7 @@ The upload template can use the \fIToday\fR property.\&
The upload template comes with JavaScript that allows users to paste images or
drag and drop files.\&
.PP
The upload template changed the id for the filename field from `text` to `name`.\&
The upload template changed the id for the filename field from "text" to "name".\&
.PP
The source repository now comes with example templates.\&
.PP
@@ -208,7 +336,7 @@ No user-visible changes.\& Documentation and code comments got better.\&
.PP
.SS 1.7 (2024)
.PP
Allow upload of multiple files.\& This requires an update to the \fIupload.\&html\fR
Allow upload of multiple files.\& This requires an update to the "upload.\&html"
template: Add the \fImultiple\fR attribute to the file input element and change the
label from "file" to "files".\&
.PP

View File

@@ -8,14 +8,135 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 1.20 (2026)
Add -shrink and -glob options to the _static_ subcommand. See _oddmu-static_(1)
for more.
Some tools were used to check the code (goimports, golint, gocritic).
Unfortunately, the resulting changes necessitates a change in the templates
("feed.html", "preview.html", "search.html", "static.html", "view.html"):
"{{.Html}}" must be changed to "{{.HTML}}". One way to do this:
```
find . -regex '.*/\(feed\|preview\|search\|static\|view\)\.html' \
-exec sed -i~ 's/{{.Html}}/{{.HTML}}/g' '{}' '+'
```
The _feed_ subcommand uses the page URL to extract a pubDate instead of relying
on the file's last modified time. For a complete feed (an archive), the last
modified time is less important.
The feed for the index page is paginated, like other feeds. But since it grows
faster than any of the feeds for hashtag pages, presumably, an extra features
was added: on the first and on the last page of the feed, a link to the next or
the previous year is added, if such a page exists. This works if at beginning of
every year, you move all the entries on to a dedicated year page. You need to
add the necessary links to the "feed.html" template. See _oddmu-templates_(5)
for more.
Example:
```
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"
xmlns:fh="http://purl.org/syndication/history/1.0">
{{if .PrevYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.PrevYear}}.rss?n={{.N}}"
rel="previous" type="application/rss+xml"/>
{{end}}
{{if .NextYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.NextYear}}.rss?n={{.N}}"
rel="next" type="application/rss+xml"/>
{{end}}
```
Add _sitemap_ subcommand and handler. See _oddmu-sitemap_(1) for more. If you
want to make it available for search engines and the like, you most likely have
to add it to your proxy configuration. See _oddmu-apache_(5) or _oddmu-nginx_(5)
for more.
## 1.19 (2025)
Add _feed_ subcommand. This produces a "complete" feed.
Add feed pagination for the _feed_ action. This produces a "paginated" feed.
See RFC 5005 for more information.
If you like the idea of feed pagination (not a given since that also helps bots
scrape your site!) you need to add the necessary links to the "feed.html"
template. See _oddmu-templates_(5) for more.
Example, adding the feed history namespace:
```
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"
xmlns:fh="http://purl.org/syndication/history/1.0">
{{if .From}}
<atom:link rel="previous" type="application/rss+xml"
href="https://example.org/view/{{.Path}}.rss?from={{.Prev}}&amp;n={{.N}}"/>
{{end}}
{{if .Next}}
<atom:link rel="next" type="application/rss+xml"
href="https://example.org/view/{{.Path}}.rss?from={{.Next}}&amp;n={{.N}}"/>
{{end}}
{{if .Complete}}<fh:complete/>{{end}}
```
## 1.18 (2025)
The _hashtags_ gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages. See _oddmu-hashtags_(1) for more.
In an effort to remove features that can be handled by the web server, the
_list_, _delete_ and _rename_ actions were removed again. See _oddmu-webdav_(5)
for a better solution.
You probably need to remove a sentence linking to the list action from the
"upload.html" template.
## 1.17 (2025)
You need to update the "upload.html" template. Many things have changed! See
_oddmu-templates_(5) for more.
You probably want to ensure that the upload link on the "view.html" template and
others, if you added it, has a _filename_ and _pagename_ parameters.
Example:
```
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}">Upload</a>
```
You need to change {{.Name}} to {{.Path}} when it is used in URLs, in the
"list.html" template. If you don't do this, file deleting and rename may not
work on files containing a comma, a semicolon, a questionmark or a hash
character. This fix was necessary because URLs for files containing a
questionmark or a hash character would end the path at this character and treat
the rest as a query parameter or fragment, respectively.
Updated the example themes. Some of my sites got a text area that tries to take
all the vertical space available. This is great for monitors in portrait mode.
_list_ action now skips dot files.
## 1.16 (2025)
Add support for WebP images for uploading and resizing.
You need to change {{.Name}} to {{.Path}} in HTML templates. If you don't do
this, your page names (i.e. filenames for pages) may not include a comma, a
semicolon or a questionmark. This fix was necessary because file uploads of
filenames with non-ASCII characters ended up double-encoded.
You need to change {{.Name}} to {{.Path}} in HTML templates where pages are
concerned. If you don't do this, your page names (i.e. filenames for pages) may
not include a comma, a semicolon, a questionmark or a hash sign. This fix was
necessary because file uploads of filenames with non-ASCII characters ended up
double-encoded.
Note that on the "list.html" template, {{.Name}} refers to file instead of a
page and File.Path() isn't implemented, yet. This is fixed in the next release.
Improved the example themes. The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket
@@ -48,7 +169,7 @@ together with appropriate permission checks.
See _oddmu-apache_(5) or _oddmu-nginx_(5) for example.
In addition to that, you might want a link to the _list_ action from one of the
existing templates. For example, from upload.html:
existing templates. For example, from the "upload.html" template:
```
<p>You can rename and delete files <a href="/list/{{.Dir}}">from the file list</a>.
@@ -81,10 +202,9 @@ These are the quotation marks currently supported: 'foo' "foo" foo foo
“foo” „foo“ ”foo” «foo» »foo« foo foo 「foo」 「foo」 『foo』 any such
quoted text is searched as-is, including whitespace.
Add loading="lazy" for images in search.html
If you want to take advantage of this, you'll need to adapt your "search.html"
template accordingly. Use like this, for example:
Add loading="lazy" for images in the search template. If you want to take
advantage of this, you'll need to adapt your "search.html" template accordingly.
Use like this, for example:
```
{{range .Items}}
@@ -136,10 +256,10 @@ If you want to take advantage of this, you'll need to adapt your templates
accordingly. The "preview.html" template is a mix of "view.html" and
"edit.html".
There is an optional change to make to copies of _upload.html_ if you upload
multiple images at a time. Instead of showing just the link to the last upload,
you can now show the link (and the images or links, if you want to) to all the
files uploaded. Use like this, for example:
There is an optional change to make to copies of the "upload.html" template if
you upload multiple images at a time. Instead of showing just the link to the
last upload, you can now show the link (and the images or links, if you want to)
to all the files uploaded. Use like this, for example:
```
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
@@ -147,9 +267,9 @@ Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
## 1.9 (2024)
There is a change to make to copies of _upload.html_ if subdirectories are being
used. The _Last_ property no longer contains the directory. It has to be added
to the template as follows:
There is a change to make to copies of the "upload.html" template if
subdirectories are being used. The _Last_ property no longer contains the
directory. It has to be added to the template as follows:
```
{{if ne .Last ""}}
@@ -173,7 +293,7 @@ The upload template can use the _Today_ property.
The upload template comes with JavaScript that allows users to paste images or
drag and drop files.
The upload template changed the id for the filename field from `text` to `name`.
The upload template changed the id for the filename field from "text" to "name".
The source repository now comes with example templates.
@@ -183,7 +303,7 @@ No user-visible changes. Documentation and code comments got better.
## 1.7 (2024)
Allow upload of multiple files. This requires an update to the _upload.html_
Allow upload of multiple files. This requires an update to the "upload.html"
template: Add the _multiple_ attribute to the file input element and change the
label from "file" to "files".

49
man/oddmu-sitemap.1 Normal file
View File

@@ -0,0 +1,49 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SITEMAP" "1" "2026-01-03"
.PP
.SH NAME
.PP
oddmu-sitemap - print static sitemap.\&xml
.PP
.SH SYNOPSIS
.PP
\fBoddmu sitemap\fR [\fB-base\fR \fIURL\fR] [\fB-filter\fR \fIregexp\fR]
.PP
.SH DESCRIPTION
.PP
The "sitemap" subcommand prints the list of all pages in Sitemap format.\& Oddmu
already serves the sitemap at the URL "/sitemap.\&xml" but if you'\&d prefer to
provide a static file, use this command and redirect the output to a file called
"sitemap.\&xml" in your document root at regular intervals.\&
.PP
If you do this, don'\&t proxy the "/sitemap" URL in the web server configuration.\&
.PP
Your "robots.\&txt" file, if you have one, should point at the sitemap you
provide.\&
.PP
.SH OPTIONS
.PP
\fB-base\fR \fIURL\fR
.RS 4
The base URL is something like "https://example.\&org/view/".\&
.RE
\fB-filter\fR \fIregexp\fR
.RS 4
A regular expression matching the pages to exclude from the sitemap.\&
This emulates the effect of the ODDMU_FILTER environment variable.\&
.PP
.RE
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-apache\fR(1), \fIoddmu-nginx\fR(1),
https://www.\&sitemaps.\&org/
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

38
man/oddmu-sitemap.1.txt Normal file
View File

@@ -0,0 +1,38 @@
ODDMU-SITEMAP(1)
# NAME
oddmu-sitemap - print static sitemap.xml
# SYNOPSIS
*oddmu sitemap* [*-base* _URL_] [*-filter* _regexp_]
# DESCRIPTION
The "sitemap" subcommand prints the list of all pages in Sitemap format. Oddmu
already serves the sitemap at the URL "/sitemap.xml" but if you'd prefer to
provide a static file, use this command and redirect the output to a file called
"sitemap.xml" in your document root at regular intervals.
If you do this, don't proxy the "/sitemap" URL in the web server configuration.
Your "robots.txt" file, if you have one, should point at the sitemap you
provide.
# OPTIONS
*-base* _URL_
The base URL is something like "https://example.org/view/".
*-filter* _regexp_
A regular expression matching the pages to exclude from the sitemap.
This emulates the effect of the ODDMU_FILTER environment variable.
# SEE ALSO
_oddmu_(1), _oddmu-filter_(7), _oddmu-apache_(1), _oddmu-nginx_(1),
https://www.sitemaps.org/
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2024-08-29"
.TH "ODDMU-STATIC" "1" "2026-02-06"
.PP
.SH NAME
.PP
@@ -13,7 +13,7 @@ oddmu-static - create a static copy of the site
.PP
.SH SYNOPSIS
.PP
\fBoddmu static\fR \fIdir-name\fR
\fBoddmu static\fR [\fB-jobs\fR \fIn\fR] [\fB-glob\fR \fIpattern\fR] [\fB-shrink\fR] \fIdir-name\fR
.PP
.SH DESCRIPTION
.PP
@@ -28,7 +28,8 @@ pages get ".\&html" appended.\&
If a page has a name case-insensitively matching a hashtag, a feed file is
generated (ending with ".\&rss") if any suitable links are found.\& A suitable link
for a feed item must appear in a bullet list item using an asterisk ("*").\& If
no feed items are found, no feed is written.\&
no feed items are found, no feed is written.\& The feed is limited to the ten most
recent items.\&
.PP
Hidden files and directories (starting with a ".\&") and backup files (ending with
a "~") are skipped.\&
@@ -38,12 +39,11 @@ the images take a lot more space than the text.\& On my blog in 2023 I had 2.\&6
GiB of JPG files and 0.\&02 GiB of Markdown files.\& There is no point in copying
all those images, most of the time.\&
.PP
Note, however: Hard links cannot span filesystems.\& A hard link is just an extra
name for the same file.\& This is why the destination directory for the static
site has to be on same filesystem as the current directory, if it contains any
other files besides Markdown files.\&
As hard links cannot span filesystems, all other files are \fIcopied\fR if the
destination directory for the static site is not on same filesystem as the
current directory.\&
.PP
Furthermore, in-place editing changes the file for all names.\& Avoid editing the
Note that in-place editing changes the file for all names.\& Avoid editing the
hard-linked files (anything that'\&s not a HTML file) in the destination
directory, just to be on the safe side.\& Usually you should be fine, as an editor
moves the file that'\&s being edited to a backup file and creates a new file.\& But
@@ -51,6 +51,35 @@ then again, who knows.\& A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.\&
.PP
.SH OPTIONS
.PP
\fB-jobs\fR \fIn\fR
.RS 4
By default, two jobs are used to process the files.\& If your machine has
more cores, you can increase the number of jobs.\&
.PP
.RE
\fB-glob\fR \fIpattern\fR
.RS 4
By default, all files are used for the static export.\& You can limit the
files used by providing a shell file name pattern.\& A "*" matches any
number of characters; a "?\&" matches exactly one character; "[a-z]"
matches a character listed, including ranges; "[^a-z]" matches a
character not listed, including ranges; "\e" a backslash escapes the
following character.\& You must use quotes around the pattern if you are
using a shell as the shell would otherwise expand the pattern, resulting
in the error "Exactly one target directory is required".\&
.PP
.PP
.RE
\fB-shrink\fR
.RS 4
By default, images are linked or copied.\& With this option, JPEG, PNG and
WebP files are scaled down if more than 800 pixels wide and the quality
is set to 30% for JPEG and WebP files.\& This is \fIbad quality\fR but the
result is that these image files are very small.\&
.PP
.RE
.SH EXAMPLES
.PP
Generate a static copy of the site, but only loading language detection for
@@ -89,7 +118,11 @@ speed language determination up.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-templates\fR(5)
See \fIoddmu\fR(1) and \fIoddmu-templates\fR(5) for general information.\&
.PP
See \fIoddmu-html\fR(1) for a subcommand that converts individual pages file to HTML
and see \fIoddmu-feed\fR(1) for a subcommand that generates feeds for individual
files.\&
.PP
.SH AUTHORS
.PP

View File

@@ -6,7 +6,7 @@ oddmu-static - create a static copy of the site
# SYNOPSIS
*oddmu static* _dir-name_
*oddmu static* [*-jobs* _n_] [*-glob* _pattern_] [*-shrink*] _dir-name_
# DESCRIPTION
@@ -21,7 +21,8 @@ pages get ".html" appended.
If a page has a name case-insensitively matching a hashtag, a feed file is
generated (ending with ".rss") if any suitable links are found. A suitable link
for a feed item must appear in a bullet list item using an asterisk ("\*"). If
no feed items are found, no feed is written.
no feed items are found, no feed is written. The feed is limited to the ten most
recent items.
Hidden files and directories (starting with a ".") and backup files (ending with
a "~") are skipped.
@@ -31,12 +32,11 @@ the images take a lot more space than the text. On my blog in 2023 I had 2.62
GiB of JPG files and 0.02 GiB of Markdown files. There is no point in copying
all those images, most of the time.
Note, however: Hard links cannot span filesystems. A hard link is just an extra
name for the same file. This is why the destination directory for the static
site has to be on same filesystem as the current directory, if it contains any
other files besides Markdown files.
As hard links cannot span filesystems, all other files are _copied_ if the
destination directory for the static site is not on same filesystem as the
current directory.
Furthermore, in-place editing changes the file for all names. Avoid editing the
Note that in-place editing changes the file for all names. Avoid editing the
hard-linked files (anything that's not a HTML file) in the destination
directory, just to be on the safe side. Usually you should be fine, as an editor
moves the file that's being edited to a backup file and creates a new file. But
@@ -44,6 +44,29 @@ then again, who knows. A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.
# OPTIONS
*-jobs* _n_
By default, two jobs are used to process the files. If your machine has
more cores, you can increase the number of jobs.
*-glob* _pattern_
By default, all files are used for the static export. You can limit the
files used by providing a shell file name pattern. A "\*" matches any
number of characters; a "?" matches exactly one character; "[a-z]"
matches a character listed, including ranges; "[^a-z]" matches a
character not listed, including ranges; "\\" a backslash escapes the
following character. You must use quotes around the pattern if you are
using a shell as the shell would otherwise expand the pattern, resulting
in the error "Exactly one target directory is required".
*-shrink*
By default, images are linked or copied. With this option, JPEG, PNG and
WebP files are scaled down if more than 800 pixels wide and the quality
is set to 30% for JPEG and WebP files. This is _bad quality_ but the
result is that these image files are very small.
# EXAMPLES
Generate a static copy of the site, but only loading language detection for
@@ -80,7 +103,11 @@ speed language determination up.
# SEE ALSO
_oddmu_(1), _oddmu-templates_(5)
See _oddmu_(1) and _oddmu-templates_(5) for general information.
See _oddmu-html_(1) for a subcommand that converts individual pages file to HTML
and see _oddmu-feed_(1) for a subcommand that generates feeds for individual
files.
# AUTHORS

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-08-30" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2026-01-03" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -31,12 +31,12 @@ placeholders.\&
.IP \(bu 4
\fIfeed.\&html\fR uses a \fIfeed\fR
.IP \(bu 4
\fIlist.\&html\fR uses a \fIlist\fR
.IP \(bu 4
\fIpreview.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIsearch.\&html\fR uses a \fIsearch\fR
.IP \(bu 4
\fIsitemap.\&html\fR uses a \fIsitemap\fR
.IP \(bu 4
\fIstatic.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIupload.\&html\fR uses an \fIupload\fR
@@ -44,6 +44,29 @@ placeholders.\&
\fIview.\&html\fR uses a \fIpage\fR
.PD
.PP
The following property lists always indicate whether the property is
percent-encoded or not.\& In theory, the html/template package would handle this.\&
The problem is that the package gives special treatment to the semicolon, comma,
question-mark and hash-sign as these are potential separators in a URL.\&
.PP
Consider the following:
.PP
.nf
.RS 4
<a href="{{\&.Name}}">{{\&.Name}}</a>
.fi
.RE
.PP
If \fI.\&Name\fR is "#foo", the html/template package treats it as a URL fragment
inside the attribute instead of a file path that needs to be escaped to
"%23foo".\& The same problem arises if \fI.\&Name\fR is "foo?\&" as the questionmark is
not escaped and therefore treated as the separator between URL path and query
parameters instead of being part of the name.\&
.PP
The consequences for template authors is that the properties that are
percent-encoded must be used in links where as the regular properties must be
used outside of links.\&
.PP
.SS Page
.PP
A page has the following properties:
@@ -51,14 +74,14 @@ A page has the following properties:
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own title, the
page name is used.\&
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\& More specifically, it is
percent-escaped except for the slashes.\& The page name doesn'\&t include the \fI.\&md\fR
extension.\&
\fI{{.\&Name}}\fR is the page name.\& The page name doesn'\&t include the \fI.\&md\fR extension.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-escaped except for the slashes.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-encoded.\&
.PP
\fI{{.\&Base}}\fR is the basename of the current file (without the directory and
without the \fI.\&md\fR extension), escaped for use in URLs.\&
without the \fI.\&md\fR extension), percent-encoded.\&
.PP
\fI{{.\&Language}}\fR is the suspected language of the page.\& This is used to set the
language on the \fIview.\&html\fR template.\& See "Non-English hyphenation" below.\&
@@ -109,36 +132,51 @@ An item is a page plus a date.\& All the properties of a page can be used (see
.PP
\fI{{.\&Date}}\fR is the date of the last update to the page, in RFC 822 format.\&
.PP
.SS List
In order to paginate feeds, the following attributes are also available in the
feed:
.PP
The list contains a directory name and an array of files.\&
\fI{{.\&From}}\fR is the item number where the feed starts.\& The first page starts at
0.\& This can be passed to Oddmu via the query parameter \fIfrom\fR.\&
.PP
\fI{{.\&Dir}}\fR is the directory name that is being listed.\&
\fI{{.\&N}}\fR is the number items per page.\& The default is 10.\& This can be passed to
Oddmu via the query parameter \fIn\fR.\& If this is set to 0, the feed is not
paginated.\&
.PP
\fI{{.\&Files}}\fR is the array of files.\& To refer to them, you need to use a \fI{{range
Files}}\fR\fI{{end}}\fR construct.\&
\fI{{.\&Complete}}\fR is a boolean that is true if the feed is not paginated.\& Such a
feed cannot have a previous or next page.\&
.PP
Each file has the following attributes:
\fI{{.\&Prev}}\fR is the item number where the previous page of the feed starts.\& On
the first page, it'\&s value is 0 instead of -10.\& You need to test if \fI{{.\&From}}\fR
is non-zero (in which case this is not the first page) before using \fI{{.\&Prev}}\fR.\&
.PP
\fI{{.\&Name}}\fR is the filename.\& The ".\&md" suffix for Markdown files is part of the
name (unlike page names).\&
\fI{{.\&Next}}\fR is the item number where the next feed starts, if there are any
items left.\& If there are none, it'\&s value is 0.\&
.PP
\fI{{.\&Title}}\fR is the page title, if the file in question is a Markdown file.\&
\fI{{.\&PrevYear}}\fR is the year for the previous yearly archive.\& This is added on
the index page or on year pages.\& Year pages are pages whose name is just a
number (presumably a year).\& The property is only set on the first page of the
feed, if the previous year page exists.\& The previous year is one higher than the
year currently shown (if on a year page) or the current year (if looking at the
index), since the feed goes backwards in time as new entries appear at the top.\&
When looking at the page "2024" the previous page is "2025".\& Strangely enough,
if the current year is 2026 but a page "2027" already exists, and the feed for
the index page is generated, then "2027" (in the future) is the previous page.\&
If the current year is 2026, the feed of the index page points to "2025" as the
next year, if it exists.\& When the feed for "2025" is generated, however, the
previous year is not set, assuming that the "2026" page does not yet exist and
it is strange to consider the index page "the previous year" of "2025" in 2026.\&
This might change in the future.\& If it isn'\&t set, it'\&s value is 0.\&
.PP
\fI{{.\&IsDir}}\fR is a boolean used to indicate that this file is a directory.\&
.PP
\fI{{.\&IsUp}}\fR is a boolean used to indicate the entry for the parent directory
(the first file in the array, unless the directory being listed is the top
directory).\& The filename of this file is ".\&.\&".\&
.PP
\fI{{.\&Date}}\fR is the last modification date of the file.\&
\fI{{.\&NextYear}}\fR is the year for the next yearly archive.\& See above for an
explanation.\& The next year is one lower than the year currently shown (if on a
year page) or the current year (if looking at the index).\& If it isn'\&t set, it'\&s
value is 0.\&
.PP
.SS Search
.PP
\fI{{.\&Query}}\fR is the query string.\&
.PP
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-escaped except
for the slashes.\&
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-encoded.\&
.PP
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR and \fI{{.\&Next}}\fR are the previous, current and next
page number in the results since doing arithmetics in templates is hard.\& The
@@ -172,26 +210,31 @@ are only listed if a search term matches.\&
\fI{{.\&Html}}\fR the image alt-text with a bold tag used to highlight the first
search term that matched.\&
.PP
.SS Sitemap
.PP
The sitemap contains a list of URLs, each with its location:
.PP
\fI{{.\&URL}}\fR is the list of URLs.\&
.PP
Each URL has the following attributes:
.PP
\fI{{.\&Loc}}\fR with the actual page URL.\&
.PP
.SS Upload
.PP
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.\&
path, percent-encoded.\&
.PP
\fI{{.\&Name}}\fR is the \fIfilename\fR query parameter.\&
\fI{{.\&FileName}}\fR is the \fIfilename\fR query parameter used to suggested a filename.\&
.PP
\fI{{.\&Last}}\fR is the filename of the last file uploaded.\&
\fI{{.\&FilePath}}\fR is the filename, percent-encoded.\&
.PP
\fI{{.\&Actual}}\fR is an array of filenames of all the files uploaded.\& Use {{range
Actual}} … {{.\&}} … {{end}} to loop over all the filenames.\&
\fI{{.\&Name}}\fR is the \fIpagename\fR query parameter used to indicate where to append
links to the files.\&
.PP
\fI{{.\&Base}}\fR is the basename of the first file uploaded (without the directory,
extension and numeric part at the end), escaped for use in URLs.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Title}}\fR is the title of the basename, if it exists.\&
.PP
\fI{{.\&Image}}\fR is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in \fI.\&jpg\fR).\& If so, a thumbnail
can be shown by the template, for example.\&
\fI{{.\&Title}}\fR is the title of the page, if it exists.\&
.PP
\fI{{.\&MaxWidth}}\fR is the \fImaxwidth\fR query parameter, i.\&e.\& the value used for the
previous image uploaded.\&
@@ -201,6 +244,22 @@ previous image uploaded.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\&
.PP
\fI{{.\&Uploads}}\fR an array of files already uploaded, based on the \fIuploads\fR query
parameter.\& To refer to them, you need to use a \fI{{range .\&Uploads}}\fR\fI{{end}}\fR
construct.\& This is required because the \fIdrop\fR action redirects back to the
\fIupload\fR action, so after saving one or more files, you can upload even more
files.\&
.PP
Each upload has the following attributes:
.PP
\fI{{.\&Name}}\fR is the filename.\&
.PP
\fI{{.\&Path}}\fR is the file name, percent-encoded.\&
.PP
\fI{{.\&Image}}\fR is a boolean to indicate whether the upload is an image or not
(such as ending in \fI.\&jpg\fR).\& If so, a thumbnail can be shown by the template, for
example.\&
.PP
.SS Non-English hyphenation
.PP
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -214,16 +273,16 @@ use a small number of languages or just a single language!\& you can set
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
.PP
"view.\&html" is used the template to render a single page and so the language
detected is added to the "html" element.\&
"view.\&html" is used to render a single page and so the language detected is
added to the "html" element.\&
.PP
"search.\&html" is the template used to render search results and so "en" is used
for the "html" element and the language detected for every page in the search
result is added to the "article" element for each snippet.\&
.PP
"edit.\&html" and "add.\&html" are the templates used to edit a page and at that
point, the language isn'\&t known, so "en" is used for the "html" element and no
language is used for the "textarea" element.\&
"edit.\&html" and "add.\&html" are the templates used to edit a page.\& If the page
already exists, its language is used for the "textarea" element.\& If the page is
new, no language is used for the "textarea" element.\&
.PP
.SH EXAMPLES
.PP

View File

@@ -18,13 +18,34 @@ placeholders.
- _diff.html_ uses a _page_
- _edit.html_ uses a _page_
- _feed.html_ uses a _feed_
- _list.html_ uses a _list_
- _preview.html_ uses a _page_
- _search.html_ uses a _search_
- _sitemap.html_ uses a _sitemap_
- _static.html_ uses a _page_
- _upload.html_ uses an _upload_
- _view.html_ uses a _page_
The following property lists always indicate whether the property is
percent-encoded or not. In theory, the html/template package would handle this.
The problem is that the package gives special treatment to the semicolon, comma,
question-mark and hash-sign as these are potential separators in a URL.
Consider the following:
```
<a href="{{.Name}}">{{.Name}}</a>
```
If _.Name_ is "#foo", the html/template package treats it as a URL fragment
inside the attribute instead of a file path that needs to be escaped to
"%23foo". The same problem arises if _.Name_ is "foo?" as the questionmark is
not escaped and therefore treated as the separator between URL path and query
parameters instead of being part of the name.
The consequences for template authors is that the properties that are
percent-encoded must be used in links where as the regular properties must be
used outside of links.
## Page
A page has the following properties:
@@ -32,14 +53,14 @@ A page has the following properties:
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
page name is used.
_{{.Name}}_ is the page name, escaped for use in URLs. More specifically, it is
percent-escaped except for the slashes. The page name doesn't include the _.md_
extension.
_{{.Name}}_ is the page name. The page name doesn't include the _.md_ extension.
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Path}}_ is the page name, percent-encoded.
_{{.Dir}}_ is the page directory, percent-encoded.
_{{.Base}}_ is the basename of the current file (without the directory and
without the _.md_ extension), escaped for use in URLs.
without the _.md_ extension), percent-encoded.
_{{.Language}}_ is the suspected language of the page. This is used to set the
language on the _view.html_ template. See "Non-English hyphenation" below.
@@ -85,36 +106,51 @@ An item is a page plus a date. All the properties of a page can be used (see
_{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
## List
In order to paginate feeds, the following attributes are also available in the
feed:
The list contains a directory name and an array of files.
_{{.From}}_ is the item number where the feed starts. The first page starts at
0. This can be passed to Oddmu via the query parameter _from_.
_{{.Dir}}_ is the directory name that is being listed.
_{{.N}}_ is the number items per page. The default is 10. This can be passed to
Oddmu via the query parameter _n_. If this is set to 0, the feed is not
paginated.
_{{.Files}}_ is the array of files. To refer to them, you need to use a _{{range
.Files}}_ … _{{end}}_ construct.
_{{.Complete}}_ is a boolean that is true if the feed is not paginated. Such a
feed cannot have a previous or next page.
Each file has the following attributes:
_{{.Prev}}_ is the item number where the previous page of the feed starts. On
the first page, it's value is 0 instead of -10. You need to test if _{{.From}}_
is non-zero (in which case this is not the first page) before using _{{.Prev}}_.
_{{.Name}}_ is the filename. The ".md" suffix for Markdown files is part of the
name (unlike page names).
_{{.Next}}_ is the item number where the next feed starts, if there are any
items left. If there are none, it's value is 0.
_{{.Title}}_ is the page title, if the file in question is a Markdown file.
_{{.PrevYear}}_ is the year for the previous yearly archive. This is added on
the index page or on year pages. Year pages are pages whose name is just a
number (presumably a year). The property is only set on the first page of the
feed, if the previous year page exists. The previous year is one higher than the
year currently shown (if on a year page) or the current year (if looking at the
index), since the feed goes backwards in time as new entries appear at the top.
When looking at the page "2024" the previous page is "2025". Strangely enough,
if the current year is 2026 but a page "2027" already exists, and the feed for
the index page is generated, then "2027" (in the future) is the previous page.
If the current year is 2026, the feed of the index page points to "2025" as the
next year, if it exists. When the feed for "2025" is generated, however, the
previous year is not set, assuming that the "2026" page does not yet exist and
it is strange to consider the index page "the previous year" of "2025" in 2026.
This might change in the future. If it isn't set, it's value is 0.
_{{.IsDir}}_ is a boolean used to indicate that this file is a directory.
_{{.IsUp}}_ is a boolean used to indicate the entry for the parent directory
(the first file in the array, unless the directory being listed is the top
directory). The filename of this file is "..".
_{{.Date}}_ is the last modification date of the file.
_{{.NextYear}}_ is the year for the next yearly archive. See above for an
explanation. The next year is one lower than the year currently shown (if on a
year page) or the current year (if looking at the index). If it isn't set, it's
value is 0.
## Search
_{{.Query}}_ is the query string.
_{{.Dir}}_ is the directory in which the search starts, percent-escaped except
for the slashes.
_{{.Dir}}_ is the directory in which the search starts, percent-encoded.
_{{.Previous}}_, _{{.Page}}_ and _{{.Next}}_ are the previous, current and next
page number in the results since doing arithmetics in templates is hard. The
@@ -148,26 +184,31 @@ _{{.Name}}_ is the file name for use in URLs.
_{{.Html}}_ the image alt-text with a bold tag used to highlight the first
search term that matched.
## Sitemap
The sitemap contains a list of URLs, each with its location:
_{{.URL}}_ is the list of URLs.
Each URL has the following attributes:
_{{.Loc}}_ with the actual page URL.
## Upload
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.
path, percent-encoded.
_{{.Name}}_ is the _filename_ query parameter.
_{{.FileName}}_ is the _filename_ query parameter used to suggested a filename.
_{{.Last}}_ is the filename of the last file uploaded.
_{{.FilePath}}_ is the filename, percent-encoded.
_{{.Actual}}_ is an array of filenames of all the files uploaded. Use {{range
.Actual}} … {{.}} … {{end}} to loop over all the filenames.
_{{.Name}}_ is the _pagename_ query parameter used to indicate where to append
links to the files.
_{{.Base}}_ is the basename of the first file uploaded (without the directory,
extension and numeric part at the end), escaped for use in URLs.
_{{.Path}}_ is the page name, percent-encoded.
_{{.Title}}_ is the title of the basename, if it exists.
_{{.Image}}_ is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in _.jpg_). If so, a thumbnail
can be shown by the template, for example.
_{{.Title}}_ is the title of the page, if it exists.
_{{.MaxWidth}}_ is the _maxwidth_ query parameter, i.e. the value used for the
previous image uploaded.
@@ -177,6 +218,22 @@ previous image uploaded.
_{{.Today}}_ is the current date, in ISO format.
_{{.Uploads}}_ an array of files already uploaded, based on the _uploads_ query
parameter. To refer to them, you need to use a _{{range .Uploads}}_ … _{{end}}_
construct. This is required because the _drop_ action redirects back to the
_upload_ action, so after saving one or more files, you can upload even more
files.
Each upload has the following attributes:
_{{.Name}}_ is the filename.
_{{.Path}}_ is the file name, percent-encoded.
_{{.Image}}_ is a boolean to indicate whether the upload is an image or not
(such as ending in _.jpg_). If so, a thumbnail can be shown by the template, for
example.
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -190,16 +247,16 @@ use a small number of languages or just a single language! you can set t
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt".
"view.html" is used the template to render a single page and so the language
detected is added to the "html" element.
"view.html" is used to render a single page and so the language detected is
added to the "html" element.
"search.html" is the template used to render search results and so "en" is used
for the "html" element and the language detected for every page in the search
result is added to the "article" element for each snippet.
"edit.html" and "add.html" are the templates used to edit a page and at that
point, the language isn't known, so "en" is used for the "html" element and no
language is used for the "textarea" element.
"edit.html" and "add.html" are the templates used to edit a page. If the page
already exists, its language is used for the "textarea" element. If the page is
new, no language is used for the "textarea" element.
# EXAMPLES

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-WEBDAV" "5" "2024-09-25"
.TH "ODDMU-WEBDAV" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -14,27 +14,34 @@ oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
.SH DESCRIPTION
.PP
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor.\& If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.\&
.PP
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.\&
file system and manage the files using some other tool.\& Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.\&
.PP
.SH CONFIGURATION
.PP
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files.\& In the example below,
"/home/alex/campaignwiki.\&org/data" is both the document root for static files
and the data directory for Oddmu.\& This is the directory where Oddmu needs to
run.\& When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn'\&t
handle "/data".\& Instead, Apache gets to handle it.\& Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.\&
Consider the "campaignwiki.\&org" site in the example below.\& This site offers
users their own wikis.\& Thus:
.PP
"Dav On" enables Web-DAV for the "knochentanz" wiki.\& It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.\&
"https://campaignwiki.\&org/" is a regular website with static files.\&
.PP
"https://campaignwiki.\&org/view/index" is one of the requests that gets passed to
a Unix domain socket.\& See "Socket Activation" in \fIoddmu\fR(1).\&
.PP
Some of these actions are protected by basic authentication.\& A valid user is
required to make changes to the site.\& Valid users are "admin" and "alex".\&
.PP
"data" is the Oddmu working directory.\& WebDAV is turned on for this directory.\& A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access.\& The other paths also require authentication and map to
Oddmu actions.\& The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect.\& The only drawback is that "https://campaignwiki.\&org/data/" now
requires authentication even if only used for reading.\&
.PP
"https://campaignwiki.\&org/view/knochentanz/index" is a separate site called
"knochentanz".\& The only valid user is "knochentanz".\&
.PP
Notice how the \fIarchive\fR action is not available at the top level, only for
subdirectories.\&
.PP
.nf
.RS 4
@@ -48,28 +55,32 @@ MDomain campaignwiki\&.org
<VirtualHost *:443>
ServerAdmin alex@campaignwiki\&.org
ServerName campaignwiki\&.org
# Static HTML, CSS, JavaScript files and so on are saved here\&.
DocumentRoot /home/alex/campaignwiki\&.org
<Directory /home/alex/campaignwiki\&.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket\&.
ProxyPassMatch
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/\&.+)/(\&.*))$"
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/\&.+)/(\&.*))$"
"unix:/home/oddmu/campaignwiki\&.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data\&.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
# Making changes to the wiki requires authentication\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
.fi
.RE
@@ -101,9 +112,13 @@ sudo chmod g+w /home/alex/campaignwiki\&.org/data/knochentanz
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions.\& If you'\&re trying to use \fIgvfs\fR(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you'\&re on
your own.\&
your own.\& Sometimes it works.\& I'\&ve used Nemo 5.\&6.\&4 to connect to the server and
edited files using gedit 44.\&2.\& But I'\&ve used other file managers and other
editors with WebDAV support and they didn'\&t work very well.\&
.PP
This section has examples sessions using tools that work.\&
On Windows, try third party tools like WinSCP.\&
.PP
This section has examples sessions using command-line tools that work.\&
.PP
.SS cadaver
.PP
@@ -183,6 +198,9 @@ alex@melanobombus ~> echo test >> knochentanz/index\&.md
"Apache Module mod_dav".\&
https://httpd.\&apache.\&org/docs/current/mod/mod_dav.\&html
.PP
"WinSCP"
https://winscp.\&net/
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -7,27 +7,34 @@ oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
# DESCRIPTION
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor. If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.
file system and manage the files using some other tool. Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.
# CONFIGURATION
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files. In the example below,
"/home/alex/campaignwiki.org/data" is both the document root for static files
and the data directory for Oddmu. This is the directory where Oddmu needs to
run. When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn't
handle "/data". Instead, Apache gets to handle it. Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.
Consider the "campaignwiki.org" site in the example below. This site offers
users their own wikis. Thus:
"Dav On" enables Web-DAV for the "knochentanz" wiki. It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.
"https://campaignwiki.org/" is a regular website with static files.
"https://campaignwiki.org/view/index" is one of the requests that gets passed to
a Unix domain socket. See "Socket Activation" in _oddmu_(1).
Some of these actions are protected by basic authentication. A valid user is
required to make changes to the site. Valid users are "admin" and "alex".
"data" is the Oddmu working directory. WebDAV is turned on for this directory. A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access. The other paths also require authentication and map to
Oddmu actions. The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect. The only drawback is that "https://campaignwiki.org/data/" now
requires authentication even if only used for reading.
"https://campaignwiki.org/view/knochentanz/index" is a separate site called
"knochentanz". The only valid user is "knochentanz".
Notice how the _archive_ action is not available at the top level, only for
subdirectories.
```
MDomain campaignwiki.org
@@ -40,28 +47,32 @@ MDomain campaignwiki.org
<VirtualHost *:443>
ServerAdmin alex@campaignwiki.org
ServerName campaignwiki.org
# Static HTML, CSS, JavaScript files and so on are saved here.
DocumentRoot /home/alex/campaignwiki.org
<Directory /home/alex/campaignwiki.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket.
ProxyPassMatch \
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/.+)/(.*))$" \
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/.+)/(.*))$" \
"unix:/home/oddmu/campaignwiki.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
# Making changes to the wiki requires authentication.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
```
@@ -88,9 +99,13 @@ sudo chmod g+w /home/alex/campaignwiki.org/data/knochentanz
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions. If you're trying to use _gvfs_(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you're on
your own.
your own. Sometimes it works. I've used Nemo 5.6.4 to connect to the server and
edited files using gedit 44.2. But I've used other file managers and other
editors with WebDAV support and they didn't work very well.
This section has examples sessions using tools that work.
On Windows, try third party tools like WinSCP.
This section has examples sessions using command-line tools that work.
## cadaver
@@ -164,6 +179,9 @@ _oddmu_(1), _oddmu-apache_(5)
"Apache Module mod_dav".
https://httpd.apache.org/docs/current/mod/mod_dav.html
"WinSCP"
https://winscp.net/
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2025-03-14"
.TH "ODDMU" "1" "2026-01-03"
.PP
.SH NAME
.PP
@@ -77,14 +77,10 @@ directory:
.IP \(bu 4
\fI/drop/dir/name\fR saves an upload
.IP \(bu 4
\fI/list/dir/\fR lists the files in a directory
.IP \(bu 4
\fI/delete/dir/name\fR deletes a file or directory
.IP \(bu 4
\fI/rename/dir/name?\&name=new\fR renames a file or directory
.IP \(bu 4
\fI/search/dir/?\&q=term\fR to search for a term
.IP \(bu 4
\fI/sitemap.\&xml\fR to list the links to all the pages
.IP \(bu 4
\fI/archive/dir/name.\&zip\fR to download a zip file of a directory
.PD
.PP
@@ -390,10 +386,12 @@ Oddmu running as a webserver:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-hashtags\fR(1), on how to count the hashtags used
\fIoddmu-hashtags\fR(1), on working with hashtags
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page
.IP \(bu 4
\fIoddmu-feed\fR(1), on how to render a feed
.IP \(bu 4
\fIoddmu-list\fR(1), on how to list pages and titles
.IP \(bu 4
\fIoddmu-links\fR(1), on how to list the outgoing links for a page
@@ -406,6 +404,8 @@ Oddmu running as a webserver:
.IP \(bu 4
\fIoddmu-search\fR(1), on how to run a search
.IP \(bu 4
\fIoddmu-sitemap\fR(1), on generating a static sitemap.\&xml
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site
.IP \(bu 4
\fIoddmu-toc\fR(1), on how to list the table of contents (toc) a page

View File

@@ -55,10 +55,8 @@ directory:
- _/append/dir/name_ appends an addition to a page
- _/upload/dir/name_ shows a form to upload a file
- _/drop/dir/name_ saves an upload
- _/list/dir/_ lists the files in a directory
- _/delete/dir/name_ deletes a file or directory
- _/rename/dir/name?name=new_ renames a file or directory
- _/search/dir/?q=term_ to search for a term
- _/sitemap.xml_ to list the links to all the pages
- _/archive/dir/name.zip_ to download a zip file of a directory
When calling the _save_ and _append_ action, the page name is taken from the URL
@@ -318,14 +316,16 @@ If you run Oddmu as a web server:
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
- _oddmu-hashtags_(1), on how to count the hashtags used
- _oddmu-hashtags_(1), on working with hashtags
- _oddmu-html_(1), on how to render a page
- _oddmu-feed_(1), on how to render a feed
- _oddmu-list_(1), on how to list pages and titles
- _oddmu-links_(1), on how to list the outgoing links for a page
- _oddmu-missing_(1), on how to find broken local links
- _oddmu-notify_(1), on updating index, changes and hashtag pages
- _oddmu-replace_(1), on how to search and replace text
- _oddmu-search_(1), on how to run a search
- _oddmu-sitemap_(1), on generating a static sitemap.xml
- _oddmu-static_(1), on generating a static site
- _oddmu-toc_(1), on how to list the table of contents (toc) a page
- _oddmu-version_(1), on how to get all the build information from the binary

View File

@@ -1,7 +1,6 @@
package main
import (
"github.com/stretchr/testify/assert"
"go/parser"
"go/token"
"io/fs"
@@ -12,6 +11,8 @@ import (
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// Does oddmu(1) link to all the other man pages?
@@ -60,6 +61,40 @@ func TestManTemplates(t *testing.T) {
assert.Greater(t, count, 0, "no templates were found")
}
// Does oddmu-templates(5) mention all the templates?
func TestManTemplateAttributess(t *testing.T) {
mfp := "man/oddmu-templates.5.txt"
b, err := os.ReadFile(mfp)
man := string(b)
assert.NoError(t, err)
re := regexp.MustCompile(`{{(?:(?:if|range) )?(\.[A-Z][a-z]*)}}`)
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if fp != "." && info.IsDir() {
return filepath.SkipDir
}
if !strings.HasSuffix(fp, ".html") {
return nil
}
h, err := os.ReadFile(fp)
matches := re.FindAllSubmatch(h, -1)
assert.Greater(t, len(matches), 0, "%s contains no attributes", fp)
seen := make(map[string]bool)
for _, m := range matches {
attr := string(m[1])
if seen[attr] {
continue
}
seen[attr] = true
assert.Contains(t, man, "_{{"+attr+"}}_", "%s does not mention _{{%s}}_", mfp, attr)
}
assert.NoError(t, err)
return nil
})
}
// Does oddmu(1) mention all the actions? We're not going to parse the go file and make sure to catch them all. I tried
// it, and it's convoluted.
func TestManActions(t *testing.T) {
@@ -71,7 +106,7 @@ func TestManActions(t *testing.T) {
wiki := string(b)
count := 0
// this doesn't match the root handler
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)(, http\.Method(Get|Post))+\)\)`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
var path string

View File

@@ -4,14 +4,15 @@ import (
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
"io"
"net/url"
"os"
"path"
"strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
)
type missingCmd struct {
@@ -32,6 +33,12 @@ func (cmd *missingCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *missingCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
n, err := index.load()
if err != nil {
fmt.Fprintf(os.Stderr, "Index load: %s\n", err)
return subcommands.ExitFailure
}
fmt.Fprintf(os.Stderr, "Indexed %d pages\n", n)
return missingCli(os.Stdout, &index)
}
@@ -94,8 +101,7 @@ func (p *Page) links() []string {
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
if v, ok := node.(*ast.Link); ok {
link := string(v.Destination)
url, err := url.Parse(link)
if err != nil {

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestMissingCmd(t *testing.T) {

View File

@@ -4,10 +4,11 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"strings"
"github.com/google/subcommands"
)
type notifyCmd struct {
@@ -31,13 +32,12 @@ func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface
}
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
index.load()
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
name = name[0 : len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

60
page.go
View File

@@ -2,7 +2,6 @@ package main
import (
"bytes"
"github.com/microcosm-cc/bluemonday"
"html/template"
"log"
"net/url"
@@ -12,6 +11,8 @@ import (
"regexp"
"strings"
"time"
"github.com/microcosm-cc/bluemonday"
)
// Page is a struct containing information about a single page. Title is the title extracted from the page content using
@@ -21,7 +22,7 @@ type Page struct {
Title string
Name string
Body []byte
Html template.HTML
HTML template.HTML
Hashtags []string
}
@@ -29,7 +30,7 @@ type Page struct {
// the Name "foo").
type Link struct {
Title string
Url string
URL string
}
// blogRe is a regular expression that matches blog pages. If the filename of a blog page starts with an ISO date
@@ -61,7 +62,7 @@ func nameEscape(s string) string {
}
// save saves a Page. The path is based on the Page.Name and gets the ".md" extension. Page.Body is saved, without any
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
// carriage return characters ("\r"). Page.Title and Page.HTML are not saved. There is no caching. Before removing or
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
func (p *Page) save() error {
fp := filepath.FromSlash(p.Name) + ".md"
@@ -88,6 +89,17 @@ func (p *Page) save() error {
return os.WriteFile(fp, s, 0644)
}
// ModTime returns the last modification time of the page file. If the page does not exist, the current time is
// returned.
func (p *Page) ModTime() (time.Time, error) {
fp := filepath.FromSlash(p.Name) + ".md"
fi, err := os.Stat(fp)
if err != nil {
return time.Now(), err
}
return fi.ModTime(), nil
}
// backup a file by renaming it unless the existing backup is less than an hour old. A backup gets a tilde appended to
// it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
// what to do with a file called "image.png~". This expects a filepath. The backup file gets its modification time set
@@ -111,7 +123,7 @@ func backup(fp string) error {
}
// loadPage loads a Page given a name. The path loaded is that Page.Name with the ".md" extension. The Page.Title is set
// to the Page.Name (and possibly changed, later). The Page.Body is set to the file content. The Page.Html remains
// to the Page.Name (and possibly changed, later). The Page.Body is set to the file content. The Page.HTML remains
// undefined (there is no caching).
func loadPage(name string) (*Page, error) {
name = strings.TrimPrefix(name, "./") // result of a path.TreeWalk starting with "."
@@ -136,10 +148,10 @@ func (p *Page) handleTitle(replace bool) {
}
}
// summarize sets Page.Html to an extract.
// summarize sets Page.HTML to an extract.
func (p *Page) summarize(q string) {
t := p.plainText()
p.Html = sanitizeStrict(snippets(q, t))
p.HTML = sanitizeStrict(snippets(q, t))
}
// IsBlog returns true if the page name starts with an ISO date
@@ -150,19 +162,24 @@ func (p *Page) IsBlog() bool {
const upperhex = "0123456789ABCDEF"
// Path returns the page name with semicolon, comma and questionmark escaped because html/template doesn't escape those.
// This is suitable for use in HTML templates.
// Path returns the Page.Name with some characters escaped because html/template doesn't escape those. This is suitable
// for use in HTML templates.
func (p *Page) Path() string {
s := p.Name
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?")
return pathEncode(p.Name)
}
// pathEncode returns the page name with some characters escaped because html/template doesn't escape those. This is
// suitable for use in HTML templates.
func pathEncode(s string) string {
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?") + strings.Count(s, "#")
if n == 0 {
return p.Name
return s
}
t := make([]byte, len(s) + 2*n)
t := make([]byte, len(s)+2*n)
j := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case ';', ',', '?':
case ';', ',', '?', '#':
t[j] = '%'
t[j+1] = upperhex[s[i]>>4]
t[j+2] = upperhex[s[i]&15]
@@ -172,21 +189,22 @@ func (p *Page) Path() string {
j++
}
}
return string(t);
return string(t)
}
// Dir returns the directory part of the page name. It's either the empty string if the page is in the Oddmu working
// directory, or it ends in a slash. This is used to create the upload link in "view.html", for example.
// Dir returns the directory part of the page name, percent-escaped except for the slashes. It's either the empty string
// if the page is in the Oddmu working directory, or it ends in a slash. This is used to create the upload link in
// "view.html", for example.
func (p *Page) Dir() string {
d := path.Dir(p.Name)
if d == "." {
return ""
}
return d + "/"
return pathEncode(d) + "/"
}
// Base returns the basename of the page name: no directory. This is used to create the upload link in "view.html", for
// example.
// Base returns the basename of the page name: no directory, percent-escaped except for the slashes. This is used to
// create the upload link in "view.html", for example.
func (p *Page) Base() string {
n := path.Base(p.Name)
if n == "." {
@@ -217,7 +235,7 @@ func (p *Page) Parents() []*Link {
if !ok {
title = "…"
}
link := &Link{Title: title, Url: strings.Repeat("../", len(elems)-i-1) + "index"}
link := &Link{Title: title, URL: strings.Repeat("../", len(elems)-i-1) + "index"}
links = append(links, link)
s += elems[i] + "/"
}

View File

@@ -1,9 +1,10 @@
package main
import (
"github.com/stretchr/testify/assert"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPageTitle(t *testing.T) {
@@ -58,12 +59,12 @@ And untouchable`)}
// "testdata/parents/children/something/index" is a sibling and doesn't count!
parents := p.Parents()
assert.Equal(t, "Welcome to Oddμ", parents[0].Title)
assert.Equal(t, "../../../../index", parents[0].Url)
assert.Equal(t, "../../../../index", parents[0].URL)
assert.Equal(t, "…", parents[1].Title)
assert.Equal(t, "../../../index", parents[1].Url)
assert.Equal(t, "../../../index", parents[1].URL)
assert.Equal(t, "Solar", parents[2].Title)
assert.Equal(t, "../../index", parents[2].Url)
assert.Equal(t, "../../index", parents[2].URL)
assert.Equal(t, "Lunar", parents[3].Title)
assert.Equal(t, "../index", parents[3].Url)
assert.Equal(t, "../index", parents[3].URL)
assert.Equal(t, 4, len(parents))
}

View File

@@ -2,12 +2,13 @@ package main
import (
"bytes"
"net/url"
"path"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"net/url"
"path"
)
// wikiLink returns an inline parser function. This indirection is
@@ -89,12 +90,12 @@ func wikiRenderer() *html.Renderer {
return renderer
}
// renderHtml renders the Page.Body to HTML and sets Page.Html, Page.Hashtags, and escapes Page.Name.
func (p *Page) renderHtml() {
// renderHTML renders the Page.Body to HTML and sets Page.HTML, Page.Hashtags, and escapes Page.Name.
func (p *Page) renderHTML() {
parser, hashtags := wikiParser()
renderer := wikiRenderer()
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.HTML = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
}
@@ -133,8 +134,7 @@ func (p *Page) images() []ImageData {
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Image:
if v, ok := node.(*ast.Image); ok {
// not an absolute URL, not a full URL, not a mailto: URI
text := toString(v)
if len(text) > 0 {
@@ -164,8 +164,7 @@ func toString(node ast.Node) string {
b := new(bytes.Buffer)
ast.WalkFunc(node, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Text:
if v, ok := node.(*ast.Text); ok {
b.Write(v.Literal)
}
}

View File

@@ -1,8 +1,9 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPagePlainText(t *testing.T) {
@@ -19,14 +20,14 @@ func TestPageHtml(t *testing.T) {
Silver leaves shine bright
They droop, boneless, weak and sad
A cruel sun stares down`)}
p.renderHtml()
p.renderHTML()
r := `<h1 id="sun">Sun</h1>
<p>Silver leaves shine bright
They droop, boneless, weak and sad
A cruel sun stares down</p>
`
assert.Equal(t, r, string(p.Html))
assert.Equal(t, r, string(p.HTML))
}
func TestPageHtmlHashtag(t *testing.T) {
@@ -36,7 +37,7 @@ Too faint to focus, so far
I am cold, alone
#Haiku #Cold_Poets`)}
p.renderHtml()
p.renderHTML()
r := `<h1 id="comet">Comet</h1>
<p>Stars flicker above
@@ -45,7 +46,7 @@ I am cold, alone</p>
<p><a class="tag" href="/search/?q=%23Haiku">#Haiku</a> <a class="tag" href="/search/?q=%23Cold_Poets">#Cold Poets</a></p>
`
assert.Equal(t, r, string(p.Html))
assert.Equal(t, r, string(p.HTML))
}
func TestPageHtmlHashtagCornerCases(t *testing.T) {
@@ -53,13 +54,13 @@ func TestPageHtmlHashtagCornerCases(t *testing.T) {
ok # #o #ok
[oh #ok \#nok](ok)`)}
p.renderHtml()
p.renderHTML()
r := `<p>#</p>
<p>ok # <a class="tag" href="/search/?q=%23o">#o</a> <a class="tag" href="/search/?q=%23ok">#ok</a>
<a href="ok">oh #ok #nok</a></p>
`
assert.Equal(t, r, string(p.Html))
assert.Equal(t, r, string(p.HTML))
}
func TestPageHtmlWikiLink(t *testing.T) {
@@ -67,14 +68,14 @@ func TestPageHtmlWikiLink(t *testing.T) {
Blue and green and black
Sky and grass and [ragged cliffs](cliffs)
Our [[time together]]`)}
p.renderHtml()
p.renderHTML()
r := `<h1 id="photos-and-books">Photos and Books</h1>
<p>Blue and green and black
Sky and grass and <a href="cliffs">ragged cliffs</a>
Our <a href="time%20together">time together</a></p>
`
assert.Equal(t, r, string(p.Html))
assert.Equal(t, r, string(p.HTML))
}
func TestPageHtmlDollar(t *testing.T) {
@@ -82,34 +83,34 @@ func TestPageHtmlDollar(t *testing.T) {
Dragonfly hovers
darts chases turns lands and rests
A mighty jewel`)}
p.renderHtml()
p.renderHTML()
r := `<h1 id="no-dollar-can-buy-this">No $dollar$ can buy this</h1>
<p>Dragonfly hovers
darts chases turns lands and rests
A mighty jewel</p>
`
assert.Equal(t, r, string(p.Html))
assert.Equal(t, r, string(p.HTML))
}
func TestLazyLoadImages(t *testing.T) {
p := &Page{Body: []byte(`![](test.jpg)`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "lazy")
p.renderHTML()
assert.Contains(t, string(p.HTML), "lazy")
}
// The fractions available in Latin 1 (?) are rendered.
func TestFractions(t *testing.T) {
p := &Page{Body: []byte(`1/4`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "&frac14;")
p.renderHTML()
assert.Contains(t, string(p.HTML), "&frac14;")
}
// Other fractions are not rendered.
func TestNoFractions(t *testing.T) {
p := &Page{Body: []byte(`1/6`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "1/6")
p.renderHTML()
assert.Contains(t, string(p.HTML), "1/6")
}
// webfinger
@@ -119,17 +120,17 @@ func TestAt(t *testing.T) {
// prevent lookups
accounts.Lock()
accounts.uris = make(map[string]string)
accounts.uris["alex@alexschroeder.ch"] = "https://social.alexschroeder.ch/@alex";
accounts.uris["alex@alexschroeder.ch"] = "https://social.alexschroeder.ch/@alex"
accounts.Unlock()
// test account
p := &Page{Body: []byte(`My fedi handle is @alex@alexschroeder.ch.`)}
p.renderHtml()
assert.Contains(t,string(p.Html),
p.renderHTML()
assert.Contains(t, string(p.HTML),
`My fedi handle is <a class="account" href="https://social.alexschroeder.ch/@alex" title="@alex@alexschroeder.ch">@alex</a>.`)
// test escaped account
p = &Page{Body: []byte(`My fedi handle is \@alex@alexschroeder.ch. \`)}
p.renderHtml()
assert.Contains(t,string(p.Html),
p.renderHTML()
assert.Contains(t, string(p.HTML),
`My fedi handle is @alex@alexschroeder.ch.`)
// disable webfinger
useWebfinger = false

View File

@@ -10,11 +10,16 @@ import (
// otherwise the rendered template has garbage bytes at the end. Note also that we need to remove the title from the
// page so that the preview works as intended (and much like the "view.html" template) where as the editing requires the
// page content including the header… which is why it needs to be added in the "preview.html" template. This makes me
// sad.
// sad. While viewing the preview, links will point to the /preview path. In order to handle this, regular GET requests
// are passed on the the {viewHandler}.
func previewHandler(w http.ResponseWriter, r *http.Request, path string) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/view/"+strings.TrimPrefix(path, "/preview/"), http.StatusFound)
return
}
body := strings.ReplaceAll(r.FormValue("body"), "\r", "")
p := &Page{Name: path, Body: []byte(body)}
p.handleTitle(true)
p.renderHtml()
p.renderHTML()
renderTemplate(w, p.Dir(), "preview", p)
}

View File

@@ -3,18 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Preview: {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
@@ -23,7 +23,7 @@ img { max-width: 100%; }
</header>
<main>
<h1>Previewing {{.Title}}</h1>
{{.Html}}
{{.HTML}}
</main>
<hr>
<section id="edit">

View File

@@ -1,9 +1,11 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPreview(t *testing.T) {
@@ -12,6 +14,6 @@ func TestPreview(t *testing.T) {
data := url.Values{}
data.Set("body", "**Hallo**!")
r := assert.HTTPBody(makeHandler(previewHandler, false), "POST", "/view/testdata/preview/alex", data)
r := assert.HTTPBody(makeHandler(previewHandler, false, http.MethodGet), "POST", "/view/testdata/preview/alex", data)
assert.Contains(t, r, "<strong>Hallo</strong>!")
}

View File

@@ -4,10 +4,6 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"io"
"io/fs"
"os"
@@ -15,6 +11,11 @@ import (
"regexp"
"slices"
"strings"
"github.com/google/subcommands"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
)
type replaceCmd struct {
@@ -63,9 +64,8 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
return nil
}
// skipp all but page files
if !strings.HasSuffix(fp, ".md") {
@@ -80,14 +80,14 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
changes++
if isConfirmed {
fmt.Fprintln(w, fp)
_ = os.Rename(fp, fp + "~")
_ = os.Rename(fp, fp+"~")
err = os.WriteFile(fp, result, 0644)
if err != nil {
return err
}
} else {
edits := myers.ComputeEdits(span.URIFromPath(fp + "~"), string(body), string(result))
diff := fmt.Sprint(gotextdiff.ToUnified(fp + "~", fp, string(body), edits))
edits := myers.ComputeEdits(span.URIFromPath(fp+"~"), string(body), string(result))
diff := fmt.Sprint(gotextdiff.ToUnified(fp+"~", fp, string(body), edits))
fmt.Fprintln(w, diff)
}
}

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestReplaceCmd(t *testing.T) {

View File

@@ -67,11 +67,12 @@ func sortNames(tokens []string) func(a, b string) int {
na := unicode.IsNumber(ra)
rb, _ := utf8.DecodeRuneInString(b)
nb := unicode.IsNumber(rb)
if na && !nb {
switch {
case na && !nb:
return -1
} else if !na && nb {
case !na && nb:
return 1
} else if na && nb {
case na && nb:
if a < b {
return 1
} else if a > b {
@@ -99,7 +100,7 @@ func sortNames(tokens []string) func(a, b string) int {
// results.
const itemsPerPage = 20
// search returns a sorted []Page where each page contains an extract of the actual Page.Body in its Page.Html. Page
// search returns a sorted []Page where each page contains an extract of the actual Page.Body in its Page.HTML. Page
// size is 20. Specify either the page number to return, or that all the results should be returned. Only ask for all
// results if runtime is not an issue, like on the command line. The boolean return value indicates whether there are
// more results.
@@ -140,7 +141,7 @@ func search(q, dir, filter string, page int, all bool) ([]*Result, bool) {
if strings.Contains(title, term) {
re, err := re(term)
if err == nil {
img.Html = template.HTML(highlight(re, img.Title))
img.HTML = template.HTML(highlight(re, img.Title))
}
res = append(res, img)
continue ImageLoop
@@ -199,14 +200,15 @@ func filterNames(names, predicates []string) []string {
defer index.RUnlock()
for _, predicate := range predicates {
r := make([]string, 0)
if strings.HasPrefix(predicate, "title:") {
switch {
case strings.HasPrefix(predicate, "title:"):
token := predicate[6:]
for _, name := range names {
if strings.Contains(strings.ToLower(index.titles[name]), token) {
r = append(r, name)
}
}
} else if predicate == "blog:true" || predicate == "blog:false" {
case predicate == "blog:true" || predicate == "blog:false":
blog := predicate == "blog:true"
re := regexp.MustCompile(`(^|/)\d\d\d\d-\d\d-\d\d`)
for _, name := range names {
@@ -215,7 +217,7 @@ func filterNames(names, predicates []string) []string {
r = append(r, name)
}
}
} else {
default:
log.Printf("Unsupported predicate: %s", predicate)
}
names = intersection(names, r)
@@ -300,3 +302,9 @@ func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
Results: len(items) > 0, More: more}
renderTemplate(w, dir, "search", s)
}
// Path returns the ImageData.Name with some characters escaped because html/template doesn't escape those. This is
// suitable for use in HTML templates.
func (img *ImageData) Path() string {
return pathEncode(img.Name)
}

View File

@@ -3,24 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small; }
.image img { max-width: 100%; }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
@@ -40,9 +40,9 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
<blockquote>{{.HTML}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.HTML}}
{{end}}
</article>
{{end}}

View File

@@ -4,14 +4,15 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"github.com/muesli/reflow/wordwrap"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/google/subcommands"
"github.com/muesli/reflow/wordwrap"
)
type searchCmd struct {
@@ -70,14 +71,15 @@ func searchCli(w io.Writer, cmd *searchCmd, args []string) subcommands.ExitStatu
fmt.Fprint(os.Stderr, " results\n")
}
}
if cmd.extract {
switch {
case cmd.extract:
searchExtract(w, items)
} else if cmd.files {
case cmd.files:
for _, p := range items {
name := filepath.FromSlash(p.Name) + ".md\n"
fmt.Fprintf(w, name)
}
} else {
default:
for _, p := range items {
name := p.Name
if strings.HasPrefix(name, dir) {
@@ -98,7 +100,7 @@ func searchExtract(w io.Writer, items []*Result) {
match := func(s string) string { return "\x1b[1m" + s + "\x1b[0m" } // bold
re := regexp.MustCompile(`<b>(.*?)</b>`)
for _, p := range items {
s := re.ReplaceAllString(string(p.Html), match(`$1`))
s := re.ReplaceAllString(string(p.HTML), match(`$1`))
fmt.Fprintln(w, heading(p.Title))
if p.Name != p.Title {
fmt.Fprintln(w, p.Name)

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestSearchCmd(t *testing.T) {

View File

@@ -2,10 +2,12 @@ package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"slices"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSortNames(t *testing.T) {
@@ -63,15 +65,15 @@ func TestSearch(t *testing.T) {
data := url.Values{}
data.Set("q", "oddμ")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
assert.Contains(t, body, `<span class="score">5</span>`)
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata", data)
assert.NotContains(t, body, "Welcome")
data.Set("q", "'create a new page'")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
}
@@ -158,16 +160,16 @@ Where is lady luck?`)}
data := url.Values{}
data.Set("q", "luck")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/dir", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/other", data)
assert.Contains(t, body, "No results")
}
@@ -261,7 +263,7 @@ Please call me, my love.
assert.NotEmpty(t, items[0].Images)
assert.Equal(t, "phone call", items[0].Images[0].Title)
assert.Equal(t, "phone <b>call</b>", string(items[0].Images[0].Html))
assert.Equal(t, "phone <b>call</b>", string(items[0].Images[0].HTML))
assert.Equal(t, "testdata/images/2024-07-21.jpg", items[0].Images[0].Name)
assert.Empty(t, items[1].Images)
@@ -277,7 +279,7 @@ The silence streches.`)}
p.save()
data := url.Values{}
data.Set("q", "look")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/question/", data)
assert.Contains(t, body, "We <b>look</b>")
assert.NotContains(t, body, "Odd?")
assert.Contains(t, body, "Even?")

53
sitemap.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"regexp"
)
type SitemapURL struct {
Loc string
}
type Sitemap struct {
URL []*SitemapURL
}
// sitemapHandler lists all the pages. See https://www.sitemaps.org/protocol.html for more. It takes the
// ODDMU_FILTER environment variable into account.
func sitemapHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sitemap.xml" {
http.NotFound(w, r)
} else {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n"))
scheme := "http"
if r.TLS != nil {
scheme += "s"
}
base := fmt.Sprintf("%s://%s/view/", scheme, r.Host)
filter := os.Getenv("ODDMU_FILTER")
renderTemplate(w, ".", "sitemap", sitemap(&index, base, filter))
}
}
// sitemap generates the list of URLs. A reference to the index needs to be provided to make it easier to write
// tests. Exclude pages matching the filter.
func sitemap(idx *indexStore, base, filter string) Sitemap {
url := make([]*SitemapURL, 0)
re, err := regexp.Compile(filter)
if err != nil {
log.Println("ODDMU_FILTER does not compile:", filter, err)
return Sitemap{URL: url}
}
idx.RLock()
defer idx.RUnlock()
for name := range idx.titles {
if filter == "" || !re.MatchString(name) {
url = append(url, &SitemapURL{Loc: base + name})
}
}
return Sitemap{URL: url}
}

3
sitemap.html Normal file
View File

@@ -0,0 +1,3 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{range .URL}}<url><loc>{{.Loc}}</loc></url>
{{end}}</urlset>

62
sitemap_cmd.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"context"
"fmt"
"flag"
"io"
"log"
"os"
"github.com/google/subcommands"
)
type sitemapCmd struct {
base string
filter string
}
func (cmd *sitemapCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&cmd.base, "base", "http://localhost:8080/view/", "the base URL for the sitemap")
f.StringVar(&cmd.filter, "filter", "", "a regular expression to filter pages")
}
func (*sitemapCmd) Name() string { return "sitemap" }
func (*sitemapCmd) Synopsis() string { return "list all the pages known in Sitemap format" }
func (*sitemapCmd) Usage() string {
return `sitemap [-base URL] [-filter regex]:
Print all the pages known in Sitemap format.
See https://www.sitemaps.org/ for more.
`
}
func (cmd *sitemapCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
n, err := index.load()
if err != nil {
fmt.Fprintf(os.Stderr, "Index load: %s\n", err)
return subcommands.ExitFailure
}
fmt.Fprintf(os.Stderr, "Indexed %d pages\n", n)
return sitemapCli(os.Stdout, &index, cmd.base, cmd.filter)
}
// sitemapCli implements the printing of a Sitemap. In order to make testing easier, it takes a Writer and an
// indexStore. The Writer is important so that test code can provide a buffer instead of os.Stdout; the indexStore
// is important so that test code can ensure no other test running in parallel can interfere with the list of known
// pages (by adding or deleting pages).
func sitemapCli(w io.Writer, idx *indexStore, base, filter string) subcommands.ExitStatus {
loadTemplates()
template := "sitemap.html"
t := templates.template[template]
if t == nil {
log.Println("Template not found:", template)
return subcommands.ExitFailure
}
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n"))
err := t.Execute(w, sitemap(idx, base, filter))
if err != nil {
log.Println(err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

18
sitemap_cmd_test.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
func TestSitemapCmd(t *testing.T) {
b := new(bytes.Buffer)
s := sitemapCli(b, minimalIndex(t), "https://example.org/view/", "^themes/")
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), "https://example.org/view/index")
assert.Contains(t, b.String(), "https://example.org/view/README")
assert.NotContains(t, b.String(), "https://example.org/view/themes/")
}

View File

@@ -90,9 +90,9 @@ func snippets(q string, s string) string {
}
}
t = s[start:end]
res = res + t
res += t
if len(s) > end {
res = res + " …"
res += " …"
}
// truncate text to avoid rematching the same string.
s = s[end:]

View File

@@ -1,8 +1,9 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSnippets(t *testing.T) {

View File

@@ -3,23 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
{{.HTML}}
</main>
<footer>
<address>

View File

@@ -5,10 +5,8 @@ import (
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/google/subcommands"
"image/jpeg"
"io"
"io/fs"
"net/url"
"os"
@@ -16,14 +14,31 @@ import (
"slices"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/edwvee/exiffix"
"github.com/gen2brain/webp"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/google/subcommands"
)
var shrinkWidth = 800
var shrinkQuality = 30
type staticCmd struct {
jobs int
jobs int
shrink bool
glob string
verbose bool
}
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
f.IntVar(&cmd.jobs, "jobs", 2, "how many jobs to use")
f.BoolVar(&cmd.shrink, "shrink", false, "shrink images by decreasing the quality")
f.StringVar(&cmd.glob, "glob", "", "only export files matching this shell file name pattern")
f.BoolVar(&cmd.verbose, "verbose", false, "print the files as they are being processed")
}
func (*staticCmd) Name() string { return "static" }
@@ -38,11 +53,11 @@ func (*staticCmd) Usage() string {
func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
args := f.Args()
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "Exactly one target directory is required")
fmt.Fprintln(os.Stderr, "Exactly one target directory is require", args)
return subcommands.ExitFailure
}
dir := filepath.Clean(args[0])
return staticCli(".", dir, cmd.jobs, false)
return staticCli(".", dir, cmd.jobs, cmd.glob, cmd.shrink, cmd.verbose, false)
}
type args struct {
@@ -52,20 +67,20 @@ type args struct {
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
// tests. The source directory cannot be set from the command-line. The current directory (".") is assumed.
func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStatus {
func staticCli(source, target string, jobs int, glob string, shrink, verbose, quiet bool) subcommands.ExitStatus {
index.load()
index.RLock()
defer index.RUnlock()
loadLanguages()
loadTemplates()
tasks := make(chan args)
results := make(chan error)
done := make(chan bool)
tasks := make(chan args, 10000)
results := make(chan error, jobs)
done := make(chan bool, jobs)
stop := make(chan error)
for i := 0; i < jobs; i++ {
go staticWorker(tasks, results, done)
go staticWorker(tasks, results, done, shrink, verbose)
}
go staticWalk(source, target, tasks, stop)
go staticWalk(source, target, glob, tasks, stop)
go staticWatch(jobs, results, done)
n, err := staticProgressIndicator(results, stop, quiet)
if !quiet {
@@ -81,13 +96,15 @@ func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStat
// staticWalk walks the source directory tree. Any directory it finds, it recreates in the target directory. Any file it
// finds, it puts into the tasks channel for the staticWorker. When the directory walk is finished, the tasks channel is
// closed. If there's an error on the stop channel, the walk returns that error.
func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
func staticWalk(source, target, glob string, tasks chan (args), stop chan (error)) {
// The error returned here is what's in the stop channel but at the very end, a worker might return an error
// even though the walk is already done. This is why we cannot rely on the return value of the walk.
filepath.Walk(source, func(fp string, info fs.FileInfo, err error) error {
n := 0
err := filepath.Walk(source, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// don't wait for the stop channel
select {
case err := <-stop:
return err
@@ -96,14 +113,30 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
return nil
}
// skip backup files, avoid recursion
if strings.HasSuffix(fp, "~") || strings.HasPrefix(fp, target) {
return nil
}
// skip templates
if slices.Contains(templateFiles, filepath.Base(fp)) {
return nil
}
// skip files that don't match the glob, if set
if fp != "." && glob != "" {
match, err := filepath.Match(glob, fp)
if err != nil {
return err // abort
}
if !match {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
// determine the actual target: if source is a/ and target is b/ and path is a/file, then the
// target is b/file
var actualTarget string
@@ -115,18 +148,32 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
}
actualTarget = filepath.Join(target, fp[len(source):])
}
// recreate subdirectories
// recreate subdirectories, ignore existing ones
if info.IsDir() {
return os.Mkdir(actualTarget, 0755)
os.Mkdir(actualTarget, 0755)
return nil
}
// Markdown files end up as HTML files
if strings.HasSuffix(actualTarget, ".md") {
actualTarget = actualTarget[:len(actualTarget)-3] + ".html"
}
// do the task if the target file doesn't exist or if the source file is newer
other, err := os.Stat(actualTarget)
if err != nil || info.ModTime().After(other.ModTime()) {
if err == nil {
fmt.Println(fp, info.ModTime(), other.ModTime(), info.ModTime().After(other.ModTime()))
}
n++
tasks <- args{source: fp, target: actualTarget, info: info}
}
return nil
}
})
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("\r%d files to process\n", n)
}
close(tasks)
}
@@ -141,55 +188,111 @@ func staticWatch(jobs int, results chan (error), done chan (bool)) {
// staticWorker takes arguments off the tasks channel (the file to process) and put results in the results channel (any
// errors encountered); when they're done they send true on the done channel.
func staticWorker(tasks chan (args), results chan (error), done chan (bool)) {
func staticWorker(tasks chan (args), results chan (error), done chan (bool), shrink, verbose bool) {
task, ok := <-tasks
for ok {
results <- staticFile(task.source, task.target, task.info)
if verbose {
fmt.Println(task.source)
}
results <- staticFile(task.source, task.target, task.info, shrink)
task, ok = <-tasks
}
done <- true
}
// staticProgressIndicator watches the results channel and does a countdown. If the result channel reports an error,
// that is put into the stop channel so that staticWalk stops adding to the tasks channel.
// staticProgressIndicator watches the results channel and prints a running count. If the result channel reports an
// error, that is put into the stop channel so that staticWalk stops adding to the tasks channel.
func staticProgressIndicator(results chan (error), stop chan (error), quiet bool) (int, error) {
n := 0
t := time.Now()
var err error
for result := range results {
if result != nil {
err := result
// this stops the walker from adding more tasks
stop <- err
} else {
n++
if !quiet && n%13 == 0 {
if time.Since(t) > time.Second {
fmt.Printf("\r%d", n)
t = time.Now()
}
err, ok := <-results
for ok && err == nil {
n++
if !quiet && n%13 == 0 {
if time.Since(t) > time.Second {
fmt.Printf("\r%d", n)
t = time.Now()
}
}
err, ok = <-results
}
if ok && err != nil {
// this stops the walker from adding more tasks
stop <- err
}
return n, err
}
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
// subdirectories, link files, render HTML files.
func staticFile(source, target string, info fs.FileInfo) error {
func staticFile(source, target string, info fs.FileInfo, shrink bool) error {
// render pages
if strings.HasSuffix(source, ".md") {
p, err := staticPage(source[:len(source)-3], target[:len(target)-3]+".html")
// target already has ".html" extension
p, err := staticPage(source[:len(source)-3], target)
if err != nil {
return err
}
return staticFeed(source[:len(source)-3], target[:len(target)-3]+".rss", p, info.ModTime())
return staticFeed(source[:len(source)-3], target[:len(target)-5]+".rss", p, info.ModTime())
}
// remaining files are linked unless this is a template
if slices.Contains(templateFiles, filepath.Base(source)) {
if shrink {
switch filepath.Ext(source) {
case ".jpg", ".jpeg", ".webp":
return shrinkImage(source, target, info)
}
}
// delete before linking, ignore errors
os.Remove(target)
err := os.Link(source, target)
if err == nil {
return nil
}
return os.Link(source, target)
// in case of invalid cross-device link error, copy file instead
src, err := os.Open(source)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(target)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
// shrink Image shrinks images down and reduces the quality dramatically.
func shrinkImage(source, target string, info fs.FileInfo) error {
file, err := os.Open(source)
if err != nil {
return err
}
defer file.Close()
img, _, err := exiffix.Decode(file)
if err != nil {
return fmt.Errorf("%s cannot be decoded", source)
}
if img.Bounds().Dx() > shrinkWidth {
res := imaging.Resize(img, shrinkWidth, 0, imaging.Lanczos) // preserve aspect ratio
// imaging functions don't return errors but empty images…
if res.Rect.Empty() {
return fmt.Errorf("%s cannot be resized", source)
}
img = res
}
dst, err := os.Create(target)
if err != nil {
return err
}
defer dst.Close()
switch filepath.Ext(source) {
case ".jpg", ".jpeg":
err = jpeg.Encode(dst, img, &jpeg.Options{Quality: shrinkQuality})
case ".webp":
err = webp.Encode(dst, img, webp.Options{Quality: shrinkQuality})
}
return err
}
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
@@ -200,7 +303,7 @@ func staticPage(source, target string) (*Page, error) {
return nil, err
}
p.handleTitle(true)
// instead of p.renderHtml() we do it all ourselves, appending ".html" to all the local links
// instead of p.renderHTML() we do it all ourselves, appending ".html" to all the local links
parser, hashtags := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, staticLinks)
@@ -210,7 +313,7 @@ func staticPage(source, target string) (*Page, error) {
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.HTML = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
return p, write(p, target, "", "static.html")
}
@@ -221,7 +324,7 @@ func staticFeed(source, target string, p *Page, ti time.Time) error {
base := filepath.Base(source)
_, ok := index.token[strings.ToLower(base)]
if base == "index" || ok {
f := feed(p, ti)
f := feed(p, ti, 0, 10, ModTime)
if len(f.Items) > 0 {
return write(f, target, `<?xml version="1.0" encoding="UTF-8"?>`, "feed.html")
}
@@ -232,8 +335,7 @@ func staticFeed(source, target string, p *Page, ti time.Time) error {
// staticLinks checks a node and if it is a link to a local page, it appends ".html" to the link destination.
func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
if v, ok := node.(*ast.Link); ok {
// not an absolute URL, not a full URL, not a mailto: URI
if !bytes.HasPrefix(v.Destination, []byte("/")) &&
!bytes.Contains(v.Destination, []byte("://")) &&

View File

@@ -1,15 +1,16 @@
package main
import (
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"os"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
func TestStaticCmd(t *testing.T) {
cleanup(t, "testdata/static")
s := staticCli(".", "testdata/static", 2, true)
s := staticCli(".", "testdata/static", 2, "", false, false, true)
assert.Equal(t, subcommands.ExitSuccess, s)
// pages
assert.FileExists(t, "testdata/static/index.html")
@@ -34,7 +35,7 @@ And the cars so loud
`)}
h.save()
h.notify()
s := staticCli("testdata/static-feed", "testdata/static-feed-out", 2, true)
s := staticCli("testdata/static-feed", "testdata/static-feed-out", 2, "", false, false, true)
assert.Equal(t, subcommands.ExitSuccess, s)
assert.FileExists(t, "testdata/static-feed-out/2024-03-07-poem.html")
assert.FileExists(t, "testdata/static-feed-out/Haiku.html")

View File

@@ -11,11 +11,11 @@ import (
"sync"
)
// templateFiles are the various HTML template files used. These files must exist in the root directory for Oddmu to be
// able to generate HTML output. This always requires a template.
// templateFiles are the various HTML template files used. These files must exist in the root directory for Oddmu
// to be able to generate HTML output. This always requires a template.
var templateFiles = []string{"edit.html", "add.html", "view.html", "preview.html",
"diff.html", "search.html", "static.html", "upload.html", "feed.html",
"list.html" }
"sitemap.html"}
// templateStore controls access to map of parsed HTML templates. Make sure to lock and unlock as appropriate. See
// renderTemplate and loadTemplates.

View File

@@ -2,9 +2,11 @@ package main
import (
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTemplates(t *testing.T) {
@@ -18,13 +20,12 @@ Memories of cold
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil),
"Skip navigation")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/snow", nil), "Skip")
// save a new view handler
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
html := "<body><h1>{{.Title}}</h1>{{.HTML}}"
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
field, err := writer.CreateFormField("filename")
assert.NoError(t, err)
field.Write([]byte("view.html"))
file, err := writer.CreateFormFile("file", "test.html")
@@ -33,18 +34,17 @@ Memories of cold
assert.NoError(t, err)
assert.Equal(t, len(html), n)
writer.Close()
HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/templates/", writer.FormDataContentType(), form)
HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/templates/", writer.FormDataContentType(), form)
assert.FileExists(t, "view.html", "original view.html still exists")
assert.FileExists(t, "testdata/templates/view.html", "new view.html also exists")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/view.html", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/view.html", nil),
html)
// verify that it works
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/snow", nil)
assert.Contains(t, body, "<h1>Snow</h1>")
assert.NotContains(t, body, "Skip")
// verify that the top level still uses the old template
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
"Skip navigation")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index", nil), "Skip")
}

View File

@@ -1,16 +1,17 @@
download:
rsync --archive --delete --itemize-changes --exclude='*-*' sibirocobombus:alexschroeder.ch/wiki/'*.html' alexschroeder.ch/
rsync --archive sibirocobombus:alexschroeder.ch/css/oddmu-2023.css alexschroeder.ch/oddmu.css
sed --in-place=~ --expression='s/\/css\/oddmu-2023\.css/oddmu.css/' alexschroeder.ch/*.html
rsync --archive --delete --itemize-changes sibirocobombus:flying-carpet.ch/wiki/'*.html' flying-carpet.ch/
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
SHELL=/usr/bin/fish
# Manually figure out what needs to change:
# (ediff-directories "alexschroeder.ch" "/ssh:sibirocobombus:alexschroeder.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root|sudo:claudia@sibirocobombus.root:/home/alex/flying-carpet.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root:/home/claudia/flying-carpet.ch/wiki/" "html$") + fix permissions
# (ediff-directories "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.org/data/" "html$")
# (ediff-directories "communitywiki.org" "/ssh:sibirocobombus:communitywiki.org/data/" "html$")
# (ediff-directories "transjovian.org" "/ssh:sibirocobombus.root:/home/oddmu/" "html$")
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
# (ediff-directories "communitywiki.org" ".." "html$")
# Upload the theme for the web site. This does not deploy the themes!
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' \
. sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
@echo Updated the templates for the Oddmu site

Some files were not shown because too many files have changed in this diff Show More