44 Commits
v1.14 ... v1.16

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

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

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

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

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

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

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

Search with image previews.

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

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

Add an index.md file so that the Home link works.
2025-02-09 13:20:42 +01:00
Alex Schroeder
26033de177 Update release-information 2025-02-09 11:46:35 +01:00
Alex Schroeder
e1ba007f97 Don't link hashtags in link text
This change depends on a change to the markdown library. Specifically,
the parser's InsideLink must be public.

This means that the #like_this hashtag from the README.md in the
source directory is no longer available, so that test had to be
rewritten.

Another change to reduce the number of false hashtags was to use the
hashtag parser for all situations: It's also used to identify hashtags
in the search query string. The parser doesn't automatically turn the
matches to lower-case, however, so that has to be done when indexing
documents and when searching for hashtags.

The hashtags command for the commandline no longer prints a hash for
all the tags.
2025-02-07 20:05:36 +01:00
Alex Schroeder
e90ff9e7dd Fix timestamp handling for backup files
The timestamp has to be updated!
2024-11-16 11:45:24 +01:00
Alex Schroeder
70356e850a Fiddle with case for the headings 2024-11-16 11:37:09 +01:00
Alex Schroeder
81a59fd6ac Add preview test 2024-11-15 19:29:05 +01:00
Alex Schroeder
52d6f26eed Add unreleased 0.15 to the docs 2024-11-15 19:28:46 +01:00
Alex Schroeder
171910ff4f Fix release notes for 0.14 2024-11-15 19:26:45 +01:00
Alex Schroeder
5fb0f57b5c Add base element to edit template 2024-11-15 19:22:56 +01:00
123 changed files with 1044 additions and 601 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
/oddmu
test.md
/testdata/
/oddmu-darwin-*
/oddmu-linux-*
/oddmu-windows-*
/oddmu.exe
/oddmu

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ When preparing a new release
1. Run tests
2. Update man/oddmu-releases.7.txt
- add missing items
- change "(unreleased)"
3. make docs

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="{{.Language}}" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import (
"html"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -25,11 +24,7 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
func (p *Page) Diff() template.HTML {
path, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
fp := filepath.FromSlash(path)
fp := filepath.FromSlash(p.Name)
a := fp + ".md~"
t1, err := os.ReadFile(a)
if err != nil {

View File

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

View File

@@ -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>`)
}

View File

@@ -4,6 +4,7 @@
<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>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
@@ -12,14 +13,14 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form action="/save/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<button formaction="/preview/{{.Name}}" type="submit">Preview</button>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<button formaction="/preview/{{.Path}}" type="submit">Preview</button>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

9
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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")
}

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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))
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type linksCmd struct {
@@ -43,6 +44,11 @@ func linksCli(w io.Writer, args []string) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

View File

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

42
list.go
View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -26,38 +27,38 @@ type List struct {
}
// listHandler uses the "list.html" template to enable file management in a particular directory.
func listHandler(w http.ResponseWriter, r *http.Request, dir string) {
func listHandler(w http.ResponseWriter, r *http.Request, name string) {
files := []File{}
d := filepath.FromSlash(dir)
d := filepath.FromSlash(name)
if d == "" {
d = "."
} else if !strings.HasSuffix(d, "/") {
http.Redirect(w, r, "/list/"+d+"/", http.StatusFound)
http.Redirect(w, r, "/list/" + nameEscape(name) + "/", http.StatusFound)
return
} else {
it := File{Name: "..", IsUp: true, IsDir: true }
files = append(files, it)
}
err := filepath.Walk(d, func (path string, fi fs.FileInfo, err error) error {
err := filepath.Walk(d, func (fp string, fi fs.FileInfo, err error) error {
if err != nil {
return err
}
isDir := false
if fi.IsDir() {
if d == path {
if d == fp {
return nil
}
isDir = true
}
name := filepath.ToSlash(path)
base := filepath.Base(name)
name := filepath.ToSlash(fp)
base := filepath.Base(fp)
title := ""
if !isDir && strings.HasSuffix(name, ".md") {
index.RLock()
defer index.RUnlock()
title = index.titles[name[:len(name)-3]]
}
if isDir {
} else if isDir {
// even on Windows, this looks like a Unix directory
base += "/"
}
it := File{Name: base, Title: title, Date: fi.ModTime().Format(time.DateTime), IsDir: isDir }
@@ -72,31 +73,36 @@ func listHandler(w http.ResponseWriter, r *http.Request, dir string) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
renderTemplate(w, dir, "list", &List{Dir: dir, Files: files})
renderTemplate(w, d, "list", &List{Dir: name, Files: files})
}
// deleteHandler deletes the named file and then redirects back to the list
func deleteHandler(w http.ResponseWriter, r *http.Request, path string) {
fn := filepath.Clean(filepath.FromSlash(path))
func deleteHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
err := os.RemoveAll(fn) // and all its children!
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/"+filepath.Dir(fn)+"/", http.StatusFound)
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(name)) + "/", http.StatusFound)
}
// renameHandler renames the named file and then redirects back to the list
func renameHandler(w http.ResponseWriter, r *http.Request, path string) {
fn := filepath.Clean(filepath.FromSlash(path))
target := filepath.Join(filepath.Dir(fn), r.FormValue("name"))
err := os.Rename(fn, target)
func renameHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
dir := path.Dir(name)
target := path.Join(dir, r.FormValue("name"))
if (isHiddenName(target)) {
http.Error(w, "the target file would be hidden", http.StatusForbidden)
return
}
err := os.Rename(fn, filepath.FromSlash(target))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/"+filepath.Dir(target)+"/", http.StatusFound)
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(filepath.ToSlash(target))) + "/", http.StatusFound)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2024-11-09"
.TH "ODDMU-RELEASES" "7" "2025-04-06"
.PP
.SH NAME
.PP
@@ -15,7 +15,36 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.14 (unreleased)
.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.\&
.PP
@@ -34,7 +63,7 @@ existing templates.\& For example, from upload.\&html:
.fi
.RE
.PP
The following line was added to the "preview.\&html" template:
The following line was added to the "preview.\&html" and "edit.\&html" template:
.PP
.nf
.RS 4
@@ -44,7 +73,8 @@ The following line was added to the "preview.\&html" template:
.PP
You might want to do that as well, if you have your own.\& Without this, links in
the preview cannot be followed as they all point to \fB/preview\fR instead of
\fB/view\fR.\&
\fB/view\fR and the link to the list of changes cannot be followed from the edit
page: it leads to editing the list of changes.\&
.PP
.SS 1.13 (2024)
.PP

View File

@@ -8,7 +8,36 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 1.14 (unreleased)
## 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.
@@ -25,7 +54,7 @@ existing templates. For example, from upload.html:
<p>You can rename and delete files <a href="/list/{{.Dir}}">from the file list</a>.
```
The following line was added to the "preview.html" template:
The following line was added to the "preview.html" and "edit.html" template:
```
<base href="/view/{{.Dir}}">
@@ -33,7 +62,8 @@ The following line was added to the "preview.html" template:
You might want to do that as well, if you have your own. Without this, links in
the preview cannot be followed as they all point to */preview* instead of
*/view*.
*/view* and the link to the list of changes cannot be followed from the edit
page: it leads to editing the list of changes.
## 1.13 (2024)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,13 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-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".\&

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/subcommands"
"io"
"os"
"strings"
)
type notifyCmd struct {
@@ -32,6 +33,11 @@ func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
index.load()
for _, name := range args {
if !strings.HasSuffix(name, ".md") {
fmt.Fprintf(os.Stderr, "%s does not end in '.md'\n", name)
return subcommands.ExitFailure
}
name = name[0:len(name)-3]
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)

BIN
oddmu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

60
page.go
View File

@@ -64,7 +64,7 @@ func nameEscape(s string) string {
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
func (p *Page) save() error {
fp := filepath.FromSlash(p.Name + ".md")
fp := filepath.FromSlash(p.Name) + ".md"
watches.ignore(fp)
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
@@ -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 ""
}

View File

@@ -57,7 +57,7 @@ And untouchable`)}
p = &Page{Name: "testdata/parents/children/something/other"}
// "testdata/parents/children/something/index" is a sibling and doesn't count!
parents := p.Parents()
assert.Equal(t, "Welcome to Oddµ", parents[0].Title)
assert.Equal(t, "Welcome to Oddμ", parents[0].Title)
assert.Equal(t, "../../../../index", parents[0].Url)
assert.Equal(t, "…", parents[1].Title)
assert.Equal(t, "../../../index", parents[1].Url)

View File

@@ -8,7 +8,6 @@ import (
"github.com/gomarkdown/markdown/parser"
"net/url"
"path"
"path/filepath"
)
// wikiLink returns an inline parser function. This indirection is
@@ -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.

View File

@@ -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))
}

View File

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

17
preview_test.go Normal file
View 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>!")
}

View File

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

View File

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

View File

@@ -9,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

View File

@@ -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)
`

View File

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

View File

@@ -84,7 +84,7 @@ func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStat
func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
// The error returned here is what's in the stop channel but at the very end, a worker might return an error
// even though the walk is already done. This is why we cannot rely on the return value of the walk.
filepath.Walk(source, func(path string, info fs.FileInfo, err error) error {
filepath.Walk(source, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
@@ -92,9 +92,8 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
case err := <-stop:
return err
default:
base := filepath.Base(path)
// skip hidden directories and files
if path != "." && strings.HasPrefix(base, ".") {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
@@ -102,28 +101,28 @@ func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, target) {
if strings.HasSuffix(fp, "~") || strings.HasPrefix(fp, target) {
return nil
}
// determine the actual target: if source is a/ and target is b/ and path is a/file, then the
// target is b/file
var actual_target string
var actualTarget string
if source == "." {
actual_target = filepath.Join(target, path)
actualTarget = filepath.Join(target, fp)
} else {
if !strings.HasPrefix(path, source) {
return fmt.Errorf("%s is not a subdirectory of %s", path, source)
if !strings.HasPrefix(fp, source) {
return fmt.Errorf("%s is not a subdirectory of %s", fp, source)
}
actual_target = filepath.Join(target, path[len(source):])
actualTarget = filepath.Join(target, fp[len(source):])
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(actual_target, 0755)
return os.Mkdir(actualTarget, 0755)
}
// do the task if the target file doesn't exist or if the source file is newer
other, err := os.Stat(actual_target)
other, err := os.Stat(actualTarget)
if err != nil || info.ModTime().After(other.ModTime()) {
tasks <- args{source: path, target: actual_target, info: info}
tasks <- args{source: fp, target: actualTarget, info: info}
}
return nil
}
@@ -211,7 +210,6 @@ func staticPage(source, target string) (*Page, error) {
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Hashtags = *hashtags
return p, write(p, target, "", "static.html")
@@ -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

View File

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

View File

@@ -6,6 +6,11 @@ download:
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
# (ediff-directories "alexschroeder.ch" "/ssh:sibirocobombus:alexschroeder.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root|sudo:claudia@sibirocobombus.root:/home/alex/flying-carpet.ch/wiki/" "html$")
# (ediff-directories "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.org/data/" "html$")
# (ediff-directories "transjovian.org" "/ssh:sibirocobombus.root:/home/oddmu/" "html$")
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,11 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form action="/append/{{.Path}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<a href="/view/{{.Path}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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