413 Commits

Author SHA1 Message Date
0696603f69 Run test on Linux 2026-04-03 20:29:44 +09:00
987d859431 Run test on OpenBSD 2026-04-03 20:22:39 +09:00
36d40dff50 Make buildable OpenBSD build on Linux 2026-04-03 17:54:27 +09:00
10d055c117 Restructure OpenBSD build 2026-04-03 17:49:38 +09:00
01dad0b932 Use conditional compile for OpenBSD 2026-04-02 15:53:21 +09:00
f5be60d026 Merge branch 'main' into openbsd 2026-03-31 15:16:45 +09:00
Alex Schroeder
f0d814b8f3 Document that webp images can be encoded 2026-02-22 23:16:43 +01:00
Alex Schroeder
41f6d9e48e Run go mod tidy 2026-02-20 13:38:12 +01:00
Alex Schroeder
7beaf3e375 Mention the man subcommand in the README 2026-02-20 13:37:38 +01:00
Alex Schroeder
8f277714d7 Note how to install into /usr/local 2026-02-12 20:41:54 +01:00
Alex Schroeder
11eace1b3c Update license copyright
Add the check to the RELEASE file. Add "generator" with link to the
sources to view, feed and static templates.
2026-02-12 13:30:39 +01:00
Alex Schroeder
ad778a3068 Embed the man pages 2026-02-11 16:52:30 +01:00
Alex Schroeder
6ec69d27eb Note that the templates are written when missing 2026-02-10 12:55:18 +01:00
Alex Schroeder
0a093182e9 Embed the default templates
If any templates are missing when templates are initialized, an
embedded version is written into the working directory.
2026-02-10 12:46:43 +01:00
Alex Schroeder
9952f2363b Note where to get golint, go-critic and goimports from 2026-02-10 12:45:35 +01:00
Alex Schroeder
4d14517668 Add comments to exported types 2026-02-10 12:40:11 +01:00
Alex Schroeder
0051a5ca66 Add embedded templates 2026-02-10 00:37:32 +01:00
Alex Schroeder
2436cc1114 Improve man oddmu 2026-02-08 16:25:13 +01:00
Alex Schroeder
96993b794a Upgrade from Go 1.22 to Go 1.25 2026-02-06 16:14:43 +01:00
Alex Schroeder
991260b78c Release 1.20 2026-02-06 15:57:16 +01:00
Alex Schroeder
751b9fe63d Switch -shrink quality to 30% instead of 10%
At 30% the images seem to be a lot let worse than 10% and are only
twice as big.
2026-02-06 10:58:12 +01:00
Alex Schroeder
34afc151a4 Explain how to use real usernames and passwords 2026-01-29 14:00:31 +01:00
Alex Schroeder
efbea969fa Typo fix 2026-01-29 13:50:24 +01:00
Alex Schroeder
e12ab8594c Add dist-upload target to my server 2026-01-14 18:38:54 +01:00
Alex Schroeder
233b9817b5 Add sitemap command and handler 2026-01-03 18:09:31 +01:00
Alex Schroeder
ecfee31cbd Fixed golint and gocritic issues 2026-01-02 22:36:43 +01:00
Alex Schroeder
e86de1beb8 Remove list.html from the templates 2026-01-02 22:31:44 +01:00
Alex Schroeder
e9666a5ec5 Fixed missing command
Outside of the test it always got an empty index and therefore never
found any missing pages.
2026-01-02 14:33:42 +01:00
Alex Schroeder
f71a0e9780 Feeds page through yearly archives, too 2026-01-01 17:44:44 +01:00
Alex Schroeder
aca1d82fe0 Feed subcommand uses page name to determine date
The pubDate of the items in an archive feed should not be based on the
last modified time of the file because mass edits are irrelevant in
this context. The intended creation time is more important.
2025-12-31 20:28:41 +01:00
Alex Schroeder
290ad16e09 Change {{.Html}} to {{.HTML}} in the themes folder
Add note to the release page.
2025-12-06 14:24:19 +01:00
Alex Schroeder
ad1732f57f Fix all gocritic and golint issues 2025-12-06 14:10:20 +01:00
Alex Schroeder
67120af7cc Run goimports and rearrange imports 2025-12-06 13:38:46 +01:00
Alex Schroeder
c186253a25 Add check and fix targets to Makefile 2025-12-06 13:35:34 +01:00
Alex Schroeder
d0d4545f74 Ignore some errors when generating a static site
When using the static subcommand:

- ignore existing directory;
- remove existing files before creating a link;
- report any remaining errors.
2025-12-06 13:35:31 +01:00
Alex Schroeder
c32f087af4 Add shrink option to static command 2025-12-05 14:22:21 +01:00
Alex Schroeder
f657ac60a3 Extract MIME type detection by extension 2025-12-04 23:33:46 +01:00
Alex Schroeder
16ae6cc143 Don't load the index for notifyCli
It's not required for the command-line and improves execution time.
2025-11-08 23:33:33 +01:00
19a1ea6efe Adjust to build on OpenBSD
Dropped support for HEIC and WebP.
2025-11-03 21:52:03 +09:00
Alex Schroeder
e041c5ecae Fiddle with the wording of the README 2025-09-29 16:53:25 +02:00
Alex Schroeder
3078d63890 Release v1.19 2025-09-28 19:38:52 +02:00
Alex Schroeder
143ecb8a0a Update release info 2025-09-26 00:34:37 +02:00
Alex Schroeder
d66aa03a2d Document missing attributes of the feed template 2025-09-24 08:30:44 +02:00
Alex Schroeder
64954ddf5d Use & instead of & in the feed test 2025-09-24 08:30:21 +02:00
Alex Schroeder
a1d6ebfdff Fix ampersand quoting in feed.html 2025-09-22 23:15:42 +02:00
Alex Schroeder
db3a3f5009 Remove the .git suffix from the module
This counters the change in 2092b57. The .git suffix is technically
correct for the git repository but results in a binary called
"oddmu.git" which is incorrect.
2025-09-22 20:22:07 +02:00
Alex Schroeder
ece9649e3d Test for complete feeds 2025-09-22 13:44:58 +02:00
Alex Schroeder
23074cdd58 Video width 100% in CSS 2025-09-22 13:34:59 +02:00
Alex Schroeder
06c07209a2 Add test for feed command 2025-09-21 23:24:44 +02:00
Alex Schroeder
7b2a835729 Add feed pagination 2025-09-21 23:24:24 +02:00
Alex Schroeder
d0fe534f8e Explicitly gzip the .tar.gz file 2025-09-21 22:18:33 +02:00
Alex Schroeder
ac7de17a87 Use better globbing in the Makefile 2025-09-11 22:12:51 +02:00
Alex Schroeder
84e6a757b2 Upload the README, too 2025-09-11 22:12:40 +02:00
Alex Schroeder
2dfb2afbf5 Add a paragraph about "go install" 2025-09-11 22:11:48 +02:00
Alex Schroeder
2092b5777c Switch module identifier
From alexschroeder.ch/cgit/oddmu to src.alexschroeder.ch/oddmu.git
2025-09-02 23:00:36 +02:00
Alex Schroeder
f635cb738a go fmt 2025-09-01 17:40:17 +02:00
Alex Schroeder
da398a3315 Fix feed title and add test 2025-08-31 22:08:43 +02:00
Alex Schroeder
7315abd5bb Check for zero page names 2025-08-31 21:56:24 +02:00
Alex Schroeder
b39901b244 More man page fiddling 2025-08-31 21:47:23 +02:00
Alex Schroeder
bb4843c2f4 Updated oddmu-releases(7) 2025-08-31 16:29:35 +02:00
Alex Schroeder
816c981200 More cross-linking on the man pages 2025-08-31 16:28:22 +02:00
Alex Schroeder
89d550a1a4 Add feed subcommand 2025-08-31 13:13:44 +02:00
Alex Schroeder
4eb013a4da Remove link to list from upload.html 2025-08-10 08:59:19 +02:00
Alex Schroeder
e8f6ae0450 Release 1.18 2025-08-10 08:47:04 +02:00
Alex Schroeder
9bf3beb440 Add the new feature to the release docs 2025-08-09 18:21:20 +02:00
Alex Schroeder
cd6809d791 Add -update and -dry-run options to hashtags command 2025-08-09 18:18:11 +02:00
Alex Schroeder
7c5a3860e7 Fix addLinkToPage
It now tries to keep the links sorted.
2025-08-09 18:17:56 +02:00
Alex Schroeder
a7c343decb Be more restrictive about the request methods 2025-08-08 17:16:41 +02:00
Alex Schroeder
18bb5da8c0 Preview requests using GET redirect to view requests 2025-08-08 00:58:50 +02:00
Alex Schroeder
2a0ea791ec Removed unnecessary option description 2025-08-07 22:12:11 +02:00
Alex Schroeder
726586b39d Updated README 2025-08-07 07:11:54 +02:00
Alex Schroeder
8f30704be9 Improve WebDAV documentation 2025-07-16 16:24:36 +02:00
Alex Schroeder
616ae0a1ba Mention how to sudo make install PREFIX= 2025-07-16 11:20:04 +02:00
Alex Schroeder
af86b865bf Delete list, delete and rename actions
In an effort to remove features that can be handled by the web server, the
list, delete and rename actions were removed again.

The sentence linking to the list action from the upload
template ("upload.html") was also deleted.
2025-07-16 11:03:40 +02:00
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
Alex Schroeder
171910ff4f Fix release notes for 0.14 2024-11-15 19:26:45 +01:00
Alex Schroeder
5fb0f57b5c Add base element to edit template 2024-11-15 19:22:56 +01:00
Alex Schroeder
d712b132cc Add base element to preview template
Without this, links in the preview cannot be followed as they all
point to /preview/ instead of /view/.
2024-11-09 23:13:50 +01:00
Alex Schroeder
199c236c08 Use parser.EscapeChars
With github.com/gomarkdown/markdown commit 7e0a027d98c5 escapeChars is
now exported as EscapeChars and therefore the whole wrapper function
is no longer necessary.
2024-10-05 14:13:12 +02:00
Alex Schroeder
0e7f7a2c05 Add quotes 2024-09-30 08:44:02 +02:00
Alex Schroeder
4af15b48db Small documentation improvements 2024-09-30 08:42:14 +02:00
Alex Schroeder
9b6c54ccb4 Allow escaping of @ 2024-09-30 08:35:02 +02:00
Alex Schroeder
83f447b643 Move mod_dav documentation to new man page 2024-09-26 01:10:43 +02:00
Alex Schroeder
d5e37fa90a Document WebDAV setup 2024-09-26 01:08:26 +02:00
Alex Schroeder
609da1fbc2 Improve the man tests
Add a check for template documentation. Add checks to prevent tests
from passing when nothing was found.
2024-08-30 17:52:59 +02:00
Alex Schroeder
ba32e0dcce Document the list template 2024-08-30 17:34:11 +02:00
Alex Schroeder
e975c527d1 Add test for all actions in the man page
Add the /list/, /delete/ and /rename/ actions to the man page.
2024-08-30 14:03:32 +02:00
Alex Schroeder
656b9490a1 Fix delete test 2024-08-30 14:03:12 +02:00
Alex Schroeder
9bd7ca59fa Add rename action 2024-08-29 14:46:10 +02:00
Alex Schroeder
56f95553d6 Update man pages 2024-08-29 13:10:06 +02:00
Alex Schroeder
76e63278d6 Add subcommands to synopsis of oddmu(1) man page
Add more subcommands to the Options section, too.
2024-08-29 13:09:25 +02:00
Alex Schroeder
1e957b5411 Use EXAMPLES instead of EXAMPLE for man pages 2024-08-29 13:08:24 +02:00
Alex Schroeder
e666fb44cb Add list and delete actions 2024-08-29 10:44:42 +02:00
Alex Schroeder
754bf11516 Fix test due to themes/plain/README
That file has no Markdown title as recognized by Oddmu and therefore
its title is "README" (equal to the page name).
2024-08-27 19:41:35 +02:00
Alex Schroeder
7eeb81fa94 README reword stow example 2024-08-27 18:30:50 +02:00
Alex Schroeder
9c70935362 More work on the plain text theme 2024-08-24 23:39:21 +02:00
Alex Schroeder
9d65c01bb0 Improve description of transjovian.org theme 2024-08-24 23:20:27 +02:00
Alex Schroeder
0179d393dd Fix the scdoc-to-markdown script
When in a literal block, be more restrictive about what gets changed
before printing.
2024-08-24 23:07:48 +02:00
Alex Schroeder
f8b97f794b Add plain text theme 2024-08-24 22:49:10 +02:00
Alex Schroeder
b801f83fe0 Fix dist target 2024-08-24 22:49:02 +02:00
Alex Schroeder
486c3f8620 Release 1.13 2024-08-24 14:16:03 +02:00
Alex Schroeder
5b0fcdd69f Update Makefiles
Help is more compact.
docs/Makefile comes with help.
Fixed make install.
Added clean target.
Use long options for tar and rm.
2024-08-24 11:52:19 +02:00
Alex Schroeder
bb99d05a0d Add more cross-compilation targets
- oddmu-darwin-amd64.tar.gz
- oddmu-windows-amd64.tar.gz
- oddmu-linux-arm64.tar.gz
2024-08-24 11:38:04 +02:00
Alex Schroeder
98358a008b Switched HEIC decoder again
This time I'm using github.com/gen2brain/heic. "Based on libheif and
libde265 compiled to WASM and used with wazero runtime (CGo-free)."
Amazing.
2024-08-23 17:37:54 +02:00
Alex Schroeder
51c8348ef7 Switched HEIC decoder fork
Moving from github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
to github.com/adrium/goheif v0.0.0-20230113233934-ca402e77a786.

There is no cross-compiling available. If only somebody wrote a HEIC
decoder in pure Go.
2024-08-23 17:24:23 +02:00
Alex Schroeder
5e77f1332e Tiny typo in the README 2024-08-23 12:55:15 +02:00
Alex Schroeder
fbbb4a543f Split a section in oddmuse.service
Moved the part about the privileged port 80 from the "A personal wiki"
section in oddmuse.service(5) into its own section.
2024-08-23 12:44:45 +02:00
Alex Schroeder
ccc7c0bc8f Run go fmt 2024-08-22 08:05:58 +02:00
Alex Schroeder
aae2ae1265 The "html" subcommand takes - for stdin 2024-08-21 18:05:09 +02:00
Alex Schroeder
8929d72acd Fix export subcommand by actually loading the index
Without this fix, only the tests load the index.
2024-08-21 18:04:28 +02:00
Alex Schroeder
014507ce4e Move sed expressions to Perl script
This simplifies the Makefile inside the man directory.
2024-08-18 00:26:15 +02:00
Alex Schroeder
554a929bf5 Document use of ODDMU_LANGUAGES for export 2024-08-16 18:37:26 +02:00
Alex Schroeder
5f8e006594 Fixing export and missing subcommands
Both of these subcommands rely on the global index. Loading this
global index and acting upon it while tests are running is
problematic, however. Walking that index results in being unable to
load files that are being created and deleted in other tests.

Therefore, these tests get their own minimal index that only indexes
the Markdown files in the original checkout: README.md, index.md, and
the Markdown files in the the "themes" tree.

This also means that missingCli and exportCli must accept a pointer to
an index and use that instead of the global index.

Add a test for language in the export feed.

Add a test for a JSON feed.

Both of these tests used to fail because the exportCli funtion did not
copy p.Body to it.Body so when the template calls .Languages the
result was always empty.
2024-08-16 18:27:34 +02:00
Alex Schroeder
e347a59603 Fix documentation of renderHtml
Page.Language hasn't been a member of the Page type for a long time.
2024-08-16 18:24:54 +02:00
Alex Schroeder
964dc3bf4a Ensure cleanup of testdata before running tests 2024-08-16 18:24:28 +02:00
Alex Schroeder
d5f8b280ac Support for JSON exports 2024-08-16 13:41:21 +02:00
Alex Schroeder
8ee5705ae7 Wording for man-pages
Remove "from the command-line" in a few places.
2024-08-16 12:33:11 +02:00
Alex Schroeder
43bf1574c9 Add export subcommand 2024-08-16 12:32:58 +02:00
Alex Schroeder
1c8af9fcdb missing subcommand no longer walks the file-system
Instead, rely on the code that loads the index. Delete the
existingPages function.
2024-08-16 11:23:34 +02:00
Alex Schroeder
f6fa76bd5f Update alexschroeder.ch theme 2024-08-16 11:13:31 +02:00
Alex Schroeder
111c617556 Ready to release 1.12 2024-08-15 14:52:58 +02:00
Alex Schroeder
66fe28062d Update oddmu-releases man page 2024-08-15 14:51:45 +02:00
Alex Schroeder
7e03b67267 Add "toc" subcommand 2024-08-15 14:49:39 +02:00
Alex Schroeder
11343067af links subcommand accepts "-" as input file 2024-08-15 14:46:37 +02:00
Alex Schroeder
a0ff3ed03c Fix the help synopsis
Start lowercase, no period.
2024-08-15 12:32:12 +02:00
Alex Schroeder
ccead37f44 Add links subcommand
The code for missing links was improved. The links function was fixed
so that it only joined page directory and link destination for
relative URLs.
2024-08-12 08:20:54 +02:00
Alex Schroeder
a8b4ec9acd Link to man pages on the web
Don't link to blobs in the repo.
2024-08-12 07:16:09 +02:00
Alex Schroeder
2531a469bf Update man pages, specially the HTML copy
Unlink URLs and links in double-quotes.
2024-07-31 16:43:35 +02:00
Alex Schroeder
51808bc1fb Hashtag ranking starts with 1, not 0 2024-07-31 16:17:21 +02:00
Alex Schroeder
2375dad845 Run go fmt 2024-07-31 11:40:57 +02:00
Alex Schroeder
0ca53690d8 Updated to the newest gomarkdown/markdown
This is necessary for toplevel SVG elements.
2024-07-31 11:39:50 +02:00
Alex Schroeder
a0c7517e8a Make test for filewatching more rebust
Wait for 10ms instead of just 1ms occasionally the tests would fail
because of this.
2024-07-31 11:37:43 +02:00
Alex Schroeder
912b6baad0 Do not use Chdir
Using os.Chdir in the tests confuses the test process. This means
rewriting staticCli so that it accepts an input directory. When called
from the command-line, that input directory is always the current
working directory. For the tests, however, that is not necessarily
true.
2024-07-31 11:37:18 +02:00
Alex Schroeder
b6c068c72f Update oddmu-releases man page 2024-07-31 11:30:15 +02:00
Alex Schroeder
89ef292736 New command: hashtags 2024-07-31 10:25:58 +02:00
Alex Schroeder
c658de5a6f Add searching for multi-word phrases 2024-07-30 14:02:16 +02:00
Alex Schroeder
4bab25e2ac Explain how to run a private wiki on port 80 2024-07-24 15:22:18 +02:00
Alex Schroeder
c518a193d0 Create new image list for every page
With out this, any page listed after one with images continues to show
the same images.
2024-07-23 13:13:29 +02:00
Alex Schroeder
2dc950cb5e Add loading="lazy" for images in search.html 2024-07-21 16:59:25 +02:00
Alex Schroeder
87d1e72f0f Remove unnecessary "last" class for search.html 2024-07-21 16:55:29 +02:00
Alex Schroeder
44213e1d43 Add the ability to search for image alt text
Page no longer has Score.

Search contains an array of Result.

Result is like Page plus Score and an array of image data.

Image data is collected during startup just as page titles are.

The search.html template has a section listing files with matching
alt-text.
2024-07-21 16:46:21 +02:00
Alex Schroeder
ae9698aae3 Rename internal variable from buff to buf 2024-07-21 14:11:40 +02:00
Alex Schroeder
24871eee99 Index image data 2024-07-21 12:43:34 +02:00
Alex Schroeder
5f44853bab The tokenizer respects backslashes
That is, #tag is a hashtag, \#tag is not.
2024-07-21 12:43:34 +02:00
Alex Schroeder
f0a3d2c5a0 List styling for chat theme 2024-07-21 12:24:54 +02:00
Alex Schroeder
b8f916b7c9 Add new wiki to upload target 2024-07-21 12:24:18 +02:00
Alex Schroeder
db8a060d65 Fix SmartypantsFractions bit code 2024-05-12 14:12:27 +02:00
Alex Schroeder
9d216f37ee Remove "smart fractions" from the HTML renderer 2024-05-12 14:02:55 +02:00
Alex Schroeder
39c2fe6dfd The nginx man page talks about Unix-domain sockets, too 2024-05-11 14:43:57 +02:00
Alex Schroeder
3151fe63fa Prevent hashtags for just the hash 2024-05-11 14:38:04 +02:00
Alex Schroeder
abd3ceae2e Add preview.go to the README
This fixes a failing test.
2024-05-11 14:25:37 +02:00
Alex Schroeder
edad64e76c Document preview.html 2024-05-09 21:26:07 +02:00
Alex Schroeder
71315bc662 Split previewHandler from view.go to preview.go 2024-05-09 21:13:05 +02:00
Alex Schroeder
27509bcdd4 Ready for next release 2024-05-09 16:34:14 +02:00
Alex Schroeder
04e8cb3ee8 Prepare for release 2024-05-09 16:31:02 +02:00
Alex Schroeder
2be4fe503d Add preview action 2024-05-09 16:27:45 +02:00
Alex Schroeder
7a405b22b8 Fix markup in nginx man page 2024-05-09 10:40:38 +02:00
Alex Schroeder
3f6fce7165 Switch MemoryHigh and MemoryMax 2024-05-09 10:40:05 +02:00
Alex Schroeder
721d5907d8 wikiLink doesn't need the parser argument 2024-05-09 10:39:41 +02:00
Alex Schroeder
cb0dbedaed Update the chat theme
Support blockquotes as coming from other people.
2024-05-09 10:39:05 +02:00
Alex Schroeder
3ba967781e Add Unix domain socket setup for nginx 2024-04-21 12:43:29 +02:00
Alex Schroeder
e985707b51 Replace ' with ’ in the upload template 2024-04-21 12:42:22 +02:00
Alex Schroeder
a5e7dca7d8 Fixed tests for the "actual" parameter 2024-04-02 14:08:58 +02:00
Alex Schroeder
74387910d8 Append links to file uploads, directly
This adds two new functions for the upload template, Base and Title.
The upload template uses these to offer to append files just uploaded
to the suspected page they belong to.
2024-04-02 13:58:22 +02:00
Alex Schroeder
121408d6d9 Report all the links of uploaded images 2024-03-24 22:37:10 +01:00
Alex Schroeder
1b7419466a Fix regexp for file uploads
Without this fix, only the last digit gets incremented and the
filenames used is 1 .. 9, 10, 11 .. 19, 110, 111 .. 119, 1110, …
2024-03-24 22:11:38 +01:00
Alex Schroeder
8a513746d5 Links to parent pages 2024-03-17 14:17:33 +01:00
Alex Schroeder
e736802da5 Homepage theme uses the new template features
Use {{.Language}} for add.html and edit.html so that the edit area has
the language of the original text, if any.

Use {{.IsBlog}} to only add the date to an empty textarea for add.html
if the original page is a blog page.
2024-03-15 09:43:50 +01:00
Alex Schroeder
380692d616 Replace the .Language property with p.Language()
This computes it on demand for the templates and it is not used
anywhere else.

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

Consequently, only the types that are used to write templates are
exported! For those types, the documentation has been improved (mostly
for Feed and Item).

In order to avoid nameclashes when hiding the types that didn't need
exporting, new naming schema was used. As these types were contains
for maps plus a mutex, they are called "stores" controlling access to
the maps: Accounts → accountStore, Index → indexStore, Template →
templateStore, Watches → watchStore.
2024-02-24 17:56:02 +01:00
Alex Schroeder
fe9a621f1e Document HTTP handlers in the README
Change the heading from Source to Hacking because I suspect the README
is were the notes on the source code go. The reason I don't want to
put them in code comments is because almost none of the Oddmu symbols
are exported.
2024-02-24 16:58:02 +01:00
Alex Schroeder
be663eed32 Makefile improvements
Introduce a PREFIX variable for the install target so one can override
this. Document this in the README.

Add rules for a build target and for a binary target that depends on
all the Go files so that these can be used as prerequisites for other
targets. The goal is to avoid unnecessary recompilations.

Designate all the non-file targets as phony targets.
2024-02-24 16:54:31 +01:00
Alex Schroeder
86ef305e9c Fix oddmu-list man page format
The optional string provided on the command line is set in italics.
2024-02-24 16:52:46 +01:00
Alex Schroeder
1fd97ae717 Document -full for the version subcommand 2024-02-23 12:32:27 +01:00
Alex Schroeder
d0fdf8c3c6 Highlight template name in the man page 2024-02-23 12:32:27 +01:00
Alex Schroeder
1786050e72 Reorganize the list of man page links
Use two groups, one for the web server and one for command-line use.
2024-02-23 12:32:27 +01:00
Alex Schroeder
f12252e148 Add missing source file to README
Add a test for that, too.
2024-02-23 00:28:41 +01:00
Alex Schroeder
f5f997261e Add missing man page link to the README
Add a test for that!
2024-02-23 00:23:52 +01:00
Alex Schroeder
43408707c5 Add package documentation 2024-02-23 00:09:07 +01:00
Alex Schroeder
50ce79d60d Update RELEASE instructions 2024-02-21 07:32:41 +01:00
Alex Schroeder
745500f09f Describe features of release 1.7 2024-02-20 21:54:14 +01:00
Alex Schroeder
f02491dda0 Add -full to version command 2024-02-20 21:47:59 +01:00
Alex Schroeder
0001583044 Go fmt 2024-02-20 21:47:50 +01:00
Alex Schroeder
4a5b7d52cd Use exiffix library to fix image orientation
If you upload an unedited image from an iPhone, the EXIF data
indicates how it should be oriented. Oddmu used to resize images strip
the EXIF data, resulting in resized images that were not oriented
correctly. If the EXIF data is stripped, the image has to be rotated.
In order to do this, the exiffix single-function library is used to
work around "image/jpeg: correct for EXIF orientation? #4341", opened
in 2012. See https://github.com/golang/go/issues/4341 for more
information and a link to https://github.com/edwvee/exiffix.
2024-02-20 21:45:51 +01:00
Alex Schroeder
c65f3ea386 Switch image manipulation library
Switch from github.com/anthonynsimon/bild/imgio
to github.com/disintegration/imaging. The goal is to get some sort of
auto-orientation going.

Image resizing now uses Lanczos instead of Linear.
2024-02-20 20:13:46 +01:00
Alex Schroeder
d2adffed6e Test HEIC upload
The bashdrew/goheif library is now used implicity and image decoding
figures out how to do it on its own.

The bashdrew/goheif only does decoding, not encoding, and so the HEIC
testfile is base64 encoded.
2024-02-20 13:09:22 +01:00
Alex Schroeder
d8e1d79127 Wording changes for the upload.html template 2024-02-19 22:44:33 +01:00
Alex Schroeder
d839219f96 Upload multiple files in one go
This requires an update to the upload.html template.
2024-02-19 22:27:56 +01:00
Alex Schroeder
103d1f4609 Add oddmu-nginx man page 2024-02-19 17:38:22 +01:00
Alex Schroeder
28a63e7479 Return 404 for feed requests of non-existing pages
Otherwise, there's a panic if requesting the RSS feed of a
non-existing page. This was caused by a recent rewrite of the
viewHandler.
2024-02-19 11:10:21 +01:00
Alex Schroeder
3cfe2d71b4 Add oddmu-releases(7) 2024-02-17 23:40:37 +01:00
Alex Schroeder
30b0b64129 Test whether the main man page links to all the others 2024-02-17 23:40:22 +01:00
Alex Schroeder
b352930651 Regenerate man pages using scdoc 1.11.3 2024-02-17 20:50:33 +01:00
Alex Schroeder
9e5f83d36e Typo 2024-02-16 23:44:31 +01:00
Alex Schroeder
2b298d84c3 Filter processing is now a bit simpler
Since prefixes always have to match, the boolean expression can be
simplified and tests still pass.
2024-02-16 23:37:55 +01:00
Alex Schroeder
ebd7b920ca More documentation about ODDMU_FILTER 2024-02-16 22:39:09 +01:00
Alex Schroeder
9f97ed8d04 Test archive 2024-02-16 13:14:48 +01:00
Alex Schroeder
4837d6477c Markup changes for better HTML output 2024-02-16 00:51:01 +01:00
Alex Schroeder
9eb9a977f1 Document templates and file upload 2024-02-16 00:34:01 +01:00
Alex Schroeder
ed05d028e8 More notes in the README about filepath 2024-02-15 10:10:36 +01:00
Alex Schroeder
0a0aa59f7e Better markup of the README 2024-02-15 08:54:01 +01:00
Alex Schroeder
73fb124c74 Remove the .md files in the man directory
Those break the tests… Sigh!
2024-02-15 08:53:42 +01:00
Alex Schroeder
e454b02011 Document a reduced setup with only /view/ action 2024-02-14 23:52:28 +01:00
Alex Schroeder
ffc4a515fc Move actions to the main man page 2024-02-14 23:44:02 +01:00
Alex Schroeder
a26c7d046b Document actions for templates 2024-02-14 23:36:39 +01:00
Alex Schroeder
3e68ecd388 List archive action for templates 2024-02-14 23:35:34 +01:00
Alex Schroeder
1abde4c884 Capitalisation of Oddmu in the man pages 2024-02-14 23:35:17 +01:00
Alex Schroeder
24e950931b Archive handler 2024-02-14 14:23:02 +01:00
Alex Schroeder
a08df076fa Add ODDMU_FILTER environment variable
This is used to hide secret subdirectories from search since search
includes subdirectories.
2024-02-13 23:17:22 +01:00
Alex Schroeder
a5a7549bec Add version command
As suggested by Xavier B.
2024-02-13 15:32:07 +01:00
Alex Schroeder
883f3fee47 Add one more system to the Makefile 2024-02-13 15:06:10 +01:00
Alex Schroeder
6a4d1e5ca9 Better distinction between path and filepath
Make sure to serve the page foo if both foo.md and the directory foo/
exist.
2024-02-13 15:06:01 +01:00
Alex Schroeder
8698c64dda Don't log missing pages in the index
Only existing pages are in the index. Therefore there is no point in
logging pages missing in the index since this happens for every new
page.
2024-02-11 12:57:53 +01:00
Alex Schroeder
dade693451 Log usernames when saving files
Return an error when page notification breaks in the appendHandler,
just like it does in the saveHandler and the dropHandler.
2024-02-11 09:31:48 +01:00
Alex Schroeder
b5272803f5 More logging improvements
Errors in p.save aren't logged, so the saveHandler must log them.

Errors in p.notify are logged, so the saveHandler must not log them.
That's because in this situation, the errors are not directly related
to page saving (which is what the saveHandler does). However, the user
must still be made aware of the error, so a HTTP error page is shown.

When backup fails (since it now returns an error), save does not
proceed.

The helper functions in notify.go using c as the variable for the
changes page, the index page, or a hashtag page now all use p.
2024-02-11 00:01:56 +01:00
Alex Schroeder
3f586a51f1 Log file saves and fix potential backup problem
Log file saves and deletes.

When logging errors, just log err.

Better HTTP status codes. If something is a bad request, say so. If
something really is an internal error, log it.

Saving a file foo/bar would rename bar to bar~ (in the root
directory!) instead of renaming foo/bar to foo/bar~! Perhaps this was
the source of the sometimes disappearing view.html file during
testing? It doesn't explain why this happend only sometimes, though.

Also make sure that backup rename errors only get reported if the
original file exists.

Rename updateIndex to update.
2024-02-10 13:49:29 +01:00
Alex Schroeder
72aad37979 Ensure watcher never sees ./ prefix 2024-02-10 00:47:07 +01:00
Alex Schroeder
93de7b2517 Move hidden segment check into makeHandler
Apply it to the id, too.
2024-02-10 00:46:41 +01:00
Alex Schroeder
afda27be76 Fix more errors regarding tree walking
Also prevent hidden files from being shown.
2024-02-10 00:35:02 +01:00
Alex Schroeder
9457d38cc1 Fix errors regarding skipping hidden files
When looking for hidden files, one needs to check whether the base
name of the path starts with a period. Sadly, a few places checked the
path itself, which only makes sense in the root directory.
2024-02-09 21:36:28 +01:00
Alex Schroeder
cd92bea269 Fixing stuff in index and watch, again 2024-02-09 20:33:36 +01:00
Alex Schroeder
fdaf1303c8 Update to the static man page 2024-02-09 19:11:04 +01:00
Alex Schroeder
d5286c9633 Switching to shuffled tests and fixing stuff 2024-02-09 18:51:35 +01:00
Alex Schroeder
a1469a7c19 Small test data fix 2024-02-09 17:42:44 +01:00
Alex Schroeder
31065ea95e Fix comment 2024-02-09 17:42:44 +01:00
Alex Schroeder
785b861ba0 Remove useless prefix to error log print 2024-02-09 17:42:44 +01:00
Alex Schroeder
2ecc0861f0 Don't watch hidden files or directories 2024-02-09 17:42:44 +01:00
Alex Schroeder
e7be104718 Add more assertions in the templates test
There remains the mysterious case of tests running and occasionally
deleting view.html. Why?
2024-02-09 17:42:44 +01:00
Alex Schroeder
6bec3500c3 Remove commented test
This test is never going to be brought back since it results in
network traffic.
2024-02-09 17:42:44 +01:00
Alex Schroeder
9b3dfd31d0 Add ignores map to the watches
- renamed watchDo to watchDoUpdate and takes ignore map into account
- add watchDoRemove which takes ignore map into account
- watchTimer now takes a path argument instead of iterating over all
  files
2024-02-09 17:42:17 +01:00
Alex Schroeder
3f876c6326 Delete some comments 2024-02-09 17:02:52 +01:00
Alex Schroeder
74b192a609 deleteDocument no longer requires the original text
If only hashtags are indexed, then there are comparatively few tokens
and we can search them all when deindexing a file. This has the
benefit of no longer requiring the old text to be provided (which is a
requirement to be able to handle filesystem deletes).
2024-02-09 17:00:40 +01:00
Alex Schroeder
c39ad26901 Wrap sort in an index read lock
sortNames access the index and that requires a read lock.
2024-02-09 16:58:24 +01:00
Alex Schroeder
293e5bc1c5 Whitespace changes
Reformatting some comments to the new width.
2024-02-09 16:57:29 +01:00
Alex Schroeder
1b3dc89f28 Fix order of arguments in test 2024-02-09 16:56:38 +01:00
Alex Schroeder
7374114bbd Whitespace changes
Reformatting some comments to the new width.
2024-02-09 16:56:10 +01:00
Alex Schroeder
0802091da1 Rename filename to path in page.go
I want to move away from "filename" and use "path", "base" and "dir"
instead.
2024-02-09 16:54:51 +01:00
Alex Schroeder
148aff3d74 Turn templates into a struct with mutex
Since templates can be updated in parallel and they're in a map, a
mutex is required.
2024-02-09 16:48:22 +01:00
Alex Schroeder
bcaa51506c Moved some code into init functions
- initAccounts
- watches.files initialisation
2024-02-09 16:44:20 +01:00
Alex Schroeder
2d12168612 Fix pattern in man/Makefile 2024-02-09 01:22:13 +01:00
Alex Schroeder
c8197cd811 Makefile to turn man pages into markdown files 2024-02-09 01:10:52 +01:00
Alex Schroeder
791b95e80d Rewrite watches: handle file creation events
When a file is moved or renamed into the directory, this is a Create
event, not a Write event. So handle it appropriately and immediately.
2024-02-09 01:06:53 +01:00
Alex Schroeder
f0517a1d30 Mention oddmu-notify in the main man page 2024-02-09 00:41:15 +01:00
Alex Schroeder
6cf1a2dc9e Return when unable to stat file
Otherwise fi.IsDir() turns into a panic.
2024-02-09 00:15:31 +01:00
Alex Schroeder
c236adf0d6 Add links to the README 2024-02-08 01:09:41 +01:00
Alex Schroeder
6f48140958 Restructure watches and write test 2024-02-07 21:48:56 +01:00
Alex Schroeder
85b3b63af8 Reverse the order of return values
watches.install should return error as the last value.
2024-02-07 20:57:29 +01:00
Alex Schroeder
739787f582 Bring fsnotify back
The goal is to allow users to make changes to the files directly and
the wiki still takes these into account.
2024-02-07 17:45:49 +01:00
Alex Schroeder
ffe39953a4 Note the template files in the main man page 2024-02-06 23:51:18 +01:00
Alex Schroeder
e0fa8756ea Run go fmt and reword comment 2024-02-06 23:44:07 +01:00
Alex Schroeder
0cb4291394 Fix TestUploadDir so no files are left behind 2024-02-06 23:43:33 +01:00
Alex Schroeder
fd6bce6418 Delete files when uploading 0 byte files 2024-02-06 23:42:59 +01:00
Alex Schroeder
7e5d8b768c Pages in subdirectories prefer local templates
When rendering templates, pass along the directory of the page or data
being rendered and if the required template is found in that
directory, prefer using that.
2024-02-06 23:42:36 +01:00
Alex Schroeder
ff04c08537 Get rid of fsnotify
When uploading a file via the drop handler, check whether the saved
file is a valid template and if so, reload the template. There is no
point in using fsnotify unless we do the same thing for the index.
That would be interesting, in fact. But it's also complicated and
gives me a headache.
2024-02-06 22:06:20 +01:00
Alex Schroeder
57763c661b Recursively add watchers to all directories 2024-02-06 21:54:39 +01:00
Alex Schroeder
67d9a2a178 Move template code from wiki.go into templates.go 2024-02-05 23:25:57 +01:00
Alex Schroeder
5083787f7c Switch assert.Regexp to assert.Contains 2024-02-05 23:25:28 +01:00
Alex Schroeder
3ffe58d4b9 Document that templates can be changed uploading 2024-02-04 23:38:35 +01:00
Alex Schroeder
d8ee0ff963 Switch to a single file per template
This allows us to correctly reload them even when the template has
been executed.
2024-02-04 23:34:44 +01:00
Alex Schroeder
c829725f91 More documentation for the template reloading code 2024-02-04 23:07:51 +01:00
Alex Schroeder
0fb599bf8b Reload templates on demand
There is now a global variable to hold the parsed templates, and a
fsnotify watcher that watches the directory for any changes to the
templates. If a template is written to, a go routine is started which
waits for a second and then checks whether any of the files files that
were written to haven't been written to in the last second. If so, it
is assumed that writing is finished and the template is reloaded.

Thus, if file is written to twice, two go routines start, the first
one doesn't find a file that's old enough but the second one does, and
so the template is reloaded.

Previously, it would reload all the templates for every request that
needs a template. And before that, it would only load the templates as
the wiki started up.
2024-02-04 19:51:24 +01:00
Alex Schroeder
eec2a477ab Fix tests for the upload template 2024-02-04 19:50:34 +01:00
Alex Schroeder
0b0315802c Document {{.Base}} and id= 2024-02-03 22:46:05 +01:00
Alex Schroeder
efa9175c71 Suggest filename for uploads
Add a new function Base() for pages.
Use this in the view template to suggest an image upload.
2024-02-03 22:27:39 +01:00
Alex Schroeder
3b4211bc61 Run go fmt 2024-02-02 22:51:34 +01:00
Alex Schroeder
0283be53d6 Update dependencies 2024-02-02 22:50:27 +01:00
Alex Schroeder
5b1558cc57 Add a tiny test to rule out some bug 2024-02-02 22:49:42 +01:00
Alex Schroeder
1cd68929ff Test id parameter with subdirectory
This tests a construct like the following in view.html:

<form action="/edit/" method="GET">
  <label for="id">New page:</label>
  <input id="id" type="text" spellcheck="false" name="id"
         accesskey="g" value="{{.Dir}}/{{.Today}}" required>
  <button>Edit</button>
</form>

For a while I tried to handle mixed situations where both URL path and
query parameter have to be handled but I got strange 500 status codes.
Basically I wanted to support the following and was unable to write
code and a test for it to make that work:

<form action="/edit/{{.Dir}}" method="GET">
  <label for="id">New page:</label>
  <input id="id" type="text" spellcheck="false" name="id"
         accesskey="g" value="{{.Today}}" required>
  <button>Edit</button>
</form>

I kept getting a strange 500 code and duplicate headers according to
the tests. Very strange.
2024-02-02 22:48:41 +01:00
Alex Schroeder
d7711832c6 Home and changes link point to the local files 2024-02-02 22:48:00 +01:00
Alex Schroeder
304b803114 Explain static CSS file 2024-01-29 13:15:42 +01:00
Alex Schroeder
eef226f9d2 Typo 2024-01-29 13:11:18 +01:00
Alex Schroeder
e72a4418fd Updated Apache documentation
Dropping some unnecessary rewrite rules and prefer a redirect match.
2024-01-28 13:44:25 +01:00
Alex Schroeder
912957c990 Return a 404 from the root Handler
Otherwise any URL that isn't handled by Oddmu results in a redirect to
the index. This confuses me when trying to understand how redirects,
rewrites and proxying work on the web server.
2024-01-28 12:38:59 +01:00
Alex Schroeder
b64d56a648 Document a curious bug with the Apache configuration 2024-01-27 21:27:17 +01:00
Alex Schroeder
ce64d04dde Switch to 70ch max-width for all 2024-01-17 22:09:11 +01:00
Alex Schroeder
de5bd2d23e Remove restore and simplify cleanup
The test setups are now simplified, since no files in the source
directory are modified.
2024-01-17 14:15:07 +01:00
Alex Schroeder
29842fe685 Major change in how notifications work 2024-01-17 14:10:07 +01:00
Alex Schroeder
4042be68f3 Reformatting of documentation, man page generation
Fill column is 80, no double spaces after sentence endings.
2024-01-17 14:06:05 +01:00
Christopher Brannon
87846e15b9 Support socket activation.
This also removes the Unix socket support added yesterday.  To use
Unix sockets, use socket activation and let systemd manage the socket.
2024-01-17 13:56:05 +01:00
Alex Schroeder
1390d82e29 Update man page 2024-01-16 14:02:32 +01:00
Alex Schroeder
bb5bd1c629 Small comment fix
Reformatted comment for 120 characters. Note that the presence of a
forward-slash anywhere in ODDMU_ADDRESS makes this a Unix-domain
socket.
2024-01-16 13:46:09 +01:00
Christopher Brannon
777c498700 Allow specifying the listen address.
This patch also adds support for HTTP over a Unix-domain socket.
2024-01-16 13:43:01 +01:00
Alex Schroeder
803025f56a Fix test for new year 2024-01-16 13:43:01 +01:00
Alex Schroeder
dce66ec5a1 Silence output of static command for tests 2024-01-16 13:43:01 +01:00
Alex Schroeder
b6d596cb08 Improve man page for static subcommand
Discuss what it means to have some files hard linked.
Discuss the use of ODDMU_LANGUAGES to speed site generation up.
2024-01-10 07:37:33 +01:00
Alex Schroeder
3be26b9af1 Load languages before generating static site
Without it, all HTML files have lang="".

Also added a progress indication because generating the site takes a
lot longer, now.
2024-01-10 07:20:47 +01:00
215 changed files with 13076 additions and 1967 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

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

101
Makefile
View File

@@ -1,47 +1,102 @@
SHELL=/bin/bash
SHELL=/usr/bin/env bash
PREFIX=${HOME}/.local
BINARIES=oddmu-linux-amd64.tar.gz oddmu-linux-arm64.tar.gz oddmu-darwin-amd64.tar.gz oddmu-darwin-arm64.tar.gz oddmu-windows-amd64.tar.gz
.PHONY: help build test run upload docs install priv clean dist dist-upload
help:
@echo Help for Oddmu
@echo =====================
@echo
@echo ==============
@echo make run
@echo " runs program, offline"
@echo
@echo make test
@echo " runs the tests"
@echo
@echo " runs the tests without log output"
@echo make check
@echo " checks the code with golint and gocritic"
@echo make fix
@echo " fixes formatting issues with goimports instead of go fmt"
@echo make docs
@echo " create man pages from text files"
@echo
@echo go build
@echo make build
@echo " just build it"
@echo
@echo make install
@echo " install the files to ~/.local"
@echo
@echo sudo make install PREFIX=/usr/local
@echo " install the files to /usr/local"
@echo make upload
@echo " this is how I upgrade my server"
@echo make dist
@echo " cross compile for other systems"
@echo make clean
@echo " remove built files"
build: oddmu
oddmu: *.go
go build
test:
rm -rf testdata/*
go test -shuffle on .
# go install golang.org/x/lint/golint@latest
# go install github.com/go-critic/go-critic/cmd/go-critic@latest
check:
golint
go-critic check
# go install golang.org/x/tools/cmd/goimports@latest
fix:
goimports -w *.go
run:
go run .
test:
go test
upload:
go build
upload: build
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia"
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia; systemctl restart campaignwiki; systemctl restart community"
@echo Changes to the template files need careful consideration
docs:
cd man; make
cd man; make man
install:
make docs
for n in 1 5 7; do install -D -t $$HOME/.local/share/man/man$$n man/*.$$n; done
go build
install -D -t $$HOME/.local/bin oddmu
for n in 1 5 7; do install -D -t ${PREFIX}/share/man/man$$n man/*.$$n; done
install -D -t ${PREFIX}/bin oddmu
missing:
for f in man/*.txt; do grep --quiet "$$f" README.md || echo $$f is not in the README; done
clean:
rm -f oddmu oddmu.exe oddmu-{linux,darwin,windows}-{amd64,arm64}{,.tar.gz}
cd man && make clean
dist-upload: $(BINARIES)
rsync -ai $^ sibirocobombus:alexschroeder.ch/wiki/oddmu/
dist: $(BINARIES)
oddmu-linux-amd64: *.go
GOOS=linux GOARCH=amd64 go build -o $@
oddmu-linux-arm64: *.go
env GOOS=linux GOARCH=arm64 GOARM=5 go build -o $@
oddmu-darwin-amd64: *.go
GOOS=darwin GOARCH=amd64 go build -o $@
oddmu-darwin-arm64: *.go
GOOS=darwin GOARCH=arm64 go build -o $@
oddmu.exe: *.go
GOOS=windows GOARCH=amd64 go build -o $@
oddmu-windows-amd64.tar.gz: oddmu.exe
cd man && make html
tar --create --file $@ --transform='s/^/oddmu\//' --exclude='*~' \
$< *.md man/*.[157].{html,md} themes/
%.tar.gz: %
tar --create --gzip --file $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< *.html Makefile *.socket *.service *.md man/Makefile man/*.[157] themes/
priv:
sudo setcap 'cap_net_bind_service=+ep' oddmu

427
README.md
View File

@@ -1,110 +1,201 @@
# Oddµ: A minimal wiki
# Oddμ: A minimal wiki
This program helps you run a minimal wiki. There is no version
history. It's well suited as a *secondary* medium: collaboration and
conversation happens elsewhere, in chat, on social media. The wiki
serves as the text repository that results from these discussions.
Oddμ (or Oddmu) helps you run a minimal wiki, blog, digital garden,
memex or Zettelkasten.
If you're the only user and it just runs on your laptop, then you can
think of it as a [memex](https://en.wikipedia.org/wiki/Memex), a
memory extender.
Oddμ can be run as a static site generator, processing a directory
with Markdown files, turning them into HTML files. HTML templates
allow the customisation of headers, footers and styling. There are no
plugins.
Oddµ can be used as a web server behind a reverse proxy such as Apache
or it can be used as a static site generator.
Oddμ is well suited as a self-hosted, single-user web application.
Edit the pages from your phone or laptop, while you're on the move.
When Oddµ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages and allows you to edit them.
If the site is public, use a regular web server as a proxy to make
people log in before making changes. As there is no version history,
it is not easy to undo vandalism and spam. Only grant write-access
to people you trust.
If your files don't provide their own title (`# title`), the file name
(without `.md`) is used for the title. Subdirectories are created as
If the site is private, running on a local machine and unreachable
from the Internet, no such precautions are necessary.
This makes Oddμ well suited as a secondary medium for a close-knit group:
collaboration and conversation happens elsewhere, in chat, on social
media. The wiki serves as the text repository that results from these
discussions.
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.
Oddμ adds the following extensions to Markdown: local links `[[like
this]]`, hashtags `#Like_This` and fediverse account links like
`@alex@alexschroeder.ch`.
If your pages don't provide their own title (`# title`), the file name
(without `.md`) is used as the title. Subdirectories are created as
necessary.
Oddµ uses a [Markdown library](https://github.com/gomarkdown/markdown)
to generate the web pages from Markdown. Oddmu adds the following
extensions: local links `[[like this]]`, hashtags `#Like_This` and
fediverse account links like `@alex@alexschroeder.ch`.
The [lingua](https://github.com/pemistahl/lingua-go) library detects
languages in order to get hyphenation right.
The standard [html/template](https://pkg.go.dev/html/template) library
is used to generate HTML.
Other files can be uploaded and images (ending in `.jpg`, `.jpeg`,
`.png`, `.heic` or `.webp`) can be resized when they are uploaded
(resulting in `.jpg`, `.png` or `.webp` files).
## Documentation
This project uses man(1) pages. They are generated from text files
This project uses man pages. They are generated from text files
using [scdoc](https://git.sr.ht/~sircmpwn/scdoc). These are the files
available:
[oddmu(1)](/oddmu.git/blob/main/man/oddmu.1.txt): This man page has a
short introduction to Oddmu, its configuration via templates and
environment variables, plus points to the other man pages.
[oddmu(1)](https://alexschroeder.ch/view/oddmu/oddmu.1): This man page
has a short introduction to Oddmu, its configuration via templates and
environment variables, plus pointers to the other man pages.
[oddmu(5)](/oddmu.git/blob/main/man/oddmu.5.txt): This man page talks
about the Markdown and includes some examples for the non-standard
[oddmu(5)](https://alexschroeder.ch/view/oddmu/oddmu.5): This man page
talks about Markdown and includes some examples for the non-standard
features such as table markup. It also talks about the Oddmu
extensions to Markdown: wiki links, hashtags and fediverse account
links. Local links must use percent encoding for page names so there
is a section about percent encoding. The man page also explains how
feeds are generated.
[oddmu-list(1)](/oddmu.git/blob/main/man/oddmu-list.1.txt): This man
page documents the "list" subcommand which you can use to get page
names and page titles.
[oddmu-releases(7)](https://alexschroeder.ch/view/oddmu/oddmu-releases.7):
This man page lists all the Oddmu versions and their user-visible
changes.
[oddmu-search(1)](/oddmu.git/blob/main/man/oddmu-search.1.txt): This
man page documents the "search" subcommand which you can use to build
indexes lists of page links. These are important for feeds.
[oddmu-version(1)](https://alexschroeder.ch/view/oddmu/oddmu-version.1):
This man page documents the "version" subcommand which you can use to
get the installed Oddmu version.
[oddmu-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
man page documents how search and scoring work.
[oddmu-man(1)](https://alexschroeder.ch/view/oddmu/oddmu-man.1): Oddmu
comes with a "man" subcommand to print the manual pages. This man page
documents the subcommand, but I guess if you can read the man page,
you don't need the "man" subcommand.
[oddmu-replace(1)](/oddmu.git/blob/main/man/oddmu-replace.1.txt): This
man page documents the "replace" subcommand to make mass changes to
the files much like find(1), grep(1) and sed(1) or perl(1).
Working locally:
[oddmu-missing(1)](/oddmu.git/blob/main/man/oddmu-missing.1.txt): This
man page documents the "missing" subcommand to list local links that
don't point to any existing pages or files.
[oddmu-links(1)](https://alexschroeder.ch/view/oddmu/oddmu-links.1):
This man page documents the "links" subcommand which you can use to
get the outgoing links for a page.
[oddmu-html(1)](/oddmu.git/blob/main/man/oddmu-html.1.txt): This man
page documents the "html" subcommand to generate HTML from Markdown
pages from the command line.
[oddmu-list(1)](https://alexschroeder.ch/view/oddmu/oddmu-list.1):
This man page documents the "list" subcommand which you can use to get
page names and page titles.
[oddmu-static(1)](/oddmu.git/blob/main/man/oddmu-static.1.txt): This
man page documents the "static" subcommand to generate an entire
[oddmu-replace(1)](https://alexschroeder.ch/view/oddmu/oddmu-replace.1):
This man page documents the "replace" subcommand to make mass changes
to the files much like find(1), grep(1) and sed(1) or perl(1).
[oddmu-search(1)](https://alexschroeder.ch/view/oddmu/oddmu-search.1):
This man page documents the "search" subcommand which you can use to
build indexes lists of page links. These are important for feeds.
[oddmu-search(7)](https://alexschroeder.ch/view/oddmu/oddmu-search.7):
This man page documents how search and scoring work.
[oddmu-toc(1)](https://alexschroeder.ch/view/oddmu/oddmu-toc.1): This
man page documents the "toc" subcommand which you can use to generate
a table of contents linking to all the headings on the page.
Reporting:
[oddmu-missing(1)](https://alexschroeder.ch/view/oddmu/oddmu-missing.1):
This man page documents the "missing" subcommand to list local links
that don't point to any existing pages or files.
[oddmu-hashtags(1)](https://alexschroeder.ch/view/oddmu/oddmu-hashtags.1):
This man page documents the "hashtags" subcommand to count the
hashtags used from the command line.
Static site generator:
[oddmu-html(1)](https://alexschroeder.ch/view/oddmu/oddmu-html.1):
This man page documents the "html" subcommand to generate HTML from
Markdown pages from the command line.
[oddmu-feed(1)](https://alexschroeder.ch/view/oddmu/oddmu-feed.1):
This man page documents the "feed" subcommand to generate a feed from
Markdown pages from the command line.
[oddmu-sitemap(1)](https://alexschroeder.ch/view/oddmu/oddmu-sitemap.1):
This man page documents the "sitemap" subcommand to generate the
static sitemap from the command line.
[oddmu-static(1)](https://alexschroeder.ch/view/oddmu/oddmu-static.1):
This man page documents the "static" subcommand to generate an entire
static website from the command line, avoiding the need to run Oddmu
as a server. Also great for archiving.
[oddmu-notify(1)](/oddmu.git/blob/main/man/oddmu-notify.1.txt): This
man page documents the "notify" subcommand to add links to hashtag
pages, index and changes for a given page. This is useful when you
edit the Markdown files locally.
[oddmu-notify(1)](https://alexschroeder.ch/view/oddmu/oddmu-notify.1):
This man page documents the "notify" subcommand to add links to
hashtag pages, index and changes for a given page. This is useful when
you edit the Markdown files locally.
[oddmu-templates(5)](/oddmu.git/blob/main/man/oddmu-templates.5.txt):
Configuration:
[oddmu-templates(5)](https://alexschroeder.ch/view/oddmu/oddmu-templates.5):
This man page documents how the templates can be changed (how they
*must* be changed) and lists the attributes available for the various
templates.
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
man page documents how to set up the web server for various common
tasks such as using logins to limit what visitors can edit.
System administration:
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
man page documents how to setup a systemd unit and have it manage
[oddmu-apache(5)](https://alexschroeder.ch/view/oddmu/oddmu-apache.5):
This man page documents how to set up the Apache web server for
various common tasks such as using logins to limit what visitors can
edit.
[oddmu-filter(7)](https://alexschroeder.ch/view/oddmu/oddmu-filter.7):
This man page documents how to exclude subdirectories from search and
archiving.
[oddmu-nginx(5)](https://alexschroeder.ch/view/oddmu/oddmu-nginx.5):
This man page documents how to set up the freenginx web server for
various common tasks such as using logins to limit what visitors can
edit.
[oddmu.service(5)](https://alexschroeder.ch/view/oddmu/oddmu.service.5):
This man page documents how to setup a systemd unit and have it manage
Oddmu. “Great configurability brings great burdens.”
[oddmu-webdav(5)](https://alexschroeder.ch/view/oddmu/oddmu-webdav.5):
This man page documents how to set up the Apache web server so that
the wiki can be accessed via Web-DAV.
Leaving:
[oddmu-export(1)](https://alexschroeder.ch/view/oddmu/oddmu-export.1):
This man page documents how to export all the pages as one RSS feed so
that you can import them all into a new platform that doesn't use
Markdown files.
## Building
To build the binary:
```sh
go build
```
The man pages are already built. If you want to rebuild them, you need
to have [scdoc](https://git.sr.ht/~sircmpwn/scdoc) installed.
```sh
make docs
```
The `Makefile` in the `man` directory has targets to create Markdown
and HTML files.
As the repository changed URLs a few times (from GitHub, to
self-hosted using `cgit` to self-hosted using `legit`), there is no
way to install it using `go install`. You need to `git clone` the
repository and build it locally.
## Running
The working directory is where pages are saved and where templates are
loaded from. You need a copy of the template files in this directory.
Here's how to start it in the source directory:
Here's how to build and run straight from the source directory:
```sh
go run .
@@ -113,38 +204,206 @@ go run .
The program serves the local directory as a wiki on port 8080. Point
your browser to http://localhost:8080/ to use it.
## Bugs
Once the `oddmu` binary is built, you can run it instead:
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
```sh
./oddmu
```
## Source
To read the main man page witihout installing Oddmu:
```sh
man -l man/oddmu.1
```
## Installing
This installs `oddmu` into `$HOME/.local/bin` and the manual pages
into `$HOME/.local/share/man/`.
```sh
make install
```
This installs `oddmu` into `/usr/local/bin` and the manual pages into
`/usr/local/share/man`:
```sh
sudo make install PREFIX=/usr/local
```
Here's an example using [GNU Stow](https://www.gnu.org/software/stow/)
to install it into `/usr/local/stow` in a way that allows you to
uninstall it later:
```sh
sudo mkdir /usr/local/stow/oddmu
sudo make install PREFIX=/usr/local/stow/oddmu/
cd /usr/local/stow
sudo stow oddmu
```
## Hacking
If you're interested in making changes to the code, here's a
high-level introduction to the various source files.
- *_test.go are the test files; a few library functions are defined in
wiki_test.go.
- *_cmd.go are the files implementing the various subcommands with
- `*_test.go` are the test files; a few library functions are defined
in `wiki_test.go`.
- `*_cmd.go` are the files implementing the various subcommands with
matching names
- accounts.go implements the webfinger code to fetch fediverse account
link destinations with the URI provided by webfinger
- add_append.go implements the /add and /append handlers
- diff.go implements the /diff handler
- edit_save.go implements the /edit and /save handlers
- feed.go implements the feed for a page based on the links it lists
- highlight.go implements the bold tags for matches when showing
- `accounts.go` implements the webfinger code to fetch fediverse
account link destinations with the URI provided by webfinger
- `add_append.go` implements the `/add` and `/append` handlers
- `archive.go` implements the `/archive` handler
- `changes.go` implements the "notifications": the automatic addition
of links to index, changes and hashtag files when pages are edited
- `diff.go` implements the `/diff` handler
- `edit_save.go` implements the `/edit` and `/save` handlers
- `feed.go` implements the feed for a page based on the links it lists
- `highlight.go` implements the bold tags for matches when showing
search results
- index.go implements the index of all the hashtags
- languages.go implements the language detection
- page.go implements the page loading and saving
- parser.go implements the Markdown parsing
- score.go implements the page scoring when showing search results
- search.go implements the /search handler
- snippets.go implements the page summaries for search results
- tokenizer.go implements the various tokenizers used
- upload_drop.go implements the /upload and /drop handlers
- view.go implements the /view handler
- wiki.go implements the main function
- `index.go` implements the index of all the hashtags
- `languages.go` implements the language detection
- `list.go` implements the file list page
- `page.go` implements the page loading and saving
- `parser.go` implements the Markdown parsing
- `preview.go` implements the `/preview` handler
- `score.go` implements the page scoring when showing search results
- `search.go` implements the `/search` handler
- `sitemap.go` implements the `/sitemap` handler
- `snippets.go` implements the page summaries for search results
- `templates.go` implements template loading and reloading
- `tokenizer.go` implements the various tokenizers used
- `upload_drop.go` implements the `/upload` and `/drop` handlers
- `view.go` implements the `/view` handler
- `watch.go` implements the filesystem notification watch
- `wiki.go` implements the main function
The code of this package is licensed to you under the
AGPL-3.0-or-later license. If you do make changes and your site is
public, be aware of section 13:
> … if you modify the Program, your modified version must prominently
> offer all users interacting with it remotely through a computer
> network (if your version supports such interaction) an opportunity
> to receive the Corresponding Source of your version by providing
> access to the Corresponding Source from a network server at no
> charge, through some standard or customary means of facilitating
> copying of software.
### Changing the markup rules
If you want to change the markup rules, your starting point should be
`parser.go`. Make sure you read the documentation of [Go
Markdown](https://github.com/gomarkdown/markdown) and note that it
offers MathJax support (needs a change to the `view.html` template so
that the MathJax Javascript gets loaded) and
[MMark](https://mmark.miek.nl/post/syntax/) support, and it shows how
extensions can be added.
### Filenames and URL path
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
an example.
### HTTP handlers
The URL paths all have the form `/action/directory/pagename` (with
directory being optional and pagename sometimes being optional). If
you need to limit access in Apache or nginx or some other web server
acting as a [reverse
proxy](https://en.wikipedia.org/wiki/Reverse_proxy), you can do that.
See `man oddmu-apache` and `man oddmu-nginx` for some configuration
examples.
This is how you can prevent some actions by simply not passing them on
to Oddmu, or you can require authentication for certain actions.
Furthermore, you can do the same for directories, allowing you to use
subdirectories as separate sites, each with their own editors.
### Templates
The `themes` folder has some ideas of how to tweak the HTML templates.
### Permissions
An unexplored idea would be to parse a config file that has usernames
and passwords, groups usernames into roles, and assigns access to the
various actions based on these roles. This would obviate the need for
a web server acting as a reverse proxy.
Then again, not having to care about roles and permissions has been a
relief.
## Dependencies
This section lists the non-standard libraries Oddmu uses and their
respective licenses.
[github.com/gomarkdown/markdown](https://github.com/gomarkdown/markdown)
is used to generate the web pages from Markdown. BSD-2-Clause.
[github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday)
is used to strip rendered search results of all HTML except for the
bold tag. Regular HTML generated from pages is *not* sanitized. Don't
give people you don't trust access to your wiki. BSD-3-Clause.
[github.com/pemistahl/lingua-go](https://github.com/pemistahl/lingua-go)
detects languages in order to set the language tag in templates. This
in turn can be used by browsers to get hyphenation right. Apache-2.0.
[github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype)
is used to sniff the MIME type of files with unknown filename
extensions. MIT.
[github.com/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.
[github.com/edwvee/exiffix](https://github.com/edwvee/exiffix) is used
to rotate images before resizing them if the EXIF data says the image
wasn't taken with the default orientation of the camera. This is
necessary because after resizing, the EXIF data is gone. MIT.
[github.com/google/subcommands](https://github.com/google/subcommands)
is used for the parsing and documenting of subcommands. Apache-2.0.
[github.com/muesli/reflow/wordwrap](https://github.com/muesli/reflow/wordwrap)
is used to wrap the search subcommand output. MIT.
[github.com/hexops/gotextdiff](https://github.com/hexops/gotextdiff)
is used to show a compact unified diff on the command line before
doing any replacements. BSD-3-Clause.
[github.com/sergi/go-diff/diffmatchpatch](https://github.com/sergi/go-diff/diffmatchpatch)
is used to show the page diffs on the web. MIT.
[github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify)
is used to watch the filesystem for changes. BSD-3-Clause.
[golang.org/x/exp/constraints](https://golang.org/x/exp/constraints)
for the computation of the intersection between two sets of pages.
BSD-3-Clause.
[github.com/stretchr/testify/assert](https://github.com/stretchr/testify/assert)
is used for testing. MIT.
## Bugs
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
## References

22
RELEASE Normal file
View File

@@ -0,0 +1,22 @@
When preparing a new release
----------------------------
1. run tests
2. update man/oddmu-releases.7.txt
- add missing items
- change "(unreleased)"
3. check copyright year in LICENSE
4. make docs
5. make sure all files are checked in
6. tag the release and push the tag to the remote
7. make dist
8. make dist-upload
9. cd man && make upload

View File

@@ -2,52 +2,46 @@ package main
import (
"encoding/json"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"io"
"log"
"net/http"
"os"
"sync"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
)
// useWebfinger indicates whether Oddmu looks up the profile pages of
// fediverse accounts. To enable this, set the environment variable
// ODDMU_WEBFINGER to "1".
// useWebfinger indicates whether Oddmu looks up the profile pages of fediverse accounts. To enable this, set the
// environment variable ODDMU_WEBFINGER to "1".
var useWebfinger = false
// Accounts contains the map used to set the usernames. Make sure to
// lock and unlock as appropriate.
type Accounts struct {
// accountStore controlls access to the usernames. Make sure to lock and unlock as appropriate.
type accountStore struct {
sync.RWMutex
// uris is a map, mapping account names likes
// "@alex@alexschroeder.ch" to URIs like
// uris is a map, mapping account names likes "@alex@alexschroeder.ch" to URIs like
// "https://social.alexschroeder.ch/@alex".
uris map[string]string
}
// accounts holds the global mapping of accounts to profile URIs.
var accounts Accounts
var accounts accountStore
// initAccounts sets up the accounts map. This is called once at
// startup and therefore does not need to be locked. On ever restart,
// this map starts empty and is slowly repopulated as pages are
// visited.
func initAccounts() {
// This is called once at startup and therefore does not need to be locked. On every restart, this map starts empty and
// is slowly repopulated as pages are visited.
func init() {
if os.Getenv("ODDMU_WEBFINGER") == "1" {
accounts.uris = make(map[string]string)
useWebfinger = true
}
}
// account links a social media account like @account@domain to a
// profile page like https://domain/user/account. Any account seen for
// the first time uses a best guess profile URI. It is also looked up
// using webfinger, in parallel. See lookUpAccountUri. If the lookup
// succeeds, the best guess is replaced with the new URI so on
// subsequent requests, the URI is correct.
func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
// accountLink links a social media accountLink like @accountLink@domain to a profile page like https://domain/user/accountLink. Any
// accountLink seen for the first time uses a best guess profile URI. It is also looked up using webfinger, in parallel. See
// lookUpAccountURI. If the lookup succeeds, the best guess is replaced with the new URI so on subsequent requests, the
// URI is correct.
func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
data = data[offset:]
i := 1 // skip @ of username
n := len(data)
@@ -63,9 +57,8 @@ func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
if d != 0 {
// more than one @ is invalid
return 0, nil
} else {
d = i + 1 // skip @ of domain
}
d = i + 1 // skip @ of domain
}
i++
}
@@ -86,7 +79,7 @@ func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
log.Printf("Looking up %s\n", account)
uri = "https://" + string(domain) + "/users/" + string(user[1:])
accounts.uris[string(account)] = uri // prevent more lookings
go lookUpAccountUri(string(account), string(domain))
go lookUpAccountURI(string(account), string(domain))
}
link := &ast.Link{
AdditionalAttributes: []string{`class="account"`},
@@ -97,11 +90,9 @@ func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
return i, link
}
// lookUpAccountUri is called for accounts that haven't been seen
// before. It calls webfinger and parses the JSON. If possible, it
// extracts the link to the profile page and replaces the entry in
// accounts.
func lookUpAccountUri(account, domain string) {
// lookUpAccountURI is called for accounts that haven't been seen before. It calls webfinger and parses the JSON. If
// possible, it extracts the link to the profile page and replaces the entry in accounts.
func lookUpAccountURI(account, domain string) {
uri := "https://" + domain + "/.well-known/webfinger"
resp, err := http.Get(uri + "?resource=acct:" + account)
if err != nil {
@@ -114,7 +105,7 @@ func lookUpAccountUri(account, domain string) {
log.Printf("Failed to read from %s: %s", account, err)
return
}
var wf WebFinger
var wf webFinger
err = json.Unmarshal([]byte(body), &wf)
if err != nil {
log.Printf("Failed to parse the JSON from %s: %s", account, err)
@@ -130,25 +121,24 @@ func lookUpAccountUri(account, domain string) {
accounts.uris[account] = uri
}
// Link a link in the WebFinger JSON.
type Link struct {
// link a link in the WebFinger JSON.
type link struct {
Rel string `json:"rel"`
Type string `json:"type"`
Href string `json:"href"`
}
// WebFinger is a structure used to unmarshall JSON.
type WebFinger struct {
// webFinger is a structure used to unmarshall JSON.
type webFinger struct {
Subject string `json:"subject"`
Aliases []string `json:"aliases"`
Links []Link `json:"links"`
Links []link `json:"links"`
}
// parseWebFinger parses the web finger JSON and returns the profile
// page URI. For unmarshalling the JSON, it uses the Link and
// WebFinger structs.
// parseWebFinger parses the web finger JSON and returns the profile page URI. For unmarshalling the JSON, it uses the
// Link and WebFinger structs.
func parseWebFinger(body []byte) (string, error) {
var wf WebFinger
var wf webFinger
err := json.Unmarshal(body, &wf)
if err != nil {
return "", err

View File

@@ -1,19 +1,10 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
// This causes network access!
// func TestPageAccount(t *testing.T) {
// initAccounts()
// p := &Page{Body: []byte(`@alex, @alex@alexschroeder.ch said`)}
// p.renderHtml()
// r := `<p>@alex, <a href="https://alexschroeder.ch/users/alex">@alex</a> said</p>
// `
// assert.Equal(t, r, string(p.Html))
// }
"github.com/stretchr/testify/assert"
)
func TestWebfingerParsing(t *testing.T) {
body := []byte(`{

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">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
<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

@@ -16,11 +16,11 @@ func addHandler(w http.ResponseWriter, r *http.Request, name string) {
} else {
p.handleTitle(false)
}
renderTemplate(w, "add", p)
renderTemplate(w, p.Dir(), "add", p)
}
// appendHandler takes the "body" form parameter and appends it. The
// browser is redirected to the page view.
// appendHandler takes the "body" form parameter and appends it. The browser is redirected to the page view. This is
// similar to the saveHandler.
func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
body := r.FormValue("body")
p, err := loadPage(name)
@@ -35,21 +35,30 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", name, "by", username)
} else {
log.Println("Save", name)
}
if r.FormValue("notify") == "on" {
err = p.notify()
if err != nil {
log.Println("notify:", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
http.Redirect(w, r, "/view/"+nameEscape(name), http.StatusFound)
}
func (p *Page) append(body []byte) {
// ensure an empty line at the end
if bytes.HasSuffix(p.Body, []byte("\n\n")) {
} else if bytes.HasSuffix(p.Body, []byte("\n")) {
switch {
case bytes.HasSuffix(p.Body, []byte("\n\n")):
// two newlines, nothing to add
case bytes.HasSuffix(p.Body, []byte("\n")):
p.Body = append(p.Body, '\n')
} else {
default:
p.Body = append(p.Body, '\n', '\n')
}
p.Body = append(p.Body, body...)

View File

@@ -1,12 +1,14 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestEmptyLineAdd(t *testing.T) {
@@ -24,7 +26,6 @@ Into the oven`)
func TestAddAppend(t *testing.T) {
cleanup(t, "testdata/add")
index.load()
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Fire
Orange sky above
Reflects a distant fire
@@ -35,43 +36,39 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, true),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true),
assert.HTTPBody(makeHandler(addHandler, true, http.MethodGet),
"GET", "/add/testdata/add/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, true),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
}
func TestAddAppendChanges(t *testing.T) {
cleanup(t, "testdata/notification2", "changes.md", "changes.md~")
restore(t, "index.md")
os.Remove("changes.md")
cleanup(t, "testdata/append")
today := time.Now().Format(time.DateOnly)
p := &Page{Name: "testdata/notification2/" + today + "-water", Body: []byte(`# Water
p := &Page{Name: "testdata/append/" + today + "-water", Body: []byte(`# Water
Sunlight dancing fast
Blue and green and pebbles gray
`)}
p.save()
data := url.Values{}
data.Set("body", "Stand in cold water")
data.Add("notify", "on")
HTTPRedirectTo(t, makeHandler(appendHandler, true),
"POST", "/append/testdata/notification2/"+today+"-water",
data, "/view/testdata/notification2/"+today+"-water")
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/append/"+today+"-water",
data, "/view/testdata/append/"+today+"-water")
// The changes.md file was created
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/append/changes.md")
assert.NoError(t, err)
d := time.Now().Format(time.DateOnly)
assert.Equal(t, "# Changes\n\n## "+d+"\n* [Water](testdata/notification2/"+today+"-water)\n", string(s))
assert.Equal(t, "# Changes\n\n## "+today+"\n* [Water]("+today+"-water)\n", string(s))
// Link added to index.md file
s, err = os.ReadFile("index.md")
s, err = os.ReadFile("testdata/append/index.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "\n* [Water](testdata/notification2/"+today+"-water)\n")
// New index contains just the link
assert.Equal(t, string(s), "* [Water]("+today+"-water)\n")
}

69
archive.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"archive/zip"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
// archiveHandler serves a zip file. Directories starting with a period are skipped. Filenames starting with a period
// 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, name string) {
filter := os.Getenv("ODDMU_FILTER")
re, err := regexp.Compile(filter)
if err != nil {
log.Println("ODDMU_FILTER does not compile:", filter, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
matches := re.MatchString(name)
dir := filepath.Dir(filepath.FromSlash(name))
z := zip.NewWriter(w)
err = filepath.Walk(dir, func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
return filepath.SkipDir
}
} 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
}
f, err := os.Open(fp)
if err != nil {
log.Println(err)
return err
}
_, err = io.Copy(zf, f)
if err != nil {
log.Println(err)
return err
}
}
return nil
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = z.Close()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

29
archive_test.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"archive/zip"
"net/http"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestArchive(t *testing.T) {
cleanup(t, "testdata/archive")
assert.NoError(t, os.MkdirAll("testdata/archive/public", 0755))
assert.NoError(t, os.MkdirAll("testdata/archive/secret", 0755))
assert.NoError(t, os.WriteFile("testdata/archive/public/index.md", []byte("# Public\nChurch tower bells ringing\nA cold wind biting my ears\nWalk across the square"), 0644))
assert.NoError(t, os.WriteFile("testdata/archive/secret/index.md", []byte("# Secret\nMany years ago I danced\nSpending nights in clubs and bars\nIt is my secret"), 0644))
os.Setenv("ODDMU_FILTER", "^testdata/archive/secret/")
body := assert.HTTPBody(makeHandler(archiveHandler, true, http.MethodGet), "GET", "/archive/testdata/data.zip", nil)
r, err := zip.NewReader(strings.NewReader(body), int64(len(body)))
assert.NoError(t, err, "Unzip")
names := []string{}
for _, file := range r.File {
names = append(names, file.Name)
}
assert.Contains(t, names, "testdata/archive/public/index.md")
assert.NotContains(t, names, "testdata/archive/secret/index.md")
}

View File

@@ -1,42 +1,48 @@
package main
import (
"log"
"path"
"regexp"
"strings"
"time"
)
// notify adds a link to the "changes" page, as well as to all the existing hashtag pages. If the "changes" page does
// not exist, it is created. If the hashtag page does not exist, it is not. Hashtag pages are considered optional.
// notify adds a link to the "changes" page, the "index" page, as well as to all the existing hashtag pages. The link to
// the "index" page is only added if the page being edited is a blog page for the current year. The link to existing
// hashtag pages is only added for blog pages. If the "changes" page does not exist, it is created. If the hashtag page
// does not exist, it is not. Hashtag pages are considered optional. If the page that's being edited is in a
// subdirectory, then the "changes", "index" and hashtag pages of that particular subdirectory are affected. Every
// subdirectory is treated like a potentially independent wiki. Errors are logged before being returned because the
// error messages are confusing from the point of view of the saveHandler.
func (p *Page) notify() error {
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(p.Name)
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
dir := path.Dir(p.Name)
// Recent changes for all pages
err := addLinkWithDate("changes", link, re)
dir := p.Dir()
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
if err != nil {
log.Printf("Updating changes in %s failed: %s", dir, err)
return err
}
// For blog pages only…
if p.isBlog() {
if p.IsBlog() {
// Add to the index only if the blog post is for the current year
if strings.HasPrefix(path.Base(p.Name), time.Now().Format("2006")) {
err := addLink("index", link, re)
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)
return err
}
}
// Update hashtag pages
p.renderHtml() // to set hashtags
p.renderHTML() // to set hashtags
for _, hashtag := range p.Hashtags {
err := addLink(path.Join(dir, hashtag), link, re)
err := addLink(path.Join(dir, hashtag), false, link, re)
if err != nil {
log.Printf("Updating hashtag %s in %s failed: %s", hashtag, dir, err)
return err
}
}
@@ -50,40 +56,40 @@ func (p *Page) notify() error {
func addLinkWithDate(name, link string, re *regexp.Regexp) error {
date := time.Now().Format(time.DateOnly)
org := ""
c, err := loadPage(name)
p, err := loadPage(name)
if err != nil {
// create a new page
c = &Page{Name: "changes", Body: []byte("# Changes\n\n## " + date + "\n" + link)}
p = &Page{Name: name, Body: []byte("# Changes\n\n## " + date + "\n" + link)}
} else {
org = string(c.Body)
org = string(p.Body)
// remove the old match, if one exists
loc := re.FindIndex(c.Body)
loc := re.FindIndex(p.Body)
if loc != nil {
r := c.Body[:loc[0]]
if loc[1] < len(c.Body) {
r = append(r, c.Body[loc[1]:]...)
r := p.Body[:loc[0]]
if loc[1] < len(p.Body) {
r = append(r, p.Body[loc[1]:]...)
}
c.Body = r
if loc[0] >= 14 && len(c.Body) >= loc[0]+15 {
p.Body = r
if loc[0] >= 14 && len(p.Body) >= loc[0]+15 {
// remove the preceding date if there are now two dates following each other
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n\n## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(c.Body[loc[0]-14 : loc[0]+15]) {
c.Body = append(c.Body[0:loc[0]-14], c.Body[loc[0]+1:]...)
if re.Match(p.Body[loc[0]-14 : loc[0]+15]) {
p.Body = append(p.Body[0:loc[0]-14], p.Body[loc[0]+1:]...)
}
} else if len(c.Body) == loc[0] {
} else if len(p.Body) == loc[0] {
// remove a trailing date
re := regexp.MustCompile(`## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(c.Body[loc[0]-14 : loc[0]]) {
c.Body = c.Body[0 : loc[0]-14]
if re.Match(p.Body[loc[0]-14 : loc[0]]) {
p.Body = p.Body[0 : loc[0]-14]
}
}
}
// locate the beginning of the list to insert the line
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(c.Body)
loc = re.FindIndex(p.Body)
if loc == nil {
// if no list was found, use the end of the page
loc = []int{len(c.Body)}
loc = []int{len(p.Body)}
}
// start with new page content
r := []byte("")
@@ -91,19 +97,20 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
addDate := true
if loc[0] >= 14 {
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n`)
m := re.Find(c.Body[loc[0]-14 : loc[0]])
if m == nil {
m := re.Find(p.Body[loc[0]-14 : loc[0]])
switch {
case m == nil:
// not a date: insert date, don't move insertion point
} else if string(c.Body[loc[0]-11:loc[0]-1]) == date {
case string(p.Body[loc[0]-11:loc[0]-1]) == date:
// if the date is our date, don't add it, don't move insertion point
addDate = false
} else {
default:
// if the date is not out date, move the insertion point
loc[0] -= 14
}
}
// append up to the insertion point
r = append(r, c.Body[:loc[0]]...)
r = append(r, p.Body[:loc[0]]...)
// append date, if necessary
if addDate {
// ensure paragraph break
@@ -120,57 +127,114 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
// append link
r = append(r, []byte(link)...)
// if we just added a date, add an empty line after the single-element list
if len(c.Body) > loc[0] && c.Body[loc[0]] != '*' {
if len(p.Body) > loc[0] && p.Body[loc[0]] != '*' {
r = append(r, '\n')
}
// append the rest
r = append(r, c.Body[loc[0]:]...)
c.Body = r
r = append(r, p.Body[loc[0]:]...)
p.Body = r
}
// only save if something changed
if string(c.Body) != org {
return c.save()
if string(p.Body) != org {
return p.save()
}
return nil
}
// addLink adds a link to a named page, if the page exists and doesn't contain the link. If the link exists but with a
// different title, the title is fixed.
func addLink(name, link string, re *regexp.Regexp) error {
c, err := loadPage(name)
func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error {
p, err := loadPage(name)
if err != nil {
if mandatory {
p = &Page{Name: name, Body: []byte(link)}
return p.save()
}
// Skip non-existing files: no error
return nil
}
org := string(c.Body)
org := string(p.Body)
addLinkToPage(p, link, re)
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
func addLinkToPage(p *Page, link string, re *regexp.Regexp) {
// if a link exists, that's the place to insert the new link (in which case loc[0] and loc[1] differ)
loc := re.FindIndex(c.Body)
loc := re.FindIndex(p.Body)
// if no link exists, find a good place to insert it
if loc == nil {
// locate the beginning of the list to insert the line
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(c.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(c.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
loc[1] = loc[0]
// locate the list items
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n?`)
items := re.FindAllIndex(p.Body, -1)
first := false
pos := -1
// skip newer items
for i, it := range items {
// break if the current line is older (earlier in sort order)
stop := string(p.Body[it[0]:it[1]]) < link
// before the first match is always a good insert point
if i == 0 {
pos = it[0]
first = true
}
// if we're not stopping, then after the current item is a good insert point
if !stop {
pos = it[1]
first = false
} else {
break
}
}
// otherwise it's at the end of the list, after the last item
if pos == -1 && len(items) > 0 {
pos = items[len(items)-1][1]
first = false
}
// if no list was found, use the end of the page
if pos == -1 {
pos = len(p.Body)
first = true
}
if first {
p.Body, pos = ensureTwoNewlines(p.Body, pos)
}
// mimic a zero-width match at the insert point
loc = []int{pos, pos}
}
// start with new page content
r := []byte("")
// append up to the insertion point
r = append(r, c.Body[:loc[0]]...)
r = append(r, p.Body[:loc[0]]...)
// append link
r = append(r, []byte(link)...)
// append the rest
r = append(r, c.Body[loc[1]:]...)
c.Body = r
// only save if something changed
if string(c.Body) != org {
return c.save()
}
return nil
r = append(r, p.Body[loc[1]:]...)
p.Body = r
}
// ensureTwoNewlines makes sure that the two bytes before pos in buf are newlines. If the are not, newlines are inserted
// and pos is increased. The new buf and pos is returned.
func ensureTwoNewlines(buf []byte, pos int) ([]byte, int) {
var insert []byte
if pos >= 1 && buf[pos-1] != '\n' {
// add two newlines if buf doesn't end with a newline
insert = []byte("\n\n")
} else if pos >= 2 && buf[pos-2] != '\n' {
// add one newline if Body ends with just one newline
insert = []byte("\n")
}
if insert != nil {
r := []byte("")
r = append(r, buf[:pos]...)
r = append(r, insert...)
r = append(r, buf[pos:]...)
buf = r
pos += len(insert)
}
return buf, pos
}

View File

@@ -1,41 +1,94 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// Note TestEditSaveChanges and TestAddAppendChanges.
func TestAddLinkToPageWithNoList(t *testing.T) {
// no newlines
title := "# Test"
p := &Page{Body: []byte(title)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+"\n\n"+link, string(p.Body))
}
func TestAddLinkToPageWithOlderLink(t *testing.T) {
// one newline
title := "# Test\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-10\)\n`)
link := "* [2025-08-10](2025-08-10)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+"\n"+link+old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-09\)\n`)
link := "* [2025-08-09](2025-08-09)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+new+link+old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks2(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n* [2025-08-09](2025-08-09)\n"
old := "* [2025-08-07](2025-08-07)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+new+link+old, string(p.Body))
}
func TestAddLinkToPageAtTheEnd(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-07\)\n`)
link := "* [2025-08-07](2025-08-07)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title+new+old+link, string(p.Body))
}
func TestChanges(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
restore(t, "index.md")
os.Remove("changes.md")
cleanup(t, "testdata/washing")
today := time.Now().Format(time.DateOnly)
p := &Page{Name: "testdata/" + today + "-machine",
p := &Page{Name: "testdata/washing/" + today + "-machine",
Body: []byte(`# Washing machine
Churning growling thing
Water spraying in a box
Out of sight and dark`)}
p.notify()
// Link added to changes.md file
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/washing/changes.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "[Washing machine](testdata/"+today+"-machine)")
assert.Contains(t, string(s), "[Washing machine]("+today+"-machine)")
// Link added to index.md file
s, err = os.ReadFile("index.md")
s, err = os.ReadFile("testdata/washing/index.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "\n* [Washing machine](testdata/"+today+"-machine)\n")
// New index contains just the link
assert.Equal(t, string(s), "* [Washing machine]("+today+"-machine)\n")
}
func TestChangesWithHashtag(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
restore(t, "index.md")
os.Remove("changes.md")
cleanup(t, "testdata/changes")
intro := "# Haiku\n"
line := "* [Hotel room](testdata/changes/2023-10-27-hotel)\n"
line := "* [Hotel room](2023-10-27-hotel)\n"
h := &Page{Name: "testdata/changes/Haiku", Body: []byte(intro)}
h.save()
p := &Page{Name: "testdata/changes/2023-10-27-hotel",
@@ -46,154 +99,165 @@ Home away from home
#Haiku #Poetry`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
assert.Contains(t, string(s), line)
s, err = os.ReadFile("testdata/changes/Haiku.md")
assert.NoError(t, err)
assert.Equal(t, intro+line, string(s))
// ensure an empty line when adding at the end of the page
assert.Equal(t, intro+"\n"+line, string(s))
assert.NoFileExists(t, "testdata/changes/Poetry.md")
}
func TestChangesWithList(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](change)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+d+line), 0644)
line := "* [a change](change)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+line, string(s))
assert.Equal(t, intro+d+newLine+line, string(s))
}
func TestChangesWithOldList(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](change)\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+y+line), 0644)
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+"\n"+y+line, string(s))
assert.Equal(t, intro+d+newLine+"\n"+y+line, string(s))
}
func TestChangesWithOldDisappearingListAtTheEnd(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](testdata/changes/alex)\n"
line := "* [a change](alex)\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+y+line), 0644)
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line, string(s))
assert.Equal(t, intro+d+newLine, string(s))
}
func TestChangesWithOldDisappearingListInTheMiddle(t *testing.T) {
cleanup(t, "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](testdata/changes/alex)\n"
other := "* [other change](testdata/changes/whatever)\n"
line := "* [a change](alex)\n"
other := "* [other change](whatever)\n"
yy := "## " + time.Now().Add(-48*time.Hour).Format(time.DateOnly) + "\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+y+line+"\n"+yy+other), 0644)
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line+"\n"+yy+other), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line+"\n"+yy+other, string(s))
assert.Equal(t, intro+d+newLine+"\n"+yy+other, string(s))
}
func TestChangesWithListAtTheTop(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
line := "* [a change](change)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(line), 0644)
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// new line was added at the top, no error due to missing introduction
assert.Equal(t, d+new_line+line, string(s))
assert.Equal(t, d+newLine+line, string(s))
}
func TestChangesWithNoList(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph."
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro), 0644)
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// into is still there and a new list was started
assert.Equal(t, intro+"\n\n"+d+new_line, string(s))
assert.Equal(t, intro+"\n\n"+d+newLine, string(s))
}
func TestChangesWithUpdate(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](testdata/changes/whatever)\n"
other := "* [other change](whatever)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
line := "* [a change](testdata/changes/alex)\n"
os.WriteFile("changes.md", []byte(intro+d+other+line), 0644)
line := "* [a change](alex)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+other+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// the change was already listed, but now it moved up and has a new title
assert.Equal(t, intro+d+new_line+other, string(s))
assert.Equal(t, intro+d+newLine+other, string(s))
}
func TestChangesWithNoChangeToTheOrder(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](testdata/changes/whatever)\n"
line := "* [a change](testdata/changes/alex)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.WriteFile("changes.md", []byte(intro+d+line+other), 0644)
line := "* [a change](alex)\n"
other := "* [other change](whatever)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line+other), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](testdata/changes/alex)\n"
newLine := "* [testdata/changes/alex](alex)\n"
// the change was already listed at the top, so just use the new title
assert.Equal(t, intro+d+new_line+other, string(s))
assert.Equal(t, intro+d+newLine+other, string(s))
// since the file has changed, a backup was necessary
assert.FileExists(t, "testdata/changes/changes.md~")
}
func TestChangesWithNoChanges(t *testing.T) {
cleanup(t, "testdata/changes", "changes.md", "changes.md~")
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](testdata/changes/whatever)\n"
line := "* [a change](testdata/changes/alex)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
os.Remove("changes.md~")
os.WriteFile("changes.md", []byte(intro+d+line+other), 0644)
line := "* [a change](alex)\n"
other := "* [other change](whatever)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line+other), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte("# a change\nHallo!")}
p.notify()
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
// the change was already listed at the top, so no change was necessary
assert.Equal(t, intro+d+line+other, string(s))
// since the file hasn't changed, no backup was necessary
assert.NoFileExists(t, "changes.md~")
assert.NoFileExists(t, "testdata/changes/changes.md~")
}

View File

@@ -1,15 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
// Use go test -race to see whether this is a race condition.
func TestLoadAndSearch(t *testing.T) {
index.reset()
go index.load()
q := "Oddµ"
pages, _ := search(q, "", 1, false)
assert.Zero(t, len(pages))
}

42
diff.go
View File

@@ -2,13 +2,14 @@ package main
import (
"bytes"
"github.com/sergi/go-diff/diffmatchpatch"
"html"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/sergi/go-diff/diffmatchpatch"
)
func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
@@ -18,22 +19,19 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
return
}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, "diff", p)
p.renderHTML()
renderTemplate(w, p.Dir(), "diff", p)
}
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
// 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 {
name, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
a := name + ".md~"
fp := filepath.FromSlash(p.Name)
a := fp + ".md~"
t1, err := os.ReadFile(a)
if err != nil {
return template.HTML("Cannot read " + a + ", so the page is new.")
}
b := name + ".md"
b := fp + ".md"
t2, err := os.ReadFile(b)
if err != nil {
return template.HTML("Cannot read " + b + ", so the page was deleted.")
@@ -44,23 +42,23 @@ func (p *Page) Diff() template.HTML {
}
func diff2html(diffs []diffmatchpatch.Diff) string {
var buff bytes.Buffer
var buf bytes.Buffer
for _, item := range diffs {
text := strings.ReplaceAll(html.EscapeString(item.Text), "\n", "<br>")
switch item.Type {
case diffmatchpatch.DiffInsert:
_, _ = buff.WriteString("<ins>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</ins>")
_, _ = buf.WriteString("<ins>")
_, _ = buf.WriteString(text)
_, _ = buf.WriteString("</ins>")
case diffmatchpatch.DiffDelete:
_, _ = buff.WriteString("<del>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</del>")
_, _ = buf.WriteString("<del>")
_, _ = buf.WriteString(text)
_, _ = buf.WriteString("</del>")
case diffmatchpatch.DiffEqual:
_, _ = buff.WriteString("<span>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</span>")
_, _ = buf.WriteString("<span>")
_, _ = buf.WriteString(text)
_, _ = buf.WriteString("</span>")
}
}
return buff.String()
return buf.String()
}

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: 65ch; 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

@@ -1,10 +1,12 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDiff(t *testing.T) {
@@ -24,7 +26,7 @@ Oh so fresh, so warm.`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/bread", nil)
assert.Contains(t, body, `<del>breathe</del>`)
assert.Contains(t, body, `<ins>whisper</ins>`)
@@ -47,7 +49,7 @@ Mispronouncing words`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
assert.Contains(t, body, `<del>s</del>`)
assert.Contains(t, body, `<ins>ce</ins>`)
@@ -70,6 +72,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 +81,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,22 +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="" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
Text" lang="{{.Language}}" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
<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,10 +5,8 @@ import (
"net/http"
)
// editHandler uses the "edit.html" template to present an edit page.
// When editing, the page title is not overriden by a title in the
// text. Instead, the page name is used. The edit is saved using the
// saveHandler.
// editHandler uses the "edit.html" template to present an edit page. When editing, the page title is not overriden by a
// title in the text. Instead, the page name is used. The edit is saved using the saveHandler.
func editHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
@@ -16,24 +14,32 @@ func editHandler(w http.ResponseWriter, r *http.Request, name string) {
} else {
p.handleTitle(false)
}
renderTemplate(w, "edit", p)
renderTemplate(w, p.Dir(), "edit", p)
}
// saveHandler takes the "body" form parameter and saves it. The
// browser is redirected to the page view.
// saveHandler takes the "body" form parameter and saves it. The browser is redirected to the page view. This is similar
// to the appendHandler.
func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
body := r.FormValue("body")
p := &Page{Name: name, Body: []byte(body)}
err := p.save()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", name, "by", username)
} else {
log.Println("Save", name)
}
if r.FormValue("notify") == "on" {
err = p.notify()
err = p.notify() // errors have already been logged, so no logging here
if err != nil {
log.Println("notify:", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
http.Redirect(w, r, "/view/"+nameEscape(name), http.StatusFound)
}

View File

@@ -1,11 +1,13 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestEditSave(t *testing.T) {
@@ -15,48 +17,72 @@ func TestEditSave(t *testing.T) {
data.Set("body", "Hallo!")
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, true),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/save/alex", nil, 200)
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true),
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil),
"Hallo!")
// Delete the page and you're sent to the empty page
data.Set("body", "")
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Viewing the non-existing page redirects to the edit page (like in the beginning)
HTTPRedirectTo(t, makeHandler(viewHandler, true),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
func TestEditSaveChanges(t *testing.T) {
cleanup(t, "testdata/notification", "changes.md")
restore(t, "index.md")
os.Remove("changes.md")
cleanup(t, "testdata/notification")
data := url.Values{}
data.Set("body", "Hallo!")
data.Add("notify", "on")
today := time.Now().Format("2006-01-02")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/notification/2023-10-28-alex",
data, "/view/testdata/notification/2023-10-28-alex")
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/notification/"+today,
data, "/view/testdata/notification/"+today)
// The changes.md file was created
s, err := os.ReadFile("changes.md")
s, err := os.ReadFile("testdata/notification/changes.md")
assert.NoError(t, err)
d := time.Now().Format(time.DateOnly)
assert.Equal(t, "# Changes\n\n## "+d+
"\n* [testdata/notification/2023-10-28-alex](testdata/notification/2023-10-28-alex)\n",
"\n* [testdata/notification/"+today+"]("+today+")\n",
string(s))
// Link added to index.md file
s, err = os.ReadFile("index.md")
s, err = os.ReadFile("testdata/notification/index.md")
assert.NoError(t, err)
assert.Contains(t, string(s),
"\n* [testdata/notification/2023-10-28-alex](testdata/notification/2023-10-28-alex)\n")
// New index contains just the link
assert.Equal(t, string(s), "* [testdata/notification/"+today+"]("+today+")\n")
}
// Test the following view.html:
// <form action="/edit/" method="GET">
//
// <label for="id">New page:</label>
// <input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Dir}}/{{.Today}}" required>
// <button>Edit</button>
//
// </form>
func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
data := url.Values{}
data.Set("id", "testdata/id/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusBadRequest,
"No slashes in id")
data.Set("id", ".alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusForbidden,
"No hidden files")
data.Set("id", "alex")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/id/", data),
"Editing testdata/id/alex")
}

109
export_cmd.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"context"
"flag"
"fmt"
htmlTemplate "html/template"
"io"
"os"
"strings"
textTemplate "text/template"
"time"
"github.com/google/subcommands"
)
type exportCmd struct {
templateName string
}
func (cmd *exportCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&cmd.templateName, "template", "feed.html", "template filename")
}
func (*exportCmd) Name() string { return "export" }
func (*exportCmd) Synopsis() string { return "export the whole site as one big RSS feed" }
func (*exportCmd) Usage() string {
return `export:
Export the entire site as one big RSS feed. This may allow you to
import the whole site into a different content management system.
The feed contains every page, in HTML format, so the Markdown files
are part of the feed, but none of the other files.
The RSS feed is printed to stdout so you probably want to redirect
it:
oddmu export > /tmp/export.rss
`
}
func (cmd *exportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
index.load()
return exportCli(os.Stdout, cmd.templateName, &index)
}
// exportCli runs the export command on the command line. In order to make testing easier, it takes a Writer and an
// indexStore. The Writer is important so that test code can provide a buffer instead of os.Stdout; the indexStore is
// important so that test code can ensure no other test running in parallel can interfere with the list of known pages
// (by adding or deleting pages).
func exportCli(w io.Writer, templateName string, idx *indexStore) subcommands.ExitStatus {
loadLanguages()
feed := new(Feed)
items := []Item{}
// feed.Name remains unset
feed.Date = time.Now().Format(time.RFC3339)
for name, title := range idx.titles {
if name == "index" {
feed.Title = title
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
p.handleTitle(false)
p.renderHTML()
fi, err := os.Stat(name + ".md")
if err != nil {
fmt.Fprintf(os.Stderr, "Stat %s: %s\n", name, err)
return subcommands.ExitFailure
}
it := Item{Date: fi.ModTime().Format(time.RFC3339)}
it.Title = p.Title
it.Name = p.Name
it.Body = p.Body
it.HTML = htmlTemplate.HTML(htmlTemplate.HTMLEscaper(p.HTML))
it.Hashtags = p.Hashtags
items = append(items, it)
}
feed.Items = items
// No effort is made to work with the templates var.
if strings.HasSuffix(templateName, ".html") ||
strings.HasSuffix(templateName, ".xml") ||
strings.HasSuffix(templateName, ".rss") {
w.Write([]byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"))
t, err := htmlTemplate.ParseFiles(templateName)
if err != nil {
fmt.Fprintf(os.Stderr, "Parsing %s: %s\n", templateName, err)
return subcommands.ExitFailure
}
err = t.Execute(w, feed)
if err != nil {
fmt.Fprintf(os.Stderr, "Writing feed: %s\n", err)
return subcommands.ExitFailure
}
} else {
t, err := textTemplate.ParseFiles(templateName)
if err != nil {
fmt.Fprintf(os.Stderr, "Parsing %s: %s\n", templateName, err)
return subcommands.ExitFailure
}
err = t.Execute(w, feed)
if err != nil {
fmt.Fprintf(os.Stderr, "Writing feed: %s\n", err)
return subcommands.ExitFailure
}
}
return subcommands.ExitSuccess
}

56
export_cmd_test.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"bytes"
"os"
"regexp"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
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>")
}
func TestExportCmdLanguage(t *testing.T) {
os.Setenv("ODDMU_LANGUAGES", "de,en")
loadLanguages()
p := Page{Body: []byte("This is an English text. All right then!")}
it := Item{Page: p}
assert.Equal(t, "en", it.Language())
}
func TestExportCmdJsonFeed(t *testing.T) {
cleanup(t, "testdata/json")
os.Mkdir("testdata/json", 0755)
assert.NoError(t, os.WriteFile("testdata/json/template.json", []byte(`{
"version": "https://jsonfeed.org/version/1.1",
"title": "{{.Title}}",
"home_page_url": "https://alexschroeder.ch",
"others": [],
"items": [{{range .Items}}
{
"id": "{{.Name}}",
"url": "https://alexschroeder.ch/view/{{.Name}}",
"title": "{{.Title}}",
"language": "{{.Language}}"
"date_modified": "{{.Date}}",
"content_html": "{{.HTML}}",
"tags": [{{range .Hashtags}}"{{.}}",{{end}}""],
},{{end}}
{}
]
}
`), 0644))
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
}

131
feed.go
View File

@@ -2,33 +2,90 @@ package main
import (
"bytes"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"html/template"
"os"
"path"
"path/filepath"
"strconv"
"time"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
)
type dateSource int
const (
// ModTime means that the feed item date is based on the page file's last modification date.
ModTime dateSource = iota
// URL means that the feed item date is based on the ISO date contained in the page name.
URL
)
// Item is a Page plus a Date.
type Item struct {
// Page is the page being used as the feed item.
Page
// Date is the last modification date of the file storing the page. As the pages used by Oddmu are plain
// Markdown files, they don't contain any metadata. Instead, the last modification date of the file is used.
// This makes it work well with changes made to the files outside of Oddmu.
Date string
}
// Feed is an Item used for the feed itself, plus an array of items based on the linked pages.
type Feed struct {
// Item is the page containing the list of links. It's title is used for the feed and it's last modified time is
// used for the publication date. Thus, if linked pages change but the page with the links doesn't change, the
// publication date remains unchanged.
Item
// Items are based on the pages linked in list items starting with an asterisk ("*"). Links in
// list items starting with a minus ("-") are ignored!
Items []Item
// From is where the item number where the feed starts. It defaults to 0. Prev and From are the item numbers of
// The previous and the next page of the feed. N is the number of items per page. Next goes further into the
// past.
Prev, Next, From, N int
// When paging through the index or year pages, link to the next or previous years. NextYear goes further into
// the past (is smaller).
PrevYear, NextYear int
// Complete is set when there is no pagination.
Complete bool
}
func feed(p *Page, ti time.Time) *Feed {
// feed returns a RSS 2.0 feed for any page. The feed items it contains are the pages linked from in list items starting
// with an asterisk ("*"). The feed starts from a certain item and contains n items. If n is 0, the feed is complete
// (unpaginated). The
func feed(p *Page, ti time.Time, from, n int, source dateSource) *Feed {
feed := new(Feed)
feed.Name = p.Name
feed.Title = p.Title
feed.Date = ti.Format(time.RFC1123Z)
feed.From = from
feed.N = n
switch {
case n == 0:
feed.Complete = true
case from > n:
feed.Prev = from - n
default:
year, err := p.BlogYear()
if err == nil && p.ArchiveExists(year+1) {
feed.PrevYear = year + 1
}
}
to := from + n
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
items := make([]Item, 0)
inListItem := false
i := 0
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
// set the flag if we're in a list item
listItem, ok := node.(*ast.ListItem)
@@ -40,33 +97,81 @@ func feed(p *Page, ti time.Time) *Feed {
if !inListItem || !entering {
return ast.GoToNext
}
// if we're in a link and it's local
// if we're in a link and it's not local
link, ok := node.(*ast.Link)
if !ok || bytes.Contains(link.Destination, []byte("//")) {
return ast.GoToNext
}
name := path.Join(path.Dir(p.Name), string(link.Destination))
fi, err := os.Stat(name + ".md")
if err != nil {
// if we're too early or too late
i++
if i <= from {
return ast.GoToNext
}
if n > 0 && i > to {
// set if it's likely that more items exist
feed.Next = to
return ast.Terminate
}
// i counts links, not actual existing pages
name := path.Join(p.Dir(), string(link.Destination))
p2, err := loadPage(name)
if err != nil {
return ast.GoToNext
}
p2.handleTitle(false)
p2.renderHtml()
it := Item{Date: fi.ModTime().Format(time.RFC1123Z)}
p2.renderHTML()
date, err := p2.Date(source)
if err != nil {
return ast.GoToNext
}
it := Item{Date: date.Format(time.RFC1123Z)}
it.Title = p2.Title
it.Name = p2.Name
it.Html = template.HTML(template.HTMLEscaper(p2.Html))
it.HTML = template.HTML(template.HTMLEscaper(p2.HTML))
it.Hashtags = p2.Hashtags
items = append(items, it)
if len(items) >= 10 {
return ast.Terminate
}
return ast.GoToNext
})
// If there are no more "next" links but there is a next page, add it.
if feed.Next == 0 {
year, err := p.BlogYear()
if err == nil && p.ArchiveExists(year-1) {
feed.NextYear = year - 1
}
}
feed.Items = items
return feed
}
// Date returns the page's last modification date if the data source is ModTime. If the data source is URL, then the
// first 10 characters are parsed as an ISO date string and the time returned is for that date, 0:00, UTC.
func (p *Page) Date(source dateSource) (time.Time, error) {
if source == URL && p.IsBlog() {
name := path.Base(p.Name)
return time.Parse(time.DateOnly, name[0:10])
}
return p.ModTime()
}
// BlogYear returns the current year if the page name is "index". If the page name is a number such as "2026" then
// this is parsed as an integer and returned.
func (p *Page) BlogYear() (int, error) {
name := path.Base(p.Name)
if name == "index" {
return time.Now().Year(), nil
}
ui, err := strconv.ParseUint(name, 10, 16)
if err == nil {
return int(ui), nil
}
return 0, err
}
// ArchiveExists returns true if a page exists in the same directory as the current one with a page name matching
// the year given.
func (p *Page) ArchiveExists(year int) bool {
name := path.Join(p.Dir(), strconv.Itoa(year))
fp := filepath.FromSlash(name) + ".md"
_, err := os.Stat(fp)
return err == nil
}

View File

@@ -1,11 +1,18 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"
xmlns:fh="http://purl.org/syndication/history/1.0">
<channel>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<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"/>{{if .From}}
<atom:link href="https://example.org/view/{{.Path}}.rss?from={{.Prev}}&amp;n={{.N}}" rel="previous" type="application/rss+xml"/>{{end}}{{if .PrevYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.PrevYear}}.rss?n={{.N}}" rel="previous" type="application/rss+xml"/>{{end}}{{if .Next}}
<atom:link href="https://example.org/view/{{.Path}}.rss?from={{.Next}}&amp;n={{.N}}" rel="next" type="application/rss+xml"/>{{end}}{{if .NextYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.NextYear}}.rss?n={{.N}}" rel="next" type="application/rss+xml"/>{{end}}{{if .Complete}}
<generator>Oddμ https://src.alexschroeder.ch/oddmu.git/</generator>
<fh:complete/>{{end}}
<description>This is the digital garden of Your Name.</description>
<image>
<url>https://example.org/view/logo.jpg</url>
@@ -15,9 +22,9 @@
{{range .Items}}
<item>
<title>{{.Title}}</title>
<link>https://example.org/view/{{.Name}}</link>
<guid>https://example.org/view/{{.Name}}</guid>
<description>{{.Html}}</description>
<link>https://example.org/view/{{.Path}}</link>
<guid>https://example.org/view/{{.Path}}</guid>
<description>{{.HTML}}</description>
<pubDate>{{.Date}}</pubDate>
{{range .Hashtags}}
<category>{{.}}</category>

90
feed_cmd.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/google/subcommands"
)
type feedCmd struct {
}
func (*feedCmd) Name() string { return "feed" }
func (*feedCmd) Synopsis() string { return "render a page as feed" }
func (*feedCmd) Usage() string {
return `feed <page name> ...:
Render one or more pages as a single feed.
Use a single - to read Markdown from stdin.
`
}
func (cmd *feedCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *feedCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if len(f.Args()) == 0 {
fmt.Fprint(os.Stderr, cmd.Usage())
return subcommands.ExitFailure
}
return feedCli(os.Stdout, f.Args())
}
func feedCli(w io.Writer, args []string) subcommands.ExitStatus {
if len(args) == 1 && args[0] == "-" {
body, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read from stdin: %s\n", err)
return subcommands.ExitFailure
}
p := &Page{Name: "stdin", Body: body}
return p.printFeed(w, time.Now())
}
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)
p.handleTitle(false)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return subcommands.ExitFailure
}
ti, _ := p.ModTime()
status := p.printFeed(w, ti)
if status != subcommands.ExitSuccess {
return status
}
}
return subcommands.ExitSuccess
}
// printFeed prints the complete feed for a page (unpaginated).
func (p *Page) printFeed(w io.Writer, ti time.Time) subcommands.ExitStatus {
f := feed(p, ti, 0, 0, URL)
if len(f.Items) == 0 {
fmt.Fprintf(os.Stderr, "Empty feed for %s\n", p.Name)
return subcommands.ExitFailure
}
_, err := w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot write prefix: %s\n", err)
return subcommands.ExitFailure
}
initTemplates()
templates.RLock()
defer templates.RUnlock()
err = templates.template["feed.html"].Execute(w, f)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute template: %s\n", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

26
feed_cmd_test.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
func TestFeedCmd(t *testing.T) {
cleanup(t, "testdata/complete")
p := &Page{Name: "testdata/complete/2025-12-01", Body: []byte("# 2025-12-01\n")}
p.save()
p = &Page{Name: "testdata/complete/index", Body: []byte(`# Index
* [2025-12-01](2025-12-01)
`)}
p.save()
b := new(bytes.Buffer)
s := feedCli(b, []string{"testdata/complete/index.md"})
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, b.String(), "<fh:complete/>")
assert.Contains(t, b.String(), "<title>2025-12-01</title>")
assert.Contains(t, b.String(), "<pubDate>Mon, 01 Dec 2025 00:00:00") // ignore timezone
}

View File

@@ -1,19 +1,27 @@
package main
import (
"github.com/stretchr/testify/assert"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index.rss", nil),
"Welcome to Oddµ")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index.rss", nil),
"Welcome to Oddμ")
}
func TestNoFeed(t *testing.T) {
assert.HTTPStatusCode(t,
makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
}
func TestFeedItems(t *testing.T) {
cleanup(t, "testdata/feed")
index.load()
p1 := &Page{Name: "testdata/feed/cactus", Body: []byte(`# Cactus
Green head and white hair
@@ -38,12 +46,157 @@ Writing poems about plants.
* [My Dragon Tree](dragon)`)}
p3.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/feed/plants.rss", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/feed/plants.rss", nil)
assert.Contains(t, body, "<title>Plants</title>")
assert.Contains(t, body, "<title>Cactus</title>")
assert.Contains(t, body, "<title>Dragon</title>")
assert.Contains(t, body, "&lt;h1&gt;Cactus&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1&gt;Dragon&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1 id=&#34;cactus&#34;&gt;Cactus&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1 id=&#34;dragon&#34;&gt;Dragon&lt;/h1&gt;")
assert.Contains(t, body, "<category>Succulent</category>")
assert.Contains(t, body, "<category>Palmtree</category>")
}
func TestFeedPagination(t *testing.T) {
cleanup(t, "testdata/pagination")
p := &Page{Name: "testdata/pagination/one", Body: []byte("# One\n")}
p.save()
p = &Page{Name: "testdata/pagination/two", Body: []byte("# Two\n")}
p.save()
p = &Page{Name: "testdata/pagination/three", Body: []byte("# Three\n")}
p.save()
p = &Page{Name: "testdata/pagination/four", Body: []byte("# Four\n")}
p.save()
p = &Page{Name: "testdata/pagination/five", Body: []byte("# Five\n")}
p.save()
p = &Page{Name: "testdata/pagination/six", Body: []byte("# Six\n")}
p.save()
p = &Page{Name: "testdata/pagination/seven", Body: []byte("# Seven\n")}
p.save()
p = &Page{Name: "testdata/pagination/eight", Body: []byte("# Eight\n")}
p.save()
p = &Page{Name: "testdata/pagination/nine", Body: []byte("# Nine\n")}
p.save()
p = &Page{Name: "testdata/pagination/ten", Body: []byte("# Ten\n")}
p.save()
p = &Page{Name: "testdata/pagination/index", Body: []byte(`# Index
* [one](one)
* [two](two)
* [three](three)
* [four](four)
* [five](five)
* [six](six)
* [seven](seven)
* [eight](eight)
* [nine](nine)
* [ten](ten)
`)}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", nil)
assert.Contains(t, body, "<title>One</title>")
assert.Contains(t, body, "<title>Ten</title>")
assert.NotContains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=10&n=10" rel="next" type="application/rss+xml"/>`)
p = &Page{Name: "testdata/pagination/eleven", Body: []byte("# Eleven\n")}
p.save()
p = &Page{Name: "testdata/pagination/index", Body: []byte(`# Index
* [one](one)
* [two](two)
* [three](three)
* [four](four)
* [five](five)
* [six](six)
* [seven](seven)
* [eight](eight)
* [nine](nine)
* [ten](ten)
* [eleven](eleven)
`)}
p.save()
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", nil)
assert.NotContains(t, body, "<title>Eleven</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=10&amp;n=10" rel="next" type="application/rss+xml"/>`)
params := url.Values{}
params.Set("n", "0")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.Contains(t, body, "<title>Eleven</title>")
assert.Contains(t, body, `<fh:complete/>`)
params = url.Values{}
params.Set("n", "3")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.Contains(t, body, "<title>One</title>")
assert.Contains(t, body, "<title>Three</title>")
assert.NotContains(t, body, "<title>Four</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=3&amp;n=3" rel="next" type="application/rss+xml"/>`)
params = url.Values{}
params.Set("from", "3")
params.Set("n", "3")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.NotContains(t, body, "<title>Three</title>")
assert.Contains(t, body, "<title>Four</title>")
assert.Contains(t, body, "<title>Six</title>")
assert.NotContains(t, body, "<title>Seven</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=0&amp;n=3" rel="previous" type="application/rss+xml"/>`)
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=6&amp;n=3" rel="next" type="application/rss+xml"/>`)
params = url.Values{}
params.Set("from", "2")
params.Set("n", "3")
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/pagination/index.rss", params)
assert.NotContains(t, body, "<title>Two</title>")
assert.Contains(t, body, "<title>Three</title>")
assert.Contains(t, body, "<title>Five</title>")
assert.NotContains(t, body, "<title>Six</title>")
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=0&amp;n=3" rel="previous" type="application/rss+xml"/>`)
assert.Contains(t, body, `<atom:link href="https://example.org/view/testdata/pagination/index.rss?from=5&amp;n=3" rel="next" type="application/rss+xml"/>`)
}
func TestFeedYearArchives(t *testing.T) {
cleanup(t, "testdata/archives")
p := &Page{Name: "testdata/archives/index", Body: []byte(`# Archives
my bent fingers hurt
keyboard rattling in the dark
but no child in sight
`)}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET",
"/view/testdata/archives/index.rss", nil)
year, err := p.BlogYear()
assert.Greater(t, year, 0)
assert.NoError(t, err)
prevLink := fmt.Sprintf(`<atom:link href="https://example.org/view/testdata/archives/%d.rss?n=10" rel="previous" type="application/rss+xml"/>`, year+1)
nextLink := fmt.Sprintf(`<atom:link href="https://example.org/view/testdata/archives/%d.rss?n=10" rel="next" type="application/rss+xml"/>`, year-1)
assert.NotContains(t, body, prevLink)
assert.NotContains(t, body, nextLink)
p = &Page{Name: fmt.Sprintf("testdata/archives/%d", year-1), Body: []byte(`# Previously
I have seen it all
invasion and denial
and cold winter hearts
`)}
p.save()
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET",
"/view/testdata/archives/index.rss", nil)
assert.NotContains(t, body, prevLink)
assert.Contains(t, body, nextLink)
p = &Page{Name: fmt.Sprintf("testdata/archives/%d", year+1), Body: []byte(`# Coming
A night of thunder
lightning, children, it's the war
of our New Year's Eve
`)}
p.save()
body = assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET",
"/view/testdata/archives/index.rss", nil)
assert.Contains(t, body, prevLink)
assert.Contains(t, body, nextLink)
}

44
go.mod
View File

@@ -1,38 +1,40 @@
module alexschroeder.ch/cgit/oddmu
module src.alexschroeder.ch/oddmu
go 1.21.0
go 1.25
require (
github.com/anthonynsimon/bild v0.13.0
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
github.com/charmbracelet/lipgloss v0.9.1
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd
github.com/disintegration/imaging v1.6.2
github.com/edwvee/exiffix v0.0.0-20240229113213-0dbb146775be
github.com/fsnotify/fsnotify v1.9.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gen2brain/heic v0.4.9
github.com/gen2brain/webp v0.5.5
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.26
github.com/microcosm-cc/bluemonday v1.0.27
github.com/muesli/reflow v0.3.0
github.com/pemistahl/lingua-go v1.4.0
github.com/sergi/go-diff v1.3.1
github.com/sergi/go-diff v1.4.0
github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/shopspring/decimal v1.3.1 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

112
go.sum
View File

@@ -1,104 +1,78 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8=
github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd h1:SxkQeH4jjXT0zMgiRgkiIQjIvWfe9vXuTAmE3cfcQrU=
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd/go.mod h1:p1sbxRy+MY71fEWHcfRmerC8WUYXDFCExF9A7aXwp98=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd h1:PppHBegd3uPZ3Y/Iax/2mlCFJm1w4Qf/zP1MdW4ju2o=
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/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.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edwvee/exiffix v0.0.0-20240229113213-0dbb146775be h1:FNPYI8/ifKGW7kdBdlogyGGaPXZmOXBbV1uz4Amr3s0=
github.com/edwvee/exiffix v0.0.0-20240229113213-0dbb146775be/go.mod h1:G3dK5MziX9e4jUa8PWjowCOPCcyQwxsZ5a0oYA73280=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/heic v0.4.9 h1:sHM7kjMV2+AlrSfYNsJiD0NrqAJqIMWAOqMSZ0HXrU8=
github.com/gen2brain/heic v0.4.9/go.mod h1:0/0SrVQnUhOA3ekFY5/lApZYniF/DsgS3g9COWe83dM=
github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

208
hashtags_cmd.go Normal file
View File

@@ -0,0 +1,208 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
)
type hashtagsCmd struct {
update bool
dryRun bool
}
func (cmd *hashtagsCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.update, "update", false, "create and update hashtag pages")
f.BoolVar(&cmd.dryRun, "dry-run", false, "only report the changes it would make")
}
func (*hashtagsCmd) Name() string { return "hashtags" }
func (*hashtagsCmd) Synopsis() string { return "hashtag overview" }
func (*hashtagsCmd) Usage() string {
return `hashtags:
Count the use of all hashtags and list them, separated by a tabulator.
`
}
func (cmd *hashtagsCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if cmd.update {
return hashtagsUpdateCli(os.Stdout, cmd.dryRun)
}
return hashtagsCli(os.Stdout)
}
// hashtagsCli runs the hashtags command on the command line. It is used
// here with an io.Writer for easy testing.
func hashtagsCli(w io.Writer) subcommands.ExitStatus {
index.load()
index.RLock()
defer index.RUnlock()
type hashtag struct {
label string
count int
}
hashtags := []hashtag{}
for token, docids := range index.token {
hashtags = append(hashtags, hashtag{label: token, count: len(docids)})
}
sort.Slice(hashtags, func(i, j int) bool {
return hashtags[i].count > hashtags[j].count
})
fmt.Fprintln(w, "Rank\tHashtag\tCount")
for i, hashtag := range hashtags {
fmt.Fprintf(w, "%d\t%s\t%d\n", i+1, hashtag.label, hashtag.count)
}
return subcommands.ExitSuccess
}
// hashtagsUpdateCli runs the hashtags command on the command line and creates and updates the hashtag pages in the
// current directory. That is, pages in subdirectories are skipped! It is used here with an io.Writer for easy testing.
func hashtagsUpdateCli(w io.Writer, dryRun bool) subcommands.ExitStatus {
index.load()
// no locking necessary since this is for the command-line
namesMap := make(map[string]string)
for hashtag, docids := range index.token {
if len(docids) <= 5 {
if dryRun {
fmt.Fprintf(w, "Skipping #%s because there are not enough entries (%d)\n", hashtag, len(docids))
}
continue
}
title, ok := namesMap[hashtag]
if !ok {
title = hashtagName(namesMap, hashtag, docids)
namesMap[hashtag] = title
}
pageName := strings.ReplaceAll(title, " ", "_")
h, err := loadPage(pageName)
original := ""
new := false
if err != nil {
new = true
h = &Page{Name: pageName, Body: []byte("# " + title + "\n\n#" + pageName + "\n\nBlog posts:\n\n")}
} else {
original = string(h.Body)
}
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
if !p.IsBlog() {
continue
}
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
// I guess & used to get escaped and now no longer does
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + strings.ReplaceAll(esc, "&", "(&|%26)") + `\)\n`)
addLinkToPage(h, link, re)
}
// only save if something changed
if string(h.Body) != original {
if dryRun {
if new {
fmt.Fprintf(w, "Creating %s.md\n", title)
} else {
fmt.Fprintf(w, "Updating %s.md\n", title)
}
fn := h.Name + ".md"
edits := myers.ComputeEdits(span.URIFromPath(fn), original, string(h.Body))
diff := fmt.Sprint(gotextdiff.ToUnified(fn+"~", fn, original, edits))
fmt.Fprint(w, diff)
} else {
err = h.save()
if err != nil {
fmt.Fprintf(w, "Saving hashtag %s failed: %s", hashtag, err)
return subcommands.ExitFailure
}
}
}
}
return subcommands.ExitSuccess
}
// Go through all the documents in the same directory and look for hashtag matches in the rendered HTML in order to
// determine the most likely capitalization.
func hashtagName(namesMap map[string]string, hashtag string, docids []docid) string {
candidate := make(map[string]int)
var mostPopular string
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
continue
}
// parsing finds all the hashtags
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
if v, ok := node.(*ast.Link); ok {
for _, attr := range v.AdditionalAttributes {
if attr == `class="tag"` {
tagName := []byte("")
ast.WalkFunc(v, func(node ast.Node, entering bool) ast.WalkStatus {
if entering && node.AsLeaf() != nil {
tagName = append(tagName, node.AsLeaf().Literal...)
}
return ast.GoToNext
})
tag := string(tagName[1:])
if strings.EqualFold(hashtag, strings.ReplaceAll(tag, " ", "_")) {
_, ok := candidate[tag]
if ok {
candidate[tag]++
} else {
candidate[tag] = 1
}
}
}
}
}
}
return ast.GoToNext
})
count := 0
for key, val := range candidate {
if val > count {
mostPopular = key
count = val
}
}
// shortcut
if count >= 5 {
return mostPopular
}
}
return mostPopular
}

26
hashtags_cmd_test.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
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, "crocus\t")
}

View File

@@ -4,10 +4,8 @@ import (
"regexp"
)
// highlight splits the query string q into terms and highlights them
// using the bold tag. Return the highlighted string.
// This assumes that q already has all its meta characters quoted.
func highlight(q string, re *regexp.Regexp, s string) string {
// highlight matches for the regular expression using the bold tag.
func highlight(re *regexp.Regexp, s string) string {
s = re.ReplaceAllString(s, "<b>$1</b>")
return s
}

View File

@@ -16,7 +16,7 @@ No birds to be heard.`
q := "window"
re, _ := re(q)
r := highlight(q, re, s)
r := highlight(re, s)
if r != h {
t.Logf("The highlighting is wrong in 「%s」", r)
t.Fail()
@@ -35,7 +35,7 @@ I hear the fountain`
q := "shout out"
re, _ := re(q)
r := highlight(q, re, s)
r := highlight(re, s)
if r != h {
t.Logf("The highlighting is wrong in 「%s」", r)
t.Fail()

View File

@@ -4,54 +4,87 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"html/template"
"io"
"os"
"strings"
"github.com/google/subcommands"
)
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>:
Render a page as HTML.
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 {
for _, arg := range args {
p, err := loadPage(arg)
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 {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
fmt.Fprintf(w, "Cannot read from stdin: %s\n", err)
return subcommands.ExitFailure
}
initAccounts()
if useTemplate {
p.handleTitle(true)
p.renderHtml()
t := "view.html"
templates := loadTemplates()
err := templates.ExecuteTemplate(w, t, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, arg, err)
return subcommands.ExitFailure
}
} else {
// do not handle title
p.renderHtml()
fmt.Fprintln(w, p.Html)
p := &Page{Name: "stdin", Body: body}
return p.printHTML(w, template)
}
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(os.Stderr, "Cannot load %s: %s\n", name, err)
return subcommands.ExitFailure
}
status := p.printHTML(w, template)
if status != subcommands.ExitSuccess {
return status
}
}
return subcommands.ExitSuccess
}
func (p *Page) printHTML(w io.Writer, fn string) subcommands.ExitStatus {
if fn == "" {
// do not handle title
p.renderHTML()
_, err := fmt.Fprintln(w, p.HTML)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot write to stdout: %s\n", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}
p.handleTitle(true)
p.renderHTML()
t, err := template.ParseFiles(fn)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot parse template %s for %s: %s\n", fn, p.Name, err)
return subcommands.ExitFailure
}
err = t.Execute(w, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute template %s for %s: %s\n", fn, p.Name, err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

View File

@@ -2,23 +2,24 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
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>Welcome to Oddµ</h1>
r := `<h1 id="welcome-to-oddμ">Welcome to Oddμ</h1>
<p>Hello! 🙃</p>
<p>Check out the <a href="README">README</a>.</p>
<p>Check out the <a href="README">README</a> and <a href="themes">themes</a>.</p>
<p>Or <a href="test">create a new page</a>.</p>
`
assert.Equal(t, r, b.String())
assert.Equal(t, b.String(), r)
}

269
index.go
View File

@@ -5,24 +5,34 @@
package main
import (
"golang.org/x/exp/constraints"
"html/template"
"io/fs"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"golang.org/x/exp/constraints"
)
type docid uint
// Index contains the two maps used for search. Make sure to lock and
// unlock as appropriate.
type Index struct {
// ImageData holds the data used to search for images using the alt-text. Title is the alt-text; Name is the complete
// URL including path (which is important since the image link itself only has the URL relative to the page in which it
// is found; and Html is a copy of the Title with highlighting of a term as applied when searching. This is temporary.
// It depends on the fact that Title is always plain text.
type ImageData struct {
Title, Name string
HTML template.HTML
}
// indexStore controls access to the maps used for search. Make sure to lock and unlock as appropriate.
type indexStore struct {
sync.RWMutex
// next_id is the number of the next document added to the index
next_id docid
// nextID is the number of the next document added to the index
nextID docid
// index is an inverted index mapping tokens to document ids.
token map[string][]docid
@@ -32,28 +42,38 @@ type Index struct {
// titles is a map, mapping page names to titles.
titles map[string]string
// images is a map, mapping pages names to alt text to an array of image data.
images map[string][]ImageData
}
var index Index
var index indexStore
// reset resets the Index. This assumes that the index is locked!
func (idx *Index) reset() {
idx.token = nil
idx.documents = nil
idx.titles = nil
func init() {
index.reset()
}
// addDocument adds the text as a new document. This assumes that the
// index is locked!
func (idx *Index) addDocument(text []byte) docid {
id := idx.next_id
idx.next_id++
// reset the index. This assumes that the index is locked. It's useful for tests.
func (idx *indexStore) reset() {
idx.nextID = 0
idx.token = make(map[string][]docid)
idx.documents = make(map[docid]string)
idx.titles = make(map[string]string)
idx.images = make(map[string][]ImageData)
}
// 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.nextID
idx.nextID++
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
// always a new one, i.e. the last one, if at all.
if ids != nil && ids[len(ids)-1] == id {
if len(ids) > 0 && ids[len(ids)-1] == id {
continue
}
idx.token[token] = append(ids, id)
@@ -61,153 +81,131 @@ func (idx *Index) addDocument(text []byte) docid {
return id
}
// deleteDocument deletes the text as a new document. The id can no
// longer be used. This assumes that the index is locked!
func (idx *Index) deleteDocument(text []byte, id docid) {
for _, token := range hashtags(text) {
ids := index.token[token]
// Tokens can appear multiple times in a text but they
// can only be deleted once. deleted.
if ids == nil {
continue
}
// If the token appears only in this document, remove
// the whole entry.
// deleteDocument deletes all references to the id. The id can no longer be used. This assumes that the index is locked.
func (idx *indexStore) deleteDocument(id docid) {
// Looping through all tokens makes sense if there are few tokens (like hashtags). It doesn't make sense if the
// number of tokens is large (like for full-text search or a trigram index).
for token, ids := range idx.token {
// If the token appears only in this document, remove the whole entry.
if len(ids) == 1 && ids[0] == id {
delete(index.token, token)
delete(idx.token, token)
continue
}
// Otherwise, remove the token from the index.
i := sort.Search(len(ids), func(i int) bool { return ids[i] >= id })
if i != -1 && i < len(ids) && ids[i] == id {
copy(ids[i:], ids[i+1:])
index.token[token] = ids[:len(ids)-1]
idx.token[token] = ids[:len(ids)-1]
continue
}
// If none of the above, then our docid wasn't
// indexed. This shouldn't happen, either.
log.Printf("The index for token %s does not contain doc id %d", token, id)
}
delete(index.documents, id)
}
// add reads a file and adds it to the index. This must happen while
// the idx is locked.
func (idx *Index) add(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
filename := path
if info.IsDir() || strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".md") {
return nil
}
name := strings.TrimSuffix(filename, ".md")
p, err := loadPage(name)
if err != nil {
return err
}
p.handleTitle(false)
id := idx.addDocument(p.Body)
idx.documents[id] = p.Name
idx.titles[p.Name] = p.Title
return nil
}
// load loads all the pages and indexes them. This takes a while.
// It returns the number of pages indexed.
func (idx *Index) load() (int, error) {
// deletePageName determines the document id based on the page name and calls deleteDocument to delete all references.
// This assumes that the index is unlocked.
func (idx *indexStore) deletePageName(name string) {
idx.Lock()
defer idx.Unlock()
idx.token = make(map[string][]docid)
idx.documents = make(map[docid]string)
idx.titles = make(map[string]string)
err := filepath.Walk(".", idx.add)
var id docid
// Reverse lookup! At least it's in memory.
for key, value := range idx.documents {
if value == name {
id = key
break
}
}
if id != 0 {
idx.deleteDocument(id)
delete(idx.documents, id)
}
delete(idx.titles, name)
delete(idx.images, name)
}
// remove the page from the index. Do this when deleting a page. This assumes that the index is unlocked.
func (idx *indexStore) remove(p *Page) {
idx.deletePageName(p.Name)
}
// load loads all the pages and indexes them. This takes a while. It returns the number of pages indexed.
func (idx *indexStore) load() (int, error) {
idx.Lock()
defer idx.Unlock()
err := filepath.Walk(".", idx.walk)
if err != nil {
idx.reset()
return 0, err
}
n := len(idx.documents)
return n, nil
}
// dump prints the index to the log for debugging. Must already be readlocked.
func (idx *Index) dump() {
index.RLock()
defer index.RUnlock()
// walk reads a file and adds it to the index. This assumes that the index is locked.
func (idx *indexStore) walk(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if fp != "." && strings.HasPrefix(filepath.Base(fp), ".") {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// skipp all but page files
if !strings.HasSuffix(fp, ".md") {
return nil
}
p, err := loadPage(strings.TrimSuffix(filepath.ToSlash(fp), ".md"))
if err != nil {
return err
}
p.handleTitle(false)
idx.addPage(p)
return nil
}
// addPage adds a page to the index. This assumes that the index is locked.
func (idx *indexStore) addPage(p *Page) {
id := idx.addDocument(p.Body)
idx.documents[id] = p.Name
p.handleTitle(false)
idx.titles[p.Name] = p.Title
idx.images[p.Name] = p.images()
}
// add a page to the index. This assumes that the index is unlocked.
func (idx *indexStore) add(p *Page) {
idx.Lock()
defer idx.Unlock()
idx.addPage(p)
}
// dump prints the index to the log for debugging.
func (idx *indexStore) dump() {
idx.RLock()
defer idx.RUnlock()
for token, ids := range idx.token {
log.Printf("%s: %v", token, ids)
}
}
// updateIndex updates the index for a single page. The old text is
// loaded from the disk and removed from the index first, if it
// exists.
func (p *Page) updateIndex() {
index.Lock()
defer index.Unlock()
var id docid
// Reverse lookup! At least it's in memory.
for docId, name := range index.documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
id = index.addDocument(p.Body)
index.documents[id] = p.Name
index.titles[p.Name] = p.Title
} else {
if o, err := loadPage(p.Name); err == nil {
index.deleteDocument(o.Body, id)
}
// Do not reuse the old id. We need a new one for
// indexing to work.
id = index.addDocument(p.Body)
// The page name stays the same but the title may have
// changed.
index.documents[id] = p.Name
p.handleTitle(false)
index.titles[p.Name] = p.Title
}
// updateIndex updates the index for a single page.
func (idx *indexStore) update(p *Page) {
idx.remove(p)
idx.add(p)
}
// removeFromIndex removes the page from the index. Do this when
// deleting a page.
func (p *Page) removeFromIndex() {
index.Lock()
defer index.Unlock()
var id docid
// Reverse lookup! At least it's in memory.
for docId, name := range index.documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
log.Printf("Page %s is not indexed", p.Name)
return
}
o, err := loadPage(p.Name)
if err != nil {
log.Printf("Page %s cannot removed from the index: %s", p.Name, err)
return
}
index.deleteDocument(o.Body, id)
}
// search searches the index for a query string and returns page
// names.
func (idx *Index) search(q string) []string {
index.RLock()
defer index.RUnlock()
// 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()
names := make([]string, 0)
hashtags := hashtags([]byte(q))
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
@@ -240,11 +238,12 @@ func intersection[T constraints.Ordered](a []T, b []T) []T {
r := make([]T, 0, maxLen)
var i, j int
for i < len(a) && j < len(b) {
if a[i] < b[j] {
switch {
case a[i] < b[j]:
i++
} else if a[i] > b[j] {
case a[i] > b[j]:
j++
} else {
default:
r = append(r, a[i])
i++
j++

View File

@@ -1,7 +1,8 @@
# Welcome to Oddµ
# Welcome to Oddμ
Hello! 🙃
Check out the [[README]].
Check out the [[README]] and [[themes]].
Or [create a new page](test).

View File

@@ -1,16 +1,29 @@
package main
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIndexAdd(t *testing.T) {
idx := &indexStore{}
idx.reset()
idx.Lock()
defer idx.Unlock()
tag := "hello"
id := idx.addDocument([]byte("oh hi #" + tag))
assert.Contains(t, idx.token, tag)
idx.deleteDocument(id)
assert.NotContains(t, idx.token, tag)
}
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
pages, _ := search(q, "", 1, false)
q := "Oddμ"
pages, _ := search(q, "", "", 1, false)
assert.NotZero(t, len(pages))
for _, p := range pages {
assert.NotContains(t, p.Title, "<b>")
@@ -19,10 +32,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))
}
@@ -34,7 +56,7 @@ func TestIndexUpdates(t *testing.T) {
p.save()
// Find the phrase
pages, _ := search("This is a test", "", 1, false)
pages, _ := search("This is a test", "", "", 1, false)
found := false
for _, p := range pages {
if p.Name == name {
@@ -45,7 +67,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find the phrase, case insensitive
pages, _ = search("this is a test", "", 1, false)
pages, _ = search("this is a test", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -56,7 +78,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find some words
pages, _ = search("this test", "", 1, false)
pages, _ = search("this test", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -69,7 +91,7 @@ func TestIndexUpdates(t *testing.T) {
// Update the page and no longer find it with the old phrase
p = &Page{Name: name, Body: []byte("# New page\nGuvf vf n grfg.")}
p.save()
pages, _ = search("This is a test", "", 1, false)
pages, _ = search("This is a test", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -80,7 +102,7 @@ func TestIndexUpdates(t *testing.T) {
assert.False(t, found)
// Find page using a new word
pages, _ = search("Guvf", "", 1, false)
pages, _ = search("Guvf", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -93,5 +115,5 @@ func TestIndexUpdates(t *testing.T) {
// Make sure the title was updated
index.RLock()
defer index.RUnlock()
assert.Equal(t, index.titles[name], "New page")
assert.Equal(t, "New page", index.titles[name])
}

View File

@@ -2,13 +2,13 @@ package main
import (
"errors"
"github.com/pemistahl/lingua-go"
"os"
"strings"
"github.com/pemistahl/lingua-go"
)
// getLangauges returns the environment variable ODDMU_LANGUAGES or
// all languages.
// getLanguages returns the environment variable ODDMU_LANGUAGES or all languages.
func getLanguages() ([]lingua.Language, error) {
v := os.Getenv("ODDMU_LANGUAGES")
if v == "" {
@@ -29,8 +29,9 @@ func getLanguages() ([]lingua.Language, error) {
// detector is the LanguageDetector initialized at startup by loadLanguages.
var detector lingua.LanguageDetector
// loadLanguages initializes the detector using the languages returned
// by getLanguages and returns the number of languages loaded.
// loadLanguages initializes the detector using the languages returned by getLanguages and returns the number of
// languages loaded. If this is skipped, no language detection happens and the templates cannot use {{.Language}} to use
// this. Usually this is used for correct hyphenation by the browser.
func loadLanguages() int {
langs, err := getLanguages()
if err == nil {
@@ -56,3 +57,9 @@ func language(s string) string {
}
return ""
}
// Language returns the language used for the page, as a lower case
// ISO 639-1 string, e.g. "en" or "de".
func (p *Page) Language() string {
return language(p.plainText())
}

View File

@@ -1,9 +1,10 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAllLanguage(t *testing.T) {

63
links_cmd.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/google/subcommands"
)
type linksCmd struct {
}
func (cmd *linksCmd) SetFlags(f *flag.FlagSet) {
}
func (*linksCmd) Name() string { return "links" }
func (*linksCmd) Synopsis() string { return "list outgoing links for a page" }
func (*linksCmd) Usage() string {
return `links <page name> ...:
Lists all the links on a page. Use a single - to read Markdown from stdin.
`
}
func (cmd *linksCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return linksCli(os.Stdout, f.Args())
}
// linksCli runs the links command on the command line. It is used
// here with an io.Writer for easy testing.
func linksCli(w io.Writer, args []string) subcommands.ExitStatus {
if len(args) == 1 && args[0] == "-" {
body, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(w, "Cannot read from stdin: %s\n", err)
return subcommands.ExitFailure
}
p := &Page{Body: body}
for _, link := range p.links() {
fmt.Fprintln(w, link)
}
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)
return subcommands.ExitFailure
}
for _, link := range p.links() {
fmt.Fprintln(w, link)
}
}
return subcommands.ExitSuccess
}

17
links_cmd_test.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
)
func TestLinksCmd(t *testing.T) {
b := new(bytes.Buffer)
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")
}

View File

@@ -4,11 +4,12 @@ import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/subcommands"
)
type listCmd struct {
@@ -20,7 +21,7 @@ func (cmd *listCmd) SetFlags(f *flag.FlagSet) {
}
func (*listCmd) Name() string { return "list" }
func (*listCmd) Synopsis() string { return "List pages with name and title." }
func (*listCmd) Synopsis() string { return "list pages with name and title" }
func (*listCmd) Usage() string {
return `list [-dir string]:
List all pages with name and title, separated by a tabulator.
@@ -50,10 +51,11 @@ func listCli(w io.Writer, dir string, args []string) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// checkDir returns an error if the directory doesn't exist. If if exists, it returns a copy ending in a slash.
func checkDir (dir string) (string, error) {
// checkDir returns an error if the directory doesn't exist. If if exists, it returns a copy ending in a slash suiteable
// for substring matching of page names.
func checkDir(dir string) (string, error) {
if dir != "" {
fi, err := os.Stat(dir)
fi, err := os.Stat(filepath.FromSlash(dir))
if err != nil {
fmt.Println(err)
return "", err
@@ -62,8 +64,7 @@ func checkDir (dir string) (string, error) {
fmt.Println("This is not a sub-directory:", dir)
return "", err
}
dir = filepath.ToSlash(dir);
if (!strings.HasSuffix(dir, "/")) {
if !strings.HasSuffix(dir, "/") {
dir += "/"
}
}

View File

@@ -2,9 +2,10 @@ package main
import (
"bytes"
"testing"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestListCmd(t *testing.T) {
@@ -12,8 +13,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) {

1
man/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.md

View File

@@ -1,6 +1,55 @@
docs: oddmu-apache.5 oddmu-html.1 oddmu-missing.1 oddmu-notify.1 \
oddmu-replace.1 oddmu-search.1 oddmu-search.7 oddmu-static.1 \
oddmu-list.1 oddmu-templates.5 oddmu.1 oddmu.5 oddmu.service.5
TEXT=$(wildcard *.txt)
MAN=$(patsubst %.txt,%,${TEXT})
HTML=$(patsubst %.txt,%.html,${TEXT})
MD=$(patsubst %.txt,%.md,${TEXT})
oddmu%: oddmu%.txt
help:
@echo Help for Oddmu Documentation
@echo ============================
@echo make man
@echo " regenerate man pages"
@echo make html
@echo " generate HTML pages"
@echo make md
@echo " generate Markdown pages"
@echo make clean
@echo " delete HTML and Markdown pages"
@echo make realclean
@echo " delete HTML, Markdown and man pages"
man: ${MAN}
%: %.txt
scdoc < $< > $@
html: ${HTML}
%.html: %.md
@echo Making $@
@echo '<!DOCTYPE html>' > $@
@oddmu html $< | sed --regexp-extended \
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
md: ${MD}
%.md: %.txt
@echo Making $@
@perl scdoc-to-markdown < $< > $@
README.md: ../README.md
@echo Making $@
@sed --regexp-extended \
-e 's/\]\(.*\/(.*)\.txt\)/](\1)/' \
< $< > $@
upload: ${MD} README.md
rsync --itemize-changes --archive *.md ../README.md sibirocobombus:alexschroeder.ch/wiki/oddmu/
make clean
clean:
@echo Removing HTML and Markdown files
@rm -f ${HTML} ${MD} README.md
realclean: clean
@echo Removing man pages
@rm -f ${MAN}

View File

@@ -1,50 +1,55 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2023-11-05"
.TH "ODDMU-APACHE" "5" "2026-01-29"
.PP
.SH NAME
.PP
oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
.PP
.SS DESCRIPTION
.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.\&
Alternatively, you can reverse proxy HTTP over a Unix-domain socket,
as shown later.\&
.PP
The best way to protect the wiki against vandalism and spam is to use a regular
web server as reverse proxy.\& This page explains how to setup Apache on Debian to
do this.\&
.PP
.SS CONFIGURATION
.SH CONFIGURATION
.PP
HTTPS is not part of Oddmu.\& You probably want to configure this in your
webserver.\& I guess you could use stunnel, too.\& If you'\&re using Apache, you can
use "mod_md" to manage your domain.\&
.PP
In the example below, the site is configured in a file called
"/etc/apache2/sites-available/500-transjovian.\&conf" and a link poins there from
The examples below use the domain "transjovian.\&org" and the Apache installation
is the one that comes with Debian.\&
.PP
The site itself is configured in a file called
"/etc/apache2/sites-available/transjovian.\&conf" and a link points there from
"/etc/apache2/sites-enabled".\& Create this link using \fIa2ensite\fR(1).\&
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
RewriteEngine on
RewriteRule ^/(\&.*) https://%{HTTP_HOST}/$1 [redirect]
ServerName transjovian\&.org
Redirect "/" "https://transjovian\&.org/"
</VirtualHost>
<VirtualHost *:443>
ServerAdmin alex@alexschroeder\&.ch
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$" "http://localhost:8080/$1"
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
@@ -53,7 +58,7 @@ First, it manages the domain, getting the necessary certificates.\& It redirects
regular HTTP traffic from port 80 to port 443.\& It turns on the SSL engine for
port 443.\& It proxies the requests for Oddmu to port 8080.\& Importantly, it
doesn'\&t send \fIall\fR the requests to Oddmu.\& This allows us to still host static
files using the web server.\&
files using the web server (see \fBServe static files\fR).\&
.PP
This is what happens:
.PP
@@ -76,16 +81,126 @@ apachectl graceful
.fi
.RE
.PP
To serve both HTTP and HTTPS, don'\&t redirect from the first virtual host to the
second instead just proxy to the wiki like you did for the second virtual
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
and "RewriteRule".\&
In a situation where Apache acts as a reverse proxy, you can prevent some
actions from being proxied.\& If you don'\&t want to allow strangers to make
changes, search or archive the site, use a limited setup like the following:
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
Redirect "/" "https://transjovian\&.org/"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/(view/\&.*)?$" "http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
.PP
You'\&ll need to edit the source pages some other way.\& Edit them locally and
upload them using rsync; edit them remotely using an editor that can do this;
use SSHFS to mount the remote directory locally for editing; use \fIstunnel\fR(8) to
access the remote wiki on the local port 8080 for editing.\& There are probably a
lot more such options available.\& All of them have the drawback that they'\&re
probably not easy to use when on a mobile phone.\&
.PP
.SS Allow HTTP for viewing
.PP
When looking at pages, you might want to allow HTTP since no password is
required.\& Therefore, proxy the read-only requests from the virtual host on port
80 to the wiki instead of redirecting them to port 443.\&
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
ProxyPassMatch "^/((view|diff|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*)|sitemap.xml)?$"
"https://transjovian\&.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
.PP
.SS Using a Unix-domain Socket
.PP
Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket.\& This requires socket activation.\& An example of configuring
the service is given in \fIoddmu.\&service(5)\fR.\&
.PP
On the Apache side, you can proxy to the socket directly.\& This sends all
requests to the socket:
.PP
.nf
.RS 4
ProxyPass "/" "unix:/run/oddmu/oddmu\&.sock|http://localhost/"
.fi
.RE
.PP
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.\&sock".\&
.PP
To test it on the command-line, use a tool like \fIcurl(1)\fR.\& Make sure to provide
the correct servername!\&
.PP
.nf
.RS 4
curl http://transjovian\&.org/view/index
.fi
.RE
.PP
You probably want to serve some static files as well (see \fBServe static files\fR).\&
In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
There'\&s a curious problem with this expression, however.\& If you use \fIcurl(1)\fR to
get the root path, Apache hangs:
.PP
.nf
.RS 4
curl http://transjovian\&.org/
.fi
.RE
.PP
A workaround is to add the redirect manually and drop the question-mark:
.PP
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*)|sitemap.xml)$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
If you know why this is happening, let me know.\&
.PP
.SS Access
.PP
Access control is not part of Oddmu.\& By default, the wiki is editable by all.\&
This is most likely not what you want unless you'\&re running it stand-alone,
unconnected to the Internet a person memex on your laptop, for example.\&
unconnected to the Internet a personal memex on your laptop, for example.\&
.PP
The following instructions create user accounts with passwords just for Oddmu.\&
These users are not real users on the web server and don'\&t have access to a
@@ -133,39 +248,32 @@ to your "<VirtualHost *:443>" section:
.fi
.RE
.PP
.SS Serve static files
.SS Actual usernames and passwords for authentication
.PP
If you want to serve static files as well, add a document root to your webserver
configuration.\& In this case, the document root is the directory where all the
data files are.\& Apache does not serve files such as ".\&htpasswd".\&
On a community server where the users have accounts, wiki editing can be limited
to the system'\&s users.\&
.PP
In order to do this, install the \fBmod-authnz-external\fR module for Apache and the
\fBpwauth\fR binary.\& The module allows the password checking normally done inside
Apache to be done by an separate external program running outside of Apache.\&
.PP
Here'\&s an example configuration:
.PP
.nf
.RS 4
DocumentRoot /home/oddmu
<Directory /home/oddmu>
Require all granted
</Directory>
AddExternalAuth pwauth /usr/sbin/pwauth
SetExternalAuthMethod pwauth pipe
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthBasicProvider external
AuthExternal pwauth
Require valid-user
</LocationMatch>
.fi
.RE
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/".\& For example, create a file called "robots.\&txt" containing the
following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
User-agent: *
Disallow: /
.fi
.RE
.PP
You site now serves "/robots.\&txt" without interfering with the wiki, and without
needing a wiki page.\&
.PP
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.\&
.PP
.SS Different logins for different access rights
.PP
What if you have a site with various subdirectories and each subdirectory is for
@@ -185,8 +293,8 @@ This requires a valid login by the user "alex" or "berta":
.PP
.SS Private wikis
.PP
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The
"LocationMatch" must cover all the URLs in order to protect everything.\&
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The location
must cover all the URLs in order to protect everything.\&
.PP
.nf
.RS 4
@@ -199,17 +307,110 @@ Based on the above, you can prevent people from \fIreading\fR the wiki.\& The
.fi
.RE
.PP
.SS Subdirectories as separate sites
.PP
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period).\& Specifically, do not rely on Apache to hide locations in subdirectories
from public view.\& Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links.\& The Sitemap lists all pages,
including subdirectories.\& Archive links pack all the subdirectories, including
locations you may have hidden from view using Apache.\&
.PP
If you to treat subdirectories as separate sites, you need to set the
environment variable ODDMU_FILTER to a regular expression matching the those
directories.\& If search starts in a directory that doesn'\&t match the regular
expression, all directories matching the regular expression are excluded.\& See
\fIoddmu-filter\fR(7).\&
.PP
In the following example, ODDMU_FILTER is set to "^secret/".\&
.PP
"http://transjovian.\&org/search/index?\&q=something" does not search the "secret/"
directory and its subdirectories are excluded.\&
.PP
"http://transjovian.\&org/search/secret/index?\&q=something" searches just the
"secret" directory and its subdirectories.\&
.PP
You need to configure the web server to prevent access to the "secret/"
directory:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require valid-user
</LocationMatch>
.fi
.RE
.PP
.SS Serve static files
.PP
If you want to serve static files as well, add a document root to your webserver
configuration.\& In this case, the document root is the directory where all the
data files are.\& Apache does not serve files such as ".\&htpasswd".\&
.PP
.nf
.RS 4
DocumentRoot /home/oddmu
<Directory /home/oddmu>
Require all granted
</Directory>
.fi
.RE
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/search/" or "/archive/".\& For example, create a file called "robots.\&txt"
containing the following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
User-agent: *
Disallow: /
.fi
.RE
.PP
Your site now serves "/robots.\&txt" without interfering with the wiki, and
without needing a wiki page.\&
.PP
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.\&
.PP
The "view.\&html" template would start as follows:
.PP
.nf
.RS 4
<!DOCTYPE html>
<html lang="{{\&.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{\&.Title}}</title>
<link href="/css/oddmu-2023\&.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{\&.Title}}" href="/view/{{\&.Name}}\&.rss" />
</head>
.fi
.RE
.PP
In this case, "/css/oddmu-2023.\&css" would be the name of your stylesheet.\& If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.\&css" for this to work.\&
.PP
.SS Virtual hosting
.PP
Virtual hosting in this context means that the program serves two different
sites for two different domains from the same machine.\& Oddmu doesn'\&t support
that, but your webserver does.\& Therefore, start an Oddmu instance for every
domain name, each listening on a different port.\& Then set up your web server
such that ever domain acts as a reverse proxy to a different Oddmu instance.\&
such that every domain proxies for a different Oddmu instance.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
\fIoddmu\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-nginx\fR(5)
.PP
"Apache Core Features".\&
https://httpd.\&apache.\&org/docs/current/mod/core.\&html

View File

@@ -4,39 +4,44 @@ ODDMU-APACHE(5)
oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
## DESCRIPTION
# 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.
Alternatively, you can reverse proxy HTTP over a Unix-domain socket,
as shown later.
The best way to protect the wiki against vandalism and spam is to use a regular
web server as reverse proxy. This page explains how to setup Apache on Debian to
do this.
## CONFIGURATION
# CONFIGURATION
HTTPS is not part of Oddmu. You probably want to configure this in your
webserver. I guess you could use stunnel, too. If you're using Apache, you can
use "mod_md" to manage your domain.
In the example below, the site is configured in a file called
"/etc/apache2/sites-available/500-transjovian.conf" and a link poins there from
The examples below use the domain "transjovian.org" and the Apache installation
is the one that comes with Debian.
The site itself is configured in a file called
"/etc/apache2/sites-available/transjovian.conf" and a link points there from
"/etc/apache2/sites-enabled". Create this link using _a2ensite_(1).
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
RewriteEngine on
RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [redirect]
ServerName transjovian.org
Redirect "/" "https://transjovian.org/"
</VirtualHost>
<VirtualHost *:443>
ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" "http://localhost:8080/$1"
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -44,7 +49,7 @@ First, it manages the domain, getting the necessary certificates. It redirects
regular HTTP traffic from port 80 to port 443. It turns on the SSL engine for
port 443. It proxies the requests for Oddmu to port 8080. Importantly, it
doesn't send _all_ the requests to Oddmu. This allows us to still host static
files using the web server.
files using the web server (see *Serve static files*).
This is what happens:
@@ -59,16 +64,112 @@ Restart the server, gracefully:
apachectl graceful
```
To serve both HTTP and HTTPS, don't redirect from the first virtual host to the
second instead just proxy to the wiki like you did for the second virtual
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
and "RewriteRule".
In a situation where Apache acts as a reverse proxy, you can prevent some
actions from being proxied. If you don't want to allow strangers to make
changes, search or archive the site, use a limited setup like the following:
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
Redirect "/" "https://transjovian.org/"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/(view/.*)?$" "http://localhost:8080/$1"
</VirtualHost>
```
You'll need to edit the source pages some other way. Edit them locally and
upload them using rsync; edit them remotely using an editor that can do this;
use SSHFS to mount the remote directory locally for editing; use _stunnel_(8) to
access the remote wiki on the local port 8080 for editing. There are probably a
lot more such options available. All of them have the drawback that they're
probably not easy to use when on a mobile phone.
## Allow HTTP for viewing
When looking at pages, you might want to allow HTTP since no password is
required. Therefore, proxy the read-only requests from the virtual host on port
80 to the wiki instead of redirecting them to port 443.
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*)|sitemap\.xml)?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
## Using a Unix-domain Socket
Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket. This requires socket activation. An example of configuring
the service is given in _oddmu.service(5)_.
On the Apache side, you can proxy to the socket directly. This sends all
requests to the socket:
```
ProxyPass "/" "unix:/run/oddmu/oddmu.sock|http://localhost/"
```
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.sock".
To test it on the command-line, use a tool like _curl(1)_. Make sure to provide
the correct servername!
```
curl http://transjovian.org/view/index
```
You probably want to serve some static files as well (see *Serve static files*).
In that case, you need to use the ProxyPassMatch directive.
```
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
There's a curious problem with this expression, however. If you use _curl(1)_ to
get the root path, Apache hangs:
```
curl http://transjovian.org/
```
A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*)|sitemap\.xml)$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
If you know why this is happening, let me know.
## Access
Access control is not part of Oddmu. By default, the wiki is editable by all.
This is most likely not what you want unless you're running it stand-alone,
unconnected to the Internet a person memex on your laptop, for example.
unconnected to the Internet a personal memex on your laptop, for example.
The following instructions create user accounts with passwords just for Oddmu.
These users are not real users on the web server and don't have access to a
@@ -108,35 +209,30 @@ to your "<VirtualHost \*:443>" section:
</LocationMatch>
```
## Serve static files
## Actual usernames and passwords for authentication
If you want to serve static files as well, add a document root to your webserver
configuration. In this case, the document root is the directory where all the
data files are. Apache does not serve files such as ".htpasswd".
On a community server where the users have accounts, wiki editing can be limited
to the system's users.
In order to do this, install the *mod-authnz-external* module for Apache and the
*pwauth* binary. The module allows the password checking normally done inside
Apache to be done by an separate external program running outside of Apache.
Here's an example configuration:
```
DocumentRoot /home/oddmu
<Directory /home/oddmu>
Require all granted
</Directory>
AddExternalAuth pwauth /usr/sbin/pwauth
SetExternalAuthMethod pwauth pipe
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthBasicProvider external
AuthExternal pwauth
Require valid-user
</LocationMatch>
```
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/". For example, create a file called "robots.txt" containing the
following, telling all robots that they're not welcome.
```
User-agent: *
Disallow: /
```
You site now serves "/robots.txt" without interfering with the wiki, and without
needing a wiki page.
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.
## Different logins for different access rights
What if you have a site with various subdirectories and each subdirectory is for
@@ -154,8 +250,8 @@ This requires a valid login by the user "alex" or "berta":
## Private wikis
Based on the above, you can prevent people from _reading_ the wiki. The
"LocationMatch" must cover all the URLs in order to protect everything.
Based on the above, you can prevent people from _reading_ the wiki. The location
must cover all the URLs in order to protect everything.
```
<Location />
@@ -166,17 +262,102 @@ Based on the above, you can prevent people from _reading_ the wiki. The
</Location>
```
## Subdirectories as separate sites
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period). Specifically, do not rely on Apache to hide locations in subdirectories
from public view. Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links. The Sitemap lists all pages,
including subdirectories. Archive links pack all the subdirectories, including
locations you may have hidden from view using Apache.
If you to treat subdirectories as separate sites, you need to set the
environment variable ODDMU_FILTER to a regular expression matching the those
directories. If search starts in a directory that doesn't match the regular
expression, all directories matching the regular expression are excluded. See
_oddmu-filter_(7).
In the following example, ODDMU_FILTER is set to "^secret/".
"http://transjovian.org/search/index?q=something" does not search the "secret/"
directory and its subdirectories are excluded.
"http://transjovian.org/search/secret/index?q=something" searches just the
"secret" directory and its subdirectories.
You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require valid-user
</LocationMatch>
```
## Serve static files
If you want to serve static files as well, add a document root to your webserver
configuration. In this case, the document root is the directory where all the
data files are. Apache does not serve files such as ".htpasswd".
```
DocumentRoot /home/oddmu
<Directory /home/oddmu>
Require all granted
</Directory>
```
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/search/" or "/archive/". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
```
User-agent: *
Disallow: /
```
Your site now serves "/robots.txt" without interfering with the wiki, and
without needing a wiki page.
Another option would be to create a CSS file and use it with a \<link\> element in
all the templates instead of relying on the \<style\> element.
The "view.html" template would start as follows:
```
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link href="/css/oddmu-2023.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
</head>
```
In this case, "/css/oddmu-2023.css" would be the name of your stylesheet. If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.css" for this to work.
## Virtual hosting
Virtual hosting in this context means that the program serves two different
sites for two different domains from the same machine. Oddmu doesn't support
that, but your webserver does. Therefore, start an Oddmu instance for every
domain name, each listening on a different port. Then set up your web server
such that ever domain acts as a reverse proxy to a different Oddmu instance.
such that every domain proxies for a different Oddmu instance.
# SEE ALSO
_oddmu_(1)
_oddmu_(1), _oddmu-filter_(7), _oddmu-nginx_(5)
"Apache Core Features".
https://httpd.apache.org/docs/current/mod/core.html
@@ -190,10 +371,10 @@ https://httpd.apache.org/docs/current/mod/mod_proxy.html
"Robot exclusion standard" on Wikipedia.
https://en.wikipedia.org/wiki/Robot_exclusion_standard
"<style>: The Style Information element"
"\<style\>: The Style Information element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style
"<link>: The External Resource Link element"
"\<link\>: The External Resource Link element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link
# AUTHORS

80
man/oddmu-export.1 Normal file
View File

@@ -0,0 +1,80 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-EXPORT" "1" "2026-01-03"
.PP
.SH NAME
.PP
oddmu-export - export all pages into one file
.PP
.SH SYNOPSIS
.PP
\fBoddmu export\fR [\fB-template\fR \fIfilename\fR]
.PP
.SH DESCRIPTION
.PP
The "export" subcommand prints a RSS file containing all the pages to stdout.\&
You probably want to redirect this into a file so that you can upload and import
it somewhere.\&
.PP
Note that this only handles pages (Markdown files).\& All other files (images,
PDFs, whatever else you uploaded) are not part of the feed and have to be
uploaded to the new platform using some other way.\&
.PP
The \fB-template\fR option specifies the template to use.\& If the template filename
ends in \fI.\&xml\fR, \fI.\&html\fR or \fI.\&rss\fR, it is assumed to contain XML and the optional
XML preamble is printed and appropriate escaping rules are used.\&
.PP
.SH FILES
.PP
By default, the export uses the feed template ("feed.\&html") in the current
directory.\&
.PP
.SH EXAMPLES
.PP
Export all the pages into a big XML file:
.PP
.nf
.RS 4
env ODDMU_LANGUAGES=de,en oddmu export > /tmp/export\&.xml
.fi
.RE
.PP
Alternatively, consider a template file like the following, to generate a JSON
feed.\& The rule to disallow a comma at the end of arrays means that we need to
add an empty tag and an empty item, unfortunately:
.PP
.nf
.RS 4
{
"version": "https://jsonfeed\&.org/version/1\&.1",
"title": "{{\&.Title}}",
"home_page_url": "https://alexschroeder\&.ch",
"others": [],
"items": [{{range \&.Items}}
{
"id": "{{\&.Name}}",
"url": "https://alexschroeder\&.ch/view/{{\&.Name}}",
"title": "{{\&.Title}}",
"content_html": "{{\&.Html}}",
"date_modified": "{{\&.Date}}",
"tags": [{{range \&.Hashtags}}"{{\&.}}",{{end}}""],
"language": "{{\&.Language}}"
},{{end}}
{}
]
}
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-templates\fR(5), \fIoddmu-static\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

69
man/oddmu-export.1.txt Normal file
View File

@@ -0,0 +1,69 @@
ODDMU-EXPORT(1)
# NAME
oddmu-export - export all pages into one file
# SYNOPSIS
*oddmu export* [*-template* _filename_]
# DESCRIPTION
The "export" subcommand prints a RSS file containing all the pages to stdout.
You probably want to redirect this into a file so that you can upload and import
it somewhere.
Note that this only handles pages (Markdown files). All other files (images,
PDFs, whatever else you uploaded) are not part of the feed and have to be
uploaded to the new platform using some other way.
The *-template* option specifies the template to use. If the template filename
ends in _.xml_, _.html_ or _.rss_, it is assumed to contain XML and the optional
XML preamble is printed and appropriate escaping rules are used.
# FILES
By default, the export uses the feed template ("feed.html") in the current
directory.
# EXAMPLES
Export all the pages into a big XML file:
```
env ODDMU_LANGUAGES=de,en oddmu export > /tmp/export.xml
```
Alternatively, consider a template file like the following, to generate a JSON
feed. The rule to disallow a comma at the end of arrays means that we need to
add an empty tag and an empty item, unfortunately:
```
{
"version": "https://jsonfeed.org/version/1.1",
"title": "{{.Title}}",
"home_page_url": "https://alexschroeder.ch",
"others": [],
"items": [{{range .Items}}
{
"id": "{{.Name}}",
"url": "https://alexschroeder.ch/view/{{.Name}}",
"title": "{{.Title}}",
"content_html": "{{.Html}}",
"date_modified": "{{.Date}}",
"tags": [{{range .Hashtags}}"{{.}}",{{end}}""],
"language": "{{.Language}}"
},{{end}}
{}
]
}
```
# SEE ALSO
_oddmu_(1), _oddmu-templates_(5), _oddmu-static_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

59
man/oddmu-feed.1 Normal file
View File

@@ -0,0 +1,59 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-FEED" "1" "2025-12-31"
.PP
.SH NAME
.PP
oddmu-feed - render Oddmu page feed
.PP
.SH SYNOPSIS
.PP
\fBoddmu feed\fR \fIpage-name\fR .\&.\&.\&
.PP
.SH DESCRIPTION
.PP
The "feed" subcommand opens the given Markdown files and writes the resulting
RSS files without item limit (ordinarily, this default is 10 items per feed).\&
This uses the "feed.\&html" template.\& Use "-" as the page name if you want to read
Markdown from \fBstdin\fR.\&
.PP
Unlike the feeds generated by the \fBstatic\fR subcommand, the \fBfeed\fR command does
not limit the feed to the ten most recent items.\& Instead, all items on the list
are turned into feed items.\&
.PP
Furthermore, if the items on the list are blog posts (their page name starts
with an ISO date), then this ISO date is used for the last update date to the
page instead of the last modification time of the file.\& The idea, more or less,
is that this feed is an archive feed and that in this context the creation date
is more important than the last modification date.\&
.PP
.SH EXAMPLES
.PP
Generate "emacs.\&rss" from "emacs.\&md":
.PP
.nf
.RS 4
oddmu feed emacs\&.md
.fi
.RE
.PP
Alternatively:
.PP
.nf
.RS 4
oddmu feed - < emacs\&.md > emacs\&.rss
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-export\fR(1), \fIoddmu-static\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

48
man/oddmu-feed.1.txt Normal file
View File

@@ -0,0 +1,48 @@
ODDMU-FEED(1)
# NAME
oddmu-feed - render Oddmu page feed
# SYNOPSIS
*oddmu feed* _page-name_ ...
# DESCRIPTION
The "feed" subcommand opens the given Markdown files and writes the resulting
RSS files without item limit (ordinarily, this default is 10 items per feed).
This uses the "feed.html" template. Use "-" as the page name if you want to read
Markdown from *stdin*.
Unlike the feeds generated by the *static* subcommand, the *feed* command does
not limit the feed to the ten most recent items. Instead, all items on the list
are turned into feed items.
Furthermore, if the items on the list are blog posts (their page name starts
with an ISO date), then this ISO date is used for the last update date to the
page instead of the last modification time of the file. The idea, more or less,
is that this feed is an archive feed and that in this context the creation date
is more important than the last modification date.
# EXAMPLES
Generate "emacs.rss" from "emacs.md":
```
oddmu feed emacs.md
```
Alternatively:
```
oddmu feed - < emacs.md > emacs.rss
```
# SEE ALSO
_oddmu_(1), _oddmu-export_(1), _oddmu-static_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

58
man/oddmu-filter.7 Normal file
View File

@@ -0,0 +1,58 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-FILTER" "7" "2026-01-03"
.PP
.SH NAME
.PP
oddmu-filter - keeping subdirectories separate
.PP
.SH DESCRIPTION
.PP
There are actions such as producing the sitemap, searching and archiving that
act on multiple pages, not just a single page.\& These actions walk the directory
tree, including all subdirectories.\& In some cases, this is not desirable.\&
.PP
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people.\& Depending on how you think about it, you might not want to
include those "sites" in searches, sitemaps or archives of the whole site.\&
.PP
Since directory tree actions always start in the directory the visitor is
currently looking at, directory tree actions starting in a "separate site"
automatically act as expected.\& The action is limited to that subdirectory tree.\&
.PP
When visitors look at a page in the "main site", however, directory tree actions
must skip any sub directories that are part of a "separate site".\&
.PP
The way to identify separate sites is via the environment variable ODDMU_FILTER.\&
It'\&s value is a regular expression matching separate sites.\&
.PP
.SH EXAMPLES
.PP
"ODDMU_FILTER=^project/" means that a directory tree action outside the
"project/" directory does not include pages in the "project/" directory.\&
.PP
In other words, http://localhost:8080/search/?\&q=oddmu skips any pages in
"project/".\&
.PP
At the same time, http://localhost:8080/search/project/?\&q=oddmu works like it
always does: search is limited to "project/" and its subdirectories.\&
.PP
.SH SECURITY
.PP
If the subdirectory is a private site, then you need to use ODDMU_FILTER to
exclude it from directory tree actions in the main site, and you need to
configure your web server such that it doesn'\&t allow visitors access to the
directory tree without authentication.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(7), \fIoddmu-apache\fR(5), \fIoddmu-nginx\fR(5)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

51
man/oddmu-filter.7.txt Normal file
View File

@@ -0,0 +1,51 @@
ODDMU-FILTER(7)
# NAME
oddmu-filter - keeping subdirectories separate
# DESCRIPTION
There are actions such as producing the sitemap, searching and archiving that
act on multiple pages, not just a single page. These actions walk the directory
tree, including all subdirectories. In some cases, this is not desirable.
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people. Depending on how you think about it, you might not want to
include those "sites" in searches, sitemaps or archives of the whole site.
Since directory tree actions always start in the directory the visitor is
currently looking at, directory tree actions starting in a "separate site"
automatically act as expected. The action is limited to that subdirectory tree.
When visitors look at a page in the "main site", however, directory tree actions
must skip any sub directories that are part of a "separate site".
The way to identify separate sites is via the environment variable ODDMU_FILTER.
It's value is a regular expression matching separate sites.
# EXAMPLES
"ODDMU_FILTER=^project/" means that a directory tree action outside the
"project/" directory does not include pages in the "project/" directory.
In other words, http://localhost:8080/search/?q=oddmu skips any pages in
"project/".
At the same time, http://localhost:8080/search/project/?q=oddmu works like it
always does: search is limited to "project/" and its subdirectories.
# SECURITY
If the subdirectory is a private site, then you need to use ODDMU_FILTER to
exclude it from directory tree actions in the main site, and you need to
configure your web server such that it doesn't allow visitors access to the
directory tree without authentication.
# SEE ALSO
_oddmu_(1), _oddmu-search_(7), _oddmu-apache_(5), _oddmu-nginx_(5)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

66
man/oddmu-hashtags.1 Normal file
View File

@@ -0,0 +1,66 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HASHTAGS" "1" "2025-08-09"
.PP
.SH NAME
.PP
oddmu-hashtags - work with hashtags
.PP
.SH SYNOPSIS
.PP
\fBoddmu hashtags\fR
.PP
\fBoddmu hashtags -update\fR [\fB-dry-run\fR]
.PP
.SH DESCRIPTION
.PP
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.\&
.PP
With the \fB-update\fR flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag.\& This only necessary when migrating a
collection of Markdown files.\& Ordinarily, Oddmu maintains the hashtag pages
automatically.\& When writing pages offline, use \fIoddmu-notify\fR(1) to update the
hashtag pages.\&
.PP
Use the \fB-dry-run\fR flag to see what would change with the \fB-update\fR flag without
actually changing any files.\&
.PP
.SH EXAMPLES
.PP
List the top 10 hashtags.\& This requires 11 lines because of the header line.\&
.PP
.nf
.RS 4
oddmu hashtags | head -n 11
.fi
.RE
.PP
See what kind of changes Oddmu would suggest:
.PP
.nf
.RS 4
oddmu hashtags -update -dry-run
.fi
.RE
.PP
And then do it:
.PP
.nf
.RS 4
oddmu hashtags -update
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

53
man/oddmu-hashtags.1.txt Normal file
View File

@@ -0,0 +1,53 @@
ODDMU-HASHTAGS(1)
# NAME
oddmu-hashtags - work with hashtags
# SYNOPSIS
*oddmu hashtags*
*oddmu hashtags -update* [*-dry-run*]
# DESCRIPTION
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.
With the *-update* flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag. This only necessary when migrating a
collection of Markdown files. Ordinarily, Oddmu maintains the hashtag pages
automatically. When writing pages offline, use _oddmu-notify_(1) to update the
hashtag pages.
Use the *-dry-run* flag to see what would change with the *-update* flag without
actually changing any files.
# EXAMPLES
List the top 10 hashtags. This requires 11 lines because of the header line.
```
oddmu hashtags | head -n 11
```
See what kind of changes Oddmu would suggest:
```
oddmu hashtags -update -dry-run
```
And then do it:
```
oddmu hashtags -update
```
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,44 +1,54 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HTML" "1" "2023-10-09"
.TH "ODDMU-HTML" "1" "2026-01-03"
.PP
.SH NAME
.PP
oddmu-html - render Oddmu page HTML from the command-line
oddmu-html - render Oddmu page HTML
.PP
.SH SYNOPSIS
.PP
\fBoddmu html\fR [-view] \fIpage-name\fR
\fBoddmu html\fR [\fB-template\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.\&
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-template\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 EXAMPLE
.SH EXAMPLES
.PP
Generate the HTML for "README.\&md":
Generate "README.\&html" from "README.\&md":
.PP
.nf
.RS 4
oddmu html README
oddmu html README\&.md > README\&.html
.fi
.RE
.PP
Alternatively:
.PP
.nf
.RS 4
oddmu html - < README\&.md > README\&.html
.fi
.RE
.PP
.PP
.SH ENVIRONMENT
.PP
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&

View File

@@ -2,32 +2,40 @@ ODDMU-HTML(1)
# NAME
oddmu-html - render Oddmu page HTML from the command-line
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.
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".
# EXAMPLE
# EXAMPLES
Generate the HTML for "README.md":
Generate "README.html" from "README.md":
```
oddmu html README
oddmu html README.md > README.html
```
Alternatively:
```
oddmu html - < README.md > README.html
```
# ENVIRONMENT
The ODDMU_WEBFINGER environment variable has no effect in this situation.

29
man/oddmu-links.1 Normal file
View File

@@ -0,0 +1,29 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LINKS" "1" "2025-04-05"
.PP
.SH NAME
.PP
oddmu-links - list outgoing links for pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu links\fR \fIpage names.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
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
\fIoddmu\fR(1), \fIoddmu-missing\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

22
man/oddmu-links.1.txt Normal file
View File

@@ -0,0 +1,22 @@
ODDMU-LINKS(1)
# NAME
oddmu-links - list outgoing links for pages
# SYNOPSIS
*oddmu links* _page names..._
# DESCRIPTION
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
_oddmu_(1), _oddmu-missing_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,19 +1,19 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LIST" "1" "2023-12-20"
.TH "ODDMU-LIST" "1" "2025-08-31"
.PP
.SH NAME
.PP
oddmu-list - list page names and titles from the command-line
oddmu-list - list page names and titles
.PP
.SH SYNOPSIS
.PP
\fBoddmu list\fR [-dir string]
\fBoddmu list\fR [-dir \fIstring\fR]
.PP
.SH DESCRIPTION
.PP
@@ -31,7 +31,7 @@ subdirectory are listed, and the directory is stripped from the page name.\&
Limit the list to a particular directory.\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
Create list of links to pages in the "dad" directory, filter it for date pages
(starting with "2"), format it as a list of links and sort in reverse order.\&

View File

@@ -2,11 +2,11 @@ ODDMU-LIST(1)
# NAME
oddmu-list - list page names and titles from the command-line
oddmu-list - list page names and titles
# SYNOPSIS
*oddmu list* [-dir string]
*oddmu list* [-dir _string_]
# DESCRIPTION
@@ -22,7 +22,7 @@ subdirectory are listed, and the directory is stripped from the page name.
*-dir* _string_
Limit the list to a particular directory.
# EXAMPLE
# EXAMPLES
Create list of links to pages in the "dad" directory, filter it for date pages
(starting with "2"), format it as a list of links and sort in reverse order.

39
man/oddmu-man.1 Normal file
View File

@@ -0,0 +1,39 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-MAN" "1" "2026-02-11"
.PP
.SH NAME
.PP
oddmu-man - print the manual pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu man\fR
.PP
\fBoddmu man\fR \fItopic\fR
.PP
.SH DESCRIPTION
.PP
The "man" subcommand lists the topics available or prints the manual page for
the given topic.\&
.PP
Example:
.PP
.nf
.RS 4
oddmu man apache
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

30
man/oddmu-man.1.txt Normal file
View File

@@ -0,0 +1,30 @@
ODDMU-MAN(1)
# NAME
oddmu-man - print the manual pages
# SYNOPSIS
*oddmu man*
*oddmu man* _topic_
# DESCRIPTION
The "man" subcommand lists the topics available or prints the manual page for
the given topic.
Example:
```
oddmu man apache
```
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,15 +1,15 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-MISSING" "1" "2023-11-05"
.TH "ODDMU-MISSING" "1" "2024-08-29"
.PP
.SH NAME
.PP
oddmu-missing - list missing pages from the command-line
oddmu-missing - list missing pages
.PP
.SH SYNOPSIS
.PP
@@ -26,7 +26,7 @@ that start with a slash "/" and links that start with a known URL schema
.PP
Notably, links that start with ".\&.\&/" are reported as missing.\&
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
Looking for broken links:
.PP

View File

@@ -2,7 +2,7 @@ ODDMU-MISSING(1)
# NAME
oddmu-missing - list missing pages from the command-line
oddmu-missing - list missing pages
# SYNOPSIS
@@ -19,7 +19,7 @@ that start with a slash "/" and links that start with a known URL schema
Notably, links that start with "../" are reported as missing.
# EXAMPLE
# EXAMPLES
Looking for broken links:

140
man/oddmu-nginx.5 Normal file
View File

@@ -0,0 +1,140 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2026-01-03"
.PP
.SH NAME
.PP
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
authenticate themselves, and so on.\&
.PP
.SH CONFIGURATION
.PP
The site is defined in "/etc/nginx/sites-available/default", in the \fIserver\fR
section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR section:
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
.RE
.PP
If you remove an action from the regular expression, those requests no longer
get passed on to Oddmu.\& They are essentially disabled.\& Somebody on the same
machine pointing their browser at http://localhost:8080/ directly would still
have access to all the actions, of course.\&
.PP
.SS Access
.PP
Access control is not part of Oddmu.\& By default, the wiki is editable by all.\&
This is most likely not what you want unless you'\&re running it stand-alone,
unconnected to the Internet a personal memex on your laptop, for example.\&
.PP
To restrict access to some actions, use two different \fIlocation\fR sections:
.PP
.nf
.RS 4
# public
location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf\&.d/htpasswd;
proxy_pass http://localhost:8080;
}
.fi
.RE
.PP
The passwords in "/etc/nginx/conf.\&d/htpasswd" are generated using \fIopenssl\fR(1).\&
Assuming the password is "CPTk&qO[Y@?\&M~L>qKOkd", this is how you encrypt it:
.PP
.nf
.RS 4
openssl passwd \&'CPTk&qO[Y@?M~L>qKOkd\&'
.fi
.RE
.PP
The output gets used in "/etc/nginx/conf.\&d/htpasswd".\& Here'\&s the user "alex"
using this password:
.PP
.nf
.RS 4
alex:$1$DOwphABk$W4VmR9p8t2\&.htxF6ctXHX\&.
.fi
.RE
.PP
These instructions create user accounts with passwords just for Oddmu.\&
These users are not real users on the web server and don'\&t have access to a
shell, mail, or any other service.\&
.PP
.SS Using a Unix-domain Socket
.PP
Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket.\& This requires socket activation.\& An example of configuring
the service is given in \fIoddmu.\&service\fR(5).\&
.PP
On the nginx side, you can proxy to the socket using an \fIupstream\fR section.\& This
sends all requests to the socket.\& Use the upstream name as the server name for
\fIproxy_pass\fR.\& Add something like the configuration below to your existing nginx
server configuration.\& On a Debian system, that'\&d be in
"/etc/nginx/sites-available/default".\&
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu\&.sock:;
}
.fi
.RE
.PP
Reload the configuration:
.PP
.nf
.RS 4
sudo systemd reload nginx
.fi
.RE
.PP
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.\&sock".\&
.PP
To test it on the command-line, use a tool like \fIcurl(1)\fR.\&
.PP
.nf
.RS 4
curl http://localhost/view/index
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-apache\fR(5)
.PP
"freenginx"
http://freenginx.\&org/
.PP
"freenginx ngx_http_proxy_module", proxy_pass
http://freenginx.\&org/en/docs/http/ngx_http_proxy_module.\&html#proxy_pass
.PP
"freenginx ngx_http_auth_basic_module"
http://freenginx.\&org/en/docs/http/ngx_http_auth_basic_module.\&html
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

119
man/oddmu-nginx.5.txt Normal file
View File

@@ -0,0 +1,119 @@
ODDMU-NGINX(5)
# NAME
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
authenticate themselves, and so on.
# CONFIGURATION
The site is defined in "/etc/nginx/sites-available/default", in the _server_
section. Add a new _location_ section after the existing _location_ section:
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://localhost:8080;
}
```
If you remove an action from the regular expression, those requests no longer
get passed on to Oddmu. They are essentially disabled. Somebody on the same
machine pointing their browser at http://localhost:8080/ directly would still
have access to all the actions, of course.
## Access
Access control is not part of Oddmu. By default, the wiki is editable by all.
This is most likely not what you want unless you're running it stand-alone,
unconnected to the Internet a personal memex on your laptop, for example.
To restrict access to some actions, use two different _location_ sections:
```
# public
location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
proxy_pass http://localhost:8080;
}
```
The passwords in "/etc/nginx/conf.d/htpasswd" are generated using _openssl_(1).
Assuming the password is "CPTk&qO[Y@?M~L>qKOkd", this is how you encrypt it:
```
openssl passwd 'CPTk&qO[Y@?M~L>qKOkd'
```
The output gets used in "/etc/nginx/conf.d/htpasswd". Here's the user "alex"
using this password:
```
alex:$1$DOwphABk$W4VmR9p8t2.htxF6ctXHX.
```
These instructions create user accounts with passwords just for Oddmu.
These users are not real users on the web server and don't have access to a
shell, mail, or any other service.
## Using a Unix-domain Socket
Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket. This requires socket activation. An example of configuring
the service is given in _oddmu.service_(5).
On the nginx side, you can proxy to the socket using an _upstream_ section. This
sends all requests to the socket. Use the upstream name as the server name for
_proxy_pass_. Add something like the configuration below to your existing nginx
server configuration. On a Debian system, that'd be in
"/etc/nginx/sites-available/default".
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|sitemap|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu.sock:;
}
```
Reload the configuration:
```
sudo systemd reload nginx
```
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.sock".
To test it on the command-line, use a tool like _curl(1)_.
```
curl http://localhost/view/index
```
# SEE ALSO
_oddmu_(1), _oddmu-apache_(5)
"freenginx"
http://freenginx.org/
"freenginx ngx_http_proxy_module", proxy_pass
http://freenginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass
"freenginx ngx_http_auth_basic_module"
http://freenginx.org/en/docs/http/ngx_http_auth_basic_module.html
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NOTIFY" "1" "2023-11-06"
.TH "ODDMU-NOTIFY" "1" "2025-04-05"
.PP
.SH NAME
.PP
@@ -17,23 +17,25 @@ 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 if it doesn'\&t exist.\& The current date
of the machine Oddmu is running on is used as the heading.\& If the requested link
already exists on the changes page, it is moved up to the current date.\& If that
leaves an old date without any links, that date heading is removed.\&
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
heading.\& If the requested link already exists on the changes page, it is moved
up to the current date.\& If that leaves an old date without any links, that date
heading is removed.\&
.PP
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
called a \fBblog\fR page.\&
.PP
A link is created from the \fBindex\fR page to blog pages if and only if the blog
pages are from the current year.\& The idea is that the front page contains a lot
of links to blog posts but eventually the blog post links are moved onto archive
pages (one per year, for example), or simply deleted.\& As when editing older
pages, links to those pages should not get added to the index as if those older
pages were new again.\& A link on the changes page is enough.\&
A link is created from the \fBindex\fR page in the current directory to blog pages
if and only if the blog pages are from the current year.\& The idea is that the
front page contains a lot of links to blog posts but eventually the blog post
links are moved onto archive pages (one per year, for example), or simply
deleted.\& As when editing older pages, links to those pages should not get added
to the index as if those older pages were new again.\& A link on the changes page
is enough.\&
.PP
For every \fBhashtag\fR used on the pages named, another link might be created.\& If a
page named like the hashtag exists, a backlink is added to it.\& A hashtag
@@ -47,7 +49,7 @@ using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at
bottom of the page.\& This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('\&-'\&).\&
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
After writing the file "2023-11-05-climate.\&md" containing the hashtag
"#Climate", add links to it from "index.\&md", "changes.\&md", and "Climate.\&md" (if
@@ -55,7 +57,45 @@ it exists):
.PP
.nf
.RS 4
oddmu notify 2023-11-05-climate
oddmu notify 2023-11-05-climate\&.md
.fi
.RE
.PP
The changes file might look as follows:
.PP
.nf
.RS 4
# Changes
This page lists all the changes made to the wiki\&.
## 2023-11-05
* [Global warming](2023-11-05-climate)
.fi
.RE
.PP
The index file might look as follows:
.PP
.nf
.RS 4
# Blog
This page links to all the blog posts\&.
* [Global warming](2023-11-05-climate)
.fi
.RE
.PP
The hashtag file might look as follows:
.PP
.nf
.RS 4
# Climate
This page links to all the blog posts tagged #Climate\&.
* [Global warming](2023-11-05-climate)
.fi
.RE
.PP

View File

@@ -10,23 +10,25 @@ 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 if it doesn't exist. The current date
of the machine Oddmu is running on is used as the heading. If the requested link
already exists on the changes page, it is moved up to the current date. If that
leaves an old date without any links, that date heading is removed.
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
heading. If the requested link already exists on the changes page, it is moved
up to the current date. If that leaves an old date without any links, that date
heading is removed.
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
called a *blog* page.
A link is created from the *index* page to blog pages if and only if the blog
pages are from the current year. The idea is that the front page contains a lot
of links to blog posts but eventually the blog post links are moved onto archive
pages (one per year, for example), or simply deleted. As when editing older
pages, links to those pages should not get added to the index as if those older
pages were new again. A link on the changes page is enough.
A link is created from the *index* page in the current directory to blog pages
if and only if the blog pages are from the current year. The idea is that the
front page contains a lot of links to blog posts but eventually the blog post
links are moved onto archive pages (one per year, for example), or simply
deleted. As when editing older pages, links to those pages should not get added
to the index as if those older pages were new again. A link on the changes page
is enough.
For every *hashtag* used on the pages named, another link might be created. If a
page named like the hashtag exists, a backlink is added to it. A hashtag
@@ -40,14 +42,46 @@ using the asterisk ('\*'). If no such list exists, a new one is started at the
bottom of the page. This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('-').
# EXAMPLE
# EXAMPLES
After writing the file "2023-11-05-climate.md" containing the hashtag
"#Climate", add links to it from "index.md", "changes.md", and "Climate.md" (if
it exists):
```
oddmu notify 2023-11-05-climate
oddmu notify 2023-11-05-climate.md
```
The changes file might look as follows:
```
# Changes
This page lists all the changes made to the wiki.
## 2023-11-05
* [Global warming](2023-11-05-climate)
```
The index file might look as follows:
```
# Blog
This page links to all the blog posts.
* [Global warming](2023-11-05-climate)
```
The hashtag file might look as follows:
```
# Climate
This page links to all the blog posts tagged #Climate.
* [Global warming](2023-11-05-climate)
```
# SEE ALSO

500
man/oddmu-releases.7 Normal file
View File

@@ -0,0 +1,500 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2026-02-11"
.PP
.SH NAME
.PP
oddmu-releases - what'\&s new?\&
.PP
.SH DESCRIPTION
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.21 (unreleased)
.PP
Write any missing templates when Oddmu starts up.\&
.PP
Add man subcommand to print manual pages.\&
.PP
Both of these features make it possible to distribute just the binary.\&
.PP
.SS 1.20 (2026)
.PP
Add -shrink and -glob options to the \fIstatic\fR subcommand.\& See \fIoddmu-static\fR(1)
for more.\&
.PP
Some tools were used to check the code (goimports, golint, gocritic).\&
Unfortunately, the resulting changes necessitates a change in the templates
("feed.\&html", "preview.\&html", "search.\&html", "static.\&html", "view.\&html"):
"{{.\&Html}}" must be changed to "{{.\&HTML}}".\& One way to do this:
.PP
.nf
.RS 4
find \&. -regex \&'\&.*/(feed|preview|search|static|view).html\&'
-exec sed -i~ \&'s/{{\&.Html}}/{{\&.HTML}}/g\&' \&'{}\&' \&'+\&'
.fi
.RE
.PP
The \fIfeed\fR subcommand uses the page URL to extract a pubDate instead of relying
on the file'\&s last modified time.\& For a complete feed (an archive), the last
modified time is less important.\&
.PP
The feed for the index page is paginated, like other feeds.\& But since it grows
faster than any of the feeds for hashtag pages, presumably, an extra features
was added: on the first and on the last page of the feed, a link to the next or
the previous year is added, if such a page exists.\& This works if at beginning of
every year, you move all the entries on to a dedicated year page.\& You need to
add the necessary links to the "feed.\&html" template.\& See \fIoddmu-templates\fR(5)
for more.\&
.PP
Example:
.PP
.nf
.RS 4
<rss xmlns:atom="http://www\&.w3\&.org/2005/Atom" version="2\&.0"
xmlns:fh="http://purl\&.org/syndication/history/1\&.0">
{{if \&.PrevYear}}
<atom:link href="https://example\&.org/view/{{\&.Dir}}{{\&.PrevYear}}\&.rss?n={{\&.N}}"
rel="previous" type="application/rss+xml"/>
{{end}}
{{if \&.NextYear}}
<atom:link href="https://example\&.org/view/{{\&.Dir}}{{\&.NextYear}}\&.rss?n={{\&.N}}"
rel="next" type="application/rss+xml"/>
{{end}}
.fi
.RE
.PP
Add \fIsitemap\fR subcommand and handler.\& See \fIoddmu-sitemap\fR(1) for more.\& If you
want to make it available for search engines and the like, you most likely have
to add it to your proxy configuration.\& See \fIoddmu-apache\fR(5) or \fIoddmu-nginx\fR(5)
for more.\&
.PP
.SS 1.19 (2025)
.PP
Add \fIfeed\fR subcommand.\& This produces a "complete" feed.\&
.PP
Add feed pagination for the \fIfeed\fR action.\& This produces a "paginated" feed.\&
.PP
See RFC 5005 for more information.\&
.PP
If you like the idea of feed pagination (not a given since that also helps bots
scrape your site!\&) you need to add the necessary links to the "feed.\&html"
template.\& See \fIoddmu-templates\fR(5) for more.\&
.PP
Example, adding the feed history namespace:
.PP
.nf
.RS 4
<rss xmlns:atom="http://www\&.w3\&.org/2005/Atom" version="2\&.0"
xmlns:fh="http://purl\&.org/syndication/history/1\&.0">
{{if \&.From}}
<atom:link rel="previous" type="application/rss+xml"
href="https://example\&.org/view/{{\&.Path}}\&.rss?from={{\&.Prev}}&amp;n={{\&.N}}"/>
{{end}}
{{if \&.Next}}
<atom:link rel="next" type="application/rss+xml"
href="https://example\&.org/view/{{\&.Path}}\&.rss?from={{\&.Next}}&amp;n={{\&.N}}"/>
{{end}}
{{if \&.Complete}}<fh:complete/>{{end}}
.fi
.RE
.PP
.SS 1.18 (2025)
.PP
The \fIhashtags\fR gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages.\& See \fIoddmu-hashtags\fR(1) for more.\&
.PP
In an effort to remove features that can be handled by the web server, the
\fIlist\fR, \fIdelete\fR and \fIrename\fR actions were removed again.\& See \fIoddmu-webdav\fR(5)
for a better solution.\&
.PP
You probably need to remove a sentence linking to the list action from the
"upload.\&html" template.\&
.PP
.SS 1.17 (2025)
.PP
You need to update the "upload.\&html" template.\& Many things have changed!\& See
\fIoddmu-templates\fR(5) for more.\&
.PP
You probably want to ensure that the upload link on the "view.\&html" template 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.\&html" template.\& 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.\&
.PP
This requires a change to your web server setup if you are using a it as a
reverse proxy because you need to pass these new actions along to Oddmu,
together with appropriate permission checks.\&
.PP
See \fIoddmu-apache\fR(5) or \fIoddmu-nginx\fR(5) for example.\&
.PP
In addition to that, you might want a link to the \fIlist\fR action from one of the
existing templates.\& For example, from the "upload.\&html" template:
.PP
.nf
.RS 4
<p>You can rename and delete files <a href="/list/{{\&.Dir}}">from the file list</a>\&.
.fi
.RE
.PP
The following line was added to the "preview.\&html" and "edit.\&html" template:
.PP
.nf
.RS 4
<base href="/view/{{\&.Dir}}">
.fi
.RE
.PP
You might want to do that as well, if you have your own.\& Without this, links in
the preview cannot be followed as they all point to \fB/preview\fR instead of
\fB/view\fR and the link to the list of changes cannot be followed from the edit
page: it leads to editing the list of changes.\&
.PP
.SS 1.13 (2024)
.PP
Add \fIexport\fR subcommand.\&
.PP
.SS 1.12 (2024)
.PP
Add \fIhashtags\fR, \fIlinks\fR and \fItoc\fR subcommands.\&
.PP
Support searching for multiple words using all sorts of quotation marks.\& That
means that it is now impossible to search for words that begin with such a
quotation mark.\&
.PP
These are the quotation marks currently supported: '\&foo'\& "foo" foo foo foo
“foo” „foo“ ”foo” «foo» »foo« foo foo 「foo」 「foo」 『foo』 any such
quoted text is searched as-is, including whitespace.\&
.PP
Add loading="lazy" for images in the search template.\& If you want to take
advantage of this, you'\&ll need to adapt your "search.\&html" template accordingly.\&
Use like this, for example:
.PP
.nf
.RS 4
{{range \&.Items}}
<article lang="{{\&.Language}}">
<p><a class="result" href="/view/{{\&.Name}}">{{\&.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}}
{{end}}
</article>
{{end}}
.fi
.RE
.PP
.SS 1.11 (2024)
.PP
The HTML renderer option for smart fractions support was removed.\& Therefore, 1/8
no longer turns into ⅛ or ¹⁄₈.\& The benefit is that something like "doi:
10.\&1017/9781009157926.\&007" doesn'\&t turn into "doi: 10.\&10179781009157926.\&007".\&
If you need to change this, take a look at the \fIwikiRenderer\fR function.\&
.PP
When search terms (excluding hashtags) match the alt text given for an image,
that image is part of the data available to the search template.\&
.PP
If you want to take advantage of this, you'\&ll need to adapt your "search.\&html"
template accordingly.\& Use like this, for example:
.PP
.nf
.RS 4
{{range \&.Items}}
<article lang="{{\&.Language}}">
<p><a class="result" href="/view/{{\&.Name}}">{{\&.Title}}</a>
<span class="score">{{\&.Score}}</span></p>
<blockquote>{{\&.Html}}</blockquote>
{{range \&.Images}}
<p class="image"><a href="/view/{{\&.Name}}"><img class="last" src="/view/{{\&.Name}}"></a><br/>{{\&.Html}}
{{end}}
</article>
{{end}}
.fi
.RE
.PP
.SS 1.10 (2024)
.PP
You can now preview edits instead of saving them.\&
.PP
.PD 0
.IP \(bu 4
a preview button was added to "edit.\&html"
.IP \(bu 4
a new "preview.\&html" was added
.PD
.PP
If you want to take advantage of this, you'\&ll need to adapt your templates
accordingly.\& The "preview.\&html" template is a mix of "view.\&html" and
"edit.\&html".\&
.PP
There is an optional change to make to copies of the "upload.\&html" template if
you upload multiple images at a time.\& Instead of showing just the link to the
last upload, you can now show the link (and the images or links, if you want to)
to all the files uploaded.\& Use like this, for example:
.PP
.nf
.RS 4
Links:<tt>{{range \&.Actual}}<br>![]({{\&.}}){{end}}</tt>
.fi
.RE
.PP
.SS 1.9 (2024)
.PP
There is a change to make to copies of the "upload.\&html" template if
subdirectories are being used.\& The \fILast\fR property no longer contains the
directory.\& It has to be added to the template as follows:
.PP
.nf
.RS 4
{{if ne \&.Last ""}}
<p>Previous upload: <a href="/view/{{\&.Dir}}{{\&.Last}}">{{\&.Last}}</a></p>
{{if \&.Image}}
<p><img class="last" src="/view/{{\&.Dir}}{{\&.Last}}"></p>
{{end}}
{{end}}
.fi
.RE
.PP
You can use the \fILast\fR property without a directory to suggest the markup to
use, for example:
.PP
.nf
.RS 4
<p>Use the following for <a href="/view/{{\&.Dir}}{{\&.Today}}">{{\&.Today}}</a>:
<pre>![]({{\&.Last}})</a></pre>
.fi
.RE
.PP
The upload template can use the \fIToday\fR property.\&
.PP
The upload template comes with JavaScript that allows users to paste images or
drag and drop files.\&
.PP
The upload template changed the id for the filename field from "text" to "name".\&
.PP
The source repository now comes with example templates.\&
.PP
.SS 1.8 (2024)
.PP
No user-visible changes.\& Documentation and code comments got better.\&
.PP
.SS 1.7 (2024)
.PP
Allow upload of multiple files.\& This requires an update to the "upload.\&html"
template: Add the \fImultiple\fR attribute to the file input element and change the
label from "file" to "files".\&
.PP
Fix orientation of uploaded images.\& JPG and HEIC images have EXIF data telling a
viewer how to orient the image.\& Oddmu now uses this information to rotate the
image correctly before stripping it.\&
.PP
The version command now displays much less information unless given the -full
argument.\&
.PP
.SS 1.6 (2024)
.PP
Add \fIarchive\fR action to serve a zip file.\&
.PP
.SS 1.5 (2024)
.PP
Filtering separate sites in subdirectories via the ODDMU_FILTER environment
variable in order to exclude them from the \fIsearch\fR action.\&
.PP
Add \fIversion\fR subcommand.\&
.PP
Add filesystem watchers to automatically reindex changed pages and reload
changed templates.\&
.PP
When rendering a page, use templates in the same directory, if available.\&
.PP
Delete uploaded files by uploading a file with zero bytes.\&
.PP
.SS 1.4 (2024)
.PP
If stdin is a Unix-domain socket, use that to serve the site.\& Otherwise, allow
specifying a listen address via the ODDMU_ADDRESS environment variable.\&
.PP
.SS 1.3 (2024)
.PP
Add support for resizing HEIC images (and saving them as JPG files).\&
.PP
.SS 1.2 (2023)
.PP
Add \fIlist\fR subcommand.\&
.PP
.SS 1.1 (2023)
.PP
Rewrote most of the README into man pages.\&
.PP
Add fediverse account rendering if ODDMU_WEBFINGER is set.\&
.PP
Add notifications when saving files: adding links to \fIindex\fR, \fIchanges\fR and
\fIhashtag\fR pages.\&
.PP
Add \fIreplace\fR subcommand.\& Add \fImissing\fR subcommand.\& Add \fInotify\fR command.\& Add
\fIstatic\fR command.\&
.PP
Add \fIdiff\fR action.\&
.PP
Add feed generation based on the local links from a page.\&
.PP
Add caching support by considering the If-Modified-Since header in requests and
providing a Last-Modified header in responses.\&
.PP
Handle HEAD requests.\&
.PP
Remove HTML sanitization.\&
.PP
Remove MathJax support from the wiki parser.\& The templates never included the
necessary MathJax JavaScript anyway so the special handling of $ was just an
annoyance.\&
.PP
Drop trigram index and just search all the files.\& This takes much less RAM and
doesn'\&t take too much time even with a few thousand pages.\&
.PP
Add "blog:true" and "blog:false" predicates to search.\&
.PP
Limit search to the current directory tree.\&
.PP
Do not overwrite fresh backups: there must be a 1h break before the backup is
overwritten.\&
.PP
.SS 1.0 (2023)
.PP
Paginate search results and no longer sort search results by score.\&
.PP
.SS 0.9 (2023)
.PP
Add image resizing.\&
.PP
Add wiki links in double square brackets to the parser.\&
.PP
.SS 0.8 (2023)
.PP
Rename files to backups before saving.\&
.PP
Rename the \fIsaveUpload\fR action to \fIdrop\fR.\&
.PP
Add the \fIsearch\fR subcommand.\&
.PP
.SS 0.7 (2023)
.PP
Add \fIupload\fR and \fIsaveUpload\fR action so that one can upload files.\&
.PP
Add \fIhtml\fR subcommand.\&
.PP
.SS 0.6 (2003)
.PP
Add \fIadd\fR and \fIappend\fR action so that one can add to an existing page.\& This is
important for me as editing pages on the phone can be cumbersome but leaving
comments on my own site has always been easy to do.\&
.PP
Serve all existing files, not just text files.\&
.PP
Save an empty page to delete it.\&
.PP
Changed default permissions from 600 to 644 for files and from 700 to 755 for
directories.\&
.PP
Make language detection configurable using an environment variable.\&
.PP
.SS 0.5 (2023)
.PP
Add hyphenation to templates using Peter M.\& Stahl'\&s Lingua library.\&
.PP
.SS 0.4 (2023)
.PP
Create subdirectories as necessary.\&
.PP
.SS 0.3 (2023)
.PP
Add \fIsearch\fR action using Damian Gryski'\&s trigram indexing, with scoring,
highlighting and snippet extraction.\&
.PP
.SS 0.2 (2023)
.PP
Switch to Krzysztof Kowalczyk'\&s Go Markdown fork of Blackfriday to render
Markdown.\& Use Dee'\&s Bluemonday to sanitize HTML.\&
.PP
Switch to GNU Affero GPL 3 license.\&
.PP
Serve text files (.\&txt).\&
.PP
Support serving on any port via the environment variable ODDMU_PORT.\&
.PP
.SS 0.1 (2015)
.PP
A web server that allows editing files in Wiki Creole Matt Self'\&s Cajun library.\&
Supported actions are \fIedit\fR, \fIsave\fR, and \fIview\fR.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

467
man/oddmu-releases.7.txt Normal file
View File

@@ -0,0 +1,467 @@
ODDMU-RELEASES(7)
# NAME
oddmu-releases - what's new?
# DESCRIPTION
This page lists user-visible features and template changes to consider.
## 1.21 (unreleased)
Write any missing templates when Oddmu starts up.
Add man subcommand to print manual pages.
Both of these features make it possible to distribute just the binary.
## 1.20 (2026)
Add -shrink and -glob options to the _static_ subcommand. See _oddmu-static_(1)
for more.
Some tools were used to check the code (goimports, golint, gocritic).
Unfortunately, the resulting changes necessitates a change in the templates
("feed.html", "preview.html", "search.html", "static.html", "view.html"):
"{{.Html}}" must be changed to "{{.HTML}}". One way to do this:
```
find . -regex '.*/\(feed\|preview\|search\|static\|view\)\.html' \
-exec sed -i~ 's/{{.Html}}/{{.HTML}}/g' '{}' '+'
```
The _feed_ subcommand uses the page URL to extract a pubDate instead of relying
on the file's last modified time. For a complete feed (an archive), the last
modified time is less important.
The feed for the index page is paginated, like other feeds. But since it grows
faster than any of the feeds for hashtag pages, presumably, an extra features
was added: on the first and on the last page of the feed, a link to the next or
the previous year is added, if such a page exists. This works if at beginning of
every year, you move all the entries on to a dedicated year page. You need to
add the necessary links to the "feed.html" template. See _oddmu-templates_(5)
for more.
Example:
```
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"
xmlns:fh="http://purl.org/syndication/history/1.0">
{{if .PrevYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.PrevYear}}.rss?n={{.N}}"
rel="previous" type="application/rss+xml"/>
{{end}}
{{if .NextYear}}
<atom:link href="https://example.org/view/{{.Dir}}{{.NextYear}}.rss?n={{.N}}"
rel="next" type="application/rss+xml"/>
{{end}}
```
Add _sitemap_ subcommand and handler. See _oddmu-sitemap_(1) for more. If you
want to make it available for search engines and the like, you most likely have
to add it to your proxy configuration. See _oddmu-apache_(5) or _oddmu-nginx_(5)
for more.
## 1.19 (2025)
Add _feed_ subcommand. This produces a "complete" feed.
Add feed pagination for the _feed_ action. This produces a "paginated" feed.
See RFC 5005 for more information.
If you like the idea of feed pagination (not a given since that also helps bots
scrape your site!) you need to add the necessary links to the "feed.html"
template. See _oddmu-templates_(5) for more.
Example, adding the feed history namespace:
```
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"
xmlns:fh="http://purl.org/syndication/history/1.0">
{{if .From}}
<atom:link rel="previous" type="application/rss+xml"
href="https://example.org/view/{{.Path}}.rss?from={{.Prev}}&amp;n={{.N}}"/>
{{end}}
{{if .Next}}
<atom:link rel="next" type="application/rss+xml"
href="https://example.org/view/{{.Path}}.rss?from={{.Next}}&amp;n={{.N}}"/>
{{end}}
{{if .Complete}}<fh:complete/>{{end}}
```
## 1.18 (2025)
The _hashtags_ gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages. See _oddmu-hashtags_(1) for more.
In an effort to remove features that can be handled by the web server, the
_list_, _delete_ and _rename_ actions were removed again. See _oddmu-webdav_(5)
for a better solution.
You probably need to remove a sentence linking to the list action from the
"upload.html" template.
## 1.17 (2025)
You need to update the "upload.html" template. Many things have changed! See
_oddmu-templates_(5) for more.
You probably want to ensure that the upload link on the "view.html" template 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.html" template. 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.
This requires a change to your web server setup if you are using a it as a
reverse proxy because you need to pass these new actions along to Oddmu,
together with appropriate permission checks.
See _oddmu-apache_(5) or _oddmu-nginx_(5) for example.
In addition to that, you might want a link to the _list_ action from one of the
existing templates. For example, from the "upload.html" template:
```
<p>You can rename and delete files <a href="/list/{{.Dir}}">from the file list</a>.
```
The following line was added to the "preview.html" and "edit.html" template:
```
<base href="/view/{{.Dir}}">
```
You might want to do that as well, if you have your own. Without this, links in
the preview cannot be followed as they all point to */preview* instead of
*/view* and the link to the list of changes cannot be followed from the edit
page: it leads to editing the list of changes.
## 1.13 (2024)
Add _export_ subcommand.
## 1.12 (2024)
Add _hashtags_, _links_ and _toc_ subcommands.
Support searching for multiple words using all sorts of quotation marks. That
means that it is now impossible to search for words that begin with such a
quotation mark.
These are the quotation marks currently supported: 'foo' "foo" foo foo foo
“foo” „foo“ ”foo” «foo» »foo« foo foo 「foo」 「foo」 『foo』 any such
quoted text is searched as-is, including whitespace.
Add loading="lazy" for images in the search template. If you want to take
advantage of this, you'll need to adapt your "search.html" template accordingly.
Use like this, for example:
```
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.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}}
{{end}}
</article>
{{end}}
```
## 1.11 (2024)
The HTML renderer option for smart fractions support was removed. Therefore, 1/8
no longer turns into ⅛ or ¹⁄₈. The benefit is that something like "doi:
10.1017/9781009157926.007" doesn't turn into "doi: 10.10179781009157926.007".
If you need to change this, take a look at the _wikiRenderer_ function.
When search terms (excluding hashtags) match the alt text given for an image,
that image is part of the data available to the search template.
If you want to take advantage of this, you'll need to adapt your "search.html"
template accordingly. Use like this, for example:
```
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
<span class="score">{{.Score}}</span></p>
<blockquote>{{.Html}}</blockquote>
{{range .Images}}
<p class="image"><a href="/view/{{.Name}}"><img class="last" src="/view/{{.Name}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}
```
## 1.10 (2024)
You can now preview edits instead of saving them.
- a preview button was added to "edit.html"
- a new "preview.html" was added
If you want to take advantage of this, you'll need to adapt your templates
accordingly. The "preview.html" template is a mix of "view.html" and
"edit.html".
There is an optional change to make to copies of the "upload.html" template if
you upload multiple images at a time. Instead of showing just the link to the
last upload, you can now show the link (and the images or links, if you want to)
to all the files uploaded. Use like this, for example:
```
Links:<tt>{{range .Actual}}<br>![]({{.}}){{end}}</tt>
```
## 1.9 (2024)
There is a change to make to copies of the "upload.html" template if
subdirectories are being used. The _Last_ property no longer contains the
directory. It has to be added to the template as follows:
```
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Dir}}{{.Last}}">{{.Last}}</a></p>
{{if .Image}}
<p><img class="last" src="/view/{{.Dir}}{{.Last}}"></p>
{{end}}
{{end}}
```
You can use the _Last_ property without a directory to suggest the markup to
use, for example:
```
<p>Use the following for <a href="/view/{{.Dir}}{{.Today}}">{{.Today}}</a>:
<pre>![]({{.Last}})</a></pre>
```
The upload template can use the _Today_ property.
The upload template comes with JavaScript that allows users to paste images or
drag and drop files.
The upload template changed the id for the filename field from "text" to "name".
The source repository now comes with example templates.
## 1.8 (2024)
No user-visible changes. Documentation and code comments got better.
## 1.7 (2024)
Allow upload of multiple files. This requires an update to the "upload.html"
template: Add the _multiple_ attribute to the file input element and change the
label from "file" to "files".
Fix orientation of uploaded images. JPG and HEIC images have EXIF data telling a
viewer how to orient the image. Oddmu now uses this information to rotate the
image correctly before stripping it.
The version command now displays much less information unless given the -full
argument.
## 1.6 (2024)
Add _archive_ action to serve a zip file.
## 1.5 (2024)
Filtering separate sites in subdirectories via the ODDMU_FILTER environment
variable in order to exclude them from the _search_ action.
Add _version_ subcommand.
Add filesystem watchers to automatically reindex changed pages and reload
changed templates.
When rendering a page, use templates in the same directory, if available.
Delete uploaded files by uploading a file with zero bytes.
## 1.4 (2024)
If stdin is a Unix-domain socket, use that to serve the site. Otherwise, allow
specifying a listen address via the ODDMU_ADDRESS environment variable.
## 1.3 (2024)
Add support for resizing HEIC images (and saving them as JPG files).
## 1.2 (2023)
Add _list_ subcommand.
## 1.1 (2023)
Rewrote most of the README into man pages.
Add fediverse account rendering if ODDMU_WEBFINGER is set.
Add notifications when saving files: adding links to _index_, _changes_ and
_hashtag_ pages.
Add _replace_ subcommand. Add _missing_ subcommand. Add _notify_ command. Add
_static_ command.
Add _diff_ action.
Add feed generation based on the local links from a page.
Add caching support by considering the If-Modified-Since header in requests and
providing a Last-Modified header in responses.
Handle HEAD requests.
Remove HTML sanitization.
Remove MathJax support from the wiki parser. The templates never included the
necessary MathJax JavaScript anyway so the special handling of $ was just an
annoyance.
Drop trigram index and just search all the files. This takes much less RAM and
doesn't take too much time even with a few thousand pages.
Add "blog:true" and "blog:false" predicates to search.
Limit search to the current directory tree.
Do not overwrite fresh backups: there must be a 1h break before the backup is
overwritten.
## 1.0 (2023)
Paginate search results and no longer sort search results by score.
## 0.9 (2023)
Add image resizing.
Add wiki links in double square brackets to the parser.
## 0.8 (2023)
Rename files to backups before saving.
Rename the _saveUpload_ action to _drop_.
Add the _search_ subcommand.
## 0.7 (2023)
Add _upload_ and _saveUpload_ action so that one can upload files.
Add _html_ subcommand.
## 0.6 (2003)
Add _add_ and _append_ action so that one can add to an existing page. This is
important for me as editing pages on the phone can be cumbersome but leaving
comments on my own site has always been easy to do.
Serve all existing files, not just text files.
Save an empty page to delete it.
Changed default permissions from 600 to 644 for files and from 700 to 755 for
directories.
Make language detection configurable using an environment variable.
## 0.5 (2023)
Add hyphenation to templates using Peter M. Stahl's Lingua library.
## 0.4 (2023)
Create subdirectories as necessary.
## 0.3 (2023)
Add _search_ action using Damian Gryski's trigram indexing, with scoring,
highlighting and snippet extraction.
## 0.2 (2023)
Switch to Krzysztof Kowalczyk's Go Markdown fork of Blackfriday to render
Markdown. Use Dee's Bluemonday to sanitize HTML.
Switch to GNU Affero GPL 3 license.
Serve text files (.txt).
Support serving on any port via the environment variable ODDMU_PORT.
## 0.1 (2015)
A web server that allows editing files in Wiki Creole Matt Self's Cajun library.
Supported actions are _edit_, _save_, and _view_.
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,15 +1,15 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-REPLACE" "1" "2023-11-24"
.TH "ODDMU-REPLACE" "1" "2025-03-05"
.PP
.SH NAME
.PP
oddmu-replace - replace text in Oddmu pages from the command-line
oddmu-replace - replace text in Oddmu pages
.PP
.SH SYNOPSIS
.PP
@@ -36,13 +36,13 @@ the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.\&) to capture groups.\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
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

@@ -2,7 +2,7 @@ ODDMU-REPLACE(1)
# NAME
oddmu-replace - replace text in Oddmu pages from the command-line
oddmu-replace - replace text in Oddmu pages
# SYNOPSIS
@@ -25,12 +25,12 @@ the current directory and its subdirectories.
the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.) to capture groups.
# EXAMPLE
# EXAMPLES
Replace "Oddmu" in the Markdown files of the current directory:
```
oddmu replace Oddmu Oddµ
oddmu replace Oddmu Oddμ
```
Result:

View File

@@ -1,15 +1,15 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "1" "2023-12-20"
.TH "ODDMU-SEARCH" "1" "2025-03-05"
.PP
.SH NAME
.PP
oddmu-search - search the Oddmu pages from the command-line
oddmu-search - search the Oddmu pages
.PP
.SH SYNOPSIS
.PP
@@ -17,8 +17,9 @@ oddmu-search - search the Oddmu pages from the command-line
.PP
.SH DESCRIPTION
.PP
The "search" subcommand searches the Markdown files in the current
directory.\&
The "search" subcommand resursively searches the Markdown files in the current
directory tree.\& That is, the files in the current directory and all its child
directories are searched.\&
.PP
Be default, this returns a Markdown-formatted list suitable for pasting into
Oddmu pages.\&
@@ -26,6 +27,10 @@ Oddmu pages.\&
If a directory is provided, only files from the tree starting at that
subdirectory are listed, and the directory is stripped from the page name.\&
.PP
If multiple terms are provided, they are all concatenated into a single,
space-separated query string.\& That is, searching for the terms A B and the term
"A B" is equivalent.\&
.PP
See \fIoddmu-search\fR(7) for more information of how pages are searched, sorted and
scored.\&
.PP
@@ -37,7 +42,7 @@ Limit search to a particular directory.\&
.RE
\fB-extract\fR
.RS 4
Print search extracts for interactive use from the command-line.\&
Print search extracts for interactive use
.RE
\fB-page\fR \fIn\fR
.RS 4
@@ -49,22 +54,32 @@ shown.\& This option allows you to view other pages.\&
Ignore pagination and just print a long list of results.\&
.PP
.RE
.SH EXAMPLE
.SH EXAMPLES
.PP
Search for "oddmu" in the Markdown files of the current directory:
Search for the two words "Alex" and "Schroeder".\& All of the following are
equivalent: Alex Schroeder, Schroeder Alex, "Alex Schroeder", "Schroeder Alex".\&
The ordering of terms does not matter.\&
.PP
.nf
.RS 4
oddmu search oddmu
~/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)
* [Themes](themes/index)
.fi
.RE
.PP
Result:
Search for the exact phrase "Alex Schroeder".\& In order to pass the quotes to
Oddmu, a second level of quotes is required.\& All of the following are
equivalent: '\&"Alex Schroeder"'\&, "'\&Alex Schroeder'\&", \e"Alex\e Schroeder\e",
\e"Alex Schroeder\e".\&
.PP
.nf
.RS 4
Search oddmu: 1 result
* [Oddµ: A minimal wiki](README) (5)
~/src/oddmu $ oddmu search "\&'Alex Schroeder\&'"
Search for \&'Alex Schroeder\&', page 1: 1 result
* [Alex Schroeder theme](themes/alexschroeder\&.ch/README)
.fi
.RE
.PP

View File

@@ -2,7 +2,7 @@ ODDMU-SEARCH(1)
# NAME
oddmu-search - search the Oddmu pages from the command-line
oddmu-search - search the Oddmu pages
# SYNOPSIS
@@ -10,8 +10,9 @@ oddmu-search - search the Oddmu pages from the command-line
# DESCRIPTION
The "search" subcommand searches the Markdown files in the current
directory.
The "search" subcommand resursively searches the Markdown files in the current
directory tree. That is, the files in the current directory and all its child
directories are searched.
Be default, this returns a Markdown-formatted list suitable for pasting into
Oddmu pages.
@@ -19,6 +20,10 @@ Oddmu pages.
If a directory is provided, only files from the tree starting at that
subdirectory are listed, and the directory is stripped from the page name.
If multiple terms are provided, they are all concatenated into a single,
space-separated query string. That is, searching for the terms A B and the term
"A B" is equivalent.
See _oddmu-search_(7) for more information of how pages are searched, sorted and
scored.
@@ -27,26 +32,36 @@ scored.
*-dir* _string_
Limit search to a particular directory.
*-extract*
Print search extracts for interactive use from the command-line.
Print search extracts for interactive use
*-page* _n_
Search results are paginated and by default only the first page is
shown. This option allows you to view other pages.
*-all*
Ignore pagination and just print a long list of results.
# EXAMPLE
# EXAMPLES
Search for "oddmu" in the Markdown files of the current directory:
Search for the two words "Alex" and "Schroeder". All of the following are
equivalent: Alex Schroeder, Schroeder Alex, "Alex Schroeder", "Schroeder Alex".
The ordering of terms does not matter.
```
oddmu search oddmu
~/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)
* [Themes](themes/index)
```
Result:
Search for the exact phrase "Alex Schroeder". In order to pass the quotes to
Oddmu, a second level of quotes is required. All of the following are
equivalent: '"Alex Schroeder"', "'Alex Schroeder'", \\"Alex\\ Schroeder\\",
\\"Alex Schroeder\\".
```
Search oddmu: 1 result
* [Oddµ: A minimal wiki](README) (5)
~/src/oddmu $ oddmu search "'Alex Schroeder'"
Search for 'Alex Schroeder', page 1: 1 result
* [Alex Schroeder theme](themes/alexschroeder.ch/README)
```
# SEE ALSO

View File

@@ -1,20 +1,16 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "7" "2023-10-28"
.TH "ODDMU-SEARCH" "7" "2025-03-05"
.PP
.SH NAME
.PP
oddmu-search - understanding the Oddmu search engine
.PP
.SH SYNOPSIS
.PP
\fBoddmu search\fR \fIterms\fR.\&.\&.\&
.PP
.SH DESCRIPTION
.PP
The wiki keeps an index of all the hash tags and page titles in memory.\& Using
@@ -61,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
@@ -89,9 +85,22 @@ A document with content "This is a test" when searched with the phrase "this
test" therefore gets a score of 8: the entire phrase does not match but each
word gets four points.\&
.PP
.SH ENVIRONMENT
.PP
To exclude subdirectories from searches, use the ODDMU_FILTER environment
variable.\& Set it to a regular expression matching sub-directories such as
"^projects/".\& If search starts in a directory matching the regular expression,
it is limited to the directory tree, as always.\& However, if search starts in a
directory that doesn'\&t match, subdirectories that do match are skipped.\& See
\fIoddmu-filter\fR(7).\&
.PP
To prevent access to a private directory tree, you must configure the web server
in addition to setting the ODDMU_FILTER environment variable.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
\fIoddmu\fR(1), \fIoddmu-search\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-apache\fR(5),
\fIoddmu-nginx\fR(5)
.PP
.SH AUTHORS
.PP

View File

@@ -4,10 +4,6 @@ ODDMU-SEARCH(7)
oddmu-search - understanding the Oddmu search engine
# SYNOPSIS
*oddmu search* _terms_...
# DESCRIPTION
The wiki keeps an index of all the hash tags and page titles in memory. Using
@@ -48,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
@@ -69,9 +65,22 @@ A document with content "This is a test" when searched with the phrase "this
test" therefore gets a score of 8: the entire phrase does not match but each
word gets four points.
# ENVIRONMENT
To exclude subdirectories from searches, use the ODDMU_FILTER environment
variable. Set it to a regular expression matching sub-directories such as
"^projects/". If search starts in a directory matching the regular expression,
it is limited to the directory tree, as always. However, if search starts in a
directory that doesn't match, subdirectories that do match are skipped. See
_oddmu-filter_(7).
To prevent access to a private directory tree, you must configure the web server
in addition to setting the ODDMU_FILTER environment variable.
# SEE ALSO
_oddmu_(1), _oddmu-search_(1)
_oddmu_(1), _oddmu-search_(1), _oddmu-filter_(7), _oddmu-apache_(5),
_oddmu-nginx_(5)
# AUTHORS

49
man/oddmu-sitemap.1 Normal file
View File

@@ -0,0 +1,49 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SITEMAP" "1" "2026-01-03"
.PP
.SH NAME
.PP
oddmu-sitemap - print static sitemap.\&xml
.PP
.SH SYNOPSIS
.PP
\fBoddmu sitemap\fR [\fB-base\fR \fIURL\fR] [\fB-filter\fR \fIregexp\fR]
.PP
.SH DESCRIPTION
.PP
The "sitemap" subcommand prints the list of all pages in Sitemap format.\& Oddmu
already serves the sitemap at the URL "/sitemap.\&xml" but if you'\&d prefer to
provide a static file, use this command and redirect the output to a file called
"sitemap.\&xml" in your document root at regular intervals.\&
.PP
If you do this, don'\&t proxy the "/sitemap" URL in the web server configuration.\&
.PP
Your "robots.\&txt" file, if you have one, should point at the sitemap you
provide.\&
.PP
.SH OPTIONS
.PP
\fB-base\fR \fIURL\fR
.RS 4
The base URL is something like "https://example.\&org/view/".\&
.RE
\fB-filter\fR \fIregexp\fR
.RS 4
A regular expression matching the pages to exclude from the sitemap.\&
This emulates the effect of the ODDMU_FILTER environment variable.\&
.PP
.RE
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-apache\fR(1), \fIoddmu-nginx\fR(1),
https://www.\&sitemaps.\&org/
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

38
man/oddmu-sitemap.1.txt Normal file
View File

@@ -0,0 +1,38 @@
ODDMU-SITEMAP(1)
# NAME
oddmu-sitemap - print static sitemap.xml
# SYNOPSIS
*oddmu sitemap* [*-base* _URL_] [*-filter* _regexp_]
# DESCRIPTION
The "sitemap" subcommand prints the list of all pages in Sitemap format. Oddmu
already serves the sitemap at the URL "/sitemap.xml" but if you'd prefer to
provide a static file, use this command and redirect the output to a file called
"sitemap.xml" in your document root at regular intervals.
If you do this, don't proxy the "/sitemap" URL in the web server configuration.
Your "robots.txt" file, if you have one, should point at the sitemap you
provide.
# OPTIONS
*-base* _URL_
The base URL is something like "https://example.org/view/".
*-filter* _regexp_
A regular expression matching the pages to exclude from the sitemap.
This emulates the effect of the ODDMU_FILTER environment variable.
# SEE ALSO
_oddmu_(1), _oddmu-filter_(7), _oddmu-apache_(1), _oddmu-nginx_(1),
https://www.sitemaps.org/
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2023-11-05"
.TH "ODDMU-STATIC" "1" "2026-02-06"
.PP
.SH NAME
.PP
@@ -13,35 +13,91 @@ oddmu-static - create a static copy of the site
.PP
.SH SYNOPSIS
.PP
\fBoddmu static\fR \fIdir-name\fR
\fBoddmu static\fR [\fB-jobs\fR \fIn\fR] [\fB-glob\fR \fIpattern\fR] [\fB-shrink\fR] \fIdir-name\fR
.PP
.SH DESCRIPTION
.PP
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given target directory.\& The target directory
must not exist to unser no existing files are clobbered.\&
directory and saves them in the given destination directory.\& Existing files are
only overwritten if they are older than the source file.\&
.PP
All pages (files with the ".\&md" extension) are turned into HTML files (with the
".\&html" extension) using the "static.\&html" template.\& Links pointing to existing
pages get ".\&html" appended.\&
.PP
If a page has a name case-insensitively matching a hashtag, a feed file is
generated (ending with ".\&rss") if any suitable links are found.\& A suitable link
for a feed item must appear in a bullet list item using an asterisk ("*").\& If
no feed items are found, no feed is written.\& The feed is limited to the ten most
recent items.\&
.PP
Hidden files and directories (starting with a ".\&") and backup files (ending with
a "~") are skipped.\&
.PP
All other files are \fIlinked\fR into the same directory.\&
All other files are \fIhard linked\fR.\& This is done to save space: on a typical blog
the images take a lot more space than the text.\& On my blog in 2023 I had 2.\&62
GiB of JPG files and 0.\&02 GiB of Markdown files.\& There is no point in copying
all those images, most of the time.\&
.PP
.SH EXAMPLE
As hard links cannot span filesystems, all other files are \fIcopied\fR if the
destination directory for the static site is not on same filesystem as the
current directory.\&
.PP
Generate a static copy of the site:
Note that in-place editing changes the file for all names.\& Avoid editing the
hard-linked files (anything that'\&s not a HTML file) in the destination
directory, just to be on the safe side.\& Usually you should be fine, as an editor
moves the file that'\&s being edited to a backup file and creates a new file.\& But
then again, who knows.\& A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.\&
.PP
.SH OPTIONS
.PP
\fB-jobs\fR \fIn\fR
.RS 4
By default, two jobs are used to process the files.\& If your machine has
more cores, you can increase the number of jobs.\&
.PP
.RE
\fB-glob\fR \fIpattern\fR
.RS 4
By default, all files are used for the static export.\& You can limit the
files used by providing a shell file name pattern.\& A "*" matches any
number of characters; a "?\&" matches exactly one character; "[a-z]"
matches a character listed, including ranges; "[^a-z]" matches a
character not listed, including ranges; "\e" a backslash escapes the
following character.\& You must use quotes around the pattern if you are
using a shell as the shell would otherwise expand the pattern, resulting
in the error "Exactly one target directory is required".\&
.PP
.PP
.RE
\fB-shrink\fR
.RS 4
By default, images are linked or copied.\& With this option, JPEG, PNG and
WebP files are scaled down if more than 800 pixels wide and the quality
is set to 30% for JPEG and WebP files.\& This is \fIbad quality\fR but the
result is that these image files are very small.\&
.PP
.RE
.SH EXAMPLES
.PP
Generate a static copy of the site, but only loading language detection for
German and English, significantly reducing the time it takes to generate the
static site:
.PP
.nf
.RS 4
oddmu static \&.\&./archive
env ODDMU_LANGUAGES=de,en oddmu static \&.\&./archive
.fi
.RE
.PP
.SH LIMITATIONS
.PP
There can be nameclashes with generated HTML and RSS files and existing files
ending in ".\&html" and ".\&rss".\& Instead of overwriting existing files in these
cases, a warning is printed.\&
.PP
Links from files to pages do not get ".\&html" appended.\& This affects existing
HTML or XML files including SVG files.\&
.PP
@@ -51,11 +107,22 @@ you to migrate static folders and applications.\&
.SH ENVIRONMENT
.PP
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&
Fediverse accounts are not linked to their profile pages.\&
Fediverse accounts are not linked to their profile pages.\& Since the data isn'\&t
cached, every run of this command would trigger a webfinger request for every
fediverse account mentioned.\&
.PP
If the site is large, determining the language of a page slows things down.\& Set
the ODDMU_LANGUAGES environment variable to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt" to limit the languages loaded and thereby
speed language determination up.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-templates\fR(5)
See \fIoddmu\fR(1) and \fIoddmu-templates\fR(5) for general information.\&
.PP
See \fIoddmu-html\fR(1) for a subcommand that converts individual pages file to HTML
and see \fIoddmu-feed\fR(1) for a subcommand that generates feeds for individual
files.\&
.PP
.SH AUTHORS
.PP

View File

@@ -6,33 +6,83 @@ oddmu-static - create a static copy of the site
# SYNOPSIS
*oddmu static* _dir-name_
*oddmu static* [*-jobs* _n_] [*-glob* _pattern_] [*-shrink*] _dir-name_
# DESCRIPTION
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given target directory. The target directory
must not exist to unser no existing files are clobbered.
directory and saves them in the given destination directory. Existing files are
only overwritten if they are older than the source file.
All pages (files with the ".md" extension) are turned into HTML files (with the
".html" extension) using the "static.html" template. Links pointing to existing
pages get ".html" appended.
If a page has a name case-insensitively matching a hashtag, a feed file is
generated (ending with ".rss") if any suitable links are found. A suitable link
for a feed item must appear in a bullet list item using an asterisk ("\*"). If
no feed items are found, no feed is written. The feed is limited to the ten most
recent items.
Hidden files and directories (starting with a ".") and backup files (ending with
a "~") are skipped.
All other files are _linked_ into the same directory.
All other files are _hard linked_. This is done to save space: on a typical blog
the images take a lot more space than the text. On my blog in 2023 I had 2.62
GiB of JPG files and 0.02 GiB of Markdown files. There is no point in copying
all those images, most of the time.
# EXAMPLE
As hard links cannot span filesystems, all other files are _copied_ if the
destination directory for the static site is not on same filesystem as the
current directory.
Generate a static copy of the site:
Note that in-place editing changes the file for all names. Avoid editing the
hard-linked files (anything that's not a HTML file) in the destination
directory, just to be on the safe side. Usually you should be fine, as an editor
moves the file that's being edited to a backup file and creates a new file. But
then again, who knows. A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.
# OPTIONS
*-jobs* _n_
By default, two jobs are used to process the files. If your machine has
more cores, you can increase the number of jobs.
*-glob* _pattern_
By default, all files are used for the static export. You can limit the
files used by providing a shell file name pattern. A "\*" matches any
number of characters; a "?" matches exactly one character; "[a-z]"
matches a character listed, including ranges; "[^a-z]" matches a
character not listed, including ranges; "\\" a backslash escapes the
following character. You must use quotes around the pattern if you are
using a shell as the shell would otherwise expand the pattern, resulting
in the error "Exactly one target directory is required".
*-shrink*
By default, images are linked or copied. With this option, JPEG, PNG and
WebP files are scaled down if more than 800 pixels wide and the quality
is set to 30% for JPEG and WebP files. This is _bad quality_ but the
result is that these image files are very small.
# EXAMPLES
Generate a static copy of the site, but only loading language detection for
German and English, significantly reducing the time it takes to generate the
static site:
```
oddmu static ../archive
env ODDMU_LANGUAGES=de,en oddmu static ../archive
```
# LIMITATIONS
There can be nameclashes with generated HTML and RSS files and existing files
ending in ".html" and ".rss". Instead of overwriting existing files in these
cases, a warning is printed.
Links from files to pages do not get ".html" appended. This affects existing
HTML or XML files including SVG files.
@@ -42,11 +92,22 @@ you to migrate static folders and applications.
# ENVIRONMENT
The ODDMU_WEBFINGER environment variable has no effect in this situation.
Fediverse accounts are not linked to their profile pages.
Fediverse accounts are not linked to their profile pages. Since the data isn't
cached, every run of this command would trigger a webfinger request for every
fediverse account mentioned.
If the site is large, determining the language of a page slows things down. Set
the ODDMU_LANGUAGES environment variable to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt" to limit the languages loaded and thereby
speed language determination up.
# SEE ALSO
_oddmu_(1), _oddmu-templates_(5)
See _oddmu_(1) and _oddmu-templates_(5) for general information.
See _oddmu-html_(1) for a subcommand that converts individual pages file to HTML
and see _oddmu-feed_(1) for a subcommand that generates feeds for individual
files.
# AUTHORS

View File

@@ -1,113 +1,264 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2023-10-29" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2026-01-03" "File Formats Manual"
.PP
.SH NAME
.PP
oddmu-templates - how to write the templates
.PP
.SH SYNTAX
.SH SYNOPSIS
.PP
The templates can refer to the following properties of a page:
Some HTML files act as templates.\& They contain special placeholders in double
bracers {{like this}}.\&
.PP
.SH DESCRIPTION
.PP
Each template receives an object and uses the object'\&s properties to replace the
placeholders.\&
.PP
.PD 0
.IP \(bu 4
\fIadd.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIdiff.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIedit.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIfeed.\&html\fR uses a \fIfeed\fR
.IP \(bu 4
\fIpreview.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIsearch.\&html\fR uses a \fIsearch\fR
.IP \(bu 4
\fIsitemap.\&html\fR uses a \fIsitemap\fR
.IP \(bu 4
\fIstatic.\&html\fR uses a \fIpage\fR
.IP \(bu 4
\fIupload.\&html\fR uses an \fIupload\fR
.IP \(bu 4
\fIview.\&html\fR uses a \fIpage\fR
.PD
.PP
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:
.PP
\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), 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.\&
.PP
\fI{{.\&Body}}\fR is the raw byte content of the page.\& Use \fI{{printf "%s" .\&Body}}\fR to
get the Markdown, as a string.\& This is used for the text area of the \fIedit.\&html\fR
template.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.PP
\fI{{.\&Html}}\fR contains some sort of HTML that depends on the template used.\&
.PP
.PD 0
.IP \(bu 4
For \fIview.\&html\fR, it is the rendered Markdown, as HTML.\&
.IP \(bu 4
For \fIsearch.\&html\fR, it is a page summary, with bold matches, as HTML.\&
.IP \(bu 4
For \fIfeed.\&html\fR, it is the escaped (!\&) HTML of the feed item.\&
.PD
.PP
\fI{{.\&IsBlog}}\fR says whether the current page has a name starting with an ISO
date.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
like links or forms (see \fBEXAMPLE\fR below).\&
.PP
For the \fIview.\&html\fR and \fIstatic.\&html\fR template:
\fI{{.\&Parents}}\fR is the array of links to parent pages (see \fBEXAMPLE\fR below).\& To
refer to them, you need to use a \fI{{range .\&Parents}}\fR\fI{{end}}\fR construct.\& A
link has to properties, \fI{{.\&Title}}\fR and \fI{{.\&Url}}\fR.\&
.PP
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
\fI{{.\&Diff}}\fR is the page diff for \fIdiff.\&html\fR.\& It is only computed on demand so
it can be used in other templates, too.\& It probably doesn'\&t make much sense to
do so, however.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.SS Feed
.PP
For the \fIdiff.\&html\fR template:
The feed contains an item for the head of the feed and an array of items.\&
.PP
\fI{{.\&Diff}}\fR is the diff for this page.\& This is only computed on demand so it can
be used in other templates, too.\& It probably doesn'\&t make much sense to do so,
however.\&
\fI{{.\&Items}}\fR is the array of feed items.\& To refer to them, you need to use a
\fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
.PP
For the \fIedit.\&html\fR template:
If page A links to pages B and C, the head of the feed is based on page A and
the list of items contains B and C.\&
.PP
\fI{{printf "%s" .\&Body}}\fR is the Markdown, as a string (the data itself is a byte
array and that'\&s why we need to call \fIprintf\fR).\&
An item is a page plus a date.\& All the properties of a page can be used (see
\fBPage\fR above).\&
.PP
For the \fIsearch.\&html\fR template only:
\fI{{.\&Date}}\fR is the date of the last update to the page, in RFC 822 format.\&
.PP
In order to paginate feeds, the following attributes are also available in the
feed:
.PP
\fI{{.\&From}}\fR is the item number where the feed starts.\& The first page starts at
0.\& This can be passed to Oddmu via the query parameter \fIfrom\fR.\&
.PP
\fI{{.\&N}}\fR is the number items per page.\& The default is 10.\& This can be passed to
Oddmu via the query parameter \fIn\fR.\& If this is set to 0, the feed is not
paginated.\&
.PP
\fI{{.\&Complete}}\fR is a boolean that is true if the feed is not paginated.\& Such a
feed cannot have a previous or next page.\&
.PP
\fI{{.\&Prev}}\fR is the item number where the previous page of the feed starts.\& On
the first page, it'\&s value is 0 instead of -10.\& You need to test if \fI{{.\&From}}\fR
is non-zero (in which case this is not the first page) before using \fI{{.\&Prev}}\fR.\&
.PP
\fI{{.\&Next}}\fR is the item number where the next feed starts, if there are any
items left.\& If there are none, it'\&s value is 0.\&
.PP
\fI{{.\&PrevYear}}\fR is the year for the previous yearly archive.\& This is added on
the index page or on year pages.\& Year pages are pages whose name is just a
number (presumably a year).\& The property is only set on the first page of the
feed, if the previous year page exists.\& The previous year is one higher than the
year currently shown (if on a year page) or the current year (if looking at the
index), since the feed goes backwards in time as new entries appear at the top.\&
When looking at the page "2024" the previous page is "2025".\& Strangely enough,
if the current year is 2026 but a page "2027" already exists, and the feed for
the index page is generated, then "2027" (in the future) is the previous page.\&
If the current year is 2026, the feed of the index page points to "2025" as the
next year, if it exists.\& When the feed for "2025" is generated, however, the
previous year is not set, assuming that the "2026" page does not yet exist and
it is strange to consider the index page "the previous year" of "2025" in 2026.\&
This might change in the future.\& If it isn'\&t set, it'\&s value is 0.\&
.PP
\fI{{.\&NextYear}}\fR is the year for the next yearly archive.\& See above for an
explanation.\& The next year is one lower than the year currently shown (if on a
year page) or the current year (if looking at the index).\& If it isn'\&t set, it'\&s
value is 0.\&
.PP
.SS Search
.PP
\fI{{.\&Query}}\fR is the query string.\&
.PP
\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
first page number is 1.\& The last page is expensive to dermine and so that isn'\&t
done.\&
first page number is 1.\& The last page is expensive to dermine and so that is not
available.\&
.PP
\fI{{.\&More}}\fR indicates if there are any more search results.\&
.PP
\fI{{.\&Results}}\fR indicates if there were any search results at all.\&
.PP
\fI{{.\&Items}}\fR is an array of pages, each containing a search result.\& A search
result is a page (with the properties seen above).\& Thus, to refer to them, you
need to use a \fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
\fI{{.\&Items}}\fR is an array of results.\& To refer to them, you need to use a
\fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
.PP
For items in the search result:
A result is a page plus a score and possibly images.\& All the properties of a
page can be used (see \fBPage\fR above).\&
.PP
\fI{{.\&Html}}\fR is the rendered Markdown of a page summary, as HTML.\&
\fI{{.\&Score}}\fR is a numerical score.\& It is only computed for \fIsearch.\&html\fR.\&
.PP
\fI{{.\&Score}}\fR is a numerical score for search results.\&
\fI{{.\&Images}}\fR are the images where the alt-text matches at least one of the
query terms (but not predicates and not hashtags since those apply to the page
as a whole).\& To refer to them, you need to use a \fI{{range .\&Images}}\fR\fI{{end}}\fR
construct.\&
.PP
For the \fIfeed.\&html\fR template:
Each image has three properties:
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
\fI{{.\&Title}}\fR is the alt-text of the image.\& It can never be empty because images
are only listed if a search term matches.\&
.PP
\fI{{.\&Title}}\fR is the title of the underlying main page.\&
\fI{{.\&Name}}\fR is the file name for use in URLs.\&
.PP
\fI{{.\&Date}}\fR is the date of the last update to the underlying main page, in RFC
822 format.\&
\fI{{.\&Html}}\fR the image alt-text with a bold tag used to highlight the first
search term that matched.\&
.PP
\fI{{.\&Items}}\fR is an array of feed items.\&
.SS Sitemap
.PP
For items in the feed:
The sitemap contains a list of URLs, each with its location:
.PP
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
\fI{{.\&URL}}\fR is the list of URLs.\&
.PP
\fI{{.\&Title}}\fR is the title of the page.\&
Each URL has the following attributes:
.PP
\fI{{.\&Html}}\fR is the rendered Markdown, as escaped (!\&) HTML.\&
\fI{{.\&Loc}}\fR with the actual page URL.\&
.PP
\fI{{.\&Hashtags}}\fR is an array of strings.\&
.SS Upload
.PP
\fI{{.\&Date}}\fR, the date of the last update to this page, in RFC 822 format.\&
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL
path, percent-encoded.\&
.PP
The \fIupload.\&html\fR template cannot refer to anything.\&
\fI{{.\&FileName}}\fR is the \fIfilename\fR query parameter used to suggested a filename.\&
.PP
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken from the URL
path and the page content is taken from the \fIbody\fR form parameter.\& To
illustrate, here'\&s how to edit the "welcome" page using \fIcurl\fR:
\fI{{.\&FilePath}}\fR is the filename, percent-encoded.\&
.PP
.nf
.RS 4
curl --form body="Did you bring a towel?"
http://localhost:8080/save/welcome
.fi
.RE
\fI{{.\&Name}}\fR is the \fIpagename\fR query parameter used to indicate where to append
links to the files.\&
.PP
When calling the \fIsearch\fR action, the query is taken from the URL parameter \fIq\fR.\&
\fI{{.\&Path}}\fR is the page name, percent-encoded.\&
.PP
.nf
.RS 4
curl http://localhost:8080/search/?q=towel
.fi
.RE
\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.\&
.PP
\fI{{.\&Quality}}\fR is the \fIquality\fR query parameter, i.\&e.\& the value used for the
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
@@ -122,18 +273,18 @@ 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 EXAMPLE
.SH EXAMPLES
.PP
The following link in a template takes people to today'\&s page.\& If no such page
exists, they are redirected to the edit form where it can be created.\&
@@ -157,18 +308,48 @@ The following form allows people to edit the suggested page name.\&
.fi
.RE
.PP
The following puts the current date into the text area if and only if the page
itself is a blog page.\& Useful for \fIadd.\&html\fR:
.PP
.nf
.RS 4
<textarea name="body" rows="20" cols="80" placeholder="Text" lang=""
autofocus required>{{- if \&.IsBlog}}**{{\&.Today}}**\&. {{end}}</textarea>
.fi
.RE
.PP
The following adds a list of links to parent directories.\& Useful for \fIview.\&html\fR:
.PP
.nf
.RS 4
<nav>
{{range \&.Parents}}/ <a href="{{\&.Url}}">{{\&.Title}}</a>{{end}}
</nav>
.fi
.RE
.PP
.SH NOTES
.PP
The template are always used as-is, irrespective of the current directory.\&
The templates are always used as-is, irrespective of the current directory.\&
Therefore, a link to a specific page must be \fIabsolute\fR or it'\&ll point to a
different page depending on the current directory.\&
.PP
Consider the link to "/view/index".\& No matter what page a visitor is looking,
this takes visitors to the top "index" page.\& If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory.\&
instead, it takes a visitor to the "index" page of the current directory.\& In
this case, a visitor looking at "/view/projects/wiki" following a link to
"index" ends up on "/view/projects/index", not on "/view/index".\&
.PP
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
"index", they end up on "/view/projects/index", not on "/view/index".\&
It'\&s up to you to decide what'\&s best for your site, of course.\&
.PP
If you want a link on \fIupload.\&html\fR to point to the current directory'\&s "index"
page, you need to use "/view/{{.\&Dir}}index" because if you link to "index" the
result points to "/upload/{{.\&Dir}}index".\&
.PP
Templates can be changed by uploading new copies of the template files.\&
.PP
Subdirectories can have their own copies of template files.\& One example use for
this is that they can point to a different CSS file.\&
.PP
.SH SEE ALSO
.PP

View File

@@ -4,99 +4,235 @@ ODDMU-TEMPLATES(5) "File Formats Manual"
oddmu-templates - how to write the templates
# SYNTAX
# SYNOPSIS
The templates can refer to the following properties of a page:
Some HTML files act as templates. They contain special placeholders in double
bracers {{like this}}.
# DESCRIPTION
Each template receives an object and uses the object's properties to replace the
placeholders.
- _add.html_ uses a _page_
- _diff.html_ uses a _page_
- _edit.html_ uses a _page_
- _feed.html_ uses a _feed_
- _preview.html_ uses a _page_
- _search.html_ uses a _search_
- _sitemap.html_ uses a _sitemap_
- _static.html_ uses a _page_
- _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:
_{{.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), 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.
_{{.Body}}_ is the raw byte content of the page. Use _{{printf "%s" .Body}}_ to
get the Markdown, as a string. This is used for the text area of the _edit.html_
template.
_{{.Hashtags}}_ is an array of strings.
_{{.Html}}_ contains some sort of HTML that depends on the template used.
- For _view.html_, it is the rendered Markdown, as HTML.
- For _search.html_, it is a page summary, with bold matches, as HTML.
- For _feed.html_, it is the escaped (!) HTML of the feed item.
_{{.IsBlog}}_ says whether the current page has a name starting with an ISO
date.
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
like links or forms (see *EXAMPLE* below).
For the _view.html_ and _static.html_ template:
_{{.Parents}}_ is the array of links to parent pages (see *EXAMPLE* below). To
refer to them, you need to use a _{{range .Parents}}_ … _{{end}}_ construct. A
link has to properties, _{{.Title}}_ and _{{.Url}}_.
_{{.Html}}_ is the rendered Markdown, as HTML.
_{{.Diff}}_ is the page diff for _diff.html_. It is only computed on demand so
it can be used in other templates, too. It probably doesn't make much sense to
do so, however.
_{{.Hashtags}}_ is an array of strings.
## Feed
For the _diff.html_ template:
The feed contains an item for the head of the feed and an array of items.
_{{.Diff}}_ is the diff for this page. This is only computed on demand so it can
be used in other templates, too. It probably doesn't make much sense to do so,
however.
_{{.Items}}_ is the array of feed items. To refer to them, you need to use a
_{{range .Items}}_ … _{{end}}_ construct.
For the _edit.html_ template:
If page A links to pages B and C, the head of the feed is based on page A and
the list of items contains B and C.
_{{printf "%s" .Body}}_ is the Markdown, as a string (the data itself is a byte
array and that's why we need to call _printf_).
An item is a page plus a date. All the properties of a page can be used (see
*Page* above).
For the _search.html_ template only:
_{{.Date}}_ is the date of the last update to the page, in RFC 822 format.
In order to paginate feeds, the following attributes are also available in the
feed:
_{{.From}}_ is the item number where the feed starts. The first page starts at
0. This can be passed to Oddmu via the query parameter _from_.
_{{.N}}_ is the number items per page. The default is 10. This can be passed to
Oddmu via the query parameter _n_. If this is set to 0, the feed is not
paginated.
_{{.Complete}}_ is a boolean that is true if the feed is not paginated. Such a
feed cannot have a previous or next page.
_{{.Prev}}_ is the item number where the previous page of the feed starts. On
the first page, it's value is 0 instead of -10. You need to test if _{{.From}}_
is non-zero (in which case this is not the first page) before using _{{.Prev}}_.
_{{.Next}}_ is the item number where the next feed starts, if there are any
items left. If there are none, it's value is 0.
_{{.PrevYear}}_ is the year for the previous yearly archive. This is added on
the index page or on year pages. Year pages are pages whose name is just a
number (presumably a year). The property is only set on the first page of the
feed, if the previous year page exists. The previous year is one higher than the
year currently shown (if on a year page) or the current year (if looking at the
index), since the feed goes backwards in time as new entries appear at the top.
When looking at the page "2024" the previous page is "2025". Strangely enough,
if the current year is 2026 but a page "2027" already exists, and the feed for
the index page is generated, then "2027" (in the future) is the previous page.
If the current year is 2026, the feed of the index page points to "2025" as the
next year, if it exists. When the feed for "2025" is generated, however, the
previous year is not set, assuming that the "2026" page does not yet exist and
it is strange to consider the index page "the previous year" of "2025" in 2026.
This might change in the future. If it isn't set, it's value is 0.
_{{.NextYear}}_ is the year for the next yearly archive. See above for an
explanation. The next year is one lower than the year currently shown (if on a
year page) or the current year (if looking at the index). If it isn't set, it's
value is 0.
## Search
_{{.Query}}_ is the query string.
_{{.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
first page number is 1. The last page is expensive to dermine and so that isn't
done.
first page number is 1. The last page is expensive to dermine and so that is not
available.
_{{.More}}_ indicates if there are any more search results.
_{{.Results}}_ indicates if there were any search results at all.
_{{.Items}}_ is an array of pages, each containing a search result. A search
result is a page (with the properties seen above). Thus, to refer to them, you
need to use a _{{range .Items}}_ … _{{end}}_ construct.
_{{.Items}}_ is an array of results. To refer to them, you need to use a
_{{range .Items}}_ … _{{end}}_ construct.
For items in the search result:
A result is a page plus a score and possibly images. All the properties of a
page can be used (see *Page* above).
_{{.Html}}_ is the rendered Markdown of a page summary, as HTML.
_{{.Score}}_ is a numerical score. It is only computed for _search.html_.
_{{.Score}}_ is a numerical score for search results.
_{{.Images}}_ are the images where the alt-text matches at least one of the
query terms (but not predicates and not hashtags since those apply to the page
as a whole). To refer to them, you need to use a _{{range .Images}}_ … _{{end}}_
construct.
For the _feed.html_ template:
Each image has three properties:
_{{.Name}}_ is the page name, escaped for use in URLs.
_{{.Title}}_ is the alt-text of the image. It can never be empty because images
are only listed if a search term matches.
_{{.Title}}_ is the title of the underlying main page.
_{{.Name}}_ is the file name for use in URLs.
_{{.Date}}_ is the date of the last update to the underlying main page, in RFC
822 format.
_{{.Html}}_ the image alt-text with a bold tag used to highlight the first
search term that matched.
_{{.Items}}_ is an array of feed items.
## Sitemap
For items in the feed:
The sitemap contains a list of URLs, each with its location:
_{{.Name}}_ is the page name, escaped for use in URLs.
_{{.URL}}_ is the list of URLs.
_{{.Title}}_ is the title of the page.
Each URL has the following attributes:
_{{.Html}}_ is the rendered Markdown, as escaped (!) HTML.
_{{.Loc}}_ with the actual page URL.
_{{.Hashtags}}_ is an array of strings.
## Upload
_{{.Date}}_, the date of the last update to this page, in RFC 822 format.
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL
path, percent-encoded.
The _upload.html_ template cannot refer to anything.
_{{.FileName}}_ is the _filename_ query parameter used to suggested a filename.
When calling the _save_ and _append_ action, the page name is taken from the URL
path and the page content is taken from the _body_ form parameter. To
illustrate, here's how to edit the "welcome" page using _curl_:
_{{.FilePath}}_ is the filename, percent-encoded.
```
curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
_{{.Name}}_ is the _pagename_ query parameter used to indicate where to append
links to the files.
When calling the _search_ action, the query is taken from the URL parameter _q_.
_{{.Path}}_ is the page name, percent-encoded.
```
curl http://localhost:8080/search/?q=towel
```
_{{.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.
_{{.Quality}}_ is the _quality_ query parameter, i.e. the value used for the
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
@@ -111,18 +247,18 @@ 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.
# EXAMPLE
# EXAMPLES
The following link in a template takes people to today's page. If no such page
exists, they are redirected to the edit form where it can be created.
@@ -142,18 +278,44 @@ The following form allows people to edit the suggested page name.
</form>
```
The following puts the current date into the text area if and only if the page
itself is a blog page. Useful for _add.html_:
```
<textarea name="body" rows="20" cols="80" placeholder="Text" lang=""
autofocus required>{{- if .IsBlog}}**{{.Today}}**. {{end}}</textarea>
```
The following adds a list of links to parent directories. Useful for _view.html_:
```
<nav>
{{range .Parents}}/ <a href="{{.Url}}">{{.Title}}</a>{{end}}
</nav>
```
# NOTES
The template are always used as-is, irrespective of the current directory.
The templates are always used as-is, irrespective of the current directory.
Therefore, a link to a specific page must be _absolute_ or it'll point to a
different page depending on the current directory.
Consider the link to "/view/index". No matter what page a visitor is looking,
this takes visitors to the top "index" page. If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory.
instead, it takes a visitor to the "index" page of the current directory. In
this case, a visitor looking at "/view/projects/wiki" following a link to
"index" ends up on "/view/projects/index", not on "/view/index".
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
"index", they end up on "/view/projects/index", not on "/view/index".
It's up to you to decide what's best for your site, of course.
If you want a link on _upload.html_ to point to the current directory's "index"
page, you need to use "/view/{{.Dir}}index" because if you link to "index" the
result points to "/upload/{{.Dir}}index".
Templates can be changed by uploading new copies of the template files.
Subdirectories can have their own copies of template files. One example use for
this is that they can point to a different CSS file.
# SEE ALSO

32
man/oddmu-toc.1 Normal file
View File

@@ -0,0 +1,32 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TOC" "1" "2025-04-05"
.PP
.SH NAME
.PP
oddmu-toc - print the table of contents (toc) for pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu toc\fR \fIpage names.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
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.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

25
man/oddmu-toc.1.txt Normal file
View File

@@ -0,0 +1,25 @@
ODDMU-TOC(1)
# NAME
oddmu-toc - print the table of contents (toc) for pages
# SYNOPSIS
*oddmu toc* _page names..._
# DESCRIPTION
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.
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

38
man/oddmu-version.1 Normal file
View File

@@ -0,0 +1,38 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-VERSION" "1" "2024-02-23"
.PP
.SH NAME
.PP
oddmu-version - print build info on the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu version\fR [-full]
.PP
.SH DESCRIPTION
.PP
The "version" subcommand prints information related to the version control
system state when it was built: what remote was used, what commit was checked
out, whether there were any local changes were made.\&
.PP
.SH OPTIONS
.PP
\fB-full\fR
.RS 4
Print a lot more information, including the versions of dependencies
used.\& It'\&s the equivalent of running "go version -m oddmu".\&
.PP
.RE
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

29
man/oddmu-version.1.txt Normal file
View File

@@ -0,0 +1,29 @@
ODDMU-VERSION(1)
# NAME
oddmu-version - print build info on the command-line
# SYNOPSIS
*oddmu version* [-full]
# DESCRIPTION
The "version" subcommand prints information related to the version control
system state when it was built: what remote was used, what commit was checked
out, whether there were any local changes were made.
# OPTIONS
*-full*
Print a lot more information, including the versions of dependencies
used. It's the equivalent of running "go version -m oddmu".
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

206
man/oddmu-webdav.5 Normal file
View File

@@ -0,0 +1,206 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-WEBDAV" "5" "2025-07-16"
.PP
.SH NAME
.PP
oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
.PP
.SH DESCRIPTION
.PP
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and manage the files using some other tool.\& Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.\&
.PP
.SH CONFIGURATION
.PP
Consider the "campaignwiki.\&org" site in the example below.\& This site offers
users their own wikis.\& Thus:
.PP
"https://campaignwiki.\&org/" is a regular website with static files.\&
.PP
"https://campaignwiki.\&org/view/index" is one of the requests that gets passed to
a Unix domain socket.\& See "Socket Activation" in \fIoddmu\fR(1).\&
.PP
Some of these actions are protected by basic authentication.\& A valid user is
required to make changes to the site.\& Valid users are "admin" and "alex".\&
.PP
"data" is the Oddmu working directory.\& WebDAV is turned on for this directory.\& A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access.\& The other paths also require authentication and map to
Oddmu actions.\& The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect.\& The only drawback is that "https://campaignwiki.\&org/data/" now
requires authentication even if only used for reading.\&
.PP
"https://campaignwiki.\&org/view/knochentanz/index" is a separate site called
"knochentanz".\& The only valid user is "knochentanz".\&
.PP
Notice how the \fIarchive\fR action is not available at the top level, only for
subdirectories.\&
.PP
.nf
.RS 4
MDomain campaignwiki\&.org
<VirtualHost *:80>
ServerName campaignwiki\&.org
Redirect permanent / https://campaignwiki\&.org/
</VirtualHost>
<VirtualHost *:443>
ServerAdmin alex@campaignwiki\&.org
ServerName campaignwiki\&.org
# Static HTML, CSS, JavaScript files and so on are saved here\&.
DocumentRoot /home/alex/campaignwiki\&.org
<Directory /home/alex/campaignwiki\&.org>
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket\&.
ProxyPassMatch
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/\&.+)/(\&.*))$"
"unix:/home/oddmu/campaignwiki\&.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data\&.zip" "/view/archive"
# Making changes to the wiki requires authentication\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require user admin alex
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
.fi
.RE
.PP
In order for this to work, you must enable the mod_dav_fs module.\& This
automatically enables to the mod_dav module, too.\& Restart the server after
installing enabling a module.\&
.PP
.nf
.RS 4
sudo a2enmod mod_dav_fs
sudo apachectl restart
.fi
.RE
.PP
Check the permissions for the data directory.\& If the Oddmu service uses the
"oddmu" user and Apache uses the "www-data" user, you could add the data
directory to the "www-data" group and give it write permissions:
.PP
.nf
.RS 4
sudo chown oddmu:www-data /home/alex/campaignwiki\&.org/data/knochentanz
sudo chmod g+w /home/alex/campaignwiki\&.org/data/knochentanz
.fi
.RE
.PP
.SH EXAMPLES
.PP
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions.\& If you'\&re trying to use \fIgvfs\fR(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you'\&re on
your own.\& Sometimes it works.\& I'\&ve used Nemo 5.\&6.\&4 to connect to the server and
edited files using gedit 44.\&2.\& But I'\&ve used other file managers and other
editors with WebDAV support and they didn'\&t work very well.\&
.PP
On Windows, try third party tools like WinSCP.\&
.PP
This section has examples sessions using command-line tools that work.\&
.PP
.SS cadaver
.PP
Here'\&s how to use \fIcadaver\fR(1).\& The "edit" command uses the editor specified in
the EDITOR environment variable.\& In this example, that'\&s
"emacsclient --alternate-editor= ".\&
.PP
.nf
.RS 4
cadaver https://campaignwiki\&.org/data/knochentanz/
Authentication required for Password Required on server `campaignwiki\&.org\&':
Username: knochentanz
Password:
dav:/data/knochentanz/> edit index\&.md
Locking `index\&.md\&': succeeded\&.
Downloading `/data/knochentanz/index\&.md\&' to /tmp/cadaver-edit-fHTllt\&.md
Progress: [=============================>] 100\&.0% of 2725 bytes succeeded\&.
Running editor: `emacsclient --alternate-editor= /tmp/cadaver-edit-fHTllt\&.md\&'\&.\&.\&.
Waiting for Emacs\&.\&.\&.
Changes were made\&.
Uploading changes to `/data/knochentanz/index\&.md\&'
Progress: [=============================>] 100\&.0% of 2726 bytes succeeded\&.
Unlocking `index\&.md\&': succeeded\&.
.fi
.RE
.PP
.SS curl and hdav
.PP
Here'\&s how to use \fIcurl\fR(1) to get the file from the public "/view" location and
how to use \fIhdav\fR(1) to put the file to the protected "/data" location.\& In this
example, \fIed\fR(1) is used to append the word "test" to the file.\&
.PP
.nf
.RS 4
alex@melanobombus ~> curl --output index\&.md https://campaignwiki\&.org/view/knochentanz/index\&.md
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2726 100 2726 0 0 36662 0 --:--:-- --:--:-- --:--:-- 37861
alex@melanobombus ~> ed index\&.md
2726
a
test
\&.
w
2731
q
alex@melanobombus ~> hdav put index\&.md https://campaignwiki\&.org/data/knochentanz/index\&.md --username knochentanz
hDAV version 1\&.3\&.4, Copyright (C) 2012-2016 Clint Adams
hDAV comes with ABSOLUTELY NO WARRANTY\&.
This is free software, and you are welcome to redistribute it
under certain conditions\&.
Password for knochentanz at URL https://campaignwiki\&.org/data/knochentanz/index\&.md: ********
.fi
.RE
.PP
.SS davfs2
.PP
Here'\&s how to use \fIdavfs2\fR(1) using \fImount\fR(1).\& Now the whole wiki is mounted
and can be edited like local files.\& In this example, \fIecho\fR(1) and redirection
is used to append the word "test" to a file.\&
.PP
.nf
.RS 4
alex@melanobombus ~> mkdir knochentanz
alex@melanobombus ~> sudo mount -t davfs -o username=knochentanz,uid=alex
https://campaignwiki\&.org/data/knochentanz/ knochentanz/
Password: ********
alex@melanobombus ~> echo test >> knochentanz/index\&.md
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-apache\fR(5)
.PP
"Apache Module mod_dav".\&
https://httpd.\&apache.\&org/docs/current/mod/mod_dav.\&html
.PP
"WinSCP"
https://winscp.\&net/
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

187
man/oddmu-webdav.5.txt Normal file
View File

@@ -0,0 +1,187 @@
ODDMU-WEBDAV(5)
# NAME
oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
# DESCRIPTION
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and manage the files using some other tool. Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.
# CONFIGURATION
Consider the "campaignwiki.org" site in the example below. This site offers
users their own wikis. Thus:
"https://campaignwiki.org/" is a regular website with static files.
"https://campaignwiki.org/view/index" is one of the requests that gets passed to
a Unix domain socket. See "Socket Activation" in _oddmu_(1).
Some of these actions are protected by basic authentication. A valid user is
required to make changes to the site. Valid users are "admin" and "alex".
"data" is the Oddmu working directory. WebDAV is turned on for this directory. A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access. The other paths also require authentication and map to
Oddmu actions. The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect. The only drawback is that "https://campaignwiki.org/data/" now
requires authentication even if only used for reading.
"https://campaignwiki.org/view/knochentanz/index" is a separate site called
"knochentanz". The only valid user is "knochentanz".
Notice how the _archive_ action is not available at the top level, only for
subdirectories.
```
MDomain campaignwiki.org
<VirtualHost *:80>
ServerName campaignwiki.org
Redirect permanent / https://campaignwiki.org/
</VirtualHost>
<VirtualHost *:443>
ServerAdmin alex@campaignwiki.org
ServerName campaignwiki.org
# Static HTML, CSS, JavaScript files and so on are saved here.
DocumentRoot /home/alex/campaignwiki.org
<Directory /home/alex/campaignwiki.org>
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket.
ProxyPassMatch \
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/.+)/(.*))$" \
"unix:/home/oddmu/campaignwiki.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data.zip" "/view/archive"
# Making changes to the wiki requires authentication.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require user admin alex
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
```
In order for this to work, you must enable the mod_dav_fs module. This
automatically enables to the mod_dav module, too. Restart the server after
installing enabling a module.
```
sudo a2enmod mod_dav_fs
sudo apachectl restart
```
Check the permissions for the data directory. If the Oddmu service uses the
"oddmu" user and Apache uses the "www-data" user, you could add the data
directory to the "www-data" group and give it write permissions:
```
sudo chown oddmu:www-data /home/alex/campaignwiki.org/data/knochentanz
sudo chmod g+w /home/alex/campaignwiki.org/data/knochentanz
```
# EXAMPLES
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions. If you're trying to use _gvfs_(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you're on
your own. Sometimes it works. I've used Nemo 5.6.4 to connect to the server and
edited files using gedit 44.2. But I've used other file managers and other
editors with WebDAV support and they didn't work very well.
On Windows, try third party tools like WinSCP.
This section has examples sessions using command-line tools that work.
## cadaver
Here's how to use _cadaver_(1). The "edit" command uses the editor specified in
the EDITOR environment variable. In this example, that's
"emacsclient --alternate-editor= ".
```
cadaver https://campaignwiki.org/data/knochentanz/
Authentication required for Password Required on server `campaignwiki.org':
Username: knochentanz
Password:
dav:/data/knochentanz/> edit index.md
Locking `index.md': succeeded.
Downloading `/data/knochentanz/index.md' to /tmp/cadaver-edit-fHTllt.md
Progress: [=============================>] 100.0% of 2725 bytes succeeded.
Running editor: `emacsclient --alternate-editor= /tmp/cadaver-edit-fHTllt.md'...
Waiting for Emacs...
Changes were made.
Uploading changes to `/data/knochentanz/index.md'
Progress: [=============================>] 100.0% of 2726 bytes succeeded.
Unlocking `index.md': succeeded.
```
## curl and hdav
Here's how to use _curl_(1) to get the file from the public "/view" location and
how to use _hdav_(1) to put the file to the protected "/data" location. In this
example, _ed_(1) is used to append the word "test" to the file.
```
alex@melanobombus ~> curl --output index.md https://campaignwiki.org/view/knochentanz/index.md
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2726 100 2726 0 0 36662 0 --:--:-- --:--:-- --:--:-- 37861
alex@melanobombus ~> ed index.md
2726
a
test
.
w
2731
q
alex@melanobombus ~> hdav put index.md https://campaignwiki.org/data/knochentanz/index.md --username knochentanz
hDAV version 1.3.4, Copyright (C) 2012-2016 Clint Adams
hDAV comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it
under certain conditions.
Password for knochentanz at URL https://campaignwiki.org/data/knochentanz/index.md: ********
```
## davfs2
Here's how to use _davfs2_(1) using _mount_(1). Now the whole wiki is mounted
and can be edited like local files. In this example, _echo_(1) and redirection
is used to append the word "test" to a file.
```
alex@melanobombus ~> mkdir knochentanz
alex@melanobombus ~> sudo mount -t davfs -o username=knochentanz,uid=alex \
https://campaignwiki.org/data/knochentanz/ knochentanz/
Password: ********
alex@melanobombus ~> echo test >> knochentanz/index.md
```
# SEE ALSO
_oddmu_(1), _oddmu-apache_(5)
"Apache Module mod_dav".
https://httpd.apache.org/docs/current/mod/mod_dav.html
"WinSCP"
https://winscp.net/
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,32 +1,42 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2023-12-17"
.TH "ODDMU" "1" "2026-02-11"
.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
\fBoddmu\fR
.PP
\fBoddmu\fR \fIsubcommand\fR [\fIarguments\fR.\&.\&.\&]
.PP
.SH DESCRIPTION
.PP
The oddmu program serves the current working directory as a wiki on port 8080.\&
Point your browser to http://localhost:8080/ to get started.\& This is equivalent
to http://localhost:8080/view/index the first page you'\&ll create, most likely.\&
Oddmu can be used as a static site generator, turning Markdown files into HTML
files, or it can be used as a public or a private wiki server.\& If it runs as a
public wiki server, a regular webserver should be used as reverse proxy.\&
.PP
If you request a page that doesn'\&t exist, oddmu tries to find a matching
Markdown file by appending the extension ".\&md" to the page name.\& In the example
above, the page name requested is "index" and the file name oddmu tries to read
is "index.\&md".\& If no such file exists, oddmu offers you to create the page.\&
Run Oddmu without any arguments to serve the current working directory as a wiki
on port 8080.\& Point your browser to http://localhost:8080/ to use it.\& This
redirects you to http://localhost:8080/view/index the first page you'\&ll
create, most likely.\&
.PP
See \fIoddmu\fR(5) for details about the page formatting.\&
.PP
If you request a file that exists, like "index.\&md", Oddmu serves it as-is.\& If
you request a file that doesn'\&t exist, like "index", Oddmu checks if a matching
Markdown file exists by appending the extension ".\&md".\& If such a file is found,
it is turned into HTML and shown.\& If no such file exists, Oddmu offers you to
create the page.\&
.PP
If your files don'\&t provide their own title ("# title"), the file name (without
".\&md") is used for the page title.\&
@@ -37,22 +47,112 @@ feed items are based on links in bullet lists using the asterix
.PP
Subdirectories are created as necessary.\&
.PP
See \fIoddmu\fR(5) for details about the page formatting.\&
The wiki knows the following actions for a given page name and (optional)
directory:
.PP
.PD 0
.IP \(bu 4
\fI/\fR redirects to /view/index
.IP \(bu 4
\fI/view/dir/\fR redirects to /view/dir/index
.IP \(bu 4
\fI/view/dir/name\fR shows a page
.IP \(bu 4
\fI/view/dir/name.\&md\fR shows the source text of a page
.IP \(bu 4
\fI/view/dir/name.\&rss\fR shows the RSS feed for the pages linked
.IP \(bu 4
\fI/diff/dir/name\fR shows the last change to a page
.IP \(bu 4
\fI/edit/dir/name\fR shows a form to edit a page
.IP \(bu 4
\fI/preview/dir/name\fR shows a preview of a page edit and the form to edit it
.IP \(bu 4
\fI/save/dir/name\fR saves an edit
.IP \(bu 4
\fI/add/dir/name\fR shows a form to add to a page
.IP \(bu 4
\fI/append/dir/name\fR appends an addition to a page
.IP \(bu 4
\fI/upload/dir/name\fR shows a form to upload a file
.IP \(bu 4
\fI/drop/dir/name\fR saves an upload
.IP \(bu 4
\fI/search/dir/?\&q=term\fR to search for a term
.IP \(bu 4
\fI/sitemap.\&xml\fR to list the links to all the pages
.IP \(bu 4
\fI/archive/dir/name.\&zip\fR to download a zip file of a directory
.PD
.PP
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken from the URL
path and the page content is taken from the \fIbody\fR form parameter.\& To
illustrate, here'\&s how to edit the "welcome" page using \fIcurl\fR:
.PP
.nf
.RS 4
curl --form body="Did you bring a towel?"
http://localhost:8080/save/welcome
.fi
.RE
.PP
When calling the \fIdrop\fR action, the query parameters used are \fIname\fR for the
target filename and \fIfile\fR for the file to upload.\& If the query parameter
\fImaxwidth\fR is set, an attempt is made to decode and resize the image.\& JPG, PNG,
WEBP and HEIC files can be decoded.\& Only JPG, PNG and WEBP files can be encoded,
however.\& If the target name ends in \fI.\&jpg\fR or \fI.\&png\fR, the \fIquality\fR query
parameter is also taken into account.\& To upload some thumbnails:
.PP
.nf
.RS 4
for f in *\&.jpg; do
curl --form name="$f" --form file=@"$f" --form maxwidth=100
http://localhost:8080/drop/
done
.fi
.RE
.PP
When calling the \fIsearch\fR action, the search terms are taken from the query
parameter \fIq\fR.\&
.PP
.nf
.RS 4
curl \&'http://localhost:8080/search/?q=towel\&'
.fi
.RE
.PP
The page name to act upon is optionally taken from the query parameter \fIid\fR.\& In
this case, the directory must still be part of the path and may not be part of
the \fIid\fR.\& This is enforced so that the path can be used by a webserver for
access control.\&
.PP
.nf
.RS 4
curl \&'http://localhost:8080/view/man/?id=oddmu\&.1\&.txt\&'
.fi
.RE
.PP
The base name for the \fIarchive\fR action is used by the browser to save the
downloaded file.\& For Oddmu, only the directory is important.\& The following zips
the \fIman\fR directory and saves it as \fIman.\&zip\fR.\&
.PP
.nf
.RS 4
curl --remote-name \&'http://localhost:8080/archive/man/man\&.zip
.fi
.RE
.PP
.SH CONFIGURATION
.PP
The template files are the HTML files in the working directory:
"add.\&html", "diff.\&html", "edit.\&html", "search.\&html", "upload.\&html" and
"view.\&html".\& Feel free to change the templates and restart the server.\&
The template files are the HTML files in the working directory.\& If they are
missing, the default files are written to disk as soon as they are required.\&
Please change these templates!\&
.PP
The first change you should make is to replace the name and email
address in the footer of "view.\&html".\& Look for "Your Name" and
"example.\&org".\&
The first change you should make is to replace the name and email address in the
footer of \fIview.\&html\fR.\& Look for "Your Name" and "example.\&org".\&
.PP
The second change you should make is to replace the name, email
address and domain name in "feed.\&html".\& Look for "Your Name" and
"example.\&org".\& This second template is used to generate the RSS feeds
(despite its ".\&html" extension).\&
The second change you should make is to replace the name, email address and
domain name in "feed.\&html".\& Look for "Your Name" and "example.\&org".\&
.PP
See \fIoddmu-templates\fR(5) for more.\&
.PP
@@ -60,6 +160,21 @@ See \fIoddmu-templates\fR(5) for more.\&
.PP
You can change the port served by setting the ODDMU_PORT environment variable.\&
.PP
You can change the address served by setting the ODDMU_ADDRESS environment
variable to either an IPv4 address or an IPv6 address.\& If ODDMU_ADDRESS is
unset, then the program listens on all available unicast addresses, both IPv4
and IPv6.\& Here are a few example addresses:
.PP
.nf
.RS 4
ODDMU_ADDRESS=127\&.0\&.0\&.1 # The loopback IPv4 address\&.
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address\&.
.fi
.RE
.PP
See the Socket Activation section for an alternative method of listening which
supports Unix-domain sockets.\&
.PP
In order to limit language-detection to the languages you actually use, set the
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
@@ -67,21 +182,37 @@ codes, e.\&g.\& "en" or "en,de,fr,pt".\&
You can enable webfinger to link fediverse accounts to their correct profile
pages by setting ODDMU_WEBFINGER to "1".\& See \fIoddmu\fR(5).\&
.PP
If you use secret subdirectories, you cannot rely on the web server to hide
those pages because some actions such as searching and archiving include
subdirectories.\& They act upon a whole tree of pages, not just a single page.\& The
ODDMU_FILTER can be used to exclude subdirectories from such tree actions.\& See
\fIoddmu-filter\fR(7) and \fIoddmu-apache\fR(5).\&
.PP
.SH Socket Activation
.PP
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
through socket activation.\& The advantage of this method is that you can use a
Unix-domain socket instead of a TCP socket, and the permissions and ownership of
the socket are set before the program starts.\& See \fIoddmu.\&service\fR(5),
\fIoddmu-apache\fR(5) and \fIoddmu-nginx\fR(5) for an example of how to use socket
activation with a Unix-domain socket under systemd and Apache.\&
.PP
.SH SECURITY
.PP
If the machine you are running Oddmu on is accessible from the Internet, you
must secure your installation.\& The best way to do this is use a regular web
server as a reverse proxy.\&
.PP
See \fIoddmu-apache\fR(5) for an example.\&
server as a reverse proxy.\& See \fIoddmu-apache\fR(5) and \fIoddmu-nginx\fR(5) for
example configurations.\&
.PP
Oddmu assumes that all the users that can edit pages or upload files are trusted
users and therefore their content is trusted.\& Therefore, Oddmu doesn'\&t perform
HTML sanitization!\&
users and therefore their content is trusted.\& Oddmu does not perform HTML
sanitization!\&
.PP
For an extra dose of security, consider using a Unix-domain socket.\&
.PP
.SH OPTIONS
.PP
The oddmu program can be run on the command-line using various subcommands.\&
Oddmu can be run on the command-line using various subcommands.\&
.PP
.PD 0
.IP \(bu 4
@@ -90,20 +221,32 @@ to generate the HTML for a single page, see \fIoddmu-html\fR(1)
to generate the HTML for the entire site, using Oddmu as a static site
generator, see \fIoddmu-static\fR(1)
.IP \(bu 4
to search a regular expression and replace it across all files, see
\fIoddmu-replace\fR(1)
to export the HTML for the entire site in one big feed, see \fIoddmu-export\fR(1)
.IP \(bu 4
to emulate a search of the files, see \fIoddmu-search\fR(1); to understand how the
search engine indexes pages and how it sorts and scores results, see
\fIoddmu-search\fR(7)
.IP \(bu 4
to search a regular expression and replace it across all files, see
\fIoddmu-replace\fR(1)
.IP \(bu 4
to learn what the most popular hashtags are, see \fIoddmu-hashtags\fR(1)
.IP \(bu 4
to print a table of contents (TOC) for a page, see \fIoddmu-toc\fR(1)
.IP \(bu 4
to list the outgoing links for a page, see \fIoddmu-links\fR(1)
.IP \(bu 4
to find missing pages (local links that go nowhere), see \fIoddmu-missing\fR(1)
.IP \(bu 4
to list all the pages with name and title, see \fIoddmu-list\fR(1)
.IP \(bu 4
to add links to changes, index and hashtag pages to pages you created locally,
see \fIoddmu-notify\fR(1)
.IP \(bu 4
to display build information, see \fIoddmu-version\fR(1)
.PD
.PP
.SH EXAMPLE
.SH EXAMPLES
.PP
When saving a page, the page name is take from the URL and the page content is
taken from the "body" form parameter.\& To illustrate, here'\&s how to edit a page
@@ -116,6 +259,14 @@ curl --form body="Did you bring a towel?"
.fi
.RE
.PP
To compute the space used by your setup, use regular tools:
.PP
.nf
.RS 4
du --exclude=\&'*/.*\&' --exclude \&'*~\&' --block-size=M
.fi
.RE
.PP
.SH DESIGN
.PP
This is a minimal wiki.\& There is no version history.\& It'\&s well suited as a
@@ -132,13 +283,14 @@ on your point of view.\& See \fIoddmu-apache\fR(5).\&
.SH NOTES
.PP
Page names are filenames with ".\&md" appended.\& If your filesystem cannot handle
it, it can'\&t be a page name.\& Filenames can contain slashes and oddmu creates
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".\&
@@ -153,79 +305,139 @@ current date of the machine Oddmu is running on is used.\& If a link already
exists on the changes page, it is moved up to the current date.\& If that leaves
an old date without any links, that date heading is removed.\&
.PP
If you want to link to the changes page, you need to do this yourself.\& Add a
link from the index, for example.\& The "view.\&html" template currently doesn'\&t do
it.\& See \fIoddmu-templates\fR(5) if you want to add the link to the template.\&
.PP
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
called a \fBblog\fR page.\& When creating or editing blog pages, links to it are added
from other pages.\&
from other pages as follows:
.PP
.PD 0
.IP \(bu 4
If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited.\& Again, you can prevent
this from happening by deselecting the checkbox "Add link to the list of
changes.\&" The index page can be edited like every other page, so it'\&s easy to
undo mistakes.\&
index page back to the blog page being created or edited.\& Again, you can
prevent this from happening by deselecting the checkbox "Add link to the list
of changes.\&" The index page can be edited like every other page, so it'\&s easy
to undo mistakes.\&
.PD
.PP
.PD 0
.IP \(bu 4
For every \fBhashtag\fR used, another link might be created.\& If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited blog
page.\&
the hashtag exists, a backlink is added to it, linking to the new or edited
blog page.\&
.PD
.PP
.PD 0
.IP \(bu 4
If a link to the new or edited blog page already exists but it'\&s title is no
longer correct, it is updated.\&
.PD
.PP
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
bottom of the page.\& This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('\&-'\&).\&
.PP
Changes made locally do not create any links on the changes page, the index page
or on any hashtag pages.\& See \fIoddmu-notify\fR(1) for a way to add the necessary
links to the changes page and possibly to the index and hashtag pages.\&
Changes made locally to the source files (using an editor) do not create any
links on the changes page, the index page or on any hashtag pages.\& See
\fIoddmu-notify\fR(1) for a way to add the necessary links to the changes page and
possibly to the index and hashtag pages.\&
.PP
A hashtag consists of a number sign ('\&#'\&) followed by Unicode letters, numbers
or the underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
.PP
The page names, titles and hashtags are loaded into memory when the server
starts.\& If you have a lot of pages, this takes a lot of memory.\& If you change
the files while the wiki runs, changes to names (creating, renaming or deleting
files), titles or hashtags confuse Oddmu.\& Restart the program in order to
resolve this.\&
starts.\& If you have a lot of pages, this takes a lot of memory.\& Oddmu watches
the working directory and any subdirectories for changes made to page files and
updates this cache when necessary.\&
.PP
You cannot edit uploaded files.\& If you upload a file called "hello.\&txt" and
attempt to edit it by using "/edit/hello.\&txt" you create a page with the name
"hello.\&txt.\&md" instead.\&
Uploaded files cannot be edited unless they end with ".\&md".\& If you upload a file
called "hello.\&txt" and attempt to edit it by using "/edit/hello.\&txt" you create
a page with the name "hello.\&txt.\&md" instead.\&
.PP
You cannot delete uploaded files via the web but you can delete regular wiki
pages by saving an empty page.\&
In order to delete uploaded files via the web, create an empty file and upload
it.\& In order to delete a wiki page, save an empty page.\&
.PP
Note that some HTML file names are special: they act as templates.\& See
\fIoddmu-templates\fR(5) for their names and their use.\& Oddmu watches the working
directory and any subdirectories for changes made to template files and reloads
them.\& There is no need to restart the server after making changes to the
templates.\&
.PP
.SH SEE ALSO
.PP
.PD 0
.IP \(bu 4
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link lists
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link
lists
.IP \(bu 4
\fIoddmu.\&service\fR(5), on how to run the service under systemd
\fIoddmu-releases\fR(7), on what features are part of the latest release
.IP \(bu 4
\fIoddmu-apache\fR(5), on how to set up a web server such as Apache
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page from the command-line
.IP \(bu 4
\fIoddmu-list\fR(1), on how to list pages and titles from the command-line
.IP \(bu 4
\fIoddmu-missing\fR(1), on how to find broken local links from the command-line
.IP \(bu 4
\fIoddmu-replace\fR(1), on how to search and replace text from the command-line
.IP \(bu 4
\fIoddmu-search\fR(1), on how to run a search from the command-line
\fIoddmu-filter\fR(7), on how to treat subdirectories as separate sites
.IP \(bu 4
\fIoddmu-search\fR(7), on how search works
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site from the command-line
.IP \(bu 4
\fIoddmu-templates\fR(5), on how to write the HTML templates
.PD
.PP
If you run Oddmu as a web server:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-apache\fR(5), on how to set up Apache as a reverse proxy
.IP \(bu 4
\fIoddmu-nginx\fR(5), on how to set up freenginx as a reverse proxy
.IP \(bu 4
\fIoddmu-webdav\fR(5), on how to set up Apache as a Web-DAV server
.IP \(bu 4
\fIoddmu.\&service\fR(5), on how to run the service under systemd
.PD
.PP
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-hashtags\fR(1), on working with hashtags
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page
.IP \(bu 4
\fIoddmu-feed\fR(1), on how to render a feed
.IP \(bu 4
\fIoddmu-list\fR(1), on how to list pages and titles
.IP \(bu 4
\fIoddmu-links\fR(1), on how to list the outgoing links for a page
.IP \(bu 4
\fIoddmu-missing\fR(1), on how to find broken local links
.IP \(bu 4
\fIoddmu-notify\fR(1), on updating index, changes and hashtag pages
.IP \(bu 4
\fIoddmu-replace\fR(1), on how to search and replace text
.IP \(bu 4
\fIoddmu-search\fR(1), on how to run a search
.IP \(bu 4
\fIoddmu-sitemap\fR(1), on generating a static sitemap.\&xml
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site
.IP \(bu 4
\fIoddmu-toc\fR(1), on how to list the table of contents (toc) a page
.IP \(bu 4
\fIoddmu-version\fR(1), on how to get all the build information from the binary
.PD
.PP
If you want to stop using Oddmu:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-export\fR(1), on how to export all the files as one big RSS file
.PD
.PP
And finally, if you don'\&t have the man pages, you can still read the original
documents:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-man\fR(1), to get help even if you don'\&t have the man pages installed
.PD
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -4,22 +4,32 @@ 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
*oddmu*
*oddmu* _subcommand_ [_arguments_...]
# DESCRIPTION
The oddmu program serves the current working directory as a wiki on port 8080.
Point your browser to http://localhost:8080/ to get started. This is equivalent
to http://localhost:8080/view/index the first page you'll create, most likely.
Oddmu can be used as a static site generator, turning Markdown files into HTML
files, or it can be used as a public or a private wiki server. If it runs as a
public wiki server, a regular webserver should be used as reverse proxy.
If you request a page that doesn't exist, oddmu tries to find a matching
Markdown file by appending the extension ".md" to the page name. In the example
above, the page name requested is "index" and the file name oddmu tries to read
is "index.md". If no such file exists, oddmu offers you to create the page.
Run Oddmu without any arguments to serve the current working directory as a wiki
on port 8080. Point your browser to http://localhost:8080/ to use it. This
redirects you to http://localhost:8080/view/index the first page you'll
create, most likely.
See _oddmu_(5) for details about the page formatting.
If you request a file that exists, like "index.md", Oddmu serves it as-is. If
you request a file that doesn't exist, like "index", Oddmu checks if a matching
Markdown file exists by appending the extension ".md". If such a file is found,
it is turned into HTML and shown. If no such file exists, Oddmu offers you to
create the page.
If your files don't provide their own title ("# title"), the file name (without
".md") is used for the page title.
@@ -30,22 +40,84 @@ feed items are based on links in bullet lists using the asterix
Subdirectories are created as necessary.
See _oddmu_(5) for details about the page formatting.
The wiki knows the following actions for a given page name and (optional)
directory:
- _/_ redirects to /view/index
- _/view/dir/_ redirects to /view/dir/index
- _/view/dir/name_ shows a page
- _/view/dir/name.md_ shows the source text of a page
- _/view/dir/name.rss_ shows the RSS feed for the pages linked
- _/diff/dir/name_ shows the last change to a page
- _/edit/dir/name_ shows a form to edit a page
- _/preview/dir/name_ shows a preview of a page edit and the form to edit it
- _/save/dir/name_ saves an edit
- _/add/dir/name_ shows a form to add to a page
- _/append/dir/name_ appends an addition to a page
- _/upload/dir/name_ shows a form to upload a file
- _/drop/dir/name_ saves an upload
- _/search/dir/?q=term_ to search for a term
- _/sitemap.xml_ to list the links to all the pages
- _/archive/dir/name.zip_ to download a zip file of a directory
When calling the _save_ and _append_ action, the page name is taken from the URL
path and the page content is taken from the _body_ form parameter. To
illustrate, here's how to edit the "welcome" page using _curl_:
```
curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
When calling the _drop_ action, the query parameters used are _name_ for the
target filename and _file_ for the file to upload. If the query parameter
_maxwidth_ is set, an attempt is made to decode and resize the image. JPG, PNG,
WEBP and HEIC files can be decoded. Only JPG, PNG and WEBP files can be encoded,
however. If the target name ends in _.jpg_ or _.png_, the _quality_ query
parameter is also taken into account. To upload some thumbnails:
```
for f in *.jpg; do
curl --form name="$f" --form file=@"$f" --form maxwidth=100 \
http://localhost:8080/drop/
done
```
When calling the _search_ action, the search terms are taken from the query
parameter _q_.
```
curl 'http://localhost:8080/search/?q=towel'
```
The page name to act upon is optionally taken from the query parameter _id_. In
this case, the directory must still be part of the path and may not be part of
the _id_. This is enforced so that the path can be used by a webserver for
access control.
```
curl 'http://localhost:8080/view/man/?id=oddmu.1.txt'
```
The base name for the _archive_ action is used by the browser to save the
downloaded file. For Oddmu, only the directory is important. The following zips
the _man_ directory and saves it as _man.zip_.
```
curl --remote-name 'http://localhost:8080/archive/man/man.zip
```
# CONFIGURATION
The template files are the HTML files in the working directory:
"add.html", "diff.html", "edit.html", "search.html", "upload.html" and
"view.html". Feel free to change the templates and restart the server.
The template files are the HTML files in the working directory. If they are
missing, the default files are written to disk as soon as they are required.
Please change these templates!
The first change you should make is to replace the name and email
address in the footer of "view.html". Look for "Your Name" and
"example.org".
The first change you should make is to replace the name and email address in the
footer of _view.html_. Look for "Your Name" and "example.org".
The second change you should make is to replace the name, email
address and domain name in "feed.html". Look for "Your Name" and
"example.org". This second template is used to generate the RSS feeds
(despite its ".html" extension).
The second change you should make is to replace the name, email address and
domain name in "feed.html". Look for "Your Name" and "example.org".
See _oddmu-templates_(5) for more.
@@ -53,6 +125,19 @@ See _oddmu-templates_(5) for more.
You can change the port served by setting the ODDMU_PORT environment variable.
You can change the address served by setting the ODDMU_ADDRESS environment
variable to either an IPv4 address or an IPv6 address. If ODDMU_ADDRESS is
unset, then the program listens on all available unicast addresses, both IPv4
and IPv6. Here are a few example addresses:
```
ODDMU_ADDRESS=127.0.0.1 # The loopback IPv4 address.
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.
```
See the Socket Activation section for an alternative method of listening which
supports Unix-domain sockets.
In order to limit language-detection to the languages you actually use, set the
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt".
@@ -60,35 +145,57 @@ codes, e.g. "en" or "en,de,fr,pt".
You can enable webfinger to link fediverse accounts to their correct profile
pages by setting ODDMU_WEBFINGER to "1". See _oddmu_(5).
If you use secret subdirectories, you cannot rely on the web server to hide
those pages because some actions such as searching and archiving include
subdirectories. They act upon a whole tree of pages, not just a single page. The
ODDMU_FILTER can be used to exclude subdirectories from such tree actions. See
_oddmu-filter_(7) and _oddmu-apache_(5).
# Socket Activation
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
through socket activation. The advantage of this method is that you can use a
Unix-domain socket instead of a TCP socket, and the permissions and ownership of
the socket are set before the program starts. See _oddmu.service_(5),
_oddmu-apache_(5) and _oddmu-nginx_(5) for an example of how to use socket
activation with a Unix-domain socket under systemd and Apache.
# SECURITY
If the machine you are running Oddmu on is accessible from the Internet, you
must secure your installation. The best way to do this is use a regular web
server as a reverse proxy.
See _oddmu-apache_(5) for an example.
server as a reverse proxy. See _oddmu-apache_(5) and _oddmu-nginx_(5) for
example configurations.
Oddmu assumes that all the users that can edit pages or upload files are trusted
users and therefore their content is trusted. Therefore, Oddmu doesn't perform
HTML sanitization!
users and therefore their content is trusted. Oddmu does not perform HTML
sanitization!
For an extra dose of security, consider using a Unix-domain socket.
# OPTIONS
The oddmu program can be run on the command-line using various subcommands.
Oddmu can be run on the command-line using various subcommands.
- to generate the HTML for a single page, see _oddmu-html_(1)
- to generate the HTML for the entire site, using Oddmu as a static site
generator, see _oddmu-static_(1)
- to search a regular expression and replace it across all files, see
_oddmu-replace_(1)
- to export the HTML for the entire site in one big feed, see _oddmu-export_(1)
- to emulate a search of the files, see _oddmu-search_(1); to understand how the
search engine indexes pages and how it sorts and scores results, see
_oddmu-search_(7)
- to search a regular expression and replace it across all files, see
_oddmu-replace_(1)
- to learn what the most popular hashtags are, see _oddmu-hashtags_(1)
- to print a table of contents (TOC) for a page, see _oddmu-toc_(1)
- to list the outgoing links for a page, see _oddmu-links_(1)
- to find missing pages (local links that go nowhere), see _oddmu-missing_(1)
- to list all the pages with name and title, see _oddmu-list_(1)
- to add links to changes, index and hashtag pages to pages you created locally,
see _oddmu-notify_(1)
- to display build information, see _oddmu-version_(1)
# EXAMPLE
# EXAMPLES
When saving a page, the page name is take from the URL and the page content is
taken from the "body" form parameter. To illustrate, here's how to edit a page
@@ -99,6 +206,12 @@ curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
To compute the space used by your setup, use regular tools:
```
du --exclude='*/\.*' --exclude '*~' --block-size=M
```
# DESIGN
This is a minimal wiki. There is no version history. It's well suited as a
@@ -115,13 +228,14 @@ on your point of view. See _oddmu-apache_(5).
# NOTES
Page names are filenames with ".md" appended. If your filesystem cannot handle
it, it can't be a page name. Filenames can contain slashes and oddmu creates
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".
@@ -136,66 +250,96 @@ current date of the machine Oddmu is running on is used. If a link already
exists on the changes page, it is moved up to the current date. If that leaves
an old date without any links, that date heading is removed.
If you want to link to the changes page, you need to do this yourself. Add a
link from the index, for example. The "view.html" template currently doesn't do
it. See _oddmu-templates_(5) if you want to add the link to the template.
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
called a *blog* page. When creating or editing blog pages, links to it are added
from other pages.
from other pages as follows:
If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited. Again, you can prevent
this from happening by deselecting the checkbox "Add link to the list of
changes." The index page can be edited like every other page, so it's easy to
undo mistakes.
- If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited. Again, you can
prevent this from happening by deselecting the checkbox "Add link to the list
of changes." The index page can be edited like every other page, so it's easy
to undo mistakes.
For every *hashtag* used, another link might be created. If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited blog
page.
- For every *hashtag* used, another link might be created. If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited
blog page.
If a link to the new or edited blog page already exists but it's title is no
longer correct, it is updated.
- If a link to the new or edited blog page already exists but it's title is no
longer correct, it is updated.
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\*'). If no such list exists, a new one is started at the
bottom of the page. This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('-').
Changes made locally do not create any links on the changes page, the index page
or on any hashtag pages. See _oddmu-notify_(1) for a way to add the necessary
links to the changes page and possibly to the index and hashtag pages.
Changes made locally to the source files (using an editor) do not create any
links on the changes page, the index page or on any hashtag pages. See
_oddmu-notify_(1) for a way to add the necessary links to the changes page and
possibly to the index and hashtag pages.
A hashtag consists of a number sign ('#') followed by Unicode letters, numbers
or the underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
The page names, titles and hashtags are loaded into memory when the server
starts. If you have a lot of pages, this takes a lot of memory. If you change
the files while the wiki runs, changes to names (creating, renaming or deleting
files), titles or hashtags confuse Oddmu. Restart the program in order to
resolve this.
starts. If you have a lot of pages, this takes a lot of memory. Oddmu watches
the working directory and any subdirectories for changes made to page files and
updates this cache when necessary.
You cannot edit uploaded files. If you upload a file called "hello.txt" and
attempt to edit it by using "/edit/hello.txt" you create a page with the name
"hello.txt.md" instead.
Uploaded files cannot be edited unless they end with ".md". If you upload a file
called "hello.txt" and attempt to edit it by using "/edit/hello.txt" you create
a page with the name "hello.txt.md" instead.
You cannot delete uploaded files via the web but you can delete regular wiki
pages by saving an empty page.
In order to delete uploaded files via the web, create an empty file and upload
it. In order to delete a wiki page, save an empty page.
Note that some HTML file names are special: they act as templates. See
_oddmu-templates_(5) for their names and their use. Oddmu watches the working
directory and any subdirectories for changes made to template files and reloads
them. There is no need to restart the server after making changes to the
templates.
# SEE ALSO
- _oddmu_(5), about the markup syntax and how feeds are generated based on link lists
- _oddmu.service_(5), on how to run the service under systemd
- _oddmu-apache_(5), on how to set up a web server such as Apache
- _oddmu-html_(1), on how to render a page from the command-line
- _oddmu-list_(1), on how to list pages and titles from the command-line
- _oddmu-missing_(1), on how to find broken local links from the command-line
- _oddmu-replace_(1), on how to search and replace text from the command-line
- _oddmu-search_(1), on how to run a search from the command-line
- _oddmu_(5), about the markup syntax and how feeds are generated based on link
lists
- _oddmu-releases_(7), on what features are part of the latest release
- _oddmu-filter_(7), on how to treat subdirectories as separate sites
- _oddmu-search_(7), on how search works
- _oddmu-static_(1), on generating a static site from the command-line
- _oddmu-templates_(5), on how to write the HTML templates
If you run Oddmu as a web server:
- _oddmu-apache_(5), on how to set up Apache as a reverse proxy
- _oddmu-nginx_(5), on how to set up freenginx as a reverse proxy
- _oddmu-webdav_(5), on how to set up Apache as a Web-DAV server
- _oddmu.service_(5), on how to run the service under systemd
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
- _oddmu-hashtags_(1), on working with hashtags
- _oddmu-html_(1), on how to render a page
- _oddmu-feed_(1), on how to render a feed
- _oddmu-list_(1), on how to list pages and titles
- _oddmu-links_(1), on how to list the outgoing links for a page
- _oddmu-missing_(1), on how to find broken local links
- _oddmu-notify_(1), on updating index, changes and hashtag pages
- _oddmu-replace_(1), on how to search and replace text
- _oddmu-search_(1), on how to run a search
- _oddmu-sitemap_(1), on generating a static sitemap.xml
- _oddmu-static_(1), on generating a static site
- _oddmu-toc_(1), on how to list the table of contents (toc) a page
- _oddmu-version_(1), on how to get all the build information from the binary
If you want to stop using Oddmu:
- _oddmu-export_(1), on how to export all the files as one big RSS file
And finally, if you don't have the man pages, you can still read the original
documents:
- _oddmu-man_(1), to get help even if you don't have the man pages installed
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "5" "2023-11-12" "File Formats Manual"
.TH "ODDMU" "5" "2025-03-05" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -28,16 +28,15 @@ 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
.SS Local links
.PP
Local links use double square brackets [[like this]].\& Oddmu does not treat
underscores like spaces, so [[like this]] and [[like_this]] link to different
destinations and are served by different files: "like this.\&md" and
"like_this.\&md".\&
Local links use double square brackets.\& Oddmu does not treat underscores like
spaces, so "[[like this]]" and "[[like_this]]" link to different destinations
and are served by different files: "like this.\&md" and "like_this.\&md".\&
.PP
.SS Hashtags
.PP
@@ -90,12 +89,12 @@ Internet
.SS Fediverse account links
.PP
Fediverse accounts look a bit like an at sign followed by an email address, e.\&g.\&
@alex@alexschroeder.\&ch.\& When rendering a page, these turn into a username linked
to a profile page.\& In this case, "@alex" would be linked to
"@alex@alexschroeder.\&ch".\& When rendering a page, these turn into a username
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".\&
@@ -118,7 +117,7 @@ autolinking of "naked" URLs are supported
.IP \(bu 4
strikethrough using two tildes is supported (~~like this~~)
.IP \(bu 4
it is strict about prefix heading rules
a space is required between the last # and the text for headings
.IP \(bu 4
you can specify an id for headings ({#id})
.IP \(bu 4
@@ -127,12 +126,12 @@ trailing backslashes turn into line breaks
.PP
.SH FEEDS
.PP
Every file can be viewed as feed by using the extension ".\&rss".\& The feed items
Every file can be viewed as a feed by using the extension ".\&rss".\& The feed items
are based on links in bullet lists using the asterix ("*").\& The items must
point to local pages.\& This is why the link may not contain two forward slashes
("//").\&
.PP
Assume this is the index page.\& The feed would be "/view/index.\&rss".\& It would
Below is an example index page.\& The feed would be "/view/index.\&rss".\& It would
contain the pages "Arianism", "Donatism" and "Monophysitism" but it would not
contain the pages "Feed" and "About" since the list items don'\&t start with an
asterix.\&
@@ -154,7 +153,8 @@ Recent posts:
.fi
.RE
.PP
The feed contains at most 10 items, starting at the top.\&
The feed contains at most 10 items, starting at the top.\& Thus, new items must be
added at the beginning of the list.\&
.PP
.SH PERCENT ENCODING
.PP

View File

@@ -19,16 +19,15 @@ 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.
## Local links
Local links use double square brackets [[like this]]. Oddmu does not treat
underscores like spaces, so [[like this]] and [[like_this]] link to different
destinations and are served by different files: "like this.md" and
"like_this.md".
Local links use double square brackets. Oddmu does not treat underscores like
spaces, so "[[like this]]" and "[[like_this]]" link to different destinations
and are served by different files: "like this.md" and "like_this.md".
## Hashtags
@@ -75,12 +74,12 @@ Internet
## Fediverse account links
Fediverse accounts look a bit like an at sign followed by an email address, e.g.
@alex@alexschroeder.ch. When rendering a page, these turn into a username linked
to a profile page. In this case, "@alex" would be linked to
"\@alex@alexschroeder.ch". When rendering a page, these turn into a username
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".
@@ -97,18 +96,18 @@ The Markdown processor comes with a few extensions:
- fenced code blocks are supported
- autolinking of "naked" URLs are supported
- strikethrough using two tildes is supported (~~like this~~)
- it is strict about prefix heading rules
- a space is required between the last # and the text for headings
- you can specify an id for headings ({#id})
- trailing backslashes turn into line breaks
# FEEDS
Every file can be viewed as feed by using the extension ".rss". The feed items
Every file can be viewed as a feed by using the extension ".rss". The feed items
are based on links in bullet lists using the asterix ("\*"). The items must
point to local pages. This is why the link may not contain two forward slashes
("//").
Assume this is the index page. The feed would be "/view/index.rss". It would
Below is an example index page. The feed would be "/view/index.rss". It would
contain the pages "Arianism", "Donatism" and "Monophysitism" but it would not
contain the pages "Feed" and "About" since the list items don't start with an
asterix.
@@ -128,7 +127,8 @@ Recent posts:
* [Monophysitism](monophysitism)
```
The feed contains at most 10 items, starting at the top.
The feed contains at most 10 items, starting at the top. Thus, new items must be
added at the beginning of the list.
# PERCENT ENCODING

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.2
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2023-10-28"
.TH "ODDMU.SERVICE" "5" "2025-03-14"
.PP
.SH NAME
.PP
@@ -32,13 +32,14 @@ all the templates files ending in ".\&html" from the source distribution to
If you want to keep everything in one place, copy the binary "oddmu" and the
service file "oddmu.\&service" to "/home/oddmu", too.\&
.PP
Edit the `oddmu.\&service` file.\& These are the lines you most likely have to take
Edit the "oddmu.\&service" file.\& These are the lines you most likely have to take
care of:
.PP
.nf
.RS 4
ExecStart=/home/oddmu/oddmu
WorkingDirectory=/home/oddmu
ReadWritePaths=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_WEBFINGER=1"
.fi
@@ -48,8 +49,7 @@ Install the service file and enable it:
.PP
.nf
.RS 4
ln -s /home/oddmu/oddmu\&.service /etc/systemd/system/
systemctl enable --now oddmu
sudo systemctl enable --now \&./oddmu\&.service
.fi
.RE
.PP
@@ -71,18 +71,147 @@ journalctl --follow --unit oddmu
.fi
.RE
.PP
For it to restart when the server reboots:
.SH Socket Activation
.PP
Alternatively, you can let systemd handle the creation of the listening socket,
passing it to Oddmu.\& See "oddmu-unix-domain.\&service" and
"oddmu-unix-domain.\&socket" for a fully worked example of how to do this with a
Unix domain socket.\& Take note of "Accept=no" in the .\&socket file and
"StandardInput=socket" in the .\&service file.\& The option "StandardInput=socket"
tells systemd to pass the socket to the service as its standard input.\&
"Accept=no" tells systemd to pass a listening socket, rather than to try calling
Oddmu for each connection.\&
.PP
Instead of using "oddmu.\&service", you need to use "oddmu-unix-domain.\&socket" and
"oddmu-unix-domain.\&service".\&
.PP
The unit file for the socket defines a file name.\& You probably need to create
the directory or change the file name.\&
.PP
.nf
.RS 4
sudo ln -sf /home/oddmu/oddmu\&.service
/etc/systemd/system/multi-user\&.target\&.wants/
sudo mkdir /run/oddmu
.fi
.RE
.PP
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
ExecStart=/home/oddmu/oddmu
WorkingDirectory=/home/oddmu
ReadWritePaths=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_WEBFINGER=1"
.fi
.RE
.PP
To install, enable and start both units:
.PP
.nf
.RS 4
sudo systemctl enable --now \&./oddmu-unix-domain\&.socket
sudo systemctl enable --now \&./oddmu-unix-domain\&.service
.fi
.RE
.PP
To test just the unix domain socket, use \fIncat(1)\fR:
.PP
.nf
.RS 4
echo -e "GET /view/index HTTP/1\&.1rnHost: localhostrnrn"
| ncat --unixsock /run/oddmu/oddmu\&.sock
.fi
.RE
.PP
Now you need to set up your web browser to use the Unix domain socket.\& See
\fIoddmu-apache\fR(5) or \fIoddmu-nginx\fR(5) for example configurations.\&
.PP
.SS A personal wiki
.PP
On a single user machine, it might be useful to have a single wiki for the main
user available.\& In order to do this, setup a "user" unit using systemd and save
the following as "user-unix-domain.\&service":
.PP
.nf
.RS 4
[Unit]
Description=Oddmu
After=network\&.target
[Install]
WantedBy=default\&.target
[Service]
Type=simple
Restart=always
StandardOutput=journal
StandardError=journal
ExecStart=/home/alex/src/oddmu/oddmu
WorkingDirectory=/home/alex/wiki
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
Install it:
.PP
.nf
.RS 4
systemctl --user enable --now \&./user-unix-domain\&.service
.fi
.RE
.PP
To examine the log:
.PP
.nf
.RS 4
journalctl --user --unit user-unix-domain\&.service
.fi
.RE
.PP
Note that no sudo is required!\&
.PP
.SS Using the priviledged port 80
.PP
When running a personal wiki, you can have the oddmu binary listen on port 80,
the standard HTTP port.\& It is not really worth the effort: It means that you can
visit "http://localhost/" instead of "http://localhost:8080".\& Nevertheless, if
you'\&re interested in giving it a try, here'\&s how to do it.\&
.PP
The service definition must specify the new port:
.PP
Environment="ODDMU_PORT=80"
.PP
Since this is a privileged port, the binary needs an extra capability for an
ordinary user to do this.\&
.PP
.nf
.RS 4
sudo setcap \&'cap_net_bind_service=+ep\&' oddmu
.fi
.RE
.PP
Note that as soon as you recompile, the capability is gone again and the above
must be repeated.\&
.PP
.SH SECURITY
.PP
Only allow direct access to Oddmu on systems and networks where you'\&re OK with
every user editing the pages.\& On the open web, this is not true.\& If your server
is on the open web, always run Oddmu behind a regular web server acting as a
reverse proxy, limiting regular visitors to read-only access.\& This means that
the regular web server listens on the regular privileged ports (80 for HTTP,
443 for HTTPS) and passes requests to Oddmu on some other port.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIsystemd.\&exec\fR(5), \fIcapabilities\fR(7)
\fIoddmu\fR(1), \fIoddmu-apache\fR(5), \fIoddmu-nginx\fR(5), \fIsystemd.\&exec\fR(5),
\fIsystemd.\&socket\fR(5), \fIcapabilities\fR(7)
.PP
.SH AUTHORS
.PP

View File

@@ -23,12 +23,13 @@ all the templates files ending in ".html" from the source distribution to
If you want to keep everything in one place, copy the binary "oddmu" and the
service file "oddmu.service" to "/home/oddmu", too.
Edit the `oddmu.service` file. These are the lines you most likely have to take
Edit the "oddmu.service" file. These are the lines you most likely have to take
care of:
```
ExecStart=/home/oddmu/oddmu
WorkingDirectory=/home/oddmu
ReadWritePaths=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_WEBFINGER=1"
```
@@ -36,8 +37,7 @@ Environment="ODDMU_WEBFINGER=1"
Install the service file and enable it:
```
ln -s /home/oddmu/oddmu.service /etc/systemd/system/
systemctl enable --now oddmu
sudo systemctl enable --now ./oddmu.service
```
You should be able to visit the wiki at http://localhost:8080/.
@@ -54,16 +54,131 @@ Follow the log:
journalctl --follow --unit oddmu
```
For it to restart when the server reboots:
# Socket Activation
Alternatively, you can let systemd handle the creation of the listening socket,
passing it to Oddmu. See "oddmu-unix-domain.service" and
"oddmu-unix-domain.socket" for a fully worked example of how to do this with a
Unix domain socket. Take note of "Accept=no" in the .socket file and
"StandardInput=socket" in the .service file. The option "StandardInput=socket"
tells systemd to pass the socket to the service as its standard input.
"Accept=no" tells systemd to pass a listening socket, rather than to try calling
Oddmu for each connection.
Instead of using "oddmu.service", you need to use "oddmu-unix-domain.socket" and
"oddmu-unix-domain.service".
The unit file for the socket defines a file name. You probably need to create
the directory or change the file name.
```
sudo ln -sf /home/oddmu/oddmu.service \
/etc/systemd/system/multi-user.target.wants/
sudo mkdir /run/oddmu
```
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
WorkingDirectory=/home/oddmu
ReadWritePaths=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_WEBFINGER=1"
```
To install, enable and start both units:
```
sudo systemctl enable --now ./oddmu-unix-domain.socket
sudo systemctl enable --now ./oddmu-unix-domain.service
```
To test just the unix domain socket, use _ncat(1)_:
```
echo -e "GET /view/index HTTP/1.1\r\nHost: localhost\r\n\r\n" \
| ncat --unixsock /run/oddmu/oddmu.sock
```
Now you need to set up your web browser to use the Unix domain socket. See
_oddmu-apache_(5) or _oddmu-nginx_(5) for example configurations.
## A personal wiki
On a single user machine, it might be useful to have a single wiki for the main
user available. In order to do this, setup a "user" unit using systemd and save
the following as "user-unix-domain.service":
```
[Unit]
Description=Oddmu
After=network.target
[Install]
WantedBy=default.target
[Service]
Type=simple
Restart=always
StandardOutput=journal
StandardError=journal
ExecStart=/home/alex/src/oddmu/oddmu
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.
Install it:
```
systemctl --user enable --now ./user-unix-domain.service
```
To examine the log:
```
journalctl --user --unit user-unix-domain.service
```
Note that no sudo is required!
## Using the priviledged port 80
When running a personal wiki, you can have the oddmu binary listen on port 80,
the standard HTTP port. It is not really worth the effort: It means that you can
visit "http://localhost/" instead of "http://localhost:8080". Nevertheless, if
you're interested in giving it a try, here's how to do it.
The service definition must specify the new port:
Environment="ODDMU_PORT=80"
Since this is a privileged port, the binary needs an extra capability for an
ordinary user to do this.
```
sudo setcap 'cap_net_bind_service=+ep' oddmu
```
Note that as soon as you recompile, the capability is gone again and the above
must be repeated.
# SECURITY
Only allow direct access to Oddmu on systems and networks where you're OK with
every user editing the pages. On the open web, this is not true. If your server
is on the open web, always run Oddmu behind a regular web server acting as a
reverse proxy, limiting regular visitors to read-only access. This means that
the regular web server listens on the regular privileged ports (80 for HTTP,
443 for HTTPS) and passes requests to Oddmu on some other port.
# SEE ALSO
_oddmu_(1), _systemd.exec_(5), _capabilities_(7)
_oddmu_(1), _oddmu-apache_(5), _oddmu-nginx_(5), _systemd.exec_(5),
_systemd.socket_(5), _capabilities_(7)
# AUTHORS

31
man/scdoc-to-markdown Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/perl
use strict;
use warnings;
my $literal = 0;
while (<>) {
# switch literal style
$literal = !$literal if /^```$/;
if ($literal) {
print;
next;
}
# bold
s/\*([^*]+)\*/**$1**/g;
# link to oddmu man pages (before italics)
s/_(oddmu[a-z.-]*)_\(([1-9])\)/[$1($2)]($1.$2)/g;
# italic
s/\b_([^_]+)_\b/*$1*/g;
# move all H1 headers to H2
s/^# (.*)/"## ".ucfirst(lc($1))/e;
# the new H1 title
s/^([A-Z.-]*\([1-9]\))( ".*")?$/"# ".lc($1)/e;
# quoted URLs
s/"(http.*?)"/`$1`/g;
# quoted wiki links
s/"(\[\[[^]]*\]\])"/`$1`/g;
# quoted Markdown links
s/"(\[.*?\]\(.*?\))"/`$1`/g;
# protect hashtags
s/#([^ #])/\\#$1/;
print;
}

81
man_cmd.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"context"
"embed"
"fmt"
"flag"
"io"
"os"
"slices"
"strings"
"github.com/google/subcommands"
)
type manCmd struct {
}
func (cmd *manCmd) SetFlags(f *flag.FlagSet) {
}
func (*manCmd) Name() string { return "man" }
func (*manCmd) Synopsis() string { return "show a manual page" }
func (*manCmd) Usage() string {
return `man <topic>:
Print a manual page on a topic. If no topic is given, the
available topics are listed. Substrings are possible.
`
}
// A filesystem with a read-only copy of the man pages at build time.
//
//go:embed man/*.txt
var manFiles embed.FS
func (cmd *manCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
topic := strings.Join(f.Args(), " ")
return manCli(os.Stdout, topic)
}
func manCli(w io.Writer, topic string) subcommands.ExitStatus {
entries, err := manFiles.ReadDir("man")
if err != nil {
fmt.Println("An error in the build resulted in unreadable manual pages:", err)
return subcommands.ExitFailure
}
if (topic == "") {
fmt.Println("Topics:")
names := []string{}
for _, entry := range entries {
names = append(names, entry.Name())
}
slices.Sort(names)
for _, name := range names {
fmt.Println(name)
}
return subcommands.ExitSuccess
}
var candidate string
for _, entry := range entries {
name := entry.Name()
if strings.Contains(name, topic) {
if candidate != "" {
fmt.Printf("The topic '%s' matches both %s and %s, maybe more.\n", topic, candidate, name)
fmt.Println("Please be more specific.")
return subcommands.ExitFailure
}
candidate = name
}
}
if candidate == "" {
fmt.Printf("No manual page matching topic '%s' found\n", topic)
return subcommands.ExitFailure
}
b, err := manFiles.ReadFile("man/" + candidate)
if err != nil {
return subcommands.ExitFailure
}
os.Stdout.Write(b)
return subcommands.ExitSuccess
}

219
man_test.go Normal file
View File

@@ -0,0 +1,219 @@
package main
import (
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// Does oddmu(1) link to all the other man pages?
func TestManPages(t *testing.T) {
b, err := os.ReadFile("man/oddmu.1.txt")
main := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(fp, ".txt") &&
fp != "man/oddmu.1.txt" {
count++
s := strings.TrimPrefix(fp, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "_" + s[:i] + "_(" + s[i+1:] + ")"
assert.Contains(t, main, ref, ref)
}
return nil
})
assert.Greater(t, count, 0, "no man pages were found")
}
// Does oddmu-templates(5) mention all the templates?
func TestManTemplates(t *testing.T) {
b, err := os.ReadFile("man/oddmu-templates.5.txt")
man := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(fp, ".html") {
count++
assert.Contains(t, man, fp, fp)
}
if fp != "." && info.IsDir() {
return filepath.SkipDir
}
return nil
})
assert.Greater(t, count, 0, "no templates were found")
}
// Does oddmu-templates(5) mention all the template attributes?
func TestManTemplateAttributes(t *testing.T) {
mfp := "man/oddmu-templates.5.txt"
b, err := os.ReadFile(mfp)
man := string(b)
assert.NoError(t, err)
re := regexp.MustCompile(`{{(?:(?:if|range) )?(\.[A-Z][a-z]*)}}`)
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if fp != "." && info.IsDir() {
return filepath.SkipDir
}
if !strings.HasSuffix(fp, ".html") {
return nil
}
h, err := os.ReadFile(fp)
matches := re.FindAllSubmatch(h, -1)
assert.Greater(t, len(matches), 0, "%s contains no attributes", fp)
seen := make(map[string]bool)
for _, m := range matches {
attr := string(m[1])
if seen[attr] {
continue
}
seen[attr] = true
assert.Contains(t, man, "_{{"+attr+"}}_", "%s does not mention _{{%s}}_", mfp, attr)
}
assert.NoError(t, err)
return nil
})
}
// Does oddmu(1) mention all the actions? We're not going to parse the go file and make sure to catch them all. I tried
// it, and it's convoluted.
func TestManActions(t *testing.T) {
b, err := os.ReadFile("man/oddmu.1.txt")
assert.NoError(t, err)
main := string(b)
b, err = os.ReadFile("wiki.go")
assert.NoError(t, err)
wiki := string(b)
count := 0
// this doesn't match the root handler
re := regexp.MustCompile(`mux\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)(, http\.Method(Get|Post))+\)\)`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
var path string
if match[2] == "true" {
path = "_" + match[1] + "dir/name"
} else {
path = "_" + match[1] + "dir/"
}
assert.Contains(t, main, path, path)
}
assert.Greater(t, count, 0, "no handlers were found")
// root handler is manual
assert.Contains(t, main, "\n- _/_", "root")
}
// Does oddmu(1) mention all the commands?
func TestManCommands(t *testing.T) {
b, err := os.ReadFile("man/oddmu.1.txt")
assert.NoError(t, err)
main := string(b)
b, err = os.ReadFile("wiki.go")
assert.NoError(t, err)
wiki := string(b)
count := 0
re := regexp.MustCompile(`subcommands\.Register\(&([a-z]+)Cmd`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
command := match[1]
ref := "_oddmu-" + command + "_(1)"
assert.Contains(t, main, ref, "link to the '%s' command", command)
}
assert.Greater(t, count, 0, "no commands were found")
// root handler is manual
assert.Contains(t, main, "\n- _/_", "root")
}
// Does the README link to all the man pages and all the Go source files,
// excluding the command and test files?
func TestReadme(t *testing.T) {
b, err := os.ReadFile("README.md")
readme := string(b)
assert.NoError(t, err)
count := 0
filepath.Walk("man", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(fp, ".txt") {
count++
s := strings.TrimPrefix(fp, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
assert.Contains(t, readme, ref, ref)
}
return nil
})
assert.Greater(t, count, 0, "no man pages were found")
count = 0
filepath.Walk(".", func(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(fp, ".go") &&
!strings.HasSuffix(fp, "_test.go") &&
!strings.HasSuffix(fp, "_cmd.go") &&
!strings.HasSuffix(fp, "_common.go") &&
!strings.HasSuffix(fp, "_nowebp.go") {
count++
s := strings.TrimPrefix(fp, "./")
ref := "`" + s + "`"
assert.Contains(t, readme, ref, ref)
}
return nil
})
assert.Greater(t, count, 0, "no source pages were found")
}
// Does the README document all the dependecies, checking all the all the packages with names containing a period?
func TestDocumentDependencies(t *testing.T) {
b, err := os.ReadFile("README.md")
readme := string(b)
assert.NoError(t, err)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", nil, parser.ImportsOnly)
assert.NoError(t, err)
imports := []string{}
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, imp := range file.Imports {
name := imp.Path.Value[1 : len(imp.Path.Value)-1]
if strings.Contains(name, ".") && !slices.Contains(imports, name) {
imports = append(imports, name)
}
}
}
}
assert.Greater(t, len(imports), 0, "no imports found")
sort.Slice(imports, func(i, j int) bool { return len(imports[i]) < len(imports[j]) })
IMPORT:
for _, name := range imports {
for _, other := range imports {
if strings.HasPrefix(name, other) && name != other {
continue IMPORT
}
}
ok := strings.Contains(readme, name)
assert.True(t, ok, name)
}
}

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