forked from mirror/oddmu
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380692d616 | ||
|
|
7ad04e561c | ||
|
|
0e0c9f3bb5 | ||
|
|
7d4530383e | ||
|
|
df9439c356 | ||
|
|
b4ee637600 | ||
|
|
2cd5a38885 | ||
|
|
149f4cf7a4 | ||
|
|
d26e3479c9 | ||
|
|
2e94abfabb | ||
|
|
9116095f69 | ||
|
|
a82bbba62b | ||
|
|
3066574167 | ||
|
|
e065888279 | ||
|
|
4e81401b8c | ||
|
|
acac745e1f | ||
|
|
3f542388db | ||
|
|
8a275c103a | ||
|
|
2934471ed3 | ||
|
|
86cd3baa89 | ||
|
|
95b0c6951d | ||
|
|
f935237fcb | ||
|
|
50d603781e | ||
|
|
0a37ac34d5 | ||
|
|
14f9f783eb | ||
|
|
def0534771 | ||
|
|
8c70bd5c7b | ||
|
|
81f2dc3ace | ||
|
|
07aa4f0064 | ||
|
|
926c4faf06 | ||
|
|
4636b10cce | ||
|
|
114e2b8790 | ||
|
|
1fa26ab6f9 | ||
|
|
69d2f452d1 | ||
|
|
c3b3bbb1c4 | ||
|
|
07d395ab1e | ||
|
|
6ed4e17767 | ||
|
|
10bbda4a6e | ||
|
|
006b8211f4 | ||
|
|
1885fdaaad | ||
|
|
cd6fb187b4 | ||
|
|
4f5b49a065 | ||
|
|
7fbdac3f9a | ||
|
|
38f13dc8f8 | ||
|
|
e3ceaf031e | ||
|
|
5feb5f9b21 | ||
|
|
086e65c304 | ||
|
|
ffaae10f94 | ||
|
|
6c6d326c59 | ||
|
|
f41d6b8e9e | ||
|
|
8e35336cb3 | ||
|
|
2a44c2a74f | ||
|
|
fe9a621f1e | ||
|
|
be663eed32 | ||
|
|
86ef305e9c | ||
|
|
1fd97ae717 | ||
|
|
d0fdf8c3c6 | ||
|
|
1786050e72 | ||
|
|
f12252e148 | ||
|
|
f5f997261e | ||
|
|
43408707c5 | ||
|
|
50ce79d60d |
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
||||
This software is Copyright (c) 2015–2023 by Alex Schroeder.
|
||||
This software is Copyright (c) 2015–2024 by Alex Schroeder.
|
||||
|
||||
This is free software, licensed under:
|
||||
|
||||
|
||||
34
Makefile
34
Makefile
@@ -1,4 +1,7 @@
|
||||
SHELL=/bin/bash
|
||||
PREFIX=${HOME}/.local
|
||||
|
||||
.PHONY: help build test run upload docs install missing
|
||||
|
||||
help:
|
||||
@echo Help for Oddmu
|
||||
@@ -13,7 +16,7 @@ help:
|
||||
@echo make docs
|
||||
@echo " create man pages from text files"
|
||||
@echo
|
||||
@echo go build
|
||||
@echo make build
|
||||
@echo " just build it"
|
||||
@echo
|
||||
@echo make install
|
||||
@@ -22,14 +25,18 @@ help:
|
||||
@echo make upload
|
||||
@echo " this is how I upgrade my server"
|
||||
|
||||
run:
|
||||
go run .
|
||||
build: oddmu
|
||||
|
||||
oddmu: *.go
|
||||
go build
|
||||
|
||||
test:
|
||||
go test -shuffle on .
|
||||
|
||||
upload:
|
||||
go build
|
||||
run:
|
||||
go run .
|
||||
|
||||
upload: build
|
||||
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
|
||||
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia; systemctl restart campaignwiki"
|
||||
@echo Changes to the template files need careful consideration
|
||||
@@ -38,10 +45,15 @@ docs:
|
||||
cd man; make
|
||||
|
||||
install:
|
||||
make docs
|
||||
for n in 1 5 7; do install -D -t $$HOME/.local/share/man/man$$n man/*.$$n; done
|
||||
go build
|
||||
install -D -t $$HOME/.local/bin oddmu
|
||||
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
|
||||
|
||||
missing:
|
||||
for f in man/*.txt; do grep --quiet "$$f" README.md || echo $$f is not in the README; done
|
||||
# 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/
|
||||
|
||||
217
README.md
217
README.md
@@ -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
|
||||
|
||||
@@ -48,6 +52,18 @@ links. Local links must use percent encoding for page names so there
|
||||
is a section about percent encoding. The man page also explains how
|
||||
feeds are generated.
|
||||
|
||||
[oddmu-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
|
||||
This man page lists all the Oddmu versions and their user-visible
|
||||
changes.
|
||||
|
||||
[oddmu-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
|
||||
This man page lists all the Oddmu versions and their user-visible
|
||||
changes.
|
||||
|
||||
[oddmu-version(1)](/oddmu.git/blob/main/man/oddmu-version.1.txt): This
|
||||
man page documents the "version" subcommand which you can use to get
|
||||
installed Oddmu version.
|
||||
|
||||
[oddmu-list(1)](/oddmu.git/blob/main/man/oddmu-list.1.txt): This man
|
||||
page documents the "list" subcommand which you can use to get page
|
||||
names and page titles.
|
||||
@@ -91,8 +107,12 @@ This man page documents how the templates can be changed (how they
|
||||
templates.
|
||||
|
||||
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
|
||||
man page documents how to set up the web server for various common
|
||||
tasks such as using logins to limit what visitors can edit.
|
||||
man page documents how to set up the Apache web server for various
|
||||
common tasks such as using logins to limit what visitors can edit.
|
||||
|
||||
[oddmu-nginx(5)](/oddmu.git/blob/main/man/oddmu-nginx.5.txt): This man
|
||||
page documents how to set up the freenginx web server for various
|
||||
common tasks such as using logins to limit what visitors can edit.
|
||||
|
||||
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
|
||||
man page documents how to setup a systemd unit and have it manage
|
||||
@@ -100,15 +120,35 @@ Oddmu. “Great configurability brings great burdens.”
|
||||
|
||||
## Building
|
||||
|
||||
To build the binary:
|
||||
|
||||
```sh
|
||||
go build
|
||||
```
|
||||
|
||||
The man pages are already built. If you want to rebuild them, you need
|
||||
to have [scdoc](https://git.sr.ht/~sircmpwn/scdoc) installed.
|
||||
|
||||
```sh
|
||||
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
|
||||
loaded from. You need a copy of the template files in this directory.
|
||||
Here's how to start it in the source directory:
|
||||
|
||||
Here's how to build and run straight from the source directory:
|
||||
|
||||
```sh
|
||||
go run .
|
||||
@@ -117,11 +157,39 @@ go run .
|
||||
The program serves the local directory as a wiki on port 8080. Point
|
||||
your browser to http://localhost:8080/ to use it.
|
||||
|
||||
## Bugs
|
||||
Once the `oddmu` binary is built, you can run it instead:
|
||||
|
||||
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
|
||||
```sh
|
||||
./oddmu
|
||||
```
|
||||
|
||||
## Source
|
||||
To read the main man page witihout installing Oddmu:
|
||||
|
||||
```sh
|
||||
man -l man/oddmu.1
|
||||
```
|
||||
|
||||
## Installing
|
||||
|
||||
This installs `oddmu` into `$HOME/.local/bin` and the manual pages
|
||||
into `$HOME/.local/share/man/`.
|
||||
|
||||
```sh
|
||||
make install
|
||||
```
|
||||
|
||||
To install it elsewhere, here's an example using [GNU
|
||||
Stow](https://www.gnu.org/software/stow/) to install it into
|
||||
`/usr/local/stow` in a way that allows you to uninstall it later:
|
||||
|
||||
```sh
|
||||
sudo mkdir /usr/local/stow/oddmu
|
||||
sudo make install PREFIX=/usr/local/stow/oddmu/
|
||||
cd /usr/local/stow
|
||||
sudo stow oddmu
|
||||
```
|
||||
|
||||
## Hacking
|
||||
|
||||
If you're interested in making changes to the code, here's a
|
||||
high-level introduction to the various source files.
|
||||
@@ -134,6 +202,8 @@ high-level introduction to the various source files.
|
||||
account link destinations with the URI provided by webfinger
|
||||
- `add_append.go` implements the `/add` and `/append` handlers
|
||||
- `archive.go` implements the `/archive` handler
|
||||
- `changes.go` implements the "notifications": the automatic addition
|
||||
of links to index, changes and hashtag files when pages are edited
|
||||
- `diff.go` implements the `/diff` handler
|
||||
- `edit_save.go` implements the `/edit` and `/save` handlers
|
||||
- `feed.go` implements the feed for a page based on the links it lists
|
||||
@@ -153,6 +223,20 @@ 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
|
||||
`parser.go`. Make sure you read the documentation of [Go
|
||||
Markdown](https://github.com/gomarkdown/markdown) and note that it
|
||||
@@ -161,6 +245,8 @@ that the MathJax Javascript gets loaded) and
|
||||
[MMark](https://mmark.miek.nl/post/syntax/) support, and it shows how
|
||||
extensions can be added.
|
||||
|
||||
### Filenames and URL path
|
||||
|
||||
One of the sad parts of the code is the distinction between path and
|
||||
filepath. On a Linux system, this doesn't matter. I suspect that it
|
||||
also doesn't matter on MacOS and Windows because the file systems
|
||||
@@ -178,6 +264,81 @@ If you need to access the page name in code that is used from a
|
||||
template, you have to decode the path. See the code in `diff.go` for
|
||||
an example.
|
||||
|
||||
### HTTP handlers
|
||||
|
||||
The URL paths all have the form `/action/directory/pagename` (with
|
||||
directory being optional and pagename sometimes being optional). If
|
||||
you need to limit access in Apache or nginx or some other web server
|
||||
acting as a [reverse
|
||||
proxy](https://en.wikipedia.org/wiki/Reverse_proxy), you can do that.
|
||||
See `man oddmu-apache` and `man oddmu-nginx` for some configuration
|
||||
examples.
|
||||
|
||||
This is how you can prevent some actions by simply not passing them on
|
||||
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/)
|
||||
|
||||
8
RELEASE
8
RELEASE
@@ -3,10 +3,12 @@ When preparing a new release
|
||||
|
||||
1. Run tests
|
||||
|
||||
2. Make docs
|
||||
2. Update man/oddmu-releases.7.txt
|
||||
|
||||
3. Make sure all files are checked in
|
||||
3. make docs
|
||||
|
||||
4. Update man/oddmu-releases.7.txt
|
||||
4. Make sure all files are checked in
|
||||
|
||||
5. Tag the release and push the tag to all remotes
|
||||
|
||||
6. cd man && make upload
|
||||
26
accounts.go
26
accounts.go
@@ -15,8 +15,8 @@ import (
|
||||
// environment variable ODDMU_WEBFINGER to "1".
|
||||
var useWebfinger = false
|
||||
|
||||
// Accounts contains the map used to set the usernames. Make sure to lock and unlock as appropriate.
|
||||
type Accounts struct {
|
||||
// accountStore controlls access to the usernames. Make sure to lock and unlock as appropriate.
|
||||
type accountStore struct {
|
||||
sync.RWMutex
|
||||
|
||||
// uris is a map, mapping account names likes "@alex@alexschroeder.ch" to URIs like
|
||||
@@ -25,7 +25,7 @@ type Accounts struct {
|
||||
}
|
||||
|
||||
// accounts holds the global mapping of accounts to profile URIs.
|
||||
var accounts Accounts
|
||||
var accounts accountStore
|
||||
|
||||
// This is called once at startup and therefore does not need to be locked. On every restart, this map starts empty and
|
||||
// is slowly repopulated as pages are visited.
|
||||
@@ -36,11 +36,11 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// account links a social media account like @account@domain to a profile page like https://domain/user/account. Any
|
||||
// account seen for the first time uses a best guess profile URI. It is also looked up using webfinger, in parallel. See
|
||||
// accountLink links a social media accountLink like @accountLink@domain to a profile page like https://domain/user/accountLink. Any
|
||||
// accountLink seen for the first time uses a best guess profile URI. It is also looked up using webfinger, in parallel. See
|
||||
// lookUpAccountUri. If the lookup succeeds, the best guess is replaced with the new URI so on subsequent requests, the
|
||||
// URI is correct.
|
||||
func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
data = data[offset:]
|
||||
i := 1 // skip @ of username
|
||||
n := len(data)
|
||||
@@ -105,7 +105,7 @@ func lookUpAccountUri(account, domain string) {
|
||||
log.Printf("Failed to read from %s: %s", account, err)
|
||||
return
|
||||
}
|
||||
var wf WebFinger
|
||||
var wf webFinger
|
||||
err = json.Unmarshal([]byte(body), &wf)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse the JSON from %s: %s", account, err)
|
||||
@@ -121,24 +121,24 @@ func lookUpAccountUri(account, domain string) {
|
||||
accounts.uris[account] = uri
|
||||
}
|
||||
|
||||
// Link a link in the WebFinger JSON.
|
||||
type Link struct {
|
||||
// link a link in the WebFinger JSON.
|
||||
type link struct {
|
||||
Rel string `json:"rel"`
|
||||
Type string `json:"type"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// WebFinger is a structure used to unmarshall JSON.
|
||||
type WebFinger struct {
|
||||
// webFinger is a structure used to unmarshall JSON.
|
||||
type webFinger struct {
|
||||
Subject string `json:"subject"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Links []Link `json:"links"`
|
||||
Links []link `json:"links"`
|
||||
}
|
||||
|
||||
// parseWebFinger parses the web finger JSON and returns the profile page URI. For unmarshalling the JSON, it uses the
|
||||
// Link and WebFinger structs.
|
||||
func parseWebFinger(body []byte) (string, error) {
|
||||
var wf WebFinger
|
||||
var wf webFinger
|
||||
err := json.Unmarshal(body, &wf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
2
add.html
2
add.html
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
17
feed.go
17
feed.go
@@ -11,16 +11,33 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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.
|
||||
Date string
|
||||
}
|
||||
|
||||
// Feed is an Item used for the feed itself, plus an array of items based on the linked pages.
|
||||
type Feed struct {
|
||||
|
||||
// Item is the page containing the list of links. It's title is used for the feed and it's last modified time is
|
||||
// used for the publication date. Thus, if linked pages change but the page with the links doesn't change, the
|
||||
// publication date remains unchanged.
|
||||
Item
|
||||
|
||||
// Items are based on the pages linked in list items starting with an asterisk ("*"). Links in
|
||||
// list items starting with a minus ("-") are ignored!
|
||||
Items []Item
|
||||
}
|
||||
|
||||
// feed returns a RSS 2.0 feed for any page. The feed items it contains are the pages linked from in list items starting
|
||||
// with an asterisk ("*").
|
||||
func feed(p *Page, ti time.Time) *Feed {
|
||||
feed := new(Feed)
|
||||
feed.Name = p.Name
|
||||
|
||||
10
feed_test.go
10
feed_test.go
@@ -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, "<h1>Cactus</h1>")
|
||||
assert.Contains(t, body, "<h1>Dragon</h1>")
|
||||
assert.Contains(t, body, "<h1 id="cactus">Cactus</h1>")
|
||||
assert.Contains(t, body, "<h1 id="dragon">Dragon</h1>")
|
||||
assert.Contains(t, body, "<category>Succulent</category>")
|
||||
assert.Contains(t, body, "<category>Palmtree</category>")
|
||||
}
|
||||
|
||||
8
go.mod
8
go.mod
@@ -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
13
go.sum
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
31
index.go
31
index.go
@@ -16,9 +16,8 @@ import (
|
||||
|
||||
type docid uint
|
||||
|
||||
// Index contains the two maps used for search. Make sure to lock and
|
||||
// unlock as appropriate.
|
||||
type Index struct {
|
||||
// indexStore controls access to the maps used for search. Make sure to lock and unlock as appropriate.
|
||||
type indexStore struct {
|
||||
sync.RWMutex
|
||||
|
||||
// next_id is the number of the next document added to the index
|
||||
@@ -34,14 +33,14 @@ type Index struct {
|
||||
titles map[string]string
|
||||
}
|
||||
|
||||
var index Index
|
||||
var index indexStore
|
||||
|
||||
func init() {
|
||||
index.reset()
|
||||
}
|
||||
|
||||
// reset the index. This assumes that the index is locked. It's useful for tests.
|
||||
func (idx *Index) reset() {
|
||||
func (idx *indexStore) reset() {
|
||||
idx.next_id = 0
|
||||
idx.token = make(map[string][]docid)
|
||||
idx.documents = make(map[docid]string)
|
||||
@@ -49,7 +48,7 @@ func (idx *Index) reset() {
|
||||
}
|
||||
|
||||
// addDocument adds the text as a new document. This assumes that the index is locked!
|
||||
func (idx *Index) addDocument(text []byte) docid {
|
||||
func (idx *indexStore) addDocument(text []byte) docid {
|
||||
id := idx.next_id
|
||||
idx.next_id++
|
||||
for _, token := range hashtags(text) {
|
||||
@@ -66,7 +65,7 @@ func (idx *Index) addDocument(text []byte) docid {
|
||||
}
|
||||
|
||||
// deleteDocument deletes all references to the id. The id can no longer be used. This assumes that the index is locked.
|
||||
func (idx *Index) deleteDocument(id docid) {
|
||||
func (idx *indexStore) deleteDocument(id docid) {
|
||||
// Looping through all tokens makes sense if there are few tokens (like hashtags). It doesn't make sense if the
|
||||
// number of tokens is large (like for full-text search or a trigram index).
|
||||
for token, ids := range idx.token {
|
||||
@@ -87,7 +86,7 @@ func (idx *Index) deleteDocument(id docid) {
|
||||
|
||||
// deletePageName determines the document id based on the page name and calls deleteDocument to delete all references.
|
||||
// This assumes that the index is unlocked.
|
||||
func (idx *Index) deletePageName(name string) {
|
||||
func (idx *indexStore) deletePageName(name string) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
var id docid
|
||||
@@ -106,12 +105,12 @@ func (idx *Index) deletePageName(name string) {
|
||||
}
|
||||
|
||||
// remove the page from the index. Do this when deleting a page. This assumes that the index is unlocked.
|
||||
func (idx *Index) remove(p *Page) {
|
||||
func (idx *indexStore) remove(p *Page) {
|
||||
idx.deletePageName(p.Name)
|
||||
}
|
||||
|
||||
// load loads all the pages and indexes them. This takes a while. It returns the number of pages indexed.
|
||||
func (idx *Index) load() (int, error) {
|
||||
func (idx *indexStore) load() (int, error) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
err := filepath.Walk(".", idx.walk)
|
||||
@@ -123,7 +122,7 @@ func (idx *Index) load() (int, error) {
|
||||
}
|
||||
|
||||
// walk reads a file and adds it to the index. This assumes that the index is locked.
|
||||
func (idx *Index) walk(path string, info fs.FileInfo, err error) error {
|
||||
func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,7 +148,7 @@ func (idx *Index) walk(path string, info fs.FileInfo, err error) error {
|
||||
}
|
||||
|
||||
// addPage adds a page to the index. This assumes that the index is locked.
|
||||
func (idx *Index) addPage(p *Page) {
|
||||
func (idx *indexStore) addPage(p *Page) {
|
||||
id := idx.addDocument(p.Body)
|
||||
idx.documents[id] = p.Name
|
||||
p.handleTitle(false)
|
||||
@@ -157,14 +156,14 @@ func (idx *Index) addPage(p *Page) {
|
||||
}
|
||||
|
||||
// add a page to the index. This assumes that the index is unlocked.
|
||||
func (idx *Index) add(p *Page) {
|
||||
func (idx *indexStore) add(p *Page) {
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
idx.addPage(p)
|
||||
}
|
||||
|
||||
// dump prints the index to the log for debugging.
|
||||
func (idx *Index) dump() {
|
||||
func (idx *indexStore) dump() {
|
||||
idx.RLock()
|
||||
defer idx.RUnlock()
|
||||
for token, ids := range idx.token {
|
||||
@@ -173,14 +172,14 @@ func (idx *Index) dump() {
|
||||
}
|
||||
|
||||
// updateIndex updates the index for a single page.
|
||||
func (idx *Index) update(p *Page) {
|
||||
func (idx *indexStore) update(p *Page) {
|
||||
idx.remove(p)
|
||||
idx.add(p)
|
||||
}
|
||||
|
||||
// search searches the index for a query string and returns page
|
||||
// names.
|
||||
func (idx *Index) search(q string) []string {
|
||||
func (idx *indexStore) search(q string) []string {
|
||||
idx.RLock()
|
||||
defer idx.RUnlock()
|
||||
names := make([]string, 0)
|
||||
|
||||
2
index.md
2
index.md
@@ -2,6 +2,6 @@
|
||||
|
||||
Hello! 🙃
|
||||
|
||||
Check out the [[README]].
|
||||
Check out the [[README]] and [[themes]].
|
||||
|
||||
Or [create a new page](test).
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestIndexAdd(t *testing.T) {
|
||||
idx := &Index{}
|
||||
idx := &indexStore{}
|
||||
idx.reset()
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
18
man/Makefile
18
man/Makefile
@@ -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}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-HTML" "1" "2024-02-17"
|
||||
.TH "ODDMU-HTML" "1" "2024-02-26"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-LIST" "1" "2024-02-17"
|
||||
.TH "ODDMU-LIST" "1" "2024-02-24"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -13,7 +13,7 @@ oddmu-list - list page names and titles from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu list\fR [-dir string]
|
||||
\fBoddmu list\fR [-dir \fIstring\fR]
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
|
||||
@@ -6,7 +6,7 @@ oddmu-list - list page names and titles from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu list* [-dir string]
|
||||
*oddmu list* [-dir _string_]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-RELEASES" "7" "2024-02-20"
|
||||
.TH "ODDMU-RELEASES" "7" "2024-03-10"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -15,9 +15,49 @@ 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></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.\&
|
||||
.PP
|
||||
.SS 1.7 (2024)
|
||||
.PP
|
||||
Allow upload of multiple files.\& This requires an update to the upload.\&html
|
||||
Allow upload of multiple files.\& This requires an update to the \fIupload.\&html\fR
|
||||
template: Add the \fImultiple\fR attribute to the file input element and change the
|
||||
label from "file" to "files".\&
|
||||
.PP
|
||||
|
||||
@@ -8,9 +8,45 @@ 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></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.
|
||||
|
||||
## 1.7 (2024)
|
||||
|
||||
Allow upload of multiple files. This requires an update to the upload.html
|
||||
Allow upload of multiple files. This requires an update to the _upload.html_
|
||||
template: Add the _multiple_ attribute to the file input element and change the
|
||||
label from "file" to "files".
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-STATIC" "1" "2024-02-17"
|
||||
.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-VERSION" "1" "2024-02-17"
|
||||
.TH "ODDMU-VERSION" "1" "2024-02-23"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -13,22 +13,22 @@ oddmu-version - print build info on the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu version\fR
|
||||
\fBoddmu version\fR [-full]
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "version" subcommand prints a lot of stuff used to build the binary,
|
||||
including the git revision, git repository, versions of dependencies used and
|
||||
more.\&
|
||||
The "version" subcommand prints information related to the version control
|
||||
system state when it was built: what remote was used, what commit was checked
|
||||
out, whether there were any local changes were made.\&
|
||||
.PP
|
||||
It'\&s the equivalent of running this:
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
.nf
|
||||
\fB-full\fR
|
||||
.RS 4
|
||||
go version -m oddmu
|
||||
.fi
|
||||
.RE
|
||||
Print a lot more information, including the versions of dependencies
|
||||
used.\& It'\&s the equivalent of running "go version -m oddmu".\&
|
||||
.PP
|
||||
.RE
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
|
||||
@@ -6,19 +6,19 @@ oddmu-version - print build info on the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu version*
|
||||
*oddmu version* [-full]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "version" subcommand prints a lot of stuff used to build the binary,
|
||||
including the git revision, git repository, versions of dependencies used and
|
||||
more.
|
||||
The "version" subcommand prints information related to the version control
|
||||
system state when it was built: what remote was used, what commit was checked
|
||||
out, whether there were any local changes were made.
|
||||
|
||||
It's the equivalent of running this:
|
||||
# OPTIONS
|
||||
|
||||
```
|
||||
go version -m oddmu
|
||||
```
|
||||
*-full*
|
||||
Print a lot more information, including the versions of dependencies
|
||||
used. It's the equivalent of running "go version -m oddmu".
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
|
||||
53
man/oddmu.1
53
man/oddmu.1
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "1" "2024-02-19"
|
||||
.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
|
||||
@@ -353,11 +361,30 @@ Note that some HTML file names are special: they act as templates.\& See
|
||||
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link
|
||||
lists
|
||||
.IP \(bu 4
|
||||
\fIoddmu.\&service\fR(5), on how to run the service under systemd
|
||||
\fIoddmu-releases\fR(7), on what features are part of the latest release
|
||||
.IP \(bu 4
|
||||
\fIoddmu-filter\fR(7), on how to treat subdirectories as separate sites
|
||||
.IP \(bu 4
|
||||
\fIoddmu-search\fR(7), on how search works
|
||||
.IP \(bu 4
|
||||
\fIoddmu-templates\fR(5), on how to write the HTML templates
|
||||
.PD
|
||||
.PP
|
||||
If you run Oddmu as a web server:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fIoddmu-apache\fR(5), on how to set up Apache as a reverse proxy
|
||||
.IP \(bu 4
|
||||
\fIoddmu-filter\fR(7), on how to treat subdirectories as separate sites
|
||||
\fIoddmu-nginx\fR(5), on how to set up freenginx as a reverse proxy
|
||||
.IP \(bu 4
|
||||
\fIoddmu.\&service\fR(5), on how to run the service under systemd
|
||||
.PD
|
||||
.PP
|
||||
If you run Oddmu as a static site generator or pages offline and sync them with
|
||||
Oddmu running as a webserver:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
\fIoddmu-html\fR(1), on how to render a page from the command-line
|
||||
.IP \(bu 4
|
||||
@@ -365,23 +392,15 @@ lists
|
||||
.IP \(bu 4
|
||||
\fIoddmu-missing\fR(1), on how to find broken local links from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-nginx\fR(5), on how to set up freenginx as a reverse proxy
|
||||
.IP \(bu 4
|
||||
\fIoddmu-releases\fR(7), on what features are part of the latest release
|
||||
\fIoddmu-notify\fR(1), on updating index, changes and hashtag pages from the
|
||||
command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-replace\fR(1), on how to search and replace text from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-search\fR(1), on how to run a search from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-search\fR(7), on how search works
|
||||
.IP \(bu 4
|
||||
\fIoddmu-static\fR(1), on generating a static site from the command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-notify\fR(1), on updating index, changes and hashtag pages from the
|
||||
command-line
|
||||
.IP \(bu 4
|
||||
\fIoddmu-templates\fR(5), on how to write the HTML templates
|
||||
.IP \(bu 4
|
||||
\fIoddmu-version\fR(1), on how to get all the build information from the binary
|
||||
.PD
|
||||
.PP
|
||||
|
||||
@@ -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
|
||||
@@ -295,21 +301,28 @@ _oddmu-templates_(5) for their names and their use.
|
||||
|
||||
- _oddmu_(5), about the markup syntax and how feeds are generated based on link
|
||||
lists
|
||||
- _oddmu.service_(5), on how to run the service under systemd
|
||||
- _oddmu-apache_(5), on how to set up Apache as a reverse proxy
|
||||
- _oddmu-releases_(7), on what features are part of the latest release
|
||||
- _oddmu-filter_(7), on how to treat subdirectories as separate sites
|
||||
- _oddmu-search_(7), on how search works
|
||||
- _oddmu-templates_(5), on how to write the HTML templates
|
||||
|
||||
If you run Oddmu as a web server:
|
||||
|
||||
- _oddmu-apache_(5), on how to set up Apache as a reverse proxy
|
||||
- _oddmu-nginx_(5), on how to set up freenginx as a reverse proxy
|
||||
- _oddmu.service_(5), on how to run the service under systemd
|
||||
|
||||
If you run Oddmu as a static site generator or pages offline and sync them with
|
||||
Oddmu running as a webserver:
|
||||
|
||||
- _oddmu-html_(1), on how to render a page from the command-line
|
||||
- _oddmu-list_(1), on how to list pages and titles from the command-line
|
||||
- _oddmu-missing_(1), on how to find broken local links from the command-line
|
||||
- _oddmu-nginx_(5), on how to set up freenginx as a reverse proxy
|
||||
- _oddmu-releases_(7), on what features are part of the latest release
|
||||
- _oddmu-replace_(1), on how to search and replace text from the command-line
|
||||
- _oddmu-search_(1), on how to run a search from the command-line
|
||||
- _oddmu-search_(7), on how search works
|
||||
- _oddmu-static_(1), on generating a static site from the command-line
|
||||
- _oddmu-notify_(1), on updating index, changes and hashtag pages from the
|
||||
command-line
|
||||
- _oddmu-templates_(5), on how to write the HTML templates
|
||||
- _oddmu-replace_(1), on how to search and replace text from the command-line
|
||||
- _oddmu-search_(1), on how to run a search from the command-line
|
||||
- _oddmu-static_(1), on generating a static site from the command-line
|
||||
- _oddmu-version_(1), on how to get all the build information from the binary
|
||||
|
||||
# AUTHORS
|
||||
|
||||
67
man_test.go
67
man_test.go
@@ -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"
|
||||
)
|
||||
@@ -28,3 +32,66 @@ func TestManPages(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadme(t *testing.T) {
|
||||
b, err := os.ReadFile("README.md")
|
||||
main := string(b)
|
||||
assert.NoError(t, err)
|
||||
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".txt") {
|
||||
s := strings.TrimPrefix(path, "man/")
|
||||
s = strings.TrimSuffix(s, ".txt")
|
||||
i := strings.LastIndex(s, ".")
|
||||
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
|
||||
assert.Contains(t, main, ref, ref)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") &&
|
||||
!strings.HasSuffix(path, "_test.go") &&
|
||||
!strings.HasSuffix(path, "_cmd.go") {
|
||||
s := strings.TrimPrefix(path, "./")
|
||||
ref := "`" + s + "`"
|
||||
assert.Contains(t, main, ref, ref)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
30
oddmu.svg
Normal 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
10
page.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -61,14 +61,14 @@ 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))
|
||||
fn, hashtags := hashtag()
|
||||
parser.RegisterInline('#', fn)
|
||||
if useWebfinger {
|
||||
parser.RegisterInline('@', account)
|
||||
parser.RegisterInline('@', accountLink)
|
||||
}
|
||||
return parser, hashtags
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
216
static_cmd.go
216
static_cmd.go
@@ -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
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
|
||||
14
templates.go
14
templates.go
@@ -17,15 +17,19 @@ import (
|
||||
var templateFiles = []string{"edit.html", "add.html", "view.html",
|
||||
"diff.html", "search.html", "static.html", "upload.html", "feed.html"}
|
||||
|
||||
// templates are the parsed HTML templates used. See renderTemplate and loadTemplates. Subdirectories may contain their
|
||||
// own templates which override the templates in the root directory. If so, they are not filepaths. Use
|
||||
// filepath.ToSlash() if necessary.
|
||||
type Template struct {
|
||||
// templateStore controls access to map of parsed HTML templates. Make sure to lock and unlock as appropriate. See
|
||||
// renderTemplate and loadTemplates.
|
||||
type templateStore struct {
|
||||
sync.RWMutex
|
||||
|
||||
// template is a map of parsed HTML templates. The key is their path name. By default, the map only contains
|
||||
// top-level templates like "view.html". Subdirectories may contain their own templates which override the
|
||||
// templates in the root directory. If so, they are paths like "dir/view.html", not filepaths. Use
|
||||
// filepath.ToSlash() if necessary.
|
||||
template map[string]*template.Template
|
||||
}
|
||||
|
||||
var templates Template
|
||||
var templates templateStore
|
||||
|
||||
// loadTemplates loads the templates. If templates have already been loaded, return immediately.
|
||||
func loadTemplates() {
|
||||
|
||||
@@ -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
11
themes/Makefile
Normal 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/
|
||||
|
||||
13
themes/alexschroeder.ch/README.md
Normal file
13
themes/alexschroeder.ch/README.md
Normal 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).)
|
||||
19
themes/alexschroeder.ch/add.html
Normal file
19
themes/alexschroeder.ch/add.html
Normal 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>
|
||||
22
themes/alexschroeder.ch/diff.html
Normal file
22
themes/alexschroeder.ch/diff.html
Normal 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>
|
||||
21
themes/alexschroeder.ch/edit.html
Normal file
21
themes/alexschroeder.ch/edit.html
Normal 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>
|
||||
28
themes/alexschroeder.ch/feed.html
Normal file
28
themes/alexschroeder.ch/feed.html
Normal 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>
|
||||
45
themes/alexschroeder.ch/oddmu.css
Normal file
45
themes/alexschroeder.ch/oddmu.css
Normal 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
|
||||
}
|
||||
}
|
||||
45
themes/alexschroeder.ch/search.html
Normal file
45
themes/alexschroeder.ch/search.html
Normal 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>
|
||||
30
themes/alexschroeder.ch/static.html
Normal file
30
themes/alexschroeder.ch/static.html
Normal 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>
|
||||
104
themes/alexschroeder.ch/upload.html
Normal file
104
themes/alexschroeder.ch/upload.html
Normal 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: </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>
|
||||
41
themes/alexschroeder.ch/view.html
Normal file
41
themes/alexschroeder.ch/view.html
Normal 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 <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
13
themes/campaignwiki.org/README.md
Normal file
13
themes/campaignwiki.org/README.md
Normal 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).)
|
||||
22
themes/campaignwiki.org/add.html
Normal file
22
themes/campaignwiki.org/add.html
Normal 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>
|
||||
28
themes/campaignwiki.org/diff.html
Normal file
28
themes/campaignwiki.org/diff.html
Normal 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>
|
||||
24
themes/campaignwiki.org/edit.html
Normal file
24
themes/campaignwiki.org/edit.html
Normal 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>
|
||||
27
themes/campaignwiki.org/feed.html
Normal file
27
themes/campaignwiki.org/feed.html
Normal 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>
|
||||
55
themes/campaignwiki.org/search.html
Normal file
55
themes/campaignwiki.org/search.html
Normal 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>
|
||||
30
themes/campaignwiki.org/static.html
Normal file
30
themes/campaignwiki.org/static.html
Normal 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>
|
||||
111
themes/campaignwiki.org/upload.html
Normal file
111
themes/campaignwiki.org/upload.html
Normal 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: </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>
|
||||
51
themes/campaignwiki.org/view.html
Normal file
51
themes/campaignwiki.org/view.html
Normal 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 <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
13
themes/chat/README.md
Normal file
13
themes/chat/README.md
Normal 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
40
themes/chat/edit.html
Normal 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
53
themes/chat/search.html
Normal 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
45
themes/chat/upload.html
Normal 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></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 don’t, 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
52
themes/chat/view.html
Normal 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>
|
||||
9
themes/flying-carpet.ch/README.md
Normal file
9
themes/flying-carpet.ch/README.md
Normal 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).)
|
||||
22
themes/flying-carpet.ch/add.html
Normal file
22
themes/flying-carpet.ch/add.html
Normal 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>
|
||||
28
themes/flying-carpet.ch/diff.html
Normal file
28
themes/flying-carpet.ch/diff.html
Normal 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>
|
||||
24
themes/flying-carpet.ch/edit.html
Normal file
24
themes/flying-carpet.ch/edit.html
Normal 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>
|
||||
21
themes/flying-carpet.ch/feed.html
Normal file
21
themes/flying-carpet.ch/feed.html
Normal 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>
|
||||
54
themes/flying-carpet.ch/search.html
Normal file
54
themes/flying-carpet.ch/search.html
Normal 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>
|
||||
30
themes/flying-carpet.ch/static.html
Normal file
30
themes/flying-carpet.ch/static.html
Normal 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>
|
||||
111
themes/flying-carpet.ch/upload.html
Normal file
111
themes/flying-carpet.ch/upload.html
Normal 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: </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>
|
||||
40
themes/flying-carpet.ch/view.html
Normal file
40
themes/flying-carpet.ch/view.html
Normal 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
16
themes/index.md
Normal 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.)
|
||||
6
themes/transjovian.org/README.md
Normal file
6
themes/transjovian.org/README.md
Normal 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).)
|
||||
24
themes/transjovian.org/add.html
Normal file
24
themes/transjovian.org/add.html
Normal 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>
|
||||
29
themes/transjovian.org/diff.html
Normal file
29
themes/transjovian.org/diff.html
Normal 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>
|
||||
26
themes/transjovian.org/edit.html
Normal file
26
themes/transjovian.org/edit.html
Normal 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>
|
||||
28
themes/transjovian.org/feed.html
Normal file
28
themes/transjovian.org/feed.html
Normal 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>
|
||||
56
themes/transjovian.org/search.html
Normal file
56
themes/transjovian.org/search.html
Normal 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>
|
||||
31
themes/transjovian.org/static.html
Normal file
31
themes/transjovian.org/static.html
Normal 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 <<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
113
themes/transjovian.org/upload.html
Normal file
113
themes/transjovian.org/upload.html
Normal 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: </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>
|
||||
46
themes/transjovian.org/view.html
Normal file
46
themes/transjovian.org/view.html
Normal 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 <<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
83
upload.html
83
upload.html
@@ -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: </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>
|
||||
|
||||
@@ -4,22 +4,24 @@ 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 {
|
||||
type upload struct {
|
||||
Dir string
|
||||
Name string
|
||||
Last string
|
||||
@@ -34,7 +36,7 @@ var lastRe = regexp.MustCompile(`^(.*)([0-9]+)(.*)$`)
|
||||
// parameters are used to copy name, maxwidth and quality from the previous upload. If the previous name contains a
|
||||
// number, this is incremented by one.
|
||||
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
data := &Upload{Dir: dir}
|
||||
data := &upload{Dir: dir}
|
||||
maxwidth := r.FormValue("maxwidth")
|
||||
if maxwidth != "" {
|
||||
data.MaxWidth = maxwidth
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
16
view.go
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
55
view_test.go
55
view_test.go
@@ -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 & 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 & 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"))
|
||||
}
|
||||
|
||||
45
watch.go
45
watch.go
@@ -12,18 +12,33 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Watches holds a map and a mutex. The map contains the template names that have been requested and the exact time at
|
||||
// which they have been requested. Adding the same file multiple times, such as when the watch function sees multiple
|
||||
// Write events for the same file, the time keeps getting updated so that when the go routine runs, it only acts on
|
||||
// files that haven't been updated in the last second. The go routine is what forces us to use the RWMutex for the map.
|
||||
type Watches struct {
|
||||
// watchStore controls access to the maps used by the filesystem watches. Make sure to lock and unlock as appropriate.
|
||||
// The maps are used to control a sort of queue for files that need reloading (if a template) or reindexing (if a page).
|
||||
// File system notifications add files to the queue in order to handle changes made without Oddmu, while Oddmu is
|
||||
// running.
|
||||
type watchStore struct {
|
||||
sync.RWMutex
|
||||
|
||||
// files contains the filenames that have been queued for reloading (if a template) or reindexing (if a page)
|
||||
// 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
|
||||
|
||||
// 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
|
||||
// template) or reindexing (if a page). When Oddmu is making the changes, it can ignore the corresponding
|
||||
// notifications by the file system. Those notifications are consequences of Oddmu doing its job. In other
|
||||
// words, Oddmu does not rely on file system notification even it is Oddmu doing the changes. This avoids a 1s
|
||||
// when changing templates, for example.
|
||||
ignores map[string]time.Time
|
||||
files map[string]time.Time
|
||||
|
||||
// watcher is the pointer to the actual watcher doing the file system watching. It watches a set of paths.
|
||||
// Whenever Oddmu creates a new subdirectory, it adds the path for this subdirectory to the watcher.
|
||||
watcher *fsnotify.Watcher
|
||||
}
|
||||
|
||||
var watches Watches
|
||||
var watches watchStore
|
||||
|
||||
func init() {
|
||||
watches.ignores = make(map[string]time.Time)
|
||||
@@ -31,7 +46,7 @@ func init() {
|
||||
}
|
||||
|
||||
// install initializes watches and installs watchers for all directories and subdirectories.
|
||||
func (w *Watches) install() (int, error) {
|
||||
func (w *watchStore) install() (int, error) {
|
||||
// create a watcher for the root directory and never close it
|
||||
var err error
|
||||
w.watcher, err = fsnotify.NewWatcher()
|
||||
@@ -48,7 +63,7 @@ func (w *Watches) install() (int, error) {
|
||||
}
|
||||
|
||||
// add installs a watch for every directory that isn't hidden. Note that the root directory (".") is not skipped.
|
||||
func (w *Watches) add(path string, info fs.FileInfo, err error) error {
|
||||
func (w *watchStore) add(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,7 +87,7 @@ func (w *Watches) add(path string, info fs.FileInfo, err error) error {
|
||||
// adds an entry to the files map, or updates the file's time, and starts a go routine. Example: If a file gets three
|
||||
// consecutive Write events, the first two go routine invocations won't do anything, since the time kept getting
|
||||
// updated. Only the last invocation will act upon the event.
|
||||
func (w *Watches) watch() {
|
||||
func (w *watchStore) watch() {
|
||||
for {
|
||||
select {
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
@@ -94,7 +109,7 @@ func (w *Watches) watch() {
|
||||
// Incidentally, this also prevents rsync updates from generating activity ("stat ./.index.md.tTfPFg: no such file or
|
||||
// directory"). Note the painful details: If moving a file into a watched directory, a Create event is received. If a
|
||||
// new file is created in a watched directory, a Create event and one or more Write events is received.
|
||||
func (w *Watches) watchHandle(e fsnotify.Event) {
|
||||
func (w *watchStore) watchHandle(e fsnotify.Event) {
|
||||
path := strings.TrimPrefix(e.Name, "./")
|
||||
if strings.HasPrefix(filepath.Base(path), ".") {
|
||||
return
|
||||
@@ -130,7 +145,7 @@ func (w *Watches) watchHandle(e fsnotify.Event) {
|
||||
|
||||
// watchTimer checks if the file hasn't been updated in 1s and if so, it calls watchDoUpdate. If another write has
|
||||
// updated the file, do nothing because another watchTimer will run at the appropriate time and check again.
|
||||
func (w *Watches) watchTimer(path string) {
|
||||
func (w *watchStore) watchTimer(path string) {
|
||||
t, ok := w.files[path]
|
||||
if ok && t.Add(time.Second).Before(time.Now().Add(time.Nanosecond)) {
|
||||
delete(w.files, path)
|
||||
@@ -141,7 +156,7 @@ func (w *Watches) watchTimer(path string) {
|
||||
// Do the right thing right now. For Create events such as directories being created or files being moved into a watched
|
||||
// directory, this is the right thing to do. When a file is being written to, watchHandle will have started a timer and
|
||||
// will call this function after 1s of no more writes. If, however, the path is in the ignores map, do nothing.
|
||||
func (w *Watches) watchDoUpdate(path string) {
|
||||
func (w *watchStore) watchDoUpdate(path string) {
|
||||
_, ignored := w.ignores[path]
|
||||
if ignored {
|
||||
return
|
||||
@@ -170,7 +185,7 @@ func (w *Watches) watchDoUpdate(path string) {
|
||||
|
||||
// watchDoRemove removes files from the index or discards templates. If the path in question is in the ignores map, do
|
||||
// nothing.
|
||||
func (w *Watches) watchDoRemove(path string) {
|
||||
func (w *watchStore) watchDoRemove(path string) {
|
||||
_, ignored := w.ignores[path]
|
||||
if ignored {
|
||||
return
|
||||
@@ -189,7 +204,7 @@ func (w *Watches) watchDoRemove(path string) {
|
||||
|
||||
// ignore is before code that is known suspected save files and trigger watchHandle eventhough the code already handles
|
||||
// this. This is achieved by adding the path to the ignores map for 1s.
|
||||
func (w *Watches) ignore(path string) {
|
||||
func (w *watchStore) ignore(path string) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
w.ignores[path] = time.Now()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user