forked from mirror/oddmu
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77a38ddf66 | ||
|
|
d3ffe82a90 | ||
|
|
4a12721462 | ||
|
|
07b1277764 | ||
|
|
f99a54e2ef | ||
|
|
56a4461bd6 | ||
|
|
2e38daf667 | ||
|
|
a5421372d8 | ||
|
|
7017363f6a | ||
|
|
9a7a1ee2a9 | ||
|
|
76d7598854 | ||
|
|
d7b48b975b | ||
|
|
4314a35d1d | ||
|
|
63e1c987f2 | ||
|
|
7d748f82da | ||
|
|
8385bc424a | ||
|
|
820763bf23 | ||
|
|
7d40fa4adb | ||
|
|
6e24603c27 | ||
|
|
5096627b87 | ||
|
|
0c691123ff | ||
|
|
5770966cdd | ||
|
|
a001a77692 | ||
|
|
0e29ed77ea | ||
|
|
173bb62a79 | ||
|
|
58249aac85 | ||
|
|
1fdd502e95 | ||
|
|
196ff605c3 | ||
|
|
6c1e595f13 | ||
|
|
34fdb5d9a9 | ||
|
|
b28614fa52 | ||
|
|
e7511ed059 | ||
|
|
a5d03dd136 | ||
|
|
87d5efcb7a | ||
|
|
c9bb062a04 | ||
|
|
e2eec5e052 | ||
|
|
26033de177 | ||
|
|
e1ba007f97 | ||
|
|
e90ff9e7dd | ||
|
|
70356e850a | ||
|
|
81a59fd6ac | ||
|
|
52d6f26eed |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
/oddmu
|
||||
test.md
|
||||
/testdata/
|
||||
/oddmu-darwin-*
|
||||
/oddmu-linux-*
|
||||
/oddmu-windows-*
|
||||
/oddmu.exe
|
||||
/oddmu
|
||||
|
||||
2
Makefile
2
Makefile
@@ -72,7 +72,7 @@ oddmu-windows-amd64.tar.gz: oddmu.exe
|
||||
|
||||
%.tar.gz: %
|
||||
tar --create --file $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
|
||||
$< Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
|
||||
$< *.html Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
|
||||
|
||||
priv:
|
||||
sudo setcap 'cap_net_bind_service=+ep' oddmu
|
||||
|
||||
33
README.md
33
README.md
@@ -1,4 +1,4 @@
|
||||
# Oddµ: A minimal wiki
|
||||
# Oddμ: A minimal wiki
|
||||
|
||||
This program helps you run a minimal wiki, blog, digital garden, memex
|
||||
or Zettelkasten. There is no version history.
|
||||
@@ -19,7 +19,7 @@ write-access to the site.
|
||||
It's well suited as a simple static site generator. There are no
|
||||
plugins.
|
||||
|
||||
When Oddµ runs as a web server, it serves all the Markdown files
|
||||
When Oddμ runs as a web server, it serves all the Markdown files
|
||||
(ending in `.md`) as web pages. These pages can be edited via the web.
|
||||
|
||||
Oddmu adds the following extensions to Markdown: local links `[[like
|
||||
@@ -32,7 +32,7 @@ necessary.
|
||||
|
||||
Other files can be uploaded and images (ending in `.jpg`, `.jpeg`,
|
||||
`.png`, `.heic` or `.webp`) can be resized when they are uploaded
|
||||
(resulting in `.jpg` or `.png` files).
|
||||
(resulting in `.jpg`, `.png` or `.webp` files).
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -167,8 +167,6 @@ make docs
|
||||
The `Makefile` in the `man` directory has targets to create Markdown
|
||||
and HTML files.
|
||||
|
||||
The HEIC library uses C code and prevents cross-compilation.
|
||||
|
||||
As the repository changed URLs a few times (from GitHub, to
|
||||
self-hosted using `cgit` to self-hosted using `legit`), there is no
|
||||
way to install it using `go install`. You need to `git clone` the
|
||||
@@ -280,18 +278,10 @@ extensions can be added.
|
||||
|
||||
### Filenames and URL path
|
||||
|
||||
One of the sad parts of the code is the distinction between path and
|
||||
filepath. On a Linux system, this doesn't matter. I suspect that it
|
||||
also doesn't matter on MacOS and Windows because the file systems
|
||||
handle forward slashes just fine. The code still tries to do the right
|
||||
thing. A path that is derived from a URL is a path with slashes.
|
||||
Before accessing a file, it has to be turned into a filepath using
|
||||
`filepath.FromSlashes` and in the rare case where the inverse happens,
|
||||
use `filepath.ToSlashes`. Any path received via the URL path uses
|
||||
slashes and needs to be converted to a filepath before passing it to
|
||||
any `os` function. Any path received within a `path/filepath.WalkFunc`
|
||||
is a filepath and needs to be converted to use slashes when used in
|
||||
HTML output.
|
||||
There are some simplifications made. The code doesn't consider the
|
||||
various encodings (UTF-8 NFC on the web vs UTF-8 NFD for HFS+, for
|
||||
example; it also doesn't check for characters in page names that are
|
||||
illegal filenames on the filesystem used).
|
||||
|
||||
If you need to access the page name in code that is used from a
|
||||
template, you have to decode the path. See the code in `diff.go` for
|
||||
@@ -347,9 +337,12 @@ in turn can be used by browsers to get hyphenation right. Apache-2.0.
|
||||
is used to sniff the MIME type of files with unknown filename
|
||||
extensions. MIT.
|
||||
|
||||
[github.com/gen2brain/heic](https://github.com/gen2brain/heic) is
|
||||
used to decode HEIC files (the new default file format for photos on
|
||||
iPhones). LGPL-3.0-only.
|
||||
[github.com/gen2brain/heic](https://github.com/gen2brain/heic) is used
|
||||
to decode HEIC files (the new default file format for photos on
|
||||
iPhones). MIT.
|
||||
|
||||
[github.com/gen2brain/webp](https://github.com/gen2brain/webp) is used
|
||||
to encode and decode WebP files. MIT.
|
||||
|
||||
[github.com/disintegration/imaging](https://github.com/disintegration/imaging)
|
||||
is used to resize images. MIT.
|
||||
|
||||
4
add.html
4
add.html
@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
|
||||
</head>
|
||||
<body>
|
||||
<h1>Adding to {{.Title}}</h1>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<form action="/append/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required></textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Add">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -48,7 +48,7 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+name, http.StatusFound)
|
||||
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
|
||||
}
|
||||
|
||||
func (p *Page) append(body []byte) {
|
||||
|
||||
20
archive.go
20
archive.go
@@ -16,7 +16,7 @@ import (
|
||||
// are skipped. If the environment variable ODDMU_FILTER is a regular expression that matches the starting directory,
|
||||
// this is a "separate site"; if the regular expression does not match, this is the "main site" and page names must also
|
||||
// not match the regular expression.
|
||||
func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
|
||||
func archiveHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
filter := os.Getenv("ODDMU_FILTER")
|
||||
re, err := regexp.Compile(filter)
|
||||
if err != nil {
|
||||
@@ -24,30 +24,30 @@ func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
matches := re.MatchString(path)
|
||||
dir := filepath.Dir(filepath.FromSlash(path))
|
||||
matches := re.MatchString(name)
|
||||
dir := filepath.Dir(filepath.FromSlash(name))
|
||||
z := zip.NewWriter(w)
|
||||
err = filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
|
||||
err = filepath.Walk(dir, func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
} else if !strings.HasPrefix(filepath.Base(path), ".") &&
|
||||
(matches || !re.MatchString(path)) {
|
||||
zf, err := z.Create(path)
|
||||
} else if !strings.HasPrefix(filepath.Base(fp), ".") &&
|
||||
(matches || !re.MatchString(filepath.ToSlash(fp))) {
|
||||
zf, err := z.Create(fp)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
f, err := os.Open(fp)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(zf, file)
|
||||
_, err = io.Copy(zf, f)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
|
||||
@@ -20,10 +20,10 @@ func (p *Page) notify() error {
|
||||
if p.Title == "" {
|
||||
p.Title = p.Name
|
||||
}
|
||||
esc := nameEscape(path.Base(p.Name))
|
||||
esc := nameEscape(p.Base())
|
||||
link := "* [" + p.Title + "](" + esc + ")\n"
|
||||
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
|
||||
dir := path.Dir(p.Name)
|
||||
dir := p.Dir()
|
||||
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
|
||||
if err != nil {
|
||||
log.Printf("Updating changes in %s failed: %s", dir, err)
|
||||
@@ -31,7 +31,7 @@ func (p *Page) notify() error {
|
||||
}
|
||||
if p.IsBlog() {
|
||||
// Add to the index only if the blog post is for the current year
|
||||
if strings.HasPrefix(path.Base(p.Name), time.Now().Format("2006")) {
|
||||
if strings.HasPrefix(p.Base(), time.Now().Format("2006")) {
|
||||
err := addLink(path.Join(dir, "index"), true, link, re)
|
||||
if err != nil {
|
||||
log.Printf("Updating index in %s failed: %s", dir, err)
|
||||
|
||||
7
diff.go
7
diff.go
@@ -6,7 +6,6 @@ import (
|
||||
"html"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -25,11 +24,7 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
|
||||
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
|
||||
func (p *Page) Diff() template.HTML {
|
||||
path, err := url.PathUnescape(p.Name)
|
||||
if err != nil {
|
||||
return template.HTML("Cannot unescape " + p.Name)
|
||||
}
|
||||
fp := filepath.FromSlash(path)
|
||||
fp := filepath.FromSlash(p.Name)
|
||||
a := fp + ".md~"
|
||||
t1, err := os.ReadFile(a)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/view/{{.Name}}">Back</a>
|
||||
<a href="/view/{{.Path}}">Back</a>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
|
||||
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
|
||||
<pre>
|
||||
{{.Diff}}
|
||||
</pre>
|
||||
|
||||
15
diff_test.go
15
diff_test.go
@@ -70,6 +70,7 @@ I hate the machine!`
|
||||
I shiver at home
|
||||
the monitor glares and moans
|
||||
my grey heart grows cold`
|
||||
// create s and overwrite it with r
|
||||
p := &Page{Name: "testdata/backup/cold", Body: []byte(s)}
|
||||
p.save()
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
|
||||
@@ -78,19 +79,29 @@ my grey heart grows cold`
|
||||
// diff from s to r:
|
||||
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
|
||||
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
|
||||
// save u
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(u)}
|
||||
p.save()
|
||||
body = string(p.Diff())
|
||||
// diff from s to u since r was not 60 min or older
|
||||
// diff from s to u since r was not 60 min or older and so the backup is kept
|
||||
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
|
||||
assert.Contains(t, body, `<ins>my grey heart grows cold</ins>`)
|
||||
// set timestamp 2h in the past
|
||||
ts := time.Now().Add(-2 * time.Hour)
|
||||
assert.NoError(t, os.Chtimes("testdata/backup/cold.md~", ts, ts))
|
||||
assert.NoError(t, os.Chtimes("testdata/backup/cold.md", ts, ts))
|
||||
// save r
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
|
||||
p.save()
|
||||
body = string(p.Diff())
|
||||
// diff from u to r:
|
||||
// diff from u to r since enough time has passed and the old backup is discarded
|
||||
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
|
||||
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
|
||||
// save s
|
||||
p = &Page{Name: "testdata/backup/cold", Body: []byte(s)}
|
||||
p.save()
|
||||
body = string(p.Diff())
|
||||
// diff from u to s since this is still "the same" editing window
|
||||
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
|
||||
assert.Contains(t, body, `<ins>fear or cold, who knows?</ins>`)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ form, textarea { width: 100%; }
|
||||
</head>
|
||||
<body>
|
||||
<h1>Editing {{.Title}}</h1>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -41,5 +41,5 @@ func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+name, http.StatusFound)
|
||||
http.Redirect(w, r, "/view/" + nameEscape(name), http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestExportCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := exportCli(b, "feed.html", minimalIndex(t))
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
assert.Contains(t, b.String(), "<title>Oddµ: A minimal wiki</title>")
|
||||
assert.Contains(t, b.String(), "<title>Welcome to Oddµ</title>")
|
||||
assert.Contains(t, b.String(), "<title>Oddμ: A minimal wiki</title>")
|
||||
assert.Contains(t, b.String(), "<title>Welcome to Oddμ</title>")
|
||||
}
|
||||
|
||||
func TestExportCmdLanguage(t *testing.T) {
|
||||
@@ -50,6 +50,6 @@ func TestExportCmdJsonFeed(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := exportCli(b, "testdata/json/template.json", minimalIndex(t))
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
assert.Contains(t, b.String(), `"title": "Oddµ: A minimal wiki"`)
|
||||
assert.Regexp(t, regexp.MustCompile("<h1.*>Welcome to Oddµ</h1>"), b.String()) // skip id
|
||||
assert.Contains(t, b.String(), `"title": "Oddμ: A minimal wiki"`)
|
||||
assert.Regexp(t, regexp.MustCompile("<h1.*>Welcome to Oddμ</h1>"), b.String()) // skip id
|
||||
}
|
||||
|
||||
2
feed.go
2
feed.go
@@ -63,7 +63,7 @@ func feed(p *Page, ti time.Time) *Feed {
|
||||
if !ok || bytes.Contains(link.Destination, []byte("//")) {
|
||||
return ast.GoToNext
|
||||
}
|
||||
name := path.Join(path.Dir(p.Name), string(link.Destination))
|
||||
name := path.Join(p.Dir(), string(link.Destination))
|
||||
fi, err := os.Stat(filepath.FromSlash(name) + ".md")
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<link>https://example.org/</link>
|
||||
<managingEditor>you@example.org (Your Name)</managingEditor>
|
||||
<webMaster>you@example.org (Your Name)</webMaster>
|
||||
<atom:link href="https://example.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<atom:link href="https://example.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the digital garden of Your Name.</description>
|
||||
<image>
|
||||
<url>https://example.org/view/logo.jpg</url>
|
||||
@@ -15,8 +15,8 @@
|
||||
{{range .Items}}
|
||||
<item>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/view/{{.Name}}</link>
|
||||
<guid>https://example.org/view/{{.Name}}</guid>
|
||||
<link>https://example.org/view/{{.Path}}</link>
|
||||
<guid>https://example.org/view/{{.Path}}</guid>
|
||||
<description>{{.Html}}</description>
|
||||
<pubDate>{{.Date}}</pubDate>
|
||||
{{range .Hashtags}}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func TestFeed(t *testing.T) {
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
|
||||
"Welcome to Oddµ")
|
||||
"Welcome to Oddμ")
|
||||
}
|
||||
|
||||
func TestNoFeed(t *testing.T) {
|
||||
|
||||
9
go.mod
9
go.mod
@@ -9,7 +9,8 @@ require (
|
||||
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-20240930133403-7e0a027d98c5
|
||||
github.com/gen2brain/heic v0.3.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/hexops/gotextdiff v1.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
@@ -23,15 +24,15 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/ebitengine/purego v0.7.1 // indirect
|
||||
github.com/gen2brain/heic v0.3.1 // indirect
|
||||
github.com/ebitengine/purego v0.8.1 // indirect
|
||||
github.com/gen2brain/webp v0.5.2 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.6 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/shopspring/decimal v1.3.1 // indirect
|
||||
github.com/tetratelabs/wazero v1.7.3 // indirect
|
||||
github.com/tetratelabs/wazero v1.8.1 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
|
||||
14
go.sum
14
go.sum
@@ -7,20 +7,22 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA=
|
||||
github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
||||
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
|
||||
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
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/gen2brain/heic v0.0.0-20230113233934-ca402e77a786 h1:zvgtcRb2B5gynWjm+Fc9oJZPHXwmcgyH0xCcNm6Rmo4=
|
||||
github.com/gen2brain/heic v0.0.0-20230113233934-ca402e77a786/go.mod h1:aKVJoQ0cc9K5Xb058XSnnAxXLliR97qbSqWBlm5ca1E=
|
||||
github.com/gen2brain/heic v0.3.1 h1:ClY5YTdXdIanw7pe9ZVUM9XcsqH6CCCa5CZBlm58qOs=
|
||||
github.com/gen2brain/heic v0.3.1/go.mod h1:m2sVIf02O7wfO8mJm+PvE91lnq4QYJy2hseUon7So10=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gen2brain/webp v0.5.2 h1:aYdjbU/2L98m+bqUdkYMOIY93YC+EN3HuZLMaqgMD9U=
|
||||
github.com/gen2brain/webp v0.5.2/go.mod h1:Nb3xO5sy6MeUAHhru9H3GT7nlOQO5dKRNNlE92CZrJw=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5 h1:qIhG9h8tUzKsVHn0iHtWUohq7Ve7btgA8rGp7TvrIHw=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
@@ -61,6 +63,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw=
|
||||
github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
|
||||
github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA550=
|
||||
github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
|
||||
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
@@ -68,8 +72,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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
@@ -8,9 +8,18 @@ import (
|
||||
)
|
||||
|
||||
func TestHashtagsCmd(t *testing.T) {
|
||||
cleanup(t, "testdata/hashtag")
|
||||
p := &Page{Name: "testdata/hashtag/hash", Body: []byte(`# Hash
|
||||
|
||||
I hope for a time
|
||||
not like today, relentless,
|
||||
just crocus blooming
|
||||
|
||||
#Crocus`)}
|
||||
p.save()
|
||||
b := new(bytes.Buffer)
|
||||
s := hashtagsCli(b)
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
x := b.String()
|
||||
assert.Contains(t, x, "#like_this\t")
|
||||
assert.Contains(t, x, "crocus\t")
|
||||
}
|
||||
|
||||
35
html_cmd.go
35
html_cmd.go
@@ -7,30 +7,32 @@ import (
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type htmlCmd struct {
|
||||
useTemplate bool
|
||||
template string
|
||||
}
|
||||
|
||||
func (*htmlCmd) Name() string { return "html" }
|
||||
func (*htmlCmd) Synopsis() string { return "render a page as HTML" }
|
||||
func (*htmlCmd) Usage() string {
|
||||
return `html [-view] <page name> ...:
|
||||
return `html [-template <template name>] <page name> ...:
|
||||
Render one or more pages as HTML.
|
||||
Use a single - to read Markdown from stdin.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *htmlCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.BoolVar(&cmd.useTemplate, "view", false, "use the 'view.html' template.")
|
||||
f.StringVar(&cmd.template, "template", "",
|
||||
"use the given HTML file as a template (probably view.html or static.html).")
|
||||
}
|
||||
|
||||
func (cmd *htmlCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return htmlCli(os.Stdout, cmd.useTemplate, f.Args())
|
||||
return htmlCli(os.Stdout, cmd.template, f.Args())
|
||||
}
|
||||
|
||||
func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatus {
|
||||
func htmlCli(w io.Writer, template string, args []string) subcommands.ExitStatus {
|
||||
if len(args) == 1 && args[0] == "-" {
|
||||
body, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
@@ -38,15 +40,20 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
p := &Page{Name: "stdin", Body: body}
|
||||
return p.printHtml(w, useTemplate)
|
||||
return p.printHtml(w, template)
|
||||
}
|
||||
for _, arg := range args {
|
||||
p, err := loadPage(arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
|
||||
for _, name := range args {
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
status := p.printHtml(w, useTemplate)
|
||||
name = name[0:len(name)-3]
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
status := p.printHtml(w, template)
|
||||
if status != subcommands.ExitSuccess {
|
||||
return status
|
||||
}
|
||||
@@ -54,9 +61,9 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func (p *Page) printHtml(w io.Writer, useTemplate bool) subcommands.ExitStatus {
|
||||
if useTemplate {
|
||||
t := "view.html"
|
||||
func (p *Page) printHtml(w io.Writer, template string) subcommands.ExitStatus {
|
||||
if len(template) > 0 {
|
||||
t := template
|
||||
loadTemplates()
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
|
||||
func TestHtmlCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := htmlCli(b, false, []string{"index"})
|
||||
s := htmlCli(b, "", []string{"index.md"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `<h1 id="welcome-to-oddµ">Welcome to Oddµ</h1>
|
||||
r := `<h1 id="welcome-to-oddμ">Welcome to Oddμ</h1>
|
||||
|
||||
<p>Hello! 🙃</p>
|
||||
|
||||
|
||||
15
index.go
15
index.go
@@ -62,10 +62,12 @@ func (idx *indexStore) reset() {
|
||||
}
|
||||
|
||||
// addDocument adds the text as a new document. This assumes that the index is locked!
|
||||
// The hashtags (only!) are used as tokens. They are stored in lower case.
|
||||
func (idx *indexStore) addDocument(text []byte) docid {
|
||||
id := idx.next_id
|
||||
idx.next_id++
|
||||
for _, token := range hashtags(text) {
|
||||
token = strings.ToLower(token)
|
||||
ids := idx.token[token]
|
||||
// Don't add same ID more than once. Checking the last
|
||||
// position of the []docid works because the id is
|
||||
@@ -137,12 +139,12 @@ func (idx *indexStore) load() (int, error) {
|
||||
}
|
||||
|
||||
// walk reads a file and adds it to the index. This assumes that the index is locked.
|
||||
func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
|
||||
func (idx *indexStore) walk(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
@@ -150,10 +152,10 @@ func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
|
||||
}
|
||||
}
|
||||
// skipp all but page files
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
if !strings.HasSuffix(fp, ".md") {
|
||||
return nil
|
||||
}
|
||||
p, err := loadPage(strings.TrimSuffix(path, ".md"))
|
||||
p, err := loadPage(strings.TrimSuffix(filepath.ToSlash(fp), ".md"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,8 +195,8 @@ func (idx *indexStore) update(p *Page) {
|
||||
idx.add(p)
|
||||
}
|
||||
|
||||
// search searches the index for a query string and returns page
|
||||
// names.
|
||||
// search searches the index. The query string is parsed for tokens. Each token is turned to lower cased and looked up
|
||||
// in the index. Each page in the result must contain all the tokens. Returns page names.
|
||||
func (idx *indexStore) search(q string) []string {
|
||||
idx.RLock()
|
||||
defer idx.RUnlock()
|
||||
@@ -203,6 +205,7 @@ func (idx *indexStore) search(q string) []string {
|
||||
if len(hashtags) > 0 {
|
||||
var r []docid
|
||||
for _, token := range hashtags {
|
||||
token = strings.ToLower(token)
|
||||
if ids, ok := idx.token[token]; ok {
|
||||
if r == nil {
|
||||
r = ids
|
||||
|
||||
@@ -11,8 +11,8 @@ func TestIndexAdd(t *testing.T) {
|
||||
idx.reset()
|
||||
idx.Lock()
|
||||
defer idx.Unlock()
|
||||
tag := "#hello"
|
||||
id := idx.addDocument([]byte("oh hi " + tag))
|
||||
tag := "hello"
|
||||
id := idx.addDocument([]byte("oh hi #" + tag))
|
||||
assert.Contains(t, idx.token, tag)
|
||||
idx.deleteDocument(id)
|
||||
assert.NotContains(t, idx.token, tag)
|
||||
@@ -21,7 +21,7 @@ func TestIndexAdd(t *testing.T) {
|
||||
// TestIndex relies on README.md being indexed
|
||||
func TestIndex(t *testing.T) {
|
||||
index.load()
|
||||
q := "Oddµ"
|
||||
q := "Oddμ"
|
||||
pages, _ := search(q, "", "", 1, false)
|
||||
assert.NotZero(t, len(pages))
|
||||
for _, p := range pages {
|
||||
@@ -31,10 +31,19 @@ func TestIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Lower case hashtag!
|
||||
func TestSearchHashtag(t *testing.T) {
|
||||
cleanup(t, "testdata/search-hashtag")
|
||||
p := &Page{Name: "testdata/search-hashtag/search", Body: []byte(`# Search
|
||||
|
||||
I'm back in this room
|
||||
Shelf, table, chair, and shelf again
|
||||
Where are my glasses?
|
||||
|
||||
#Searching`)}
|
||||
p.save()
|
||||
index.load()
|
||||
q := "#like_this"
|
||||
pages, _ := search(q, "", "", 1, false)
|
||||
pages, _ := search("#searching", "", "", 1, false)
|
||||
assert.NotZero(t, len(pages))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type linksCmd struct {
|
||||
@@ -43,6 +44,11 @@ func linksCli(w io.Writer, args []string) subcommands.ExitStatus {
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
for _, name := range args {
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
name = name[0:len(name)-3]
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestLinksCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := linksCli(b, []string{"README"})
|
||||
s := linksCli(b, []string{"README.md"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
x := b.String()
|
||||
assert.Contains(t, x, "https://alexschroeder.ch/view/oddmu/oddmu.1\n")
|
||||
|
||||
42
list.go
42
list.go
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,38 +27,38 @@ type List struct {
|
||||
}
|
||||
|
||||
// listHandler uses the "list.html" template to enable file management in a particular directory.
|
||||
func listHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
func listHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
files := []File{}
|
||||
d := filepath.FromSlash(dir)
|
||||
d := filepath.FromSlash(name)
|
||||
if d == "" {
|
||||
d = "."
|
||||
} else if !strings.HasSuffix(d, "/") {
|
||||
http.Redirect(w, r, "/list/"+d+"/", http.StatusFound)
|
||||
http.Redirect(w, r, "/list/" + nameEscape(name) + "/", http.StatusFound)
|
||||
return
|
||||
} else {
|
||||
it := File{Name: "..", IsUp: true, IsDir: true }
|
||||
files = append(files, it)
|
||||
}
|
||||
err := filepath.Walk(d, func (path string, fi fs.FileInfo, err error) error {
|
||||
err := filepath.Walk(d, func (fp string, fi fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
isDir := false
|
||||
if fi.IsDir() {
|
||||
if d == path {
|
||||
if d == fp {
|
||||
return nil
|
||||
}
|
||||
isDir = true
|
||||
}
|
||||
name := filepath.ToSlash(path)
|
||||
base := filepath.Base(name)
|
||||
name := filepath.ToSlash(fp)
|
||||
base := filepath.Base(fp)
|
||||
title := ""
|
||||
if !isDir && strings.HasSuffix(name, ".md") {
|
||||
index.RLock()
|
||||
defer index.RUnlock()
|
||||
title = index.titles[name[:len(name)-3]]
|
||||
}
|
||||
if isDir {
|
||||
} else if isDir {
|
||||
// even on Windows, this looks like a Unix directory
|
||||
base += "/"
|
||||
}
|
||||
it := File{Name: base, Title: title, Date: fi.ModTime().Format(time.DateTime), IsDir: isDir }
|
||||
@@ -72,31 +73,36 @@ func listHandler(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
renderTemplate(w, dir, "list", &List{Dir: dir, Files: files})
|
||||
renderTemplate(w, d, "list", &List{Dir: name, Files: files})
|
||||
}
|
||||
|
||||
|
||||
// deleteHandler deletes the named file and then redirects back to the list
|
||||
func deleteHandler(w http.ResponseWriter, r *http.Request, path string) {
|
||||
fn := filepath.Clean(filepath.FromSlash(path))
|
||||
func deleteHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
fn := filepath.FromSlash(name)
|
||||
err := os.RemoveAll(fn) // and all its children!
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/list/"+filepath.Dir(fn)+"/", http.StatusFound)
|
||||
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(name)) + "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// renameHandler renames the named file and then redirects back to the list
|
||||
func renameHandler(w http.ResponseWriter, r *http.Request, path string) {
|
||||
fn := filepath.Clean(filepath.FromSlash(path))
|
||||
target := filepath.Join(filepath.Dir(fn), r.FormValue("name"))
|
||||
err := os.Rename(fn, target)
|
||||
func renameHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
fn := filepath.FromSlash(name)
|
||||
dir := path.Dir(name)
|
||||
target := path.Join(dir, r.FormValue("name"))
|
||||
if (isHiddenName(target)) {
|
||||
http.Error(w, "the target file would be hidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
err := os.Rename(fn, filepath.FromSlash(target))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/list/"+filepath.Dir(target)+"/", http.StatusFound)
|
||||
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(filepath.ToSlash(target))) + "/", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestListCmd(t *testing.T) {
|
||||
s := listCli(b, "", nil)
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
x := b.String()
|
||||
assert.Contains(t, x, "README\tOddµ: A minimal wiki\n")
|
||||
assert.Contains(t, x, "index\tWelcome to Oddµ\n")
|
||||
assert.Contains(t, x, "README\tOddμ: A minimal wiki\n")
|
||||
assert.Contains(t, x, "index\tWelcome to Oddμ\n")
|
||||
}
|
||||
|
||||
func TestListSubdirCmd(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-HTML" "1" "2024-08-29"
|
||||
.TH "ODDMU-HTML" "1" "2025-04-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -13,21 +13,21 @@ oddmu-html - render Oddmu page HTML
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu html\fR [-view] \fIpage-name\fR
|
||||
\fBoddmu html\fR [\fB\fR-template\fB\fR \fItemplate-name\fR] \fIpage-name\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "html" subcommand opens the Markdown file for the given page name (appending
|
||||
the ".\&md" extension) and prints the HTML to STDOUT without invoking the
|
||||
"view.\&html" template.\& Use "-" as the page name if you want to read Markdown from
|
||||
\fBstdin\fR.\&
|
||||
The "html" subcommand opens the given Markdown file and prints the resulting
|
||||
HTML to STDOUT without invoking the "view.\&html" template.\& Use "-" as the page
|
||||
name if you want to read Markdown from \fBstdin\fR.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-view\fR
|
||||
\fB\fR-template\fB\fR \fItemplate-name\fR
|
||||
.RS 4
|
||||
Use the "view.\&html" template to render the page.\& Without this, the HTML
|
||||
lacks html and body tags.\&
|
||||
Use the given template to render the page.\& Without this, the HTML lacks
|
||||
html and body tags.\& The only two options that make sense are "view.\&html"
|
||||
and "static.\&html".\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLES
|
||||
@@ -36,7 +36,7 @@ Generate "README.\&html" from "README.\&md":
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu html README > README\&.html
|
||||
oddmu html README\&.md > README\&.html
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
|
||||
@@ -6,27 +6,27 @@ oddmu-html - render Oddmu page HTML
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu html* [-view] _page-name_
|
||||
*oddmu html* [**-template** _template-name_] _page-name_
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "html" subcommand opens the Markdown file for the given page name (appending
|
||||
the ".md" extension) and prints the HTML to STDOUT without invoking the
|
||||
"view.html" template. Use "-" as the page name if you want to read Markdown from
|
||||
*stdin*.
|
||||
The "html" subcommand opens the given Markdown file and prints the resulting
|
||||
HTML to STDOUT without invoking the "view.html" template. Use "-" as the page
|
||||
name if you want to read Markdown from *stdin*.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-view*
|
||||
Use the "view.html" template to render the page. Without this, the HTML
|
||||
lacks html and body tags.
|
||||
**-template** _template-name_
|
||||
Use the given template to render the page. Without this, the HTML lacks
|
||||
html and body tags. The only two options that make sense are "view.html"
|
||||
and "static.html".
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
Generate "README.html" from "README.md":
|
||||
|
||||
```
|
||||
oddmu html README > README.html
|
||||
oddmu html README.md > README.html
|
||||
```
|
||||
|
||||
Alternatively:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-LINKS" "1" "2024-08-15"
|
||||
.TH "ODDMU-LINKS" "1" "2025-04-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -17,8 +17,8 @@ oddmu-links - list outgoing links for pages
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "links" subcommand lists outgoing links for one or more page names.\& Use "-"
|
||||
as the page name if you want to read Markdown from \fBstdin\fR.\&
|
||||
The "links" subcommand lists outgoing links for one or more Markdown files.\& Use
|
||||
"-" as the page name if you want to read Markdown from \fBstdin\fR.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
|
||||
@@ -10,8 +10,8 @@ oddmu-links - list outgoing links for pages
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "links" subcommand lists outgoing links for one or more page names. Use "-"
|
||||
as the page name if you want to read Markdown from *stdin*.
|
||||
The "links" subcommand lists outgoing links for one or more Markdown files. Use
|
||||
"-" as the page name if you want to read Markdown from *stdin*.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-NGINX" "5" "2024-08-29"
|
||||
.TH "ODDMU-NGINX" "5" "2025-03-16"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
|
||||
oddmu-nginx - how to setup nginx as a reverse proxy for Oddmu
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.\&
|
||||
This is an unpriviledged port so an ordinary user account can do this.\&
|
||||
.PP
|
||||
This page explains how to setup NGINX on Debian to act as a reverse proxy for
|
||||
Oddmu.\& Once this is done, you can use NGINX to provide HTTPS, request users to
|
||||
This page explains how to setup nginx on Debian to act as a reverse proxy for
|
||||
Oddmu.\& Once this is done, you can use nginx to provide HTTPS, request users to
|
||||
authenticate themselves, and so on.\&
|
||||
.PP
|
||||
.SH CONFIGURATION
|
||||
|
||||
@@ -2,15 +2,15 @@ ODDMU-NGINX(5)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
|
||||
oddmu-nginx - how to setup nginx as a reverse proxy for Oddmu
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.
|
||||
This is an unpriviledged port so an ordinary user account can do this.
|
||||
|
||||
This page explains how to setup NGINX on Debian to act as a reverse proxy for
|
||||
Oddmu. Once this is done, you can use NGINX to provide HTTPS, request users to
|
||||
This page explains how to setup nginx on Debian to act as a reverse proxy for
|
||||
Oddmu. Once this is done, you can use nginx to provide HTTPS, request users to
|
||||
authenticate themselves, and so on.
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-NOTIFY" "1" "2024-08-29"
|
||||
.TH "ODDMU-NOTIFY" "1" "2025-04-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -17,8 +17,8 @@ oddmu-notify - add links to changes.\&md, index.\&md, and hashtag pages
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "notify" subcommand takes all the page names provided (without the ".\&md"
|
||||
extension) and adds links to it from other pages.\&
|
||||
The "notify" subcommand takes all the Markdown files provided and adds links to
|
||||
these pages from other pages.\&
|
||||
.PP
|
||||
A new link is added to the \fBchanges\fR page in the current directory if it doesn'\&t
|
||||
exist.\& The current date of the machine Oddmu is running on is used as the
|
||||
@@ -57,7 +57,7 @@ it exists):
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu notify 2023-11-05-climate
|
||||
oddmu notify 2023-11-05-climate\&.md
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
|
||||
@@ -10,8 +10,8 @@ oddmu-notify - add links to changes.md, index.md, and hashtag pages
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "notify" subcommand takes all the page names provided (without the ".md"
|
||||
extension) and adds links to it from other pages.
|
||||
The "notify" subcommand takes all the Markdown files provided and adds links to
|
||||
these pages from other pages.
|
||||
|
||||
A new link is added to the *changes* page in the current directory if it doesn't
|
||||
exist. The current date of the machine Oddmu is running on is used as the
|
||||
@@ -49,7 +49,7 @@ After writing the file "2023-11-05-climate.md" containing the hashtag
|
||||
it exists):
|
||||
|
||||
```
|
||||
oddmu notify 2023-11-05-climate
|
||||
oddmu notify 2023-11-05-climate.md
|
||||
```
|
||||
|
||||
The changes file might look as follows:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-RELEASES" "7" "2024-11-15"
|
||||
.TH "ODDMU-RELEASES" "7" "2025-04-06"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -15,6 +15,35 @@ oddmu-releases - what'\&s new?\&
|
||||
.PP
|
||||
This page lists user-visible features and template changes to consider.\&
|
||||
.PP
|
||||
.SS 1.16 (2025)
|
||||
.PP
|
||||
Add support for WebP images for uploading and resizing.\&
|
||||
.PP
|
||||
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates.\& If you don'\&t do
|
||||
this, your page names (i.\&e.\& filenames for pages) may not include a comma, a
|
||||
semicolon or a questionmark.\& This fix was necessary because file uploads of
|
||||
filenames with non-ASCII characters ended up double-encoded.\&
|
||||
.PP
|
||||
Improved the example themes.\& The chat theme got better list styling and better
|
||||
upload functionality with automatic "add" button; the plain theme got rocket
|
||||
links via JavaScript; the alexschroeder.\&ch theme got a preview button and better
|
||||
image support for upload and search; the transjovian.\&org theme got better image
|
||||
support for upload.\&
|
||||
.PP
|
||||
Switch the \fIhtml\fR, \fIlink\fR, \fInotify\fR and \fItoc\fR subcommand to take filenames
|
||||
(including the `.\&md` suffix) instead of page names (without the `.\&md` suffix).\&
|
||||
.PP
|
||||
.SS 1.15 (2025)
|
||||
.PP
|
||||
Fix the hashtag detection.\& This was necessary to cut down on the many false
|
||||
positives.\& They were most obvious with the \fIhashtags\fR subcommand.\& Now the
|
||||
Markdown parser is used at startup to index the pages, making startup slower
|
||||
(about twice as long with my blog).\& The Markdown parser is also used to parse
|
||||
search terms (where it makes little difference).\&
|
||||
.PP
|
||||
Fix the timestamp for backup files.\& This was necessary because the diff didn'\&t
|
||||
work as intended.\&
|
||||
.PP
|
||||
.SS 1.14 (2024)
|
||||
.PP
|
||||
Add \fIlist\fR, \fIdelete\fR and \fIrename\fR actions.\&
|
||||
|
||||
@@ -8,6 +8,35 @@ oddmu-releases - what's new?
|
||||
|
||||
This page lists user-visible features and template changes to consider.
|
||||
|
||||
## 1.16 (2025)
|
||||
|
||||
Add support for WebP images for uploading and resizing.
|
||||
|
||||
You need to change {{.Name}} to {{.Path}} in HTML templates. If you don't do
|
||||
this, your page names (i.e. filenames for pages) may not include a comma, a
|
||||
semicolon or a questionmark. This fix was necessary because file uploads of
|
||||
filenames with non-ASCII characters ended up double-encoded.
|
||||
|
||||
Improved the example themes. The chat theme got better list styling and better
|
||||
upload functionality with automatic "add" button; the plain theme got rocket
|
||||
links via JavaScript; the alexschroeder.ch theme got a preview button and better
|
||||
image support for upload and search; the transjovian.org theme got better image
|
||||
support for upload.
|
||||
|
||||
Switch the _html_, _link_, _notify_ and _toc_ subcommand to take filenames
|
||||
(including the `.md` suffix) instead of page names (without the `.md` suffix).
|
||||
|
||||
## 1.15 (2025)
|
||||
|
||||
Fix the hashtag detection. This was necessary to cut down on the many false
|
||||
positives. They were most obvious with the _hashtags_ subcommand. Now the
|
||||
Markdown parser is used at startup to index the pages, making startup slower
|
||||
(about twice as long with my blog). The Markdown parser is also used to parse
|
||||
search terms (where it makes little difference).
|
||||
|
||||
Fix the timestamp for backup files. This was necessary because the diff didn't
|
||||
work as intended.
|
||||
|
||||
## 1.14 (2024)
|
||||
|
||||
Add _list_, _delete_ and _rename_ actions.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-REPLACE" "1" "2024-08-29"
|
||||
.TH "ODDMU-REPLACE" "1" "2025-03-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -42,7 +42,7 @@ Replace "Oddmu" in the Markdown files of the current directory:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu replace Oddmu Oddµ
|
||||
oddmu replace Oddmu Oddμ
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
|
||||
@@ -30,7 +30,7 @@ the current directory and its subdirectories.
|
||||
Replace "Oddmu" in the Markdown files of the current directory:
|
||||
|
||||
```
|
||||
oddmu replace Oddmu Oddµ
|
||||
oddmu replace Oddmu Oddμ
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "1" "2024-08-29"
|
||||
.TH "ODDMU-SEARCH" "1" "2025-03-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -65,7 +65,7 @@ The ordering of terms does not matter.\&
|
||||
~/src/oddmu $ oddmu search Alex Schroeder
|
||||
Search for Alex Schroeder, page 1: 3 results
|
||||
* [Alex Schroeder theme](themes/alexschroeder\&.ch/README)
|
||||
* [Oddµ: A minimal wiki](README)
|
||||
* [Oddμ: A minimal wiki](README)
|
||||
* [Themes](themes/index)
|
||||
.fi
|
||||
.RE
|
||||
|
||||
@@ -49,7 +49,7 @@ The ordering of terms does not matter.
|
||||
~/src/oddmu $ oddmu search Alex Schroeder
|
||||
Search for Alex Schroeder, page 1: 3 results
|
||||
* [Alex Schroeder theme](themes/alexschroeder.ch/README)
|
||||
* [Oddµ: A minimal wiki](README)
|
||||
* [Oddμ: A minimal wiki](README)
|
||||
* [Themes](themes/index)
|
||||
```
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "7" "2024-02-19"
|
||||
.TH "ODDMU-SEARCH" "7" "2025-03-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -57,9 +57,9 @@ exactly (without the leading '\&#'\&) is listed first, even if it doesn'\&t cont
|
||||
the hashtag.\& It is assumed that this page offers some kind of introduction to
|
||||
people searching for the hashtag.\&
|
||||
.PP
|
||||
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
|
||||
(in other words, the file "Oddµ.\&md" exists), it is prepended to the results even
|
||||
if it doesn'\&t have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
|
||||
Example: When people click on the hashtag "#Oddμ" and a page named "Oddμ" exists
|
||||
(in other words, the file "Oddμ.\&md" exists), it is prepended to the results even
|
||||
if it doesn'\&t have the hashtag "#Oddμ" and even if it has a title of "Oddμ, a
|
||||
minimal wiki" (which wouldn'\&t be an exact match).\&
|
||||
.PP
|
||||
The score and highlighting of snippets is used to help visitors decide which
|
||||
|
||||
@@ -44,9 +44,9 @@ exactly (without the leading '#') is listed first, even if it doesn't contain
|
||||
the hashtag. It is assumed that this page offers some kind of introduction to
|
||||
people searching for the hashtag.
|
||||
|
||||
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
|
||||
(in other words, the file "Oddµ.md" exists), it is prepended to the results even
|
||||
if it doesn't have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
|
||||
Example: When people click on the hashtag "#Oddμ" and a page named "Oddμ" exists
|
||||
(in other words, the file "Oddμ.md" exists), it is prepended to the results even
|
||||
if it doesn't have the hashtag "#Oddμ" and even if it has a title of "Oddμ, a
|
||||
minimal wiki" (which wouldn't be an exact match).
|
||||
|
||||
The score and highlighting of snippets is used to help visitors decide which
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-TOC" "1" "2024-08-15"
|
||||
.TH "ODDMU-TOC" "1" "2025-04-05"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -13,13 +13,12 @@ oddmu-toc - print the table of contents (toc) for pages
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
|
||||
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "toc" subcommand prints the table of contents for one or more page
|
||||
names.\& Use "-" as the page name if you want to read Markdown from
|
||||
\fBstdin\fR.\&
|
||||
The "toc" subcommand prints the table of contents for one or more Markdown
|
||||
files.\& Use "-" as the page name if you want to read Markdown from \fBstdin\fR.\&
|
||||
.PP
|
||||
This can be useful for very long pages that need a table of contents
|
||||
at the beginning.\&
|
||||
|
||||
@@ -6,13 +6,12 @@ oddmu-toc - print the table of contents (toc) for pages
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu toc* _page names..._
|
||||
*oddmu toc* _page names..._
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "toc" subcommand prints the table of contents for one or more page
|
||||
names. Use "-" as the page name if you want to read Markdown from
|
||||
*stdin*.
|
||||
The "toc" subcommand prints the table of contents for one or more Markdown
|
||||
files. Use "-" as the page name if you want to read Markdown from *stdin*.
|
||||
|
||||
This can be useful for very long pages that need a table of contents
|
||||
at the beginning.
|
||||
|
||||
11
man/oddmu.1
11
man/oddmu.1
@@ -5,13 +5,13 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "1" "2024-09-25"
|
||||
.TH "ODDMU" "1" "2025-03-14"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu - a wiki server
|
||||
.PP
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.\&
|
||||
Oddmu is sometimes written Oddμ because μ is the letter mu.\&
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
@@ -288,9 +288,10 @@ it, it can'\&t be a page name.\& Filenames can contain slashes and Oddmu creates
|
||||
subdirectories as necessary.\&
|
||||
.PP
|
||||
Files may not end with a tilde ('\&~'\&) – these are backup files.\& When saving pages
|
||||
and file uploads, the old file renamed to the backup file unless the backup file
|
||||
is less than an hour old, thus collapsing all edits made in an hour into a
|
||||
single diff when comparing backup and current version.\&
|
||||
and file uploads, the old file is renamed to the backup file unless the backup
|
||||
file is less than an hour old, thus collapsing all edits made in an hour into a
|
||||
single diff when comparing backup and current version.\& The backup also gets an
|
||||
updated timestamp so that subsequent edits don'\&t immediately overwrite it.\&
|
||||
.PP
|
||||
The \fBindex\fR page is the default page.\& People visiting the "root" of the site are
|
||||
redirected to "/view/index".\&
|
||||
|
||||
@@ -4,7 +4,7 @@ ODDMU(1)
|
||||
|
||||
oddmu - a wiki server
|
||||
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.
|
||||
Oddmu is sometimes written Oddμ because μ is the letter mu.
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
@@ -231,9 +231,10 @@ it, it can't be a page name. Filenames can contain slashes and Oddmu creates
|
||||
subdirectories as necessary.
|
||||
|
||||
Files may not end with a tilde ('~') – these are backup files. When saving pages
|
||||
and file uploads, the old file renamed to the backup file unless the backup file
|
||||
is less than an hour old, thus collapsing all edits made in an hour into a
|
||||
single diff when comparing backup and current version.
|
||||
and file uploads, the old file is renamed to the backup file unless the backup
|
||||
file is less than an hour old, thus collapsing all edits made in an hour into a
|
||||
single diff when comparing backup and current version. The backup also gets an
|
||||
updated timestamp so that subsequent edits don't immediately overwrite it.
|
||||
|
||||
The *index* page is the default page. People visiting the "root" of the site are
|
||||
redirected to "/view/index".
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "5" "2024-09-30" "File Formats Manual"
|
||||
.TH "ODDMU" "5" "2025-03-05" "File Formats Manual"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -28,7 +28,7 @@ The page name has to be percent-encoded.\& See the section "Percent Encoding".\&
|
||||
If you link to the actual Markdown file (with the ".\&md" extension), then Oddmu
|
||||
serves the Markdown file!\&
|
||||
.PP
|
||||
There are three Oddµ-specific extensions: local links, hashtags and fediverse
|
||||
There are three Oddμ-specific extensions: local links, hashtags and fediverse
|
||||
account links.\& The Markdown library used features some additional extensions,
|
||||
most importantly tables and definition lists.\&
|
||||
.PP
|
||||
@@ -94,7 +94,7 @@ linked to a profile page.\& In this case, "@alex" would be linked to
|
||||
"https://alexschroeder.\&ch/users/alex".\&
|
||||
.PP
|
||||
In many cases, this works as is.\& In reality, however, the link to the profile
|
||||
page needs to be retrieved via webfinger.\& Oddµ does that in the background, and
|
||||
page needs to be retrieved via webfinger.\& Oddμ does that in the background, and
|
||||
as soon as the information is available, the actual profile link is used when
|
||||
pages are rendered.\& In the example above, the result would be
|
||||
"https://social.\&alexschroeder.\&ch/@alex".\&
|
||||
|
||||
@@ -19,7 +19,7 @@ The page name has to be percent-encoded. See the section "Percent Encoding".
|
||||
If you link to the actual Markdown file (with the ".md" extension), then Oddmu
|
||||
serves the Markdown file!
|
||||
|
||||
There are three Oddµ-specific extensions: local links, hashtags and fediverse
|
||||
There are three Oddμ-specific extensions: local links, hashtags and fediverse
|
||||
account links. The Markdown library used features some additional extensions,
|
||||
most importantly tables and definition lists.
|
||||
|
||||
@@ -79,7 +79,7 @@ linked to a profile page. In this case, "@alex" would be linked to
|
||||
"https://alexschroeder.ch/users/alex".
|
||||
|
||||
In many cases, this works as is. In reality, however, the link to the profile
|
||||
page needs to be retrieved via webfinger. Oddµ does that in the background, and
|
||||
page needs to be retrieved via webfinger. Oddμ does that in the background, and
|
||||
as soon as the information is available, the actual profile link is used when
|
||||
pages are rendered. In the example above, the result would be
|
||||
"https://social.alexschroeder.ch/@alex".
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU.SERVICE" "5" "2024-08-23"
|
||||
.TH "ODDMU.SERVICE" "5" "2025-03-14"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
@@ -94,8 +94,8 @@ sudo mkdir /run/oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The unit file for the service defines where the "oddmu" is and where the data
|
||||
directory is.\& These are the lines you most likely have to take care of:
|
||||
The unit file for the service defines where the Oddmu binary is and where the
|
||||
data directory is.\& These are the lines you most likely have to take care of:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
@@ -152,6 +152,9 @@ Environment="ODDMU_LANGUAGES=de,en"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Make sure to change the "ExecStart" entry so that it points to your copy of the
|
||||
Oddmu binary.\&
|
||||
.PP
|
||||
Since this is a user service, the same user can edit the files using their
|
||||
favourite text editor.\&
|
||||
.PP
|
||||
|
||||
@@ -75,8 +75,8 @@ the directory or change the file name.
|
||||
sudo mkdir /run/oddmu
|
||||
```
|
||||
|
||||
The unit file for the service defines where the "oddmu" is and where the data
|
||||
directory is. These are the lines you most likely have to take care of:
|
||||
The unit file for the service defines where the Oddmu binary is and where the
|
||||
data directory is. These are the lines you most likely have to take care of:
|
||||
|
||||
```
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
@@ -125,6 +125,9 @@ WorkingDirectory=/home/alex/wiki
|
||||
Environment="ODDMU_LANGUAGES=de,en"
|
||||
```
|
||||
|
||||
Make sure to change the "ExecStart" entry so that it points to your copy of the
|
||||
Oddmu binary.
|
||||
|
||||
Since this is a user service, the same user can edit the files using their
|
||||
favourite text editor.
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ while (<>) {
|
||||
# italic
|
||||
s/\b_([^_]+)_\b/*$1*/g;
|
||||
# move all H1 headers to H2
|
||||
s/^# /## /;
|
||||
s/^# (.*)/"## ".ucfirst(lc($1))/e;
|
||||
# the new H1 title
|
||||
s/^([A-Z.-]*\([1-9]\))( ".*")?$/# $1/;
|
||||
s/^([A-Z.-]*\([1-9]\))( ".*")?$/"# ".lc($1)/e;
|
||||
# quoted URLs
|
||||
s/"(http.*?)"/`$1`/g;
|
||||
# quoted wiki links
|
||||
|
||||
34
man_test.go
34
man_test.go
@@ -20,14 +20,14 @@ func TestManPages(t *testing.T) {
|
||||
main := string(b)
|
||||
assert.NoError(t, err)
|
||||
count := 0
|
||||
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
|
||||
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".txt") &&
|
||||
path != "man/oddmu.1.txt" {
|
||||
if strings.HasSuffix(fp, ".txt") &&
|
||||
fp != "man/oddmu.1.txt" {
|
||||
count++
|
||||
s := strings.TrimPrefix(path, "man/")
|
||||
s := strings.TrimPrefix(fp, "man/")
|
||||
s = strings.TrimSuffix(s, ".txt")
|
||||
i := strings.LastIndex(s, ".")
|
||||
ref := "_" + s[:i] + "_(" + s[i+1:] + ")"
|
||||
@@ -44,15 +44,15 @@ func TestManTemplates(t *testing.T) {
|
||||
man := string(b)
|
||||
assert.NoError(t, err)
|
||||
count := 0
|
||||
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".html") {
|
||||
if strings.HasSuffix(fp, ".html") {
|
||||
count++
|
||||
assert.Contains(t, man, path, path)
|
||||
assert.Contains(t, man, fp, fp)
|
||||
}
|
||||
if path != "." && info.IsDir() {
|
||||
if fp != "." && info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
@@ -71,7 +71,7 @@ func TestManActions(t *testing.T) {
|
||||
wiki := string(b)
|
||||
count := 0
|
||||
// this doesn't match the root handler
|
||||
re := regexp.MustCompile(`http.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
|
||||
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
|
||||
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
|
||||
count++
|
||||
var path string
|
||||
@@ -94,13 +94,13 @@ func TestReadme(t *testing.T) {
|
||||
readme := string(b)
|
||||
assert.NoError(t, err)
|
||||
count := 0
|
||||
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
|
||||
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".txt") {
|
||||
if strings.HasSuffix(fp, ".txt") {
|
||||
count++
|
||||
s := strings.TrimPrefix(path, "man/")
|
||||
s := strings.TrimPrefix(fp, "man/")
|
||||
s = strings.TrimSuffix(s, ".txt")
|
||||
i := strings.LastIndex(s, ".")
|
||||
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
|
||||
@@ -110,15 +110,15 @@ func TestReadme(t *testing.T) {
|
||||
})
|
||||
assert.Greater(t, count, 0, "no man pages were found")
|
||||
count = 0
|
||||
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".go") &&
|
||||
!strings.HasSuffix(path, "_test.go") &&
|
||||
!strings.HasSuffix(path, "_cmd.go") {
|
||||
if strings.HasSuffix(fp, ".go") &&
|
||||
!strings.HasSuffix(fp, "_test.go") &&
|
||||
!strings.HasSuffix(fp, "_cmd.go") {
|
||||
count++
|
||||
s := strings.TrimPrefix(path, "./")
|
||||
s := strings.TrimPrefix(fp, "./")
|
||||
ref := "`" + s + "`"
|
||||
assert.Contains(t, readme, ref, ref)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type notifyCmd struct {
|
||||
@@ -32,6 +33,11 @@ func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface
|
||||
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
|
||||
index.load()
|
||||
for _, name := range args {
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
name = name[0:len(name)-3]
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
|
||||
|
||||
60
page.go
60
page.go
@@ -64,7 +64,7 @@ func nameEscape(s string) string {
|
||||
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
|
||||
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
|
||||
func (p *Page) save() error {
|
||||
fp := filepath.FromSlash(p.Name + ".md")
|
||||
fp := filepath.FromSlash(p.Name) + ".md"
|
||||
watches.ignore(fp)
|
||||
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
|
||||
if len(s) == 0 {
|
||||
@@ -88,9 +88,10 @@ func (p *Page) save() error {
|
||||
return os.WriteFile(fp, s, 0644)
|
||||
}
|
||||
|
||||
// backup a file by renaming (!) it unless the existing backup is less than an hour old. A backup gets a tilde appended
|
||||
// to it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
|
||||
// what to do with a file called "image.png~". This expects a file path. Use filepath.FromSlash(path) if necessary.
|
||||
// backup a file by renaming it unless the existing backup is less than an hour old. A backup gets a tilde appended to
|
||||
// it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
|
||||
// what to do with a file called "image.png~". This expects a filepath. The backup file gets its modification time set
|
||||
// to now so that subsequent edits don't immediately overwrite it again.
|
||||
func backup(fp string) error {
|
||||
_, err := os.Stat(fp)
|
||||
if err != nil {
|
||||
@@ -99,7 +100,12 @@ func backup(fp string) error {
|
||||
bp := fp + "~"
|
||||
fi, err := os.Stat(bp)
|
||||
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
|
||||
return os.Rename(fp, bp)
|
||||
err = os.Rename(fp, bp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := time.Now()
|
||||
return os.Chtimes(bp, ts, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -107,13 +113,13 @@ func backup(fp string) error {
|
||||
// loadPage loads a Page given a name. The path loaded is that Page.Name with the ".md" extension. The Page.Title is set
|
||||
// to the Page.Name (and possibly changed, later). The Page.Body is set to the file content. The Page.Html remains
|
||||
// undefined (there is no caching).
|
||||
func loadPage(path string) (*Page, error) {
|
||||
path = strings.TrimPrefix(path, "./") // result of a filepath.TreeWalk starting with "."
|
||||
body, err := os.ReadFile(filepath.FromSlash(path + ".md"))
|
||||
func loadPage(name string) (*Page, error) {
|
||||
name = strings.TrimPrefix(name, "./") // result of a path.TreeWalk starting with "."
|
||||
body, err := os.ReadFile(filepath.FromSlash(name) + ".md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Page{Title: path, Name: path, Body: body}, nil
|
||||
return &Page{Title: name, Name: name, Body: body}, nil
|
||||
}
|
||||
|
||||
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
|
||||
@@ -133,7 +139,6 @@ func (p *Page) handleTitle(replace bool) {
|
||||
// summarize sets Page.Html to an extract.
|
||||
func (p *Page) summarize(q string) {
|
||||
t := p.plainText()
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = sanitizeStrict(snippets(q, t))
|
||||
}
|
||||
|
||||
@@ -143,10 +148,37 @@ func (p *Page) IsBlog() bool {
|
||||
return blogRe.MatchString(name)
|
||||
}
|
||||
|
||||
// Dir returns the directory the page is in. It's either the empty string if the page is in the Oddmu working directory,
|
||||
// or it ends in a slash. This is used to create the upload link in "view.html", for example.
|
||||
const upperhex = "0123456789ABCDEF"
|
||||
|
||||
// Path returns the page name with semicolon, comma and questionmark escaped because html/template doesn't escape those.
|
||||
// This is suitable for use in HTML templates.
|
||||
func (p *Page) Path() string {
|
||||
s := p.Name
|
||||
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?")
|
||||
if n == 0 {
|
||||
return p.Name
|
||||
}
|
||||
t := make([]byte, len(s) + 2*n)
|
||||
j := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case ';', ',', '?':
|
||||
t[j] = '%'
|
||||
t[j+1] = upperhex[s[i]>>4]
|
||||
t[j+2] = upperhex[s[i]&15]
|
||||
j += 3
|
||||
default:
|
||||
t[j] = s[i]
|
||||
j++
|
||||
}
|
||||
}
|
||||
return string(t);
|
||||
}
|
||||
|
||||
// Dir returns the directory part of the page name. It's either the empty string if the page is in the Oddmu working
|
||||
// directory, or it ends in a slash. This is used to create the upload link in "view.html", for example.
|
||||
func (p *Page) Dir() string {
|
||||
d := filepath.Dir(p.Name)
|
||||
d := path.Dir(p.Name)
|
||||
if d == "." {
|
||||
return ""
|
||||
}
|
||||
@@ -156,7 +188,7 @@ func (p *Page) Dir() string {
|
||||
// Base returns the basename of the page name: no directory. This is used to create the upload link in "view.html", for
|
||||
// example.
|
||||
func (p *Page) Base() string {
|
||||
n := filepath.Base(p.Name)
|
||||
n := path.Base(p.Name)
|
||||
if n == "." {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ And untouchable`)}
|
||||
p = &Page{Name: "testdata/parents/children/something/other"}
|
||||
// "testdata/parents/children/something/index" is a sibling and doesn't count!
|
||||
parents := p.Parents()
|
||||
assert.Equal(t, "Welcome to Oddµ", parents[0].Title)
|
||||
assert.Equal(t, "Welcome to Oddμ", parents[0].Title)
|
||||
assert.Equal(t, "../../../../index", parents[0].Url)
|
||||
assert.Equal(t, "…", parents[1].Title)
|
||||
assert.Equal(t, "../../../index", parents[1].Url)
|
||||
|
||||
15
parser.go
15
parser.go
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// wikiLink returns an inline parser function. This indirection is
|
||||
@@ -37,9 +36,13 @@ func wikiLink(fn func(p *parser.Parser, data []byte, offset int) (int, ast.Node)
|
||||
|
||||
// hashtag returns an inline parser function. This indirection is
|
||||
// required because we want to receive an array of hashtags found.
|
||||
// The hashtags in the array keep their case.
|
||||
func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node), *[]string) {
|
||||
hashtags := make([]string, 0)
|
||||
return func(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
if p.InsideLink {
|
||||
return 0, nil
|
||||
}
|
||||
data = data[offset:]
|
||||
i := 0
|
||||
n := len(data)
|
||||
@@ -91,7 +94,6 @@ func (p *Page) renderHtml() {
|
||||
parser, hashtags := wikiParser()
|
||||
renderer := wikiRenderer()
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = unsafeBytes(maybeUnsafeHTML)
|
||||
p.Hashtags = *hashtags
|
||||
}
|
||||
@@ -125,7 +127,7 @@ func (p *Page) plainText() string {
|
||||
|
||||
// images returns an array of ImageData.
|
||||
func (p *Page) images() []ImageData {
|
||||
dir := path.Dir(filepath.ToSlash(p.Name))
|
||||
dir := p.Dir()
|
||||
images := make([]ImageData, 0)
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
@@ -148,6 +150,13 @@ func (p *Page) images() []ImageData {
|
||||
return images
|
||||
}
|
||||
|
||||
// hashtags returns an array of hashtags
|
||||
func hashtags(s []byte) []string {
|
||||
parser, hashtags := wikiParser()
|
||||
markdown.Parse(s, parser)
|
||||
return *hashtags
|
||||
}
|
||||
|
||||
// toString for a node returns the text nodes' literals, concatenated. There is no whitespace added so the expectation
|
||||
// is that there is only one child node. Otherwise, there may be a space missing between the literals, depending on the
|
||||
// exact child nodes they belong to.
|
||||
|
||||
@@ -51,11 +51,13 @@ I am cold, alone</p>
|
||||
func TestPageHtmlHashtagCornerCases(t *testing.T) {
|
||||
p := &Page{Body: []byte(`#
|
||||
|
||||
ok # #o #ok`)}
|
||||
ok # #o #ok
|
||||
[oh #ok \#nok](ok)`)}
|
||||
p.renderHtml()
|
||||
r := `<p>#</p>
|
||||
|
||||
<p>ok # <a class="tag" href="/search/?q=%23o">#o</a> <a class="tag" href="/search/?q=%23ok">#ok</a></p>
|
||||
<p>ok # <a class="tag" href="/search/?q=%23o">#o</a> <a class="tag" href="/search/?q=%23ok">#ok</a>
|
||||
<a href="ok">oh #ok #nok</a></p>
|
||||
`
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ img { max-width: 100%; }
|
||||
<hr>
|
||||
<section id="edit">
|
||||
<h2>Editing {{.Title}}</h2>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" lang="{{.Language}}" autofocus>{{printf "# %s\n\n%s" .Title .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</section>
|
||||
</body>
|
||||
|
||||
17
preview_test.go
Normal file
17
preview_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreview(t *testing.T) {
|
||||
cleanup(t, "testdata/preview")
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("body", "**Hallo**!")
|
||||
|
||||
r := assert.HTTPBody(makeHandler(previewHandler, false), "POST", "/view/testdata/preview/alex", data)
|
||||
assert.Contains(t, r, "<strong>Hallo</strong>!")
|
||||
}
|
||||
@@ -55,12 +55,12 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
|
||||
}
|
||||
repl := []byte(args[1])
|
||||
changes := 0
|
||||
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
err := filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
@@ -68,10 +68,10 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
|
||||
}
|
||||
}
|
||||
// skipp all but page files
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
if !strings.HasSuffix(fp, ".md") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
body, err := os.ReadFile(fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,15 +79,15 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
|
||||
if !slices.Equal(result, body) {
|
||||
changes++
|
||||
if isConfirmed {
|
||||
fmt.Fprintln(w, path)
|
||||
_ = os.Rename(path, path+"~")
|
||||
err = os.WriteFile(path, result, 0644)
|
||||
fmt.Fprintln(w, fp)
|
||||
_ = os.Rename(fp, fp + "~")
|
||||
err = os.WriteFile(fp, result, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
edits := myers.ComputeEdits(span.URIFromPath(path+"~"), string(body), string(result))
|
||||
diff := fmt.Sprint(gotextdiff.ToUnified(path+"~", path, string(body), edits))
|
||||
edits := myers.ComputeEdits(span.URIFromPath(fp + "~"), string(body), string(result))
|
||||
diff := fmt.Sprint(gotextdiff.ToUnified(fp + "~", fp, string(body), edits))
|
||||
fmt.Fprintln(w, diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
|
||||
<span class="score">{{.Score}}</span></p>
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
{{range .Images}}
|
||||
<p class="image"><a href="/view/{{.Name}}"><img loading="lazy" src="/view/{{.Name}}"></a><br/>{{.Html}}
|
||||
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -18,6 +19,8 @@ type searchCmd struct {
|
||||
page int
|
||||
all bool
|
||||
extract bool
|
||||
files bool
|
||||
quiet bool
|
||||
}
|
||||
|
||||
func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
|
||||
@@ -25,12 +28,14 @@ func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.IntVar(&cmd.page, "page", 1, "the page in the search result set, default 1")
|
||||
f.BoolVar(&cmd.all, "all", false, "show all the pages and ignore -page")
|
||||
f.BoolVar(&cmd.extract, "extract", false, "print page extract instead of link list")
|
||||
f.BoolVar(&cmd.files, "files", false, "show just the filenames")
|
||||
f.BoolVar(&cmd.quiet, "quiet", false, "suppress summary line at the top")
|
||||
}
|
||||
|
||||
func (*searchCmd) Name() string { return "search" }
|
||||
func (*searchCmd) Synopsis() string { return "search pages and print a list of links" }
|
||||
func (*searchCmd) Usage() string {
|
||||
return `search [-dir string] [-page <n>|-all] [-extract] <terms>:
|
||||
return `search [-dir string] [-page <n>|-all] [-extract|-files] [-quiet] <terms>:
|
||||
Search for pages matching terms and print the result set as a
|
||||
Markdown list. Before searching, all the pages are indexed. Thus,
|
||||
startup is slow. The benefit is that the page order is exactly as
|
||||
@@ -39,24 +44,24 @@ func (*searchCmd) Usage() string {
|
||||
}
|
||||
|
||||
func (cmd *searchCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return searchCli(os.Stdout, cmd.dir, cmd.page, cmd.all, cmd.extract, false, f.Args())
|
||||
return searchCli(os.Stdout, cmd, f.Args())
|
||||
}
|
||||
|
||||
// searchCli runs the search command on the command line. It is used
|
||||
// here with an io.Writer for easy testing.
|
||||
func searchCli(w io.Writer, dir string, n int, all, extract bool, quiet bool, args []string) subcommands.ExitStatus {
|
||||
dir, err := checkDir(dir)
|
||||
func searchCli(w io.Writer, cmd *searchCmd, args []string) subcommands.ExitStatus {
|
||||
dir, err := checkDir(cmd.dir)
|
||||
if err != nil {
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
index.reset()
|
||||
index.load()
|
||||
q := strings.Join(args, " ")
|
||||
items, more := search(q, dir, "", n, true)
|
||||
if !quiet {
|
||||
items, more := search(q, dir, "", cmd.page, true)
|
||||
if !cmd.quiet {
|
||||
fmt.Fprint(os.Stderr, "Search for ", q)
|
||||
if !all {
|
||||
fmt.Fprint(os.Stderr, ", page ", n)
|
||||
if !cmd.all {
|
||||
fmt.Fprint(os.Stderr, ", page ", cmd.page)
|
||||
}
|
||||
fmt.Fprint(os.Stderr, ": ", len(items))
|
||||
if len(items) == 1 {
|
||||
@@ -65,8 +70,13 @@ func searchCli(w io.Writer, dir string, n int, all, extract bool, quiet bool, ar
|
||||
fmt.Fprint(os.Stderr, " results\n")
|
||||
}
|
||||
}
|
||||
if extract {
|
||||
if cmd.extract {
|
||||
searchExtract(w, items)
|
||||
} else if cmd.files {
|
||||
for _, p := range items {
|
||||
name := filepath.FromSlash(p.Name) + ".md\n"
|
||||
fmt.Fprintf(w, name)
|
||||
}
|
||||
} else {
|
||||
for _, p := range items {
|
||||
name := p.Name
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
func TestSearchCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := searchCli(b, "", 1, false, false, true, []string{"oddµ"})
|
||||
s := searchCli(b, &searchCmd{quiet: true}, []string{"oddμ"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `* [Oddµ: A minimal wiki](README)
|
||||
r := `* [Oddμ: A minimal wiki](README)
|
||||
* [Themes](themes/index)
|
||||
* [Welcome to Oddµ](index)
|
||||
* [Welcome to Oddμ](index)
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
@@ -26,7 +26,7 @@ that before we type and speak
|
||||
we hear that moment`)}
|
||||
p.save()
|
||||
b := new(bytes.Buffer)
|
||||
s := searchCli(b, "testdata/search", 1, false, false, true, []string{"speak"})
|
||||
s := searchCli(b, &searchCmd{dir: "testdata/search", quiet: true}, []string{"speak"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `* [Wait](wait)
|
||||
`
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestSearch(t *testing.T) {
|
||||
index.load()
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("q", "oddµ")
|
||||
data.Set("q", "oddμ")
|
||||
|
||||
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
|
||||
assert.Contains(t, body, "Welcome")
|
||||
@@ -182,7 +182,7 @@ func TestTitleSearch(t *testing.T) {
|
||||
|
||||
items, more = search("title:wel", "", "", 1, false) // README also contains "wel"
|
||||
assert.Equal(t, 1, len(items), "one page found")
|
||||
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
|
||||
assert.Equal(t, "index", items[0].Name, "Welcome to Oddμ")
|
||||
assert.Greater(t, items[0].Score, 0, "matches result in a score")
|
||||
assert.False(t, more)
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStat
|
||||
func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
|
||||
// The error returned here is what's in the stop channel but at the very end, a worker might return an error
|
||||
// even though the walk is already done. This is why we cannot rely on the return value of the walk.
|
||||
filepath.Walk(source, func(path string, info fs.FileInfo, err error) error {
|
||||
filepath.Walk(source, func(fp string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,9 +92,8 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
|
||||
case err := <-stop:
|
||||
return err
|
||||
default:
|
||||
base := filepath.Base(path)
|
||||
// skip hidden directories and files
|
||||
if path != "." && strings.HasPrefix(base, ".") {
|
||||
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
} else {
|
||||
@@ -102,28 +101,28 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
|
||||
}
|
||||
}
|
||||
// skip backup files, avoid recursion
|
||||
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, target) {
|
||||
if strings.HasSuffix(fp, "~") || strings.HasPrefix(fp, target) {
|
||||
return nil
|
||||
}
|
||||
// determine the actual target: if source is a/ and target is b/ and path is a/file, then the
|
||||
// target is b/file
|
||||
var actual_target string
|
||||
var actualTarget string
|
||||
if source == "." {
|
||||
actual_target = filepath.Join(target, path)
|
||||
actualTarget = filepath.Join(target, fp)
|
||||
} else {
|
||||
if !strings.HasPrefix(path, source) {
|
||||
return fmt.Errorf("%s is not a subdirectory of %s", path, source)
|
||||
if !strings.HasPrefix(fp, source) {
|
||||
return fmt.Errorf("%s is not a subdirectory of %s", fp, source)
|
||||
}
|
||||
actual_target = filepath.Join(target, path[len(source):])
|
||||
actualTarget = filepath.Join(target, fp[len(source):])
|
||||
}
|
||||
// recreate subdirectories
|
||||
if info.IsDir() {
|
||||
return os.Mkdir(actual_target, 0755)
|
||||
return os.Mkdir(actualTarget, 0755)
|
||||
}
|
||||
// do the task if the target file doesn't exist or if the source file is newer
|
||||
other, err := os.Stat(actual_target)
|
||||
other, err := os.Stat(actualTarget)
|
||||
if err != nil || info.ModTime().After(other.ModTime()) {
|
||||
tasks <- args{source: path, target: actual_target, info: info}
|
||||
tasks <- args{source: fp, target: actualTarget, info: info}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -211,7 +210,6 @@ func staticPage(source, target string) (*Page, error) {
|
||||
}
|
||||
renderer := html.NewRenderer(opts)
|
||||
maybeUnsafeHTML := markdown.Render(doc, renderer)
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = unsafeBytes(maybeUnsafeHTML)
|
||||
p.Hashtags = *hashtags
|
||||
return p, write(p, target, "", "static.html")
|
||||
@@ -221,7 +219,7 @@ func staticPage(source, target string) (*Page, error) {
|
||||
func staticFeed(source, target string, p *Page, ti time.Time) error {
|
||||
// render feed, maybe
|
||||
base := filepath.Base(source)
|
||||
_, ok := index.token["#"+strings.ToLower(base)]
|
||||
_, ok := index.token[strings.ToLower(base)]
|
||||
if base == "index" || ok {
|
||||
f := feed(p, ti)
|
||||
if len(f.Items) > 0 {
|
||||
@@ -257,22 +255,22 @@ func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
|
||||
}
|
||||
|
||||
// write a page or feed with an appropriate template to a specific destination, overwriting it.
|
||||
func write(data any, path, prefix, templateFile string) error {
|
||||
file, err := os.Create(path)
|
||||
func write(data any, fp, prefix, templateFile string) error {
|
||||
file, err := os.Create(fp)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", path, err)
|
||||
fmt.Fprintf(os.Stderr, "Cannot create %s: %s\n", fp, err)
|
||||
return err
|
||||
}
|
||||
_, err = file.Write([]byte(prefix))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", path, err)
|
||||
fmt.Fprintf(os.Stderr, "Cannot write prefix %s: %s\n", fp, err)
|
||||
return err
|
||||
}
|
||||
templates.RLock()
|
||||
defer templates.RUnlock()
|
||||
err = templates.template[templateFile].Execute(file, data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, path, err)
|
||||
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", templateFile, fp, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
15
templates.go
15
templates.go
@@ -5,7 +5,6 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -23,10 +22,9 @@ var templateFiles = []string{"edit.html", "add.html", "view.html", "preview.html
|
||||
type templateStore struct {
|
||||
sync.RWMutex
|
||||
|
||||
// template is a map of parsed HTML templates. The key is their path name. By default, the map only contains
|
||||
// template is a map of parsed HTML templates. The key is their filepath name. By default, the map only contains
|
||||
// top-level templates like "view.html". Subdirectories may contain their own templates which override the
|
||||
// templates in the root directory. If so, they are paths like "dir/view.html", not filepaths. Use
|
||||
// filepath.ToSlash() if necessary.
|
||||
// templates in the root directory. If so, they are filepaths like "dir/view.html".
|
||||
template map[string]*template.Template
|
||||
}
|
||||
|
||||
@@ -58,8 +56,7 @@ func loadTemplate(fp string, info fs.FileInfo, err error) error {
|
||||
log.Println("Cannot parse template:", fp, err)
|
||||
// ignore error
|
||||
} else {
|
||||
// log.Println("Parse template:", path)
|
||||
templates.template[filepath.ToSlash(fp)] = t
|
||||
templates.template[fp] = t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -75,7 +72,7 @@ func updateTemplate(fp string) {
|
||||
} else {
|
||||
templates.Lock()
|
||||
defer templates.Unlock()
|
||||
templates.template[filepath.ToSlash(fp)] = t
|
||||
templates.template[fp] = t
|
||||
log.Println("Parse template:", fp)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +84,7 @@ func removeTemplate(fp string) {
|
||||
filepath.Dir(fp) != "." {
|
||||
templates.Lock()
|
||||
defer templates.Unlock()
|
||||
delete(templates.template, filepath.ToSlash(fp))
|
||||
delete(templates.template, fp)
|
||||
log.Println("Discard template:", fp)
|
||||
}
|
||||
}
|
||||
@@ -99,7 +96,7 @@ func renderTemplate(w http.ResponseWriter, dir, tmpl string, data any) {
|
||||
base := tmpl + ".html"
|
||||
templates.RLock()
|
||||
defer templates.RUnlock()
|
||||
t := templates.template[path.Join(dir, base)]
|
||||
t := templates.template[filepath.Join(dir, base)]
|
||||
if t == nil {
|
||||
t = templates.template[base]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ download:
|
||||
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
|
||||
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
|
||||
|
||||
# (ediff-directories "alexschroeder.ch" "/ssh:sibirocobombus:alexschroeder.ch/wiki/" "html$")
|
||||
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root|sudo:claudia@sibirocobombus.root:/home/alex/flying-carpet.ch/wiki/" "html$")
|
||||
# (ediff-directories "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.org/data/" "html$")
|
||||
# (ediff-directories "transjovian.org" "/ssh:sibirocobombus.root:/home/oddmu/" "html$")
|
||||
|
||||
upload:
|
||||
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
|
||||
|
||||
|
||||
@@ -6,14 +6,37 @@
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Add to {{.Title}}</title>
|
||||
<link href="oddmu.css" rel="stylesheet" />
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
let t = document.getElementsByTagName('textarea').item(0);
|
||||
t.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
let ch;
|
||||
if (event.key == "i") {
|
||||
ch = ["*", "*"];
|
||||
} else if (event.key == "b") {
|
||||
ch = ["**", "**"];
|
||||
} else if (event.key == "k") {
|
||||
ch = ["[", "]()"];
|
||||
}
|
||||
if (ch) {
|
||||
event.preventDefault();
|
||||
let s = t.value.substring(t.selectionStart, t.selectionEnd);
|
||||
t.setRangeText(ch[0] + s + ch[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Adding to {{.Title}}</h1>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<form action="/append/{{.Path}}" method="POST">
|
||||
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required>{{if .IsBlog}}**{{.Today}}**. {{end}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Add">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/view/{{.Name}}">Back</a>
|
||||
<a href="/view/{{.Path}}">Back</a>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
|
||||
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
|
||||
<pre class="diff">
|
||||
{{.Diff}}
|
||||
</pre>
|
||||
|
||||
@@ -4,18 +4,43 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<base href="/view/{{.Dir}}">
|
||||
<title>Editing {{.Title}}</title>
|
||||
<link href="oddmu.css" rel="stylesheet" />
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
let t = document.getElementsByTagName('textarea').item(0);
|
||||
t.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
let ch;
|
||||
if (event.key == "i") {
|
||||
ch = ["*", "*"];
|
||||
} else if (event.key == "b") {
|
||||
ch = ["**", "**"];
|
||||
} else if (event.key == "k") {
|
||||
ch = ["[", "]()"];
|
||||
}
|
||||
if (ch) {
|
||||
event.preventDefault();
|
||||
let s = t.value.substring(t.selectionStart, t.selectionEnd);
|
||||
t.setRangeText(ch[0] + s + ch[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Editing {{.Title}}</h1>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<p>Use <tt>Control+I</tt> for italics, <tt>Control+B</tt> for bold, <tt>Control+k</tt> for link.</p>
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" lang="{{.Language}}" autofocus>{{ or .Body (printf "# %s " .Today) | printf "%s" }}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
<link>https://alexschroeder.ch/</link>
|
||||
<managingEditor>alex@alexschroeder.ch (Alex Schroeder)</managingEditor>
|
||||
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
|
||||
<atom:link href="https://alexschroeder.ch/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<atom:link href="https://alexschroeder.ch/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the digital garden of Alex Schroeder.</description>
|
||||
<image>
|
||||
<url>https://alexschroeder.ch/view/logo.jpg</url>
|
||||
<url>https://alexschroeder.ch/pics/alex-and-beard.jpg</url>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://alexschroeder.ch/</link>
|
||||
</image>
|
||||
{{range .Items}}
|
||||
<item>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://alexschroeder.ch/view/{{.Name}}</link>
|
||||
<guid>https://alexschroeder.ch/view/{{.Name}}</guid>
|
||||
<link>https://alexschroeder.ch/view/{{.Path}}</link>
|
||||
<guid>https://alexschroeder.ch/view/{{.Path}}</guid>
|
||||
<description>{{.Html}}</description>
|
||||
<pubDate>{{.Date}}</pubDate>
|
||||
{{range .Hashtags}}
|
||||
|
||||
@@ -27,9 +27,12 @@
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
|
||||
<span class="score">{{.Score}}</span></p>
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
{{range .Images}}
|
||||
<p class="image"><a href="/view/{{.Path}}"><img loading="lazy" src="/view/{{.Path}}"></a><br/>{{.Html}}
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
<p>
|
||||
|
||||
@@ -69,15 +69,21 @@ window.addEventListener('load', uploadFiles.init);
|
||||
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
|
||||
<tt>Link: </tt>
|
||||
Links:<tt>{{range .Actual}}<br>{{end}}</tt>
|
||||
{{else}}
|
||||
<p>Link: <tt>[text]({{.Last}})</tt>
|
||||
{{end}}
|
||||
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
|
||||
<input type="hidden" name="body" value="{{range .Actual}}
|
||||
{{end}}">
|
||||
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
|
||||
<input type="submit" value="Add">
|
||||
</form>
|
||||
{{end}}
|
||||
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
|
||||
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
|
||||
Please provide your own filename. End the base name with "-1" to auto-increment.
|
||||
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
|
||||
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
|
||||
<p><label for="name">Filename:</label>
|
||||
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
|
||||
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
|
||||
@@ -85,7 +91,7 @@ window.addEventListener('load', uploadFiles.init);
|
||||
Feel free to specify a max width of 1200 pixels, for example.
|
||||
<p><label for="maxwidth">Max width:</label>
|
||||
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
|
||||
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
|
||||
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
|
||||
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
|
||||
<p><label for="quality">Quality:</label>
|
||||
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<link href="oddmu.css" rel="stylesheet" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Path}}.rss" />
|
||||
</head>
|
||||
<body>
|
||||
<header id="view">
|
||||
<a href="#main">Skip</a>
|
||||
<a href="index">Home</a>
|
||||
<a href="changes">Changes</a>
|
||||
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Name}}" accesskey="a">Add</a>
|
||||
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
|
||||
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Path}}" accesskey="a">Add</a>
|
||||
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
|
||||
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<label for="search">Search:</label>
|
||||
|
||||
@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
|
||||
</head>
|
||||
<body>
|
||||
<h1>Adding to {{.Title}}</h1>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<form action="/append/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Add">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/view/{{.Name}}">Back</a>
|
||||
<a href="/view/{{.Path}}">Back</a>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
|
||||
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
|
||||
<pre>
|
||||
{{.Diff}}
|
||||
</pre>
|
||||
|
||||
@@ -12,13 +12,14 @@ form, textarea { width: 100%; }
|
||||
</head>
|
||||
<body>
|
||||
<h1>Editing {{.Title}}</h1>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
<channel>
|
||||
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://campaignwiki.org/view/{{.Name}}</link>
|
||||
<link>https://campaignwiki.org/view/{{.Path}}</link>
|
||||
<webMaster>alex@alexschroeder.ch (Alex Schroeder)</webMaster>
|
||||
<atom:link href="https://campaignwiki.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<atom:link href="https://campaignwiki.org/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the feed for the campaign wiki {{.Title}}.</description>
|
||||
<image>
|
||||
<url>https://campaignwiki.org/blue-mountain-logo.jpg</url>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://campaignwiki.org/view/{{.Name}}</link>
|
||||
<link>https://campaignwiki.org/view/{{.Path}}</link>
|
||||
</image>
|
||||
{{range .Items}}
|
||||
<item>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://campaignwiki.org/view/{{.Name}}</link>
|
||||
<guid>https://campaignwiki.org/view/{{.Name}}</guid>
|
||||
<link>https://campaignwiki.org/view/{{.Path}}</link>
|
||||
<guid>https://campaignwiki.org/view/{{.Path}}</guid>
|
||||
<description>{{.Html}}</description>
|
||||
<pubDate>{{.Date}}</pubDate>
|
||||
{{range .Hashtags}}
|
||||
|
||||
@@ -37,7 +37,7 @@ img { max-width: 20%; }
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
|
||||
<span class="score">{{.Score}}</span></p>
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
</article>
|
||||
|
||||
@@ -76,15 +76,21 @@ window.addEventListener('load', uploadFiles.init);
|
||||
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
|
||||
<tt>Link: </tt>
|
||||
Links:<tt>{{range .Actual}}<br>{{end}}</tt>
|
||||
{{else}}
|
||||
<p>Link: <tt>[text]({{.Last}})</tt>
|
||||
{{end}}
|
||||
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
|
||||
<input type="hidden" name="body" value="{{range .Actual}}
|
||||
{{end}}">
|
||||
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
|
||||
<input type="submit" value="Add">
|
||||
</form>
|
||||
{{end}}
|
||||
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
|
||||
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
|
||||
Please provide your own filename. End the base name with "-1" to auto-increment.
|
||||
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
|
||||
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
|
||||
<p><label for="name">Filename:</label>
|
||||
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
|
||||
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
|
||||
@@ -92,7 +98,7 @@ window.addEventListener('load', uploadFiles.init);
|
||||
Feel free to specify a max width of 1200 pixels, for example.
|
||||
<p><label for="maxwidth">Max width:</label>
|
||||
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
|
||||
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
|
||||
<p>If the filename you provided above ends in <tt>.jpg</tt> or <tt>.webp</tt>, you can specify a quality.
|
||||
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
|
||||
<p><label for="quality">Quality:</label>
|
||||
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
|
||||
@@ -100,7 +106,7 @@ window.addEventListener('load', uploadFiles.init);
|
||||
Picture metadata is only removed if the pictures gets resized.
|
||||
Providing a new max width is recommended for all pictures.
|
||||
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
|
||||
<p>To delete a file, upload an empty file.
|
||||
<p>You can rename or delete files <a href="/list/{{.Dir}}">from the file list</a>.
|
||||
<p><label for="file">Pick files to upload:</label>
|
||||
<input type="file" name="file" required multiple>
|
||||
<p><input type="submit" value="Save">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="Campaign Wiki: {{.Title}}" href="/view/{{.Name}}.rss" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Campaign Wiki: {{.Title}}" href="/view/{{.Path}}.rss" />
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
|
||||
body { hyphens: auto; }
|
||||
@@ -22,9 +22,9 @@ img { max-width: 100%; }
|
||||
<a href="#main">Skip</a>
|
||||
<a href="index">Home</a>
|
||||
<a href="changes">Changes</a>
|
||||
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Name}}" accesskey="a">Add</a>
|
||||
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
|
||||
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Path}}" accesskey="a">Add</a>
|
||||
<a href="/diff/{{.Path}}" accesskey="d">Diff</a>
|
||||
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
|
||||
<a href="/archive/{{.Dir}}data.zip" accesskey="u">Zip</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
|
||||
@@ -30,7 +30,7 @@ p { margin: 0.5ch 0 0 0; }
|
||||
</header>
|
||||
<main>
|
||||
<h1>{{.Title}}</h1>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="30" lang="" autofocus>{{printf "# %s" .Today | or .Body | printf "%s"}}</textarea>
|
||||
<input type="hidden" name="notify" value="on">
|
||||
<p><input id="send" type="submit" value="Save">
|
||||
|
||||
8
themes/chat/index.md
Normal file
8
themes/chat/index.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Chat theme
|
||||
|
||||
The chat theme is for people whose attention is drifting, who like
|
||||
microblogs, who like to post little snippets, who like their blog to
|
||||
look like some instant messaging app.
|
||||
|
||||
- [README](README)
|
||||
- [Themes](../index)
|
||||
@@ -35,7 +35,7 @@ button { font-size: large; background-color: #eee; color: inherit; border-radius
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
|
||||
<span class="score">{{.Score}}</span></p>
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
</article>
|
||||
|
||||
@@ -22,11 +22,17 @@ label { display: inline-block; width: 7ch }
|
||||
<main>
|
||||
<h1>Upload</h1>
|
||||
{{if ne .Last ""}}
|
||||
<p>Use the following for <a href="/view/{{.Dir}}{{.Today}}">{{.Today}}</a>:
|
||||
<pre></a></pre>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Dir}}{{.Last}}">
|
||||
{{end}}
|
||||
<p>Use the following to post the image:
|
||||
<pre></a></pre>
|
||||
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
|
||||
<input type="hidden" name="body" value="{{range .Actual}}
|
||||
{{end}}">
|
||||
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
|
||||
<input type="submit" value="Add">
|
||||
</form>
|
||||
{{end}}
|
||||
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
|
||||
<p>What name to use for the uploads.
|
||||
|
||||
@@ -13,23 +13,33 @@ label { width: 7ch; display: inline-block; }
|
||||
#search { width: 30ch; }
|
||||
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px; }
|
||||
main > *, footer { clear: both; }
|
||||
main p {
|
||||
main > p, main > ul, main > ol, main > dl {
|
||||
float: right;
|
||||
color: #000; background: #8fd;
|
||||
padding: 3px 1ch; margin: 1pt auto 1pt 5ch;
|
||||
border-radius: 6px; border: 1px outset #eee; }
|
||||
main blockquote {
|
||||
main blockquote, ul p, ol p, dl p {
|
||||
padding: 0; margin: 0; }
|
||||
main blockquote p {
|
||||
float: left;
|
||||
color: #000; background: #ccc;
|
||||
padding: 3px 1ch; margin: 1pt 5ch 1pt 0;
|
||||
border-radius: 6px; border: 1px outset #eee; }
|
||||
p + blockquote > p, blockquote + p { margin-top: 5pt; }
|
||||
main ul, main ol, main dl {
|
||||
float: left;
|
||||
color: #000; background: #4ed;
|
||||
padding: 3px 1ch; margin: 1pt 0; border-radius: 6px; border: 1px outset #eee; }
|
||||
p + blockquote > p, blockquote + p {
|
||||
margin-top: 5pt; }
|
||||
/* for the marker */
|
||||
main ul {
|
||||
padding-left: 2em; }
|
||||
main ol {
|
||||
display: table; }
|
||||
ol li {
|
||||
counter-increment: list-item;
|
||||
display: table-row; }
|
||||
ol li::before {
|
||||
content: counter(list-item) ".\a0";
|
||||
display: table-cell;
|
||||
text-align: right; }
|
||||
/* footer */
|
||||
footer p { margin: 0.5ch 0 0 0; }
|
||||
textarea {
|
||||
width: 97%; margin: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
|
||||
@@ -42,8 +52,8 @@ img { max-width: 100%; margin-top: 5px; }
|
||||
<header>
|
||||
<a href="index">Home</a>
|
||||
<a href="{{.Today}}" accesskey="t">Today</a>
|
||||
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
|
||||
<a href="/upload/{{.Dir}}?filename={{.Today}}-pic-1.jpg" accesskey="u">Upload</a>
|
||||
<a href="/edit/{{.Path}}" accesskey="e">Edit</a>
|
||||
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<label for="search">Suchen:</label>
|
||||
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
|
||||
@@ -55,7 +65,7 @@ img { max-width: 100%; margin-top: 5px; }
|
||||
{{.Html}}
|
||||
</main>
|
||||
<footer>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<form action="/append/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="4" cols="30" placeholder="Text" lang="" autofocus required></textarea>
|
||||
<input type="hidden" name="notify" value="on">
|
||||
<p><input id="send" type="submit" value="Send">
|
||||
|
||||
@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
|
||||
</head>
|
||||
<body>
|
||||
<h1>Adding to {{.Title}}</h1>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<form action="/append/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Add">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,11 +15,11 @@ pre { white-space: normal; background-color: white; border: 1px solid #eee; padd
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/view/{{.Name}}">Back</a>
|
||||
<a href="/view/{{.Path}}">Back</a>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
|
||||
<p>This is the diff between <a href="/view/{{.Path}}.md~">the backup</a> and <a href="/view/{{.Path}}.md">the current copy</a>.</p>
|
||||
<pre>
|
||||
{{.Diff}}
|
||||
</pre>
|
||||
|
||||
@@ -12,13 +12,13 @@ form, textarea { width: 100%; }
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bearbeiten von {{.Title}}</h1>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://flying-carpet.ch/</link>
|
||||
<atom:link href="https://flying-carpet.ch/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<atom:link href="https://flying-carpet.ch/view/{{.Path}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the digital garden of Claudia.</description>
|
||||
{{range .Items}}
|
||||
<item>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://flying-carpet.ch/view/{{.Name}}</link>
|
||||
<guid>https://flying-carpet.ch/view/{{.Name}}</guid>
|
||||
<link>https://flying-carpet.ch/view/{{.Path}}</link>
|
||||
<guid>https://flying-carpet.ch/view/{{.Path}}</guid>
|
||||
<description>{{.Html}}</description>
|
||||
<pubDate>{{.Date}}</pubDate>
|
||||
{{range .Hashtags}}
|
||||
|
||||
@@ -36,7 +36,7 @@ img { max-width: 20%; }
|
||||
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Nächste Seite</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
<p><a class="result" href="/view/{{.Path}}">{{.Title}}</a>
|
||||
<span class="score">{{.Score}}</span></p>
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
</article>
|
||||
|
||||
@@ -76,15 +76,21 @@ window.addEventListener('load', uploadFiles.init);
|
||||
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
|
||||
{{if .Image}}
|
||||
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
|
||||
<tt>Link: </tt>
|
||||
Links:<tt>{{range .Actual}}<br>{{end}}</tt>
|
||||
{{else}}
|
||||
<p>Link: <tt>[text]({{.Last}})</tt>
|
||||
{{end}}
|
||||
<form id="add" action="/append/{{.Dir}}{{.Base}}" method="POST">
|
||||
<input type="hidden" name="body" value="{{range .Actual}}
|
||||
{{end}}">
|
||||
<p>Append this to <a href="/view/{{.Dir}}{{.Base}}">{{.Title}}</a>?
|
||||
<input type="submit" value="Add">
|
||||
</form>
|
||||
{{end}}
|
||||
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
|
||||
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
|
||||
Please provide your own filename. End the base name with "-1" to auto-increment.
|
||||
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
|
||||
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
|
||||
<p><label for="name">Filename:</label>
|
||||
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
|
||||
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
|
||||
|
||||
@@ -19,7 +19,7 @@ img { max-width: 100%; }
|
||||
<body>
|
||||
<header>
|
||||
<a href="/view/index">Willkommen</a>
|
||||
<a href="/edit/{{.Name}}" accesskey="e">Bearbeiten</a>
|
||||
<a href="/edit/{{.Path}}" accesskey="e">Bearbeiten</a>
|
||||
<a href="/upload/{{.Dir}}" accesskey="u">Upload</a>
|
||||
<form role="search" action="/search/{{.Dir}}" method="GET">
|
||||
<label for="search">Suchen:</label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Themes
|
||||
|
||||
[Oddµ](../index) uses HTML templates to create its pages. The following
|
||||
[Oddμ](../index) uses HTML templates to create its pages. The following
|
||||
subdirectories contain different themes for you to use and adapt for
|
||||
your own sites.
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ Now, the text is still saved in Markdown files and the Markdown is
|
||||
still rendered to HTML – but the "view" template just prints the page
|
||||
body inside a "pre" block and ignores the rendered HTML.
|
||||
|
||||
This being text files, there are also no links to follow. That is why
|
||||
there's no link here back to themes. Sorry!
|
||||
In addition to that, a piece of JavaScript goes through the text and
|
||||
replaces rocket links with actual links. Rocket links are how Gemini
|
||||
links to other pages: On a line by itself (no inline links!) write
|
||||
"=>", a space, the URL, and optionally another space and the text to
|
||||
use.
|
||||
|
||||
This also means that you can only edit new files by editing the URL in
|
||||
the address bar of your browser since you can't link to them.
|
||||
=> ../index Themes
|
||||
|
||||
@@ -10,10 +10,10 @@ form, textarea { width: 100% }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<form action="/save/{{.Path}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
10
themes/plain/index.md
Normal file
10
themes/plain/index.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Plain theme
|
||||
|
||||
The plain theme is for people who like their post to look like a
|
||||
plain-text file, reminiscent of Gopher.
|
||||
|
||||
In an ironic reversal of priorities, this theme uses JavaScript to
|
||||
create rocket links, Gemini-style.
|
||||
|
||||
=> README
|
||||
=> ../index Themes
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user