78 Commits

Author SHA1 Message Date
Alex Schroeder
7110e0af68 Release 1.17 2025-04-26 19:39:17 +02:00
Alex Schroeder
8841372814 Fix typos 2025-04-26 19:36:33 +02:00
Alex Schroeder
fefa283775 Reword oddmu-releases 2025-04-23 08:05:46 +02:00
Alex Schroeder
5a09d65dab Theme fixes 2025-04-23 08:05:33 +02:00
Alex Schroeder
2cf0855994 Add changes link
To transjovian.org and default theme. I must have dropped it at some
point but I think it makes no sense.
2025-04-22 08:26:28 +02:00
Alex Schroeder
f98312e12f Fix communitywiki theme
Add missing templates.
2025-04-22 08:25:11 +02:00
Alex Schroeder
d213ee2815 Add communitywiki.org theme
This is a very minor theme.
2025-04-22 07:59:27 +02:00
Alex Schroeder
0cd09666c6 Don't inherit the color for buttons 2025-04-21 23:20:53 +02:00
Alex Schroeder
bd9364dc09 Change "Skip navigation" to "Skip" in all templates 2025-04-21 16:39:42 +02:00
Alex Schroeder
93fd49bc4c Fix template test
I changed "Skip navigation" to "Skip" in the templates.
2025-04-21 16:36:49 +02:00
Alex Schroeder
300e411960 Make search handler more robust 2025-04-21 16:36:18 +02:00
Alex Schroeder
10cea2bf2c Fix comment 2025-04-21 16:30:09 +02:00
Alex Schroeder
830af140eb Add initial-scale=1.0, user-scalable=no to all templates 2025-04-21 16:28:39 +02:00
Alex Schroeder
c758dd7df7 Fix image inclusion for the search template 2025-04-21 16:25:47 +02:00
Alex Schroeder
969df2aef9 Add pasting of images to chat theme 2025-04-21 16:25:30 +02:00
Alex Schroeder
39f414694c Update alexschroeder.ch theme
Drop the separate CSS file and switch to inline CSS again.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 16:24:32 +02:00
Alex Schroeder
fa67508692 Update campaignwiki.org theme
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:47:02 +02:00
Alex Schroeder
d5696135c1 Use image-1.jpg as the default filename 2025-04-21 00:40:14 +02:00
Alex Schroeder
284fc3094d Update flying-carpet.ch theme
Use .Path instead of .Name in URLs.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:33:23 +02:00
Alex Schroeder
57161bbc98 Updated release info
The link to the upload page needs two query parameters, most likely.
2025-04-21 00:28:08 +02:00
Alex Schroeder
d855d9d91a Update transjovian.org theme
Use .Path instead of .Name in URLs.
Change the upload page based on the new setup.
Change view.html to pass along filename and pagename.
2025-04-21 00:25:19 +02:00
Alex Schroeder
ca85250514 Remove trailing semicolon in standard theme 2025-04-21 00:24:52 +02:00
Alex Schroeder
649fde81fe Add a warning about JavaScript to plain theme 2025-04-21 00:24:19 +02:00
Alex Schroeder
8a47e9c5fe Remove some extra semicolons in a theme 2025-04-21 00:05:34 +02:00
Alex Schroeder
fd9a515e0f Use .Path instead of .Name in list.html
This ensures that files with hash signs or question marks in their
name get handled correctly.
2025-04-21 00:04:32 +02:00
Alex Schroeder
da04c6dc27 Major update of upload.html! 2025-04-21 00:02:28 +02:00
Alex Schroeder
bd2da1414c Remove trailing semicolons in CSS 2025-04-09 21:14:16 +02:00
Alex Schroeder
6d1a5462b4 Make textarea "full size" 2025-04-09 21:10:54 +02:00
Alex Schroeder
3dcaf8aca1 Fix markup 2025-04-08 08:49:28 +02:00
Alex Schroeder
80ce16f873 Improve the documentation of the template changes 2025-04-08 08:41:41 +02:00
Alex Schroeder
41347ad5dc Fix list action
More explicit path encoding for directory and file name.
2025-04-08 08:04:52 +02:00
Alex Schroeder
6a911b2860 Fix handling of hashes in filenames 2025-04-08 00:14:59 +02:00
Alex Schroeder
1d6db77660 Handle files with # in their name 2025-04-06 23:44:57 +02:00
Alex Schroeder
8a8afcb56f List action skips dot files and dot directories 2025-04-06 21:08:36 +02:00
Alex Schroeder
6803b8e90d Update the themes 2025-04-06 20:44:57 +02:00
Alex Schroeder
ff357a4048 Added more steps to the RELEASE file 2025-04-06 13:48:12 +02:00
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
151 changed files with 2587 additions and 1124 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

@@ -13,4 +13,10 @@ When preparing a new release
5. Tag the release and push the tag to all remotes
6. cd man && make upload
6. cd man && make upload
7. make dist
8. create a new release at https://github.com/kensanata/oddmu/releases
9. upload the four .tar.gz binaries to the GitHub release

View File

@@ -3,20 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<form id="editor" 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

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
@@ -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

@@ -3,24 +3,27 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<form id="editor" 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,9 @@ 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/gen2brain/webp v0.5.2
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 +25,14 @@ 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/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

20
go.sum
View File

@@ -5,22 +5,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
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/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5 h1:qIhG9h8tUzKsVHn0iHtWUohq7Ve7btgA8rGp7TvrIHw=
github.com/gomarkdown/markdown v0.0.0-20240930133403-7e0a027d98c5/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-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=
@@ -59,8 +57,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
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 +66,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")

57
list.go
View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -26,43 +27,50 @@ 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") {
if strings.HasPrefix(base, ".") {
// skip dot directories and dot files
if isDir {
return filepath.SkipDir
}
return nil
} else 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 }
files = append(files, it)
if isDir {
// never descend into directories
return filepath.SkipDir
}
return nil
@@ -72,31 +80,42 @@ 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: pathEncode(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)
}
// Path returns the File.Name with some characters escaped because html/template doesn't escape those. This is suitable
// for use in HTML templates.
func (f *File) Path() string {
return pathEncode(f.Name)
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
@@ -21,7 +21,7 @@ td:first-child, td:last-child { white-space: nowrap }
</head>
<body lang="en">
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
@@ -44,11 +44,11 @@ td:first-child, td:last-child { white-space: nowrap }
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Name}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Name}}">{{.Name}}</a>{{end}}</td>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Name}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Path}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Name}}">
<form action="/rename/{{$.Dir}}{{.Path}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>

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

@@ -13,6 +13,22 @@ func TestListHandler(t *testing.T) {
"index.md")
}
func TestListDot(t *testing.T) {
cleanup(t, "testdata/list-dot")
p := &Page{Name: "testdata/list-dot/haiku", Body: []byte(`# Pressure
fingers tap and dance
round and round they go at night
before we go to bed
`)}
p.save()
_, err := os.Create("testdata/list-dot/.secret")
assert.NoError(t, err)
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/list-dot/", nil)
assert.NotContains(t, body, "secret", "secret file was not found")
assert.Contains(t, body, "haiku", "regular page was found")
}
func TestDeleteHandler(t *testing.T) {
cleanup(t, "testdata/delete")
assert.NoError(t, os.Mkdir("testdata/delete", 0755))
@@ -23,8 +39,38 @@ See the forest loom above
And an orange sky
`)}
p.save()
list := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.Contains(t, list, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.Contains(t, list, `<td>Sunset</td>`)
assert.Contains(t, list, `<button form="manage" formaction="/delete/testdata/delete/haiku.md" title="Delete haiku.md">`)
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.Contains(t, body, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.Contains(t, body, `<td>Sunset</td>`)
assert.Contains(t, body, `<button form="manage" formaction="/delete/testdata/delete/haiku.md" title="Delete haiku.md">`)
// ensure that it exists
assert.FileExists(t, "testdata/delete/haiku.md")
// delete file
HTTPRedirectTo(t, makeHandler(deleteHandler, false), "GET", "/delete/testdata/delete/haiku.md", nil, "/list/testdata/delete/")
// verify that it is gone
body = assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.NotContains(t, body, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.NoFileExists(t, "testdata/delete/haiku.md")
}
func TestListUmlautHandler(t *testing.T) {
cleanup(t, "testdata/list-umlaut")
p := &Page{Name: "testdata/list-umlaut/hägar", Body: []byte(`# Hägar
Hägar was a man
Loud and strong and quick to act
he did not like it
`)}
p.save()
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/list-umlaut/", nil)
assert.Contains(t, body, `<button form="manage" formaction="/delete/testdata/list-umlaut/h%c3%a4gar.md" title="Delete hägar.md">`)
}
func TestListHash(t *testing.T) {
cleanup(t, "testdata/list-#hash")
os.Mkdir("testdata/list-#hash", 0755)
_, err := os.Create("testdata/list-#hash/#secret")
assert.NoError(t, err)
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/list-%23hash/", nil)
assert.Contains(t, body, `<button form="manage" formaction="/delete/testdata/list-%23hash/%23secret" title="Delete #secret">`)
}

View File

@@ -27,7 +27,7 @@ html: ${HTML}
%.html: %.md
@echo Making $@
@echo '<!DOCTYPE html>' > $@
@oddmu html $(basename $<) | sed --regexp-extended \
@oddmu html $< | sed --regexp-extended \
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
md: ${MD}

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-15"
.TH "ODDMU-RELEASES" "7" "2025-04-26"
.PP
.SH NAME
.PP
@@ -15,6 +15,68 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.17 (2025)
.PP
You need to update the upload template ("upload.\&html").\& Many things have
changed!\& See \fIoddmu-templates\fR(5) for more.\&
.PP
You probably want to ensure that the upload link on the view template
("view.\&html") and others, if you added it, has a \fIfilename\fR and \fIpagename\fR
parameters.\&
.PP
Example:
.PP
.nf
.RS 4
<a href="/upload/{{\&.Dir}}?filename={{\&.Base}}-1\&.jpg&pagename={{\&.Base}}">Upload</a>
.fi
.RE
.PP
You need to change {{.\&Name}} to {{.\&Path}} when it is used in URLs, in the list
template ("list.\&html").\& If you don'\&t do this, file deleting and rename may not
work on files containing a comma, a semicolon, a questionmark or a hash
character.\& This fix was necessary because URLs for files containing a
questionmark or a hash character would end the path at this character and treat
the rest as a query parameter or fragment, respectively.\&
.PP
Updated the example themes.\& Some of my sites got a text area that tries to take
all the vertical space available.\& This is great for monitors in portrait mode.\&
.PP
\fIlist\fR action now skips dot files.\&
.PP
.SS 1.16 (2025)
.PP
Add support for WebP images for uploading and resizing.\&
.PP
You need to change {{.\&Name}} to {{.\&Path}} in HTML templates where pages are
concerned.\& If you don'\&t do this, your page names (i.\&e.\& filenames for pages) may
not include a comma, a semicolon, a questionmark or a hash sign.\& This fix was
necessary because file uploads of filenames with non-ASCII characters ended up
double-encoded.\&
.PP
Note that on the "list.\&html" template, {{.\&Name}} refers to file instead of a
page and File.\&Path() isn'\&t implemented, yet.\& This is fixed in the next release.\&
.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.\&

View File

@@ -8,6 +8,66 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 1.17 (2025)
You need to update the upload template ("upload.html"). Many things have
changed! See _oddmu-templates_(5) for more.
You probably want to ensure that the upload link on the view template
("view.html") and others, if you added it, has a _filename_ and _pagename_
parameters.
Example:
```
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg&pagename={{.Base}}">Upload</a>
```
You need to change {{.Name}} to {{.Path}} when it is used in URLs, in the list
template ("list.html"). If you don't do this, file deleting and rename may not
work on files containing a comma, a semicolon, a questionmark or a hash
character. This fix was necessary because URLs for files containing a
questionmark or a hash character would end the path at this character and treat
the rest as a query parameter or fragment, respectively.
Updated the example themes. Some of my sites got a text area that tries to take
all the vertical space available. This is great for monitors in portrait mode.
_list_ action now skips dot files.
## 1.16 (2025)
Add support for WebP images for uploading and resizing.
You need to change {{.Name}} to {{.Path}} in HTML templates where pages are
concerned. If you don't do this, your page names (i.e. filenames for pages) may
not include a comma, a semicolon, a questionmark or a hash sign. This fix was
necessary because file uploads of filenames with non-ASCII characters ended up
double-encoded.
Note that on the "list.html" template, {{.Name}} refers to file instead of a
page and File.Path() isn't implemented, yet. This is fixed in the next release.
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.

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-TEMPLATES" "5" "2024-08-30" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2025-04-26" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -44,6 +44,29 @@ placeholders.\&
\fIview.\&html\fR uses a \fIpage\fR
.PD
.PP
The following property lists always indicate whether the property is
percent-encoded or not.\& In theory, the html/template package would handle this.\&
The problem is that the package gives special treatment to the semicolon, comma,
question-mark and hash-sign as these are potential separators in a URL.\&
.PP
Consider the following:
.PP
.nf
.RS 4
<a href="{{\&.Name}}">{{\&.Name}}</a>
.fi
.RE
.PP
If \fI.\&Name\fR is "#foo", the html/template package treats it as a URL fragment
inside the attribute instead of a file path that needs to be escaped to
"%23foo".\& The same problem arises if \fI.\&Name\fR is "foo?\&" as the questionmark is
not escaped and therefore treated as the separator between URL path and query
parameters instead of being part of the name.\&
.PP
The consequences for template authors is that the properties that are
percent-encoded must be used in links where as the regular properties must be
used outside of links.\&
.PP
.SS Page
.PP
A page has the following properties:
@@ -51,14 +74,14 @@ A page has the following properties:
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own title, the
page name is used.\&
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\& More specifically, it is
percent-escaped except for the slashes.\& The page name doesn'\&t include the \fI.\&md\fR
extension.\&
\fI{{.\&Name}}\fR is the page name.\& The page name doesn'\&t include the \fI.\&md\fR extension.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-escaped except for the slashes.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-encoded.\&
.PP
\fI{{.\&Base}}\fR is the basename of the current file (without the directory and
without the \fI.\&md\fR extension), escaped for use in URLs.\&
without the \fI.\&md\fR extension), percent-encoded.\&
.PP
\fI{{.\&Language}}\fR is the suspected language of the page.\& This is used to set the
language on the \fIview.\&html\fR template.\& See "Non-English hyphenation" below.\&
@@ -113,7 +136,7 @@ An item is a page plus a date.\& All the properties of a page can be used (see
.PP
The list contains a directory name and an array of files.\&
.PP
\fI{{.\&Dir}}\fR is the directory name that is being listed.\&
\fI{{.\&Dir}}\fR is the directory name that is being listed, percent-encoded.\&
.PP
\fI{{.\&Files}}\fR is the array of files.\& To refer to them, you need to use a \fI{{range
Files}}\fR\fI{{end}}\fR construct.\&
@@ -123,6 +146,8 @@ Each file has the following attributes:
\fI{{.\&Name}}\fR is the filename.\& The ".\&md" suffix for Markdown files is part of the
name (unlike page names).\&
.PP
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Title}}\fR is the page title, if the file in question is a Markdown file.\&
.PP
\fI{{.\&IsDir}}\fR is a boolean used to indicate that this file is a directory.\&
@@ -137,8 +162,7 @@ directory).\& The filename of this file is ".\&.\&".\&
.PP
\fI{{.\&Query}}\fR is the query string.\&
.PP
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-escaped except
for the slashes.\&
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-encoded.\&
.PP
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR and \fI{{.\&Next}}\fR are the previous, current and next
page number in the results since doing arithmetics in templates is hard.\& The
@@ -175,23 +199,18 @@ search term that matched.\&
.SS Upload
.PP
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.\&
path, percent-encoded.\&
.PP
\fI{{.\&Name}}\fR is the \fIfilename\fR query parameter.\&
\fI{{.\&FileName}}\fR is the \fIfilename\fR query parameter used to suggested a filename.\&
.PP
\fI{{.\&Last}}\fR is the filename of the last file uploaded.\&
\fI{{.\&FilePath}}\fR is the filename, percent-encoded.\&
.PP
\fI{{.\&Actual}}\fR is an array of filenames of all the files uploaded.\& Use {{range
Actual}} … {{.\&}} … {{end}} to loop over all the filenames.\&
\fI{{.\&Name}}\fR is the \fIpagename\fR query parameter used to indicate where to append
links to the files.\&
.PP
\fI{{.\&Base}}\fR is the basename of the first file uploaded (without the directory,
extension and numeric part at the end), escaped for use in URLs.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
\fI{{.\&Title}}\fR is the title of the basename, if it exists.\&
.PP
\fI{{.\&Image}}\fR is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in \fI.\&jpg\fR).\& If so, a thumbnail
can be shown by the template, for example.\&
\fI{{.\&Title}}\fR is the title of the page, if it exists.\&
.PP
\fI{{.\&MaxWidth}}\fR is the \fImaxwidth\fR query parameter, i.\&e.\& the value used for the
previous image uploaded.\&
@@ -201,6 +220,22 @@ previous image uploaded.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\&
.PP
\fI{{.\&Uploads}}\fR an array of files already uploaded, based on the \fIuploads\fR query
parameter.\& To refer to them, you need to use a \fI{{range .\&Uploads}}\fR\fI{{end}}\fR
construct.\& This is required because the \fIdrop\fR action redirects back to the
\fIupload\fR action, so after saving one or more files, you can upload even more
files.\&
.PP
Each upload has the following attributes:
.PP
\fI{{.\&Name}}\fR is the filename.\&
.PP
\fI{{.\&Path}}\fR is the file name, percent-encoded.\&
.PP
\fI{{.\&Image}}\fR is a boolean to indicate whether the upload is an image or not
(such as ending in \fI.\&jpg\fR).\& If so, a thumbnail can be shown by the template, for
example.\&
.PP
.SS Non-English hyphenation
.PP
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -214,16 +249,16 @@ use a small number of languages or just a single language!\& you can set
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
.PP
"view.\&html" is used the template to render a single page and so the language
detected is added to the "html" element.\&
"view.\&html" is used to render a single page and so the language detected is
added to the "html" element.\&
.PP
"search.\&html" is the template used to render search results and so "en" is used
for the "html" element and the language detected for every page in the search
result is added to the "article" element for each snippet.\&
.PP
"edit.\&html" and "add.\&html" are the templates used to edit a page and at that
point, the language isn'\&t known, so "en" is used for the "html" element and no
language is used for the "textarea" element.\&
"edit.\&html" and "add.\&html" are the templates used to edit a page.\& If the page
already exists, its language is used for the "textarea" element.\& If the page is
new, no language is used for the "textarea" element.\&
.PP
.SH EXAMPLES
.PP

View File

@@ -25,6 +25,27 @@ placeholders.
- _upload.html_ uses an _upload_
- _view.html_ uses a _page_
The following property lists always indicate whether the property is
percent-encoded or not. In theory, the html/template package would handle this.
The problem is that the package gives special treatment to the semicolon, comma,
question-mark and hash-sign as these are potential separators in a URL.
Consider the following:
```
<a href="{{.Name}}">{{.Name}}</a>
```
If _.Name_ is "#foo", the html/template package treats it as a URL fragment
inside the attribute instead of a file path that needs to be escaped to
"%23foo". The same problem arises if _.Name_ is "foo?" as the questionmark is
not escaped and therefore treated as the separator between URL path and query
parameters instead of being part of the name.
The consequences for template authors is that the properties that are
percent-encoded must be used in links where as the regular properties must be
used outside of links.
## Page
A page has the following properties:
@@ -32,14 +53,14 @@ A page has the following properties:
_{{.Title}}_ is the page title. If the page doesn't provide its own title, the
page name is used.
_{{.Name}}_ is the page name, escaped for use in URLs. More specifically, it is
percent-escaped except for the slashes. The page name doesn't include the _.md_
extension.
_{{.Name}}_ is the page name. The page name doesn't include the _.md_ extension.
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Path}}_ is the page name, percent-encoded.
_{{.Dir}}_ is the page directory, percent-encoded.
_{{.Base}}_ is the basename of the current file (without the directory and
without the _.md_ extension), escaped for use in URLs.
without the _.md_ extension), percent-encoded.
_{{.Language}}_ is the suspected language of the page. This is used to set the
language on the _view.html_ template. See "Non-English hyphenation" below.
@@ -89,7 +110,7 @@ _{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
The list contains a directory name and an array of files.
_{{.Dir}}_ is the directory name that is being listed.
_{{.Dir}}_ is the directory name that is being listed, percent-encoded.
_{{.Files}}_ is the array of files. To refer to them, you need to use a _{{range
.Files}}_ … _{{end}}_ construct.
@@ -99,6 +120,8 @@ Each file has the following attributes:
_{{.Name}}_ is the filename. The ".md" suffix for Markdown files is part of the
name (unlike page names).
_{{.Path}}_ is the page name, percent-encoded.
_{{.Title}}_ is the page title, if the file in question is a Markdown file.
_{{.IsDir}}_ is a boolean used to indicate that this file is a directory.
@@ -113,8 +136,7 @@ _{{.Date}}_ is the last modification date of the file.
_{{.Query}}_ is the query string.
_{{.Dir}}_ is the directory in which the search starts, percent-escaped except
for the slashes.
_{{.Dir}}_ is the directory in which the search starts, percent-encoded.
_{{.Previous}}_, _{{.Page}}_ and _{{.Next}}_ are the previous, current and next
page number in the results since doing arithmetics in templates is hard. The
@@ -151,23 +173,18 @@ search term that matched.
## Upload
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL
path, percent-escaped except for the slashes.
path, percent-encoded.
_{{.Name}}_ is the _filename_ query parameter.
_{{.FileName}}_ is the _filename_ query parameter used to suggested a filename.
_{{.Last}}_ is the filename of the last file uploaded.
_{{.FilePath}}_ is the filename, percent-encoded.
_{{.Actual}}_ is an array of filenames of all the files uploaded. Use {{range
.Actual}} … {{.}} … {{end}} to loop over all the filenames.
_{{.Name}}_ is the _pagename_ query parameter used to indicate where to append
links to the files.
_{{.Base}}_ is the basename of the first file uploaded (without the directory,
extension and numeric part at the end), escaped for use in URLs.
_{{.Path}}_ is the page name, percent-encoded.
_{{.Title}}_ is the title of the basename, if it exists.
_{{.Image}}_ is a boolean to indicate whether the last file uploaded has a file
name indicating an image or not (such as ending in _.jpg_). If so, a thumbnail
can be shown by the template, for example.
_{{.Title}}_ is the title of the page, if it exists.
_{{.MaxWidth}}_ is the _maxwidth_ query parameter, i.e. the value used for the
previous image uploaded.
@@ -177,6 +194,22 @@ previous image uploaded.
_{{.Today}}_ is the current date, in ISO format.
_{{.Uploads}}_ an array of files already uploaded, based on the _uploads_ query
parameter. To refer to them, you need to use a _{{range .Uploads}}_ … _{{end}}_
construct. This is required because the _drop_ action redirects back to the
_upload_ action, so after saving one or more files, you can upload even more
files.
Each upload has the following attributes:
_{{.Name}}_ is the filename.
_{{.Path}}_ is the file name, percent-encoded.
_{{.Image}}_ is a boolean to indicate whether the upload is an image or not
(such as ending in _.jpg_). If so, a thumbnail can be shown by the template, for
example.
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -190,16 +223,16 @@ use a small number of languages or just a single language! you can set t
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt".
"view.html" is used the template to render a single page and so the language
detected is added to the "html" element.
"view.html" is used to render a single page and so the language detected is
added to the "html" element.
"search.html" is the template used to render search results and so "en" is used
for the "html" element and the language detected for every page in the search
result is added to the "article" element for each snippet.
"edit.html" and "add.html" are the templates used to edit a page and at that
point, the language isn't known, so "en" is used for the "html" element and no
language is used for the "textarea" element.
"edit.html" and "add.html" are the templates used to edit a page. If the page
already exists, its language is used for the "textarea" element. If the page is
new, no language is used for the "textarea" element.
# EXAMPLES

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

72
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,20 +148,53 @@ 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 some characters escaped because html/template doesn't escape those. This is suitable
// for use in HTML templates.
func (p *Page) Path() string {
return pathEncode(p.Name)
}
// pathEncode returns the page name with some characters escaped because html/template doesn't escape those. This is
// suitable for use in HTML templates.
func pathEncode(s string) string {
n := strings.Count(s, ";") + strings.Count(s, ",") + strings.Count(s, "?") + strings.Count(s, "#")
if n == 0 {
return s
}
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, percent-escaped except for the slashes. 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 ""
}
return d + "/"
return pathEncode(d) + "/"
}
// Base returns the basename of the page name: no directory. This is used to create the upload link in "view.html", for
// example.
// Base returns the basename of the page name: no directory, percent-escaped except for the slashes. 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

@@ -3,18 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Preview: {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
@@ -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

@@ -300,3 +300,9 @@ func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
Results: len(items) > 0, More: more}
renderTemplate(w, dir, "search", s)
}
// Path returns the ImageData.Name with some characters escaped because html/template doesn't escape those. This is
// suitable for use in HTML templates.
func (img *ImageData) Path() string {
return pathEncode(img.Name)
}

View File

@@ -3,24 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small; }
.image img { max-width: 100%; }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
@@ -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)
@@ -277,7 +277,7 @@ The silence streches.`)}
p.save()
data := url.Values{}
data.Set("q", "look")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/question/", data)
assert.Contains(t, body, "We <b>look</b>")
assert.NotContains(t, body, "Odd?")
assert.Contains(t, body, "Even?")

View File

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

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

@@ -18,13 +18,12 @@ Memories of cold
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil),
"Skip navigation")
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil), "Skip")
// save a new view handler
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
field, err := writer.CreateFormField("filename")
assert.NoError(t, err)
field.Write([]byte("view.html"))
file, err := writer.CreateFormFile("file", "test.html")
@@ -45,6 +44,5 @@ Memories of cold
assert.NotContains(t, body, "Skip")
// verify that the top level still uses the old template
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
"Skip navigation")
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil), "Skip")
}

View File

@@ -1,11 +1,17 @@
download:
rsync --archive --delete --itemize-changes --exclude='*-*' sibirocobombus:alexschroeder.ch/wiki/'*.html' alexschroeder.ch/
rsync --archive sibirocobombus:alexschroeder.ch/css/oddmu-2023.css alexschroeder.ch/oddmu.css
sed --in-place=~ --expression='s/\/css\/oddmu-2023\.css/oddmu.css/' alexschroeder.ch/*.html
rsync --archive --delete --itemize-changes sibirocobombus:flying-carpet.ch/wiki/'*.html' flying-carpet.ch/
rsync --archive --delete --itemize-changes sibirocobombus:campaignwiki.org/data/'*.html' campaignwiki.org/
rsync --archive --delete --itemize-changes sibirocobombus.root:/home/oddmu/'*.html' transjovian.org/
SHELL=/usr/bin/fish
# Manually figure out what needs to change:
# (ediff-directories "alexschroeder.ch" "/ssh:sibirocobombus:alexschroeder.ch/wiki/" "html$")
# (ediff-directories "flying-carpet.ch" "/ssh:sibirocobombus.root:/home/claudia/flying-carpet.ch/wiki/" "html$") + fix permissions
# (ediff-directories "campaignwiki.org" "/ssh:sibirocobombus:campaignwiki.org/data/" "html$")
# (ediff-directories "communitywiki.org" "/ssh:sibirocobombus:communitywiki.org/data/" "html$")
# (ediff-directories "transjovian.org" "/ssh:sibirocobombus.root:/home/oddmu/" "html$")
# (ediff-directories "communitywiki.org" ".." "html$")
# Upload the theme for the web site. This does not deploy the themes!
upload:
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' . sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
rsync --archive --delete --itemize-changes --exclude=Makefile --exclude='*~' \
. sibirocobombus:alexschroeder.ch/wiki/oddmu/themes/
@echo Updated the templates for the Oddmu site

View File

@@ -2,11 +2,6 @@
At the top there's a text input to quickly create new pages.
This theme comes with an external CSS file. If you plan to use
subdirectories for your site, you need to change the URL of the CSS in
the HTML templates to `/view/oddmu.css` or serve it as a static file
from a `/css` directory.
The CSS switches between light and dark mode based on the visitor's
setup.

View File

@@ -3,17 +3,54 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
@media (prefers-color-scheme: dark) {
body { color: #ffe; background-color: #110 }
pre { background-color: #333 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<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 id="editor" 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

@@ -3,20 +3,30 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
ins { background-color: #070 }
del { background-color: #f40 }
pre { color: #eeeee8; background-color: #555 }
}
</style>
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<pre class="diff">
<h1>{{.Title}}</h1>
<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>
</main>
</pre>
</body>
</html>

View File

@@ -3,19 +3,58 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<base href="/view/{{.Dir}}">
<title>Editing {{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto }
body { hyphens: auto; color: #111; background-color: #ffe; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100%; font-size: inherit }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
@media (prefers-color-scheme: dark) {
body { color: #ffe; background-color: #110 }
pre { background-color: #333 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<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 id="editor" 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

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form { width: 100% }
hr { border-bottom: 1px }
table { border-collapse: collapse }
th { font-weight: normal }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 20ch; overflow: hidden }
tr:nth-child(odd) { background-color: #eed }
td:first-child, td:last-child { white-space: nowrap }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #111 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, button { color: #eeeee8; background-color: #555 }
tr:nth-child(odd) { background-color: #333 }
mark { color: #111; background-color: #ffa; padding: 4px; border-radius: 4px }
}
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Path}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Path}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

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

View File

@@ -3,13 +3,32 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8 }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small }
.image img { max-width: 100% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out }
img:hover { opacity: 1 }
}
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
@@ -27,9 +46,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

@@ -3,17 +3,27 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
footer { border-top: 1px solid #666 }
.right img { border: 1px solid #111 }
.left img { border: 1px solid #111 }
pre { background-color: #333 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out }
img:hover { opacity: 1 }
}
</style>
</head>
<body>

View File

@@ -3,9 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<link href="oddmu.css" rel="stylesheet" />
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.upload { max-width: 20% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<script type="text/javascript">
var uploadFiles = {
init: function() {
@@ -41,7 +55,7 @@ var uploadFiles = {
post: function(files) {
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("filename", document.getElementById('filename').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
fd.append("quality", document.getElementById('quality').value);
for (var i = 0; i < files.length; i++) {
@@ -65,39 +79,42 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{end}}
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.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.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<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">
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>To delete a file, upload an empty file.
If youre 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>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -3,20 +3,44 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<link href="oddmu.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
label { display: inline-block; width: 10ch; margin: 4px 0 }
input[type=text] { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100% }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #110 }
footer { border-top: 1px solid #666 }
.right img { border: 1px solid #111 }
.left img { border: 1px solid #111 }
pre { background-color: #333 }
button { background-color: #eee; color: inherit }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
img { opacity: .75; transition: opacity .5s ease-in-out }
img:hover { opacity: 1 }
input, input[type="text"], textarea, button { color: #eeeee8; background-color: #555 }
}
</style>
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Path}}.rss" />
</head>
<body>
<header id="view">
<header>
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/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&pagename={{.Base}}" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>

View File

@@ -3,20 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed }
form, textarea { width: 100% }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<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

@@ -3,11 +3,11 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
@@ -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

@@ -3,22 +3,49 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 1ch; height: calc(100% - 2ch); margin: auto; color: #111; background-color: #eed }
body { hyphens: auto; margin: 0; padding: 0; height: 100%; display: flex; flex-flow: column }
form, textarea { box-sizing: border-box; width: 100% }
#editor { flex: 1 1 auto; display: flex; flex-flow: column }
textarea { flex: 1 1 auto }
</style>
<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 id="editor" 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="" 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>
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

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

@@ -3,23 +3,23 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 20ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
img { max-width: 20% }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.score { font-size: smaller; opacity: 0.8 }
</style>
</head>
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
@@ -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

@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
header a { margin-right: 1ch }
form { display: inline-block }
input#search { width: 12ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed; }
body { hyphens: auto; }
form, textarea { width: 100%; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
form, textarea { width: 100% }
label { display: inline-block; width: 20ch }
input [type=text] { width: 30ch }
.last { max-width: 20% }
@@ -16,7 +16,7 @@ input [type=text] { width: 30ch }
<script type="text/javascript">
var uploadFiles = {
init: function() {
let e = document.getElementById('form');
let e = document.getElementById('upload');
if (e) {
e.addEventListener('paste', uploadFiles.pasteHandler);
e.addEventListener('dragover', e => e.preventDefault());
@@ -46,7 +46,7 @@ var uploadFiles = {
uploadFiles.post(files)
},
post: function(files) {
let action = document.getElementById('form').getAttribute('action');
let action = document.getElementById('upload').getAttribute('action');
var fd = new FormData();
fd.append("name", document.getElementById('name').value);
fd.append("maxwidth", document.getElementById('maxwidth').value);
@@ -72,39 +72,42 @@ window.addEventListener('load', uploadFiles.init);
</head>
<body lang="en">
<h1>Upload Files</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"><br>
<tt>Link: ![]({{.Last}})</tt>
{{else}}
<p>Link: <tt>[text]({{.Last}})</tt>
{{if .Uploads}}
<p>Previous uploads:
<p>{{range .Uploads}}
{{if .Image}}<img class="upload" src="/view/{{$.Dir}}{{.Path}}">{{else}}<a class="upload" href="/view/{{$.Dir}}{{.Path}}">{{end}}{{end}}
<form id="add" action="/append/{{.Dir}}{{.Path}}" method="POST">
<input type="hidden" name="body" value="{{range .Uploads}}{{if .Image}}!{{end}}[{{.Name}}]({{.Path}})
{{end}}">
<input type="hidden" name="pagename" value="{{.Name}}">
<p>Append it to <a href="/view/{{.Dir}}{{.Path}}">{{.Title}}</a>?
<input type="submit" value="Add">
</form>
{{end}}
{{end}}
<form id="form" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<form id="upload" action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading a picture from a phone, its filename is going to be something like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension if you want to resize the picture.
<p><label for="name">Filename:</label>
<input id="name" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
Use <tt>.jpg</tt>, <tt>.png</tt> or <tt>.webp</tt> as the extension if you want to resize the picture.
<p><label for="filename">Filename:</label>
<input id="filename" name="filename" value="{{.FileName}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.webp</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<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">
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If you're uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>To delete a file, upload an empty file.
<p><label for="file">Pick files to upload:</label>
If youre 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>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a>
<a href="/view/{{.Dir}}{{.Path}}"><button type="button">Cancel</button></a>
<p>You can also paste images or drag and drop files.
</form>
</body>

View File

@@ -3,29 +3,30 @@
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<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; }
header a { margin-right: 1ch; }
label { display: inline-block; width: 10ch; }
input#search, input#id { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #eed }
body { hyphens: auto }
header a { margin-right: 1ch }
h1 { text-wrap: balance }
label { display: inline-block; width: 10ch }
input#search, input#id { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
img { max-width: 100% }
</style>
</head>
<body>
<header id="view">
<header>
<a href="#main">Skip</a>
<a href="index">Home</a>
<a href="changes">Changes</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/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&pagename={{.Base}}" accesskey="u">Upload</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="u">Zip</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
@@ -38,7 +39,7 @@ img { max-width: 100%; }
<button>Edit</button>
</form>
</header>
<main id="main">
<main>
<h1>{{.Title}}</h1>
{{.Html}}
</main>

View File

@@ -6,16 +6,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>{{.Title}}</title>
<style>
html { font-family: sans-serif; font-size: large; max-width: 50ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9; }
body { hyphens: auto; font-size: large; }
header a { margin-right: 1ch; }
label { width: 7ch; display: inline-block; }
#search { width: 30ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
html { font-family: sans-serif; font-size: large; max-width: 50ch; padding: 1ch; margin: auto; color: #111; background-color: #f9f9f9 }
body { hyphens: auto; font-size: large }
header a { margin-right: 1ch }
label { width: 7ch; display: inline-block }
#search { width: 30ch }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px }
textarea { width: 97%; margin-top: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;
border-radius: 6px; border: 1px outset #eee; }
p { margin: 0.5ch 0 0 0; }
#send { float: right; font-size: large; }
border-radius: 6px; border: 1px outset #eee }
p { margin: 0.5ch 0 0 0 }
#send { float: right; font-size: large }
</style>
</head>
<body>
@@ -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)

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