Skip to content

Commit

Permalink
Merge pull request #7 from gwynforthewyn/main
Browse files Browse the repository at this point in the history
grouping pages by directory
  • Loading branch information
gwynforthewyn committed Aug 17, 2024
2 parents 2596793 + a9bc765 commit 4a51508
Show file tree
Hide file tree
Showing 4 changed files with 308 additions and 107 deletions.
115 changes: 97 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
# andrew
Andrew is a web server like 90s web servers used to be™. It renders web pages from the file system, no databases
involved. This is the basic design restriction that informs feature decisions. You get started by writing an "index.html"
file and then running Andrew from that directory.

I wanted an http server that allows me to add a simple annotation into an index.html that is replaced
I wanted an http server that allows me to add a simple go template instruction into an index.html that is replaced
with the contents of any html files that are below the current index.html in the file system hierarchy.

It's grown a little to include a small sitemap generator.
Andrew contains the concept of an AndrewPage. This structure makes various pieces of metadata stored within your web page
available to Andrew for creating links and sorting pages in the various tables of contents available (see below). The specifics
are explained below, but conceptually I'm trying to use standard html elements to inform Andrew about site metadata. For more
you may want to check the [Architecture.md](./ARCHITECTURE.md)

Andrew includes a simple sitemap generator. Your new website needs some way to establish its identity with search engines,
after all.

## To install it

Expand All @@ -24,8 +33,50 @@ address is the address you want to bind the server to. Specify as an address:por
baseUrl is the hostname you're serving from. This is a part of sitemaps and future rss feeds. It also contains the protocol
e.g. `https://playtechnique.io`

It, unfortunately, does not terminate SSL at this point. If you include a copy of Nginx as a reverse proxy, as is standard
in kubernetes, nginx can terminate SSL for you. I'll get to SSL, just be patient with me ❤️

# Feature Specifics
## Andrew's Custom Page Elements
### Valid Go Template Instructions for Rendering Page Structures
```text
.AndrewTableOfContents
.AndrewTableOfContentsWithDirectories
```
These are for generating lists of web pages that exist at the same level in the file system as the web page and in child directories.

Andrew sorts by page publish date. This publish date is tricky for a file-based web server to get consistent, so here's the rules:
1. If you have the tag `<meta name="andrew-publish-time" content="YYYY-MM-DD"/>`, Andrew uses this date.
2. Andrew uses the page's mtime. This means that if you edit a page that does not contain the `andrew-publish-time` element, then you will push it back to the top of the list.

If your page contains an `andrew-publish-time` meta element, the time must be formatted in YYYY-MM-DD format. Minutes and hours aren't supported yet.
I don't write a lot, so I don't need granularity beyond a single day. Adding finer granularity isn't hard; feel free to ask for it or write a PR.


### Semantically Meaningful Andrew-specific HTML elements
```html
<meta name="andrew-publish-time" content="YYYY-MM-DD"/>
<title>Your page title</title>
```

All `meta` elements are actually parsed in the [Andrew Page](./page.go), but Andrew doesn't use a lot of them just yet.

### Custom CSS IDs and classes
I've tried to consistently include the string `andrew` in front of any CSS classes or IDs, so they're less likely to
clash with your whimsy for laying out your own site.

The reason these classes and IDs exist is simple: it makes it easier for you to style Andrew's unstyled HTML. I don't want
Andrew making decisions about your website's layout.

I include classes and IDs that get my sites looking how I want. If you need more, file a request.

### How does the .AndrewTableOfContents render?
AndrewTableOfContents is for rendering a table of contents of the pages beneath the current page. It only lists page links.
If you want your links grouped by directories, check out `.AndrewTableOfContentsWithDirectories`.

This is handled in linksbuilder.go. I try to keep this README up to date, but if seems like it doesn't sync with reality
the final word is the source code.

## rendering the .AndrewTableOfContents
Given this file system structure:
```text
index.html
Expand All @@ -47,37 +98,65 @@ fanfics/
if articles/index.html contains `{{ .AndrewTableOfContents }}` anywhere, that will be replaced with:

```html
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="article-1.html">article 1</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="article-2.html">article 2</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="article-1.html">article 1</a> - <span class=\"publish-date\">0000-00-01</span></li>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="article-2.html">article 2</a> - <span class=\"publish-date\">0000-00-01</span></li>
```

if fanfics/index.html contains `{{ .AndrewTableOfContents }}`, that'll be replaced with:

```html
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="story-1/potter-and-draco.html">Potter and Draco</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="story-2/what-if-elves-rode-mice-pt1.html">what-if-elves-rode-mice-pt1.html</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink2" href="story-2/what-if-elves-rode-mice-pt1.html">what-if-elves-rode-mice-pt2.html</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="story-1/potter-and-draco.html">Potter and Draco</a> - <span class=\"publish-date\">0000-00-01</span></li>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="story-2/what-if-elves-rode-mice-pt1.html">what-if-elves-rode-mice-pt1.html</a> - <span class=\"andrew-page-publish-date\">0000-00-01</span></li>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink2" href="story-2/what-if-elves-rode-mice-pt1.html">what-if-elves-rode-mice-pt2.html</a> - <span class=\"andrew-page-publish-date\">0000-00-01</span></li>
```


## how is the .AndrewTableOfContentsWithDirectories rendered?
Given this file system structure:
```text
groupedContents.html
articles/
index.html #this will be ignored. index.html normally contains its own listing of pages, but this is already a page list.
article-1.html
article-2.css #this will be ignored; Andrew only links to html files.
articles-series/
dragons-are-lovely.html
dragons-are-fierce.html
```
if index.html contains `{{ .AndrewTableOfContentsWithDirectories }}` anywhere, that will be replaced with a `<div>` called AndrewTableOfContentsWithDirectories.
Inside the `<div>` is a decent representation of all of your content:

```html
<div class="AndrewTableOfContentsWithDirectories">
<ul>
<h5>articles/</h5>
<li><a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="articles/article-1.html">article-1.html</a> - <span class="andrew-page-publish-date">0001-01-01</span></li>
</ul>

<ul>
<h5><span class="AndrewParentDir">articles/</span>articles-series/</h5>
<li><a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="articles/articles-series/dragons-are-lovely.html">dragons-are-lovely.html</a> - <span class="andrew-page-publish-date">0001-01-01</span></li>
<li><a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="articles/articles-series/dragons-are-fierce.html">dragons-are-fierce.html</a> - <span class="andrew-page-publish-date">0001-01-01</span></li>
</ul>

</div>
```

Note the inclusion of a `<span>` around the name of the parent directory. The parent directory name is a bit repetitive, so I wanted to
be able to style it to not draw attention to it.

If the above seems out of sync with reality, the easiest place to get a canonical representation of what Andrew's building will be
in [linksbuilder_test.go](./linksbuilder_test.go)

## page titles
If a page contains a `<title>` element, Andrew picks it up and uses that as the name of a link.
If the page does not contain a `<title>` element, then Andrew will use the file name of that file as the link name.

## meta elements
Andrew parses meta tags and makes them accessible on its AndrewPage object.

For a meta element to be picked up, it must be formatted with andrew- prepending the meta element's name, like this `<meta name="andrew-<rest of the name>" value="your-value">`

### valid meta elements
<meta name="andrew-publish-time" value="2024-03-12">
<meta name="andrew-tag" value="diary entry">

## ordering of pages
If a page contains the meta element `<meta name=andrew-publish-time value="2024-03-12">` in its `<head>`, Andrew orders on these tags.
If the page does not contain the meta element, it uses the mtime of the file to try and determine ordering. This means that if you edit a page
that does not contain the `andrew-publish-time` element, then you will push it back to the top of the list.

If your page contains an `andrew-publish-time` meta element, the time must be formatted in YYYY-MM-DD format. Minutes and hours aren't supported yet.

## sitemap.xml
When the endpoint `baseUrl/sitemap.xml` is visited, Andrew will automatically generate a sitemap containing paths to all html pages.
Expand Down
8 changes: 3 additions & 5 deletions andrew_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,13 +422,11 @@ func TestMainCalledWithInvalidAddressPanics(t *testing.T) {
// what file attribute andrew is actually sorting on.
func TestArticlesInAndrewTableOfContentsAreDefaultSortedByModTime(t *testing.T) {

expected, err := regexp.Compile(`.*b_newer.*a_older.*`)
expectedOrder, err := regexp.Compile(`(?s).*b_newer.*a_older.*`)

if err != nil {
t.Fatal(err)
}
// expected := `<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="b_newer.html">b_newer.html</a>` +
// `<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="a_older.html">a_older.html</a>`

contentRoot := t.TempDir()

Expand Down Expand Up @@ -465,8 +463,8 @@ func TestArticlesInAndrewTableOfContentsAreDefaultSortedByModTime(t *testing.T)

received := page.Content

if expected.FindString(received) == "" {
t.Fatalf(cmp.Diff(expected, received))
if expectedOrder.FindString(received) == "" {
t.Fatalf(cmp.Diff(expectedOrder, received))
}

}
Expand Down
130 changes: 125 additions & 5 deletions linksbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ package andrew
import (
"bytes"
"fmt"
"path"
"regexp"
"sort"
"strings"
"text/template"
"time"
)
Expand All @@ -13,20 +17,136 @@ import (
// as a list of html links to those pages.
func RenderTemplates(siblings []Page, startingPage Page) ([]byte, error) {

tableOfContents, err := regexp.Compile(`.*{{\s*\.AndrewTableOfContents\s*}}.*`)
if err != nil {
return nil, err
}

if tableOfContents.FindString(startingPage.Content) != "" {
return renderAndrewTableOfContents(siblings, startingPage)
}

tableOfContentsWithDirs, err := regexp.Compile(`.*{{\s*\.AndrewTableOfContentsWithDirectories\s*}}.*`)
if err != nil {
return nil, err
}

if tableOfContentsWithDirs.FindString(startingPage.Content) != "" {
return renderAndrewTableOfContentsWithDirectories(siblings, startingPage)
}

return []byte(startingPage.Content), nil
}

func countSlashes(s string) int {
return strings.Count(s, "/")
}

func renderAndrewTableOfContentsWithDirectories(siblings []Page, startingPage Page) ([]byte, error) {
var html bytes.Buffer
var templateBuffer bytes.Buffer
directoriesAndContents := mapFromPagePaths(siblings)

directoriesInDepthOrder := keysOrderedByNumberOfSlashes(directoriesAndContents)
linkCount := 0

html.Write([]byte("<div class=\"AndrewTableOfContentsWithDirectories\">\n"))

for _, parentDir := range directoriesInDepthOrder {
// Skip the root directory if it only contains the starting page
if parentDir == "" && len(directoriesAndContents[parentDir]) == 1 && directoriesAndContents[parentDir][0] == startingPage {
continue
}

// Start the list for the directory
html.Write([]byte("<ul>\n"))

// Add the directory heading inside the <ul>
if parentDir != "" {
if countSlashes(parentDir) == 1 {
html.Write([]byte("<h5>" + parentDir + "</h5>\n"))
} else {
dirs := strings.Split(parentDir, "/")
html.Write([]byte("<h5><span class=\"AndrewParentDir\">" + dirs[0] + "/</span>" + strings.Join(dirs[1:], "/") + "</h5>\n"))
}
}

// Add the links to the list
for _, sibling := range directoriesAndContents[parentDir] {
// Skip the starting page
if sibling == startingPage {
continue
}
html.Write(buildAndrewTableOfContentsLink(sibling.UrlPath, sibling.Title, sibling.PublishTime.Format(time.DateOnly), linkCount))
linkCount++
}

html.Write([]byte("</ul>\n"))
}

html.Write([]byte("</div>\n"))

t, err := template.New(startingPage.UrlPath).Parse(startingPage.Content)
if err != nil {
panic(err)
}

err = t.Execute(&templateBuffer, map[string]string{"AndrewTableOfContentsWithDirectories": html.String()})
if err != nil {
return templateBuffer.Bytes(), err
}

return templateBuffer.Bytes(), nil
}

// mapFromPagePaths takes an array of pages and returns a map of those pages in which the keys
// are the directories containing a specific page and the value is the path inside the directory
// to that page.
// So pages at page.html, parent/page1.html, parent/page2.html and parent/child/page.html
// become {"": "page.html", "parent": ["page1.html","page2.html"], "parent/child": ["page.html"]}
// The indexes are directory names as strings; the values are arrays of Pages.
func mapFromPagePaths(siblings []Page) map[string][]Page {
directoriesAndContents := make(map[string][]Page)

for _, sibling := range siblings {
path, _ := path.Split(sibling.UrlPath)
directoriesAndContents[path] = append(directoriesAndContents[path], sibling)
}
return directoriesAndContents
}

func keysOrderedByNumberOfSlashes(directoriesAndContents map[string][]Page) []string {
keysOrderedByLength := make([]string, 0, len(directoriesAndContents))
for k := range directoriesAndContents {
keysOrderedByLength = append(keysOrderedByLength, k)
}

sort.Slice(keysOrderedByLength, func(i, j int) bool {
slashesI := countSlashes(keysOrderedByLength[i])
slashesJ := countSlashes(keysOrderedByLength[j])
if slashesI == slashesJ {
return keysOrderedByLength[i] < keysOrderedByLength[j]
}
return slashesI < slashesJ
})
return keysOrderedByLength
}

func renderAndrewTableOfContents(siblings []Page, startingPage Page) ([]byte, error) {
var links bytes.Buffer

links.Write([]byte("<ul>"))
links.Write([]byte("<ul>\n"))
for i, sibling := range siblings {
links.Write(buildAndrewTableOfContentsLink(sibling.UrlPath, sibling.Title, sibling.PublishTime.Format(time.DateOnly), i))
}
links.Write([]byte("</ul>"))
links.Write([]byte("</ul>\n"))

templateBuffer := bytes.Buffer{}
// execute template here, write it to something and then return it as the pageContent

t, err := template.New(startingPage.UrlPath).Parse(startingPage.Content)

if err != nil {
// TODO: swap this for proper error handling

panic(err)
}

Expand All @@ -40,7 +160,7 @@ func RenderTemplates(siblings []Page, startingPage Page) ([]byte, error) {

// buildAndrewTableOfContentsLink encapsulates the format of the link
func buildAndrewTableOfContentsLink(urlPath string, title string, publishDate string, cssIdNumber int) []byte {
link := fmt.Sprintf("<li><a class=\"andrewtableofcontentslink\" id=\"andrewtableofcontentslink%s\" href=\"%s\">%s</a> - <span class=\"publish-date\">%s</span></li>", fmt.Sprint(cssIdNumber), urlPath, title, publishDate)
link := fmt.Sprintf("<li><a class=\"andrewtableofcontentslink\" id=\"andrewtableofcontentslink%s\" href=\"%s\">%s</a> - <span class=\"andrew-page-publish-date\">%s</span></li>\n", fmt.Sprint(cssIdNumber), urlPath, title, publishDate)
b := []byte(link)
return b
}
Loading

0 comments on commit 4a51508

Please sign in to comment.