Index: cache.rkt
==================================================================
--- cache.rkt
+++ cache.rkt
@@ -7,31 +7,26 @@
          db/base
          db/sqlite3
          threading
          pollen/setup
          racket/match
-         (rename-in racket/list
-                    (group-by group-list-by))
          "dust.rkt"
          (except-in pollen/core select))
 
 (provide init-cache-db!
          cache-conn                     ; The most eligible bachelor in Neo Yokyo
          (schema-out cache:article)
          (schema-out cache:note)
-         (schema-out cache:series)
          (schema-out cache:index-entry)
          (schema-out listing)
          delete-article!
          delete-notes!
          articles
          articles+notes
          listing-htmls
          fenced-listing
-         unfence
-         preheat-series!
-         series-grouped-list)
+         unfence)
 
 ;; Cache DB and Schemas
 
 (define DBFILE (build-path (current-project-root) "vitreous.sqlite"))
 (define cache-conn (make-parameter (sqlite3-connect #:database DBFILE #:mode 'create)))
@@ -47,11 +42,11 @@
    [author               string/f]
    [conceal              string/f]
    [series-page          symbol/f]
    [noun-singular        string/f]
    [note-count           integer/f]
-   [content-html             string/f]
+   [content-html         string/f]
    [disposition          string/f]
    [disp-html-anchor     string/f]
    [listing-full-html    string/f]   ; full content but without notes
    [listing-excerpt-html string/f]   ; Not used for now
    [listing-short-html   string/f])) ; Date and title only
@@ -71,18 +66,10 @@
    [conceal              string/f]
    [listing-full-html    string/f]
    [listing-excerpt-html string/f]   ; Not used for now
    [listing-short-html   string/f])) ; Date and title only
 
-(define-schema cache:series #:table "series"
-  ([id            id/f #:primary-key #:auto-increment]
-   [page          symbol/f]
-   [title         string/f]
-   [published     string/f]
-   [noun-plural   string/f]
-   [noun-singular string/f]))
-
 (define-schema cache:index-entry #:table "index_entries"
   ([id          id/f #:primary-key #:auto-increment]
    [entry       string/f]
    [subentry    string/f]
    [page        symbol/f]
@@ -98,11 +85,10 @@
    [html        string/f]))
 
 (define (init-cache-db!)
   (create-table! (cache-conn) 'cache:article)
   (create-table! (cache-conn) 'cache:note)
-  (create-table! (cache-conn) 'cache:series)
   (create-table! (cache-conn) 'cache:index-entry))
 
 (define (delete-article! page)
   (query-exec (cache-conn)
               (~> (from cache:article #:as a)
@@ -208,32 +194,5 @@
 
 ;; Remove "<style>" and "</style>" introduced by using ->html on docs containing output from
 ;; listing functions
 (define (unfence html-str)
   (regexp-replace* #px"<[\\/]{0,1}style>" html-str ""))
-
-;;
-;;  ~~~ Fetching series ~~~
-;;
-(define (series-grouped-list)
-  (~> (for/list ([row (in-entities (cache-conn) 
-                                   (order-by (from cache:series #:as s)
-                                             ([s.noun-plural #:asc])))]) row)
-      (group-list-by cache:series-noun-plural _ string-ci=?)))
-
-;; Preloads the SQLite cache with info about each series.
-;; I may not actually need this but I’m leaving it for now.
-(define (preheat-series!)
-  (query-exec (cache-conn)
-              (~> (from cache:series #:as s)
-                  (where 1)
-                  delete))
-  (define series-rows
-    (for/list ([series-pagenode (in-list (cdr (series-pagetree)))])
-      (define series-metas (get-metas series-pagenode))
-      (make-cache:series
-       #:page series-pagenode
-       #:title (hash-ref series-metas 'title)
-       #:published (hash-ref series-metas 'published "")
-       #:noun-plural (hash-ref series-metas 'noun-plural "")
-       #:noun-singular (hash-ref series-metas 'noun-singular ""))))
-  (void (apply insert! (cache-conn) series-rows)))

Index: code-docs/cache.scrbl
==================================================================
--- code-docs/cache.scrbl
+++ code-docs/cache.scrbl
@@ -42,16 +42,10 @@
 in the project root folder) by running queries to create tables in the database if they do not
 exist.
 
 }
 
-@defproc[(preheat-series!) void?]{
-
-Save info about each series in @racket[series-folder] to the cache.
-
-}
-
 @section{Retrieving cached data}
 
 Some of this looks a little wacky, but it’s a case of putting a little extra complextity into the
 back end to make things simple on the front end. These functions are most commonly used inside the
 @emph{body} of a Pollen document (i.e., series pages). 
@@ -144,17 +138,10 @@
 Use this in templates with strings returned from @racket[->html] when called on docs that use the
 @racket[fenced-listing] tag function.
 
 }
 
-@defproc[(series-grouped-list) (listof (listof cache:series?))]{
-
-Return a list of lists of all @racket[cache:series] in the cache database. The series are grouped so
-that series using the same value in the @tt{noun_plural} column appear together.
-
-}
-
 @section{Deleting records}
 
 @deftogether[(@defproc[(delete-article! [page stringish?]) void?]
               @defproc[(delete-notes!   [page stringish?]) void?])]{
               
@@ -218,22 +205,10 @@
                         [listing-short-html   string/f])
                        #:constructor-name make-cache:note]{
 
 Table holding cached information on notes.
 
-}
-
-@defstruct*[cache:series ([id            id/f]
-                          [page          symbol/f]
-                          [title         string/f]
-                          [published     string/f]
-                          [noun-plural   string/f]
-                          [noun-singular string/f])
-                         #:constructor-name make-cache:series]{
-
-Table holding cached information on series.
-
 }
 
 @defstruct*[cache:index-entry ([id id/f]
                                [entry       string/f]
                                [subentry    string/f]

Index: code-docs/crystalize.scrbl
==================================================================
--- code-docs/crystalize.scrbl
+++ code-docs/crystalize.scrbl
@@ -43,15 +43,6 @@
 article. If there are @racket[note]s or @racket[index] tags in the doc, they are parsed and saved
 individually to the SQLite cache (using @racket[make-cache:note] and
 @racket[make-cache:index-entry]). If any of the notes use the @code{#:disposition} attribute,
 information about the disposition is parsed out and used in the rendering of the article. 
 
-}
-
-@defproc[(cache-series!) void?]{
-
-Attempts to look up certain values in @racket[current-metas] which we expect to be defined on
-a typical series page, and saves them to the cache using @racket[make-cache:series]. If @tt{title}
-is not defined in the current metas, you’ll get an error. If any of the others are missing, an empty
-string is used.
-
 }

Index: code-docs/dust.scrbl
==================================================================
--- code-docs/dust.scrbl
+++ code-docs/dust.scrbl
@@ -7,10 +7,11 @@
           scribble/example)
 
 @(require (for-label "../pollen.rkt"
                      "../dust.rkt"
                      "../cache.rkt"
+                     "../series-list.rkt"
                      racket/base
                      racket/contract
                      txexpr
                      sugar/coerce
                      pollen/tag
@@ -182,24 +183,31 @@
             "individual as Queequeg circulating among the polite society of a civilized "
             "town, that astonishment soon departed upon taking my first daylight "
             "stroll through the streets of New Bedford…")))
 (default-title (get-elements doc))]
 
-@defproc[(metas-series-pagenode) pagenode?]
+@defproc[(current-series-pagenode) pagenode?]
 
 If @code{(current-metas)} has the key @racket['series], converts its value to the pagenode pointing to
 that series, otherwise returns @racket['||].
 
-@defproc[(series-metas-noun) string?]
-
-If @code{(current-metas)} has the key @racket['series], and if the corresponding series defines a meta
-value for @racket['noun-singular], then return it, otherwise return @racket[""].
-
-@defproc[(series-metas-title) string?]
-
-If @code{(current-metas)} has the key @racket['series], and if the corresponding series defines a meta
-value for @racket['title], then return it, otherwise return @racket[""].
+@examples[#:eval dust-eval
+(require pollen/core)
+(parameterize ([current-metas (hash 'series "marquee-fiction")])
+  (current-series-pagenode))]
+
+@defproc[(current-series-noun) string?]
+
+If @code{(current-metas)} has the key @racket['series] and if there is a corresponding
+@racket[series] in the @racket[series-list], return its @racket[series-noun-singular] value;
+otherwise return @racket[""].
+
+@defproc[(current-series-title) string?]
+
+If @code{(current-metas)} has the key @racket['series] and if there is a corresponding
+@racket[series] in the @racket[series-list], return its @racket[series-title] value;
+otherwise return @racket[""].
 
 @defproc[(invalidate-series) (or/c void? boolean?)]
 
 If the current article specifies a @racket['series] meta, and if a corresponding @filepath{.poly.pm}
 file exists in @racket[series-folder], attempts to “touch” the last-modified timestamp on that file,

Index: code-docs/main.scrbl
==================================================================
--- code-docs/main.scrbl
+++ code-docs/main.scrbl
@@ -49,10 +49,11 @@
 @local-table-of-contents[]
 
 @include-section["tour.scrbl"]
 @include-section["design.scrbl"]
 @include-section["pollen.scrbl"]  @; pollen.rkt
+@include-section["series.scrbl"]
 @include-section["dust.scrbl"]    @; dust.rkt
 @include-section["snippets-html.scrbl"] @; you get the idea
 @include-section["cache.scrbl"]
 @include-section["crystalize.scrbl"]
 @include-section["other-files.scrbl"]

ADDED   code-docs/series.scrbl
Index: code-docs/series.scrbl
==================================================================
--- code-docs/series.scrbl
+++ code-docs/series.scrbl
@@ -0,0 +1,74 @@
+#lang scribble/manual
+
+@; SPDX-License-Identifier: BlueOak-1.0.0
+@; This file is licensed under the Blue Oak Model License 1.0.0.
+
+@(require "scribble-helpers.rkt"
+          scribble/example)
+
+@(require (for-label "../pollen.rkt"
+                     "../series-list.rkt"
+                     "../dust.rkt"
+                     "../cache.rkt"
+                     pollen/core
+                     racket/base
+                     racket/contract))
+
+@title{Defining Series}
+
+To create a new series:
+
+@itemlist[#:style 'ordered
+
+  @item{Create a file @filepath{my-key.poly.pm} inside @racket[series-folder] and include a call
+  to @racket[fenced-listing] to list all the articles and notes that will be included in the series:
+  @codeblock|{
+#lang pollen
+
+◊(define-meta title "My New Series")
+
+◊block{Here’s what we call a bunch of similar articles:
+
+◊(fenced-listing (articles 'short))
+
+}
+  }|
+  }
+  
+  @item{Add an entry for @racket[_my-key] to @racket[series-list] in @filepath{series-list.rkt}}
+
+  @item{Use @racket[(define-meta series "my-key")] in articles to add them to the series.}
+
+  @item{If @racket[series-ptree-ordered?] is @racket[#t], create a @seclink["Pagetree" #:doc '(lib
+  "pollen/scribblings/pollen.scrbl")]{pagetree} file in @racket[series-folder] named
+  @filepath{my-key.ptree}.}
+
+  ]
+
+@section{Series list}
+
+@defmodule["series-list.rkt" #:packages ()]
+
+This module contains the most commonly used bits of meta-info about @tech{series}. Storing these
+bits in a hash table of structs makes them faster to retrieve than when they are stored inside the
+metas of the Pollen documents for the series themselves.
+
+@defthing[series-list hash?]{
+
+An immutable hash containing all the title and noun info for each @tech{series}. Each key is
+a string and each value is a @racket[series] struct.
+
+}
+
+@defstruct[series ([key            string?]
+                   [title          string?]
+                   [noun-plural    string?]
+                   [noun-singular  string?]
+                   [ptree-ordered? boolean?])]{
+
+Struct for holding metadata for a @tech{series}. The @racket[_ptree-ordered?] value should be
+@racket[#t] if there is a @filepath{@italic{key}.ptree} file in @racket[series-folder] that provides
+information on how articles in the series are ordered.
+
+}
+

Index: crystalize.rkt
==================================================================
--- crystalize.rkt
+++ crystalize.rkt
@@ -14,12 +14,11 @@
          (except-in pollen/core select) ; avoid conflict with deta
          )
 
 (require "dust.rkt" "cache.rkt" "snippets-html.rkt")
 
-(provide parse-and-cache-article!
-         cache-series!)
+(provide parse-and-cache-article!)
 
 (define current-title       (make-parameter #f))
 (define current-excerpt     (make-parameter #f))
 (define current-notes       (make-parameter '()))
 (define current-disposition (make-parameter ""))
@@ -53,11 +52,11 @@
                                           (current-disposition)
                                           (current-disp-id))]
          [title-html  (->html title-tx #:splice? #t)]
          [title-plain (tx-strs title-tx)]
          [header      (html$-article-open pagenode title-specified? title-tx pubdate)]
-         [series-node (metas-series-pagenode)]
+         [series-node (current-series-pagenode)]
          [footertext  (make-article-footertext pagenode
                                                series-node
                                                (current-disposition)
                                                (current-disp-id)
                                                (length (current-notes)))]
@@ -79,11 +78,11 @@
                   #:published pubdate
                   #:updated (maybe-meta 'updated)
                   #:author (maybe-meta 'author default-authorname)
                   #:conceal (maybe-meta 'conceal)
                   #:series-page series-node
-                  #:noun-singular (maybe-meta 'noun (series-metas-noun))
+                  #:noun-singular (maybe-meta 'noun (current-series-noun))
                   #:note-count (length (current-notes))
                   #:content-html doc-html
                   #:disposition (current-disposition)
                   #:disp-html-anchor (current-disp-id)
                   #:listing-full-html listing-full
@@ -119,14 +118,14 @@
   `(title ,@title-elems ,disposition-part))
 
 ;; Convert a bunch of information about an article into some nice English and links.
 (define (make-article-footertext pagenode series disposition disp-note-id note-count)
   (define series-part
-    (match (series-metas-title)
+    (match (current-series-title)
       [(? non-empty-string? s-title)
        (format "<span class=\"series-part\">This is ~a, part of <a href=\"/~a\">‘~a’</a>.</span>"
-               (series-metas-noun)
+               (current-series-noun)
                series
                s-title)]
       [_ ""]))
   (define disp-part
     (cond [(non-empty-string? disposition)
@@ -202,11 +201,11 @@
                   #:title-plain (tx-strs title-tx)
                   #:published note-date
                   #:author author
                   #:author-url author-url
                   #:disposition disposition-attr
-                  #:series-page (metas-series-pagenode)
+                  #:series-page (current-series-pagenode)
                   #:conceal (or (maybe-attr 'conceal attrs #f) (maybe-meta 'conceal))
                   #:content-html content-html
                   #:listing-full-html listing-full
                   #:listing-excerpt-html listing-full
                   #:listing-short-html ""))
@@ -253,22 +252,5 @@
   (unless (null? entry-txs)
     (void
      (apply insert! (cache-conn)
             (for/list ([etx (in-list entry-txs)])
               (txexpr->index-entry etx pagenode))))))
-
-
-;; Save the current article to the `series` table of the SQLite cache
-;; Should be called from a template for series pages
-(define (cache-series!)
-  (define here-page (path->string (here-output-path)))
-  (query-exec (cache-conn)
-              (delete (~> (from cache:series #:as s)
-                          (where (= s.page ,here-page)))))
-  (void
-   (insert-one! (cache-conn)
-                (make-cache:series
-                 #:page (string->symbol here-page)
-                 #:title (hash-ref (current-metas) 'title)
-                 #:published (hash-ref (current-metas) 'published "")
-                 #:noun-plural (hash-ref (current-metas) 'noun-plural "")
-                 #:noun-singular (hash-ref (current-metas) 'noun-singular "")))))

Index: dust.rkt
==================================================================
--- dust.rkt
+++ dust.rkt
@@ -2,14 +2,16 @@
 
 ; SPDX-License-Identifier: BlueOak-1.0.0
 ; This file is licensed under the Blue Oak Model License 1.0.0.
 
 (require pollen/core
+         "series-list.rkt"
          pollen/pagetree
          pollen/setup
          pollen/file
          net/uri-codec
+         threading
          file/sha1
          gregor
          txexpr
          racket/list
          racket/match
@@ -23,13 +25,13 @@
          maybe-attr     ; Return an attribute’s value or a default ("") if not available
          here-output-path
          here-source-path
          here-id
          listing-context
-         series-metas-noun    ; Retrieve noun-singular from current 'series meta, or ""
-         series-metas-title   ; Retrieve title of series in current 'series meta, or ""
-         metas-series-pagenode
+         current-series-noun    ; Retrieve noun-singular from current 'series meta, or #f
+         current-series-title   ; Retrieve title of series in current 'series meta, or #f
+         current-series-pagenode
          invalidate-series
          checked-in?
          make-tag-predicate
          tx-strs
          ymd->english
@@ -79,28 +81,30 @@
 
 (define listing-context (make-parameter ""))
 
 ;; Checks current-metas for a 'series meta and returns the pagenode of that series,
 ;; or '|| if no series is specified.
-(define (metas-series-pagenode)
-  (define maybe-series (or (select-from-metas 'series (current-metas)) ""))
-  (cond
-    [(non-empty-string? maybe-series)
-     (->pagenode (format "~a/~a.html" series-folder maybe-series))]
-    [else '||]))
-
-(define (series-metas-noun)
-  (define series-pnode (metas-series-pagenode)) 
-  (case series-pnode
-    ['|| ""] ; no series specified
-    [else (or (select-from-metas 'noun-singular series-pnode) "")]))
-
-(define (series-metas-title)
-  (define series-pnode (metas-series-pagenode)) 
-  (case series-pnode
-    ['|| ""] ; no series specified
-    [else (or (select-from-metas 'title series-pnode) "")]))
+(define (current-series-pagenode)
+  (or (and~> (current-metas)
+             (hash-ref 'series #f)
+             (format "~a/~a.html" series-folder _)
+             ->pagenode)
+      '||))
+
+(define (current-series-noun)
+  (or (and~> (current-metas)
+             (hash-ref 'series #f)
+             (hash-ref series-list _ #f)
+             series-noun-singular)
+      ""))
+
+(define (current-series-title)
+  (or (and~> (current-metas)
+             (hash-ref 'series #f)
+             (hash-ref series-list _ #f)
+             series-title)
+      ""))
 
 (define article-ids (make-hash))
 
 ;; Generates a short ID for the current article
 (define (here-id [suffix #f])

Index: makefile
==================================================================
--- makefile
+++ makefile
@@ -5,11 +5,11 @@
 
 # ~~~ Variables used by rules ~~~
 #
 
 core-files := pollen.rkt dust.rkt
-html-deps  := snippets-html.rkt tags-html.rkt crystalize.rkt cache.rkt
+html-deps  := snippets-html.rkt tags-html.rkt crystalize.rkt cache.rkt series-list.rkt
 
 article-sources := $(wildcard articles/*.poly.pm)
 articles-html   := $(patsubst %.poly.pm, %.html, $(article-sources))
 articles-pdf    := $(patsubst %.poly.pm, %.pdf,  $(article-sources))
 

Index: pollen.rkt
==================================================================
--- pollen.rkt
+++ pollen.rkt
@@ -31,16 +31,18 @@
   (define-runtime-path tags-html.rkt     "tags-html.rkt")
   (define-runtime-path snippets-html.rkt "snippets-html.rkt")
   (define-runtime-path dust.rkt          "dust.rkt")
   (define-runtime-path crystalize.rkt    "crystalize.rkt")
   (define-runtime-path cache.rkt         "cache.rkt")
+  (define-runtime-path series-list.rkt   "series-list.rkt")
   (define cache-watchlist
     (map resolve-module-path
          (list tags-html.rkt
                snippets-html.rkt
                dust.rkt
                cache.rkt
+               series-list.rkt
                crystalize.rkt))))
 
 ;; Macro for defining tag functions that automatically branch based on the 
 ;; current output format and the list of poly-targets in the setup module.
 ;; Use this macro when you know you will need keyword arguments.

ADDED   series-list.rkt
Index: series-list.rkt
==================================================================
--- series-list.rkt
+++ series-list.rkt
@@ -0,0 +1,33 @@
+#lang racket/base
+
+; SPDX-License-Identifier: BlueOak-1.0.0
+; This file is licensed under the Blue Oak Model License 1.0.0.
+
+;; Provides fast metadata for series.
+;; TO MAKE A NEW SERIES:
+;;   1. Create series/my-key.poly.pm
+;;   2. Add an entry for my-key to series-list below
+;;   3. Use ◊define-meta[series "my-key"] in articles to add them to the series.
+;;   4. If ptree-ordered is #t, also create series/my-key.ptree
+
+(require racket/list)
+(struct series (key title noun-plural noun-singular ptree-ordered?))
+
+(define series-list
+  (make-immutable-hash
+   (list
+   ;; ------- DEFINE SERIES HERE -----------
+            ; Key               Title              plural noun    singular noun phrase
+   (+series "marquee-fiction"   "Marquee Fiction" "inventions"    "an invention"         #f)
+   )))
+
+(define (series-grouped-list)
+  (group-by series-noun-plural (hash-values series-list)))
+
+;; Quick macro to save a little typing
+(define-syntax-rule (+series key title plural singular ptree)
+  (cons key (series key title plural singular ptree)))
+
+(provide (struct-out series)
+         series-list
+         series-grouped-list)

Index: series/template.html.p
==================================================================
--- series/template.html.p
+++ series/template.html.p
@@ -1,8 +1,7 @@
 <!DOCTYPE html>
 <html lang="en">
-◊cache-series![]
 ◊html$-page-head[(select-from-metas 'title metas)]
 
 ◊html$-page-body-open["series-page"]
 
 ◊(unfence (->html doc #:splice? #t))

Index: snippets-html.rkt
==================================================================
--- snippets-html.rkt
+++ snippets-html.rkt
@@ -10,10 +10,11 @@
          racket/string
          racket/function
          racket/list
          txexpr
          "cache.rkt"
+         "series-list.rkt"
          "dust.rkt")
 
 (provide html$-page-head
          html$-page-body-open
          html$-series-list
@@ -238,13 +239,13 @@
         (page-func (+ pagenum 1) "Older&thinsp;&rarr;" "nav-text")))
 
   (string-join `(,prev-link ,@page-group ,next-link)))
 
 (define (series->txpr s)
-  `(li (a [[href ,(string-append web-root (symbol->string (cache:series-page s)))]]
-          (i ,(cache:series-title s)))))
+  `(li (a [[href ,(string-append web-root (format "~a/~a.html" series-folder (series-key s)))]]
+          (i ,(series-title s)))))
 
 (define (html$-series-list)
   (define series-list-items
     (for/list ([group (in-list (series-grouped-list))])
-      `(div (h2 ,(cache:series-noun-plural (first group))) (ul ,@(map series->txpr group)))))
+      `(div (h2 ,(series-noun-plural (first group))) (ul ,@(map series->txpr group)))))
   (->html `(section [[class "column-list"] [style "margin-top: 1.3rem"]] ,@series-list-items)))

Index: util/init.rkt
==================================================================
--- util/init.rkt
+++ util/init.rkt
@@ -7,10 +7,9 @@
 
 (provide main)
 
 (define (main)
   (init-cache-db!)
-  (preheat-series!)
   
   (display-to-file (html$-page-footer)
                    (build-path (current-project-root) "scribbled" "site-footer.html")
                    #:exists 'replace))