forked from mirror/oddmu
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991260b78c | ||
|
|
751b9fe63d | ||
|
|
34afc151a4 | ||
|
|
efbea969fa | ||
|
|
e12ab8594c | ||
|
|
233b9817b5 | ||
|
|
ecfee31cbd | ||
|
|
e86de1beb8 | ||
|
|
e9666a5ec5 | ||
|
|
f71a0e9780 | ||
|
|
aca1d82fe0 | ||
|
|
290ad16e09 | ||
|
|
ad1732f57f | ||
|
|
67120af7cc | ||
|
|
c186253a25 | ||
|
|
d0d4545f74 | ||
|
|
c32f087af4 | ||
|
|
f657ac60a3 | ||
|
|
16ae6cc143 | ||
|
|
e041c5ecae | ||
|
|
3078d63890 | ||
|
|
143ecb8a0a | ||
|
|
d66aa03a2d | ||
|
|
64954ddf5d | ||
|
|
a1d6ebfdff | ||
|
|
db3a3f5009 | ||
|
|
ece9649e3d | ||
|
|
23074cdd58 | ||
|
|
06c07209a2 | ||
|
|
7b2a835729 | ||
|
|
d0fe534f8e | ||
|
|
ac7de17a87 | ||
|
|
84e6a757b2 | ||
|
|
2dfb2afbf5 | ||
|
|
2092b5777c | ||
|
|
f635cb738a | ||
|
|
da398a3315 | ||
|
|
7315abd5bb | ||
|
|
b39901b244 | ||
|
|
bb4843c2f4 | ||
|
|
816c981200 | ||
|
|
89d550a1a4 | ||
|
|
4eb013a4da | ||
|
|
e8f6ae0450 | ||
|
|
9bf3beb440 | ||
|
|
cd6809d791 | ||
|
|
7c5a3860e7 | ||
|
|
a7c343decb | ||
|
|
18bb5da8c0 | ||
|
|
2a0ea791ec | ||
|
|
726586b39d | ||
|
|
8f30704be9 | ||
|
|
616ae0a1ba | ||
|
|
af86b865bf | ||
|
|
7110e0af68 | ||
|
|
8841372814 | ||
|
|
fefa283775 | ||
|
|
5a09d65dab | ||
|
|
2cf0855994 | ||
|
|
f98312e12f | ||
|
|
d213ee2815 | ||
|
|
0cd09666c6 | ||
|
|
bd9364dc09 | ||
|
|
93fd49bc4c | ||
|
|
300e411960 | ||
|
|
10cea2bf2c | ||
|
|
830af140eb | ||
|
|
c758dd7df7 | ||
|
|
969df2aef9 | ||
|
|
39f414694c | ||
|
|
fa67508692 | ||
|
|
d5696135c1 | ||
|
|
284fc3094d | ||
|
|
57161bbc98 | ||
|
|
d855d9d91a | ||
|
|
ca85250514 | ||
|
|
649fde81fe | ||
|
|
8a47e9c5fe | ||
|
|
fd9a515e0f | ||
|
|
da04c6dc27 | ||
|
|
bd2da1414c | ||
|
|
6d1a5462b4 | ||
|
|
3dcaf8aca1 | ||
|
|
80ce16f873 | ||
|
|
41347ad5dc | ||
|
|
6a911b2860 | ||
|
|
1d6db77660 | ||
|
|
8a8afcb56f | ||
|
|
6803b8e90d | ||
|
|
ff357a4048 |
26
Makefile
26
Makefile
@@ -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
|
||||
|
||||
60
README.md
60
README.md
@@ -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
|
||||
|
||||
8
RELEASE
8
RELEASE
@@ -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
|
||||
|
||||
16
accounts.go
16
accounts.go
@@ -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 {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWebfingerParsing(t *testing.T) {
|
||||
|
||||
11
add.html
11
add.html
@@ -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">
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
97
changes.go
97
changes.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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~")
|
||||
}
|
||||
|
||||
7
diff.go
7
diff.go
@@ -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~"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
11
edit.html
11
edit.html
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
115
feed.go
@@ -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
|
||||
}
|
||||
|
||||
12
feed.html
12
feed.html
@@ -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}}&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}}&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
90
feed_cmd.go
Normal 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
26
feed_cmd_test.go
Normal 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
|
||||
}
|
||||
157
feed_test.go
157
feed_test.go
@@ -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&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&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&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&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&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&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
4
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
151
hashtags_cmd.go
151
hashtags_cmd.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashtagsCmd(t *testing.T) {
|
||||
|
||||
41
html_cmd.go
41
html_cmd.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHtmlCmd(t *testing.T) {
|
||||
|
||||
25
index.go
25
index.go
@@ -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++
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIndexAdd(t *testing.T) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAllLanguage(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
108
list.go
@@ -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)
|
||||
}
|
||||
59
list.html
59
list.html
@@ -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>
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
)
|
||||
|
||||
type listCmd struct {
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListCmd(t *testing.T) {
|
||||
|
||||
30
list_test.go
30
list_test.go
@@ -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">`)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
59
man/oddmu-feed.1
Normal 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
48
man/oddmu-feed.1.txt
Normal 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>.
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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}}&n={{\&.N}}"/>
|
||||
{{end}}
|
||||
{{if \&.Next}}
|
||||
<atom:link rel="next" type="application/rss+xml"
|
||||
href="https://example\&.org/view/{{\&.Path}}\&.rss?from={{\&.Next}}&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
|
||||
|
||||
@@ -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}}&n={{.N}}"/>
|
||||
{{end}}
|
||||
{{if .Next}}
|
||||
<atom:link rel="next" type="application/rss+xml"
|
||||
href="https://example.org/view/{{.Path}}.rss?from={{.Next}}&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
49
man/oddmu-sitemap.1
Normal 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
38
man/oddmu-sitemap.1.txt
Normal 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>.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>.\&
|
||||
|
||||
@@ -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>.
|
||||
|
||||
16
man/oddmu.1
16
man/oddmu.1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
39
man_test.go
39
man_test.go
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMissingCmd(t *testing.T) {
|
||||
|
||||
@@ -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
60
page.go
@@ -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] + "/"
|
||||
}
|
||||
|
||||
11
page_test.go
11
page_test.go
@@ -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))
|
||||
}
|
||||
|
||||
17
parser.go
17
parser.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(``)}
|
||||
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), "¼")
|
||||
p.renderHTML()
|
||||
assert.Contains(t, string(p.HTML), "¼")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
18
preview.html
18
preview.html
@@ -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">
|
||||
|
||||
@@ -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>!")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplaceCmd(t *testing.T) {
|
||||
|
||||
24
search.go
24
search.go
@@ -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)
|
||||
}
|
||||
|
||||
26
search.html
26
search.html
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchCmd(t *testing.T) {
|
||||
|
||||
@@ -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
53
sitemap.go
Normal 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
3
sitemap.html
Normal 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
62
sitemap_cmd.go
Normal 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
18
sitemap_cmd_test.go
Normal 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/")
|
||||
}
|
||||
@@ -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:]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSnippets(t *testing.T) {
|
||||
|
||||
18
static.html
18
static.html
@@ -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>
|
||||
|
||||
196
static_cmd.go
196
static_cmd.go
@@ -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("://")) &&
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user