50 Commits
v1.8 ... v1.9

Author SHA1 Message Date
Alex Schroeder
380692d616 Replace the .Language property with p.Language()
This computes it on demand for the templates and it is not used
anywhere else.

Also use it for the add.html and edit.html templates.
2024-03-15 00:28:40 +01:00
Alex Schroeder
7ad04e561c Rename p.isBlog to p.IsBlog
This makes it usable in templates.
2024-03-15 00:21:23 +01:00
Alex Schroeder
0e0c9f3bb5 Use oddmu instead of groff for HTML output 2024-03-13 14:57:50 +01:00
Alex Schroeder
7d4530383e Don't require an empty target dir for static site 2024-03-12 23:46:43 +01:00
Alex Schroeder
df9439c356 Enable AutoHeadingIDs 2024-03-10 23:12:33 +01:00
Alex Schroeder
b4ee637600 Update the upload template for templates 2024-03-10 19:36:30 +01:00
Alex Schroeder
2cd5a38885 Revert id change for the upload form 2024-03-10 19:09:02 +01:00
Alex Schroeder
149f4cf7a4 Add drop to the upload template 2024-03-10 18:34:10 +01:00
Alex Schroeder
d26e3479c9 Add paste to the upload template 2024-03-10 14:04:03 +01:00
Alex Schroeder
2e94abfabb Default upload template shows Markdown link 2024-03-10 10:59:37 +01:00
Alex Schroeder
9116095f69 File upload tries to use non-existing files 2024-03-09 19:39:58 +01:00
Alex Schroeder
a82bbba62b Add note on the AGPL to the README 2024-03-08 23:08:59 +01:00
Alex Schroeder
3066574167 Add the Oddmu mascot 2024-03-08 18:26:35 +01:00
Alex Schroeder
e065888279 Switch from lipgloss to wordwrap 2024-03-08 18:26:07 +01:00
Alex Schroeder
4e81401b8c Update themes 2024-03-08 12:42:31 +01:00
Alex Schroeder
acac745e1f Rewrite staticCmd to use a lot of go routines
Starting a go routine that walks the directory tree, adding files to a
tasks channel and then closing it; starting a bunch of worker go
routines that take files from the tasks channel until there is nothing
left to do; putting errors on a results channel and when they’re done,
putting a true value on a done channel; a watcher go routine that
checks the done channel and if every worker is done, close the results
channel; and the main program goes through all the values on the
results channel and sets up some short-circuiting in the case of
errors and otherwise it prints the counter. What a glorious spaghetti
mess of code!
2024-03-08 12:42:08 +01:00
Alex Schroeder
3f542388db Warn of existing files instead of overwriting them 2024-03-08 00:34:44 +01:00
Alex Schroeder
8a275c103a Fix static cmd setup 2024-03-07 15:25:22 +01:00
Alex Schroeder
2934471ed3 Don't link template files 2024-03-07 15:12:44 +01:00
Alex Schroeder
86cd3baa89 Document that webp images are decoded 2024-03-07 15:01:34 +01:00
Alex Schroeder
95b0c6951d Static subcommand does not write empty feeds
Also improve the oddmu-static(1) man page and note name-clash
problems.
2024-03-07 09:13:18 +01:00
Alex Schroeder
f935237fcb Fix static feed generation 2024-03-06 19:33:56 +01:00
Alex Schroeder
50d603781e Improve the scdoc → Markdown 2024-03-06 19:18:18 +01:00
Alex Schroeder
0a37ac34d5 Fix encoding errors when writing static .rss files
Using p.Name as a filepath is not cool because the name comes percent
escaped.
2024-03-06 14:39:37 +01:00
Alex Schroeder
14f9f783eb Static command writes RSS files, too
For every index and for every potential hashtag file, the feed is
written as well.
2024-03-06 08:41:22 +01:00
Alex Schroeder
def0534771 Notify command uses Writer
The output of notify doesn't need to go to Stderr.
2024-03-06 08:40:44 +01:00
Alex Schroeder
8c70bd5c7b go fmt 2024-03-05 09:46:00 +01:00
Alex Schroeder
81f2dc3ace Fix handling of id query parameter
With the id query parameter, the viewHandler actually doesn't require
a page. Therefore, all the calls to makeHandler(viewHandler, true)
were changed to makeHandler(viewHandler, false). The code of
makeHandler was rewritten so that r.ParseForm only called for GET
requests such as not to confuse dropHandler.
2024-03-05 09:44:58 +01:00
Alex Schroeder
07aa4f0064 clean target removes man/README.md
This is now also a generated file.
2024-03-05 08:38:19 +01:00
Alex Schroeder
926c4faf06 Don't print imports in man test 2024-03-05 08:38:01 +01:00
Alex Schroeder
4636b10cce Remove id attribute from main element
This isn't used in the CSS and I'm trying to make the HTML and CSS as
simple as possible.
2024-03-05 08:11:39 +01:00
Alex Schroeder
114e2b8790 Document how to list space requirements 2024-03-04 15:54:09 +01:00
Alex Schroeder
1fa26ab6f9 Rewrite oddmu-templates man page 2024-03-04 09:22:27 +01:00
Alex Schroeder
69d2f452d1 Upload a modified README to the documentation site 2024-03-03 09:40:09 +01:00
Alex Schroeder
c3b3bbb1c4 Add license info to the list of non-standard licenses 2024-03-02 22:29:54 +01:00
Alex Schroeder
07d395ab1e Updated year for the license 2024-03-02 22:15:35 +01:00
Alex Schroeder
6ed4e17767 Discuss all the external libraries in the README
Add a test to make sure that all the libraries used directly are
mentioned in the README.
2024-03-02 22:14:03 +01:00
Alex Schroeder
10bbda4a6e Whitespace 2024-03-02 20:29:33 +01:00
Alex Schroeder
006b8211f4 Fix version command test 2024-03-02 20:29:24 +01:00
Alex Schroeder
1885fdaaad More work on the chat theme 2024-03-02 14:26:34 +01:00
Alex Schroeder
cd6fb187b4 Add more emoji 2024-03-02 13:19:27 +01:00
Alex Schroeder
4f5b49a065 Changes to the upload template
And the "chat" theme that makes use of this change.
2024-03-02 12:13:26 +01:00
Alex Schroeder
7fbdac3f9a Add a target for a binary package
The "install" target no longer depends on build and docs so that it
can run from a binary-only installation. In that case, there are no Go
source files and there's no need to recreate the man pages.
2024-03-01 22:56:57 +01:00
Alex Schroeder
38f13dc8f8 Mention problems cross-compiling the HEIC library 2024-03-01 22:55:26 +01:00
Alex Schroeder
e3ceaf031e The archive link in the campaign wiki theme 2024-03-01 21:23:55 +01:00
Alex Schroeder
5feb5f9b21 Add upload target for themes
Add --itemize-changes to the download target, too.
2024-03-01 15:10:09 +01:00
Alex Schroeder
086e65c304 Add themes subdirectory 2024-03-01 13:23:15 +01:00
Alex Schroeder
ffaae10f94 Serve files with a MIME-type, if possible
This adds a new dependency: https://github.com/gabriel-vasile/mimetype
2024-03-01 12:55:08 +01:00
Alex Schroeder
6c6d326c59 Add handling of relative links to missing command
Links such as [up](../index) are now handled correctly by joining it
to the directory of the page containing this link. A page foo/bar with
such a link no longer registers as linking to a missing page if the
index page exists. Note that [up](..) still registers as a link to a
missing page because the code does not take redirects from directories
to index pages into account.
2024-03-01 12:52:52 +01:00
Alex Schroeder
f41d6b8e9e Rework the missing pages command
Move finding of existing pages into a separate function.
2024-03-01 12:52:32 +01:00
93 changed files with 2690 additions and 383 deletions

View File

@@ -1,4 +1,4 @@
This software is Copyright (c) 20152023 by Alex Schroeder.
This software is Copyright (c) 20152024 by Alex Schroeder.
This is free software, licensed under:

View File

@@ -44,6 +44,16 @@ upload: build
docs:
cd man; make
install: build docs
install:
for n in 1 5 7; do install -D -t ${PREFIX}/share/man/man$$n man/*.$$n; done
install -D -t ${PREFIX}/.local/bin oddmu
# More could be added, of course!
dist: oddmu-linux-amd64.tar.gz
oddmu-linux-amd64: *.go
GOOS=linux GOARCH=amd64 go build -o $@
%.tar.gz: %
tar czf $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/

131
README.md
View File

@@ -1,34 +1,38 @@
# Oddµ: A minimal wiki
This program helps you run a minimal wiki. There is no version
history. It's well suited as a *secondary* medium: collaboration and
conversation happens elsewhere, in chat, on social media. The wiki
serves as the text repository that results from these discussions.
This program helps you run a minimal wiki, blog, digital garden, memex
or Zettelkasten. There is no version history.
If you're the only user and it just runs on your laptop, then you can
think of it as a [memex](https://en.wikipedia.org/wiki/Memex), a
memory extender.
It's well suited as a self-hosted, single-user web application, when
there is no need for collaboration on the site itself. Links and email
connect you to the rest of the net. The wiki can be public or private.
Perhaps it just runs on your local machine, unreachable from the
Internet.
Oddµ can be used as a web server behind a reverse proxy such as Apache
or it can be used as a static site generator.
It's well suited as a secondary medium for a close-knit group:
collaboration and conversation happens elsewhere, in chat, on social
media. The wiki serves as the text repository that results from these
discussions. As there are no logins and no version histories, it is
not possible to undo vandalism and spam. Only allow people you trust
write-access to the site.
It's well suited as a simple static site generator. There are no
plugins.
When Oddµ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages and allows you to edit them.
(ending in `.md`) as web pages. These pages can be edited via the web.
If your files don't provide their own title (`# title`), the file name
(without `.md`) is used for the title. Subdirectories are created as
Oddmu adds the following extensions to Markdown: local links `[[like
this]]`, hashtags `#Like_This` and fediverse account links like
`@alex@alexschroeder.ch`.
If your pages don't provide their own title (`# title`), the file name
(without `.md`) is used as the title. Subdirectories are created as
necessary.
Oddµ uses a [Markdown library](https://github.com/gomarkdown/markdown)
to generate the web pages from Markdown. Oddmu adds the following
extensions: local links `[[like this]]`, hashtags `#Like_This` and
fediverse account links like `@alex@alexschroeder.ch`.
The [lingua](https://github.com/pemistahl/lingua-go) library detects
languages in order to get hyphenation right.
The standard [html/template](https://pkg.go.dev/html/template) library
is used to generate HTML.
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).
## Documentation
@@ -132,6 +136,13 @@ 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
repository and build it locally.
## Running
The working directory is where pages are saved and where templates are
@@ -178,10 +189,6 @@ cd /usr/local/stow
sudo stow oddmu
```
## Bugs
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
## Hacking
If you're interested in making changes to the code, here's a
@@ -216,6 +223,18 @@ high-level introduction to the various source files.
- `watch.go` implements the filesystem notification watch
- `wiki.go` implements the main function
The code of this package is licensed to you under the
AGPL-3.0-or-later license. If you do make changes and your site is
public, be aware of section 13:
> … if you modify the Program, your modified version must prominently
> offer all users interacting with it remotely through a computer
> network (if your version supports such interaction) an opportunity
> to receive the Corresponding Source of your version by providing
> access to the Corresponding Source from a network server at no
> charge, through some standard or customary means of facilitating
> copying of software.
### Changing the markup rules
If you want to change the markup rules, your starting point should be
@@ -260,6 +279,66 @@ to Oddmu, or you can require authentication for certain actions.
Furthermore, you can do the same for directories, allowing you to use
subdirectories as separate sites, each with their own editors.
## Dependencies
This section lists the non-standard libraries Oddmu uses and their
respective licenses.
[github.com/gomarkdown/markdown](https://github.com/gomarkdown/markdown)
is used to generate the web pages from Markdown. BSD-2-Clause.
[github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday)
is used to strip rendered search results of all HTML except for the
bold tag. Regular HTML generated from pages is *not* sanitized. Don't
give people you don't trust access to your wiki. BSD-3-Clause.
[github.com/pemistahl/lingua-go](https://github.com/pemistahl/lingua-go)
detects languages in order to set the language tag in templates. This
in turn can be used by browsers to get hyphenation right. Apache-2.0.
[github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype)
is used to sniff the MIME type of files with unknown filename
extensions. MIT.
[github.com/bashdrew/goheif](https://github.com/bashdrew/goheif) is
used to decode HEIC files (the new default file format for photos on
iPhones). LGPL-3.0-only.
[github.com/disintegration/imaging](https://github.com/disintegration/imaging)
is used to resize images. MIT.
[github.com/edwvee/exiffix](https://github.com/edwvee/exiffix) is used
to rotate images before resizing them if the EXIF data says the image
wasn't taken with the default orientation of the camera. This is
necessary because after resizing, the EXIF data is gone. MIT.
[github.com/google/subcommands](https://github.com/google/subcommands)
is used for the parsing and documenting of subcommands. Apache-2.0.
[github.com/muesli/reflow/wordwrap](https://github.com/muesli/reflow/wordwrap)
is used to wrap the search subcommand output. MIT.
[github.com/hexops/gotextdiff](https://github.com/hexops/gotextdiff)
is used to show a compact unified diff on the command line before
doing any replacements. BSD-3-Clause.
[github.com/sergi/go-diff/diffmatchpatch](https://github.com/sergi/go-diff/diffmatchpatch)
is used to show the page diffs on the web. MIT.
[github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify)
is used to watch the filesystem for changes. BSD-3-Clause.
[golang.org/x/exp/constraints](https://golang.org/x/exp/constraints)
for the computation of the intersection between two sets of pages.
BSD-3-Clause.
[github.com/stretchr/testify/assert](https://github.com/stretchr/testify/assert)
is used for testing. MIT.
## Bugs
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
## References
[Writing Web Applications](https://golang.org/doc/articles/wiki/)

View File

@@ -13,7 +13,7 @@ form, textarea { width: 100%; }
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
<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>

View File

@@ -34,7 +34,7 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, true),
assert.HTTPBody(makeHandler(viewHandler, false),
"GET", "/view/testdata/add/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true),
@@ -42,7 +42,7 @@ It's not `)}
HTTPRedirectTo(t, makeHandler(appendHandler, true),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, true),
assert.HTTPBody(makeHandler(viewHandler, false),
"GET", "/view/testdata/add/fire", nil))
}

View File

@@ -29,7 +29,7 @@ func (p *Page) notify() error {
log.Printf("Updating changes in %s failed: %s", dir, err)
return err
}
if p.isBlog() {
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")) {
err := addLink(path.Join(dir, "index"), true, link, re)

View File

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

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"testing"
@@ -15,7 +16,7 @@ func TestEditSave(t *testing.T) {
data.Set("body", "Hallo!")
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, true),
HTTPRedirectTo(t, makeHandler(viewHandler, false),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
@@ -24,7 +25,7 @@ func TestEditSave(t *testing.T) {
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true),
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false),
"GET", "/view/testdata/save/alex", nil),
"Hallo!")
// Delete the page and you're sent to the empty page
@@ -32,7 +33,7 @@ func TestEditSave(t *testing.T) {
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Viewing the non-existing page redirects to the edit page (like in the beginning)
HTTPRedirectTo(t, makeHandler(viewHandler, true),
HTTPRedirectTo(t, makeHandler(viewHandler, false),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
@@ -70,7 +71,17 @@ func TestEditSaveChanges(t *testing.T) {
// </form>
func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
data := url.Values{}
data.Set("id", "testdata/id/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
"GET", "/edit/", data, http.StatusBadRequest,
"No slashes in id")
data.Set("id", ".alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
"GET", "/edit/", data, http.StatusForbidden,
"No hidden files")
data.Set("id", "alex")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
"GET", "/edit/?id=testdata/id/alex", nil),
"GET", "/edit/testdata/id/", data),
"Editing testdata/id/alex")
}

View File

@@ -13,10 +13,10 @@ import (
// Item is a Page plus a Date.
type Item struct {
// Page is the page being used as the feed item.
Page
// Date is the last modification date of the file storing the page. As the pages used by Oddmu are plain
// Markdown files, they don't contain any metadata. Instead, the last modification date of the file is used.
// This makes it work well with changes made to the files outside of Oddmu.

View File

@@ -8,13 +8,13 @@ import (
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index.rss", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
"Welcome to Oddµ")
}
func TestNoFeed(t *testing.T) {
assert.HTTPStatusCode(t,
makeHandler(viewHandler, true), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
makeHandler(viewHandler, false), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
}
func TestFeedItems(t *testing.T) {
@@ -44,12 +44,12 @@ Writing poems about plants.
* [My Dragon Tree](dragon)`)}
p3.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/feed/plants.rss", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/feed/plants.rss", nil)
assert.Contains(t, body, "<title>Plants</title>")
assert.Contains(t, body, "<title>Cactus</title>")
assert.Contains(t, body, "<title>Dragon</title>")
assert.Contains(t, body, "&lt;h1&gt;Cactus&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1&gt;Dragon&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1 id=&#34;cactus&#34;&gt;Cactus&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1 id=&#34;dragon&#34;&gt;Dragon&lt;/h1&gt;")
assert.Contains(t, body, "<category>Succulent</category>")
assert.Contains(t, body, "<category>Palmtree</category>")
}

8
go.mod
View File

@@ -4,14 +4,15 @@ go 1.21.0
require (
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
github.com/charmbracelet/lipgloss v0.9.1
github.com/disintegration/imaging v1.6.2
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.26
github.com/muesli/reflow v0.3.0
github.com/pemistahl/lingua-go v1.4.0
github.com/sergi/go-diff v1.3.1
github.com/stretchr/testify v1.8.4
@@ -19,15 +20,10 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // 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

13
go.sum
View File

@@ -1,11 +1,7 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd h1:SxkQeH4jjXT0zMgiRgkiIQjIvWfe9vXuTAmE3cfcQrU=
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd/go.mod h1:p1sbxRy+MY71fEWHcfRmerC8WUYXDFCExF9A7aXwp98=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,6 +11,8 @@ github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAE
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
@@ -30,10 +28,6 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -41,8 +35,6 @@ github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3r
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -68,7 +60,6 @@ golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -11,14 +11,14 @@ func TestHtmlCmd(t *testing.T) {
b := new(bytes.Buffer)
s := htmlCli(b, false, []string{"index"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `<h1>Welcome to Oddµ</h1>
r := `<h1 id="welcome-to-oddµ">Welcome to Oddµ</h1>
<p>Hello! 🙃</p>
<p>Check out the <a href="README">README</a>.</p>
<p>Check out the <a href="README">README</a> and <a href="themes">themes</a>.</p>
<p>Or <a href="test">create a new page</a>.</p>
`
assert.Equal(t, r, b.String())
assert.Equal(t, b.String(), r)
}

View File

@@ -2,6 +2,6 @@
Hello! 🙃
Check out the [[README]].
Check out the [[README]] and [[themes]].
Or [create a new page](test).

View File

@@ -56,3 +56,9 @@ func language(s string) string {
}
return ""
}
// Language returns the language used for the page, as a lower case
// ISO 639-1 string, e.g. "en" or "de".
func (p *Page) Language() string {
return language(p.plainText())
}

View File

@@ -10,8 +10,10 @@ man: ${MAN}
html: ${HTML}
%.html: %
groff -mandoc -Dutf8 -Thtml $< | sed 's/<style type="text\/css">/<style type="text\/css">\n body {font-family: mono; max-width: 80ch }/' > $@
%.html: %.md
echo '<!DOCTYPE html>' > $@
oddmu html $(basename $<) | sed --regexp-extended \
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
md: ${MD}
@@ -20,16 +22,22 @@ md: ${MD}
-e 's/\*([^*]+)\*/**\1**/g' \
-e 's/_(oddmu[a-z.-]*)_\(([1-9])\)/[\1(\2)](\1.\2)/g' \
-e 's/\b_([^_]+)_\b/*\1*/g' \
-e 's/^#/##/' \
-e 's/^# /## /' \
-e 's/#([^ #])/\\#\1/' \
-e 's/^([A-Z.-]*\([1-9]\))( ".*")?$$/# \1/' \
< $< > $@
upload: ${MD}
README.md: ../README.md
sed --regexp-extended \
-e 's/\]\(.*\/(.*)\.txt\)/](\1)/' \
< $< > $@
upload: ${MD} README.md
rsync --itemize-changes --archive *.md sibirocobombus:alexschroeder.ch/wiki/oddmu/
make clean
clean:
rm --force ${HTML} ${MD}
rm --force ${HTML} ${MD} README.md
realclean: clean
rm --force ${MAN}

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2024-02-26"
.TH "ODDMU-RELEASES" "7" "2024-03-10"
.PP
.SH NAME
.PP
@@ -15,6 +15,42 @@ oddmu-releases - what'\&s new in this releases?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.9 (2024)
.PP
There is a change to make to copies of \fIupload.\&html\fR if subdirectories are being
used.\& The \fILast\fR property no longer contains the directory.\& It has to be added
to the template as follows:
.PP
.nf
.RS 4
{{if ne \&.Last ""}}
<p>Previous upload: <a href="/view/{{\&.Dir}}{{\&.Last}}">{{\&.Last}}</a></p>
{{if \&.Image}}
<p><img class="last" src="/view/{{\&.Dir}}{{\&.Last}}"></p>
{{end}}
{{end}}
.fi
.RE
.PP
You can use the \fILast\fR property without a directory to suggest the markup to
use, for example:
.PP
.nf
.RS 4
<p>Use the following for <a href="/view/{{\&.Dir}}{{\&.Today}}">{{\&.Today}}</a>:
<pre>![]({{\&.Last}})</a></pre>
.fi
.RE
.PP
The upload template can use the \fIToday\fR property.\&
.PP
The upload template comes with JavaScript that allows users to paste images or
drag and drop files.\&
.PP
The upload template changed the id for the filename field from `text` to `name`.\&
.PP
The source repository now comes with example templates.\&
.PP
.SS 1.8 (2024)
.PP
No user-visible changes.\& Documentation and code comments got better.\&

View File

@@ -8,6 +8,38 @@ oddmu-releases - what's new in this releases?
This page lists user-visible features and template changes to consider.
## 1.9 (2024)
There is a change to make to copies of _upload.html_ if subdirectories are being
used. The _Last_ property no longer contains the directory. It has to be added
to the template as follows:
```
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a></p>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"></p>
{{end}}
{{end}}
```
You can use the _Last_ property without a directory to suggest the markup to
use, for example:
```
<p>Use the following for <a href="/view/{{.Dir}}{{.Today}}">{{.Today}}</a>:
<pre>![]({{.Last}})</a></pre>
```
The upload template can use the _Today_ property.
The upload template comes with JavaScript that allows users to paste images or
drag and drop files.
The upload template changed the id for the filename field from `text` to `name`.
The source repository now comes with example templates.
## 1.8 (2024)
No user-visible changes. Documentation and code comments got better.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2024-02-26"
.TH "ODDMU-STATIC" "1" "2024-03-12"
.PP
.SH NAME
.PP
@@ -18,13 +18,18 @@ oddmu-static - create a static copy of the site
.SH DESCRIPTION
.PP
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given destination directory.\& The destination
directory must not exist.\&
directory and saves them in the given destination directory.\& Existing files are
only overwritten if they are older than the source file.\&
.PP
All pages (files with the ".\&md" extension) are turned into HTML files (with the
".\&html" extension) using the "static.\&html" template.\& Links pointing to existing
pages get ".\&html" appended.\&
.PP
If a page has a name case-insensitively matching a hashtag, a feed file is
generated (ending with ".\&rss") if any suitable links are found.\& A suitable link
for a feed item must appear in a bullet list item using an asterisk ("*").\& If
no feed items are found, no feed is written.\&
.PP
Hidden files and directories (starting with a ".\&") and backup files (ending with
a "~") are skipped.\&
.PP
@@ -48,16 +53,22 @@ original, too.\&
.PP
.SH EXAMPLE
.PP
Generate a static copy of the site:
Generate a static copy of the site, but only loading language detection for
German and English, significantly reducing the time it takes to generate the
static site:
.PP
.nf
.RS 4
oddmu static \&.\&./archive
env ODDMU_LANGUAGES=de,en oddmu static \&.\&./archive
.fi
.RE
.PP
.SH LIMITATIONS
.PP
There can be nameclashes with generated HTML and RSS files and existing files
ending in ".\&html" and ".\&rss".\& Instead of overwriting existing files in these
cases, a warning is printed.\&
.PP
Links from files to pages do not get ".\&html" appended.\& This affects existing
HTML or XML files including SVG files.\&
.PP

View File

@@ -11,13 +11,18 @@ oddmu-static - create a static copy of the site
# DESCRIPTION
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given destination directory. The destination
directory must not exist.
directory and saves them in the given destination directory. Existing files are
only overwritten if they are older than the source file.
All pages (files with the ".md" extension) are turned into HTML files (with the
".html" extension) using the "static.html" template. Links pointing to existing
pages get ".html" appended.
If a page has a name case-insensitively matching a hashtag, a feed file is
generated (ending with ".rss") if any suitable links are found. A suitable link
for a feed item must appear in a bullet list item using an asterisk ("\*"). If
no feed items are found, no feed is written.
Hidden files and directories (starting with a ".") and backup files (ending with
a "~") are skipped.
@@ -41,14 +46,20 @@ original, too.
# EXAMPLE
Generate a static copy of the site:
Generate a static copy of the site, but only loading language detection for
German and English, significantly reducing the time it takes to generate the
static site:
```
oddmu static ../archive
env ODDMU_LANGUAGES=de,en oddmu static ../archive
```
# LIMITATIONS
There can be nameclashes with generated HTML and RSS files and existing files
ending in ".html" and ".rss". Instead of overwriting existing files in these
cases, a warning is printed.
Links from files to pages do not get ".html" appended. This affects existing
HTML or XML files including SVG files.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-02-17" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2024-03-14" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -13,13 +13,37 @@ oddmu-templates - how to write the templates
.PP
.SH SYNOPSIS
.PP
These files act as HTML templates: add.\&html, diff.\&html, edit.\&html, feed.\&html,
search.\&html, static.\&html, upload.\&html and view.\&html.\& They contain special
placeholders in double bracers {{like this}}.\&
These files act as HTML templates: \fIadd.\&html\fR, \fIdiff.\&html\fR, \fIedit.\&html\fR,
\fIfeed.\&html\fR, \fIsearch.\&html\fR, \fIstatic.\&html\fR, \fIupload.\&html\fR and \fIview.\&html\fR.\& They
contain special placeholders in double bracers {{like this}}.\&
.PP
.SH SYNTAX
.SH DESCRIPTION
.PP
The templates can refer to the following properties of a page:
Each template receives an object and uses the object'\&s properties to replace the
placeholders.\&
.PP
.PD 0
.IP \(bu 4
\fIadd.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIdiff.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIedit.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIfeed.\&html\fR uses a \fIfeed\fR
.IP \(bu 4
\fIsearch.\&html\fR uses a \fIsearch\fR
.IP \(bu 4
\fIstatic.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIupload.\&html\fR uses an \fIupload\fR
.IP \(bu 4
\fIview.\&html\fR uses a \fIpage\fR
.PD
.PP
.SS Page
.PP
A page has the following properties:
.PP
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own title, the
page name is used.\&
@@ -33,71 +57,92 @@ extension.\&
\fI{{.\&Base}}\fR is the basename of the current file (without the directory and
without the \fI.\&md\fR extension), escaped for use in URLs.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
like links or forms (see \fBEXAMPLE\fR below).\&
\fI{{.\&Language}}\fR is the suspected language of the page.\& This is used to set the
language on the \fIview.\&html\fR template.\& See "Non-English hyphenation" below.\&
.PP
For the \fIview.\&html\fR and \fIstatic.\&html\fR template:
.PP
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
\fI{{.\&Body}}\fR is the raw byte content of the page.\& Use \fI{{printf "%s" .\&Body}}\fR to
get the Markdown, as a string.\& This is used for the text area of the \fIedit.\&html\fR
template.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.PP
For the \fIdiff.\&html\fR template:
\fI{{.\&Html}}\fR contains some sort of HTML that depends on the template used.\&
.PP
\fI{{.\&Diff}}\fR is the diff for this page.\& This is only computed on demand so it can
be used in other templates, too.\& It probably doesn'\&t make much sense to do so,
however.\&
.PD 0
.IP \(bu 4
For \fIview.\&html\fR, it is the rendered Markdown, as HTML.\&
.IP \(bu 4
For \fIsearch.\&html\fR, it is a page summary, with bold matches, as HTML.\&
.IP \(bu 4
For \fIfeed.\&html\fR, it is the escaped (!\&) HTML of the feed item.\&
.PD
.PP
For the \fIedit.\&html\fR template:
\fI{{.\&Score}}\fR is a numerical score.\& It is only computed for \fIsearch.\&html\fR.\&
.PP
\fI{{printf "%s" .\&Body}}\fR is the Markdown, as a string (the data itself is a byte
array and that'\&s why we need to call \fIprintf\fR).\&
\fI{{.\&IsBlog}}\fR says whether the current page has a name starting with an ISO
date.\&
.PP
For the \fIsearch.\&html\fR template only:
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
like links or forms (see \fBEXAMPLE\fR below).\&
.PP
\fI{{.\&Diff}}\fR is the page diff for \fIdiff.\&html\fR.\& It is only computed on demand so
it can be used in other templates, too.\& It probably doesn'\&t make much sense to
do so, however.\&
.PP
.SS Feed
.PP
The feed contains an item for the head of the feed and an array of items.\&
.PP
\fI{{.\&Items}}\fR is the array of feed items.\& To refer to them, you need to use a
\fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
.PP
If page A links to pages B and C, the head of the feed is based on page A and
the list of items contains B and C.\&
.PP
An item is a page plus a date.\& All the properties of a page can be used (see
\fBPage\fR above).\&
.PP
\fI{{.\&Date}}\fR is the date of the last update to the page, in RFC 822 format.\&
.PP
.SS Search
.PP
\fI{{.\&Query}}\fR is the query string.\&
.PP
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-escaped except
for the slashes.\&
.PP
\fI{{.\&Items}}\fR is an array of pages (see \fBPage\fR above).\& To refer to them, you need
to use a \fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
.PP
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR and \fI{{.\&Next}}\fR are the previous, current and next
page number in the results since doing arithmetics in templates is hard.\& The
first page number is 1.\& The last page is expensive to dermine and so that isn'\&t
done.\&
first page number is 1.\& The last page is expensive to dermine and so that is not
available.\&
.PP
\fI{{.\&More}}\fR indicates if there are any more search results.\&
.PP
\fI{{.\&Results}}\fR indicates if there were any search results at all.\&
.PP
\fI{{.\&Items}}\fR is an array of pages, each containing a search result.\& A search
result is a page (with the properties seen above).\& Thus, to refer to them, you
need to use a \fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
.SS Upload
.PP
For items in the search result:
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.\&
.PP
\fI{{.\&Html}}\fR is the rendered Markdown of a page summary, as HTML.\&
\fI{{.\&Name}}\fR is the \fIfilename\fR query parameter.\&
.PP
\fI{{.\&Score}}\fR is a numerical score for search results.\&
\fI{{.\&Last}}\fR is the filename of the last image uploaded.\&
.PP
For the \fIfeed.\&html\fR template:
\fI{{.\&Image}}\fR is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in \fI.\&jpg\fR).\& If so, a thumbnail
can be shown by the template, for example.\&
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
\fI{{.\&MaxWidth}}\fR is the \fImaxwidth\fR query parameter, i.\&e.\& the value used for the
previous image uploaded.\&
.PP
\fI{{.\&Title}}\fR is the title of the underlying main page.\&
\fI{{.\&Quality}}\fR is the \fIquality\fR query parameter, i.\&e.\& the value used for the
previous image uploaded.\&
.PP
\fI{{.\&Date}}\fR is the date of the last update to the underlying main page, in RFC
822 format.\&
.PP
\fI{{.\&Items}}\fR is an array of feed items.\&
.PP
For items in the feed:
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
.PP
\fI{{.\&Title}}\fR is the title of the page.\&
.PP
\fI{{.\&Html}}\fR is the rendered Markdown, as escaped (!\&) HTML.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.PP
\fI{{.\&Date}}\fR, the date of the last update to this page, in RFC 822 format.\&
.PP
The \fIupload.\&html\fR template cannot refer to anything.\&
\fI{{.\&Today}}\fR is the current date, in ISO format.\&
.PP
.SS Non-English hyphenation
.PP
@@ -147,6 +192,16 @@ The following form allows people to edit the suggested page name.\&
.fi
.RE
.PP
The following puts the current date into the text area if and only if the page
itself is a blog page.\& Useful for \fIadd.\&html\fR:
.PP
.nf
.RS 4
<textarea name="body" rows="20" cols="80" placeholder="Text" lang=""
autofocus required>{{- if \&.IsBlog}}**{{\&.Today}}**\&. {{end}}</textarea>
.fi
.RE
.PP
.SH NOTES
.PP
The templates are always used as-is, irrespective of the current directory.\&
@@ -161,6 +216,10 @@ this case, a visitor looking at "/view/projects/wiki" following a link to
.PP
It'\&s up to you to decide what'\&s best for your site, of course.\&
.PP
If you want a link on \fIupload.\&html\fR to point to the current directory'\&s "index"
page, you need to use "/view/{{.\&Dir}}index" because if you link to "index" the
result points to "/upload/{{.\&Dir}}index".\&
.PP
Templates can be changed by uploading new copies of the template files.\&
.PP
Subdirectories can have their own copies of template files.\& One example use for

View File

@@ -6,13 +6,27 @@ oddmu-templates - how to write the templates
# SYNOPSIS
These files act as HTML templates: add.html, diff.html, edit.html, feed.html,
search.html, static.html, upload.html and view.html. They contain special
placeholders in double bracers {{like this}}.
These files act as HTML templates: _add.html_, _diff.html_, _edit.html_,
_feed.html_, _search.html_, _static.html_, _upload.html_ and _view.html_. They
contain special placeholders in double bracers {{like this}}.
# SYNTAX
# DESCRIPTION
The templates can refer to the following properties of a page:
Each template receives an object and uses the object's properties to replace the
placeholders.
- _add.html_ uses a _page_
- _diff.html_ uses a _page_
- _edit.html_ uses a _page_
- _feed.html_ uses a _feed_
- _search.html_ uses a _search_
- _static.html_ uses a _page_
- _upload.html_ uses an _upload_
- _view.html_ uses a _page_
## Page
A page has the following properties:
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
page name is used.
@@ -26,71 +40,87 @@ _{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Base}}_ is the basename of the current file (without the directory and
without the _.md_ extension), escaped for use in URLs.
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
like links or forms (see *EXAMPLE* below).
_{{.Language}}_ is the suspected language of the page. This is used to set the
language on the _view.html_ template. See "Non-English hyphenation" below.
For the _view.html_ and _static.html_ template:
_{{.Html}}_ is the rendered Markdown, as HTML.
_{{.Body}}_ is the raw byte content of the page. Use _{{printf "%s" .Body}}_ to
get the Markdown, as a string. This is used for the text area of the _edit.html_
template.
_{{.Hashtags}}_ is an array of strings.
For the _diff.html_ template:
_{{.Html}}_ contains some sort of HTML that depends on the template used.
_{{.Diff}}_ is the diff for this page. This is only computed on demand so it can
be used in other templates, too. It probably doesn't make much sense to do so,
however.
- For _view.html_, it is the rendered Markdown, as HTML.
- For _search.html_, it is a page summary, with bold matches, as HTML.
- For _feed.html_, it is the escaped (!) HTML of the feed item.
For the _edit.html_ template:
_{{.Score}}_ is a numerical score. It is only computed for _search.html_.
_{{printf "%s" .Body}}_ is the Markdown, as a string (the data itself is a byte
array and that's why we need to call _printf_).
_{{.IsBlog}}_ says whether the current page has a name starting with an ISO
date.
For the _search.html_ template only:
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
like links or forms (see *EXAMPLE* below).
_{{.Diff}}_ is the page diff for _diff.html_. It is only computed on demand so
it can be used in other templates, too. It probably doesn't make much sense to
do so, however.
## Feed
The feed contains an item for the head of the feed and an array of items.
_{{.Items}}_ is the array of feed items. To refer to them, you need to use a
_{{range .Items}}_ … _{{end}}_ construct.
If page A links to pages B and C, the head of the feed is based on page A and
the list of items contains B and C.
An item is a page plus a date. All the properties of a page can be used (see
*Page* above).
_{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
## Search
_{{.Query}}_ is the query string.
_{{.Dir}}_ is the directory in which the search starts, percent-escaped except
for the slashes.
_{{.Items}}_ is an array of pages (see *Page* above). To refer to them, you need
to use a _{{range .Items}}_ … _{{end}}_ construct.
_{{.Previous}}_, _{{.Page}}_ and _{{.Next}}_ are the previous, current and next
page number in the results since doing arithmetics in templates is hard. The
first page number is 1. The last page is expensive to dermine and so that isn't
done.
first page number is 1. The last page is expensive to dermine and so that is not
available.
_{{.More}}_ indicates if there are any more search results.
_{{.Results}}_ indicates if there were any search results at all.
_{{.Items}}_ is an array of pages, each containing a search result. A search
result is a page (with the properties seen above). Thus, to refer to them, you
need to use a _{{range .Items}}_ … _{{end}}_ construct.
## Upload
For items in the search result:
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.
_{{.Html}}_ is the rendered Markdown of a page summary, as HTML.
_{{.Name}}_ is the _filename_ query parameter.
_{{.Score}}_ is a numerical score for search results.
_{{.Last}}_ is the filename of the last image uploaded.
For the _feed.html_ template:
_{{.Image}}_ is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in _.jpg_). If so, a thumbnail
can be shown by the template, for example.
_{{.Name}}_ is the page name, escaped for use in URLs.
_{{.MaxWidth}}_ is the _maxwidth_ query parameter, i.e. the value used for the
previous image uploaded.
_{{.Title}}_ is the title of the underlying main page.
_{{.Quality}}_ is the _quality_ query parameter, i.e. the value used for the
previous image uploaded.
_{{.Date}}_ is the date of the last update to the underlying main page, in RFC
822 format.
_{{.Items}}_ is an array of feed items.
For items in the feed:
_{{.Name}}_ is the page name, escaped for use in URLs.
_{{.Title}}_ is the title of the page.
_{{.Html}}_ is the rendered Markdown, as escaped (!) HTML.
_{{.Hashtags}}_ is an array of strings.
_{{.Date}}_, the date of the last update to this page, in RFC 822 format.
The _upload.html_ template cannot refer to anything.
_{{.Today}}_ is the current date, in ISO format.
## Non-English hyphenation
@@ -136,6 +166,14 @@ The following form allows people to edit the suggested page name.
</form>
```
The following puts the current date into the text area if and only if the page
itself is a blog page. Useful for _add.html_:
```
<textarea name="body" rows="20" cols="80" placeholder="Text" lang=""
autofocus required>{{- if .IsBlog}}**{{.Today}}**. {{end}}</textarea>
```
# NOTES
The templates are always used as-is, irrespective of the current directory.
@@ -150,6 +188,10 @@ this case, a visitor looking at "/view/projects/wiki" following a link to
It's up to you to decide what's best for your site, of course.
If you want a link on _upload.html_ to point to the current directory's "index"
page, you need to use "/view/{{.Dir}}index" because if you link to "index" the
result points to "/upload/{{.Dir}}index".
Templates can be changed by uploading new copies of the template files.
Subdirectories can have their own copies of template files. One example use for

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-02-23"
.TH "ODDMU" "1" "2024-03-07"
.PP
.SH NAME
.PP
@@ -91,10 +91,10 @@ curl --form body="Did you bring a towel?"
.PP
When calling the \fIdrop\fR action, the query parameters used are \fIname\fR for the
target filename, \fIfile\fR for the file to upload.\& If the query parameter
\fImaxwidth\fR is set, an attempt is made to decode and resize the image.\& JPG, PNG
and HEIC files can be decoded.\& Only JPG and PNG files can be encoded, however.\&
If the target name ends in \fI.\&jpg\fR, the \fIquality\fR query parameter is also taken
into account.\& To upload some thumbnails:
\fImaxwidth\fR is set, an attempt is made to decode and resize the image.\& JPG, PNG,
WEBP and HEIC files can be decoded.\& Only JPG and PNG files can be encoded,
however.\& If the target name ends in \fI.\&jpg\fR, the \fIquality\fR query parameter is
also taken into account.\& To upload some thumbnails:
.PP
.nf
.RS 4
@@ -259,6 +259,14 @@ curl --form body="Did you bring a towel?"
.fi
.RE
.PP
To compute the space used by your setup, use regular tools:
.PP
.nf
.RS 4
du --exclude=\&'*/.*\&' --exclude \&'*~\&' --block-size=M
.fi
.RE
.PP
.SH DESIGN
.PP
This is a minimal wiki.\& There is no version history.\& It'\&s well suited as a

View File

@@ -66,10 +66,10 @@ curl --form body="Did you bring a towel?" \
When calling the _drop_ action, the query parameters used are _name_ for the
target filename, _file_ for the file to upload. If the query parameter
_maxwidth_ is set, an attempt is made to decode and resize the image. JPG, PNG
and HEIC files can be decoded. Only JPG and PNG files can be encoded, however.
If the target name ends in _.jpg_, the _quality_ query parameter is also taken
into account. To upload some thumbnails:
_maxwidth_ is set, an attempt is made to decode and resize the image. JPG, PNG,
WEBP and HEIC files can be decoded. Only JPG and PNG files can be encoded,
however. If the target name ends in _.jpg_, the _quality_ query parameter is
also taken into account. To upload some thumbnails:
```
for f in *.jpg; do
@@ -204,6 +204,12 @@ curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
To compute the space used by your setup, use regular tools:
```
du --exclude='*/\.*' --exclude '*~' --block-size=M
```
# DESIGN
This is a minimal wiki. There is no version history. It's well suited as a

View File

@@ -2,9 +2,13 @@ package main
import (
"github.com/stretchr/testify/assert"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
)
@@ -60,3 +64,34 @@ func TestReadme(t *testing.T) {
return nil
})
}
func TestDocumentDependencies(t *testing.T) {
b, err := os.ReadFile("README.md")
readme := string(b)
assert.NoError(t, err)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", nil, parser.ImportsOnly)
assert.NoError(t, err)
imports := []string{}
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, imp := range file.Imports {
name := imp.Path.Value[1 : len(imp.Path.Value)-1]
if strings.Contains(name, ".") && !slices.Contains(imports, name) {
imports = append(imports, name)
}
}
}
}
sort.Slice(imports, func(i, j int) bool { return len(imports[i]) < len(imports[j]) })
IMPORT:
for _, name := range imports {
for _, other := range imports {
if strings.HasPrefix(name, other) && name != other {
continue IMPORT
}
}
ok := strings.Contains(readme, name)
assert.True(t, ok, name)
}
}

View File

@@ -11,6 +11,7 @@ import (
"io/fs"
"net/url"
"os"
"path"
"path/filepath"
"strings"
)
@@ -22,7 +23,10 @@ func (*missingCmd) Name() string { return "missing" }
func (*missingCmd) Synopsis() string { return "list missing pages" }
func (*missingCmd) Usage() string {
return `missing:
Listing pages with links to missing pages.
Listing pages with links to missing pages. This command does not
understand links to directories being redirected to index pages.
A link such as [up](..) is reported as a link to a missing page.
Rewrite it as [up](../index) for it to work as intended.
`
}
@@ -30,31 +34,11 @@ func (cmd *missingCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *missingCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return missingCli(os.Stdout, f.Args())
return missingCli(os.Stdout)
}
func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
names := make(map[string]bool)
err := filepath.Walk(".", func(path 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 info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
if strings.HasSuffix(path, ".md") {
name := filepath.ToSlash(strings.TrimSuffix(path, ".md"))
names[name] = true
} else {
names[path] = false
}
return nil
})
func missingCli(w io.Writer) subcommands.ExitStatus {
names, err := existingPages()
if err != nil {
fmt.Fprintln(w, err)
return subcommands.ExitFailure
@@ -105,6 +89,31 @@ func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
func existingPages() (map[string]bool, error) {
names := make(map[string]bool)
err := filepath.Walk(".", func(path 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 info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
if strings.HasSuffix(path, ".md") {
name := filepath.ToSlash(strings.TrimSuffix(path, ".md"))
names[name] = true
} else {
names[path] = false
}
return nil
})
return names, err
}
// links parses the page content and returns an array of link destinations.
func (p *Page) links() []string {
var links []string
@@ -114,7 +123,9 @@ func (p *Page) links() []string {
if entering {
switch v := node.(type) {
case *ast.Link:
links = append(links, string(v.Destination))
link := string(v.Destination)
dir := p.Dir()
links = append(links, path.Join(dir, link))
}
}
return ast.GoToNext

View File

@@ -9,7 +9,7 @@ import (
func TestMissingCmd(t *testing.T) {
b := new(bytes.Buffer)
s := missingCli(b, nil)
s := missingCli(b)
assert.Equal(t, subcommands.ExitSuccess, s)
r := `Page Missing
index test

View File

@@ -34,12 +34,12 @@ func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
for _, name := range args {
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
err = p.notify()
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", name, err)
fmt.Fprintf(w, "%s: %s\n", name, err)
return subcommands.ExitFailure
}
}

30
oddmu.svg Normal file
View File

@@ -0,0 +1,30 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
heigh="320" width="320"
viewBox="0 0 320 320">
<g>
<circle id="hand1" cx="60" cy="190" r="15" fill="white" stroke="black" stroke-width="2"/>
<path id="stick" d="M40,40 L60,300 H66 L46,40 Z" fill="white" stroke="black" stroke-width="2"/>
<ellipse id="fingers" cx="50" cy="195" rx="5" ry="15" fill="white" stroke="black" stroke-width="2"/>
<path id="thumb" d="M60,180 Q40,180 60,190 Z" fill="white" stroke="black" stroke-width="2"/>
</g>
<g id="hand2">
<circle cx="240" cy="200" r="15" fill="white" stroke="black" stroke-width="2"/>
</g>
<g id="body">
<path d="M60,170 H170 L240,180 V230 L190,220
L200,222 L220,290 H120 V215
L125,215 H60 V170" fill="white" stroke="black" stroke-width="2"/>
</g>
<g id="face">
<circle cx="150" cy="150" r="30" fill="white" stroke="black" stroke-width="2"/>
<circle cx="138" cy="145" r="2" fill="black" stroke="black"/>
<circle cx="158" cy="145" r="2" fill="black" stroke="black"/>
<path d="M132,158 Q145,175 170,155 " fill="none" stroke="black" stroke-width="2"/>
</g>
<g id="foot1">
<path d="M120,300 C100,270 180,270 160,300 Z" fill="white" stroke="black" stroke-width="2"/>
</g>
<g id="foot1">
<path d="M180,300 V290 H190 C210,270 250,280 240,300 Z" fill="white" stroke="black" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

10
page.go
View File

@@ -23,7 +23,6 @@ import (
type Page struct {
Title string
Name string
Language string
Body []byte
Html template.HTML
Score int
@@ -109,7 +108,7 @@ func loadPage(path string) (*Page, error) {
if err != nil {
return nil, err
}
return &Page{Title: path, Name: path, Body: body, Language: ""}, nil
return &Page{Title: path, Name: path, 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
@@ -132,16 +131,15 @@ func (p *Page) score(q string) {
p.Score = score(q, string(p.Body)) + score(q, p.Title)
}
// summarize sets Page.Html to an extract and sets Page.Language.
// 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))
p.Language = language(t)
}
// isBlog returns true if the page name starts with an ISO date
func (p *Page) isBlog() bool {
// IsBlog returns true if the page name starts with an ISO date
func (p *Page) IsBlog() bool {
name := path.Base(p.Name)
return blogRe.MatchString(name)
}

View File

@@ -61,7 +61,7 @@ func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node),
// wikiParser returns a parser with the Oddmu specific changes. Specifically: [[wiki links]], #hash_tags,
// @webfinger@accounts. It also uses the CommonExtensions and Block Attributes, and no MathJax ($).
func wikiParser() (*parser.Parser, *[]string) {
extensions := (parser.CommonExtensions | parser.Attributes) & ^parser.MathJax
extensions := (parser.CommonExtensions | parser.AutoHeadingIDs | parser.Attributes) & ^parser.MathJax
parser := parser.NewWithExtensions(extensions)
prev := parser.RegisterInline('[', nil)
parser.RegisterInline('[', wikiLink(parser, prev))
@@ -90,7 +90,6 @@ func (p *Page) renderHtml() {
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
p.Hashtags = *hashtags
}

View File

@@ -20,7 +20,7 @@ Silver leaves shine bright
They droop, boneless, weak and sad
A cruel sun stares down`)}
p.renderHtml()
r := `<h1>Sun</h1>
r := `<h1 id="sun">Sun</h1>
<p>Silver leaves shine bright
They droop, boneless, weak and sad
@@ -37,7 +37,7 @@ I am cold, alone
#Haiku #Cold_Poets`)}
p.renderHtml()
r := `<h1>Comet</h1>
r := `<h1 id="comet">Comet</h1>
<p>Stars flicker above
Too faint to focus, so far
@@ -54,7 +54,7 @@ Blue and green and black
Sky and grass and [ragged cliffs](cliffs)
Our [[time together]]`)}
p.renderHtml()
r := `<h1>Photos and Books</h1>
r := `<h1 id="photos-and-books">Photos and Books</h1>
<p>Blue and green and black
Sky and grass and <a href="cliffs">ragged cliffs</a>
@@ -69,7 +69,7 @@ Dragonfly hovers
darts chases turns lands and rests
A mighty jewel`)}
p.renderHtml()
r := `<h1>No $dollar$ can buy this</h1>
r := `<h1 id="no-dollar-can-buy-this">No $dollar$ can buy this</h1>
<p>Dragonfly hovers
darts chases turns lands and rests

View File

@@ -4,7 +4,7 @@ import (
"context"
"flag"
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/wordwrap"
"github.com/google/subcommands"
"io"
"os"
@@ -83,16 +83,17 @@ func searchCli(w io.Writer, dir string, n int, all, extract bool, quiet bool, ar
// searchExtract prints the search extracts to stdout with highlighting for a terminal.
func searchExtract(w io.Writer, items []*Page) {
heading := lipgloss.NewStyle().Bold(true).Underline(true)
quote := lipgloss.NewStyle().PaddingLeft(4).Width(78)
match := lipgloss.NewStyle().Bold(true)
heading := func (s string) string { return "\x1b[1;4m" + s + "\x1b[0m" } // bold + underline
match := func (s string) string { return "\x1b[1m" + s + "\x1b[0m" } // bold
re := regexp.MustCompile(`<b>(.*?)</b>`)
for _, p := range items {
s := re.ReplaceAllString(string(p.Html), match.Render(`$1`))
fmt.Fprintln(w, heading.Render(p.Title))
s := re.ReplaceAllString(string(p.Html), match(`$1`))
fmt.Fprintln(w, heading(p.Title))
if p.Name != p.Title {
fmt.Fprintln(w, p.Name)
}
fmt.Fprintln(w, quote.Render(s))
for _, s := range strings.Split(wordwrap.String(s, 72), "\n") {
fmt.Fprintln(w, " ", s)
}
}
}

View File

@@ -12,6 +12,7 @@ func TestSearchCmd(t *testing.T) {
s := searchCli(b, "", 1, false, false, true, []string{"oddµ"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Oddµ: A minimal wiki](README)
* [Themes](themes/index)
* [Welcome to Oddµ](index)
`
assert.Equal(t, r, b.String())

View File

@@ -13,47 +13,63 @@ import (
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
type staticCmd struct {
jobs int
}
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
f.IntVar(&cmd.jobs, "jobs", 2, "how many jobs to use")
}
func (*staticCmd) Name() string { return "static" }
func (*staticCmd) Synopsis() string { return "generate static HTML files for all pages" }
func (*staticCmd) Usage() string {
return `static <dir name>:
Create static copies in the given directory.
return `static [-jobs n] <dir name>:
Create static copies in the given directory. Per default, two jobs
are used to read and write files, but more can be assigned.
`
}
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
args := f.Args()
if len(args) != 1 {
fmt.Println("Exactly one target directory is required")
fmt.Fprintln(os.Stderr, "Exactly one target directory is required")
return subcommands.ExitFailure
}
return staticCli(filepath.Clean(args[0]), false)
dir := filepath.Clean(args[0])
return staticCli(dir, cmd.jobs, false)
}
type args struct {
source, target string
info fs.FileInfo
}
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
// tests.
func staticCli(dir string, quiet bool) subcommands.ExitStatus {
func staticCli(dir string, jobs int, quiet bool) subcommands.ExitStatus {
index.load()
index.RLock()
defer index.RUnlock()
loadLanguages()
loadTemplates()
n := 0
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
n++
if !quiet && (n < 100 || n < 1000 && n%10 == 0 || n%100 == 0) {
fmt.Fprintf(os.Stdout, "\r%d", n)
}
return staticFile(path, dir, info, err)
})
tasks := make(chan args)
results := make(chan error)
done := make(chan bool)
stop := make(chan error)
for i := 0; i < jobs; i++ {
go staticWorker(tasks, results, done)
}
go staticWalk(dir, tasks, stop)
go staticWatch(jobs, results, done)
n, err := staticProgressIndicator(results, stop, quiet)
if !quiet {
fmt.Printf("\r%d\n", n)
fmt.Printf("\r%d files processed\n", n)
}
if err != nil {
fmt.Println(err)
@@ -62,43 +78,117 @@ func staticCli(dir string, quiet bool) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
// subdirectories, link files, render HTML files.
func staticFile(path, dir 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 info.IsDir() {
return filepath.SkipDir
} else {
// staticWalk walks the directory tree. Any directory it finds, it recreates in the destination directory. Any file it
// finds, it puts into the tasks channel for the staticWorker. When the directory walk is finished, the tasks channel is
// closed. If there's an error on the stop channel, the walk returns that error.
func staticWalk (dir 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(".", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
select {
case err := <- stop:
return err
default:
base := filepath.Base(path)
// skip hidden directories and files
if path != "." && strings.HasPrefix(base, ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, dir) {
return nil
}
// recreate subdirectories
target := filepath.Join(dir, path)
if info.IsDir() {
return os.Mkdir(target, 0755)
}
// do the task if the target file doesn't exist or if the source file is newer
other, err := os.Stat(target)
if err != nil || info.ModTime().After(other.ModTime()) {
tasks <- args{ source: path, target: target, info: info }
}
return nil
}
})
close(tasks)
}
// staticWatch counts the values coming out of the done channel. When the count matches the number of jobs started, we
// know that all the tasks have been processed and the results channel is closed.
func staticWatch(jobs int, results chan(error), done chan(bool)) {
for i := 0; i < jobs; i++ {
<- done
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, dir) {
close(results)
}
// staticWorker takes arguments off the tasks channel (the file to process) and put results in the results channel (any
// errors encountered); when they're done they send true on the done channel.
func staticWorker(tasks chan(args), results chan(error), done chan(bool)) {
task, ok := <- tasks
for ok {
results <- staticFile(task.source, task.target, task.info)
task, ok = <- tasks
}
done <- true
}
// staticProgressIndicator watches the results channel and does a countdown. If the result channel reports an error,
// that is put into the stop channel so that staticWalk stops adding to the tasks channel.
func staticProgressIndicator(results chan(error), stop chan(error), quiet bool) (int, error) {
n := 0
t := time.Now()
var err error
for result := range results {
if result != nil {
err := result
// this stops the walker from adding more tasks
stop <- err
} else {
n++
if !quiet && n % 13 == 0 {
if time.Since(t) > time.Second {
fmt.Printf("\r%d", n)
t = time.Now()
}
}
}
}
return n, err
}
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
// subdirectories, link files, render HTML files.
func staticFile(source, target string, info fs.FileInfo) error {
// render pages
if strings.HasSuffix(source, ".md") {
p, err := staticPage(source[:len(source)-3], target[:len(target)-3] + ".html")
if err != nil {
return err
}
return staticFeed(source[:len(source)-3], target[:len(target)-3] + ".rss", p, info.ModTime())
}
// remaining files are linked unless this is a template
if slices.Contains(templateFiles, filepath.Base(source)) {
return nil
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(filepath.Join(dir, path), 0755)
}
// render pages
if strings.HasSuffix(path, ".md") {
return staticPage(path, dir)
}
// remaining files are linked
return os.Link(path, filepath.Join(dir, path))
return os.Link(source, target)
}
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
func staticPage(path, dir string) error {
name := strings.TrimSuffix(path, ".md")
p, err := loadPage(filepath.ToSlash(name))
func staticPage(source, target string) (*Page, error) {
p, err := loadPage(filepath.ToSlash(source))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return err
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", source, err)
return nil, err
}
p.handleTitle(true)
// instead of p.renderHtml() we do it all ourselves, appending ".html" to all the local links
@@ -112,9 +202,22 @@ func staticPage(path, dir string) error {
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
p.Hashtags = *hashtags
return p.write(filepath.Join(dir, name+".html"))
return p, write(p, target, "", "static.html")
}
// staticFeed writes a .rss file for a page, but only if it's an index page or a page that might be used as a hashtag
func staticFeed(source, target string, p *Page, ti time.Time) error {
// render feed, maybe
base := filepath.Base(source)
_, ok := index.token["#"+strings.ToLower(base)]
if base == "index" || ok {
f := feed(p, ti)
if len(f.Items) > 0 {
return write(f, target, `<?xml version="1.0" encoding="UTF-8"?>`, "feed.html")
}
}
return nil
}
// staticLinks checks a node and if it is a link to a local page, it appends ".html" to the link destination.
@@ -142,16 +245,23 @@ func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
return ast.GoToNext
}
func (p *Page) write(destination string) error {
t := "static.html"
f, err := os.Create(destination)
// 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)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot create %s.html: %s\n", destination, err)
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", path, err)
return err
}
err = templates.template[t].Execute(f, p)
_, err = file.Write([]byte(prefix))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, destination, err)
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", path, 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)
return err
}
return nil

View File

@@ -3,12 +3,13 @@ package main
import (
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"os"
"testing"
)
func TestStatusCmd(t *testing.T) {
func TestStaticCmd(t *testing.T) {
cleanup(t, "testdata/static")
s := staticCli("testdata/static", true)
s := staticCli("testdata/static", 2, true)
assert.Equal(t, subcommands.ExitSuccess, s)
// pages
assert.FileExists(t, "testdata/static/index.html")
@@ -17,3 +18,31 @@ func TestStatusCmd(t *testing.T) {
assert.FileExists(t, "testdata/static/static_cmd.go")
assert.FileExists(t, "testdata/static/static_cmd_test.go")
}
func TestFeedStaticCmd(t *testing.T) {
cleanup(t, "testdata/static-feed")
cleanup(t, "testdata/static-feed-out")
p := &Page{Name: "testdata/static-feed/Haiku", Body: []byte("# Haiku\n")}
p.save()
h := &Page{Name: "testdata/static-feed/2024-03-07-poem",
Body: []byte(`# Rain
I cannot hear you
The birds outside are singing
And the cars so loud
#Haiku
`)}
h.save()
h.notify()
wd, err := os.Getwd()
assert.NoError(t, err)
assert.NoError(t, os.Chdir("testdata/static-feed"))
s := staticCli("../static-feed-out/", 2, true)
assert.Equal(t, subcommands.ExitSuccess, s)
assert.NoError(t, os.Chdir(wd))
assert.FileExists(t, "testdata/static-feed-out/2024-03-07-poem.html")
assert.FileExists(t, "testdata/static-feed-out/Haiku.html")
b, err := os.ReadFile("testdata/static-feed-out/Haiku.rss")
assert.NoError(t, err)
assert.Contains(t, string(b), "<channel>")
}

View File

@@ -18,7 +18,7 @@ Memories of cold
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/templates/snow", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil),
"Skip navigation")
// save a new view handler
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
@@ -40,11 +40,11 @@ Memories of cold
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/view.html", nil),
html)
// verify that it works
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/templates/snow", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil)
assert.Contains(t, body, "<h1>Snow</h1>")
assert.NotContains(t, body, "Skip")
// verify that the top level still uses the old template
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
"Skip navigation")
}

11
themes/Makefile Normal file
View File

@@ -0,0 +1,11 @@
download:
rsync --archive --delete --itemize-changes --exclude='*-*' sibirocobombus:alexschroeder.ch/wiki/'*.html' alexschroeder.ch/
rsync --archive sibirocobombus:alexschroeder.ch/css/oddmu-2023.css alexschroeder.ch/oddmu.css
sed --in-place=~ --expression='s/\/css\/oddmu-2023\.css/oddmu.css/' alexschroeder.ch/*.html
rsync --archive --delete --itemize-changes sibirocobombus:flying-carpet.ch/wiki/'*.html' flying-carpet.ch/
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/

View File

@@ -0,0 +1,13 @@
# Alex Schroeder theme
At the top there's a text input to quickly create new pages.
This theme comes with an external CSS file. If you plan to use
subdirectories for your site, you need to change the URL of the CSS in
the HTML templates to `/view/oddmu.css` or serve it as a static file
from a `/css` directory.
The CSS switches between light and dark mode based on the visitor's
setup.
(Back up to the [list of themes](../index).)

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required>**{{.Today}}**. </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>
</form>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
</head>
<body>
<header>
<a href="/view/{{.Name}}">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>
<pre class="diff">
{{.Diff}}
</pre>
</main>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Editing {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<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"/>
<description>This is the digital garden of Alex Schroeder.</description>
<image>
<url>https://alexschroeder.ch/view/logo.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>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>
{{end}}
</item>
{{end}}
</channel>
</rss>

View File

@@ -0,0 +1,45 @@
html { max-width: 80ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
footer { border-top: 1px solid #888 }
form, textarea { width: 97%; font-size: inherit }
pre { background-color: #fff; padding: 1ex 2ex; overflow-x: scroll }
.diff { font-size: inherit; white-space: normal; overflow-wrap: break-word; background-color: white; border: 1px solid #333; padding: 1ch }
img { max-width: 100%; max-height: 90vh }
.right img { float: right; margin-left: 2em; margin-bottom: 1em; border: 1px solid #111 }
.left img { float: left; margin-right: 2em; margin-bottom: 1em; border: 1px solid #111 }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
del { background-color: #fab }
ins { background-color: #af8 }
#search, #id { width: 30ch }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8 }
#view form { margin-top: 2px }
#view button { width: 6ch }
#view label { display: inline-block; width: 10ch }
#upload label { display: inline-block; width: 15ch }
#upload input[type=text] { width: 30ch }
img.last { max-width: 20% }
hr { border-bottom: 1px }
th { font-weight: normal }
th + th, td + td { padding-left: 1em }
@media (prefers-color-scheme: dark) {
html { color: #eeeee8; background-color: #333 }
footer { border-top: 1px solid #666 }
.diff { background-color: inherit; border: 1px solid #666 }
.right img { border: 1px solid #111 }
.left img { border: 1px solid #111 }
pre { background-color: #000; }
button { background-color: #eee; color: inherit }
del { background-color: #f40 }
ins { background-color: #070 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out; }
img:hover { opacity: 1; }
input, input[type="text"], textarea, button, .diff {
color: #eeeee8; background-color: #555
}
}

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<link href="oddmu.css" rel="stylesheet" />
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">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>
<button>Go</button>
</form>
</header>
<main id="main">
<h1>Search for {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{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>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail to Alex Schroeder <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
</address>
</footer>
</body>
</html>

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<link href="oddmu.css" rel="stylesheet" />
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<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>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
{{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.
<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.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
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.
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">
<p>Finally, pick the files or photos to upload.
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><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<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" />
</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="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
<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 id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
I love getting mail. Send comments to Alex Schroeder &lt;<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>&gt;
</address>
</footer>
</body>
</html>

View File

@@ -0,0 +1,13 @@
# Campaign Wiki theme
A beige theme that sticks close to the default. This site treats every
subdirectory as its own wiki and so there's a "Changes" link to the
local changes files and a "Zip" link to download the current
directory. This is so people can download a backup of their data.
On the real site, the link to the zip file on the top level is
unavailable because of a redirect so that people download only their
own wikis and not all the wikis, for bandwidth reasons. This is part
of the Apache config, however.
(Back up to the [list of themes](../index).)

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
</style>
</head>
<body>
<header>
<a href="/view/{{.Name}}">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>
<pre>
{{.Diff}}
</pre>
</main>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
<atom:link href="https://campaignwiki.org/view/{{.Name}}.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>
</image>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://campaignwiki.org/view/{{.Name}}</link>
<guid>https://campaignwiki.org/view/{{.Name}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>
{{end}}
</item>
{{end}}
</channel>
</rss>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">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>
<button>Go</button>
</form>
</header>
<main id="main">
<h1>Search for {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{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>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail to Alex Schroeder <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
</address>
</footer>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<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>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
{{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.
<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.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
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.
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">
<p>Finally, pick the files or photos to upload.
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><label for="file">Pick files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<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" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { display: inline-block; width: 10ch; }
input#search, input#id { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</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="/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">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
<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 id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail to Alex Schroeder &lt;<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>&gt;
</address>
</footer>
</body>
</html>

13
themes/chat/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Chat theme
A theme that focuses on appending short paragraphs to existing pages.
This theme makes it all look like chat. 😍
Type and submit. 🥳
Hm. 🤔
I think I like it! 😄
More [themes](../index)! 👀

40
themes/chat/edit.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 50ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; font-size: large; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
textarea { width: 97%; margin-top: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
border-radius: 6px; border: 1px outset #eee; }
p { margin: 0.5ch 0 0 0; }
#send { float: right; font-size: large; }
</style>
</head>
<body>
<header>
<a href="/view/{{.Dir}}index">Home</a>
<a href="/view/{{.Dir}}{{.Today}}" accesskey="t">Today</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>
<button>Los</button>
</form>
</header>
<main>
<h1>{{.Title}}</h1>
<form action="/save/{{.Name}}" 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">
</form>
</footer>
</body>
</html>

53
themes/chat/search.html Normal file
View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 40ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px; }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
</style>
</head>
<body>
<header>
<a href="/view/{{.Dir}}index">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>
<button>Go</button>
</form>
</header>
<main id="main">
<h1>Search for {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{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>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}
</main>
</body>
</html>

45
themes/chat/upload.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Upload</title>
<style>
html { max-width: 50ch; padding: 2ch; margin: auto; color: #000; background-color: #f9f9f9; }
body { hyphens: auto; }
form, textarea { width: 100%; }
label { display: inline-block; width: 7ch }
.last { max-width: 100%; }
#name { width: 25ch; }
</style>
</head>
<body lang="en">
<header>
<a href="/view/{{.Dir}}index">Home</a>
<a href="/view/{{.Dir}}{{.Today}}" accesskey="t">Today</a>
</header>
<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}}
{{end}}
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>What name to use for the uploads.
Make sure to increase the number at the end if you already uploaded images!
If you dont, your upload overwrites the existing images.
<p><label for="text">Name:</label>
<input id="name" name="name" value="{{.Name}}" type="text" autofocus required>
<input name="maxwidth" value="1200" type="hidden">
<input name="quality" value="75" type="hidden">
<p><label for="file">Photos:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Upload">
</form>
<main>
</body>
</html>

52
themes/chat/view.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 40ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
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 {
float: right; text-align: right;
color: #000; background: #8fd;
padding: 3px 1ch; margin: 1pt 0; border-radius: 6px; border: 1px outset #eee; }
footer p { margin: 0.5ch 0 0 0; }
textarea {
width: 97%; margin: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
border-radius: 6px; border: 1px outset #eee; }
#send { float: right; font-size: large; }
img { max-width: 100%; margin-top: 5px; }
</style>
</head>
<body>
<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>
<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>
<button>Los</button>
</form>
<h1>{{.Title}}</h1>
</header>
<main>
{{.Html}}
</main>
<footer>
<form action="/append/{{.Name}}" 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">
</form>
</footer>
</body>
</html>

View File

@@ -0,0 +1,9 @@
# Flying carpet theme
A minimal black on light grey theme.
The visible HTML templates are in German.
"add.html" and "diff.html" are not translated.
The "view.html" template has no footer.
(Back up to the [list of themes](../index).)

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
</style>
</head>
<body>
<header>
<a href="/view/{{.Name}}">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>
<pre>
{{.Diff}}
</pre>
</main>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Bearbeiten von {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Bearbeiten von {{.Title}}</h1>
<form action="/save/{{.Name}}" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<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"/>
<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>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>
{{end}}
</item>
{{end}}
</channel>
</rss>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Suche nach {{.Query}}</title>
<style>
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
</style>
</head>
<body>
<header>
<a href="/view/index">Willkommen</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Suchen:</label>
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Los</button>
</form>
</header>
<main id="main">
<h1>Suche nach {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">Erste Seite</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Vorherige Seite</a>{{end}}
Seite {{.Page}}
{{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>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">Erste Seite</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Vorherige Seite</a>{{end}}
Seite {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Nächste Seite</a>{{end}}
{{else}}
<p>Nichts gefunden.</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail.
</address>
</footer>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<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>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
{{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.
<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.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
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.
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">
<p>Finally, pick the files or photos to upload.
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><label for="file">Pick files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
input { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<header>
<a href="/view/index">Willkommen</a>
<a href="/edit/{{.Name}}" 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>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Los</button>
</form>
<form action="/edit/{{.Dir}}" method="GET">
<label for="id">Neu:</label>
<input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Today}}" required>
<button>Los</button>
</form>
</header>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
</body>
</html>

16
themes/index.md Normal file
View File

@@ -0,0 +1,16 @@
# Themes
[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.
- [themes/alexschroeder.ch](alexschroeder.ch/README)
- [themes/campaignwiki.org](campaignwiki.org/README)
- [themes/flying-carpet.ch](flying-carpet.ch/README)
- [themes/transjovian.org](transjovian.org/README)
Theoretical themes:
- [themes/chat](chat/README)
(Up to the [Welcome](../index) page.)

View File

@@ -0,0 +1,6 @@
# Transjovian theme
This theme uses a nearly-black background and bright blue links.
There's an added "Raw" link that links to the Markdown file for the page.
(Back up to the [list of themes](../index).)

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; color: #222; background-color: #ddd; border: 1px solid #eee; padding: 1ch }
</style>
</head>
<body>
<header>
<a href="/view/{{.Name}}">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>
<pre>
{{.Diff}}
</pre>
</main>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" 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>
</form>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<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"/>
<description>This is the digital garden of Ashivom Bandaralum.</description>
<image>
<url>https://transjovian.org/jupiter.svg</url>
<title>{{.Title}}</title>
<link>https://transjovian.org/</link>
</image>
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://transjovian.org/view/{{.Name}}</link>
<guid>https://transjovian.org/view/{{.Name}}</guid>
<description>{{.Html}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>
{{end}}
</item>
{{end}}
</channel>
</rss>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">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>
<button>Go</button>
</form>
</header>
<main id="main">
<h1>Search for {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{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>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}
</main>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Send text via the long-range comms to Ashivom Bandaralum &lt;<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>&gt;
</address>
</footer>
</body>
</html>

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<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>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
{{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.
<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.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
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.
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">
<p>Finally, pick the files or photos to upload.
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><label for="file">Pick files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<header>
<a href="#main">Skip</a>
<a href="/view/index">Home</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="/view/{{.Name}}.md" accesskey="r">Raw</a>
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<input type="submit" value="Go"/>
</form>
</header>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Send text via long-range comms to Mr. Bandaralum &lt;<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>&gt;
</address>
</footer>
</body>
</html>

View File

@@ -10,25 +10,85 @@ html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-colo
body { hyphens: auto; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
e.addEventListener('drop', uploadFiles.dropHandler);
}
},
pasteHandler: function(e) {
uploadFiles.handle(e.clipboardData);
},
dropHandler: function(e) {
e.preventDefault();
uploadFiles.handle(e.dataTransfer);
},
handle: function(dataTransfer) {
let files = [];
if (dataTransfer.items) {
[...dataTransfer.items].forEach((item, i) => {
if (item.kind === "file")
files.push(item.getAsFile());
});
} else {
[...dataTransfer.files].forEach((file, i) => {
files.push(file);
});
}
if (files.length)
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
fd.append("file", files[i]);
}
try {
fetch(action, { method: "POST", body: fd })
.then(response => {
if (response.ok) {
window.location = response.url;
} else {
alert(response.text);
}})
} catch (e) {
alert(e);
}
},
};
window.addEventListener('load', uploadFiles.init);
</script>
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Last}}">{{.Last}}</a></p>
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Last}}"></p>
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
{{end}}
<form action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading pictures from a phone, its filename is going to be something cryptic like IMG_1234.JPG.
<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.
<p><label for="text">Filename to use:</label>
<input id="text" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
Use <tt>.jpg</tt> or <tt>.png</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.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
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">
@@ -39,10 +99,13 @@ label { display: inline-block; width: 20ch }
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
<p><label for="file">Pick files to upload:</label>
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><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a></p>
<a href="/view/index"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>
</html>

View File

@@ -4,19 +4,21 @@ package main
// This is why we import goheif for side effects: HEIC files are read correctly.
import (
"fmt"
_ "github.com/bashdrew/goheif"
"github.com/disintegration/imaging"
"github.com/edwvee/exiffix"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type upload struct {
@@ -44,31 +46,53 @@ func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
data.Quality = quality
}
name := r.FormValue("filename")
var err error
if name != "" {
data.Name = name
data.Name, err = next(dir, name, 0)
} else if last := r.FormValue("last"); last != "" {
ext := strings.ToLower(filepath.Ext(last))
switch ext {
case ".png", ".jpg", ".jpeg":
data.Image = true
}
data.Last = path.Join(dir, last)
data.Name, _ = next(last)
data.Last = last
mimeType := mime.TypeByExtension(filepath.Ext(last))
data.Image = strings.HasPrefix(mimeType, "image/")
data.Name, err = next(dir, last, 1)
}
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
renderTemplate(w, dir, "upload", data)
}
// next returns the next name for a string matching lastRe. The last number in the given string is incremented by one
// ("a2b" → "a3b"). The second return value indicates whether such a replacement was made or not.
func next(s string) (string, bool) {
m := lastRe.FindStringSubmatch(s)
if m != nil {
n, err := strconv.Atoi(m[2])
if err == nil {
return m[1] + strconv.Itoa(n+1) + m[3], true
// next returns the next filename for a filename containing a number. The last number is identified using lastRe. This
// number is increased by the second argument. Then, for as long as a file with that number exists, the number is
// increased by one. Thus, when called with "image-1.jpg", 0 the string returned will be "image-1.jpg" if no such file
// exists. If "image-1.jpg" exists but "image-2.jpg" does not, then that is returned. When called with "image.jpg"
// (containing no number) and the file does not exist, it is returned unchanged. If it exists, "image-1.jpg" is assumed
// and the algorithm described previously is used to find the next unused filename.
func next(dir, fn string, i int) (string, error) {
m := lastRe.FindStringSubmatch(fn)
if m == nil {
_, err := os.Stat(filepath.Join(dir, fn))
if err != nil {
return fn, nil
}
ext := filepath.Ext(fn)
// faking it
m = []string{"", fn[:len(fn)-len(ext)]+"-", "0", ext}
}
n, err := strconv.Atoi(m[2])
if err == nil {
n += i
for {
s := m[1] + strconv.Itoa(n) + m[3]
_, err = os.Stat(filepath.Join(dir, s))
if err != nil {
return s, nil
}
n += 1
}
}
return s, false
return fn, fmt.Errorf("unable to find next filename after %s", fn)
}
// dropHandler takes the "name" form field and the "file" form file and saves the file under the given name. The browser
@@ -83,7 +107,7 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
return
}
if !fi.IsDir() {
http.Error(w, "directory does not exist", http.StatusBadRequest)
http.Error(w, "not a directory", http.StatusBadRequest)
return
}
data := url.Values{}
@@ -135,13 +159,13 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
return
}
defer file.Close()
// the first filename overwrites!
if !first {
s, ok := next(filename)
if ok {
filename = s
} else {
ext := filepath.Ext(s)
filename = s[:len(s)-len(ext)] + "-1" + ext
filename, err = next(d, filename, 1)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
first = false
@@ -164,7 +188,7 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
// do not use imaging.Decode(file, imaging.AutoOrientation(true)) because that only works for JPEG files
img, fmt, err := exiffix.Decode(file)
if err != nil {
http.Error(w, "The image could not be decoded (only PNG, JPG and HEIC formats are supported for resizing)", http.StatusBadRequest)
http.Error(w, "The image could not be decoded (only PNG, JPG, WEBP and HEIC formats are supported for resizing)", http.StatusBadRequest)
return
}
log.Println("Decoded", fmt, "file")
@@ -210,3 +234,8 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
data.Set("last", filename) // has no slashes
http.Redirect(w, r, "/upload/"+dir+"?"+data.Encode(), http.StatusFound)
}
// Today returns the date, as a string, for use in templates.
func (u *upload) Today() string {
return time.Now().Format(time.DateOnly)
}

View File

@@ -33,7 +33,7 @@ func TestUpload(t *testing.T) {
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
writer.FormDataContentType(), form, "/upload/testdata/files/?last=ok.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/files/ok.txt", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/files/ok.txt", nil),
"Hello!")
}
@@ -131,7 +131,7 @@ But here: jasmin dreams`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/multi/culture", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/?filename=culture-1.jpg"`)
// check location for drop
@@ -178,7 +178,7 @@ There is no answer`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/test", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/test", nil)
assert.Contains(t, body, `href="/upload/testdata/dir/?filename=test-1.jpg"`)
// check location for drop
@@ -230,13 +230,14 @@ func TestUploadTwoInOne(t *testing.T) {
assert.FileExists(t, "testdata/two/2024-02-19-hike-1.jpg")
assert.FileExists(t, "testdata/two/2024-02-19-hike-2.jpg")
}
func TestUploadTwoInOneAgain(t *testing.T) {
cleanup(t, "testdata/zwei")
os.MkdirAll("testdata/zwei", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("image.jpg")) // cannot be incremented!
field.Write([]byte("image.jpg"))
file1, _ := writer.CreateFormFile("file", "one.jpg")
img1 := image.NewRGBA(image.Rect(0, 0, 10, 10))
jpeg.Encode(file1, img1, &jpeg.Options{Quality: 90})

View File

@@ -29,20 +29,24 @@ func (*versionCmd) Usage() string {
}
func (cmd *versionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return versionCli(os.Stdout, cmd.full, f.Args())
return versionCli(os.Stdout, cmd.full)
}
func versionCli(w io.Writer, full bool, args []string) subcommands.ExitStatus {
func versionCli(w io.Writer, full bool) subcommands.ExitStatus {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("This binary contains no debug info.")
w.Write([]byte("This binary contains no debug info.\n"))
} else if full {
fmt.Println(info)
w.Write([]byte(info.String()))
} else {
fmt.Println(info.Path)
w.Write([]byte(info.Path + "\n"))
for _, setting := range info.Settings {
if strings.HasPrefix(setting.Key, "vcs") {
fmt.Printf("%s=%s\n", setting.Key, setting.Value)
_, err := fmt.Fprintf(w, "%s=%s\n", setting.Key, setting.Value)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
return subcommands.ExitFailure
}
}
}
}

View File

@@ -9,7 +9,7 @@ import (
func TestVersionCmd(t *testing.T) {
b := new(bytes.Buffer)
s := versionCli(b, false, nil)
s := versionCli(b, false)
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, "vcs.revision", b.String())
assert.Contains(t, b.String(), "oddmu")
}

16
view.go
View File

@@ -1,7 +1,9 @@
package main
import (
"github.com/gabriel-vasile/mimetype"
"io"
"mime"
"net/http"
"os"
urlpath "path"
@@ -96,12 +98,26 @@ func viewHandler(w http.ResponseWriter, r *http.Request, path string) {
w.WriteHeader(http.StatusOK)
return
}
// if the file exists, serve it
if t == file {
file, err := os.Open(fp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// set MIME type by extension or by sniffing
mimeType := mime.TypeByExtension(filepath.Ext(fp))
if mimeType == "" {
mtype, err := mimetype.DetectReader(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
mimeType = mtype.String()
}
w.Header().Set("Content-Type", mimeType)
file.Seek(0, io.SeekStart)
// copy file
_, err = io.Copy(w, file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@@ -19,7 +19,7 @@ img { max-width: 100%; }
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="index">Home</a>
<a href="/view/index">Home</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>
@@ -31,7 +31,7 @@ img { max-width: 100%; }
<button>Go</button>
</form>
</header>
<main id="main">
<main>
<h1>{{.Title}}</h1>
{{.Html}}
</main>

View File

@@ -15,32 +15,32 @@ func TestRootHandler(t *testing.T) {
// relies on index.md in the current directory!
func TestViewHandler(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
"Welcome to Oddµ")
}
func TestViewHandlerDir(t *testing.T) {
cleanup(t, "testdata/dir")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/", nil, "/view/testdata/index")
assert.NoError(t, os.Mkdir("testdata/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
assert.NoError(t, os.Mkdir("testdata/dir/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.NoError(t, os.WriteFile("testdata/dir/dir.md", []byte(`# Blackbird
The oven hums and
the music plays, coffee smells
blackbirds sing outside
`), 0644))
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
}
// relies on index.md in the current directory!
@@ -48,7 +48,7 @@ func TestViewHandlerWithId(t *testing.T) {
data := make(url.Values)
data.Set("id", "index")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/", data),
"Welcome to Oddµ")
}
@@ -59,14 +59,14 @@ func TestPageTitleWithAmp(t *testing.T) {
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Rock &amp; Roll")
p = &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Sex &amp; Drugs")
}
@@ -76,7 +76,7 @@ func TestPageTitleWithQuestionMark(t *testing.T) {
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
assert.Contains(t, body, "No means no")
assert.Contains(t, body, "<a href=\"/edit/testdata/q/How%20about%20no%3F\" accesskey=\"e\">Edit</a>")
}
@@ -91,17 +91,17 @@ In the autumn chill
`), 0644))
fi, err := os.Stat("testdata/file-mod/now.txt")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
h := makeHandler(viewHandler, false)
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "GET", "/view/testdata/file-mod/now.txt", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/file-mod/now.txt", fi.ModTime())
}
func TestForbidden(t *testing.T) {
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.git/description", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.git/description", nil, http.StatusForbidden)
}
func TestPageLastModified(t *testing.T) {
@@ -114,7 +114,7 @@ I like spring better
p.save()
fi, err := os.Stat("testdata/page-mod/now.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
h := makeHandler(viewHandler, false)
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "GET", "/view/testdata/page-mod/now", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/page-mod/now", fi.ModTime())
@@ -130,7 +130,7 @@ Just me and the birds.
p.save()
fi, err := os.Stat("testdata/head/peace.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
h := makeHandler(viewHandler, false)
assert.Equal(t, []string(nil),
HTTPHeaders(h, "HEAD", "/view/testdata/head/war", nil, "Last-Modified"))
assert.Equal(t, []string(nil),
@@ -140,3 +140,10 @@ Just me and the birds.
assert.Equal(t, "",
assert.HTTPBody(h, "HEAD", "/view/testdata/head/peace", nil))
}
func TestMimeType(t *testing.T) {
assert.Equal(t, []string{"text/markdown; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false), "GET", "/view/index.md", nil, "Content-Type"))
assert.Equal(t, []string{"text/css; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false), "GET", "/view/themes/alexschroeder.ch/oddmu.css", nil, "Content-Type"))
}

View File

@@ -23,7 +23,7 @@ type watchStore struct {
// and the exact time at which they have been added. When the same file is added multiple times, such as when
// the watchStore function sees multiple Write events for the same file, the time keeps getting updated so that
// when the watchTimer runs, it only acts on files that haven't been updated in the last second.
files map[string]time.Time
files map[string]time.Time
// ignores contains the files that some code intends to change, knowing that subsequent writes events would
// result in file system notifications that would end up adding the filenames to the queue for reloading (if a

View File

@@ -62,7 +62,7 @@ the smell is everywhere
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/watched-template/raclette", nil),
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/watched-template/raclette", nil),
"Skip navigation")
// save a new view handler directly
@@ -83,7 +83,7 @@ the smell is everywhere
watches.watchTimer(path)
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/"+name, nil)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/"+name, nil)
assert.Contains(t, body, "<h1>Raclette</h1>") // page text is still there
assert.NotContains(t, body, "Skip") // but the header is not
}

41
wiki.go
View File

@@ -30,7 +30,9 @@ var titleRegexp = regexp.MustCompile("(?m)^#\\s*(.*)\n+")
// makeHandler returns a handler that uses the URL path without the first path element as its argument, e.g. if the URL
// path is /edit/foo/bar, the editHandler is called with "foo/bar" as its argument. This uses the second group from the
// validPath regular expression. The boolean argument indicates whether the following path is required. When false, a
// URL like /upload/ is OK. The argument can also be provided using a form parameter, i.e. call /edit/?id=foo/bar.
// URL like /upload/ is OK. The argument can also be provided using a form parameter, i.e. call /edit/?id=foo/bar. The
// handle itself is called with the remaining URL path fragment. To turn it into a filepath:
// filepath.Clean(filepath.FromSlash(url.PathUnescape(dir))).
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// no hidden files or directories
@@ -41,25 +43,30 @@ func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required b
}
}
m := validPath.FindStringSubmatch(r.URL.Path)
if m != nil && (!required || len(m[2]) > 0) {
fn(w, r, m[2])
// handle / and /action
if m == nil {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, "Cannot parse form", 400)
return
}
id := r.Form.Get("id")
// no hidden files or directories
for _, segment := range strings.Split(id, "/") {
if strings.HasPrefix(segment, ".") {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
path := m[2]
if r.Method == "GET" && (path == "" || strings.HasSuffix(path, "/")) {
err := r.ParseForm()
if err == nil {
id := r.Form.Get("id")
if strings.HasPrefix(id, ".") {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
}
if strings.Contains(id, "/") {
http.Error(w, "id may not contain slashes", http.StatusBadRequest)
return
}
path += id
}
}
if m != nil {
fn(w, r, id)
// handle /action/ or /action/page
if !required || len(path) > 0 {
fn(w, r, path)
return
}
http.NotFound(w, r)
@@ -163,7 +170,7 @@ func scheduleInstallWatcher() {
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/archive/", makeHandler(archiveHandler, true))
http.HandleFunc("/view/", makeHandler(viewHandler, true))
http.HandleFunc("/view/", makeHandler(viewHandler, false))
http.HandleFunc("/diff/", makeHandler(diffHandler, true))
http.HandleFunc("/edit/", makeHandler(editHandler, true))
http.HandleFunc("/save/", makeHandler(saveHandler, true))

View File

@@ -63,6 +63,9 @@ func HTTPUploadLocation(t *testing.T, handler http.HandlerFunc, url, contentType
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url, code)
headers := w.Result().Header["Location"]
assert.True(t, len(headers) == 1, "Expected a single redirect header but got %d locations", len(headers))
if len(headers) == 0 {
return ""
}
return headers[0]
}