1
0
mirror of https://github.com/osmarks/mycorrhiza.git synced 2024-12-13 05:50:27 +00:00

Merge pull request #27 from bouncepaw/0.10

0.10
This commit is contained in:
Timur Ismagilov 2020-11-11 21:56:28 +05:00 committed by GitHub
commit 4f10aa35fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1125 additions and 375 deletions

View File

@ -1,4 +1,4 @@
# 🍄 MycorrhizaWiki 0.9
# 🍄 MycorrhizaWiki 0.10
A wiki engine.
## Building
@ -11,12 +11,25 @@ make
# * create an executable called `mycorrhiza`. Run it with path to your wiki.
```
## Usage
```
mycorrhiza [OPTIONS...] WIKI_PATH
Options:
-home string
The home page (default "home")
-port string
Port to serve the wiki at (default "1737")
-title string
How to call your wiki in the navititle (default "🍄")
```
## Features
* Edit pages through html forms
* Responsive design
* Works in text browsers
* Wiki pages (called hyphae) are in gemtext
* Everything is stored as simple files, no database required
* Wiki pages (called hyphae) are written in mycomarkup
* Everything is stored as simple files, no database required. You can run a wiki on almost any directory and get something to work with.
* Page trees
* Changes are saved to git
* List of hyphae page
@ -34,4 +47,3 @@ Help is always needed. We have a [tg chat](https://t.me/mycorrhizadev) where som
* Tagging system
* Authorization
* Better history viewing
* More markups

36
flag.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"flag"
"log"
"path/filepath"
"github.com/bouncepaw/mycorrhiza/util"
)
func init() {
flag.StringVar(&util.ServerPort, "port", "1737", "Port to serve the wiki at")
flag.StringVar(&util.HomePage, "home", "home", "The home page")
flag.StringVar(&util.SiteTitle, "title", "🍄", "How to call your wiki in the navititle")
}
// Do the things related to cli args and die maybe
func parseCliArgs() {
flag.Parse()
args := flag.Args()
if len(args) == 0 {
log.Fatal("Error: pass a wiki directory")
}
var err error
WikiDir, err = filepath.Abs(args[0])
util.WikiDir = WikiDir
if err != nil {
log.Fatal(err)
}
if !isCanonicalName(util.HomePage) {
log.Fatal("Error: you must use a proper name for the homepage")
}
}

View File

@ -59,16 +59,16 @@ func (rev Revision) HyphaeLinks() (html string) {
}
for _, filename := range strings.Split(out.String(), "\n") {
// If filename has an ampersand:
if strings.IndexRune(filename, '&') >= 0 {
if strings.IndexRune(filename, '.') >= 0 {
// Remove ampersanded suffix from filename:
ampersandPos := strings.LastIndexByte(filename, '&')
ampersandPos := strings.LastIndexByte(filename, '.')
hyphaName := string([]byte(filename)[0:ampersandPos]) // is it safe?
if isNewName(hyphaName) {
// Entries are separated by commas
if len(set) > 1 {
html += `<span aria-hidden="true">, </span>`
}
html += fmt.Sprintf(`<a href="/rev/%[1]s/%[2]s">%[2]s</a>`, rev.Hash, hyphaName)
html += fmt.Sprintf(`<a href="/page/%[1]s">%[1]s</a>`, hyphaName)
}
}
}
@ -77,10 +77,10 @@ func (rev Revision) HyphaeLinks() (html string) {
func (rev Revision) RecentChangesEntry() (html string) {
return fmt.Sprintf(`
<li><time>%s</time></li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li class="rc-entry__time"><time>%s</time></li>
<li class="rc-entry__hash">%s</li>
<li class="rc-entry__links">%s</li>
<li class="rc-entry__msg">%s</li>
`, rev.TimeString(), rev.Hash, rev.HyphaeLinks(), rev.Message)
}
@ -125,6 +125,6 @@ func unixTimestampAsTime(ts string) *time.Time {
// Rename renames from `from` to `to` using `git mv`.
func Rename(from, to string) error {
log.Println(util.ShorterPath(from), util.ShorterPath(to))
_, err := gitsh("mv", from, to)
_, err := gitsh("mv", "--force", from, to)
return err
}

View File

@ -39,7 +39,7 @@ func Revisions(hyphaName string) ([]Revision, error) {
"log", "--oneline", "--no-merges",
// Hash, Commiter email, Commiter time, Commit msg separated by tab
"--pretty=format:\"%h\t%ce\t%ct\t%s\"",
"--", hyphaName+"&.*",
"--", hyphaName+".*",
)
revs []Revision
)

View File

@ -55,6 +55,12 @@ func (hop *HistoryOp) gitop(args ...string) *HistoryOp {
return hop
}
// WithError appends the `err` to the list of errors.
func (hop *HistoryOp) WithError(err error) *HistoryOp {
hop.Errs = append(hop.Errs, err)
return hop
}
// WithFilesRemoved git-rm-s all passed `paths`. Paths can be rooted or not. Paths that are empty strings are ignored.
func (hop *HistoryOp) WithFilesRemoved(paths ...string) *HistoryOp {
args := []string{"rm", "--quiet", "--"}
@ -71,7 +77,9 @@ func (hop *HistoryOp) WithFilesRenamed(pairs map[string]string) *HistoryOp {
for from, to := range pairs {
if from != "" {
os.MkdirAll(filepath.Dir(to), 0777)
hop.gitop(append([]string{"mv"}, from, to)...)
if err := Rename(from, to); err != nil {
hop.Errs = append(hop.Errs, err)
}
}
}
return hop

View File

@ -2,13 +2,9 @@ package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/templates"
"github.com/bouncepaw/mycorrhiza/util"
)
@ -116,14 +112,13 @@ func handlerEdit(w http.ResponseWriter, rq *http.Request) {
textAreaFill, err = FetchTextPart(hyphaData)
if err != nil {
log.Println(err)
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error",
"Could not fetch text data")
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error", "Could not fetch text data")
return
}
} else {
warning = `<p>You are creating a new hypha.</p>`
}
util.HTTP200Page(w, base("Edit"+hyphaName, templates.EditHTML(hyphaName, textAreaFill, warning)))
util.HTTP200Page(w, base("Edit "+hyphaName, templates.EditHTML(hyphaName, textAreaFill, warning)))
}
// handlerUploadText uploads a new text part for the hypha.
@ -133,42 +128,19 @@ func handlerUploadText(w http.ResponseWriter, rq *http.Request) {
hyphaName = HyphaNameFromRq(rq, "upload-text")
hyphaData, isOld = HyphaStorage[hyphaName]
textData = rq.PostFormValue("text")
textDataBytes = []byte(textData)
fullPath = filepath.Join(WikiDir, hyphaName+"&.gmi")
)
if textData == "" {
HttpErr(w, http.StatusBadRequest, hyphaName, "Error",
"No text data passed")
return
}
// For some reason, only 0777 works. Why?
if err := os.MkdirAll(filepath.Dir(fullPath), 0777); err != nil {
log.Println(err)
}
if err := ioutil.WriteFile(fullPath, textDataBytes, 0644); err != nil {
log.Println(err)
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error",
fmt.Sprintf("Failed to write %d bytes to %s",
len(textDataBytes), fullPath))
return
}
if !isOld {
hd := HyphaData{
textType: TextGemini,
textPath: fullPath,
}
HyphaStorage[hyphaName] = &hd
hyphaData = &hd
} else {
hyphaData.textType = TextGemini
hyphaData.textPath = fullPath
hyphaData = &HyphaData{}
}
if textData == "" {
HttpErr(w, http.StatusBadRequest, hyphaName, "Error", "No text data passed")
return
}
if hop := hyphaData.UploadText(hyphaName, textData, isOld); len(hop.Errs) != 0 {
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error", hop.Errs[0].Error())
} else {
http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther)
}
history.Operation(history.TypeEditText).
WithFiles(fullPath).
WithMsg(fmt.Sprintf("Edit %s", hyphaName)).
WithSignature("anon").
Apply()
http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther)
}
// handlerUploadBinary uploads a new binary part for the hypha.
@ -176,62 +148,29 @@ func handlerUploadBinary(w http.ResponseWriter, rq *http.Request) {
log.Println(rq.URL)
hyphaName := HyphaNameFromRq(rq, "upload-binary")
rq.ParseMultipartForm(10 << 20)
// Read file
file, handler, err := rq.FormFile("binary")
if file != nil {
defer file.Close()
}
// If file is not passed:
if err != nil {
HttpErr(w, http.StatusBadRequest, hyphaName, "Error",
"No binary data passed")
HttpErr(w, http.StatusBadRequest, hyphaName, "Error", "No binary data passed")
return
}
// If file is passed:
var (
hyphaData, isOld = HyphaStorage[hyphaName]
mimeType = MimeToBinaryType(handler.Header.Get("Content-Type"))
ext = mimeType.Extension()
fullPath = filepath.Join(WikiDir, hyphaName+"&"+ext)
mime = handler.Header.Get("Content-Type")
)
data, err := ioutil.ReadAll(file)
if err != nil {
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error",
"Could not read passed data")
return
}
if err := os.MkdirAll(filepath.Dir(fullPath), 0777); err != nil {
log.Println(err)
}
if !isOld {
hd := HyphaData{
binaryPath: fullPath,
binaryType: mimeType,
}
HyphaStorage[hyphaName] = &hd
hyphaData = &hd
hyphaData = &HyphaData{}
}
hop := hyphaData.UploadBinary(hyphaName, mime, file, isOld)
if len(hop.Errs) != 0 {
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error", hop.Errs[0].Error())
} else {
if hyphaData.binaryPath != fullPath {
if err := history.Rename(hyphaData.binaryPath, fullPath); err != nil {
log.Println(err)
} else {
log.Println("Moved", hyphaData.binaryPath, "to", fullPath)
}
}
hyphaData.binaryPath = fullPath
hyphaData.binaryType = mimeType
http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther)
}
if err = ioutil.WriteFile(fullPath, data, 0644); err != nil {
HttpErr(w, http.StatusInternalServerError, hyphaName, "Error",
"Could not save passed data")
return
}
log.Println("Written", len(data), "of binary data for", hyphaName, "to path", fullPath)
history.Operation(history.TypeEditText).
WithFiles(fullPath, hyphaData.binaryPath).
WithMsg(fmt.Sprintf("Upload binary part for %s with type %s", hyphaName, mimeType.Mime())).
WithSignature("anon").
Apply()
http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther)
}

View File

@ -6,10 +6,11 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/bouncepaw/mycorrhiza/gemtext"
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/markup"
"github.com/bouncepaw/mycorrhiza/templates"
"github.com/bouncepaw/mycorrhiza/tree"
"github.com/bouncepaw/mycorrhiza/util"
@ -32,11 +33,11 @@ func handlerRevision(w http.ResponseWriter, rq *http.Request) {
revHash = shorterUrl[:firstSlashIndex]
hyphaName = CanonicalName(shorterUrl[firstSlashIndex+1:])
contents = fmt.Sprintf(`<p>This hypha had no text at this revision.</p>`)
textPath = hyphaName + "&.gmi"
textPath = hyphaName + ".myco"
textContents, err = history.FileAtRevision(textPath, revHash)
)
if err == nil {
contents = gemtext.ToHtml(hyphaName, textContents)
contents = markup.ToHtml(hyphaName, textContents)
}
page := templates.RevisionHTML(
hyphaName,
@ -75,7 +76,7 @@ func handlerText(w http.ResponseWriter, rq *http.Request) {
hyphaName := HyphaNameFromRq(rq, "text")
if data, ok := HyphaStorage[hyphaName]; ok {
log.Println("Serving", data.textPath)
w.Header().Set("Content-Type", data.textType.Mime())
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.ServeFile(w, rq, data.textPath)
}
}
@ -86,7 +87,7 @@ func handlerBinary(w http.ResponseWriter, rq *http.Request) {
hyphaName := HyphaNameFromRq(rq, "binary")
if data, ok := HyphaStorage[hyphaName]; ok {
log.Println("Serving", data.binaryPath)
w.Header().Set("Content-Type", data.binaryType.Mime())
w.Header().Set("Content-Type", ExtensionToMime(filepath.Ext(data.binaryPath)))
http.ServeFile(w, rq, data.binaryPath)
}
}
@ -103,7 +104,7 @@ func handlerPage(w http.ResponseWriter, rq *http.Request) {
fileContentsT, errT := ioutil.ReadFile(data.textPath)
_, errB := os.Stat(data.binaryPath)
if errT == nil {
contents = gemtext.ToHtml(hyphaName, string(fileContentsT))
contents = markup.ToHtml(hyphaName, string(fileContentsT))
}
if !os.IsNotExist(errB) {
contents = binaryHtmlBlock(hyphaName, data) + contents

119
hypha.go
View File

@ -5,21 +5,22 @@ import (
"fmt"
"io/ioutil"
"log"
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/bouncepaw/mycorrhiza/gemtext"
"github.com/bouncepaw/mycorrhiza/history"
"github.com/bouncepaw/mycorrhiza/markup"
"github.com/bouncepaw/mycorrhiza/util"
)
func init() {
gemtext.HyphaExists = func(hyphaName string) bool {
markup.HyphaExists = func(hyphaName string) bool {
_, hyphaExists := HyphaStorage[hyphaName]
return hyphaExists
}
gemtext.HyphaAccess = func(hyphaName string) (rawText, binaryBlock string, err error) {
markup.HyphaAccess = func(hyphaName string) (rawText, binaryBlock string, err error) {
if hyphaData, ok := HyphaStorage[hyphaName]; ok {
rawText, err = FetchTextPart(hyphaData)
if hyphaData.binaryPath != "" {
@ -35,9 +36,54 @@ func init() {
// HyphaData represents a hypha's meta information: binary and text parts rooted paths and content types.
type HyphaData struct {
textPath string
textType TextType
binaryPath string
binaryType BinaryType
}
// uploadHelp is a helper function for UploadText and UploadBinary
func (hd *HyphaData) uploadHelp(hop *history.HistoryOp, hyphaName, ext string, originalFullPath *string, isOld bool, data []byte) *history.HistoryOp {
var (
fullPath = filepath.Join(WikiDir, hyphaName+ext)
)
if err := os.MkdirAll(filepath.Dir(fullPath), 0777); err != nil {
return hop.WithError(err)
}
if err := ioutil.WriteFile(fullPath, data, 0644); err != nil {
return hop.WithError(err)
}
if isOld && *originalFullPath != fullPath && *originalFullPath != "" {
if err := history.Rename(*originalFullPath, fullPath); err != nil {
return hop.WithError(err)
}
log.Println("Move", *originalFullPath, "to", fullPath)
}
if !isOld {
HyphaStorage[hyphaName] = hd
}
*originalFullPath = fullPath
log.Printf("%v\n", *hd)
return hop.WithFiles(fullPath).
WithSignature("anon").
Apply()
}
// UploadText loads a new text part from `textData` for hypha `hyphaName` with `hd`. It must be marked if the hypha `isOld`.
func (hd *HyphaData) UploadText(hyphaName, textData string, isOld bool) *history.HistoryOp {
hop := history.Operation(history.TypeEditText).WithMsg(fmt.Sprintf("Edit %s", hyphaName))
return hd.uploadHelp(hop, hyphaName, ".myco", &hd.textPath, isOld, []byte(textData))
}
// UploadBinary loads a new binary part from `file` for hypha `hyphaName` with `hd`. The contents have the specified `mime` type. It must be marked if the hypha `isOld`.
func (hd *HyphaData) UploadBinary(hyphaName, mime string, file multipart.File, isOld bool) *history.HistoryOp {
var (
hop = history.Operation(history.TypeEditBinary).WithMsg(fmt.Sprintf("Upload binary part for %s with type %s", hyphaName, mime))
data, err = ioutil.ReadAll(file)
)
if err != nil {
return hop.WithError(err).Apply()
}
return hd.uploadHelp(hop, hyphaName, MimeToExtension(mime), &hd.binaryPath, isOld, data)
}
// DeleteHypha deletes hypha and makes a history record about that.
@ -61,10 +107,13 @@ func findHyphaeToRename(hyphaName string, recursive bool) []string {
return hyphae
}
func renamingPairs(hyphaNames []string, replaceName func(string) string) map[string]string {
func renamingPairs(hyphaNames []string, replaceName func(string) string) (map[string]string, error) {
renameMap := make(map[string]string)
for _, hn := range hyphaNames {
if hd, ok := HyphaStorage[hn]; ok {
if _, nameUsed := HyphaStorage[replaceName(hn)]; nameUsed {
return nil, errors.New("Hypha " + replaceName(hn) + " already exists")
}
if hd.textPath != "" {
renameMap[hd.textPath] = replaceName(hd.textPath)
}
@ -73,7 +122,7 @@ func renamingPairs(hyphaNames []string, replaceName func(string) string) map[str
}
}
}
return renameMap
return renameMap, nil
}
// word Data is plural here
@ -94,11 +143,15 @@ func (hd *HyphaData) RenameHypha(hyphaName, newName string, recursive bool) *his
replaceName = func(str string) string {
return strings.Replace(str, hyphaName, newName, 1)
}
hyphaNames = findHyphaeToRename(hyphaName, recursive)
renameMap = renamingPairs(hyphaNames, replaceName)
renameMsg = "Rename %s to %s"
hop = history.Operation(history.TypeRenameHypha)
hyphaNames = findHyphaeToRename(hyphaName, recursive)
renameMap, err = renamingPairs(hyphaNames, replaceName)
renameMsg = "Rename %s to %s"
hop = history.Operation(history.TypeRenameHypha)
)
if err != nil {
hop.Errs = append(hop.Errs, err)
return hop
}
if recursive {
renameMsg += " recursively"
}
@ -113,14 +166,14 @@ func (hd *HyphaData) RenameHypha(hyphaName, newName string, recursive bool) *his
}
// binaryHtmlBlock creates an html block for binary part of the hypha.
func binaryHtmlBlock(hyphaName string, d *HyphaData) string {
switch d.binaryType {
case BinaryJpeg, BinaryGif, BinaryPng, BinaryWebp, BinarySvg, BinaryIco:
func binaryHtmlBlock(hyphaName string, hd *HyphaData) string {
switch filepath.Ext(hd.binaryPath) {
case ".jpg", ".gif", ".png", ".webp", ".svg", ".ico":
return fmt.Sprintf(`
<div class="binary-container binary-container_with-img">
<img src="/binary/%s"/>
<a href="/page/%[1]s"><img src="/binary/%[1]s"/></a>
</div>`, hyphaName)
case BinaryOgg, BinaryWebm, BinaryMp4:
case ".ogg", ".webm", ".mp4":
return fmt.Sprintf(`
<div class="binary-container binary-container_with-video">
<video>
@ -128,7 +181,7 @@ func binaryHtmlBlock(hyphaName string, d *HyphaData) string {
<p>Your browser does not support video. See video's <a href="/binary/%[1]s">direct url</a></p>
</video>
`, hyphaName)
case BinaryMp3:
case ".mp3":
return fmt.Sprintf(`
<div class="binary-container binary-container_with-audio">
<audio>
@ -153,30 +206,36 @@ func Index(path string) {
}
for _, node := range nodes {
// If this hypha looks like it can be a hypha path, go deeper
if node.IsDir() && isCanonicalName(node.Name()) {
// If this hypha looks like it can be a hypha path, go deeper. Do not touch the .git and static folders for they have an admnistrative importance!
if node.IsDir() && isCanonicalName(node.Name()) && node.Name() != ".git" && node.Name() != "static" {
Index(filepath.Join(path, node.Name()))
continue
}
hyphaPartFilename := filepath.Join(path, node.Name())
skip, hyphaName, isText, mimeId := DataFromFilename(hyphaPartFilename)
var (
hyphaPartPath = filepath.Join(path, node.Name())
hyphaName, isText, skip = DataFromFilename(hyphaPartPath)
hyphaData *HyphaData
)
if !skip {
var (
hyphaData *HyphaData
ok bool
)
if hyphaData, ok = HyphaStorage[hyphaName]; !ok {
// Reuse the entry for existing hyphae, create a new one for those that do not exist yet.
if hd, ok := HyphaStorage[hyphaName]; ok {
hyphaData = hd
} else {
hyphaData = &HyphaData{}
HyphaStorage[hyphaName] = hyphaData
}
if isText {
hyphaData.textPath = hyphaPartFilename
hyphaData.textType = TextType(mimeId)
hyphaData.textPath = hyphaPartPath
} else {
hyphaData.binaryPath = hyphaPartFilename
hyphaData.binaryType = BinaryType(mimeId)
// Notify the user about binary part collisions. It's a design decision to just use any of them, it's the user's fault that they have screwed up the folder structure, but the engine should at least let them know, right?
if hyphaData.binaryPath != "" {
log.Println("There is a file collision for binary part of a hypha:", hyphaData.binaryPath, "and", hyphaPartPath, "-- going on with the latter")
}
hyphaData.binaryPath = hyphaPartPath
}
}
}
}

31
main.go
View File

@ -29,7 +29,7 @@ var HyphaStorage = make(map[string]*HyphaData)
// IterateHyphaNamesWith is a closure to be passed to subpackages to let them iterate all hypha names read-only.
func IterateHyphaNamesWith(f func(string)) {
for hyphaName, _ := range HyphaStorage {
for hyphaName := range HyphaStorage {
f(hyphaName)
}
}
@ -40,7 +40,7 @@ func HttpErr(w http.ResponseWriter, status int, name, title, errMsg string) {
w.Header().Set("Content-Type", "text/html;charset=utf-8")
w.WriteHeader(status)
fmt.Fprint(w, base(title, fmt.Sprintf(
`<p>%s. <a href="/page/%s">Go back to the hypha.<a></p>`,
`<main><p>%s. <a href="/page/%s">Go back to the hypha.<a></p></main>`,
errMsg, name)))
}
@ -52,7 +52,7 @@ func handlerList(w http.ResponseWriter, rq *http.Request) {
pageCount = len(HyphaStorage)
)
for hyphaName, data := range HyphaStorage {
tbody += templates.HyphaListRowHTML(hyphaName, data.binaryType.Mime(), data.binaryPath != "")
tbody += templates.HyphaListRowHTML(hyphaName, ExtensionToMime(filepath.Ext(data.binaryPath)), data.binaryPath != "")
}
util.HTTP200Page(w, base("List of pages", templates.HyphaListHTML(tbody, pageCount)))
}
@ -92,22 +92,26 @@ func handlerRecentChanges(w http.ResponseWriter, rq *http.Request) {
noPrefix = strings.TrimPrefix(rq.URL.String(), "/recent-changes/")
n, err = strconv.Atoi(noPrefix)
)
if err == nil {
if err == nil && n < 101 {
util.HTTP200Page(w, base(strconv.Itoa(n)+" recent changes", history.RecentChanges(n)))
} else {
http.Redirect(w, rq, "/recent-changes/20", http.StatusSeeOther)
}
}
func handlerStyle(w http.ResponseWriter, rq *http.Request) {
log.Println(rq.URL)
if _, err := os.Stat(WikiDir + "/static/common.css"); err == nil {
http.ServeFile(w, rq, WikiDir+"/static/common.css")
} else {
w.Header().Set("Content-Type", "text/css;charset=utf-8")
w.Write([]byte(templates.DefaultCSS()))
}
}
func main() {
log.Println("Running MycorrhizaWiki β")
var err error
WikiDir, err = filepath.Abs(os.Args[1])
util.WikiDir = WikiDir
if err != nil {
log.Fatal(err)
}
parseCliArgs()
if err := os.Chdir(WikiDir); err != nil {
log.Fatal(err)
}
@ -128,8 +132,9 @@ func main() {
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, rq *http.Request) {
http.ServeFile(w, rq, WikiDir+"/static/favicon.ico")
})
http.HandleFunc("/static/common.css", handlerStyle)
http.HandleFunc("/", func(w http.ResponseWriter, rq *http.Request) {
http.Redirect(w, rq, "/page/home", http.StatusSeeOther)
http.Redirect(w, rq, "/page/"+util.HomePage, http.StatusSeeOther)
})
log.Fatal(http.ListenAndServe("0.0.0.0:1737", nil))
log.Fatal(http.ListenAndServe("0.0.0.0:"+util.ServerPort, nil))
}

140
markup/img.go Normal file
View File

@ -0,0 +1,140 @@
package markup
import (
"fmt"
"regexp"
"strings"
)
var imgRe = regexp.MustCompile(`^img\s+{`)
func MatchesImg(line string) bool {
return imgRe.MatchString(line)
}
type imgEntry struct {
path string
sizeH string
sizeV string
desc string
}
type Img struct {
entries []imgEntry
inDesc bool
hyphaName string
}
func (img *Img) Process(line string) (shouldGoBackToNormal bool) {
if img.inDesc {
rightBraceIndex := strings.IndexRune(line, '}')
if cnt := len(img.entries); rightBraceIndex == -1 && cnt != 0 {
img.entries[cnt-1].desc += "\n" + line
} else if rightBraceIndex != -1 && cnt != 0 {
img.entries[cnt-1].desc += "\n" + line[:rightBraceIndex]
img.inDesc = false
}
if strings.Count(line, "}") > 1 {
return true
}
} else if s := strings.TrimSpace(line); s != "" {
if s[0] == '}' {
return true
}
img.parseStartOfEntry(line)
}
return false
}
func ImgFromFirstLine(line, hyphaName string) Img {
img := Img{
hyphaName: hyphaName,
entries: make([]imgEntry, 0),
}
line = line[strings.IndexRune(line, '{'):]
if len(line) == 1 { // if { only
} else {
line = line[1:] // Drop the {
}
return img
}
func (img *Img) canonicalPathFor(path string) string {
path = strings.TrimSpace(path)
if strings.IndexRune(path, ':') != -1 || strings.IndexRune(path, '/') == 0 {
return path
} else {
return "/binary/" + xclCanonicalName(img.hyphaName, path)
}
}
func (img *Img) parseStartOfEntry(line string) (entry imgEntry, followedByDesc bool) {
pipeIndex := strings.IndexRune(line, '|')
if pipeIndex == -1 { // If no : in string
entry.path = img.canonicalPathFor(line)
} else {
entry.path = img.canonicalPathFor(line[:pipeIndex])
line = strings.TrimPrefix(line, line[:pipeIndex+1])
var (
leftBraceIndex = strings.IndexRune(line, '{')
rightBraceIndex = strings.IndexRune(line, '}')
dimensions string
)
if leftBraceIndex == -1 {
dimensions = line
} else {
dimensions = line[:leftBraceIndex]
}
sizeH, sizeV := parseDimensions(dimensions)
entry.sizeH = sizeH
entry.sizeV = sizeV
if leftBraceIndex != -1 && rightBraceIndex == -1 {
img.inDesc = true
followedByDesc = true
entry.desc = strings.TrimPrefix(line, line[:leftBraceIndex+1])
} else if leftBraceIndex != -1 && rightBraceIndex != -1 {
entry.desc = line[leftBraceIndex+1 : rightBraceIndex]
}
}
img.entries = append(img.entries, entry)
return
}
func parseDimensions(dimensions string) (sizeH, sizeV string) {
xIndex := strings.IndexRune(dimensions, '*')
if xIndex == -1 { // If no x in dimensions
sizeH = strings.TrimSpace(dimensions)
} else {
sizeH = strings.TrimSpace(dimensions[:xIndex])
sizeV = strings.TrimSpace(strings.TrimPrefix(dimensions, dimensions[:xIndex+1]))
}
return
}
func (img Img) ToHtml() (html string) {
for _, entry := range img.entries {
html += fmt.Sprintf(`<figure>
<img src="%s" width="%s" height="%s">
`, entry.path, entry.sizeH, entry.sizeV)
if entry.desc != "" {
html += ` <figcaption>`
for i, line := range strings.Split(entry.desc, "\n") {
if line != "" {
if i > 0 {
html += `<br>`
}
html += ParagraphToHtml(img.hyphaName, line)
}
}
html += `</figcaption>`
}
html += `</figure>`
}
return `<section class="img-gallery">
` + html + `
</section>`
}

47
markup/img_test.go Normal file
View File

@ -0,0 +1,47 @@
package markup
import (
"fmt"
"testing"
)
func TestParseStartOfEntry(t *testing.T) {
img := ImgFromFirstLine("img {", "h")
tests := []struct {
line string
entry imgEntry
followedByDesc bool
}{
{"apple", imgEntry{"/binary/apple", "", "", ""}, false},
{"pear|", imgEntry{"/binary/pear", "", "", ""}, false},
{"яблоко| 30*60", imgEntry{"/binary/яблоко", "30", "60", ""}, false},
{"груша | 65 ", imgEntry{"/binary/груша", "65", "", ""}, false},
{"жеронимо | 30 { full desc }", imgEntry{"/binary/жеронимо", "30", "", " full desc "}, false},
{"жорно жованна | *5555 {partial description", imgEntry{"/binary/жорноованна", "", "5555", "partial description"}, true},
{"иноске | {full}", imgEntry{"/binary/иноске", "", "", "full"}, false},
{"j|{partial", imgEntry{"/binary/j", "", "", "partial"}, true},
}
for _, triplet := range tests {
entry, followedByDesc := img.parseStartOfEntry(triplet.line)
if entry != triplet.entry || followedByDesc != triplet.followedByDesc {
t.Error(fmt.Sprintf("%q:%q != %q; %v != %v", triplet.line, entry, triplet.entry, followedByDesc, triplet.followedByDesc))
}
}
}
func TestParseDimensions(t *testing.T) {
tests := [][]string{
{"500", "500", ""},
{"3em", "3em", ""},
{"500*", "500", ""},
{"*500", "", "500"},
{"800*520", "800", "520"},
{"17%*5rem", "17%", "5rem"},
}
for _, triplet := range tests {
sizeH, sizeV := parseDimensions(triplet[0])
if sizeH != triplet[1] || sizeV != triplet[2] {
t.Error(sizeH, "*", sizeV, " != ", triplet[1], "*", triplet[2])
}
}
}

View File

@ -1,9 +1,8 @@
package gemtext
package markup
import (
"fmt"
"html"
"path"
"strings"
)
@ -13,7 +12,7 @@ var HyphaExists func(string) bool
// HyphaAccess holds function that accesses a hypha by its name.
var HyphaAccess func(string) (rawText, binaryHtml string, err error)
// GemLexerState is used by gemtext parser to remember what is going on.
// GemLexerState is used by markup parser to remember what is going on.
type GemLexerState struct {
// Name of hypha being parsed
name string
@ -21,6 +20,8 @@ type GemLexerState struct {
// Line id
id int
buf string
// Temporaries
img Img
}
type Line struct {
@ -29,47 +30,6 @@ type Line struct {
contents interface{}
}
// Parse gemtext line starting with "=>" according to wikilink rules.
// See http://localhost:1737/page/wikilink
func wikilink(src string, state *GemLexerState) (href, text, class string) {
src = strings.TrimSpace(remover("=>")(src))
if src == "" {
return
}
// Href is text after => till first whitespace
href = strings.Fields(src)[0]
// Text is everything after whitespace.
// If there's no text, make it same as href
if text = strings.TrimPrefix(src, href); text == "" {
text = href
}
class = "wikilink_internal"
switch {
case strings.HasPrefix(href, "./"):
hyphaName := canonicalName(path.Join(
state.name, strings.TrimPrefix(href, "./")))
if !HyphaExists(hyphaName) {
class = "wikilink_new"
}
href = path.Join("/page", hyphaName)
case strings.HasPrefix(href, "../"):
hyphaName := canonicalName(path.Join(
path.Dir(state.name), strings.TrimPrefix(href, "../")))
if !HyphaExists(hyphaName) {
class = "wikilink_new"
}
href = path.Join("/page", hyphaName)
case strings.HasPrefix(href, "/"):
case strings.ContainsRune(href, ':'):
class = "wikilink_external"
default:
href = path.Join("/page", href)
}
return href, strings.TrimSpace(text), class
}
func lex(name, content string) (ast []Line) {
var state = GemLexerState{name: name}
@ -79,7 +39,7 @@ func lex(name, content string) (ast []Line) {
return ast
}
// Lex `line` in gemtext and save it to `ast` using `state`.
// Lex `line` in markup and save it to `ast` using `state`.
func geminiLineToAST(line string, state *GemLexerState, ast *[]Line) {
addLine := func(text interface{}) {
*ast = append(*ast, Line{id: state.id, contents: text})
@ -89,6 +49,9 @@ func geminiLineToAST(line string, state *GemLexerState, ast *[]Line) {
if state.where == "list" {
state.where = ""
addLine(state.buf + "</ul>")
} else if state.where == "number" {
state.where = ""
addLine(state.buf + "</ol>")
}
return
}
@ -99,14 +62,25 @@ func geminiLineToAST(line string, state *GemLexerState, ast *[]Line) {
// Beware! Usage of goto. Some may say it is considered evil but in this case it helped to make a better-structured code.
switch state.where {
case "img":
goto imgState
case "pre":
goto preformattedState
case "list":
goto listState
case "number":
goto numberState
default:
goto normalState
}
imgState:
if shouldGoBackToNormal := state.img.Process(line); shouldGoBackToNormal {
state.where = ""
addLine(state.img)
}
return
preformattedState:
switch {
case startsWith("```"):
@ -121,8 +95,8 @@ preformattedState:
listState:
switch {
case startsWith("*"):
state.buf += fmt.Sprintf("\t<li>%s</li>\n", remover("*")(line))
case startsWith("* "):
state.buf += fmt.Sprintf("\t<li>%s</li>\n", ParagraphToHtml(state.name, line[2:]))
case startsWith("```"):
state.where = "pre"
addLine(state.buf + "</ul>")
@ -135,6 +109,22 @@ listState:
}
return
numberState:
switch {
case startsWith("*. "):
state.buf += fmt.Sprintf("\t<li>%s</li>\n", ParagraphToHtml(state.name, line[3:]))
case startsWith("```"):
state.where = "pre"
addLine(state.buf + "</ol>")
state.id++
state.buf = fmt.Sprintf("<pre id='%d' alt='%s' class='codeblock'><code>", state.id, strings.TrimPrefix(line, "```"))
default:
state.where = ""
addLine(state.buf + "</ol>")
goto normalState
}
return
normalState:
state.id++
switch {
@ -142,32 +132,50 @@ normalState:
case startsWith("```"):
state.where = "pre"
state.buf = fmt.Sprintf("<pre id='%d' alt='%s' class='codeblock'><code>", state.id, strings.TrimPrefix(line, "```"))
case startsWith("*"):
case startsWith("* "):
state.where = "list"
state.buf = fmt.Sprintf("<ul id='%d'>\n", state.id)
goto listState
case startsWith("*. "):
state.where = "number"
state.buf = fmt.Sprintf("<ol id='%d'>\n", state.id)
goto numberState
case startsWith("###"):
case startsWith("###### "):
addLine(fmt.Sprintf(
"<h3 id='%d'>%s</h3>", state.id, removeHeadingOctothorps(line)))
case startsWith("##"):
"<h6 id='%d'>%s</h6>", state.id, line[7:]))
case startsWith("##### "):
addLine(fmt.Sprintf(
"<h2 id='%d'>%s</h2>", state.id, removeHeadingOctothorps(line)))
case startsWith("#"):
"<h5 id='%d'>%s</h5>", state.id, line[6:]))
case startsWith("#### "):
addLine(fmt.Sprintf(
"<h1 id='%d'>%s</h1>", state.id, removeHeadingOctothorps(line)))
"<h4 id='%d'>%s</h4>", state.id, line[5:]))
case startsWith("### "):
addLine(fmt.Sprintf(
"<h3 id='%d'>%s</h3>", state.id, line[4:]))
case startsWith("## "):
addLine(fmt.Sprintf(
"<h2 id='%d'>%s</h2>", state.id, line[3:]))
case startsWith("# "):
addLine(fmt.Sprintf(
"<h1 id='%d'>%s</h1>", state.id, line[2:]))
case startsWith(">"):
addLine(fmt.Sprintf(
"<blockquote id='%d'>%s</blockquote>", state.id, remover(">")(line)))
case startsWith("=>"):
source, content, class := wikilink(line, state)
href, text, class := Rocketlink(line, state.name)
addLine(fmt.Sprintf(
`<p><a id='%d' class='%s' href="%s">%s</a></p>`, state.id, class, source, content))
`<p><a id='%d' class='rocketlink %s' href="%s">%s</a></p>`, state.id, class, href, text))
case startsWith("<="):
addLine(parseTransclusion(line, state.name))
case line == "----":
*ast = append(*ast, Line{id: -1, contents: "<hr/>"})
case MatchesImg(line):
state.where = "img"
state.img = ImgFromFirstLine(line, state.name)
default:
addLine(fmt.Sprintf("<p id='%d'>%s</p>", state.id, line))
addLine(fmt.Sprintf("<p id='%d'>%s</p>", state.id, ParagraphToHtml(state.name, line)))
}
}

View File

@ -1,4 +1,4 @@
package gemtext
package markup
import (
"fmt"
@ -7,7 +7,7 @@ import (
"testing"
)
// TODO: move test gemtext docs to files, perhaps? These strings sure are ugly
// TODO: move test markup docs to files, perhaps? These strings sure are ugly
func TestLex(t *testing.T) {
check := func(name, content string, expectedAst []Line) {
if ast := lex(name, content); !reflect.DeepEqual(ast, expectedAst) {
@ -19,15 +19,15 @@ func TestLex(t *testing.T) {
return
}
for i, e := range ast {
if e != expectedAst[i] {
t.Error("Mismatch when lexing", name, "\nExpected:", expectedAst[i], "\nGot:", e)
if !reflect.DeepEqual(e, expectedAst[i]) {
t.Error(fmt.Sprintf("Expected: %q\nGot:%q", expectedAst[i], e))
}
}
}
}
contentsB, err := ioutil.ReadFile("testdata/test.gmi")
contentsB, err := ioutil.ReadFile("testdata/test.myco")
if err != nil {
t.Error("Could not read test gemtext file!")
t.Error("Could not read test markup file!")
}
contents := string(contentsB)
check("Apple", contents, []Line{
@ -41,17 +41,27 @@ func TestLex(t *testing.T) {
</ul>`},
{6, "<p id='6'>text</p>"},
{7, "<p id='7'>more text</p>"},
{8, `<p><a id='8' class='wikilink_internal' href="/page/Pear">some link</a></p>`},
{8, `<p><a id='8' class='rocketlink wikilink_internal' href="/page/Pear">some link</a></p>`},
{9, `<ul id='9'>
<li>li\n"+</li>
<li>lin&#34;+</li>
</ul>`},
{10, `<pre id='10' alt='alt text goes here' class='codeblock'><code>=&gt; preformatted text
where gemtext is not lexed</code></pre>`},
{11, `<p><a id='11' class='wikilink_internal' href="/page/linking">linking</a></p>`},
where markup is not lexed</code></pre>`},
{11, `<p><a id='11' class='rocketlink wikilink_internal' href="/page/linking">linking</a></p>`},
{12, "<p id='12'>text</p>"},
{13, `<pre id='13' alt='' class='codeblock'><code>()
/\</code></pre>`},
// More thorough testing of xclusions is done in xclusion_test.go
{14, Transclusion{"apple", 1, 3}},
{15, Img{
hyphaName: "Apple",
inDesc: false,
entries: []imgEntry{
{"/binary/hypha1", "", "", ""},
{"/binary/hypha2", "", "", ""},
{"/binary/hypha3", "60", "", ""},
{"/binary/hypha4", "", "", " line1\nline2\n"},
{"/binary/hypha5", "", "", "\nstate of minnesota\n"},
},
}},
})
}

49
markup/link.go Normal file
View File

@ -0,0 +1,49 @@
package markup
import (
"path"
"strings"
)
// LinkParts determines what href, text and class should resulting <a> have based on mycomarkup's addr, display and hypha name.
//
// => addr display
// [[addr|display]]
func LinkParts(addr, display, hyphaName string) (href, text, class string) {
if display == "" {
text = addr
} else {
text = strings.TrimSpace(display)
}
class = "wikilink_internal"
switch {
case strings.ContainsRune(addr, ':'):
return addr, text, "wikilink_external"
case strings.HasPrefix(addr, "/"):
return addr, text, class
case strings.HasPrefix(addr, "./"):
hyphaName = canonicalName(path.Join(hyphaName, addr[2:]))
case strings.HasPrefix(addr, "../"):
hyphaName = canonicalName(path.Join(path.Dir(hyphaName), addr[3:]))
default:
hyphaName = canonicalName(addr)
}
if !HyphaExists(hyphaName) {
class += " wikilink_new"
}
return "/page/" + hyphaName, text, class
}
// Parse markup line starting with "=>" according to wikilink rules.
// See http://localhost:1737/page/wikilink
func Rocketlink(src, hyphaName string) (href, text, class string) {
src = strings.TrimSpace(src[2:]) // Drop =>
if src == "" {
return
}
// Href is text after => till first whitespace
addr := strings.Fields(src)[0]
display := strings.TrimPrefix(src, addr)
return LinkParts(addr, display, hyphaName)
}

102
markup/mycomarkup.go Normal file
View File

@ -0,0 +1,102 @@
// This is not done yet
package markup
import (
"html"
"strings"
)
// A Mycomarkup-formatted document
type MycoDoc struct {
// data
hyphaName string
contents string
// state
recursionDepth int
// results
}
// Constructor
func Doc(hyphaName, contents string) *MycoDoc {
return &MycoDoc{
hyphaName: hyphaName,
contents: contents,
}
}
// AsHtml returns an html representation of the document
func (md *MycoDoc) AsHtml() string {
return ""
}
type BlockType int
const (
BlockH1 = iota
BlockH2
BlockH3
BlockH4
BlockH5
BlockH6
BlockRocket
BlockPre
BlockQuote
BlockPara
)
type CrawlWhere int
const (
inSomewhere = iota
inPre
inEnd
)
func crawl(name, content string) []string {
stateStack := []CrawlWhere{inSomewhere}
startsWith := func(token string) bool {
return strings.HasPrefix(content, token)
}
pop := func() {
stateStack = stateStack[:len(stateStack)-1]
}
push := func(s CrawlWhere) {
stateStack = append(stateStack, s)
}
readln := func(c string) (string, string) {
parts := strings.SplitN(c, "\n", 1)
return parts[0], parts[1]
}
preAcc := ""
line := ""
for {
switch stateStack[0] {
case inSomewhere:
switch {
case startsWith("```"):
push(inPre)
_, content = readln(content)
default:
}
case inPre:
switch {
case startsWith("```"):
pop()
_, content = readln(content)
default:
line, content = readln(content)
preAcc += html.EscapeString(line)
}
}
}
return []string{}
}

159
markup/paragraph.go Normal file
View File

@ -0,0 +1,159 @@
package markup
import (
"bytes"
"fmt"
"html"
"strings"
)
type spanTokenType int
const (
spanTextNode = iota
spanItalic
spanBold
spanMono
spanSuper
spanSub
spanMark
spanLink
)
func tagFromState(stt spanTokenType, tagState map[spanTokenType]bool, tagName, originalForm string) string {
if tagState[spanMono] && (stt != spanMono) {
return originalForm
}
if tagState[stt] {
tagState[stt] = false
return fmt.Sprintf("</%s>", tagName)
} else {
tagState[stt] = true
return fmt.Sprintf("<%s>", tagName)
}
}
func getLinkNode(input *bytes.Buffer, hyphaName string) string {
input.Next(2)
var (
escaping = false
addrBuf = bytes.Buffer{}
displayBuf = bytes.Buffer{}
currBuf = &addrBuf
)
for input.Len() != 0 {
b, _ := input.ReadByte()
if escaping {
currBuf.WriteByte(b)
escaping = false
} else if b == '|' && currBuf == &addrBuf {
currBuf = &displayBuf
} else if b == ']' && bytes.HasPrefix(input.Bytes(), []byte{']'}) {
input.Next(1)
break
} else {
currBuf.WriteByte(b)
}
}
href, text, class := LinkParts(addrBuf.String(), displayBuf.String(), hyphaName)
return fmt.Sprintf(`<a href="%s" class="%s">%s</a>`, href, class, html.EscapeString(text))
}
// getTextNode splits the `input` into two parts `textNode` and `rest` by the first encountered rune that resembles a span tag. If there is none, `textNode = input`, `rest = ""`. It handles escaping with backslash.
func getTextNode(input *bytes.Buffer) string {
var (
textNodeBuffer = bytes.Buffer{}
escaping = false
)
// Always read the first byte in advance to avoid endless loops that kill computers (sad experience)
if input.Len() != 0 {
b, _ := input.ReadByte()
textNodeBuffer.WriteByte(b)
}
for input.Len() != 0 {
// Assume no error is possible because we check for length
b, _ := input.ReadByte()
if escaping {
textNodeBuffer.WriteByte(b)
escaping = false
} else if b == '\\' {
escaping = true
} else if strings.IndexByte("/*`^,![", b) >= 0 {
input.UnreadByte()
break
} else {
textNodeBuffer.WriteByte(b)
}
}
return textNodeBuffer.String()
}
func ParagraphToHtml(hyphaName, input string) string {
var (
p = bytes.NewBufferString(input)
ret strings.Builder
// true = tag is opened, false = tag is not opened
tagState = map[spanTokenType]bool{
spanItalic: false,
spanBold: false,
spanMono: false,
spanSuper: false,
spanSub: false,
spanMark: false,
spanLink: false,
}
startsWith = func(t string) bool {
return bytes.HasPrefix(p.Bytes(), []byte(t))
}
)
for p.Len() != 0 {
switch {
case startsWith("//"):
ret.WriteString(tagFromState(spanItalic, tagState, "em", "//"))
p.Next(2)
case startsWith("**"):
ret.WriteString(tagFromState(spanBold, tagState, "strong", "**"))
p.Next(2)
case startsWith("`"):
ret.WriteString(tagFromState(spanMono, tagState, "code", "`"))
p.Next(1)
case startsWith("^"):
ret.WriteString(tagFromState(spanSuper, tagState, "sup", "^"))
p.Next(1)
case startsWith(",,"):
ret.WriteString(tagFromState(spanSub, tagState, "sub", ",,"))
p.Next(2)
case startsWith("!!"):
ret.WriteString(tagFromState(spanMark, tagState, "mark", "!!"))
p.Next(2)
case startsWith("[["):
ret.WriteString(getLinkNode(p, hyphaName))
default:
ret.WriteString(html.EscapeString(getTextNode(p)))
}
}
for stt, open := range tagState {
if open {
switch stt {
case spanItalic:
ret.WriteString(tagFromState(spanItalic, tagState, "em", "//"))
case spanBold:
ret.WriteString(tagFromState(spanBold, tagState, "strong", "**"))
case spanMono:
ret.WriteString(tagFromState(spanMono, tagState, "code", "`"))
case spanSuper:
ret.WriteString(tagFromState(spanSuper, tagState, "sup", "^"))
case spanSub:
ret.WriteString(tagFromState(spanSub, tagState, "sub", ",,"))
case spanMark:
ret.WriteString(tagFromState(spanMark, tagState, "mark", "!!"))
case spanLink:
ret.WriteString(tagFromState(spanLink, tagState, "a", "[["))
}
}
}
return ret.String()
}

49
markup/paragraph_test.go Normal file
View File

@ -0,0 +1,49 @@
package markup
import (
"fmt"
"testing"
)
/*
func TestGetTextNode(t *testing.T) {
tests := [][]string{
// input textNode rest
{"barab", "barab", ""},
{"test, ", "test", ", "},
{"/test/", "", "/test/"},
{"\\/test/", "/test", "/"},
{"test \\/ar", "test /ar", ""},
{"test //italian// test", "test ", "//italian// test"},
}
for _, triplet := range tests {
a, b := getTextNode([]byte(triplet[0]))
if a != triplet[1] || string(b) != triplet[2] {
t.Error(fmt.Sprintf("Wanted: %q\nGot: %q %q", triplet, a, b))
}
}
}
*/
func TestParagraphToHtml(t *testing.T) {
tests := [][]string{
{"a simple paragraph", "a simple paragraph"},
{"//italic//", "<em>italic</em>"},
{"Embedded //italic//", "Embedded <em>italic</em>"},
{"double //italian// //text//", "double <em>italian</em> <em>text</em>"},
{"it has `mono`", "it has <code>mono</code>"},
{"this is a left **bold", "this is a left <strong>bold</strong>"},
{"this line has a ,comma, two of them", "this line has a ,comma, two of them"},
{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."},
{"A [[simple]] link", `A <a href="/page/simple" class="wikilink_internal">simple</a> link`},
}
for _, test := range tests {
if ParagraphToHtml("Apple", test[0]) != test[1] {
t.Error(fmt.Sprintf("%q: Wanted %q, got %q", test[0], test[1], ParagraphToHtml("Apple", test[0])))
}
}
}
func init() {
HyphaExists = func(_ string) bool { return true }
}

View File

@ -1,4 +1,4 @@
package gemtext
package markup
import ()
@ -13,10 +13,12 @@ func Parse(ast []Line, from, to int, state GemParserState) (html string) {
return "Transclusion depth limit"
}
for _, line := range ast {
if line.id >= from && (line.id <= to || to == 0) {
if line.id >= from && (line.id <= to || to == 0) || line.id == -1 {
switch v := line.contents.(type) {
case Transclusion:
html += Transclude(v, state)
case Img:
html += v.ToHtml()
case string:
html += v
}

View File

@ -12,7 +12,7 @@ more text
* li\n"+
```alt text goes here
=> preformatted text
where gemtext is not lexed
where markup is not lexed
```it ends here"
=>linking
@ -22,3 +22,17 @@ text
/\
```
<= Apple : 1..3
img {
hypha1
hypha2|
hypha3| 60
hypha4| { line1
line2
} this is ignored
hypha5| {
state of minnesota
}
}

View File

@ -1,4 +1,4 @@
package gemtext
package markup
import (
"strings"

View File

@ -1,4 +1,4 @@
package gemtext
package markup
import (
"fmt"
@ -9,7 +9,7 @@ import (
const xclError = -9
// Transclusion is used by gemtext parser to remember what hyphae shall be transcluded.
// Transclusion is used by markup parser to remember what hyphae shall be transcluded.
type Transclusion struct {
name string
from int // inclusive

View File

@ -1,4 +1,4 @@
package gemtext
package markup
import (
"testing"
@ -6,7 +6,7 @@ import (
func TestParseTransclusion(t *testing.T) {
check := func(line string, expectedXclusion Transclusion) {
if xcl := parseTransclusion(line); xcl != expectedXclusion {
if xcl := parseTransclusion(line, "t"); xcl != expectedXclusion {
t.Error(line, "; got:", xcl, "wanted:", expectedXclusion)
}
}

@ -1 +1 @@
Subproject commit 2c0e43199ed28f7022a38463a0eec3af3ecb03c9
Subproject commit d78a90718ae9c36f7a114901ddad84b0e23221b3

140
mime.go
View File

@ -5,104 +5,58 @@ import (
"strings"
)
// TextType is content type of text part of a hypha.
type TextType int
const (
// TextPlain is default text content type.
TextPlain TextType = iota
// TextGemini is content type for MycorrhizaWiki's dialect of gemtext.
TextGemini
)
// Mime returns mime type representation of `t`.
func (t TextType) Mime() string {
return [...]string{"text/plain", "text/gemini"}[t]
}
// Extension returns extension (with dot) to be used for files with content type `t`.
func (t TextType) Extension() string {
return [...]string{".txt", ".gmi"}[t]
}
// BinaryType is content type of binary part of a hypha.
type BinaryType int
// Supported binary content types
const (
// BinaryOctet is default binary content type.
BinaryOctet BinaryType = iota
BinaryJpeg
BinaryGif
BinaryPng
BinaryWebp
BinarySvg
BinaryIco
BinaryOgg
BinaryWebm
BinaryMp3
BinaryMp4
)
var binaryMimes = [...]string{
"application/octet-stream",
"image/jpeg", "image/gif", "image/png", "image/webp",
"image/svg+xml", "image/x-icon",
"application/ogg", "video/webm", "audio/mp3", "video/mp4",
}
// Mime returns mime type representation of `t`.
func (t BinaryType) Mime() string {
return binaryMimes[t]
}
var binaryExtensions = [...]string{
".bin", ".jpg", ".gif", ".png", ".webp", ".svg", ".ico",
".ogg", ".webm", ".mp3", ".mp4",
}
// Extension returns extension (with dot) to be used for files with content type `t`.
func (t BinaryType) Extension() string {
return binaryExtensions[t]
}
// MimeToBinaryType converts mime type to BinaryType. If the mime type is not supported, BinaryOctet is returned as a fallback type.
func MimeToBinaryType(mime string) BinaryType {
for i, binaryMime := range binaryMimes {
if binaryMime == mime {
return BinaryType(i)
}
func MimeToExtension(mime string) string {
mm := map[string]string{
"application/octet-stream": "bin",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/png": "png",
"image/webp": "webp",
"image/svg+xml": "svg",
"image/x-icon": "ico",
"application/ogg": "ogg",
"video/webm": "webm",
"audio/mp3": "mp3",
"video/mp4": "mp4",
}
return BinaryOctet
if ext, ok := mm[mime]; ok {
return "." + ext
}
return ".bin"
}
func ExtensionToMime(ext string) string {
mm := map[string]string{
".bin": "application/octet-stream",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".png": "image/png",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".ogg": "application/ogg",
".webm": "video/webm",
".mp3": "audio/mp3",
".mp4": "video/mp4",
}
if mime, ok := mm[ext]; ok {
return mime
}
return "application/octet-stream"
}
// DataFromFilename fetches all meta information from hypha content file with path `fullPath`. If it is not a content file, `skip` is true, and you are expected to ignore this file when indexing hyphae. `name` is name of the hypha to which this file relates. `isText` is true when the content file is text, false when is binary. `mimeId` is an integer representation of content type. Cast it to TextType if `isText == true`, cast it to BinaryType if `isText == false`.
func DataFromFilename(fullPath string) (skip bool, name string, isText bool, mimeId int) {
func DataFromFilename(fullPath string) (name string, isText bool, skip bool) {
shortPath := strings.TrimPrefix(fullPath, WikiDir)[1:]
// Special files start with &
// &. is used in normal hypha part names
if shortPath[0] == '&' || strings.LastIndex(shortPath, "&.") < 0 {
skip = true
return
}
ext := filepath.Ext(shortPath)
name = strings.TrimSuffix(shortPath, "&"+ext)
isText, mimeId = mimeData(ext)
name = CanonicalName(strings.TrimSuffix(shortPath, ext))
switch ext {
case ".myco":
isText = true
case "", shortPath:
skip = true
}
return
}
// mimeData determines what content type file has judging by its `ext`ension. `itText` and `mimeId` are the same as in DataFromFilename.
func mimeData(ext string) (isText bool, mimeId int) {
switch ext {
case ".txt":
return true, int(TextPlain)
case ".gmi":
return true, int(TextGemini)
}
for i, binExt := range binaryExtensions {
if ext == binExt {
return false, i
}
}
return false, 0
}

View File

@ -4,6 +4,8 @@ import (
"fmt"
"net/http"
"strings"
"github.com/bouncepaw/mycorrhiza/util"
)
// isCanonicalName checks if the `name` is canonical.
@ -17,16 +19,17 @@ func CanonicalName(name string) string {
}
// naviTitle turns `canonicalName` into html string with each hypha path parts higlighted as links.
// TODO: rework as a template
func naviTitle(canonicalName string) string {
var (
html = `<h1 class="navi-title" id="0">
<a href="/">🍄</a>`
html = fmt.Sprintf(`<h1 class="navi-title" id="navi-title">
<a href="/page/%s">%s</a>`, util.HomePage, util.SiteTitle)
prevAcc = `/page/`
parts = strings.Split(canonicalName, "/")
)
for _, part := range parts {
html += fmt.Sprintf(`
<span>/</span>
<span aria-hidden="true">/</span>
<a href="%s">%s</a>`,
prevAcc+part,
strings.Title(part))

48
templates/css.qtpl Normal file
View File

@ -0,0 +1,48 @@
{% func DefaultCSS() %}
@media screen and (min-width: 700px) {
main {margin: 0 auto; width: 700px;}
}
@media screen and (max-width: 700px) {
main {margin: 0; width: 100%;}
}
*, *::before, *::after {box-sizing: border-box;}
html {height:100%; padding:0; background-color:#ddd;
background-image: url("data:image/svg+xml,%3Csvg width='42' height='44' viewBox='0 0 42 44' xmlns='http://www.w3.org/2000/svg'%3E%3Cg id='Page-1' fill='none' fill-rule='evenodd'%3E%3Cg id='brick-wall' fill='%23bbbbbb' fill-opacity='0.4'%3E%3Cpath d='M0 0h42v44H0V0zm1 1h40v20H1V1zM0 23h20v20H0V23zm22 0h20v20H22V23z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");} /* heropatterns.com */
body {height:100%; margin:0; font-size:16px; font-family:sans-serif;}
main {padding:1rem; background-color: white; box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.2); }
main > form {margin-bottom:1rem;}
textarea {font-size:15px;}
.edit {height:100%;}
.edit-form {height:90%;}
.edit-form textarea {width:100%;height:90%;}
main h1:not(.navi-title) {font-size:1.7rem;}
blockquote {border-left: 4px black solid; margin-left: 0; padding-left: 1rem;}
.wikilink_new {color:#a55858;}
.wikilink_new:visited {color:#a55858;}
.wikilink_external::after {content:"🌐"; margin-left: .5rem; font-size: small; text-decoration: none; align: bottom;}
p code {background-color:#eee; padding: .1rem .3rem; border-radius: .25rem; font-size: 90%; }
.codeblock {background-color:#eee; padding:.5rem; font-size:16px; white-space: pre-wrap;}
.transclusion code, .transclusion .codeblock {background-color:#ddd;}
.transclusion {background-color:#eee; }
.transclusion__content > *:not(.binary-container) {margin: 0.5rem; }
.transclusion__link {display: block; text-align: right; font-style: italic; margin-top: 0.5rem; color: black; text-decoration: none;}
.transclusion__link::before {content: "⇐ ";}
.binary-container_with-img img,
.binary-container_with-video video,
.binary-container_with-audio audio {width: 100%}
.navi-title a {text-decoration:none;}
.img-gallery img { max-width: 100%; }
figure { margin: 0; }
nav ul {display:flex; padding-left:0; flex-wrap:wrap; margin-top:0;}
nav ul li {list-style-type:none;margin-right:1rem;}
#new-name {width:100%;}
.rc-entry { display: grid; list-style-type: none; padding: .25rem; background-color: #eee; grid-template-columns: 1fr 1fr; }
.rc-entry__time { font-style: italic; }
.rc-entry__hash { font-style: italic; text-align: right; }
.rc-entry__links { grid-column: 1 / span 2; }
{% endfunc %}

98
templates/css.qtpl.go Normal file
View File

@ -0,0 +1,98 @@
// Code generated by qtc from "css.qtpl". DO NOT EDIT.
// See https://github.com/valyala/quicktemplate for details.
//line templates/css.qtpl:1
package templates
//line templates/css.qtpl:1
import (
qtio422016 "io"
qt422016 "github.com/valyala/quicktemplate"
)
//line templates/css.qtpl:1
var (
_ = qtio422016.Copy
_ = qt422016.AcquireByteBuffer
)
//line templates/css.qtpl:1
func StreamDefaultCSS(qw422016 *qt422016.Writer) {
//line templates/css.qtpl:1
qw422016.N().S(`
@media screen and (min-width: 700px) {
main {margin: 0 auto; width: 700px;}
}
@media screen and (max-width: 700px) {
main {margin: 0; width: 100%;}
}
*, *::before, *::after {box-sizing: border-box;}
html {height:100%; padding:0; background-color:#ddd;
background-image: url("data:image/svg+xml,%3Csvg width='42' height='44' viewBox='0 0 42 44' xmlns='http://www.w3.org/2000/svg'%3E%3Cg id='Page-1' fill='none' fill-rule='evenodd'%3E%3Cg id='brick-wall' fill='%23bbbbbb' fill-opacity='0.4'%3E%3Cpath d='M0 0h42v44H0V0zm1 1h40v20H1V1zM0 23h20v20H0V23zm22 0h20v20H22V23z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");} /* heropatterns.com */
body {height:100%; margin:0; font-size:16px; font-family:sans-serif;}
main {padding:1rem; background-color: white; box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.2); }
main > form {margin-bottom:1rem;}
textarea {font-size:15px;}
.edit {height:100%;}
.edit-form {height:90%;}
.edit-form textarea {width:100%;height:90%;}
main h1:not(.navi-title) {font-size:1.7rem;}
blockquote {border-left: 4px black solid; margin-left: 0; padding-left: 1rem;}
.wikilink_new {color:#a55858;}
.wikilink_new:visited {color:#a55858;}
.wikilink_external::after {content:"🌐"; margin-left: .5rem; font-size: small; text-decoration: none; align: bottom;}
p code {background-color:#eee; padding: .1rem .3rem; border-radius: .25rem; font-size: 90%; }
.codeblock {background-color:#eee; padding:.5rem; font-size:16px; white-space: pre-wrap;}
.transclusion code, .transclusion .codeblock {background-color:#ddd;}
.transclusion {background-color:#eee; }
.transclusion__content > *:not(.binary-container) {margin: 0.5rem; }
.transclusion__link {display: block; text-align: right; font-style: italic; margin-top: 0.5rem; color: black; text-decoration: none;}
.transclusion__link::before {content: "⇐ ";}
.binary-container_with-img img,
.binary-container_with-video video,
.binary-container_with-audio audio {width: 100%}
.navi-title a {text-decoration:none;}
.img-gallery img { max-width: 100%; }
figure { margin: 0; }
nav ul {display:flex; padding-left:0; flex-wrap:wrap; margin-top:0;}
nav ul li {list-style-type:none;margin-right:1rem;}
#new-name {width:100%;}
.rc-entry { display: grid; list-style-type: none; padding: .25rem; background-color: #eee; grid-template-columns: 1fr 1fr; }
.rc-entry__time { font-style: italic; }
.rc-entry__hash { font-style: italic; text-align: right; }
.rc-entry__links { grid-column: 1 / span 2; }
`)
//line templates/css.qtpl:48
}
//line templates/css.qtpl:48
func WriteDefaultCSS(qq422016 qtio422016.Writer) {
//line templates/css.qtpl:48
qw422016 := qt422016.AcquireWriter(qq422016)
//line templates/css.qtpl:48
StreamDefaultCSS(qw422016)
//line templates/css.qtpl:48
qt422016.ReleaseWriter(qw422016)
//line templates/css.qtpl:48
}
//line templates/css.qtpl:48
func DefaultCSS() string {
//line templates/css.qtpl:48
qb422016 := qt422016.AcquireByteBuffer()
//line templates/css.qtpl:48
WriteDefaultCSS(qb422016)
//line templates/css.qtpl:48
qs422016 := string(qb422016.B)
//line templates/css.qtpl:48
qt422016.ReleaseByteBuffer(qb422016)
//line templates/css.qtpl:48
return qs422016
//line templates/css.qtpl:48
}

View File

@ -1,8 +1,8 @@
{% func EditHTML(hyphaName, textAreaFill, warning string) %}
<main>
<main class="edit">
<h1>Edit {%s hyphaName %}</h1>
{%s= warning %}
<form method="post" class="upload-text-form"
<form method="post" class="edit-form"
action="/upload-text/{%s hyphaName %}">
<textarea name="text">{%s textAreaFill %}</textarea>
<br/>

View File

@ -21,7 +21,7 @@ var (
func StreamEditHTML(qw422016 *qt422016.Writer, hyphaName, textAreaFill, warning string) {
//line templates/http_mutators.qtpl:1
qw422016.N().S(`
<main>
<main class="edit">
<h1>Edit `)
//line templates/http_mutators.qtpl:3
qw422016.E().S(hyphaName)
@ -32,7 +32,7 @@ func StreamEditHTML(qw422016 *qt422016.Writer, hyphaName, textAreaFill, warning
qw422016.N().S(warning)
//line templates/http_mutators.qtpl:4
qw422016.N().S(`
<form method="post" class="upload-text-form"
<form method="post" class="edit-form"
action="/upload-text/`)
//line templates/http_mutators.qtpl:6
qw422016.E().S(hyphaName)

View File

@ -1,6 +1,7 @@
{% func RecentChangesHTML(changes []string, n int) %}
<main class="recent-changes">
<h1>Recent Changes</h1>
<p><a href="/">← Back</a></p>
<nav class="recent-changes__count">
See
@ -29,7 +30,7 @@
<p>Could not find any recent changes.</p>
{% else %}
{% for i, entry := range changes %}
<ul class="recent-changes__entry" role="article"
<ul class="recent-changes__entry rc-entry" role="article"
aria-setsize="{%d n %}" aria-posinset="{%d i %}">
{%s= entry %}
</ul>

View File

@ -23,134 +23,135 @@ func StreamRecentChangesHTML(qw422016 *qt422016.Writer, changes []string, n int)
qw422016.N().S(`
<main class="recent-changes">
<h1>Recent Changes</h1>
<p><a href="/"> Back</a></p>
<nav class="recent-changes__count">
See
`)
//line templates/recent_changes.qtpl:7
//line templates/recent_changes.qtpl:8
for _, m := range []int{20, 0, 50, 0, 100} {
//line templates/recent_changes.qtpl:7
//line templates/recent_changes.qtpl:8
qw422016.N().S(`
`)
//line templates/recent_changes.qtpl:8
//line templates/recent_changes.qtpl:9
switch m {
//line templates/recent_changes.qtpl:9
//line templates/recent_changes.qtpl:10
case 0:
//line templates/recent_changes.qtpl:9
//line templates/recent_changes.qtpl:10
qw422016.N().S(`
<span aria-hidden="true">|</span>
`)
//line templates/recent_changes.qtpl:11
//line templates/recent_changes.qtpl:12
case n:
//line templates/recent_changes.qtpl:11
//line templates/recent_changes.qtpl:12
qw422016.N().S(`
<b>`)
//line templates/recent_changes.qtpl:12
//line templates/recent_changes.qtpl:13
qw422016.N().D(n)
//line templates/recent_changes.qtpl:12
//line templates/recent_changes.qtpl:13
qw422016.N().S(`</b>
`)
//line templates/recent_changes.qtpl:13
//line templates/recent_changes.qtpl:14
default:
//line templates/recent_changes.qtpl:13
//line templates/recent_changes.qtpl:14
qw422016.N().S(`
<a href="/recent-changes/`)
//line templates/recent_changes.qtpl:14
//line templates/recent_changes.qtpl:15
qw422016.N().D(m)
//line templates/recent_changes.qtpl:14
//line templates/recent_changes.qtpl:15
qw422016.N().S(`">`)
//line templates/recent_changes.qtpl:14
//line templates/recent_changes.qtpl:15
qw422016.N().D(m)
//line templates/recent_changes.qtpl:14
//line templates/recent_changes.qtpl:15
qw422016.N().S(`</a>
`)
//line templates/recent_changes.qtpl:15
//line templates/recent_changes.qtpl:16
}
//line templates/recent_changes.qtpl:15
//line templates/recent_changes.qtpl:16
qw422016.N().S(`
`)
//line templates/recent_changes.qtpl:16
//line templates/recent_changes.qtpl:17
}
//line templates/recent_changes.qtpl:16
//line templates/recent_changes.qtpl:17
qw422016.N().S(`
recent changes
</nav>
`)
//line templates/recent_changes.qtpl:25
//line templates/recent_changes.qtpl:26
qw422016.N().S(`
<section class="recent-changes__list" role="feed">
`)
//line templates/recent_changes.qtpl:28
//line templates/recent_changes.qtpl:29
if len(changes) == 0 {
//line templates/recent_changes.qtpl:28
//line templates/recent_changes.qtpl:29
qw422016.N().S(`
<p>Could not find any recent changes.</p>
`)
//line templates/recent_changes.qtpl:30
//line templates/recent_changes.qtpl:31
} else {
//line templates/recent_changes.qtpl:30
//line templates/recent_changes.qtpl:31
qw422016.N().S(`
`)
//line templates/recent_changes.qtpl:31
//line templates/recent_changes.qtpl:32
for i, entry := range changes {
//line templates/recent_changes.qtpl:31
//line templates/recent_changes.qtpl:32
qw422016.N().S(`
<ul class="recent-changes__entry" role="article"
<ul class="recent-changes__entry rc-entry" role="article"
aria-setsize="`)
//line templates/recent_changes.qtpl:33
//line templates/recent_changes.qtpl:34
qw422016.N().D(n)
//line templates/recent_changes.qtpl:33
//line templates/recent_changes.qtpl:34
qw422016.N().S(`" aria-posinset="`)
//line templates/recent_changes.qtpl:33
//line templates/recent_changes.qtpl:34
qw422016.N().D(i)
//line templates/recent_changes.qtpl:33
//line templates/recent_changes.qtpl:34
qw422016.N().S(`">
`)
//line templates/recent_changes.qtpl:34
//line templates/recent_changes.qtpl:35
qw422016.N().S(entry)
//line templates/recent_changes.qtpl:34
//line templates/recent_changes.qtpl:35
qw422016.N().S(`
</ul>
`)
//line templates/recent_changes.qtpl:36
//line templates/recent_changes.qtpl:37
}
//line templates/recent_changes.qtpl:36
//line templates/recent_changes.qtpl:37
qw422016.N().S(`
`)
//line templates/recent_changes.qtpl:37
//line templates/recent_changes.qtpl:38
}
//line templates/recent_changes.qtpl:37
//line templates/recent_changes.qtpl:38
qw422016.N().S(`
</section>
</main>
`)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
}
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
func WriteRecentChangesHTML(qq422016 qtio422016.Writer, changes []string, n int) {
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
qw422016 := qt422016.AcquireWriter(qq422016)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
StreamRecentChangesHTML(qw422016, changes, n)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
qt422016.ReleaseWriter(qw422016)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
}
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
func RecentChangesHTML(changes []string, n int) string {
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
qb422016 := qt422016.AcquireByteBuffer()
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
WriteRecentChangesHTML(qb422016, changes, n)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
qs422016 := string(qb422016.B)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
qt422016.ReleaseByteBuffer(qb422016)
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
return qs422016
//line templates/recent_changes.qtpl:40
//line templates/recent_changes.qtpl:41
}

View File

@ -5,7 +5,12 @@ import (
"strings"
)
var WikiDir string
var (
ServerPort string
HomePage string
SiteTitle string
WikiDir string
)
// ShorterPath is used by handlerList to display shorter path to the files. It simply strips WikiDir.
func ShorterPath(path string) string {