Skip to content

Commit

Permalink
Merge pull request #6 from gwynforthewyn/sort-articles-by-time
Browse files Browse the repository at this point in the history
Sort articles by time
  • Loading branch information
gwynforthewyn committed Jun 29, 2024
2 parents bd0da60 + 6e1af7e commit d50d3c1
Show file tree
Hide file tree
Showing 8 changed files with 391 additions and 126 deletions.
15 changes: 15 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Andrew has two primary concepts: the Server and the Page.

The Server owns:
* answering questions about the layout of files in the directory structure
* understanding the kinds of file that are being served
* serving those files
If you are answering a question about files, the Server's got the answers. The server creates Pages
and serves those Pages.


The Page owns the content and metadata.

Page tracks the content of a specific file and various pieces of metadata about it.

For example, the page parses the contents of a file and parses out the andrew metadata headers, so that when the Server wants to present those elements to an end-user they're already built.
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ baseUrl is the hostname you're serving from. This is a part of sitemaps and rss
e.g. `https://playtechnique.io`


## rendering the .AndrewIndexBody
## rendering the .AndrewTableOfContents
Given this file system structure:
```text
index.html
Expand All @@ -44,27 +44,41 @@ fanfics/
what-if-elves-rode-mice-pt2.html
```

if articles/index.html contains `{{ .AndrewIndexBody }}` anywhere, that will be replaced with:
if articles/index.html contains `{{ .AndrewTableOfContents }}` anywhere, that will be replaced with:

```html
<a class="andrewindexbodylink" id="andrewindexbodylink0" href="article-1.html">article 1</a>
<a class="andrewindexbodylink" id="andrewindexbodylink1" href="article-2.html">article 2</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="article-1.html">article 1</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="article-2.html">article 2</a>
```

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

```html
<a class="andrewindexbodylink" id="andrewindexbodylink0" href="story-1/potter-and-draco.html">Potter and Draco</a>
<a class="andrewindexbodylink" id="andrewindexbodylink1" href="story-2/what-if-elves-rode-mice-pt1.html">what-if-elves-rode-mice-pt1.html</a>
<a class="andrewindexbodylink" id="andrewindexbodylink2" 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>
<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>
```

## 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-created-on" value="2024-03-12">
<meta name="andrew-tag" value="diary entry">

## ordering of pages
In this release, Andrew serves you page links ascii-betically.
If a page contains the meta element `<meta name=andrew-created-on 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-created-on` element, then you will push it back to the top of the list.

If your page contains an `andrew-created-on` meta element, the time must be formatted in accordance with <SOME STANDARD HERE>. If your `andrew-created-on`
contains a date but not a time, Andrew assumes the page was created at midnight.

## sitemap.xml
When the endpoint `baseUrl/sitemap.xml` is visited, Andrew will automatically generate a sitemap containing paths to all html pages.
Expand Down
61 changes: 34 additions & 27 deletions andrew_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,28 @@ import (
// When a URL is requested, Server creates an Page for the file referenced
// in that URL and then serves the Page.
type Server struct {
SiteFiles fs.FS // The files being served
BaseUrl string // The URL used in any links generated for this website that should contain the hostname.
Address string // IpAddress:Port combo to be served on.
andrewindexbodytemplate string // The string we're searching for inside a Page that should be replaced with a template. Mightn't belong in the Server.
HTTPServer *http.Server
SiteFiles fs.FS // The files being served
BaseUrl string // The URL used in any links generated for this website that should contain the hostname.
Address string // IpAddress:Port combo to be served on.
Andrewtableofcontentstemplate string // The string we're searching for inside a Page that should be replaced with a template. Mightn't belong in the Server.
HTTPServer *http.Server
}

const (
DefaultContentRoot = "."
DefaultAddress = ":8080"
DefaultBaseUrl = "http://localhost:8080"
AndrewTableOfContentsTemplate = "AndrewTableOfContents"
DefaultContentRoot = "."
DefaultAddress = ":8080"
DefaultBaseUrl = "http://localhost:8080"
)

// NewServer is a constructor. Its primary role is setting the default andrewindexbodytemplate.
// NewServer is a constructor. Its primary role is setting the default andrewtableofcontentstemplate.
// Returns an [Server].
func NewServer(contentRoot fs.FS, address, baseUrl string) *Server {
s := &Server{
SiteFiles: contentRoot,
andrewindexbodytemplate: "AndrewIndexBody",
Address: address,
BaseUrl: baseUrl,
SiteFiles: contentRoot,
Andrewtableofcontentstemplate: "AndrewTableOfContents",
Address: address,
BaseUrl: baseUrl,
}
mux := http.NewServeMux()
mux.HandleFunc("/", s.Serve)
Expand Down Expand Up @@ -145,11 +146,8 @@ func CheckPageErrors(err error) (string, int) {
// GetSiblingsAndChildren accepts a path to a file and a filter function.
// It infers the directory that the file resides within, and then recurses the Server's fs.FS
// to return all of the files both in the same directory and further down in the directory structure.
// To filter these down to only files that you care about, pass in a filter function.
// The filter is called in the context of fs.WalkDir. It is handed fs.WalkDir's path and directory entry,
// in that order, and is expected to return a boolean false.
// If that error is nil then the current file being evaluated is skipped for consideration.
func (a Server) GetSiblingsAndChildren(pagePath string, filter func(string, fs.DirEntry) bool) ([]Page, error) {
func (a Server) GetSiblingsAndChildren(pagePath string) ([]Page, error) {

pages := []Page{}
localContentRoot := path.Dir(pagePath)

Expand All @@ -158,19 +156,28 @@ func (a Server) GetSiblingsAndChildren(pagePath string, filter func(string, fs.D
return err
}

if filter(path, d) {
// links require a URL relative to the page we're discovering siblings from, not from
// the root of the file system
page, err := NewPage(a, path)
page = page.SetUrlPath(strings.TrimPrefix(path, localContentRoot+"/"))
// We don't list index files in our collection of siblings and children, because I don't
// want a link back to a page that contains only links.
if strings.Contains(path, "index.html") {
return nil
}

// If the file we're considering isn't an html file, let's move on with our day.
if !strings.Contains(path, "html") {
return nil
}

if err != nil {
return err
}
// links require a URL relative to the page we're discovering siblings from, not from
// the root of the file system
s_page, err := NewPage(a, path)
s_page = s_page.SetUrlPath(strings.TrimPrefix(path, localContentRoot+"/"))

pages = append(pages, page)
if err != nil {
return err
}

pages = append(pages, s_page)

return nil
})

Expand Down
133 changes: 124 additions & 9 deletions andrew_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package andrew_test
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"net"
Expand Down Expand Up @@ -133,10 +134,10 @@ func TestGettingADirectoryDefaultsToIndexHtml(t *testing.T) {
</body>
`)

// fstest.MapFS does not create directory-like objects, so we need a real file system in this test.
contentRoot := t.TempDir()
os.MkdirAll(contentRoot+"/pages", 0o755)

// fstest.MapFS does not create directory-like objects, so we need a real file system in this test.
err := os.WriteFile(contentRoot+"/pages/index.html", expected, 0o755)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -216,15 +217,15 @@ func TestServerServesIndexPageByDefault(t *testing.T) {
}
}

func TestAndrewIndexBodyIsGeneratedCorrectlyInContentrootDirectory(t *testing.T) {
func TestAndrewTableOfContentsIsGeneratedCorrectlyInContentrootDirectory(t *testing.T) {
t.Parallel()

contentRoot := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`
<!doctype HTML>
<head> </head>
<body>
{{ .AndrewIndexBody }}
{{ .AndrewTableOfContents }}
</body>
`)},
"pages/1-2-3.html": &fstest.MapFile{Data: []byte(`
Expand All @@ -251,7 +252,7 @@ func TestAndrewIndexBodyIsGeneratedCorrectlyInContentrootDirectory(t *testing.T)
<!doctype HTML>
<head> </head>
<body>
<a class="andrewindexbodylink" id="andrewindexbodylink0" href="pages/1-2-3.html">1-2-3 Page</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="pages/1-2-3.html">1-2-3 Page</a>
</body>
`

Expand All @@ -260,15 +261,15 @@ func TestAndrewIndexBodyIsGeneratedCorrectlyInContentrootDirectory(t *testing.T)
}
}

func TestAndrewIndexBodyIsGeneratedCorrectlyInAChildDirectory(t *testing.T) {
func TestAndrewTableOfContentsIsGeneratedCorrectlyInAChildDirectory(t *testing.T) {
t.Parallel()

contentRoot := fstest.MapFS{
"parentDir/index.html": &fstest.MapFile{Data: []byte(`
<!doctype HTML>
<head> </head>
<body>
{{ .AndrewIndexBody }}
{{ .AndrewTableOfContents }}
</body>
`)},
"parentDir/childDir/1-2-3.html": &fstest.MapFile{Data: []byte(`
Expand All @@ -295,7 +296,7 @@ func TestAndrewIndexBodyIsGeneratedCorrectlyInAChildDirectory(t *testing.T) {
<!doctype HTML>
<head> </head>
<body>
<a class="andrewindexbodylink" id="andrewindexbodylink0" href="childDir/1-2-3.html">1-2-3 Page</a>
<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="childDir/1-2-3.html">1-2-3 Page</a>
</body>
`

Expand Down Expand Up @@ -420,6 +421,122 @@ func TestMainCalledWithInvalidAddressPanics(t *testing.T) {
andrew.Main(args, nullLogger)
}

// TestArticlesInAndrewTableOfContentsAreDefaultSortedByModTime is verifying that
// when the list of links andrew generates for the {{.AndrewTableOfContents}} are
// sorted by mtime, not using the ascii sorting order.
// This test requires having two files which are in one order when sorted
// ascii-betically and in another order by date time, so that we can tell
// what file attribute andrew is actually sorting on.
func TestArticlesInAndrewTableOfContentsAreDefaultSortedByModTime(t *testing.T) {
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()

// fstest.MapFS does not enforce file permissions, so we need a real file system in this test.
// above might be wrong
err := os.WriteFile(contentRoot+"/index.html", []byte("{{.AndrewTableOfContents}}"), 0o700)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(contentRoot+"/a_older.html", []byte{}, 0o700)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(contentRoot+"/b_newer.html", []byte{}, 0o700)
if err != nil {
t.Fatal(err)
}

now := time.Now()
older := now.Add(-10 * time.Minute)

os.Chtimes(contentRoot+"/b_newer.html", now, now)
os.Chtimes(contentRoot+"/a_older.html", older, older)

server := andrew.Server{SiteFiles: os.DirFS(contentRoot), Andrewtableofcontentstemplate: andrew.AndrewTableOfContentsTemplate}

page, err := andrew.NewPage(server, "index.html")

if err != nil {
t.Fatal(err)
}

received := page.Content

if expected != string(received) {
t.Errorf(cmp.Diff(expected, received))
}

}

// TestArticlesOrderInAndrewTableOfContentsIsOverridable is verifying that
// when a page contains an andrew-publish-time meta element then the list of links andrew
// generates for the {{.AndrewTableOfContents}} are
// sorted by the meta element, then the mtime, not using the ascii sorting order.
// This test requires having several files which are in one order when sorted
// by modtime and in another order by andrew-publish-time time, so that we can tell
// what file attribute andrew is actually sorting on.
func TestArticlesOrderInAndrewTableOfContentsIsOverridable(t *testing.T) {
expected := `<a class="andrewtableofcontentslink" id="andrewtableofcontentslink0" href="b_newest.html">b_newest.html</a>` +
`<a class="andrewtableofcontentslink" id="andrewtableofcontentslink1" href="c_newer.html">c_newer.html</a>` +
`<a class="andrewtableofcontentslink" id="andrewtableofcontentslink2" href="a_older.html">a_older.html</a>`

contentRoot := t.TempDir()

// fstest.MapFS does not enforce file permissions, so we need a real file system in this test.
// above might be wrong
err := os.WriteFile(contentRoot+"/index.html", []byte("{{.AndrewTableOfContents}}"), 0o700)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(contentRoot+"/a_older.html", []byte{}, 0o700)
if err != nil {
t.Fatal(err)
}

err = os.WriteFile(contentRoot+"/c_newer.html", []byte{}, 0o700)
if err != nil {
t.Fatal(err)
}

now := time.Now()

newest := now.Add(24 * time.Hour)
formattedDate := newest.Format("2006-01-02")

content := fmt.Sprintf(`<meta name="andrew-publish-time" content="%s">`, formattedDate)

err = os.WriteFile(contentRoot+"/b_newest.html", []byte(content), 0o700)
if err != nil {
t.Fatal(err)
}

older := now.Add(-10 * time.Minute)

os.Chtimes(contentRoot+"/c_newer.html", now, now)
os.Chtimes(contentRoot+"/a_older.html", older, older)
os.Chtimes(contentRoot+"/b_newest.html", older, older)

server := andrew.Server{SiteFiles: os.DirFS(contentRoot), Andrewtableofcontentstemplate: andrew.AndrewTableOfContentsTemplate}

page, err := andrew.NewPage(server, "index.html")

if err != nil {
t.Fatal(err)
}

received := page.Content

if expected != string(received) {
t.Errorf(cmp.Diff(expected, received))
}

}

// newTestAndrewServer starts an andrew and returns the localhost url that you can run http gets against
// to retrieve data from that server
func newTestAndrewServer(t *testing.T, contentRoot fs.FS) *andrew.Server {
Expand Down Expand Up @@ -461,7 +578,5 @@ func newTestAndrewServer(t *testing.T, contentRoot fs.FS) *andrew.Server {
// Wait for server to be confirmed ready
<-ready

t.Logf("Running server on %s\n", addr)

return server
}
Loading

0 comments on commit d50d3c1

Please sign in to comment.