20 Commits
v1.11 ... v1.12

Author SHA1 Message Date
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
42 changed files with 1011 additions and 159 deletions

View File

@@ -57,3 +57,6 @@ oddmu-linux-amd64: *.go
%.tar.gz: %
tar czf $@ --transform='s/^$</oddmu/' --transform='s/^/oddmu\//' --exclude='*~' \
$< Makefile *.socket *.service *.md man/Makefile man/*.1 man/*.5 man/*.7 themes/
priv:
sudo setcap 'cap_net_bind_service=+ep' oddmu

122
README.md
View File

@@ -40,82 +40,102 @@ This project uses man(1) 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
[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 points 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
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(5)](https://alexschroeder.ch/view/oddmu/oddmu.5): This man page
talks about the 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-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
[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-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
This man page lists all the Oddmu versions and their user-visible
changes.
[oddmu-version(1)](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-version(1)](/oddmu.git/blob/main/man/oddmu-version.1.txt): This
man page documents the "version" subcommand which you can use to get
installed Oddmu version.
Working locally:
[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-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-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-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-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
man page documents how search and scoring work.
[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-filter(7)](/oddmu.git/blob/main/man/oddmu-filter.7.txt): This
man page documents how to exclude subdirectories from search and
archiving.
[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-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).
[oddmu-search(7)](https://alexschroeder.ch/view/oddmu/oddmu-search.7):
This man page documents how search and scoring work.
[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-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.
[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.
Reporting:
[oddmu-static(1)](/oddmu.git/blob/main/man/oddmu-static.1.txt): This
man page documents the "static" subcommand to generate an entire
[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-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 Apache web server for various
common tasks such as using logins to limit what visitors can edit.
System administration:
[oddmu-nginx(5)](/oddmu.git/blob/main/man/oddmu-nginx.5.txt): This man
page documents how to set up the freenginx web server for various
common tasks such as using logins to limit what visitors can edit.
[oddmu-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.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-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.”
## Building

2
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.26

4
go.sum
View File

@@ -13,8 +13,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=

59
hashtags_cmd.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"sort"
)
type hashtagsCmd struct {
}
func (cmd *hashtagsCmd) SetFlags(f *flag.FlagSet) {
}
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 {
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
}

16
hashtags_cmd_test.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestHashtagsCmd(t *testing.T) {
b := new(bytes.Buffer)
s := hashtagsCli(b)
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "#like_this\t")
}

View File

@@ -6,13 +6,13 @@ package main
import (
"golang.org/x/exp/constraints"
"html/template"
"io/fs"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"html/template"
)
type docid uint
@@ -23,7 +23,7 @@ type docid uint
// It depends on the fact that Title is always plain text.
type ImageData struct {
Title, Name string
Html template.HTML
Html template.HTML
}
// indexStore controls access to the maps used for search. Make sure to lock and unlock as appropriate.

56
links_cmd.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
)
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 {
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
}

16
links_cmd_test.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestLinksCmd(t *testing.T) {
b := new(bytes.Buffer)
s := linksCli(b, []string{"README"})
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "https://alexschroeder.ch/view/oddmu/oddmu.1\n")
}

View File

@@ -20,7 +20,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.

View File

@@ -11,24 +11,29 @@ man: ${MAN}
html: ${HTML}
%.html: %.md
echo '<!DOCTYPE html>' > $@
oddmu html $(basename $<) | sed --regexp-extended \
@echo Making $@
@echo '<!DOCTYPE html>' > $@
@oddmu html $(basename $<) | sed --regexp-extended \
-e 's/<a href="(oddmu[a-z.-]*.[1-9])">([^<>]*)<\/a>/<a href="\1.html">\2<\/a>/g' >> $@
md: ${MD}
%.md: %.txt
sed --regexp-extended \
@echo Making $@
@sed --regexp-extended \
-e 's/\*([^*]+)\*/**\1**/g' \
-e 's/_(oddmu[a-z.-]*)_\(([1-9])\)/[\1(\2)](\1.\2)/g' \
-e 's/\b_([^_]+)_\b/*\1*/g' \
-e 's/^# /## /' \
-e 's/#([^ #])/\\#\1/' \
-e 's/"(http.*?)"/`\1`/' \
-e 's/"(\[.*?\]\(.*?\))"/`\1`/' \
-e 's/^([A-Z.-]*\([1-9]\))( ".*")?$$/# \1/' \
< $< > $@
README.md: ../README.md
sed --regexp-extended \
@echo Making $@
@sed --regexp-extended \
-e 's/\]\(.*\/(.*)\.txt\)/](\1)/' \
< $< > $@

39
man/oddmu-hashtags.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-HASHTAGS" "1" "2024-07-31"
.PP
.SH NAME
.PP
oddmu-hashtags - count the hashtags used from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu hashtags\fR
.PP
.SH DESCRIPTION
.PP
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.\&
.PP
.SH EXAMPLE
.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
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

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

@@ -0,0 +1,30 @@
ODDMU-HASHTAGS(1)
# NAME
oddmu-hashtags - count the hashtags used from the command-line
# SYNOPSIS
*oddmu hashtags*
# DESCRIPTION
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.
# EXAMPLE
List the top 10 hashtags. This requires 11 lines because of the header line.
```
oddmu hashtags | head -n 11
```
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

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" "2024-08-15"
.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 page names.\& 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 page names. 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

@@ -5,16 +5,48 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2024-07-21"
.TH "ODDMU-RELEASES" "7" "2024-08-15"
.PP
.SH NAME
.PP
oddmu-releases - what'\&s new in this releases?\&
oddmu-releases - what'\&s new?\&
.PP
.SH DESCRIPTION
.PP
This page lists user-visible features and template changes to consider.\&
.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 search.\&html
.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 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

View File

@@ -2,12 +2,42 @@ ODDMU-RELEASES(7)
# NAME
oddmu-releases - what's new in this releases?
oddmu-releases - what's new?
# DESCRIPTION
This page lists user-visible features and template changes to consider.
## 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 search.html
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

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "1" "2024-02-17"
.TH "ODDMU-SEARCH" "1" "2024-07-30"
.PP
.SH NAME
.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
@@ -51,20 +56,30 @@ Ignore pagination and just print a long list of results.\&
.RE
.SH EXAMPLE
.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

@@ -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.
@@ -36,17 +41,27 @@ scored.
# EXAMPLE
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

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

@@ -0,0 +1,33 @@
.\" 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" "2024-08-15"
.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 page
names.\& 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>.\&

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

@@ -0,0 +1,26 @@
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 page
names. 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>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-05-11"
.TH "ODDMU" "1" "2024-08-15"
.PP
.SH NAME
.PP
@@ -92,7 +92,7 @@ curl --form body="Did you bring a towel?"
.RE
.PP
When calling the \fIdrop\fR action, the query parameters used are \fIname\fR for the
target filename, \fIfile\fR for the file to upload.\& If the query parameter
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 and PNG files can be encoded,
however.\& If the target name ends in \fI.\&jpg\fR, the \fIquality\fR query parameter is
@@ -367,10 +367,15 @@ Oddmu running as a webserver:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-hashtags\fR(1), on how to count the hashtags used from the command-line
.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-links\fR(1), on how to list the outgoing links for a page 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-notify\fR(1), on updating index, changes and hashtag pages from the
@@ -382,6 +387,9 @@ command-line
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site from the command-line
.IP \(bu 4
\fIoddmu-toc\fR(1), on how to list the table of contents (toc) a page from the
command-line
.IP \(bu 4
\fIoddmu-version\fR(1), on how to get all the build information from the binary
.PD
.PP

View File

@@ -66,7 +66,7 @@ curl --form body="Did you bring a towel?" \
```
When calling the _drop_ action, the query parameters used are _name_ for the
target filename, _file_ for the file to upload. If the query parameter
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 and PNG files can be encoded,
however. If the target name ends in _.jpg_, the _quality_ query parameter is
@@ -305,14 +305,19 @@ If you run Oddmu as a web server:
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 how to count the hashtags used from the command-line
- _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-links_(1), on how to list the outgoing links for a page from the
command-line
- _oddmu-missing_(1), on how to find broken local links from the command-line
- _oddmu-notify_(1), on updating index, changes and hashtag pages 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-static_(1), on generating a static site from the command-line
- _oddmu-toc_(1), on how to list the table of contents (toc) a page from the
command-line
- _oddmu-version_(1), on how to get all the build information from the binary
# AUTHORS

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "5" "2024-02-17" "File Formats Manual"
.TH "ODDMU" "5" "2024-07-31" "File Formats Manual"
.PP
.SH NAME
.PP

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2024-04-21"
.TH "ODDMU.SERVICE" "5" "2024-07-24"
.PP
.SH NAME
.PP
@@ -131,11 +131,67 @@ echo -e "GET /view/index HTTP/1\&.1rnHost: localhostrnrn"
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, on the standard port (80).\& 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_PORT=80"
Environment="ODDMU_LANGUAGES=de,en"
.fi
.RE
.PP
Since this is a priviledged port, the binary needs an extra capability for an
ordinary user to do this.\& This is necessary so that the files are created and
owned by the same user.\& Otherwise, the regular user wouldn'\&t be able to edit the
files using their favourite text editor.\&
.PP
.nf
.RS 4
sudo setcap \&'cap_net_bind_service=+ep\&' oddmu
.fi
.RE
.PP
Note that as soon as you recomile, the capability is gone again and the above
must be repeated.\&
.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
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-apache\fR(5), \fIoddmu-nginx\fR(5), \fIsystemd.\&exec\fR(5),
\fIsystemd.\&socket(5), \fRcapabilities_(7)
\fIsystemd.\&socket\fR(5), \fIcapabilities\fR(7)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
.PP

View File

@@ -106,11 +106,59 @@ echo -e "GET /view/index HTTP/1.1\r\nHost: localhost\r\n\r\n" \
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, on the standard port (80). 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_PORT=80"
Environment="ODDMU_LANGUAGES=de,en"
```
Since this is a priviledged port, the binary needs an extra capability for an
ordinary user to do this. This is necessary so that the files are created and
owned by the same user. Otherwise, the regular user wouldn't be able to edit the
files using their favourite text editor.
```
sudo setcap 'cap_net_bind_service=+ep' oddmu
```
Note that as soon as you recomile, the capability is gone again and the above
must be repeated.
Install it:
```
systemctl --user enable --now user-unix-domain.service
```
To examine the log:
```
journalctl --user --unit user-unix-domain.service
```
# SEE ALSO
_oddmu_(1), _oddmu-apache_(5), _oddmu-nginx_(5), _systemd.exec_(5),
_systemd.socket(5), _capabilities_(7)
_systemd.socket_(5), _capabilities_(7)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -124,8 +124,17 @@ func (p *Page) links() []string {
switch v := node.(type) {
case *ast.Link:
link := string(v.Destination)
dir := p.Dir()
links = append(links, path.Join(dir, link))
url, err := url.Parse(link)
if err != nil {
// no error reporting
return ast.GoToNext
}
if url.IsAbs() {
links = append(links, link)
} else {
dir := p.Dir()
links = append(links, path.Join(dir, link))
}
}
}
return ast.GoToNext

View File

@@ -28,8 +28,8 @@ type Page struct {
// Link is a struct containing a title and a name. Name is the path without extension (so a path of "foo.md" results in
// the Name "foo").
type Link struct {
Title string
Url string
Title string
Url string
}
// blogRe is a regular expression that matches blog pages. If the filename of a blog page starts with an ISO date
@@ -179,13 +179,13 @@ func (p *Page) Parents() []*Link {
return links
}
s := ""
for i := 0; i < len(elems) - 1; i++ {
for i := 0; i < len(elems)-1; i++ {
name := s + "index"
title, ok := index.titles[name]
if !ok {
title = "…"
}
link := &Link{ Title: title, Url: strings.Repeat("../", len(elems) - i - 1) + "index" }
link := &Link{Title: title, Url: strings.Repeat("../", len(elems)-i-1) + "index"}
links = append(links, link)
s += elems[i] + "/"
}

View File

@@ -1,8 +1,8 @@
package main
import (
"log"
"html/template"
"log"
"net/http"
"os"
"path"
@@ -18,7 +18,7 @@ import (
// well the page matched for a search query. Images are the images whose description match the query.
type Result struct {
Page
Score int
Score int
Images []ImageData
}
@@ -131,8 +131,8 @@ func search(q, dir, filter string, page int, all bool) ([]*Result, bool) {
}
if len(terms) > 0 {
index.RLock()
res := make([]ImageData, 0)
for _, r := range results {
res := make([]ImageData, 0)
ImageLoop:
for _, img := range index.images[r.Name] {
title := strings.ToLower(img.Title)

View File

@@ -42,7 +42,7 @@ button { background-color: #eee; color: inherit; border-radius: 4px; border-widt
<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}}
<p class="image"><a href="/view/{{.Name}}"><img loading="lazy" src="/view/{{.Name}}"></a><br/>{{.Html}}
{{end}}
</article>
{{end}}

View File

@@ -4,8 +4,8 @@ import (
"context"
"flag"
"fmt"
"github.com/muesli/reflow/wordwrap"
"github.com/google/subcommands"
"github.com/muesli/reflow/wordwrap"
"io"
"net/url"
"os"
@@ -28,7 +28,7 @@ func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
}
func (*searchCmd) Name() string { return "search" }
func (*searchCmd) Synopsis() string { return "Search pages and print a list of links." }
func (*searchCmd) Synopsis() string { return "search pages and print a list of links" }
func (*searchCmd) Usage() string {
return `search [-dir string] [-page <n>|-all] [-extract] <terms>:
Search for pages matching terms and print the result set as a
@@ -84,8 +84,8 @@ func searchCli(w io.Writer, dir string, n int, all, extract bool, quiet bool, ar
// searchExtract prints the search extracts to stdout with highlighting for a terminal.
func searchExtract(w io.Writer, items []*Result) {
heading := func (s string) string { return "\x1b[1;4m" + s + "\x1b[0m" } // bold + underline
match := func (s string) string { return "\x1b[1m" + s + "\x1b[0m" } // bold
heading := func(s string) string { return "\x1b[1;4m" + s + "\x1b[0m" } // bold + underline
match := func(s string) string { return "\x1b[1m" + s + "\x1b[0m" } // bold
re := regexp.MustCompile(`<b>(.*?)</b>`)
for _, p := range items {
s := re.ReplaceAllString(string(p.Html), match(`$1`))
@@ -101,7 +101,7 @@ func searchExtract(w io.Writer, items []*Result) {
if err != nil {
name = img.Name
}
fmt.Fprintln(w, " - ", name);
fmt.Fprintln(w, " - ", name)
for _, s := range strings.Split(wordwrap.String(img.Title, 70), "\n") {
fmt.Fprintln(w, " ", s)
}

View File

@@ -69,6 +69,10 @@ func TestSearch(t *testing.T) {
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
assert.NotContains(t, body, "Welcome")
data.Set("q", "'create a new page'")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
}
func TestSearchFilter(t *testing.T) {
@@ -230,9 +234,9 @@ A quick sip too quick
func TestImageSearch(t *testing.T) {
cleanup(t, "testdata/images")
p := &Page{Name: "testdata/images/2024-07-21", Body: []byte(`# Pictures
p := &Page{Name: "testdata/images/2024-07-21", Body: []byte(`# 2024-07-21 Pictures
![phone](2024-07-21.jpg)
![phone call](2024-07-21.jpg)
Pictures in the box
Tiny windows to our past
@@ -241,12 +245,26 @@ Where are you, my love?
`)}
p.save()
items, _ := search("phone", "testdata/images", "", 1, false)
assert.Equal(t, 1, len(items), "one page found")
assert.Equal(t, "Pictures", items[0].Title)
assert.Equal(t, "phone", items[0].Images[0].Title)
assert.Equal(t, "<b>phone</b>", string(items[0].Images[0].Html))
q := &Page{Name: "testdata/images/2024-07-22", Body: []byte(`# 2024-07-22 The Moon
When the night is light
Behind clouds the moon is bright
Please call me, my love.
`)}
q.save()
items, _ := search("call", "testdata/images", "", 1, false)
assert.Equal(t, 2, len(items), "two pages found")
assert.Equal(t, "2024-07-21 Pictures", items[0].Title)
assert.Equal(t, "2024-07-22 The Moon", items[1].Title)
assert.NotEmpty(t, items[0].Images)
assert.Equal(t, "phone call", items[0].Images[0].Title)
assert.Equal(t, "phone <b>call</b>", string(items[0].Images[0].Html))
assert.Equal(t, "testdata/images/2024-07-21.jpg", items[0].Images[0].Name)
assert.Empty(t, items[1].Images)
}
func TestSearchQuestionmark(t *testing.T) {

View File

@@ -42,17 +42,17 @@ func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface
return subcommands.ExitFailure
}
dir := filepath.Clean(args[0])
return staticCli(dir, cmd.jobs, false)
return staticCli(".", dir, cmd.jobs, false)
}
type args struct {
source, target string
info fs.FileInfo
info fs.FileInfo
}
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
// tests.
func staticCli(dir string, jobs int, quiet bool) subcommands.ExitStatus {
// tests. The source directory cannot be set from the command-line. The current directory (".") is assumed.
func staticCli(source, target string, jobs int, quiet bool) subcommands.ExitStatus {
index.load()
index.RLock()
defer index.RUnlock()
@@ -65,7 +65,7 @@ func staticCli(dir string, jobs int, quiet bool) subcommands.ExitStatus {
for i := 0; i < jobs; i++ {
go staticWorker(tasks, results, done)
}
go staticWalk(dir, tasks, stop)
go staticWalk(source, target, tasks, stop)
go staticWatch(jobs, results, done)
n, err := staticProgressIndicator(results, stop, quiet)
if !quiet {
@@ -78,18 +78,18 @@ func staticCli(dir string, jobs int, quiet bool) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// staticWalk walks the directory tree. Any directory it finds, it recreates in the destination directory. Any file it
// staticWalk walks the source directory tree. Any directory it finds, it recreates in the target directory. Any file it
// finds, it puts into the tasks channel for the staticWorker. When the directory walk is finished, the tasks channel is
// closed. If there's an error on the stop channel, the walk returns that error.
func staticWalk (dir string, tasks chan(args), stop chan(error)) {
func staticWalk(source, target string, tasks chan (args), stop chan (error)) {
// The error returned here is what's in the stop channel but at the very end, a worker might return an error
// even though the walk is already done. This is why we cannot rely on the return value of the walk.
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
filepath.Walk(source, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
select {
case err := <- stop:
case err := <-stop:
return err
default:
base := filepath.Base(path)
@@ -102,18 +102,28 @@ func staticWalk (dir string, tasks chan(args), stop chan(error)) {
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, dir) {
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, target) {
return nil
}
// determine the actual target: if source is a/ and target is b/ and path is a/file, then the
// target is b/file
var actual_target string
if source == "." {
actual_target = filepath.Join(target, path)
} else {
if !strings.HasPrefix(path, source) {
return fmt.Errorf("%s is not a subdirectory of %s", path, source)
}
actual_target = filepath.Join(target, path[len(source):])
}
// recreate subdirectories
target := filepath.Join(dir, path)
if info.IsDir() {
return os.Mkdir(target, 0755)
return os.Mkdir(actual_target, 0755)
}
// do the task if the target file doesn't exist or if the source file is newer
other, err := os.Stat(target)
other, err := os.Stat(actual_target)
if err != nil || info.ModTime().After(other.ModTime()) {
tasks <- args{ source: path, target: target, info: info }
tasks <- args{source: path, target: actual_target, info: info}
}
return nil
}
@@ -123,27 +133,27 @@ func staticWalk (dir string, tasks chan(args), stop chan(error)) {
// staticWatch counts the values coming out of the done channel. When the count matches the number of jobs started, we
// know that all the tasks have been processed and the results channel is closed.
func staticWatch(jobs int, results chan(error), done chan(bool)) {
func staticWatch(jobs int, results chan (error), done chan (bool)) {
for i := 0; i < jobs; i++ {
<- done
<-done
}
close(results)
}
// staticWorker takes arguments off the tasks channel (the file to process) and put results in the results channel (any
// errors encountered); when they're done they send true on the done channel.
func staticWorker(tasks chan(args), results chan(error), done chan(bool)) {
task, ok := <- tasks
func staticWorker(tasks chan (args), results chan (error), done chan (bool)) {
task, ok := <-tasks
for ok {
results <- staticFile(task.source, task.target, task.info)
task, ok = <- tasks
task, ok = <-tasks
}
done <- true
}
// staticProgressIndicator watches the results channel and does a countdown. If the result channel reports an error,
// that is put into the stop channel so that staticWalk stops adding to the tasks channel.
func staticProgressIndicator(results chan(error), stop chan(error), quiet bool) (int, error) {
func staticProgressIndicator(results chan (error), stop chan (error), quiet bool) (int, error) {
n := 0
t := time.Now()
var err error
@@ -154,7 +164,7 @@ func staticProgressIndicator(results chan(error), stop chan(error), quiet bool)
stop <- err
} else {
n++
if !quiet && n % 13 == 0 {
if !quiet && n%13 == 0 {
if time.Since(t) > time.Second {
fmt.Printf("\r%d", n)
t = time.Now()
@@ -170,11 +180,11 @@ func staticProgressIndicator(results chan(error), stop chan(error), quiet bool)
func staticFile(source, target string, info fs.FileInfo) error {
// render pages
if strings.HasSuffix(source, ".md") {
p, err := staticPage(source[:len(source)-3], target[:len(target)-3] + ".html")
p, err := staticPage(source[:len(source)-3], target[:len(target)-3]+".html")
if err != nil {
return err
}
return staticFeed(source[:len(source)-3], target[:len(target)-3] + ".rss", p, info.ModTime())
return staticFeed(source[:len(source)-3], target[:len(target)-3]+".rss", p, info.ModTime())
}
// remaining files are linked unless this is a template
if slices.Contains(templateFiles, filepath.Base(source)) {

View File

@@ -9,7 +9,7 @@ import (
func TestStaticCmd(t *testing.T) {
cleanup(t, "testdata/static")
s := staticCli("testdata/static", 2, true)
s := staticCli(".", "testdata/static", 2, true)
assert.Equal(t, subcommands.ExitSuccess, s)
// pages
assert.FileExists(t, "testdata/static/index.html")
@@ -34,12 +34,8 @@ And the cars so loud
`)}
h.save()
h.notify()
wd, err := os.Getwd()
assert.NoError(t, err)
assert.NoError(t, os.Chdir("testdata/static-feed"))
s := staticCli("../static-feed-out/", 2, true)
s := staticCli("testdata/static-feed", "testdata/static-feed-out", 2, true)
assert.Equal(t, subcommands.ExitSuccess, s)
assert.NoError(t, os.Chdir(wd))
assert.FileExists(t, "testdata/static-feed-out/2024-03-07-poem.html")
assert.FileExists(t, "testdata/static-feed-out/Haiku.html")
b, err := os.ReadFile("testdata/static-feed-out/Haiku.rss")

108
toc_cmd.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
"io"
"os"
)
type tocCmd struct {
}
func (cmd *tocCmd) SetFlags(f *flag.FlagSet) {
}
func (*tocCmd) Name() string { return "toc" }
func (*tocCmd) Synopsis() string { return "print the table of contents (toc) for a page" }
func (*tocCmd) Usage() string {
return `toc <page name> ...:
Print the table of contents (toc) for a page.
Use a single - to read Markdown from stdin.
If only a single level one heading is appears
in the page, it is dropped from the table of
contents.
`
}
func (cmd *tocCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return tocCli(os.Stdout, f.Args())
}
// tocCli runs the toc command on the command line. It is used
// here with an io.Writer for easy testing.
func tocCli(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}
p.toc().print(w)
return subcommands.ExitSuccess
}
for _, name := range args {
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
p.toc().print(w)
}
return subcommands.ExitSuccess
}
// Toc represents an array of headings
type Toc []*ast.Heading
// toc parses the page content and returns a Toc.
func (p *Page) toc() Toc {
var headings Toc
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if !entering {
switch v := node.(type) {
case *ast.Heading:
headings = append(headings, v)
}
}
return ast.GoToNext
})
return headings
}
// print prints the Toc to the io.Writer. If the table of contents first heading is a level one heading and there are no
// other level one headings, this is a "regular" table of contents. For a regular table of contents, the first entry is
// skipped.
func (toc Toc) print(w io.Writer) {
minLevel := 0;
levelOneCount := 0;
for _, h := range toc {
if h.Level == 1 {
levelOneCount++
}
if h.Level < minLevel || minLevel == 0 {
minLevel = h.Level
}
}
for i, h := range toc {
if i == 0 && h.Level == 1 && levelOneCount == 1 {
minLevel++
continue
}
for j := minLevel; j < h.Level; j++ {
fmt.Fprint(w, " ")
}
fmt.Fprint(w, "* [")
for _, c := range h.GetChildren() {
fmt.Fprint(w, string(c.AsLeaf().Literal));
}
fmt.Fprintf(w, "](#%s)\n", h.HeadingID)
}
}

45
toc_cmd_test.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
// ## is promoted to level 1 because there is just one instance of level 1
func TestTocCmd(t *testing.T) {
b := new(bytes.Buffer)
s := tocCli(b, []string{"README"})
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "\n* [Bugs](#bugs)\n")
}
// ## is promoted to level 1 because there is no instance of level 1
func TestTocNoH1(t *testing.T) {
p := &Page{
Body: []byte(`## Venti
Es drückt der Sommer
Weit weg hör' ich ein Flugzeug
Ventilator hilf!`)}
b := new(bytes.Buffer)
p.toc().print(b)
assert.Equal(t, "* [Venti](#venti)\n", b.String())
}
// # is dropped because it's just one level 1 heading
func TestTocDropH1(t *testing.T) {
p := &Page{Body: []byte("# One\n## Two\n### Three\n")}
b := new(bytes.Buffer)
p.toc().print(b)
assert.Equal(t, "* [Two](#two)\n * [Three](#three)\n", b.String())
}
// # is kept because there is more than one level 1 heading
func TestTocMultipleH1(t *testing.T) {
p := &Page{Body: []byte("# One\n# Two\n## Three\n")}
b := new(bytes.Buffer)
p.toc().print(b)
assert.Equal(t, "* [One](#one)\n* [Two](#two)\n * [Three](#three)\n", b.String())
}

View File

@@ -16,16 +16,84 @@ func lowercaseFilter(tokens []string) []string {
return r
}
// tokenizeWithPredicates returns a slice of tokens for the given
// text, including punctuation. Use this to begin tokenizing the query
// string.
func tokenizeOnWhitespace(q string) []string {
return strings.Fields(q)
// IsQuote reports whether the rune has the Quotation Mark property.
func IsQuote(r rune) bool {
// This property isn't the same as Z; special-case it.
return unicode.Is(unicode.Quotation_Mark, r)
}
// predicateFilter returns two slices of tokens: the first with
// predicates, the other without predicates. Use this for query
// string tokens.
// tokenizeWithQuotes returns a slice of tokens for the given text, including punctuation. Use this to begin tokenizing
// the query string. Note that quotation marks need a matching rune to end: 'foo' "foo" foo foo foo “foo” „foo“
// ”foo” «foo» »foo« foo foo 「foo」 「foo」 『foo』 read and despair:
// https://en.wikipedia.org/wiki/Quotation_mark
//
// Also note that 〈foo〉 and 《foo》 are not considered to be quotation marks by Unicode.
func tokenizeWithQuotes(s string) []string {
type span struct {
start int
end int
}
waitFor := rune(0)
matchingRunes := [][]rune{{'\'', '\''}, {'"', '"'}, {'', ''}, {'', ''}, {'', ''}, {'“', '”'}, {'„', '“'}, {'”', '”'},
{'«', '»'}, {'»', '«'}, {'', ''}, {'', ''}, {'「', '」'}, {'「', '」'}, {'『', '』'}}
spans := make([]span, 0, 32)
// The comments in FieldsFunc say that doing this in a separate pass is faster.
start := -1 // valid span start if >= 0
RUNE:
for end, rune := range s {
if waitFor > 0 {
if rune == waitFor {
if start >= 0 {
// skip "" and the like
spans = append(spans, span{start, end})
}
// The comments in FieldsFunc say that doing this instead of using -1 is faster.
start = ^start
waitFor = 0
} else if start < 0 {
start = end
}
} else if unicode.IsSpace(rune) {
if start >= 0 {
spans = append(spans, span{start, end})
start = ^start
}
} else {
if start < 0 {
// Only check for starting quote at the beginning of a token
if IsQuote(rune) {
waitFor = rune
for _, match := range matchingRunes {
if rune == match[0] {
waitFor = match[1]
continue RUNE
}
}
}
start = end
}
}
}
// Last field might end at EOF.
if start >= 0 {
spans = append(spans, span{start, len(s)})
}
// Create strings from recorded field indices.
a := make([]string, len(spans))
for i, span := range spans {
a[i] = s[span.start:span.end]
}
return a
}
// predicateFilter returns two slices of tokens: the first with predicates, the other without predicates. Use this for
// query string tokens.
func predicateFilter(tokens []string) ([]string, []string) {
with := make([]string, 0)
without := make([]string, 0)
@@ -39,18 +107,16 @@ func predicateFilter(tokens []string) ([]string, []string) {
return with, without
}
// predicatesAndTokens returns two slices of tokens: the first with
// predicates, the other without predicates, all of them lower case.
// Use this for query strings.
// predicatesAndTokens returns two slices of tokens: the first with predicates, the other without predicates, all of
// them lower case. Use this for query strings.
func predicatesAndTokens(q string) ([]string, []string) {
tokens := tokenizeOnWhitespace(q)
tokens := tokenizeWithQuotes(q)
tokens = lowercaseFilter(tokens)
return predicateFilter(tokens)
}
// noPredicateFilter returns a slice of tokens: the predicates without
// the predicate, and all the others. That is: "foo:bar baz" is turned
// into ["bar", "baz"] and the predicate "foo:" is dropped.
// noPredicateFilter returns a slice of tokens: the predicates without the predicate, and all the others. That is:
// "foo:bar baz" is turned into ["bar", "baz"] and the predicate "foo:" is dropped.
func noPredicateFilter(tokens []string) []string {
r := make([]string, 0)
for _, token := range tokens {
@@ -63,7 +129,7 @@ func noPredicateFilter(tokens []string) []string {
// highlightTokens returns the tokens to highlight, including title
// predicates.
func highlightTokens(q string) []string {
tokens := tokenizeOnWhitespace(q)
tokens := tokenizeWithQuotes(q)
tokens = lowercaseFilter(tokens)
return noPredicateFilter(tokens)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)
@@ -22,3 +23,35 @@ func TestTokensAndPredicates(t *testing.T) {
assert.EqualValues(t, []string{"foo"}, terms)
assert.EqualValues(t, []string{"title:bar"}, predicates)
}
func TestQuoteRunes(t *testing.T) {
s := `'"‘’‘‚“”„«»«‹›‹「」「」『』`
for _, rune := range s {
assert.True(t, IsQuote(rune), fmt.Sprintf("%c is a quote", rune))
}
}
func TestQuotes(t *testing.T) {
s := `'foo' "foo" foo foo foo “foo” „foo“ ”foo” «foo» »foo« foo foo 「foo」
「foo」 『foo』`
tokens := tokenizeWithQuotes(s)
assert.EqualValues(t, []string{
"foo", "foo", "foo", "foo", "foo", "foo", "foo", "foo", "foo", "foo", "foo", "foo", "foo",
"", ""}, tokens)
}
func TestPhrases(t *testing.T) {
s := `look for 'foo bar'`
tokens := tokenizeWithQuotes(s)
assert.EqualValues(t, []string{"look", "for", "foo bar"}, tokens)
}
func TestKlingon(t *testing.T) {
s := `quSDaq balua`
tokens := tokenizeWithQuotes(s)
assert.EqualValues(t, []string{"quSDaq", "balua"}, tokens)
// quotes at the beginning of a word are not handled correctly
s = `nuqDaq oH tache`
tokens = tokenizeWithQuotes(s)
assert.EqualValues(t, []string{"nuqDaq", "oH tach", "e"}, tokens) // this is wrong 🤷
}

View File

@@ -82,7 +82,7 @@ func next(dir, fn string, i int) (string, error) {
}
ext := filepath.Ext(fn)
// faking it
m = []string{"", fn[:len(fn)-len(ext)]+"-", "0", ext}
m = []string{"", fn[:len(fn)-len(ext)] + "-", "0", ext}
}
n, err := strconv.Atoi(m[2])
if err == nil {
@@ -239,6 +239,7 @@ func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
data.Set("last", filename) // has no slashes
http.Redirect(w, r, "/upload/"+dir+"?"+data.Encode(), http.StatusFound)
}
// Base returns a page name matching the first uploaded file: no extension and no appended number. If the name
// refers to a directory, returns "index". This is used to create the form target in "upload.html", for example.
func (u *upload) Base() string {

View File

@@ -268,6 +268,6 @@ func TestUploadNext(t *testing.T) {
s = append(s, nm)
os.Create("testdata/next/" + nm)
}
r := []string{ "test-1.jpg", "test-2.jpg", "test-3.jpg", "test-4.jpg", "test-5.jpg", "test-6.jpg", "test-7.jpg", "test-8.jpg", "test-9.jpg", "test-10.jpg", "test-11.jpg", "test-12.jpg", "test-13.jpg", "test-14.jpg", "test-15.jpg", "test-16.jpg", "test-17.jpg", "test-18.jpg", "test-19.jpg", "test-20.jpg", "test-21.jpg", "test-22.jpg", "test-23.jpg", "test-24.jpg", "test-25.jpg" }
r := []string{"test-1.jpg", "test-2.jpg", "test-3.jpg", "test-4.jpg", "test-5.jpg", "test-6.jpg", "test-7.jpg", "test-8.jpg", "test-9.jpg", "test-10.jpg", "test-11.jpg", "test-12.jpg", "test-13.jpg", "test-14.jpg", "test-15.jpg", "test-16.jpg", "test-17.jpg", "test-18.jpg", "test-19.jpg", "test-20.jpg", "test-21.jpg", "test-22.jpg", "test-23.jpg", "test-24.jpg", "test-25.jpg"}
assert.Equal(t, r, s)
}

View File

@@ -14,7 +14,7 @@ func TestWatchedPageUpdate(t *testing.T) {
index.load()
watches.install()
assert.NoError(t, os.MkdirAll(dir, 0755))
time.Sleep(time.Millisecond)
time.Sleep(10 * time.Millisecond)
assert.Contains(t, watches.watcher.WatchList(), dir)
haiku := []byte(`# Pine cones
@@ -24,7 +24,7 @@ Up and up in single file
Who ate half a cone?`)
assert.NoError(t, os.WriteFile(path, haiku, 0644))
time.Sleep(time.Millisecond)
time.Sleep(10 * time.Millisecond)
watches.RLock()
assert.Contains(t, watches.files, path)
@@ -50,7 +50,7 @@ func TestWatchedTemplateUpdate(t *testing.T) {
watches.install()
assert.NoError(t, os.MkdirAll(dir, 0755))
time.Sleep(time.Millisecond)
time.Sleep(10 * time.Millisecond)
assert.Contains(t, watches.watcher.WatchList(), dir)
@@ -71,7 +71,7 @@ the smell is everywhere
[]byte("<body><h1>{{.Title}}</h1>{{.Html}}"),
0644))
time.Sleep(time.Millisecond)
time.Sleep(10 * time.Millisecond)
watches.RLock()
assert.Contains(t, watches.files, path)

View File

@@ -200,13 +200,16 @@ func commands() {
subcommands.Register(subcommands.HelpCommand(), "")
subcommands.Register(subcommands.FlagsCommand(), "")
subcommands.Register(subcommands.CommandsCommand(), "")
subcommands.Register(&hashtagsCmd{}, "")
subcommands.Register(&htmlCmd{}, "")
subcommands.Register(&listCmd{}, "")
subcommands.Register(&linksCmd{}, "")
subcommands.Register(&missingCmd{}, "")
subcommands.Register(&notifyCmd{}, "")
subcommands.Register(&replaceCmd{}, "")
subcommands.Register(&searchCmd{}, "")
subcommands.Register(&staticCmd{}, "")
subcommands.Register(&tocCmd{}, "")
subcommands.Register(&versionCmd{}, "")
flag.Parse()