34 Commits
v1.10 ... 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
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
58 changed files with 1385 additions and 283 deletions

View File

@@ -38,7 +38,7 @@ run:
upload: build
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia; systemctl restart campaignwiki"
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:
@@ -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

123
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
@@ -213,6 +233,7 @@ high-level introduction to the various source files.
- `languages.go` implements the language detection
- `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
- `snippets.go` implements the page summaries for search results

22
diff.go
View File

@@ -46,23 +46,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()
}

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

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

@@ -6,6 +6,7 @@ package main
import (
"golang.org/x/exp/constraints"
"html/template"
"io/fs"
"log"
"path/filepath"
@@ -16,6 +17,15 @@ import (
type docid uint
// 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
@@ -31,6 +41,9 @@ type indexStore 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 indexStore
@@ -45,6 +58,7 @@ func (idx *indexStore) reset() {
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!
@@ -102,6 +116,7 @@ func (idx *indexStore) deletePageName(name string) {
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.
@@ -153,6 +168,7 @@ func (idx *indexStore) addPage(p *Page) {
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.

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)/' \
< $< > $@

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2024-04-21"
.TH "ODDMU-APACHE" "5" "2024-05-09"
.PP
.SH NAME
.PP
@@ -48,7 +48,7 @@ ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -132,7 +132,7 @@ ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -170,7 +170,7 @@ In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -189,7 +189,7 @@ A workaround is to add the redirect manually and drop the question-mark:
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -274,7 +274,7 @@ directory:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd

View File

@@ -40,7 +40,7 @@ ServerAdmin alex@alexschroeder.ch
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -112,7 +112,7 @@ ServerAdmin alex@alexschroeder.ch
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -144,7 +144,7 @@ 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|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -159,7 +159,7 @@ A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -234,7 +234,7 @@ You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd

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

@@ -27,7 +27,7 @@ section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR se
.PP
.nf
.RS 4
location ~ ^/(view|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
@@ -97,7 +97,7 @@ server configuration.\& On a Debian system, that'\&d be in
.PP
.nf
.RS 4
location ~ ^/(view|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu\&.sock:;
}
.fi

View File

@@ -19,7 +19,7 @@ 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|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
```
@@ -81,7 +81,7 @@ server configuration. On a Debian system, that'd be in
"/etc/nginx/sites-available/default".
```
location ~ ^/(view|diff|edit|save|add|append|upload|drop|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu.sock:;
}
```

View File

@@ -5,16 +5,76 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2024-05-09"
.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
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.\&

View File

@@ -2,12 +2,68 @@ 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
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.

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

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-04-02" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2024-07-21" "File Formats Manual"
.PP
.SH NAME
.PP
@@ -14,8 +14,8 @@ oddmu-templates - how to write the templates
.SH SYNOPSIS
.PP
These files act as HTML templates: \fIadd.\&html\fR, \fIdiff.\&html\fR, \fIedit.\&html\fR,
\fIfeed.\&html\fR, \fIsearch.\&html\fR, \fIstatic.\&html\fR, \fIupload.\&html\fR and \fIview.\&html\fR.\& They
contain special placeholders in double bracers {{like this}}.\&
\fIfeed.\&html\fR, \fIpreview.\&html\fR, \fIsearch.\&html\fR, \fIstatic.\&html\fR, \fIupload.\&html\fR and
\fIview.\&html\fR.\& They contain special placeholders in double bracers {{like this}}.\&
.PP
.SH DESCRIPTION
.PP
@@ -32,6 +32,8 @@ placeholders.\&
.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
\fIstatic.\&html\fR uses a \fIpage\fR
@@ -77,8 +79,6 @@ For \fIsearch.\&html\fR, it is a page summary, with bold matches, as HTML.\&
For \fIfeed.\&html\fR, it is the escaped (!\&) HTML of the feed item.\&
.PD
.PP
\fI{{.\&Score}}\fR is a numerical score.\& It is only computed for \fIsearch.\&html\fR.\&
.PP
\fI{{.\&IsBlog}}\fR says whether the current page has a name starting with an ISO
date.\&
.PP
@@ -115,9 +115,6 @@ An item is a page plus a date.\& All the properties of a page can be used (see
\fI{{.\&Dir}}\fR is the directory in which the search starts, percent-escaped except
for the slashes.\&
.PP
\fI{{.\&Items}}\fR is an array of pages (see \fBPage\fR above).\& To refer to them, you need
to use a \fI{{range .\&Items}}\fR\fI{{end}}\fR construct.\&
.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 is not
@@ -127,6 +124,29 @@ available.\&
.PP
\fI{{.\&Results}}\fR indicates if there were any search results at all.\&
.PP
\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
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{{.\&Score}}\fR is a numerical score.\& It is only computed for \fIsearch.\&html\fR.\&
.PP
\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
Each image has three properties:
.PP
\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{{.\&Name}}\fR is the file name for use in URLs.\&
.PP
\fI{{.\&Html}}\fR the image alt-text with a bold tag used to highlight the first
search term that matched.\&
.PP
.SS Upload
.PP
\fI{{.\&Dir}}\fR is the directory where the uploaded file ends up, based on the URL

View File

@@ -7,8 +7,8 @@ oddmu-templates - how to write the templates
# SYNOPSIS
These files act as HTML templates: _add.html_, _diff.html_, _edit.html_,
_feed.html_, _search.html_, _static.html_, _upload.html_ and _view.html_. They
contain special placeholders in double bracers {{like this}}.
_feed.html_, _preview.html_, _search.html_, _static.html_, _upload.html_ and
_view.html_. They contain special placeholders in double bracers {{like this}}.
# DESCRIPTION
@@ -19,6 +19,7 @@ placeholders.
- _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_
- _static.html_ uses a _page_
- _upload.html_ uses an _upload_
@@ -55,8 +56,6 @@ _{{.Html}}_ contains some sort of HTML that depends on the template used.
- 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.
_{{.Score}}_ is a numerical score. It is only computed for _search.html_.
_{{.IsBlog}}_ says whether the current page has a name starting with an ISO
date.
@@ -93,9 +92,6 @@ _{{.Query}}_ is the query string.
_{{.Dir}}_ is the directory in which the search starts, percent-escaped except
for the slashes.
_{{.Items}}_ is an array of pages (see *Page* above). To refer to them, you need
to use a _{{range .Items}}_ … _{{end}}_ construct.
_{{.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 is not
@@ -105,6 +101,29 @@ _{{.More}}_ indicates if there are any more search results.
_{{.Results}}_ indicates if there were any search results at all.
_{{.Items}}_ is an array of results. To refer to them, you need to use a
_{{range .Items}}_ … _{{end}}_ construct.
A result is a page plus a score and possibly images. All the properties of a
page can be used (see *Page* above).
_{{.Score}}_ is a numerical score. It is only computed for _search.html_.
_{{.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.
Each image has three properties:
_{{.Title}}_ is the alt-text of the image. It can never be empty because images
are only listed if a search term matches.
_{{.Name}}_ is the file name for use in URLs.
_{{.Html}}_ the image alt-text with a bold tag used to highlight the first
search term that matched.
## Upload
_{{.Dir}}_ is the directory where the uploaded file ends up, based on the URL

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-04-21"
.TH "ODDMU" "1" "2024-08-15"
.PP
.SH NAME
.PP
@@ -63,6 +63,8 @@ directory:
.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
@@ -90,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
@@ -136,28 +138,8 @@ curl --remote-name \&'http://localhost:8080/archive/man/man\&.zip
.PP
.SH CONFIGURATION
.PP
The template files are the HTML files in the working directory:
.PP
.PD 0
.IP \(bu 4
\fIview.\&html\fR shows a page
.IP \(bu 4
\fIdiff.\&html\fR shows the last change to a page
.IP \(bu 4
\fIedit.\&html\fR shows a form to edit a page
.IP \(bu 4
\fIadd.\&html\fR shows a form to add to a page
.IP \(bu 4
\fIupload.\&html\fR shows a form to upload a file
.IP \(bu 4
\fIsearch.\&html\fR shows the search results
.IP \(bu 4
\fIstatic.\&html\fR is used to generate a static site
.IP \(bu 4
\fIfeed.\&html\fR is used to generate a RSS feed
.PD
.PP
Please change the templates!\&
The template files are the HTML files in the working directory.\& Please change
these templates!\&
.PP
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".\&
@@ -204,9 +186,9 @@ ODDMU_FILTER can be used to exclude subdirectories from such tree actions.\& See
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) and
\fIoddmu-apache\fR(5) for an example of how to use socket activation with a
Unix-domain socket under systemd and Apache.\&
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
@@ -385,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
@@ -400,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

@@ -47,6 +47,7 @@ directory:
- _/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
@@ -65,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
@@ -103,18 +104,8 @@ curl --remote-name 'http://localhost:8080/archive/man/man.zip
# CONFIGURATION
The template files are the HTML files in the working directory:
- _view.html_ shows a page
- _diff.html_ shows the last change to a page
- _edit.html_ shows a form to edit a page
- _add.html_ shows a form to add to a page
- _upload.html_ shows a form to upload a file
- _search.html_ shows the search results
- _static.html_ is used to generate a static site
- _feed.html_ is used to generate a RSS feed
Please change the templates!
The template files are the HTML files in the working directory. 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".
@@ -159,9 +150,9 @@ _oddmu-filter_(7) and _oddmu-apache_(5).
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) and
_oddmu-apache_(5) for an example of how to use socket activation with a
Unix-domain socket under systemd and Apache.
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
@@ -314,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

18
page.go
View File

@@ -16,22 +16,20 @@ import (
// Page is a struct containing information about a single page. Title is the title extracted from the page content using
// titleRegexp. Name is the path without extension (so a path of "foo.md" results in the Name "foo"). Body is the
// Markdown content of the page and Html is the rendered HTML for that Markdown. Score is a number indicating how well
// the page matched for a search query.
// Markdown content of the page and Html is the rendered HTML for that Markdown.
type Page struct {
Title string
Name string
Body []byte
Html template.HTML
Score int
Hashtags []string
}
// 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
@@ -132,12 +130,6 @@ func (p *Page) handleTitle(replace bool) {
}
}
// score sets Page.Title and computes Page.Score.
func (p *Page) score(q string) {
p.handleTitle(true)
p.Score = score(q, string(p.Body)) + score(q, p.Title)
}
// summarize sets Page.Html to an extract.
func (p *Page) summarize(q string) {
t := p.plainText()
@@ -187,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

@@ -7,6 +7,8 @@ import (
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"net/url"
"path"
"path/filepath"
)
// wikiLink returns an inline parser function. This indirection is
@@ -44,7 +46,7 @@ func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node),
for i < n && !parser.IsSpace(data[i]) {
i++
}
if i == 0 {
if i <= 1 {
return 0, nil
}
hashtags = append(hashtags, string(data[1:i]))
@@ -73,17 +75,17 @@ func wikiParser() (*parser.Parser, *[]string) {
return parser, hashtags
}
// wikiRenderer is a Renderer for Markdown that adds lazy loading of images. This in turn requires an exception for the
// sanitization policy!
// wikiRenderer is a Renderer for Markdown that adds lazy loading of images and disables fractions support. Remember
// that there is no HTML sanitization.
func wikiRenderer() *html.Renderer {
htmlFlags := html.CommonFlags | html.LazyLoadImages
// sync with staticPage
htmlFlags := html.CommonFlags & ^html.SmartypantsFractions | html.LazyLoadImages
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return renderer
}
// renderHtml renders the Page.Body to HTML and sets Page.Html, Page.Language, Page.Hashtags, and escapes Page.Name.
// Note: If the rendered HTML doesn't contain the attributes or elements you expect it to contain, check sanitizeBytes!
func (p *Page) renderHtml() {
parser, hashtags := wikiParser()
renderer := wikiRenderer()
@@ -119,3 +121,45 @@ func (p *Page) plainText() string {
}
return string(text)
}
// images returns an array of ImageData.
func (p *Page) images() []ImageData {
dir := path.Dir(filepath.ToSlash(p.Name))
images := make([]ImageData, 0)
parser := parser.New()
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.Image:
// not an absolute URL, not a full URL, not a mailto: URI
text := toString(v)
if len(text) > 0 {
name := path.Join(dir, string(v.Destination))
image := ImageData{Title: text, Name: name}
images = append(images, image)
}
return ast.SkipChildren
}
}
return ast.GoToNext
})
return images
}
// toString for a node returns the text nodes' literals, concatenated. There is no whitespace added so the expectation
// is that there is only one child node. Otherwise, there may be a space missing between the literals, depending on the
// exact child nodes they belong to.
func toString(node ast.Node) string {
b := new(bytes.Buffer)
ast.WalkFunc(node, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Text:
b.Write(v.Literal)
}
}
return ast.GoToNext
})
return b.String()
}

View File

@@ -48,6 +48,18 @@ I am cold, alone</p>
assert.Equal(t, r, string(p.Html))
}
func TestPageHtmlHashtagCornerCases(t *testing.T) {
p := &Page{Body: []byte(`#
ok # #o #ok`)}
p.renderHtml()
r := `<p>#</p>
<p>ok # <a class="tag" href="/search/?q=%23o">#o</a> <a class="tag" href="/search/?q=%23ok">#ok</a></p>
`
assert.Equal(t, r, string(p.Html))
}
func TestPageHtmlWikiLink(t *testing.T) {
p := &Page{Body: []byte(`# Photos and Books
Blue and green and black
@@ -83,3 +95,17 @@ func TestLazyLoadImages(t *testing.T) {
p.renderHtml()
assert.Contains(t, string(p.Html), "lazy")
}
// The fractions available in Latin 1 (?) are rendered.
func TestFractions(t *testing.T) {
p := &Page{Body: []byte(`1/4`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "&frac14;")
}
// Other fractions are not rendered.
func TestNoFractions(t *testing.T) {
p := &Page{Body: []byte(`1/6`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "1/6")
}

20
preview.go Normal file
View File

@@ -0,0 +1,20 @@
package main
import (
"net/http"
"strings"
)
// previewHandler is a bit like saveHandler and viewHandler. Instead of saving the date to a page, we create a synthetic
// Page and render it. Note that when saving, the carriage returns (\r) are removed. We need to do this as well,
// otherwise the rendered template has garbage bytes at the end. Note also that we need to remove the title from the
// page so that the preview works as intended (and much like the "view.html" template) where as the editing requires the
// page content including the header… which is why it needs to be added in the "preview.html" template. This makes me
// sad.
func previewHandler(w http.ResponseWriter, r *http.Request, path string) {
body := strings.ReplaceAll(r.FormValue("body"), "\r", "")
p := &Page{Name: path, Body: []byte(body)}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, p.Dir(), "preview", p)
}

View File

@@ -92,13 +92,16 @@ func TestScoreSubstring(t *testing.T) {
func TestScorePageAndMarkup(t *testing.T) {
s := `The Transjovian Council accepts new members. If you think we'd be a good fit, apply for an account. Contact [Alex Schroeder](https://alexschroeder.ch/wiki/Contact). Mail is best. Encrypted mail is best. [Delta Chat](https://delta.chat/de/) is a messenger app that uses encrypted mail. It's the bestest best.`
p := &Page{Title: "Test", Name: "Test", Body: []byte(s)}
r := &Result{}
r.Title = "Test"
r.Name = "Test"
r.Body = []byte(s)
q := "wiki"
p.score(q)
r.score(q)
// "wiki" is not visible in the plain text but the score is no affected:
// - wiki, all, whole, beginning, end (5)
if p.Score != 5 {
t.Logf("%s score is %d", q, p.Score)
if r.Score != 5 {
t.Logf("%s score is %d", q, r.Score)
t.Fail()
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
@@ -13,6 +14,14 @@ import (
"unicode/utf8"
)
// Result is a page plus image data. Page is the page being used as the search result. Score is a number indicating how
// well the page matched for a search query. Images are the images whose description match the query.
type Result struct {
Page
Score int
Images []ImageData
}
// Search is a struct containing the result of a search. Query is the
// query string and Items is the array of pages with the result.
// Currently there is no pagination of results! When a page is part of
@@ -20,7 +29,7 @@ import (
type Search struct {
Query string
Dir string
Items []*Page
Items []*Result
Previous int
Page int
Next int
@@ -94,9 +103,9 @@ const itemsPerPage = 20
// size is 20. Specify either the page number to return, or that all the results should be returned. Only ask for all
// results if runtime is not an issue, like on the command line. The boolean return value indicates whether there are
// more results.
func search(q, dir, filter string, page int, all bool) ([]*Page, bool) {
func search(q, dir, filter string, page int, all bool) ([]*Result, bool) {
if len(q) == 0 {
return make([]*Page, 0), false
return make([]*Result, 0), false
}
names := index.search(q) // hashtags or all names
names = filterPath(names, dir, filter)
@@ -104,16 +113,51 @@ func search(q, dir, filter string, page int, all bool) ([]*Page, bool) {
names = filterNames(names, predicates)
index.RLock()
slices.SortFunc(names, sortNames(terms))
index.RUnlock()
index.RUnlock() // unlock because grep takes long
names, keepFirst := prependQueryPage(names, dir, q)
from := itemsPerPage * (page - 1)
to := from + itemsPerPage - 1
items, more := grep(terms, names, from, to, all, keepFirst)
for _, p := range items {
p.score(q)
p.summarize(q)
results := make([]*Result, len(items))
for i, p := range items {
r := &Result{}
r.Title = p.Title
r.Name = p.Name
r.Body = p.Body
// Hashtags aren't computed and Html is getting overwritten anyway
r.summarize(q)
r.score(q)
results[i] = r
}
return items, more
if len(terms) > 0 {
index.RLock()
for _, r := range results {
res := make([]ImageData, 0)
ImageLoop:
for _, img := range index.images[r.Name] {
title := strings.ToLower(img.Title)
for _, term := range terms {
if strings.Contains(title, term) {
re, err := re(term)
if err == nil {
img.Html = template.HTML(highlight(re, img.Title))
}
res = append(res, img)
continue ImageLoop
}
}
}
r.Images = res
}
index.RUnlock()
}
return results, more
}
// score sets Page.Title and computes Page.Score.
func (r *Result) score(q string) {
r.handleTitle(true)
r.Score = score(q, string(r.Body)) + score(q, r.Title)
}
// filterPath filters the names by prefix and by a regular expression. A prefix of "." means that all the names are

View File

@@ -12,9 +12,10 @@ header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
img { max-width: 20%; }
.result { font-size: larger }
.score { font-size: smaller; opacity: 0.8; }
.image { display: inline-block; margin-right: 1em; max-width: calc(20% - 1em); font-size: small; }
.image img { max-width: 100%; }
</style>
</head>
<body>
@@ -40,6 +41,9 @@ img { max-width: 20%; }
<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}}
<p>

View File

@@ -4,9 +4,10 @@ import (
"context"
"flag"
"fmt"
"github.com/muesli/reflow/wordwrap"
"github.com/google/subcommands"
"github.com/muesli/reflow/wordwrap"
"io"
"net/url"
"os"
"regexp"
"strings"
@@ -27,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
@@ -82,9 +83,9 @@ 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 []*Page) {
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
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
re := regexp.MustCompile(`<b>(.*?)</b>`)
for _, p := range items {
s := re.ReplaceAllString(string(p.Html), match(`$1`))
@@ -95,5 +96,15 @@ func searchExtract(w io.Writer, items []*Page) {
for _, s := range strings.Split(wordwrap.String(s, 72), "\n") {
fmt.Fprintln(w, " ", s)
}
for _, img := range p.Images {
name, err := url.PathUnescape(img.Name)
if err != nil {
name = img.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) {
@@ -227,6 +231,42 @@ A quick sip too quick
assert.Equal(t, "Tea", items[1].Title, items[1].Name)
}
func TestImageSearch(t *testing.T) {
cleanup(t, "testdata/images")
p := &Page{Name: "testdata/images/2024-07-21", Body: []byte(`# 2024-07-21 Pictures
![phone call](2024-07-21.jpg)
Pictures in the box
Tiny windows to our past
Where are you, my love?
`)}
p.save()
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) {
cleanup(t, "testdata/question")
p := &Page{Name: "testdata/question/Odd?", Body: []byte(`# Even?

View File

@@ -35,7 +35,7 @@ func snippets(q string, s string) string {
}
// Short cut for short pages
if len(s) <= snippetlen {
return highlight(q, re, s)
return highlight(re, s)
}
// show a snippet from the beginning of the document
j := strings.LastIndex(s[:snippetlen], " ")
@@ -47,7 +47,7 @@ func snippets(q string, s string) string {
if len(s) > 400 {
s = s[0:400] + " …"
}
return highlight(q, re, s)
return highlight(re, s)
}
}
t := s[0:j]
@@ -98,5 +98,5 @@ func snippets(q string, s string) string {
s = s[end:]
}
}
return highlight(q, re, res)
return highlight(re, res)
}

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)) {
@@ -196,7 +206,8 @@ func staticPage(source, target string) (*Page, error) {
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, staticLinks)
opts := html.RendererOptions{
Flags: html.CommonFlags,
// sync with wikiRenderer
Flags: html.CommonFlags & ^html.SmartypantsFractions | html.LazyLoadImages,
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)

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

View File

@@ -14,18 +14,22 @@ label { width: 7ch; display: inline-block; }
button { font-size: large; background-color: #eee; color: inherit; border-radius: 6px; border-width: 1px; }
main > *, footer { clear: both; }
main p {
float: right; text-align: right;
float: right;
color: #000; background: #8fd;
padding: 3px 1ch; margin: 1pt 0 1pt 5ch;
padding: 3px 1ch; margin: 1pt auto 1pt 5ch;
border-radius: 6px; border: 1px outset #eee; }
main blockquote {
padding: 0; margin: 0; }
main blockquote p {
float: left; text-align: left;
float: left;
color: #000; background: #ccc;
padding: 3px 1ch; margin: 1pt 5ch 1pt 0;
border-radius: 6px; border: 1px outset #eee; }
p + blockquote > p, blockquote + p { margin-top: 5pt; }
main ul, main ol, main dl {
float: left;
color: #000; background: #4ed;
padding: 3px 1ch; margin: 1pt 0; border-radius: 6px; border: 1px outset #eee; }
footer p { margin: 0.5ch 0 0 0; }
textarea {
width: 97%; margin: 1ch 0 0 0; padding: 0 0.5ch; font: inherit;

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,13 +129,13 @@ 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)
}
// hashtags returns a slice of hashtags. Use this to extract hashtags
// from a page body.
// from a page body. This ignores Markdown completely.
func hashtags(s []byte) []string {
hashtags := make([]string, 0)
for {
@@ -77,6 +143,10 @@ func hashtags(s []byte) []string {
if i == -1 {
return hashtags
}
if i > 0 && s[i-1] == '\\' {
s = s[i+1:]
continue
}
from := i
i++
for {

View File

@@ -1,6 +1,7 @@
package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)
@@ -9,8 +10,48 @@ func TestHashtags(t *testing.T) {
assert.EqualValues(t, []string{"#truth"}, hashtags([]byte("This is boring. #Truth")), "hashtags")
}
func TestEscapedHashtags(t *testing.T) {
assert.EqualValues(t, []string{}, hashtags([]byte("This is not a hashtag: \\#False")), "escaped hashtags")
}
func TestBorkedHashtags(t *testing.T) {
assert.EqualValues(t, []string{}, hashtags([]byte("This is borked: \\#")), "borked hashtag")
}
func TestTokensAndPredicates(t *testing.T) {
predicates, terms := predicatesAndTokens("foo title:bar")
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)
}

14
view.go
View File

@@ -140,17 +140,3 @@ func viewHandler(w http.ResponseWriter, r *http.Request, path string) {
p.renderHtml()
renderTemplate(w, p.Dir(), "view", p)
}
// previewHandler is a bit like saveHandler and viewHandler. Instead of saving the date to a page, we create a synthetic
// Page and render it. Note that when saving, the carriage returns (\r) are removed. We need to do this as well,
// otherwise the rendered template has garbage bytes at the end. Note also that we need to remove the title from the
// page so that the preview works as intended (and much like the "view.html" template) where as the editing requires the
// page content including the header… which is why it needs to be added in the "preview.html" template. This makes me
// sad.
func previewHandler(w http.ResponseWriter, r *http.Request, path string) {
body := strings.ReplaceAll(r.FormValue("body"), "\r", "")
p := &Page{Name: path, Body: []byte(body)}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, p.Dir(), "preview", p)
}

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()