36 Commits
v1.15 ... v1.16

Author SHA1 Message Date
Alex Schroeder
77a38ddf66 Ready for release 1.16 2025-04-06 13:32:19 +02:00
Alex Schroeder
d3ffe82a90 The "toc" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:05:06 +02:00
Alex Schroeder
4a12721462 The "notify" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:02:03 +02:00
Alex Schroeder
07b1277764 The "links" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:02:03 +02:00
Alex Schroeder
f99a54e2ef The "html" subcommand now takes filenames
In order to make use of the shell's file-completion, the command now
takes filenames. These end in '.md'. Previously, that resulted in
errors such as "Cannot load index.md: open index.md.md: no such file
or directory".
2025-04-05 23:02:03 +02:00
Alex Schroeder
56a4461bd6 Fix mux setup 2025-03-24 18:30:00 +01:00
Alex Schroeder
2e38daf667 Use a new http.ServeMux
Don't rely on http.DefaultServeMux because all the dependencies also
have access to it and might register unexpected routes.
2025-03-24 17:47:36 +01:00
Alex Schroeder
a5421372d8 Set explicit timeouts
Read timeout: 2 min (the client has two minutes to upload all its
files).

Write timeout: 5 min (the server has five minutes to process all the
files).

Idle timout: 2 min (the client has two minutes to make a new request
without needing to do another TLS handshake).
2025-03-24 17:41:22 +01:00
Alex Schroeder
7017363f6a Fix upload error at the top directory
Skipping filepath.Clean resulted in the empty string for the top
directory, so that all file uploads failed. This commit fixes that.
2025-03-16 23:16:34 +01:00
Alex Schroeder
9a7a1ee2a9 Standardize spelling of nginx
Use all lower case.
2025-03-16 22:46:57 +01:00
Alex Schroeder
76d7598854 Fix bug in upload templates
For uploads, do not use {{.Path}} instead of {{.Name}}.
2025-03-16 22:46:16 +01:00
Alex Schroeder
d7b48b975b Small documentation change
Add a note to change ExecStart when using a memex.
2025-03-14 17:59:54 +01:00
Alex Schroeder
4314a35d1d Bring some features from my templates here
Ctrl+B, Ctrl+I, Ctrl+K support.
Preview button.

And a documentation update.
2025-03-14 17:28:50 +01:00
Alex Schroeder
63e1c987f2 Add the logo 2025-03-14 01:01:28 +01:00
Alex Schroeder
7d748f82da Add the default templates to the tarballs 2025-03-14 01:01:10 +01:00
Alex Schroeder
8385bc424a Improving path vs filepath code
Rename the "path" argument to "name" if it refers to a page name (i.e.
a path) or "fp" if it refers to a filepath.

Don't escape Page.Name when ready to render a HTML template because in
some case this means unescaping in functions.

New method isHiddenName to replace ad-hoc checks for "." prefixes and
the like.
2025-03-14 00:56:40 +01:00
Alex Schroeder
820763bf23 Change {{.Name}} to {{.Path}} in HTML templates
This is a major change for anybody who has templates. In order to
handle the encoding of directory and filenames correctly for uploads,
page.Base() was added. It required at url.PathUnescape because it uses
page.Name and that used to be escaped before rendering. Instead of
doing that, there's now the actual .Name (unescaped) and
.Path (escaped). This change is only necessary if the name contains
characters that the html/template doesn't escape for
encodePathSegment: semicolon, comma and question-mark. We can ignore
the forward-slash because we don't allow that in page names.
2025-03-12 09:29:38 +01:00
Alex Schroeder
7d40fa4adb Use nameEscape for all redirects
Redirects don't use templates and therefore don't escape the path
names.
2025-03-12 08:35:24 +01:00
Alex Schroeder
6e24603c27 Add some more Umlaut tests 2025-03-11 09:27:56 +01:00
Alex Schroeder
5096627b87 Unescape the base name of the page
This still feels wrong. Is the p.Name not always unescaped?
2025-03-11 09:26:57 +01:00
Alex Schroeder
0c691123ff Test the redirect with Umlauts
Surprisingly, this uses the unescaped path and that doesn't look
right. Then again, it's an IRI instead of an URI and perhaps that's
OK, too.
2025-03-11 09:25:51 +01:00
Alex Schroeder
5770966cdd Prevent trailing ? in error message
If values is nil, don't print the "?" at the end of the URL.
2025-03-11 09:25:08 +01:00
Alex Schroeder
a001a77692 Use urlpath.Join 2025-03-11 09:24:41 +01:00
Alex Schroeder
0e29ed77ea Test upload with Umlaut in filename 2025-03-10 20:06:14 +01:00
Alex Schroeder
173bb62a79 Change MICRO SIGN to GREEK SMALL LETTER MU 😭 (tests) 2025-03-10 20:02:13 +01:00
Alex Schroeder
58249aac85 Change MICRO SIGN to GREEK SMALL LETTER MU 😭 2025-03-05 20:05:38 +01:00
Alex Schroeder
1fdd502e95 Fix upload templates for themes
Note that webp images can be written, too.
2025-02-23 00:15:05 +01:00
Alex Schroeder
196ff605c3 Rewrite file uploading
Converting now happens not only when images are resized by setting the
max width. Setting the quality or changing the file extension also
results in conversion.

Previously, it was possible to upload a JPEG file and save it using a
PNG filename -- but the file itself was still JPEG encoded!
2025-02-23 00:08:43 +01:00
Alex Schroeder
6c1e595f13 Add minimal after uploading HEIC and WebP image 2025-02-22 21:49:53 +01:00
Alex Schroeder
34fdb5d9a9 Add WebP support for uploading and resizing 2025-02-22 21:46:47 +01:00
Alex Schroeder
b28614fa52 Fix README
The HEIC library is a WASM blob that no longer prevents
cross-compilation. Blows my mind but here we are.
2025-02-11 19:05:44 +01:00
Alex Schroeder
e7511ed059 Update templates of transjovian.org theme
Upload handling multiple files to add to the original page instead of
just the last one.
2025-02-09 14:31:47 +01:00
Alex Schroeder
a5d03dd136 Update templates of alexschroeder.ch theme
Added preview button.

Search with image previews.

Upload handling multiple files to add to the original page instead of
just the last one.
2025-02-09 14:16:37 +01:00
Alex Schroeder
87d5efcb7a Add section for unreleased 1.16 2025-02-09 13:24:31 +01:00
Alex Schroeder
c9bb062a04 Plain theme
Add support for rocket links, Gemini style.

Add an index.md file so that the Home link works.
2025-02-09 13:20:42 +01:00
Alex Schroeder
e2eec5e052 Chat theme
Better support for lists.

Add an index.md file so that the Home link works.
2025-02-09 13:20:42 +01:00
113 changed files with 889 additions and 521 deletions

View File

@@ -72,7 +72,7 @@ oddmu-windows-amd64.tar.gz: oddmu.exe
%.tar.gz: %
tar --create --file $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
$< *.html Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
priv:
sudo setcap 'cap_net_bind_service=+ep' oddmu

View File

@@ -1,4 +1,4 @@
# Oddµ: A minimal wiki
# Oddμ: A minimal wiki
This program helps you run a minimal wiki, blog, digital garden, memex
or Zettelkasten. There is no version history.
@@ -19,7 +19,7 @@ write-access to the site.
It's well suited as a simple static site generator. There are no
plugins.
When Oddµ runs as a web server, it serves all the Markdown files
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
@@ -32,7 +32,7 @@ necessary.
Other files can be uploaded and images (ending in `.jpg`, `.jpeg`,
`.png`, `.heic` or `.webp`) can be resized when they are uploaded
(resulting in `.jpg` or `.png` files).
(resulting in `.jpg`, `.png` or `.webp` files).
## Documentation
@@ -167,8 +167,6 @@ make docs
The `Makefile` in the `man` directory has targets to create Markdown
and HTML files.
The HEIC library uses C code and prevents cross-compilation.
As the repository changed URLs a few times (from GitHub, to
self-hosted using `cgit` to self-hosted using `legit`), there is no
way to install it using `go install`. You need to `git clone` the
@@ -280,18 +278,10 @@ extensions can be added.
### Filenames and URL path
One of the sad parts of the code is the distinction between path and
filepath. On a Linux system, this doesn't matter. I suspect that it
also doesn't matter on MacOS and Windows because the file systems
handle forward slashes just fine. The code still tries to do the right
thing. A path that is derived from a URL is a path with slashes.
Before accessing a file, it has to be turned into a filepath using
`filepath.FromSlashes` and in the rare case where the inverse happens,
use `filepath.ToSlashes`. Any path received via the URL path uses
slashes and needs to be converted to a filepath before passing it to
any `os` function. Any path received within a `path/filepath.WalkFunc`
is a filepath and needs to be converted to use slashes when used in
HTML output.
There are some simplifications made. The code doesn't consider the
various encodings (UTF-8 NFC on the web vs UTF-8 NFD for HFS+, for
example; it also doesn't check for characters in page names that are
illegal filenames on the filesystem used).
If you need to access the page name in code that is used from a
template, you have to decode the path. See the code in `diff.go` for
@@ -347,9 +337,12 @@ in turn can be used by browsers to get hyphenation right. Apache-2.0.
is used to sniff the MIME type of files with unknown filename
extensions. MIT.
[github.com/gen2brain/heic](https://github.com/gen2brain/heic) is
used to decode HEIC files (the new default file format for photos on
iPhones). LGPL-3.0-only.
[github.com/gen2brain/heic](https://github.com/gen2brain/heic) is used
to decode HEIC files (the new default file format for photos on
iPhones). MIT.
[github.com/gen2brain/webp](https://github.com/gen2brain/webp) is used
to encode and decode WebP files. MIT.
[github.com/disintegration/imaging](https://github.com/disintegration/imaging)
is used to resize images. MIT.

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form 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">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -48,7 +48,7 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
}
func (p *Page) append(body []byte) {

View File

@@ -16,7 +16,7 @@ import (
// are skipped. If the environment variable ODDMU_FILTER is a regular expression that matches the starting directory,
// this is a "separate site"; if the regular expression does not match, this is the "main site" and page names must also
// not match the regular expression.
func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
func archiveHandler(w http.ResponseWriter, r *http.Request, name string) {
filter := os.Getenv("ODDMU_FILTER")
re, err := regexp.Compile(filter)
if err != nil {
@@ -24,30 +24,30 @@ func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
matches := re.MatchString(path)
dir := filepath.Dir(filepath.FromSlash(path))
matches := re.MatchString(name)
dir := filepath.Dir(filepath.FromSlash(name))
z := zip.NewWriter(w)
err = filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
err = filepath.Walk(dir, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
return filepath.SkipDir
}
} else if !strings.HasPrefix(filepath.Base(path), ".") &&
(matches || !re.MatchString(path)) {
zf, err := z.Create(path)
} else if !strings.HasPrefix(filepath.Base(fp), ".") &&
(matches || !re.MatchString(filepath.ToSlash(fp))) {
zf, err := z.Create(fp)
if err != nil {
log.Println(err)
return err
}
file, err := os.Open(path)
f, err := os.Open(fp)
if err != nil {
log.Println(err)
return err
}
_, err = io.Copy(zf, file)
_, err = io.Copy(zf, f)
if err != nil {
log.Println(err)
return err

View File

@@ -20,10 +20,10 @@ func (p *Page) notify() error {
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(path.Base(p.Name))
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
dir := path.Dir(p.Name)
dir := p.Dir()
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
if err != nil {
log.Printf("Updating changes in %s failed: %s", dir, err)
@@ -31,7 +31,7 @@ func (p *Page) notify() error {
}
if p.IsBlog() {
// Add to the index only if the blog post is for the current year
if strings.HasPrefix(path.Base(p.Name), time.Now().Format("2006")) {
if strings.HasPrefix(p.Base(), time.Now().Format("2006")) {
err := addLink(path.Join(dir, "index"), true, link, re)
if err != nil {
log.Printf("Updating index in %s failed: %s", dir, err)

View File

@@ -6,7 +6,6 @@ import (
"html"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -25,11 +24,7 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
// 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 {
path, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
fp := filepath.FromSlash(path)
fp := filepath.FromSlash(p.Name)
a := fp + ".md~"
t1, err := os.ReadFile(a)
if err != nil {

View File

@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>

View File

@@ -13,14 +13,14 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

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

View File

@@ -13,8 +13,8 @@ func TestExportCmd(t *testing.T) {
b := new(bytes.Buffer)
s := exportCli(b, "feed.html", minimalIndex(t))
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), "<title>Oddµ: A minimal wiki</title>")
assert.Contains(t, b.String(), "<title>Welcome to Oddµ</title>")
assert.Contains(t, b.String(), "<title>Oddμ: A minimal wiki</title>")
assert.Contains(t, b.String(), "<title>Welcome to Oddμ</title>")
}
func TestExportCmdLanguage(t *testing.T) {
@@ -50,6 +50,6 @@ func TestExportCmdJsonFeed(t *testing.T) {
b := new(bytes.Buffer)
s := exportCli(b, "testdata/json/template.json", minimalIndex(t))
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), `"title": "Oddµ: A minimal wiki"`)
assert.Regexp(t, regexp.MustCompile("&lt;h1.*&gt;Welcome to Oddµ&lt;/h1&gt;"), b.String()) // skip id
assert.Contains(t, b.String(), `"title": "Oddμ: A minimal wiki"`)
assert.Regexp(t, regexp.MustCompile("&lt;h1.*&gt;Welcome to Oddμ&lt;/h1&gt;"), b.String()) // skip id
}

View File

@@ -63,7 +63,7 @@ func feed(p *Page, ti time.Time) *Feed {
if !ok || bytes.Contains(link.Destination, []byte("//")) {
return ast.GoToNext
}
name := path.Join(path.Dir(p.Name), string(link.Destination))
name := path.Join(p.Dir(), string(link.Destination))
fi, err := os.Stat(filepath.FromSlash(name) + ".md")
if err != nil {
return ast.GoToNext

View File

@@ -5,7 +5,7 @@
<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/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://example.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Your Name.</description>
<image>
<url>https://example.org/view/logo.jpg</url>
@@ -15,8 +15,8 @@
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://example.org/view/{{.Name}}</link>
<guid>https://example.org/view/{{.Name}}</guid>
<link>https://example.org/view/{{.Path}}</link>
<guid>https://example.org/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -9,7 +9,7 @@ import (
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
"Welcome to Oddµ")
"Welcome to Oddμ")
}
func TestNoFeed(t *testing.T) {

5
go.mod
View File

@@ -24,14 +24,15 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.7.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
github.com/rivo/uniseg v0.4.6 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/tetratelabs/wazero v1.7.3 // indirect
github.com/tetratelabs/wazero v1.8.1 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.21.0 // indirect

6
go.sum
View File

@@ -7,6 +7,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
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=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -15,6 +17,8 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
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=
@@ -59,6 +63,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
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=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=

View File

@@ -7,30 +7,32 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type htmlCmd struct {
useTemplate bool
template string
}
func (*htmlCmd) Name() string { return "html" }
func (*htmlCmd) Synopsis() string { return "render a page as HTML" }
func (*htmlCmd) Usage() string {
return `html [-view] <page name> ...:
return `html [-template <template name>] <page name> ...:
Render one or more pages as HTML.
Use a single - to read Markdown from stdin.
`
}
func (cmd *htmlCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.useTemplate, "view", false, "use the 'view.html' template.")
f.StringVar(&cmd.template, "template", "",
"use the given HTML file as a template (probably view.html or static.html).")
}
func (cmd *htmlCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return htmlCli(os.Stdout, cmd.useTemplate, f.Args())
return htmlCli(os.Stdout, cmd.template, f.Args())
}
func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatus {
func htmlCli(w io.Writer, template string, args []string) subcommands.ExitStatus {
if len(args) == 1 && args[0] == "-" {
body, err := io.ReadAll(os.Stdin)
if err != nil {
@@ -38,15 +40,20 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
return subcommands.ExitFailure
}
p := &Page{Name: "stdin", Body: body}
return p.printHtml(w, useTemplate)
return p.printHtml(w, template)
}
for _, arg := range args {
p, err := loadPage(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
status := p.printHtml(w, useTemplate)
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)
if status != subcommands.ExitSuccess {
return status
}
@@ -54,9 +61,9 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
return subcommands.ExitSuccess
}
func (p *Page) printHtml(w io.Writer, useTemplate bool) subcommands.ExitStatus {
if useTemplate {
t := "view.html"
func (p *Page) printHtml(w io.Writer, template string) subcommands.ExitStatus {
if len(template) > 0 {
t := template
loadTemplates()
p.handleTitle(true)
p.renderHtml()

View File

@@ -9,9 +9,9 @@ import (
func TestHtmlCmd(t *testing.T) {
b := new(bytes.Buffer)
s := htmlCli(b, false, []string{"index"})
s := htmlCli(b, "", []string{"index.md"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `<h1 id="welcome-to-oddµ">Welcome to Oddµ</h1>
r := `<h1 id="welcome-to-oddμ">Welcome to Oddμ</h1>
<p>Hello! 🙃</p>

View File

@@ -139,12 +139,12 @@ func (idx *indexStore) load() (int, error) {
}
// walk reads a file and adds it to the index. This assumes that the index is locked.
func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
func (idx *indexStore) walk(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -152,10 +152,10 @@ func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
if !strings.HasSuffix(fp, ".md") {
return nil
}
p, err := loadPage(strings.TrimSuffix(path, ".md"))
p, err := loadPage(strings.TrimSuffix(filepath.ToSlash(fp), ".md"))
if err != nil {
return err
}

View File

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

View File

@@ -21,7 +21,7 @@ func TestIndexAdd(t *testing.T) {
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
q := "Oddμ"
pages, _ := search(q, "", "", 1, false)
assert.NotZero(t, len(pages))
for _, p := range pages {

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type linksCmd struct {
@@ -43,6 +44,11 @@ func linksCli(w io.Writer, args []string) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
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)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

View File

@@ -9,7 +9,7 @@ import (
func TestLinksCmd(t *testing.T) {
b := new(bytes.Buffer)
s := linksCli(b, []string{"README"})
s := linksCli(b, []string{"README.md"})
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "https://alexschroeder.ch/view/oddmu/oddmu.1\n")

42
list.go
View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -26,38 +27,38 @@ type List struct {
}
// listHandler uses the "list.html" template to enable file management in a particular directory.
func listHandler(w http.ResponseWriter, r *http.Request, dir string) {
func listHandler(w http.ResponseWriter, r *http.Request, name string) {
files := []File{}
d := filepath.FromSlash(dir)
d := filepath.FromSlash(name)
if d == "" {
d = "."
} else if !strings.HasSuffix(d, "/") {
http.Redirect(w, r, "/list/"+d+"/", http.StatusFound)
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 (path string, fi fs.FileInfo, err error) error {
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 == path {
if d == fp {
return nil
}
isDir = true
}
name := filepath.ToSlash(path)
base := filepath.Base(name)
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]]
}
if isDir {
} 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 }
@@ -72,31 +73,36 @@ func listHandler(w http.ResponseWriter, r *http.Request, dir string) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
renderTemplate(w, dir, "list", &List{Dir: dir, Files: files})
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, path string) {
fn := filepath.Clean(filepath.FromSlash(path))
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/"+filepath.Dir(fn)+"/", http.StatusFound)
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, path string) {
fn := filepath.Clean(filepath.FromSlash(path))
target := filepath.Join(filepath.Dir(fn), r.FormValue("name"))
err := os.Rename(fn, target)
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/"+filepath.Dir(target)+"/", http.StatusFound)
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(filepath.ToSlash(target))) + "/", http.StatusFound)
}

View File

@@ -12,8 +12,8 @@ func TestListCmd(t *testing.T) {
s := listCli(b, "", nil)
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "README\tOddµ: A minimal wiki\n")
assert.Contains(t, x, "index\tWelcome to Oddµ\n")
assert.Contains(t, x, "README\tOddμ: A minimal wiki\n")
assert.Contains(t, x, "index\tWelcome to Oddμ\n")
}
func TestListSubdirCmd(t *testing.T) {

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HTML" "1" "2024-08-29"
.TH "ODDMU-HTML" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -13,21 +13,21 @@ oddmu-html - render Oddmu page HTML
.PP
.SH SYNOPSIS
.PP
\fBoddmu html\fR [-view] \fIpage-name\fR
\fBoddmu html\fR [\fB\fR-template\fB\fR \fItemplate-name\fR] \fIpage-name\fR
.PP
.SH DESCRIPTION
.PP
The "html" subcommand opens the Markdown file for the given page name (appending
the ".\&md" extension) and prints the HTML to STDOUT without invoking the
"view.\&html" template.\& Use "-" as the page name if you want to read Markdown from
\fBstdin\fR.\&
The "html" subcommand opens the given Markdown file and prints the resulting
HTML to STDOUT without invoking the "view.\&html" template.\& Use "-" as the page
name if you want to read Markdown from \fBstdin\fR.\&
.PP
.SH OPTIONS
.PP
\fB-view\fR
\fB\fR-template\fB\fR \fItemplate-name\fR
.RS 4
Use the "view.\&html" template to render the page.\& Without this, the HTML
lacks html and body tags.\&
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".\&
.PP
.RE
.SH EXAMPLES
@@ -36,7 +36,7 @@ Generate "README.\&html" from "README.\&md":
.PP
.nf
.RS 4
oddmu html README > README\&.html
oddmu html README\&.md > README\&.html
.fi
.RE
.PP

View File

@@ -6,27 +6,27 @@ oddmu-html - render Oddmu page HTML
# SYNOPSIS
*oddmu html* [-view] _page-name_
*oddmu html* [**-template** _template-name_] _page-name_
# DESCRIPTION
The "html" subcommand opens the Markdown file for the given page name (appending
the ".md" extension) and prints the HTML to STDOUT without invoking the
"view.html" template. Use "-" as the page name if you want to read Markdown from
*stdin*.
The "html" subcommand opens the given Markdown file and prints the resulting
HTML to STDOUT without invoking the "view.html" template. Use "-" as the page
name if you want to read Markdown from *stdin*.
# OPTIONS
*-view*
Use the "view.html" template to render the page. Without this, the HTML
lacks html and body tags.
**-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".
# EXAMPLES
Generate "README.html" from "README.md":
```
oddmu html README > README.html
oddmu html README.md > README.html
```
Alternatively:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LINKS" "1" "2024-08-15"
.TH "ODDMU-LINKS" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -17,8 +17,8 @@ oddmu-links - list outgoing links for pages
.PP
.SH DESCRIPTION
.PP
The "links" subcommand lists outgoing links for one or more page names.\& Use "-"
as the page name if you want to read Markdown from \fBstdin\fR.\&
The "links" subcommand lists outgoing links for one or more Markdown files.\& Use
"-" as the page name if you want to read Markdown from \fBstdin\fR.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -10,8 +10,8 @@ oddmu-links - list outgoing links for pages
# DESCRIPTION
The "links" subcommand lists outgoing links for one or more page names. Use "-"
as the page name if you want to read Markdown from *stdin*.
The "links" subcommand lists outgoing links for one or more Markdown files. Use
"-" as the page name if you want to read Markdown from *stdin*.
# SEE ALSO

View File

@@ -5,19 +5,19 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2024-08-29"
.TH "ODDMU-NGINX" "5" "2025-03-16"
.PP
.SH NAME
.PP
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
oddmu-nginx - how to setup nginx as a reverse proxy for Oddmu
.PP
.SH DESCRIPTION
.PP
The oddmu program serves the current working directory as a wiki on port 8080.\&
This is an unpriviledged port so an ordinary user account can do this.\&
.PP
This page explains how to setup NGINX on Debian to act as a reverse proxy for
Oddmu.\& Once this is done, you can use NGINX to provide HTTPS, request users to
This page explains how to setup nginx on Debian to act as a reverse proxy for
Oddmu.\& Once this is done, you can use nginx to provide HTTPS, request users to
authenticate themselves, and so on.\&
.PP
.SH CONFIGURATION

View File

@@ -2,15 +2,15 @@ ODDMU-NGINX(5)
# NAME
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
oddmu-nginx - how to setup nginx as a reverse proxy for Oddmu
# DESCRIPTION
The oddmu program serves the current working directory as a wiki on port 8080.
This is an unpriviledged port so an ordinary user account can do this.
This page explains how to setup NGINX on Debian to act as a reverse proxy for
Oddmu. Once this is done, you can use NGINX to provide HTTPS, request users to
This page explains how to setup nginx on Debian to act as a reverse proxy for
Oddmu. Once this is done, you can use nginx to provide HTTPS, request users to
authenticate themselves, and so on.
# CONFIGURATION

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NOTIFY" "1" "2024-08-29"
.TH "ODDMU-NOTIFY" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -17,8 +17,8 @@ oddmu-notify - add links to changes.\&md, index.\&md, and hashtag pages
.PP
.SH DESCRIPTION
.PP
The "notify" subcommand takes all the page names provided (without the ".\&md"
extension) and adds links to it from other pages.\&
The "notify" subcommand takes all the Markdown files provided and adds links to
these pages from other pages.\&
.PP
A new link is added to the \fBchanges\fR page in the current directory if it doesn'\&t
exist.\& The current date of the machine Oddmu is running on is used as the
@@ -57,7 +57,7 @@ it exists):
.PP
.nf
.RS 4
oddmu notify 2023-11-05-climate
oddmu notify 2023-11-05-climate\&.md
.fi
.RE
.PP

View File

@@ -10,8 +10,8 @@ oddmu-notify - add links to changes.md, index.md, and hashtag pages
# DESCRIPTION
The "notify" subcommand takes all the page names provided (without the ".md"
extension) and adds links to it from other pages.
The "notify" subcommand takes all the Markdown files provided and adds links to
these pages from other pages.
A new link is added to the *changes* page in the current directory if it doesn't
exist. The current date of the machine Oddmu is running on is used as the
@@ -49,7 +49,7 @@ After writing the file "2023-11-05-climate.md" containing the hashtag
it exists):
```
oddmu notify 2023-11-05-climate
oddmu notify 2023-11-05-climate.md
```
The changes file might look as follows:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2025-02-09"
.TH "ODDMU-RELEASES" "7" "2025-04-06"
.PP
.SH NAME
.PP
@@ -15,6 +15,24 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.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.\&
.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
links via JavaScript; the alexschroeder.\&ch theme got a preview button and better
image support for upload and search; the transjovian.\&org theme got better image
support for upload.\&
.PP
Switch the \fIhtml\fR, \fIlink\fR, \fInotify\fR and \fItoc\fR subcommand to take filenames
(including the `.\&md` suffix) instead of page names (without the `.\&md` suffix).\&
.PP
.SS 1.15 (2025)
.PP
Fix the hashtag detection.\& This was necessary to cut down on the many false

View File

@@ -8,6 +8,24 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 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.
Improved the example themes. The chat theme got better list styling and better
upload functionality with automatic "add" button; the plain theme got rocket
links via JavaScript; the alexschroeder.ch theme got a preview button and better
image support for upload and search; the transjovian.org theme got better image
support for upload.
Switch the _html_, _link_, _notify_ and _toc_ subcommand to take filenames
(including the `.md` suffix) instead of page names (without the `.md` suffix).
## 1.15 (2025)
Fix the hashtag detection. This was necessary to cut down on the many false

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-REPLACE" "1" "2024-08-29"
.TH "ODDMU-REPLACE" "1" "2025-03-05"
.PP
.SH NAME
.PP
@@ -42,7 +42,7 @@ Replace "Oddmu" in the Markdown files of the current directory:
.PP
.nf
.RS 4
oddmu replace Oddmu Oddµ
oddmu replace Oddmu Oddμ
.fi
.RE
.PP

View File

@@ -30,7 +30,7 @@ the current directory and its subdirectories.
Replace "Oddmu" in the Markdown files of the current directory:
```
oddmu replace Oddmu Oddµ
oddmu replace Oddmu Oddμ
```
Result:

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "1" "2024-08-29"
.TH "ODDMU-SEARCH" "1" "2025-03-05"
.PP
.SH NAME
.PP
@@ -65,7 +65,7 @@ The ordering of terms does not matter.\&
~/src/oddmu $ oddmu search Alex Schroeder
Search for Alex Schroeder, page 1: 3 results
* [Alex Schroeder theme](themes/alexschroeder\&.ch/README)
* [Oddµ: A minimal wiki](README)
* [Oddμ: A minimal wiki](README)
* [Themes](themes/index)
.fi
.RE

View File

@@ -49,7 +49,7 @@ The ordering of terms does not matter.
~/src/oddmu $ oddmu search Alex Schroeder
Search for Alex Schroeder, page 1: 3 results
* [Alex Schroeder theme](themes/alexschroeder.ch/README)
* [Oddµ: A minimal wiki](README)
* [Oddμ: A minimal wiki](README)
* [Themes](themes/index)
```

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "7" "2024-02-19"
.TH "ODDMU-SEARCH" "7" "2025-03-05"
.PP
.SH NAME
.PP
@@ -57,9 +57,9 @@ exactly (without the leading '\&#'\&) is listed first, even if it doesn'\&t cont
the hashtag.\& It is assumed that this page offers some kind of introduction to
people searching for the hashtag.\&
.PP
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
(in other words, the file "Oddµ.\&md" exists), it is prepended to the results even
if it doesn'\&t have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
Example: When people click on the hashtag "#Oddμ" and a page named "Oddμ" exists
(in other words, the file "Oddμ.\&md" exists), it is prepended to the results even
if it doesn'\&t have the hashtag "#Oddμ" and even if it has a title of "Oddμ, a
minimal wiki" (which wouldn'\&t be an exact match).\&
.PP
The score and highlighting of snippets is used to help visitors decide which

View File

@@ -44,9 +44,9 @@ exactly (without the leading '#') is listed first, even if it doesn't contain
the hashtag. It is assumed that this page offers some kind of introduction to
people searching for the hashtag.
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
(in other words, the file "Oddµ.md" exists), it is prepended to the results even
if it doesn't have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
Example: When people click on the hashtag "#Oddμ" and a page named "Oddμ" exists
(in other words, the file "Oddμ.md" exists), it is prepended to the results even
if it doesn't have the hashtag "#Oddμ" and even if it has a title of "Oddμ, a
minimal wiki" (which wouldn't be an exact match).
The score and highlighting of snippets is used to help visitors decide which

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TOC" "1" "2024-08-15"
.TH "ODDMU-TOC" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -13,13 +13,12 @@ oddmu-toc - print the table of contents (toc) for pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
The "toc" subcommand prints the table of contents for one or more page
names.\& Use "-" as the page name if you want to read Markdown from
\fBstdin\fR.\&
The "toc" subcommand prints the table of contents for one or more Markdown
files.\& Use "-" as the page name if you want to read Markdown from \fBstdin\fR.\&
.PP
This can be useful for very long pages that need a table of contents
at the beginning.\&

View File

@@ -6,13 +6,12 @@ oddmu-toc - print the table of contents (toc) for pages
# SYNOPSIS
*oddmu toc* _page names..._
*oddmu toc* _page names..._
# DESCRIPTION
The "toc" subcommand prints the table of contents for one or more page
names. Use "-" as the page name if you want to read Markdown from
*stdin*.
The "toc" subcommand prints the table of contents for one or more Markdown
files. Use "-" as the page name if you want to read Markdown from *stdin*.
This can be useful for very long pages that need a table of contents
at the beginning.

View File

@@ -5,13 +5,13 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-11-16"
.TH "ODDMU" "1" "2025-03-14"
.PP
.SH NAME
.PP
oddmu - a wiki server
.PP
Oddmu is sometimes written Oddµ because µ is the letter mu.\&
Oddmu is sometimes written Oddμ because μ is the letter mu.\&
.PP
.SH SYNOPSIS
.PP

View File

@@ -4,7 +4,7 @@ ODDMU(1)
oddmu - a wiki server
Oddmu is sometimes written Oddµ because µ is the letter mu.
Oddmu is sometimes written Oddμ because μ is the letter mu.
# SYNOPSIS

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "5" "2024-09-30" "File Formats Manual"
.TH "ODDMU" "5" "2025-03-05" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -28,7 +28,7 @@ The page name has to be percent-encoded.\& See the section "Percent Encoding".\&
If you link to the actual Markdown file (with the ".\&md" extension), then Oddmu
serves the Markdown file!\&
.PP
There are three Oddµ-specific extensions: local links, hashtags and fediverse
There are three Oddμ-specific extensions: local links, hashtags and fediverse
account links.\& The Markdown library used features some additional extensions,
most importantly tables and definition lists.\&
.PP
@@ -94,7 +94,7 @@ linked to a profile page.\& In this case, "@alex" would be linked to
"https://alexschroeder.\&ch/users/alex".\&
.PP
In many cases, this works as is.\& In reality, however, the link to the profile
page needs to be retrieved via webfinger.\& Oddµ does that in the background, and
page needs to be retrieved via webfinger.\& Oddμ does that in the background, and
as soon as the information is available, the actual profile link is used when
pages are rendered.\& In the example above, the result would be
"https://social.\&alexschroeder.\&ch/@alex".\&

View File

@@ -19,7 +19,7 @@ The page name has to be percent-encoded. See the section "Percent Encoding".
If you link to the actual Markdown file (with the ".md" extension), then Oddmu
serves the Markdown file!
There are three Oddµ-specific extensions: local links, hashtags and fediverse
There are three Oddμ-specific extensions: local links, hashtags and fediverse
account links. The Markdown library used features some additional extensions,
most importantly tables and definition lists.
@@ -79,7 +79,7 @@ linked to a profile page. In this case, "@alex" would be linked to
"https://alexschroeder.ch/users/alex".
In many cases, this works as is. In reality, however, the link to the profile
page needs to be retrieved via webfinger. Oddµ does that in the background, and
page needs to be retrieved via webfinger. Oddμ does that in the background, and
as soon as the information is available, the actual profile link is used when
pages are rendered. In the example above, the result would be
"https://social.alexschroeder.ch/@alex".

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2024-08-23"
.TH "ODDMU.SERVICE" "5" "2025-03-14"
.PP
.SH NAME
.PP
@@ -94,8 +94,8 @@ sudo mkdir /run/oddmu
.fi
.RE
.PP
The unit file for the service defines where the "oddmu" is and where the data
directory is.\& These are the lines you most likely have to take care of:
The unit file for the service defines where the Oddmu binary is and where the
data directory is.\& These are the lines you most likely have to take care of:
.PP
.nf
.RS 4
@@ -152,6 +152,9 @@ Environment="ODDMU_LANGUAGES=de,en"
.fi
.RE
.PP
Make sure to change the "ExecStart" entry so that it points to your copy of the
Oddmu binary.\&
.PP
Since this is a user service, the same user can edit the files using their
favourite text editor.\&
.PP

View File

@@ -75,8 +75,8 @@ the directory or change the file name.
sudo mkdir /run/oddmu
```
The unit file for the service defines where the "oddmu" is and where the data
directory is. These are the lines you most likely have to take care of:
The unit file for the service defines where the Oddmu binary is and where the
data directory is. These are the lines you most likely have to take care of:
```
ExecStart=/home/oddmu/oddmu
@@ -125,6 +125,9 @@ WorkingDirectory=/home/alex/wiki
Environment="ODDMU_LANGUAGES=de,en"
```
Make sure to change the "ExecStart" entry so that it points to your copy of the
Oddmu binary.
Since this is a user service, the same user can edit the files using their
favourite text editor.

View File

@@ -20,14 +20,14 @@ func TestManPages(t *testing.T) {
main := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".txt") &&
path != "man/oddmu.1.txt" {
if strings.HasSuffix(fp, ".txt") &&
fp != "man/oddmu.1.txt" {
count++
s := strings.TrimPrefix(path, "man/")
s := strings.TrimPrefix(fp, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "_" + s[:i] + "_(" + s[i+1:] + ")"
@@ -44,15 +44,15 @@ func TestManTemplates(t *testing.T) {
man := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".html") {
if strings.HasSuffix(fp, ".html") {
count++
assert.Contains(t, man, path, path)
assert.Contains(t, man, fp, fp)
}
if path != "." && info.IsDir() {
if fp != "." && info.IsDir() {
return filepath.SkipDir
}
return nil
@@ -71,7 +71,7 @@ func TestManActions(t *testing.T) {
wiki := string(b)
count := 0
// this doesn't match the root handler
re := regexp.MustCompile(`http.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
var path string
@@ -94,13 +94,13 @@ func TestReadme(t *testing.T) {
readme := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".txt") {
if strings.HasSuffix(fp, ".txt") {
count++
s := strings.TrimPrefix(path, "man/")
s := strings.TrimPrefix(fp, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
@@ -110,15 +110,15 @@ func TestReadme(t *testing.T) {
})
assert.Greater(t, count, 0, "no man pages were found")
count = 0
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".go") &&
!strings.HasSuffix(path, "_test.go") &&
!strings.HasSuffix(path, "_cmd.go") {
if strings.HasSuffix(fp, ".go") &&
!strings.HasSuffix(fp, "_test.go") &&
!strings.HasSuffix(fp, "_cmd.go") {
count++
s := strings.TrimPrefix(path, "./")
s := strings.TrimPrefix(fp, "./")
ref := "`" + s + "`"
assert.Contains(t, readme, ref, ref)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type notifyCmd struct {
@@ -32,6 +33,11 @@ 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]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

BIN
oddmu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

50
page.go
View File

@@ -64,7 +64,7 @@ func nameEscape(s string) string {
// 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")
fp := filepath.FromSlash(p.Name) + ".md"
watches.ignore(fp)
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
@@ -90,8 +90,8 @@ func (p *Page) save() error {
// 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 file path. Use filepath.FromSlash(path) if necessary. The
// backup file gets its modification time set to now so that subsequent edits don't immediately overwrite it again.
// what to do with a file called "image.png~". This expects a filepath. The backup file gets its modification time set
// to now so that subsequent edits don't immediately overwrite it again.
func backup(fp string) error {
_, err := os.Stat(fp)
if err != nil {
@@ -113,13 +113,13 @@ 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
// undefined (there is no caching).
func loadPage(path string) (*Page, error) {
path = strings.TrimPrefix(path, "./") // result of a filepath.TreeWalk starting with "."
body, err := os.ReadFile(filepath.FromSlash(path + ".md"))
func loadPage(name string) (*Page, error) {
name = strings.TrimPrefix(name, "./") // result of a path.TreeWalk starting with "."
body, err := os.ReadFile(filepath.FromSlash(name) + ".md")
if err != nil {
return nil, err
}
return &Page{Title: path, Name: path, Body: body}, nil
return &Page{Title: name, Name: name, Body: body}, nil
}
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
@@ -139,7 +139,6 @@ func (p *Page) handleTitle(replace bool) {
// summarize sets Page.Html to an extract.
func (p *Page) summarize(q string) {
t := p.plainText()
p.Name = nameEscape(p.Name)
p.Html = sanitizeStrict(snippets(q, t))
}
@@ -149,10 +148,37 @@ func (p *Page) IsBlog() bool {
return blogRe.MatchString(name)
}
// Dir returns the directory the page is in. 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.
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.
func (p *Page) Path() string {
s := p.Name
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?")
if n == 0 {
return p.Name
}
t := make([]byte, len(s) + 2*n)
j := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case ';', ',', '?':
t[j] = '%'
t[j+1] = upperhex[s[i]>>4]
t[j+2] = upperhex[s[i]&15]
j += 3
default:
t[j] = s[i]
j++
}
}
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.
func (p *Page) Dir() string {
d := filepath.Dir(p.Name)
d := path.Dir(p.Name)
if d == "." {
return ""
}
@@ -162,7 +188,7 @@ func (p *Page) Dir() string {
// Base returns the basename of the page name: no directory. This is used to create the upload link in "view.html", for
// example.
func (p *Page) Base() string {
n := filepath.Base(p.Name)
n := path.Base(p.Name)
if n == "." {
return ""
}

View File

@@ -57,7 +57,7 @@ And untouchable`)}
p = &Page{Name: "testdata/parents/children/something/other"}
// "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, "Welcome to Oddμ", parents[0].Title)
assert.Equal(t, "../../../../index", parents[0].Url)
assert.Equal(t, "…", parents[1].Title)
assert.Equal(t, "../../../index", parents[1].Url)

View File

@@ -8,7 +8,6 @@ import (
"github.com/gomarkdown/markdown/parser"
"net/url"
"path"
"path/filepath"
)
// wikiLink returns an inline parser function. This indirection is
@@ -95,7 +94,6 @@ func (p *Page) renderHtml() {
parser, hashtags := wikiParser()
renderer := wikiRenderer()
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
}
@@ -129,7 +127,7 @@ func (p *Page) plainText() string {
// images returns an array of ImageData.
func (p *Page) images() []ImageData {
dir := path.Dir(filepath.ToSlash(p.Name))
dir := p.Dir()
images := make([]ImageData, 0)
parser := parser.New()
doc := markdown.Parse(p.Body, parser)

View File

@@ -28,12 +28,12 @@ img { max-width: 100%; }
<hr>
<section id="edit">
<h2>Editing {{.Title}}</h2>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" lang="{{.Language}}" autofocus>{{printf "# %s\n\n%s" .Title .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</section>
</body>

View File

@@ -55,12 +55,12 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
}
repl := []byte(args[1])
changes := 0
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
err := filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -68,10 +68,10 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
if !strings.HasSuffix(fp, ".md") {
return nil
}
body, err := os.ReadFile(path)
body, err := os.ReadFile(fp)
if err != nil {
return err
}
@@ -79,15 +79,15 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
if !slices.Equal(result, body) {
changes++
if isConfirmed {
fmt.Fprintln(w, path)
_ = os.Rename(path, path+"~")
err = os.WriteFile(path, result, 0644)
fmt.Fprintln(w, fp)
_ = os.Rename(fp, fp + "~")
err = os.WriteFile(fp, result, 0644)
if err != nil {
return err
}
} else {
edits := myers.ComputeEdits(span.URIFromPath(path+"~"), string(body), string(result))
diff := fmt.Sprint(gotextdiff.ToUnified(path+"~", path, string(body), edits))
edits := myers.ComputeEdits(span.URIFromPath(fp + "~"), string(body), string(result))
diff := fmt.Sprint(gotextdiff.ToUnified(fp + "~", fp, string(body), edits))
fmt.Fprintln(w, diff)
}
}

View File

@@ -38,11 +38,11 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Name}}"><img loading="lazy" src="/view/{{.Name}}"></a><br/>{{.Html}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}

View File

@@ -9,11 +9,11 @@ import (
func TestSearchCmd(t *testing.T) {
b := new(bytes.Buffer)
s := searchCli(b, &searchCmd{quiet: true}, []string{"oddµ"})
s := searchCli(b, &searchCmd{quiet: true}, []string{"oddμ"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Oddµ: A minimal wiki](README)
r := `* [Oddμ: A minimal wiki](README)
* [Themes](themes/index)
* [Welcome to Oddµ](index)
* [Welcome to Oddμ](index)
`
assert.Equal(t, r, b.String())
}

View File

@@ -61,7 +61,7 @@ func TestSearch(t *testing.T) {
index.load()
data := url.Values{}
data.Set("q", "oddµ")
data.Set("q", "oddμ")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
@@ -182,7 +182,7 @@ func TestTitleSearch(t *testing.T) {
items, more = search("title:wel", "", "", 1, false) // README also contains "wel"
assert.Equal(t, 1, len(items), "one page found")
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
assert.Equal(t, "index", items[0].Name, "Welcome to Oddμ")
assert.Greater(t, items[0].Score, 0, "matches result in a score")
assert.False(t, more)

View File

@@ -84,7 +84,7 @@ func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStat
func staticWalk(source, target 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(path string, info fs.FileInfo, err error) error {
filepath.Walk(source, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
@@ -92,9 +92,8 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
case err := <-stop:
return err
default:
base := filepath.Base(path)
// skip hidden directories and files
if path != "." && strings.HasPrefix(base, ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -102,28 +101,28 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, target) {
if strings.HasSuffix(fp, "~") || strings.HasPrefix(fp, target) {
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 actual_target string
var actualTarget string
if source == "." {
actual_target = filepath.Join(target, path)
actualTarget = filepath.Join(target, fp)
} else {
if !strings.HasPrefix(path, source) {
return fmt.Errorf("%s is not a subdirectory of %s", path, source)
if !strings.HasPrefix(fp, source) {
return fmt.Errorf("%s is not a subdirectory of %s", fp, source)
}
actual_target = filepath.Join(target, path[len(source):])
actualTarget = filepath.Join(target, fp[len(source):])
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(actual_target, 0755)
return os.Mkdir(actualTarget, 0755)
}
// do the task if the target file doesn't exist or if the source file is newer
other, err := os.Stat(actual_target)
other, err := os.Stat(actualTarget)
if err != nil || info.ModTime().After(other.ModTime()) {
tasks <- args{source: path, target: actual_target, info: info}
tasks <- args{source: fp, target: actualTarget, info: info}
}
return nil
}
@@ -211,7 +210,6 @@ func staticPage(source, target string) (*Page, error) {
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
return p, write(p, target, "", "static.html")
@@ -257,22 +255,22 @@ func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
}
// write a page or feed with an appropriate template to a specific destination, overwriting it.
func write(data any, path, prefix, templateFile string) error {
file, err := os.Create(path)
func write(data any, fp, prefix, templateFile string) error {
file, err := os.Create(fp)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", path, err)
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", fp, err)
return err
}
_, err = file.Write([]byte(prefix))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", path, err)
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", fp, err)
return err
}
templates.RLock()
defer templates.RUnlock()
err = templates.template[templateFile].Execute(file, data)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, path, err)
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, fp, err)
return err
}
return nil

View File

@@ -5,7 +5,6 @@ import (
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"slices"
"strings"
@@ -23,10 +22,9 @@ var templateFiles = []string{"edit.html", "add.html", "view.html", "preview.html
type templateStore struct {
sync.RWMutex
// template is a map of parsed HTML templates. The key is their path name. By default, the map only contains
// template is a map of parsed HTML templates. The key is their filepath name. By default, the map only contains
// top-level templates like "view.html". Subdirectories may contain their own templates which override the
// templates in the root directory. If so, they are paths like "dir/view.html", not filepaths. Use
// filepath.ToSlash() if necessary.
// templates in the root directory. If so, they are filepaths like "dir/view.html".
template map[string]*template.Template
}
@@ -58,8 +56,7 @@ func loadTemplate(fp string, info fs.FileInfo, err error) error {
log.Println("Cannot parse template:", fp, err)
// ignore error
} else {
// log.Println("Parse template:", path)
templates.template[filepath.ToSlash(fp)] = t
templates.template[fp] = t
}
}
return nil
@@ -75,7 +72,7 @@ func updateTemplate(fp string) {
} else {
templates.Lock()
defer templates.Unlock()
templates.template[filepath.ToSlash(fp)] = t
templates.template[fp] = t
log.Println("Parse template:", fp)
}
}
@@ -87,7 +84,7 @@ func removeTemplate(fp string) {
filepath.Dir(fp) != "." {
templates.Lock()
defer templates.Unlock()
delete(templates.template, filepath.ToSlash(fp))
delete(templates.template, fp)
log.Println("Discard template:", fp)
}
}
@@ -99,7 +96,7 @@ func renderTemplate(w http.ResponseWriter, dir, tmpl string, data any) {
base := tmpl + ".html"
templates.RLock()
defer templates.RUnlock()
t := templates.template[path.Join(dir, base)]
t := templates.template[filepath.Join(dir, base)]
if t == nil {
t = templates.template[base]
}

View File

@@ -6,6 +6,11 @@ download:
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
# (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 "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.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/

View File

@@ -6,14 +6,37 @@
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
t.addEventListener("keydown", (event) => {
if (event.ctrlKey && !event.altKey && !event.metaKey) {
let ch;
if (event.key == "i") {
ch = ["*", "*"];
} else if (event.key == "b") {
ch = ["**", "**"];
} else if (event.key == "k") {
ch = ["[", "]()"];
}
if (ch) {
event.preventDefault();
let s = t.value.substring(t.selectionStart, t.selectionEnd);
t.setRangeText(ch[0] + s + ch[1]);
}
}
});
});
</script>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required>{{if .IsBlog}}**{{.Today}}**. {{end}}</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">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -9,11 +9,11 @@
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre class="diff">
{{.Diff}}
</pre>

View File

@@ -4,18 +4,43 @@
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<script>
window.addEventListener("load", () => {
let t = document.getElementsByTagName('textarea').item(0);
t.addEventListener("keydown", (event) => {
if (event.ctrlKey && !event.altKey && !event.metaKey) {
let ch;
if (event.key == "i") {
ch = ["*", "*"];
} else if (event.key == "b") {
ch = ["**", "**"];
} else if (event.key == "k") {
ch = ["[", "]()"];
}
if (ch) {
event.preventDefault();
let s = t.value.substring(t.selectionStart, t.selectionEnd);
t.setRangeText(ch[0] + s + ch[1]);
}
}
});
});
</script>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{ or .Body (printf "# %s " .Today) | printf "%s" }}</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="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -5,18 +5,18 @@
<link>https://alexschroeder.ch/</link>
<managingEditor>alex@alexschroeder.ch (Alex Schroeder)</managingEditor>
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
<atom:link href="https://alexschroeder.ch/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://alexschroeder.ch/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Alex Schroeder.</description>
<image>
<url>https://alexschroeder.ch/view/logo.jpg</url>
<url>https://alexschroeder.ch/pics/alex-and-beard.jpg</url>
<title>{{.Title}}</title>
<link>https://alexschroeder.ch/</link>
</image>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://alexschroeder.ch/view/{{.Name}}</link>
<guid>https://alexschroeder.ch/view/{{.Name}}</guid>
<link>https://alexschroeder.ch/view/{{.Path}}</link>
<guid>https://alexschroeder.ch/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -27,9 +27,12 @@
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}
<p>

View File

@@ -69,15 +69,21 @@ window.addEventListener('load', uploadFiles.init);
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
@@ -85,7 +91,7 @@ window.addEventListener('load', uploadFiles.init);
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">

View File

@@ -6,16 +6,16 @@
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Path}}.rss" />
</head>
<body>
<header id="view">
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" 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">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>

View File

@@ -12,13 +12,14 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -2,20 +2,20 @@
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<link>https://campaignwiki.org/view/{{.Path}}</link>
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
<atom:link href="https://campaignwiki.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://campaignwiki.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the feed for the campaign wiki {{.Title}}.</description>
<image>
<url>https://campaignwiki.org/blue-mountain-logo.jpg</url>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<link>https://campaignwiki.org/view/{{.Path}}</link>
</image>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<guid>https://campaignwiki.org/view/{{.Name}}</guid>
<link>https://campaignwiki.org/view/{{.Path}}</link>
<guid>https://campaignwiki.org/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -37,7 +37,7 @@ img { max-width: 20%; }
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>

View File

@@ -76,15 +76,21 @@ window.addEventListener('load', uploadFiles.init);
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
@@ -92,7 +98,7 @@ window.addEventListener('load', uploadFiles.init);
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
@@ -100,7 +106,7 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>To delete a file, upload an empty file.
<p>You can rename or delete files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Pick files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -5,7 +5,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link rel="alternate" type="application/rss+xml" title="Campaign Wiki: {{.Title}}" href="/view/{{.Name}}.rss" />
<link rel="alternate" type="application/rss+xml" title="Campaign Wiki: {{.Title}}" href="/view/{{.Path}}.rss" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
@@ -22,9 +22,9 @@ img { max-width: 100%; }
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/add/{{.Path}}" accesskey="a">Add</a>
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="u">Zip</a>
<form role="search" action="/search/{{.Dir}}" method="GET">

View File

@@ -30,7 +30,7 @@ p { margin: 0.5ch 0 0 0; }
</header>
<main>
<h1>{{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="30" lang="" autofocus>{{printf "# %s" .Today | or .Body | printf "%s"}}</textarea>
<input type="hidden" name="notify" value="on">
<p><input id="send" type="submit" value="Save">

8
themes/chat/index.md Normal file
View File

@@ -0,0 +1,8 @@
# Chat theme
The chat theme is for people whose attention is drifting, who like
microblogs, who like to post little snippets, who like their blog to
look like some instant messaging app.
- [README](README)
- [Themes](../index)

View File

@@ -35,7 +35,7 @@ button { font-size: large; background-color: #eee; color: inherit; border-radius
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>

View File

@@ -22,11 +22,17 @@ label { display: inline-block; width: 7ch }
<main>
<h1>Upload</h1>
{{if ne .Last ""}}
<p>Use the following for <a href="/view/{{.Dir}}{{.Today}}">{{.Today}}</a>:
<pre>![]({{.Last}})</a></pre>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}">
{{end}}
<p>Use the following to post the image:
<pre>![]({{.Last}})</a></pre>
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>What name to use for the uploads.

View File

@@ -13,23 +13,33 @@ label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px; }
main > *, footer { clear: both; }
main p {
main > p, main > ul, main > ol, main > dl {
float: right;
color: #000; background: #8fd;
padding: 3px 1ch; margin: 1pt auto 1pt 5ch;
border-radius: 6px; border: 1px outset #eee; }
main blockquote {
main blockquote, ul p, ol p, dl p {
padding: 0; margin: 0; }
main blockquote p {
float: left;
color: #000; background: #ccc;
padding: 3px 1ch; margin: 1pt 5ch 1pt 0;
border-radius: 6px; border: 1px outset #eee; }
p + blockquote > p, blockquote + p { margin-top: 5pt; }
main ul, main ol, main dl {
float: left;
color: #000; background: #4ed;
padding: 3px 1ch; margin: 1pt 0; border-radius: 6px; border: 1px outset #eee; }
p + blockquote > p, blockquote + p {
margin-top: 5pt; }
/* for the marker */
main ul {
padding-left: 2em; }
main ol {
display: table; }
ol li {
counter-increment: list-item;
display: table-row; }
ol li::before {
content: counter(list-item) ".\a0";
display: table-cell;
text-align: right; }
/* footer */
footer p { margin: 0.5ch 0 0 0; }
textarea {
width: 97%; margin: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
@@ -42,8 +52,8 @@ img { max-width: 100%; margin-top: 5px; }
<header>
<a href="index">Home</a>
<a href="{{.Today}}" accesskey="t">Today</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/upload/{{.Dir}}?filename={{.Today}}-pic-1.jpg" accesskey="u">Upload</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Suchen:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
@@ -55,7 +65,7 @@ img { max-width: 100%; margin-top: 5px; }
{{.Html}}
</main>
<footer>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="4" cols="30" placeholder="Text" lang="" autofocus required></textarea>
<input type="hidden" name="notify" value="on">
<p><input id="send" type="submit" value="Send">

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" 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">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>

View File

@@ -12,13 +12,13 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Bearbeiten von {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</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="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -3,13 +3,13 @@
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<link>https://flying-carpet.ch/</link>
<atom:link href="https://flying-carpet.ch/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://flying-carpet.ch/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Claudia.</description>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://flying-carpet.ch/view/{{.Name}}</link>
<guid>https://flying-carpet.ch/view/{{.Name}}</guid>
<link>https://flying-carpet.ch/view/{{.Path}}</link>
<guid>https://flying-carpet.ch/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -36,7 +36,7 @@ img { max-width: 20%; }
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Nächste Seite</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>

View File

@@ -76,15 +76,21 @@ window.addEventListener('load', uploadFiles.init);
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
<input type="hidden" name="body" value="{{range .Actual}}![]({{.}})
{{end}}">
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.

View File

@@ -19,7 +19,7 @@ img { max-width: 100%; }
<body>
<header>
<a href="/view/index">Willkommen</a>
<a href="/edit/{{.Name}}" accesskey="e">Bearbeiten</a>
<a href="/edit/{{.Path}}" accesskey="e">Bearbeiten</a>
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Suchen:</label>

View File

@@ -1,6 +1,6 @@
# Themes
[Oddµ](../index) uses HTML templates to create its pages. The following
[Oddμ](../index) uses HTML templates to create its pages. The following
subdirectories contain different themes for you to use and adapt for
your own sites.

View File

@@ -12,8 +12,10 @@ Now, the text is still saved in Markdown files and the Markdown is
still rendered to HTML but the "view" template just prints the page
body inside a "pre" block and ignores the rendered HTML.
This being text files, there are also no links to follow. That is why
there's no link here back to themes. Sorry!
In addition to that, a piece of JavaScript goes through the text and
replaces rocket links with actual links. Rocket links are how Gemini
links to other pages: On a line by itself (no inline links!) write
"=>", a space, the URL, and optionally another space and the text to
use.
This also means that you can only edit new files by editing the URL in
the address bar of your browser since you can't link to them.
=> ../index Themes

View File

@@ -10,10 +10,10 @@ form, textarea { width: 100% }
</style>
</head>
<body>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

10
themes/plain/index.md Normal file
View File

@@ -0,0 +1,10 @@
# Plain theme
The plain theme is for people who like their post to look like a
plain-text file, reminiscent of Gopher.
In an ironic reversal of priorities, this theme uses JavaScript to
create rocket links, Gemini-style.
=> README
=> ../index Themes

View File

@@ -18,8 +18,8 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">Home</a>
<a href="#main">Skip</a>
<a href="/view/{{.Dir}}">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
@@ -35,11 +35,11 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Name}}"><img loading="lazy" src="/view/{{.Name}}"></a><br/>{{.Html}}
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}

View File

@@ -5,17 +5,38 @@
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
label { display: inline-block; width: 10ch }
</style>
<script>
window.onload = rocketLinks;
function rocketLinks() {
const elements = document.getElementsByTagName("pre");
const re = /^=&gt; *(\S+) *(.*)/gm;
for (const element of elements) {
element.innerHTML = element.innerHTML.replaceAll(
/^=&gt; *(\S+) *(.*)/gm,
function (match, href, text, offset) {
text ||= href;
return `⇒ <a href="${href}">${text}</a>` })}}
</script>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">Home</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="../index">Up</a>
<a href="/edit/{{.Path}}" accesskey="e">Edit</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" required>
<button>Go</button>
</form>
<form action="/edit/{{.Dir}}" method="GET">
<label for="id">New page:</label>
<input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Today}}" required>
<button>Edit</button>
</form>
</header>
<main>
<pre>

View File

@@ -14,11 +14,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" 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">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -16,11 +16,11 @@ pre { white-space: normal; color: #222; background-color: #ddd; border: 1px soli
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
<a href="/view/{{.Path}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>

View File

@@ -14,13 +14,13 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</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="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<link>https://transjovian.org/</link>
<managingEditor>jupiter@transjovian.org (Ashivom Bandaralum)</managingEditor>
<webMaster>jupiter@transjovian.org (Ashivom Bandaralum)</webMaster>
<atom:link href="https://transjovian.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<atom:link href="https://transjovian.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Ashivom Bandaralum.</description>
<image>
<url>https://transjovian.org/jupiter.svg</url>
@@ -15,8 +15,8 @@
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://transjovian.org/view/{{.Name}}</link>
<guid>https://transjovian.org/view/{{.Name}}</guid>
<link>https://transjovian.org/view/{{.Path}}</link>
<guid>https://transjovian.org/view/{{.Path}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}

View File

@@ -38,7 +38,7 @@ img { max-width: 20%; }
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>

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